diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af8a5ec401a..fac3a5687f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: run: yarn test --coverage --coverage-reporters=lcov --detectOpenHandles=false - name: Monitor coverage - uses: codecov/codecov-action@v5.5.1 + uses: codecov/codecov-action@v5.5.2 with: fail_ci_if_error: false files: ./coverage/lcov.info diff --git a/.github/workflows/pr-auto-merge.yml b/.github/workflows/pr-auto-merge.yml index 99c224b9732..e06eba5b14b 100644 --- a/.github/workflows/pr-auto-merge.yml +++ b/.github/workflows/pr-auto-merge.yml @@ -12,6 +12,7 @@ jobs: if: ${{github.actor == 'dependabot[bot]' || github.actor == 'otto-the-bot'}} steps: - name: Approve PR + if: ${{ github.actor == 'otto-the-bot' || (github.actor == 'dependabot[bot]' && (steps.dependabot-metadata.outputs.update-type != 'version-update:semver-major')) }} run: gh pr review --approve "$PR_URL" env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/precommit-crit-flows.yml b/.github/workflows/precommit-crit-flows.yml index 31afdd07571..0f7be4fd77a 100644 --- a/.github/workflows/precommit-crit-flows.yml +++ b/.github/workflows/precommit-crit-flows.yml @@ -124,74 +124,36 @@ jobs: echo "❌ Deployment failed"; exit 1 fi - build_testservice: - name: Build Kalium Test Service - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - repository: wireapp/kalium - ref: main - - - name: Set up JDK - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - - - name: Gradle Cache - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # SHA of tag v5.0.0 - - - name: Validate Gradle wrapper - uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # SHA of tag v5.0.0 - - - name: Build the testservice - run: ./gradlew :testservice:shadowJar - - - name: Upload jar - if: ${{ !cancelled() }} - uses: actions/upload-artifact@v4 - with: - name: kalium-testservice - path: | - testservice/build/libs/ - testservice/config.yml - retention-days: 1 - e2e_crit_flow: name: Playwright Critical Flow (precommit) if: ${{ !cancelled() && github.repository == 'wireapp/wire-webapp' && github.actor != 'dependabot[bot]' && github.actor != 'dependabot' }} runs-on: ubuntu-latest - needs: [deploy_to_aws, build_testservice] + needs: [deploy_to_aws] timeout-minutes: 35 + strategy: fail-fast: false matrix: # prettier-ignore shard: [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32] + services: + # Run the kalium testservice as service container of the current job + testservice: + image: quay.io/wire/testservice:latest + ports: + - 8080:8080 + steps: - uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.sha }} - - name: Set up JDK - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - - uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn - - name: Download Testservice Jar - uses: actions/download-artifact@v5 - with: - name: kalium-testservice - path: testservice - - run: yarn --immutable - run: yarn playwright install --with-deps chrome - uses: 1password/install-cli-action@143a85f84a90555d121cde2ff5872e393a47ab9f @@ -207,12 +169,6 @@ jobs: echo "Using precommit URL: https://wire-webapp-precommit.zinfra.io/" curl -s -o /dev/null -w "HTTP %{http_code}\n" https://wire-webapp-precommit.zinfra.io/ - - name: Start Testservice in background - id: start-testservice - run: | - java -jar testservice/build/libs/testservice-*-all.jar server testservice/config.yml & - echo TESTSERVICE_PID=$! >> "$GITHUB_OUTPUT" - - name: Run critical flow tests env: # TODO: remove hardcoded precommit env in the future when ephemeral PR envs will exist @@ -229,11 +185,6 @@ jobs: path: blob-report retention-days: 1 - - name: Stop Testservice - run: kill -SIGKILL $TESTSERVICE_PID - env: - TESTSERVICE_PID: ${{ steps.start-testservice.outputs.TESTSERVICE_PID }} - e2e-report: runs-on: ubuntu-latest if: ${{ !cancelled() }} diff --git a/.github/workflows/run-cells-crit-flow-e2e-tests.yml b/.github/workflows/run-cells-crit-flow-e2e-tests.yml index ceb01afc041..497d97483a1 100644 --- a/.github/workflows/run-cells-crit-flow-e2e-tests.yml +++ b/.github/workflows/run-cells-crit-flow-e2e-tests.yml @@ -57,7 +57,7 @@ jobs: - name: Notify on Wire if succeeded if: success() continue-on-error: true - uses: 8398a7/action-slack@293f8dc0f9731ac35321056641cdef895f4f65f8 + uses: 8398a7/action-slack@77eaa4f1c608a7d68b38af4e3f739dcd8cba273e env: ENV: ${{ inputs.environment }} SLACK_WEBHOOK_URL: ${{ secrets.WIRE_CELLS_E2E_TEST_BOT_WEBHOOK_URL }} @@ -71,7 +71,7 @@ jobs: - name: Notify on Wire if failed if: failure() continue-on-error: true - uses: 8398a7/action-slack@293f8dc0f9731ac35321056641cdef895f4f65f8 + uses: 8398a7/action-slack@77eaa4f1c608a7d68b38af4e3f739dcd8cba273e env: ENV: ${{ inputs.environment }} SLACK_WEBHOOK_URL: ${{ secrets.WIRE_CELLS_E2E_TEST_BOT_WEBHOOK_URL }} diff --git a/README.md b/README.md index e02b5502325..4926113742b 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,6 @@ Prerequisites: ## 1. Fetching dependencies and configurations 1. Run `yarn` - - This will install all dependencies and fetch a [configuration](https://github.com/wireapp/wire-web-config-wire/) for the application. ## 2. Build & run diff --git a/package.json b/package.json index 9a18127ee04..0d10dbc0b2f 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,11 @@ "@tanstack/react-virtual": "3.13.4", "@wireapp/avs": "10.2.19", "@wireapp/avs-debugger": "0.0.7", - "@wireapp/commons": "5.4.9", - "@wireapp/core": "46.46.8", + "@wireapp/commons": "5.4.10", + "@wireapp/core": "46.46.9", "@wireapp/kalium-backup": "0.0.4", - "@wireapp/promise-queue": "2.4.9", - "@wireapp/react-ui-kit": "9.69.6", + "@wireapp/promise-queue": "2.4.10", + "@wireapp/react-ui-kit": "9.71.0", "@wireapp/store-engine-dexie": "2.1.16", "@wireapp/telemetry": "0.3.1", "@wireapp/webapp-events": "0.28.1", @@ -43,7 +43,7 @@ "libsodium-wrappers": "0.7.15", "linkify-it": "5.0.0", "long": "5.3.2", - "markdown-it": "14.0.0", + "markdown-it": "14.1.0", "murmurhash": "2.0.1", "oidc-client-ts": "3.4.1", "path-to-regexp": "8.3.0", @@ -98,7 +98,7 @@ "@types/keyboardjs": "2.5.3", "@types/libsodium-wrappers": "0", "@types/linkify-it": "5.0.0", - "@types/markdown-it": "14.1.1", + "@types/markdown-it": "14.1.2", "@types/node": "22.9.0", "@types/open-graph": "0.2.6", "@types/platform": "1.3.6", @@ -118,13 +118,13 @@ "@wireapp/copy-config": "2.3.4", "@wireapp/eslint-config": "3.0.7", "@wireapp/prettier-config": "0.6.9", - "@wireapp/store-engine": "5.1.16", + "@wireapp/store-engine": "5.1.17", "archiver": "7.0.1", "autoprefixer": "10.4.22", "babel-loader": "10.0.0", "babel-plugin-transform-import-meta": "2.3.3", "baseline-browser-mapping": "^2.8.32", - "browserslist": "^4.28.0", + "browserslist": "^4.28.1", "cross-env": "7.0.3", "css-loader": "7.1.2", "cssnano": "7.1.2", @@ -154,9 +154,9 @@ "postcss-import": "16.1.1", "postcss-less": "6.0.0", "postcss-loader": "8.2.0", - "postcss-preset-env": "10.4.0", + "postcss-preset-env": "10.5.0", "postcss-scss": "4.0.9", - "prettier": "3.3.2", + "prettier": "3.7.4", "qrcode-reader": "1.0.4", "raw-loader": "4.0.2", "redux-mock-store": "1.5.5", diff --git a/server/package.json b/server/package.json index 548618ad962..b505c2058d0 100644 --- a/server/package.json +++ b/server/package.json @@ -4,13 +4,13 @@ "main": "dist/index.js", "license": "GPL-3.0", "dependencies": { - "@wireapp/commons": "5.4.9", + "@wireapp/commons": "5.4.10", "dotenv": "16.5.0", "dotenv-extended": "2.9.0", "express": "4.22.0", "express-sitemap-xml": "3.1.0", "express-useragent": "1.0.15", - "fs-extra": "11.3.1", + "fs-extra": "11.3.2", "geolite2": "1.3.0", "hbs": "4.2.0", "helmet": "8.1.0", @@ -31,7 +31,7 @@ "@types/hbs": "4.0.5", "@types/jest": "^29.5.14", "@types/node": "22.5.5", - "browserslist": "^4.28.0", + "browserslist": "^4.28.1", "jest": "29.7.0", "rimraf": "6.1.2", "typescript": "5.6.3" diff --git a/server/yarn.lock b/server/yarn.lock index 8e021b250bb..d07c51d5f88 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -1123,15 +1123,15 @@ __metadata: languageName: node linkType: hard -"@wireapp/commons@npm:5.4.9": - version: 5.4.9 - resolution: "@wireapp/commons@npm:5.4.9" +"@wireapp/commons@npm:5.4.10": + version: 5.4.10 + resolution: "@wireapp/commons@npm:5.4.10" dependencies: ansi-regex: "npm:5.0.1" fs-extra: "npm:11.3.1" logdown: "npm:3.3.1" platform: "npm:1.3.6" - checksum: 10/10804e146e7dbe3f87ad2473a066eafe17faf0e2f8c2e89e378b844e2ff72777b8cb640aa76adb52f17d8ad46bcb37cfb4cffb8311fdea3102d653ff1e49bfa0 + checksum: 10/35832d8da7eeb07dae12ed3489780f302b861841683505ba07ccb10eed3e139af56ccf6245ed4c6a8ee16bddfbc374b145634ea37ff6257d86ed944bd3dc06c6 languageName: node linkType: hard @@ -1462,6 +1462,15 @@ __metadata: languageName: node linkType: hard +"baseline-browser-mapping@npm:^2.9.0": + version: 2.9.4 + resolution: "baseline-browser-mapping@npm:2.9.4" + bin: + baseline-browser-mapping: dist/cli.js + checksum: 10/71cf80f822e74e0f0109a9ed69d87fdb128d01bf06670a2ef91166a3eb636034e0a013d76cd9915a9d38594f649848c8c1ef6cbe39ed417f38314ff5bd22e393 + languageName: node + linkType: hard + "basic-ftp@npm:^5.0.2": version: 5.0.3 resolution: "basic-ftp@npm:5.0.3" @@ -1522,7 +1531,7 @@ __metadata: languageName: node linkType: hard -"browserslist@npm:^4.21.9, browserslist@npm:^4.28.0": +"browserslist@npm:^4.21.9": version: 4.28.0 resolution: "browserslist@npm:4.28.0" dependencies: @@ -1537,6 +1546,21 @@ __metadata: languageName: node linkType: hard +"browserslist@npm:^4.28.1": + version: 4.28.1 + resolution: "browserslist@npm:4.28.1" + dependencies: + baseline-browser-mapping: "npm:^2.9.0" + caniuse-lite: "npm:^1.0.30001759" + electron-to-chromium: "npm:^1.5.263" + node-releases: "npm:^2.0.27" + update-browserslist-db: "npm:^1.2.0" + bin: + browserslist: cli.js + checksum: 10/64f2a97de4bce8473c0e5ae0af8d76d1ead07a5b05fc6bc87b848678bb9c3a91ae787b27aa98cdd33fc00779607e6c156000bed58fefb9cf8e4c5a183b994cdb + languageName: node + linkType: hard + "bser@npm:2.1.1": version: 2.1.1 resolution: "bser@npm:2.1.1" @@ -1628,6 +1652,13 @@ __metadata: languageName: node linkType: hard +"caniuse-lite@npm:^1.0.30001759": + version: 1.0.30001759 + resolution: "caniuse-lite@npm:1.0.30001759" + checksum: 10/da0ec28dd993dffa99402914903426b9466d2798d41c1dc9341fcb7dd10f58fdd148122e2c65001246c030ba1c939645b7b4597f6321e3246dc792323bb11541 + languageName: node + linkType: hard + "chalk@npm:3.0.0, chalk@npm:~3.0.0": version: 3.0.0 resolution: "chalk@npm:3.0.0" @@ -2083,6 +2114,13 @@ __metadata: languageName: node linkType: hard +"electron-to-chromium@npm:^1.5.263": + version: 1.5.266 + resolution: "electron-to-chromium@npm:1.5.266" + checksum: 10/2c7e05d1df189013e01b9fa19f5794dc249b80f330ab87f78674fa7416df153e2d32738d16914eee1112b5d8878b6181336e502215a34c63c255da078de5209d + languageName: node + linkType: hard + "emittery@npm:^0.13.1": version: 0.13.1 resolution: "emittery@npm:0.13.1" @@ -2512,6 +2550,17 @@ __metadata: languageName: node linkType: hard +"fs-extra@npm:11.3.2": + version: 11.3.2 + resolution: "fs-extra@npm:11.3.2" + dependencies: + graceful-fs: "npm:^4.2.0" + jsonfile: "npm:^6.0.1" + universalify: "npm:^2.0.0" + checksum: 10/d559545c73fda69c75aa786f345c2f738b623b42aea850200b1582e006a35278f63787179e3194ba19413c26a280441758952b0c7e88dd96762d497e365a6c3e + languageName: node + linkType: hard + "fs-extra@npm:^8.1.0": version: 8.1.0 resolution: "fs-extra@npm:8.1.0" @@ -5547,6 +5596,20 @@ __metadata: languageName: node linkType: hard +"update-browserslist-db@npm:^1.2.0": + version: 1.2.2 + resolution: "update-browserslist-db@npm:1.2.2" + dependencies: + escalade: "npm:^3.2.0" + picocolors: "npm:^1.1.1" + peerDependencies: + browserslist: ">= 4.21.0" + bin: + update-browserslist-db: cli.js + checksum: 10/ae2102d3c83fca35e9deb012d82bfde6f734998ced937e34a3bf239a4b67577108fdd144283aafc0e5e3cf38ca1aecd7714906ba6f562896c762d2f2fa391026 + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" @@ -5641,14 +5704,14 @@ __metadata: "@types/hbs": "npm:4.0.5" "@types/jest": "npm:^29.5.14" "@types/node": "npm:22.5.5" - "@wireapp/commons": "npm:5.4.9" - browserslist: "npm:^4.28.0" + "@wireapp/commons": "npm:5.4.10" + browserslist: "npm:^4.28.1" dotenv: "npm:16.5.0" dotenv-extended: "npm:2.9.0" express: "npm:4.22.0" express-sitemap-xml: "npm:3.1.0" express-useragent: "npm:1.0.15" - fs-extra: "npm:11.3.1" + fs-extra: "npm:11.3.2" geolite2: "npm:1.3.0" hbs: "npm:4.2.0" helmet: "npm:8.1.0" diff --git a/src/i18n/ar-SA.json b/src/i18n/ar-SA.json index fdf73813ef8..9b835911265 100644 --- a/src/i18n/ar-SA.json +++ b/src/i18n/ar-SA.json @@ -327,6 +327,7 @@ "backupTryAgain": "حاول مرة أخرى", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "قبول", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "اختر شاشة للمشاركة", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/bn-BD.json b/src/i18n/bn-BD.json index b913bb0e214..5a730d4e929 100644 --- a/src/i18n/bn-BD.json +++ b/src/i18n/bn-BD.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Accept", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Choose a screen to share", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/bs-BA.json b/src/i18n/bs-BA.json index b913bb0e214..5a730d4e929 100644 --- a/src/i18n/bs-BA.json +++ b/src/i18n/bs-BA.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Accept", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Choose a screen to share", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/ca-ES.json b/src/i18n/ca-ES.json index b913bb0e214..5a730d4e929 100644 --- a/src/i18n/ca-ES.json +++ b/src/i18n/ca-ES.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Accept", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Choose a screen to share", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/cs-CZ.json b/src/i18n/cs-CZ.json index c4a98d0c53d..f3c4a8b86ba 100644 --- a/src/i18n/cs-CZ.json +++ b/src/i18n/cs-CZ.json @@ -327,6 +327,7 @@ "backupTryAgain": "Zkusit znovu", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Přijmout", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Vybrat obrazovku ke sdílení", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/da-DK.json b/src/i18n/da-DK.json index 7a9aaeeed6d..6f6a349ca03 100644 --- a/src/i18n/da-DK.json +++ b/src/i18n/da-DK.json @@ -327,6 +327,7 @@ "backupTryAgain": "Prøv Igen", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Besvar", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Vælg en skærm som du vil dele", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/de-DE.json b/src/i18n/de-DE.json index c6f630a4a58..13c886929f2 100644 --- a/src/i18n/de-DE.json +++ b/src/i18n/de-DE.json @@ -327,6 +327,7 @@ "backupTryAgain": "Erneut versuchen", "buttonActionError": "Ihre Antwort wurde nicht gesendet, bitte erneut versuchen.", "callAccept": "Annehmen", + "callChooseScreenCancel": "Bildschirmfreigabe schließen", "callChooseSharedScreen": "Wählen Sie einen Bildschirm aus", "callChooseSharedWindow": "Wählen Sie ein Fenster zur Freigabe aus", "callConversationAcceptOrDecline": "{conversationName} ruft an. Drücken Sie Steuerung + Eingabe, um den Anruf anzunehmen, oder drücken Sie Steuerung + Umschalt + Eingabe, um den Anruf abzulehnen.", diff --git a/src/i18n/el-CY.json b/src/i18n/el-CY.json index b913bb0e214..5a730d4e929 100644 --- a/src/i18n/el-CY.json +++ b/src/i18n/el-CY.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Accept", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Choose a screen to share", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/el-GR.json b/src/i18n/el-GR.json index 5580fa3960d..39c8b6c9d8b 100644 --- a/src/i18n/el-GR.json +++ b/src/i18n/el-GR.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Αποδοχή", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Επιλέξτε μια οθόνη για κοινή χρήση", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/en-US.json b/src/i18n/en-US.json index b913bb0e214..80647336286 100644 --- a/src/i18n/en-US.json +++ b/src/i18n/en-US.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Accept", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Choose a screen to share", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", @@ -432,6 +433,7 @@ "cells.options.label": "More options", "cells.options.move": "Move to folder", "cells.options.open": "Open", + "cells.options.versionHistory": "Version History", "cells.options.rename": "Rename", "cells.options.restore": "Restore", "cells.options.share": "Share", @@ -455,6 +457,22 @@ "cells.renameNodeModal.nameRequired": "Name is required", "cells.renameNodeModal.placeholder": "Enter a name", "cells.renameNodeModal.saveButton": "Save", + "cells.versionHistory.title": "Version History", + "cells.versionHistory.current": "Current", + "cells.versionHistory.download": "Download", + "cells.versionHistory.restore": "Restore", + "cells.versionHistory.downloadAriaLabel": "Download version from {time}", + "cells.versionHistory.restoreAriaLabel": "Restore version from {time}", + "cells.versionHistory.restoreModal.title": "Restore version", + "cells.versionHistory.restoreModal.description": "This copies the restored version and sets it as the current one. All previous versions remain available.", + "cells.versionHistory.restoreModal.cancel": "Cancel", + "cells.versionHistory.restoreModal.confirm": "Restore", + "cells.versionHistory.closeAriaLabel": "Close", + "fileHistoryModal.today": "Today", + "fileHistoryModal.yesterday": "Yesterday", + "fileHistoryModal.failedToLoadVersions": "Failed to load versions", + "fileHistoryModal.invalidNodeData": "Invalid file data", + "fileHistoryModal.failedToRestore": "Failed to restore file version", "cells.restore.error": "Something went wrong, please try again later and refresh the list.", "cells.restoreNestedNodeModal.button": "Restore Parent Folder", "cells.restoreNestedNodeModal.description1": "You can’t restore folders or files from a deleted folder. To reuse the desired item, you must restore its parent folder.", diff --git a/src/i18n/es-ES.json b/src/i18n/es-ES.json index da1258f4a72..6f8a7f8a970 100644 --- a/src/i18n/es-ES.json +++ b/src/i18n/es-ES.json @@ -327,6 +327,7 @@ "backupTryAgain": "Intentar de nuevo", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Aceptar", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Elige una pantalla para compartir", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/et-EE.json b/src/i18n/et-EE.json index b311acf2862..4e7735a3edc 100644 --- a/src/i18n/et-EE.json +++ b/src/i18n/et-EE.json @@ -327,6 +327,7 @@ "backupTryAgain": "Proovi uuesti", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Nõustu", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Vali ekraan, mida jagada", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/fa-IR.json b/src/i18n/fa-IR.json index c2cfe978540..7395d77b337 100644 --- a/src/i18n/fa-IR.json +++ b/src/i18n/fa-IR.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "میپذیرم", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "صفحه‌ای جهت به اشتراک گذاری انتخاب کنید", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/fi-FI.json b/src/i18n/fi-FI.json index 3fdc5c4e33c..a084d07ffbd 100644 --- a/src/i18n/fi-FI.json +++ b/src/i18n/fi-FI.json @@ -327,6 +327,7 @@ "backupTryAgain": "Yritä uudelleen", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Hyväksy", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Valitse näyttö jonka haluat jakaa", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/fr-FR.json b/src/i18n/fr-FR.json index e96a6bf641c..80efa83ff66 100644 --- a/src/i18n/fr-FR.json +++ b/src/i18n/fr-FR.json @@ -327,6 +327,7 @@ "backupTryAgain": "Réessayez", "buttonActionError": "Votre réponse ne peut pas être envoyée, veuillez réessayer", "callAccept": "Accepter", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Choisissez un écran à partager", "callChooseSharedWindow": "Sélectionnez une fenêtre d'application à partager", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/ga-IE.json b/src/i18n/ga-IE.json index b913bb0e214..5a730d4e929 100644 --- a/src/i18n/ga-IE.json +++ b/src/i18n/ga-IE.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Accept", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Choose a screen to share", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/he-IL.json b/src/i18n/he-IL.json index b913bb0e214..5a730d4e929 100644 --- a/src/i18n/he-IL.json +++ b/src/i18n/he-IL.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Accept", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Choose a screen to share", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/hi-IN.json b/src/i18n/hi-IN.json index ec248e00c15..4437241b102 100644 --- a/src/i18n/hi-IN.json +++ b/src/i18n/hi-IN.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "स्वीकार करें", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "स्क्रीन चुनें साझा करने के लिए", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/hr-HR.json b/src/i18n/hr-HR.json index 4292c313f41..6484be0a955 100644 --- a/src/i18n/hr-HR.json +++ b/src/i18n/hr-HR.json @@ -327,6 +327,7 @@ "backupTryAgain": "Pokušajte ponovno", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Prihvati", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Odaberite zaslon za zajedničko korištenje", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/hu-HU.json b/src/i18n/hu-HU.json index 00e3ddbbb07..f7a9b57f54f 100644 --- a/src/i18n/hu-HU.json +++ b/src/i18n/hu-HU.json @@ -327,6 +327,7 @@ "backupTryAgain": "Próbáld újra", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Elfogadás", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Válaszd ki a megosztandó képernyőt", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/id-ID.json b/src/i18n/id-ID.json index c6672b9262c..1fcdad3f54c 100644 --- a/src/i18n/id-ID.json +++ b/src/i18n/id-ID.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Terima", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Pilih tampilan untuk dibagikan", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/is-IS.json b/src/i18n/is-IS.json index b913bb0e214..5a730d4e929 100644 --- a/src/i18n/is-IS.json +++ b/src/i18n/is-IS.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Accept", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Choose a screen to share", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/it-IT.json b/src/i18n/it-IT.json index 4bed64d3012..e8a0524e326 100644 --- a/src/i18n/it-IT.json +++ b/src/i18n/it-IT.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Accetta", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Scegli quale schermata condividere", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/ja-JP.json b/src/i18n/ja-JP.json index cbf04e6a57a..d297501aaf2 100644 --- a/src/i18n/ja-JP.json +++ b/src/i18n/ja-JP.json @@ -327,6 +327,7 @@ "backupTryAgain": "再試行", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "承諾", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "共有先を選択", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/lb-LU.json b/src/i18n/lb-LU.json index b913bb0e214..5a730d4e929 100644 --- a/src/i18n/lb-LU.json +++ b/src/i18n/lb-LU.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Accept", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Choose a screen to share", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/lt-LT.json b/src/i18n/lt-LT.json index fa9afd05ea9..b08d3d0f240 100644 --- a/src/i18n/lt-LT.json +++ b/src/i18n/lt-LT.json @@ -327,6 +327,7 @@ "backupTryAgain": "Bandykite dar kartą", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Priimti", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Pasirinkite ekraną, kurį bendrinti", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/lv-LV.json b/src/i18n/lv-LV.json index 83078152d40..ed4f58aca5d 100644 --- a/src/i18n/lv-LV.json +++ b/src/i18n/lv-LV.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Pieņemt", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Izvēlieties ekrānu, ar kuru dalīties", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/me-ME.json b/src/i18n/me-ME.json index b913bb0e214..5a730d4e929 100644 --- a/src/i18n/me-ME.json +++ b/src/i18n/me-ME.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Accept", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Choose a screen to share", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/mk-MK.json b/src/i18n/mk-MK.json index b913bb0e214..5a730d4e929 100644 --- a/src/i18n/mk-MK.json +++ b/src/i18n/mk-MK.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Accept", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Choose a screen to share", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/ms-MY.json b/src/i18n/ms-MY.json index b913bb0e214..5a730d4e929 100644 --- a/src/i18n/ms-MY.json +++ b/src/i18n/ms-MY.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Accept", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Choose a screen to share", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/mt-MT.json b/src/i18n/mt-MT.json index b913bb0e214..5a730d4e929 100644 --- a/src/i18n/mt-MT.json +++ b/src/i18n/mt-MT.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Accept", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Choose a screen to share", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/nl-NL.json b/src/i18n/nl-NL.json index c80aa8e3e08..5f097f5f491 100644 --- a/src/i18n/nl-NL.json +++ b/src/i18n/nl-NL.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Neem op", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Kies een scherm om te delen", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/no-NO.json b/src/i18n/no-NO.json index 55158be4228..33b2e7bf519 100644 --- a/src/i18n/no-NO.json +++ b/src/i18n/no-NO.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Godta", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Velg en skjerm for å dele", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/pl-PL.json b/src/i18n/pl-PL.json index d75cda9b9d6..fc635a8ec7c 100644 --- a/src/i18n/pl-PL.json +++ b/src/i18n/pl-PL.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Odbierz", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Wybierz ekran do współdzielenia", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/pt-BR.json b/src/i18n/pt-BR.json index 82f17c03739..1b5a6366cbb 100644 --- a/src/i18n/pt-BR.json +++ b/src/i18n/pt-BR.json @@ -327,6 +327,7 @@ "backupTryAgain": "Tentar novamente", "buttonActionError": "Sua resposta não pode ser enviada, por favor tente novamente", "callAccept": "Aceitar", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Escolha uma tela para compartilhar", "callChooseSharedWindow": "Escolha uma janela para compartilhar", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/pt-PT.json b/src/i18n/pt-PT.json index 3141270c6e1..4ea59a29767 100644 --- a/src/i18n/pt-PT.json +++ b/src/i18n/pt-PT.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Aceitar", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Escolher um ecrã para partilhar", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/ro-RO.json b/src/i18n/ro-RO.json index 63a5468ca36..405f2d5c50c 100644 --- a/src/i18n/ro-RO.json +++ b/src/i18n/ro-RO.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Acceptă", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Alege un ecran pentru a partaja", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/ru-RU.json b/src/i18n/ru-RU.json index ba5e667f725..63b8c09c9a7 100644 --- a/src/i18n/ru-RU.json +++ b/src/i18n/ru-RU.json @@ -327,6 +327,7 @@ "backupTryAgain": "Повторить попытку", "buttonActionError": "Ваш ответ не может быть отправлен, пожалуйста, повторите попытку", "callAccept": "Принять", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Выберите экран для совместного использования", "callChooseSharedWindow": "Выберите окно для совместного использования", "callConversationAcceptOrDecline": "Вызывает {conversationName}. Нажмите control + enter, чтобы принять или control + shift + enter, чтобы отклонить вызов.", diff --git a/src/i18n/si-LK.json b/src/i18n/si-LK.json index a45ac7af2e7..78da156a26f 100644 --- a/src/i18n/si-LK.json +++ b/src/i18n/si-LK.json @@ -327,6 +327,7 @@ "backupTryAgain": "නැවත", "buttonActionError": "උත්තරය යැවීමට නොහැකිය, යළි උත්සාහ කරන්න", "callAccept": "පිළිගන්න", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "බෙදාගැනීමට තිරයක් තෝරන්න", "callChooseSharedWindow": "බෙදාගැනීමට කවුළුවක් තෝරන්න", "callConversationAcceptOrDecline": "{conversationName} අමතමින්. ඇමතුම පිළිගැනීමට පාලන + ඇතුල් කරන්න (ctrl + enter) ඔබන්න හෝ ඇමතුම ඉවතලීමට පාලන + මාරුව + ඇතුල් කරන්න (ctrl + shift + enter) ඔබන්න.", diff --git a/src/i18n/sk-SK.json b/src/i18n/sk-SK.json index 799a3a7deb3..4264402d2a6 100644 --- a/src/i18n/sk-SK.json +++ b/src/i18n/sk-SK.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Prijať", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Vybrať obrazovku pre zdieľanie", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/sl-SI.json b/src/i18n/sl-SI.json index d190a64621f..daa953da994 100644 --- a/src/i18n/sl-SI.json +++ b/src/i18n/sl-SI.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Sprejmi", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Izberite zaslon za deljenje", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/sq-AL.json b/src/i18n/sq-AL.json index b913bb0e214..5a730d4e929 100644 --- a/src/i18n/sq-AL.json +++ b/src/i18n/sq-AL.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Accept", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Choose a screen to share", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/sr-Cyrl-ME.json b/src/i18n/sr-Cyrl-ME.json index b913bb0e214..5a730d4e929 100644 --- a/src/i18n/sr-Cyrl-ME.json +++ b/src/i18n/sr-Cyrl-ME.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Accept", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Choose a screen to share", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/sr-SP.json b/src/i18n/sr-SP.json index bfafd98c8a1..7b29ab93f8a 100644 --- a/src/i18n/sr-SP.json +++ b/src/i18n/sr-SP.json @@ -327,6 +327,7 @@ "backupTryAgain": "Покушајте поново", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Прихвати", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Одаберите екран за дељење", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/sv-SE.json b/src/i18n/sv-SE.json index 7e350a1cbf1..e64dd117332 100644 --- a/src/i18n/sv-SE.json +++ b/src/i18n/sv-SE.json @@ -327,6 +327,7 @@ "backupTryAgain": "Försök igen", "buttonActionError": "Ditt svar kan inte skickas, försök igen", "callAccept": "Acceptera", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Välj en skärm att dela", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/th-TH.json b/src/i18n/th-TH.json index b913bb0e214..5a730d4e929 100644 --- a/src/i18n/th-TH.json +++ b/src/i18n/th-TH.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Accept", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Choose a screen to share", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/tr-CY.json b/src/i18n/tr-CY.json index b913bb0e214..5a730d4e929 100644 --- a/src/i18n/tr-CY.json +++ b/src/i18n/tr-CY.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Accept", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Choose a screen to share", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/tr-TR.json b/src/i18n/tr-TR.json index 5a8147b7207..ca418c071b7 100644 --- a/src/i18n/tr-TR.json +++ b/src/i18n/tr-TR.json @@ -327,6 +327,7 @@ "backupTryAgain": "Tekrar Deneyin", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Kabul et", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Paylaşmak için bir ekran seçin", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/uk-UA.json b/src/i18n/uk-UA.json index 6f3d118ab8e..6bc1d7ec908 100644 --- a/src/i18n/uk-UA.json +++ b/src/i18n/uk-UA.json @@ -327,6 +327,7 @@ "backupTryAgain": "Спробувати ще раз", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Прийняти", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Оберіть робочий стіл, скріншотами якого ви хочете поділитися", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/uz-UZ.json b/src/i18n/uz-UZ.json index b913bb0e214..5a730d4e929 100644 --- a/src/i18n/uz-UZ.json +++ b/src/i18n/uz-UZ.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Accept", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Choose a screen to share", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/vi-VN.json b/src/i18n/vi-VN.json index b913bb0e214..5a730d4e929 100644 --- a/src/i18n/vi-VN.json +++ b/src/i18n/vi-VN.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Accept", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Choose a screen to share", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/zh-CN.json b/src/i18n/zh-CN.json index b12e62329de..6a57837925a 100644 --- a/src/i18n/zh-CN.json +++ b/src/i18n/zh-CN.json @@ -327,6 +327,7 @@ "backupTryAgain": "请重试", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "接受", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "选择一个屏幕并分享", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/zh-HK.json b/src/i18n/zh-HK.json index b913bb0e214..5a730d4e929 100644 --- a/src/i18n/zh-HK.json +++ b/src/i18n/zh-HK.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "Accept", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "Choose a screen to share", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/i18n/zh-TW.json b/src/i18n/zh-TW.json index bc0156f1ed1..2204235e1ee 100644 --- a/src/i18n/zh-TW.json +++ b/src/i18n/zh-TW.json @@ -327,6 +327,7 @@ "backupTryAgain": "Try Again", "buttonActionError": "Your answer can't be sent, please retry", "callAccept": "接聽", + "callChooseScreenCancel": "Close-screen sharing", "callChooseSharedScreen": "請選擇要分享的螢幕畫面", "callChooseSharedWindow": "Choose a window to share", "callConversationAcceptOrDecline": "{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.", diff --git a/src/script/Config.ts b/src/script/Config.ts index 088c2068144..3fe38d6ffbf 100644 --- a/src/script/Config.ts +++ b/src/script/Config.ts @@ -99,7 +99,7 @@ const config = { AVS_VERSION: packageJson.dependencies['@wireapp/avs'], - COUNTLY_SERVER_URL: 'https://countly.wire.com/', + COUNTLY_SERVER_URL: 'https://wire.count.ly/', GET_WIRE_URL: 'https://get.wire.com', } as const; diff --git a/src/script/auth/component/ClientList.tsx b/src/script/auth/component/ClientList.tsx index c1158aada80..bf9c47645ef 100644 --- a/src/script/auth/component/ClientList.tsx +++ b/src/script/auth/component/ClientList.tsx @@ -115,7 +115,7 @@ const ClientListComponent = ({ {sortedClients.map(client => ( setSelectedClient(client.id)} onClientRemoval={(password?: string) => removeClient(client.id, password)} diff --git a/src/script/components/CellsGlobalView/CellsTable/CellsTableColumns/CellsTableRowOptions/CellsTableRowOptions.tsx b/src/script/components/CellsGlobalView/CellsTable/CellsTableColumns/CellsTableRowOptions/CellsTableRowOptions.tsx index 1dd40a56f77..8076db4c299 100644 --- a/src/script/components/CellsGlobalView/CellsTable/CellsTableColumns/CellsTableRowOptions/CellsTableRowOptions.tsx +++ b/src/script/components/CellsGlobalView/CellsTable/CellsTableColumns/CellsTableRowOptions/CellsTableRowOptions.tsx @@ -22,7 +22,6 @@ import {DropdownMenu, MoreIcon} from '@wireapp/react-ui-kit'; import {openFolder} from 'Components/CellsGlobalView/common/openFolder/openFolder'; import {CellsRepository} from 'Repositories/cells/CellsRepository'; import {CellNode, CellNodeType} from 'src/script/types/cellNode'; -import {isFileEditable} from 'Util/FileTypeUtil'; import {t} from 'Util/LocalizerUtil'; import {forcedDownloadFile} from 'Util/util'; @@ -33,7 +32,6 @@ import {showShareModal} from '../CellsShareModal/CellsShareModal'; interface CellsTableRowOptionsProps { node: CellNode; - cellsRepository: CellsRepository; } @@ -42,7 +40,6 @@ export const CellsTableRowOptions = ({node, cellsRepository}: CellsTableRowOptio const url = node.url; const name = node.type === CellNodeType.FOLDER ? `${node.name}.zip` : node.name; - const isEditable = node.type === CellNodeType.FILE && isFileEditable(node.extension!); return ( @@ -61,9 +58,6 @@ export const CellsTableRowOptions = ({node, cellsRepository}: CellsTableRowOptio showShareModal({type: node.type, uuid: node.id, cellsRepository})}> {t('cells.options.share')} - {isEditable && ( - handleOpenFile(node, true)}>{t('cells.options.edit')} - )} {!!url && ( diff --git a/src/script/components/CellsGlobalView/transformCellsNodes/transformCellsNodes.ts b/src/script/components/CellsGlobalView/transformCellsNodes/transformCellsNodes.ts index 8cf28f4c38a..cc7848d39ba 100644 --- a/src/script/components/CellsGlobalView/transformCellsNodes/transformCellsNodes.ts +++ b/src/script/components/CellsGlobalView/transformCellsNodes/transformCellsNodes.ts @@ -24,7 +24,7 @@ import {Conversation} from 'Repositories/entity/Conversation'; import {User} from 'Repositories/entity/User'; import {CellNode, CellNodeType} from 'src/script/types/cellNode'; import {TIME_IN_MILLIS} from 'Util/TimeUtil'; -import {formatBytes, getFileExtension} from 'Util/util'; +import {formatBytes, getFileExtension, getName} from 'Util/util'; import {getUserQualifiedIdFromNode} from '../common/getUserQualifiedIdFromNode/getUserQualifiedIdFromNode'; @@ -105,11 +105,6 @@ export const transformCellsNodes = ({ }); }; -const getName = (nodePath: string): string => { - const parts = nodePath.split('/'); - return parts[parts.length - 1]; -}; - const getPreviewImageUrl = (node: RestNode): string | undefined => { return node.Previews?.find(preview => preview.ContentType?.startsWith('image/'))?.PreSignedGET?.Url; }; diff --git a/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableRowOptions/CellsTableRowOptions.tsx b/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableRowOptions/CellsTableRowOptions.tsx index 7a5bb70df5d..bf6df8a4b20 100644 --- a/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableRowOptions/CellsTableRowOptions.tsx +++ b/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableRowOptions/CellsTableRowOptions.tsx @@ -17,7 +17,7 @@ * */ -import {useState} from 'react'; +import {useCallback, useState} from 'react'; import {QualifiedId} from '@wireapp/api-client/lib/user'; @@ -29,6 +29,7 @@ import { isInRecycleBin, isRootRecycleBinPath, } from 'Components/Conversation/ConversationCells/common/recycleBin/recycleBin'; +import {useFileHistoryModal} from 'Components/Modals/FileHistoryModal/hooks/useFileHistoryModal'; import {CellsRepository} from 'Repositories/cells/CellsRepository'; import {CellNode, CellNodeType} from 'src/script/types/cellNode'; import {isFileEditable} from 'Util/FileTypeUtil'; @@ -95,6 +96,7 @@ const CellsTableRowOptionsContent = ({ const [isMoveNodeModalOpen, setIsMoveNodeModalOpen] = useState(false); const [isTagsModalOpen, setIsTagsModalOpen] = useState(false); const [isRenameNodeModalOpen, setIsRenameNodeModalOpen] = useState(false); + const {showModal} = useFileHistoryModal(); const url = node.url; const name = node.type === CellNodeType.FOLDER ? `${node.name}.zip` : node.name; @@ -127,6 +129,11 @@ const CellsTableRowOptionsContent = ({ const isEditable = node.type === CellNodeType.FILE && isFileEditable(node.extension); + const onConfirmRestore = useCallback(() => { + onRefresh(); + handleOpenFile(node, false); + }, [handleOpenFile, node, onRefresh]); + if (isRootRecycleBin || isNestedRecycleBin) { return ( @@ -196,7 +203,12 @@ const CellsTableRowOptionsContent = ({ setIsMoveNodeModalOpen(true)}>{t('cells.options.move')} setIsTagsModalOpen(true)}>{t('cells.options.tags')} {isEditable && ( - handleOpenFile(node, true)}>{t('cells.options.edit')} + <> + handleOpenFile(node, true)}>{t('cells.options.edit')} + showModal(node.id, onConfirmRestore)}> + {t('cells.options.versionHistory')} + + )} diff --git a/src/script/components/ConversationListCell/ConversationListCell.tsx b/src/script/components/ConversationListCell/ConversationListCell.tsx index 2ff0cc482a8..8a5e8a86d60 100644 --- a/src/script/components/ConversationListCell/ConversationListCell.tsx +++ b/src/script/components/ConversationListCell/ConversationListCell.tsx @@ -29,6 +29,7 @@ import {ChannelAvatar} from 'Components/Avatar/ChannelAvatar'; import {UserBlockedBadge} from 'Components/Badge'; import {CellDescription} from 'Components/ConversationListCell/components/CellDescription'; import {UserInfo} from 'Components/UserInfo'; +import {useConversationCall} from 'Hooks/useConversationCall'; import {useNoInternetCallGuard} from 'Hooks/useNoInternetCallGuard/useNoInternetCallGuard'; import type {Conversation} from 'Repositories/entity/Conversation'; import {MediaType} from 'Repositories/media/MediaType'; @@ -45,7 +46,7 @@ interface ConversationListCellProps { dataUieName: string; isSelected?: (conversation: Conversation) => boolean; onClick: (event: ReactMouseEvent | ReactKeyBoardEvent) => void; - onJoinCall: (conversation: Conversation, mediaType: MediaType) => void; + onJoinCall: (conversation: Conversation, mediaType: MediaType) => Promise; rightClick: (conversation: Conversation, event: MouseEvent | React.MouseEvent) => void; showJoinButton: boolean; handleArrowKeyDown: (e: React.KeyboardEvent) => void; @@ -93,6 +94,7 @@ export const ConversationListCell = ({ ]); const guardCall = useNoInternetCallGuard(); + const {isCallConnecting} = useConversationCall(conversation); const {isChannelsEnabled} = useChannelsFeatureFlag(); const isActive = isSelected(conversation); @@ -103,17 +105,38 @@ export const ConversationListCell = ({ const [isContextMenuOpen, setContextMenuOpen] = useState(false); const contextMenuKeyboardShortcut = `keyboard-shortcut-${conversation.id}`; + // Ref for immediate synchronous protection from multiple clicks + const isJoiningCallRef = useRef(false); + + // Button is disabled if either local state or call state indicates joining + const isButtonDisabled = isJoiningCallRef.current || isCallConnecting; + const openContextMenu = (event: MouseEvent | React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); rightClick(conversation, event); }; - const onClickJoinCall = (event: React.MouseEvent) => { + const handleJoinCall = async (event: React.MouseEvent) => { event.preventDefault(); - guardCall(() => { - onJoinCall(conversation, MediaType.AUDIO); - }); + + // Check ref first for immediate synchronous protection + if (isJoiningCallRef.current || isButtonDisabled) { + return; + } + + // Immediately disable synchronously + isJoiningCallRef.current = true; + + try { + await guardCall(async () => { + await onJoinCall(conversation, MediaType.AUDIO); + isJoiningCallRef.current = false; + }); + } catch (error) { + // Re-enable on error + isJoiningCallRef.current = false; + } }; const handleDivKeyDown = (event: React.KeyboardEvent) => { @@ -241,10 +264,11 @@ export const ConversationListCell = ({ {showJoinButton && ( diff --git a/src/script/components/FileFullscreenModal/FileFullscreenModal.test.tsx b/src/script/components/FileFullscreenModal/FileFullscreenModal.test.tsx new file mode 100644 index 00000000000..19c4073f0ff --- /dev/null +++ b/src/script/components/FileFullscreenModal/FileFullscreenModal.test.tsx @@ -0,0 +1,238 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {render, screen} from '@testing-library/react'; + +import {FileFullscreenModal} from './FileFullscreenModal'; + +jest.mock('Components/FullscreenModal/FullscreenModal', () => ({ + FullscreenModal: ({children, isOpen}: any) => (isOpen ?
{children}
: null), +})); + +jest.mock('./FileHeader/FileHeader', () => ({ + FileHeader: () =>
Header
, +})); + +jest.mock('./FileEditor/FileEditor', () => { + let renderCount = 0; + return { + FileEditor: ({id}: {id: string; key?: number}) => { + renderCount++; + return ( +
+ Editor for {id} +
+ ); + }, + }; +}); + +jest.mock('./FileLoader/FileLoader', () => ({ + FileLoader: () =>
Loading...
, +})); + +jest.mock('./ImageFileView/ImageFileView', () => ({ + ImageFileView: () =>
Image View
, +})); + +jest.mock('./NoPreviewAvailable/NoPreviewAvailable', () => ({ + NoPreviewAvailable: () =>
No preview available
, +})); + +jest.mock('./PdfViewer/PdfViewer', () => ({ + PDFViewer: () =>
PDF Viewer
, +})); + +jest.mock('Util/FileTypeUtil', () => ({ + isFileEditable: (extension: string) => ['txt', 'md', 'json'].includes(extension), +})); + +jest.mock('Util/getFileTypeFromExtension/getFileTypeFromExtension', () => ({ + getFileTypeFromExtension: (extension: string) => { + if (extension === 'pdf') { + return 'pdf'; + } + if (['jpg', 'png', 'gif'].includes(extension)) { + return 'image'; + } + return 'unknown'; + }, +})); + +jest.mock('Util/util', () => ({ + getFileExtensionFromUrl: (url: string) => { + const match = url.match(/\.([^.]+)$/); + return match ? match[1] : ''; + }, +})); + +describe('FileFullscreenModal - File Version Restore', () => { + const defaultProps = { + id: 'test-file-id', + isOpen: true, + onClose: jest.fn(), + fileName: 'document', + fileExtension: 'txt', + fileUrl: 'https://example.com/file.txt', + filePreviewUrl: 'https://example.com/preview.txt', + status: 'success' as const, + senderName: 'John Doe', + timestamp: Date.now(), + badges: ['badge1'], + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Modal Rendering', () => { + it('should render file header when modal is open', () => { + render(); + + expect(screen.getByTestId('file-header')).toBeInTheDocument(); + }); + + it('should not render when modal is closed', () => { + render(); + + expect(screen.queryByTestId('file-header')).not.toBeInTheDocument(); + }); + + it('should render editor in edit mode for editable files', () => { + render(); + + expect(screen.getByTestId('file-editor')).toBeInTheDocument(); + expect(screen.queryByTestId('no-preview')).not.toBeInTheDocument(); + }); + + it('should render content in view mode', () => { + render(); + + expect(screen.queryByTestId('file-editor')).not.toBeInTheDocument(); + expect(screen.getByTestId('no-preview')).toBeInTheDocument(); + }); + }); + + describe('Edit Mode Handling', () => { + it('should switch from edit to view mode', () => { + const {rerender} = render(); + + expect(screen.getByTestId('file-editor')).toBeInTheDocument(); + + rerender(); + + expect(screen.queryByTestId('file-editor')).not.toBeInTheDocument(); + expect(screen.getByTestId('no-preview')).toBeInTheDocument(); + }); + + it('should not show editor for non-editable files', () => { + render(); + + expect(screen.queryByTestId('file-editor')).not.toBeInTheDocument(); + expect(screen.getByTestId('pdf-viewer')).toBeInTheDocument(); + }); + + it('should update edit mode when prop changes', () => { + const {rerender} = render(); + + expect(screen.queryByTestId('file-editor')).not.toBeInTheDocument(); + + rerender(); + + expect(screen.getByTestId('file-editor')).toBeInTheDocument(); + }); + }); + + describe('Content Rendering Based on File Type', () => { + it('should render PDF viewer for PDF files', () => { + render(); + + expect(screen.getByTestId('pdf-viewer')).toBeInTheDocument(); + }); + + it('should render image viewer for image files', () => { + render(); + + expect(screen.getByTestId('image-view')).toBeInTheDocument(); + }); + + it('should show loader when loading', () => { + render(); + + expect(screen.getByTestId('file-loader')).toBeInTheDocument(); + }); + + it('should show no preview when unavailable', () => { + render(); + + expect(screen.getByTestId('no-preview')).toBeInTheDocument(); + }); + + it('should show no preview when filePreviewUrl is missing', () => { + render(); + + expect(screen.getByTestId('no-preview')).toBeInTheDocument(); + }); + }); + + describe('Modal Close Behavior', () => { + it('should reset edit mode state when closing', () => { + const {rerender} = render(); + + expect(screen.getByTestId('file-editor')).toBeInTheDocument(); + + // Close and reopen + rerender(); + rerender(); + + // Should respect the new isEditMode prop + expect(screen.queryByTestId('file-editor')).not.toBeInTheDocument(); + }); + }); + + describe('Content Refresh After Version Restore', () => { + it('should render fresh content when component remounts', () => { + const {rerender} = render(); + + const firstRender = screen.getByTestId('file-editor'); + expect(firstRender).toBeInTheDocument(); + + // Simulate version restore by changing the file ID to force remount + rerender(); + + const secondRender = screen.getByTestId('file-editor'); + expect(secondRender).toBeInTheDocument(); + expect(secondRender).toHaveTextContent('Editor for new-file-id'); + }); + + it('should allow switching between different file types', () => { + const {rerender} = render( + , + ); + + expect(screen.getByTestId('pdf-viewer')).toBeInTheDocument(); + + // Change to image + rerender(); + + expect(screen.queryByTestId('pdf-viewer')).not.toBeInTheDocument(); + expect(screen.getByTestId('image-view')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/script/components/FileFullscreenModal/FileFullscreenModal.tsx b/src/script/components/FileFullscreenModal/FileFullscreenModal.tsx index 1217b86994f..2ba05afb488 100644 --- a/src/script/components/FileFullscreenModal/FileFullscreenModal.tsx +++ b/src/script/components/FileFullscreenModal/FileFullscreenModal.tsx @@ -63,8 +63,13 @@ export const FileFullscreenModal = ({ isEditMode, }: FileFullscreenModalProps) => { const [isEditableState, setIsEditableState] = useState(isEditMode); + const [refreshKey, setRefreshKey] = useState(0); const isEditable = isFileEditable(fileExtension); + const refreshModalContent = () => { + setRefreshKey(prev => prev + 1); + }; + const onCloseModal = () => { setIsEditableState(false); onClose(); @@ -88,11 +93,13 @@ export const FileFullscreenModal = ({ onEditModeChange={setIsEditableState} isEditable={isEditable} id={id} + onFileContentRefresh={refreshModalContent} /> {isEditableState && isEditable ? ( - + ) : ( void; + onFileContentRefresh: () => void; } export const FileHeader = ({ @@ -67,10 +79,12 @@ export const FileHeader = ({ isEditable, isInEditMode, onEditModeChange, + onFileContentRefresh, }: FileHeaderProps) => { const timeAgo = useRelativeTimestamp(timestamp); const fileNameWithExtension = getFileNameWithExtension(fileName, fileExtension); const cellsRepository = container.resolve(CellsRepository); + const {showModal} = useFileHistoryModal(); const handleFileDownload = async () => { if (fileUrl) { @@ -132,6 +146,20 @@ export const FileHeader = ({ > + {isEditable && ( + + + + + + showModal(id, () => onFileContentRefresh())}> + {t('cells.options.versionHistory')} + + + + )} ); diff --git a/src/script/components/InputBar/ReplyBar/ReplyBar.tsx b/src/script/components/InputBar/ReplyBar/ReplyBar.tsx index c06ee25766a..4ca3d199bf3 100644 --- a/src/script/components/InputBar/ReplyBar/ReplyBar.tsx +++ b/src/script/components/InputBar/ReplyBar/ReplyBar.tsx @@ -45,7 +45,7 @@ export const ReplyBar = ({replyMessageEntity, onCancel}: ReplyBarProps) => { const isMultipart = replyAsset?.isMultipart(); - const attachmentsCount = isMultipart ? replyAsset.attachments?.()?.length ?? 0 : 0; + const attachmentsCount = isMultipart ? (replyAsset.attachments?.()?.length ?? 0) : 0; const attachmentsCountCopy = attachmentsCount === 1 diff --git a/src/script/components/MessagesList/Message/ContentMessage/asset/MultipartAssets/FileAssetCard/FileAssetSmall/FileAssetSmall.tsx b/src/script/components/MessagesList/Message/ContentMessage/asset/MultipartAssets/FileAssetCard/FileAssetSmall/FileAssetSmall.tsx index 68787006804..5a0a4dc057a 100644 --- a/src/script/components/MessagesList/Message/ContentMessage/asset/MultipartAssets/FileAssetCard/FileAssetSmall/FileAssetSmall.tsx +++ b/src/script/components/MessagesList/Message/ContentMessage/asset/MultipartAssets/FileAssetCard/FileAssetSmall/FileAssetSmall.tsx @@ -76,7 +76,7 @@ export const FileAssetSmall = ({ {!isError && } - + {!isError && } - {!isError && } + {!isError && } + + ); +}; diff --git a/src/script/components/Modals/FileHistoryModal/FileHistoryModal.styles.ts b/src/script/components/Modals/FileHistoryModal/FileHistoryModal.styles.ts new file mode 100644 index 00000000000..f158c63c4b1 --- /dev/null +++ b/src/script/components/Modals/FileHistoryModal/FileHistoryModal.styles.ts @@ -0,0 +1,226 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {CSSObject} from '@emotion/react'; + +export const fileHistoryModalWrapperCss: CSSObject = { + overflow: 'unset', + overflowY: 'unset', + width: '500px', + maxHeight: '650px', + height: '80vh', + margin: '1rem', + borderRadius: '10px', +}; + +export const fileVersionRestoreModalWrapperCss: CSSObject = { + overflow: 'unset', + overflowY: 'unset', + width: '460px', + height: 'auto', + margin: '1rem', + borderRadius: '10px', +}; + +// Header styles +export const fileHistoryHeaderContainerCss: CSSObject = { + display: 'flex', + alignItems: 'end', + justifyContent: 'space-between', + padding: '12px 12px 12px 16px', + height: '100px', + flexDirection: 'row', +}; + +export const fileHistoryHeaderTitleCss: CSSObject = { + fontSize: 'var(--font-size-large)', + fontWeight: 'var(--font-weight-semibold)', + margin: 0, +}; + +export const fileHeaderInfoWrapperCss: CSSObject = { + display: 'flex', + flexDirection: 'column', + gap: '6px', + marginTop: 12, +}; + +export const fileHeaderFileInfoCss: CSSObject = { + display: 'flex', + alignItems: 'center', + gap: '6px', + color: 'var(--gray-70)', + height: '21px', +}; + +export const fileHistoryCloseButtonCss: CSSObject = { + background: 'none', + border: 'none', + cursor: 'pointer', + display: 'flex', + alignSelf: 'flex-start', + padding: '0px', + '&:hover': { + opacity: 0.7, + }, +}; + +export const fileHistoryRestoreCloseButtonCss: CSSObject = { + ...fileHistoryCloseButtonCss, + alignSelf: 'flex-end', +}; + +// Content styles +export const fileHistoryContentCss: CSSObject = { + padding: '6px 0px 6px 16px', + overflowY: 'auto', +}; + +export const fileHistoryListCss: CSSObject = { + display: 'flex', + flexDirection: 'column', + gap: '16px', + position: 'relative', +}; + +export const fileHistoryDateHeadingCss: CSSObject = { + marginBottom: '8px', +}; + +export const fileHistoryTimelineContainerCss: CSSObject = { + position: 'relative', + ':before': { + content: '""', + position: 'absolute', + left: '20px', + top: '15px', + bottom: '40px', + borderLeft: '1px dashed var(--gray-50)', + }, +}; + +// Version item styles +export const fileVersionItemWrapperCss: CSSObject = { + display: 'flex', + gap: '12px', + padding: '8px 16px', + borderRadius: '8px', + ':hover': { + backgroundColor: 'var(--gray-20)', + button: { + visibility: 'visible', + }, + }, +}; + +export const versionDotCurrentCss: CSSObject = { + width: '9px', + height: '9px', + backgroundColor: 'var(--accent-color)', + border: 'none', + borderRadius: '50%', + marginTop: '8px', + position: 'relative', +}; + +export const versionDotOldCss: CSSObject = { + width: '9px', + height: '9px', + backgroundColor: 'var(--modal-bg)', + border: '1px solid var(--gray-70)', + borderRadius: '50%', + marginTop: '8px', + position: 'relative', +}; + +export const versionInfoContainerCss: CSSObject = { + flex: 1, +}; + +export const versionTimeTextCss: CSSObject = { + fontWeight: 'var(--font-weight-regular)', + fontSize: 'var(--font-size-base)', + margin: 0, +}; + +export const versionMetaTextCss: CSSObject = { + color: 'var(--gray-70)', + marginTop: '4px', + margin: 0, +}; + +export const versionOwnerSpanCss: CSSObject = { + marginRight: '8px', +}; + +// Action button styles +export const versionActionsWrapperCss: CSSObject = { + display: 'flex', + gap: '8px', + marginLeft: 'auto', +}; + +export const versionButtonCss: CSSObject = { + visibility: 'hidden', + height: '32px', + minHeight: 'auto', + minWidth: 'auto', + padding: '0px 16px', + alignSelf: 'center', + marginBottom: '0px', + borderRadius: '12px', +}; + +export const iconMarginRightCss: CSSObject = { + marginRight: '8px', +}; + +export const restoreIconCss: CSSObject = { + transform: 'scaleY(-1) rotate(180deg)', + marginRight: '8px', +}; + +// Restore modal styles +export const restoreModalContainerCss: CSSObject = { + flexDirection: 'column', + display: 'flex', + padding: '12px', +}; + +export const restoreModalTitleCss: CSSObject = { + textAlign: 'center', + margin: '0 0 8px 0', +}; + +export const restoreModalDescriptionCss: CSSObject = { + fontSize: 16, + margin: '14px 18px', +}; + +export const restoreModalButtonsContainerCss: CSSObject = { + display: 'flex', + justifyContent: 'space-evenly', + gap: '12px', + margin: '0px 14px', +}; + +export const restoreModalButtonCss: CSSObject = { + marginBottom: '0px', + width: '100%', +}; diff --git a/src/script/components/Modals/FileHistoryModal/FileHistoryModal.test.tsx b/src/script/components/Modals/FileHistoryModal/FileHistoryModal.test.tsx new file mode 100644 index 00000000000..f064c18c013 --- /dev/null +++ b/src/script/components/Modals/FileHistoryModal/FileHistoryModal.test.tsx @@ -0,0 +1,264 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {render, screen, fireEvent, waitFor} from '@testing-library/react'; + +import {withTheme} from 'src/script/auth/util/test/TestUtil'; + +import {FileHistoryModal} from './FileHistoryModal'; +import {useFileHistoryModal} from './hooks/useFileHistoryModal'; +import {useFileVersions} from './hooks/useFileVersions'; + +jest.mock('./hooks/useFileHistoryModal'); +jest.mock('./hooks/useFileVersions'); +jest.mock('./FileHistoryHeader', () => ({ + FileHistoryHeader: ({file}: {file?: {name: string; extension: string}}) => ( +
{file?.name}
+ ), +})); +jest.mock('./FileHistoryContent', () => ({ + FileHistoryContent: () =>
Content
, +})); +jest.mock('Components/FileFullscreenModal/FileLoader/FileLoader', () => ({ + FileLoader: () =>
Loading...
, +})); +jest.mock('Util/LocalizerUtil', () => ({ + t: (key: string) => key, +})); + +const mockedUseFileHistoryModal = jest.mocked(useFileHistoryModal); +const mockedUseFileVersions = jest.mocked(useFileVersions); + +describe('FileHistoryModal', () => { + const mockHideModal = jest.fn(); + const mockHandleDownload = jest.fn(); + const mockHandleRestore = jest.fn(); + const mockSetToBeRestoredVersionId = jest.fn(); + + const defaultFileHistoryModalState = { + isOpen: false, + hideModal: mockHideModal, + nodeUuid: undefined as string | undefined, + showModal: jest.fn(), + onRestore: undefined as (() => void) | undefined, + }; + + const defaultFileVersionsState = { + fileVersions: {}, + isLoading: false, + handleDownload: mockHandleDownload, + handleRestore: mockHandleRestore, + fileInfo: undefined as undefined, + toBeRestoredVersionId: undefined as string | undefined, + setToBeRestoredVersionId: mockSetToBeRestoredVersionId, + error: undefined as string | undefined, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockedUseFileHistoryModal.mockReturnValue(defaultFileHistoryModalState); + mockedUseFileVersions.mockReturnValue(defaultFileVersionsState); + }); + + it('should not render when modal is closed', () => { + const {container} = render(withTheme()); + expect(container.querySelector('[data-uie-name="file-history-modal"]')).not.toBeInTheDocument(); + }); + + it('should render header and content when modal is open', () => { + mockedUseFileHistoryModal.mockReturnValue({ + ...defaultFileHistoryModalState, + isOpen: true, + }); + + render(withTheme()); + + expect(screen.getByTestId('file-history-header')).toBeInTheDocument(); + expect(screen.getByTestId('file-history-content')).toBeInTheDocument(); + }); + + it('should render loader when loading', () => { + mockedUseFileHistoryModal.mockReturnValue({ + ...defaultFileHistoryModalState, + isOpen: true, + }); + mockedUseFileVersions.mockReturnValue({ + ...defaultFileVersionsState, + isLoading: true, + }); + + render(withTheme()); + + expect(screen.getByTestId('file-loader')).toBeInTheDocument(); + expect(screen.queryByTestId('file-history-content')).not.toBeInTheDocument(); + }); + + it('should render restore confirmation modal when toBeRestoredVersionId is set', () => { + mockedUseFileHistoryModal.mockReturnValue({ + ...defaultFileHistoryModalState, + isOpen: true, + }); + mockedUseFileVersions.mockReturnValue({ + ...defaultFileVersionsState, + toBeRestoredVersionId: 'version-123', + }); + + render(withTheme()); + + expect(screen.getByText('cells.versionHistory.restoreModal.title')).toBeInTheDocument(); + expect(screen.getByText('cells.versionHistory.restoreModal.description')).toBeInTheDocument(); + expect(screen.getByText('cells.versionHistory.restoreModal.cancel')).toBeInTheDocument(); + expect(screen.getByText('cells.versionHistory.restoreModal.confirm')).toBeInTheDocument(); + }); + + it('should call setToBeRestoredVersionId(undefined) when cancel button is clicked', () => { + mockedUseFileHistoryModal.mockReturnValue({ + ...defaultFileHistoryModalState, + isOpen: true, + }); + mockedUseFileVersions.mockReturnValue({ + ...defaultFileVersionsState, + toBeRestoredVersionId: 'version-123', + }); + + render(withTheme()); + + const cancelButton = screen.getByText('cells.versionHistory.restoreModal.cancel'); + fireEvent.click(cancelButton); + + expect(mockSetToBeRestoredVersionId).toHaveBeenCalledWith(undefined); + }); + + it('should call handleRestore when restore confirm button is clicked', () => { + mockedUseFileHistoryModal.mockReturnValue({ + ...defaultFileHistoryModalState, + isOpen: true, + }); + mockedUseFileVersions.mockReturnValue({ + ...defaultFileVersionsState, + toBeRestoredVersionId: 'version-123', + }); + + render(withTheme()); + + const restoreButton = screen.getByText('cells.versionHistory.restoreModal.confirm'); + fireEvent.click(restoreButton); + + expect(mockHandleRestore).toHaveBeenCalled(); + }); + + it('should show loading state on restore button when restoring', () => { + mockedUseFileHistoryModal.mockReturnValue({ + ...defaultFileHistoryModalState, + isOpen: true, + }); + mockedUseFileVersions.mockReturnValue({ + ...defaultFileVersionsState, + toBeRestoredVersionId: 'version-123', + isLoading: true, + }); + + render(withTheme()); + + // When loading is true, confirm button text is not visible (replaced by spinner) + expect(screen.queryByText('cells.versionHistory.restoreModal.confirm')).not.toBeInTheDocument(); + + // But the modal title should still be visible + expect(screen.getByText('cells.versionHistory.restoreModal.title')).toBeInTheDocument(); + }); + + it('should call setToBeRestoredVersionId(undefined) when close button in restore modal is clicked', () => { + mockedUseFileHistoryModal.mockReturnValue({ + ...defaultFileHistoryModalState, + isOpen: true, + }); + mockedUseFileVersions.mockReturnValue({ + ...defaultFileVersionsState, + toBeRestoredVersionId: 'version-123', + }); + + render(withTheme()); + + const closeButton = screen.getByRole('button', {name: 'cells.versionHistory.closeAriaLabel'}); + fireEvent.click(closeButton); + + expect(mockSetToBeRestoredVersionId).toHaveBeenCalledWith(undefined); + }); + + // Skipping this test as it requires actual ModalComponent keyboard event handling + // which is better tested at the ModalComponent level + it.skip('should call hideModal when escape key is pressed', async () => { + mockedUseFileHistoryModal.mockReturnValue({ + ...defaultFileHistoryModalState, + isOpen: true, + }); + + render(withTheme()); + + const modal = screen.getByRole('dialog', {hidden: true}); + fireEvent.keyDown(modal, {key: 'Escape', code: 'Escape'}); + + await waitFor(() => { + expect(mockHideModal).toHaveBeenCalled(); + }); + }); + + it('should pass fileInfo to header component', () => { + const fileInfo = { + name: 'test-document.pdf', + extension: 'pdf', + }; + + mockedUseFileHistoryModal.mockReturnValue({ + ...defaultFileHistoryModalState, + isOpen: true, + }); + mockedUseFileVersions.mockReturnValue({ + ...defaultFileVersionsState, + fileInfo, + }); + + render(withTheme()); + + expect(screen.getByText(fileInfo.name)).toBeInTheDocument(); + }); + + it('should use correct wrapper CSS based on modal state', () => { + mockedUseFileHistoryModal.mockReturnValue({ + ...defaultFileHistoryModalState, + isOpen: true, + }); + + const {rerender} = render(withTheme()); + + // Default state uses fileHistoryModalWrapperCss + let modal = screen.getByRole('dialog', {hidden: true}); + expect(modal).toBeInTheDocument(); + + // When restoring, uses fileVersionRestoreModalWrapperCss + mockedUseFileVersions.mockReturnValue({ + ...defaultFileVersionsState, + toBeRestoredVersionId: 'version-123', + }); + + rerender(withTheme()); + modal = screen.getByRole('dialog', {hidden: true}); + expect(modal).toBeInTheDocument(); + }); +}); diff --git a/src/script/components/Modals/FileHistoryModal/FileHistoryModal.tsx b/src/script/components/Modals/FileHistoryModal/FileHistoryModal.tsx new file mode 100644 index 00000000000..0e4fc1f59b4 --- /dev/null +++ b/src/script/components/Modals/FileHistoryModal/FileHistoryModal.tsx @@ -0,0 +1,75 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {FileLoader} from 'Components/FileFullscreenModal/FileLoader/FileLoader'; +import {handleEscDown} from 'Util/KeyboardUtil'; + +import {FileHistoryContent} from './FileHistoryContent'; +import {FileHistoryHeader} from './FileHistoryHeader'; +import {fileHistoryModalWrapperCss, fileVersionRestoreModalWrapperCss} from './FileHistoryModal.styles'; +import {FileRestoreConfirmContent} from './FileRestoreConfirmContent'; +import {useFileHistoryModal} from './hooks/useFileHistoryModal'; +import {useFileVersions} from './hooks/useFileVersions'; + +import {ModalComponent} from '../ModalComponent'; + +export const FileHistoryModal = () => { + const {isOpen, hideModal, nodeUuid, onRestore} = useFileHistoryModal(); + const { + fileVersions, + isLoading, + handleDownload, + fileInfo, + toBeRestoredVersionId, + setToBeRestoredVersionId, + handleRestore, + } = useFileVersions(nodeUuid, hideModal, onRestore); + + return ( + handleEscDown(event, hideModal)} + > + {toBeRestoredVersionId ? ( + setToBeRestoredVersionId(undefined)} + onConfirm={handleRestore} + /> + ) : ( + <> + + {isLoading ? ( + + ) : ( + + )} + + )} + + ); +}; diff --git a/src/script/components/Modals/FileHistoryModal/FileRestoreConfirmContent.tsx b/src/script/components/Modals/FileHistoryModal/FileRestoreConfirmContent.tsx new file mode 100644 index 00000000000..dc5b4498fab --- /dev/null +++ b/src/script/components/Modals/FileHistoryModal/FileRestoreConfirmContent.tsx @@ -0,0 +1,63 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {Button, ButtonVariant} from '@wireapp/react-ui-kit'; + +import * as Icon from 'Components/Icon'; +import {t} from 'Util/LocalizerUtil'; + +import { + fileHistoryRestoreCloseButtonCss, + restoreModalButtonCss, + restoreModalButtonsContainerCss, + restoreModalContainerCss, + restoreModalDescriptionCss, + restoreModalTitleCss, +} from './FileHistoryModal.styles'; + +interface FileRestoreConfirmModalProps { + isLoading: boolean; + onClose: () => void; + onConfirm: () => void; +} + +export const FileRestoreConfirmContent = ({isLoading, onClose, onConfirm}: FileRestoreConfirmModalProps) => { + return ( +
+ +

{t('cells.versionHistory.restoreModal.title')}

+

{t('cells.versionHistory.restoreModal.description')}

+
+ + +
+
+ ); +}; diff --git a/src/script/components/Modals/FileHistoryModal/FileVersionItem.tsx b/src/script/components/Modals/FileHistoryModal/FileVersionItem.tsx new file mode 100644 index 00000000000..4d61f7a2d72 --- /dev/null +++ b/src/script/components/Modals/FileHistoryModal/FileVersionItem.tsx @@ -0,0 +1,86 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {Button, ButtonVariant, DownloadIcon, ReloadIcon} from '@wireapp/react-ui-kit'; + +import {t} from 'Util/LocalizerUtil'; + +import { + fileVersionItemWrapperCss, + iconMarginRightCss, + restoreIconCss, + versionActionsWrapperCss, + versionButtonCss, + versionDotCurrentCss, + versionDotOldCss, + versionInfoContainerCss, + versionMetaTextCss, + versionOwnerSpanCss, + versionTimeTextCss, +} from './FileHistoryModal.styles'; + +interface FileVersionItemProps { + version: { + versionId: string; + time: string; + ownerName: string; + size: string; + downloadUrl: string; + }; + isCurrentVersion: boolean; + onDownload: (downloadUrl: string) => void | Promise; + onRestore: (versionId: string) => void; +} + +export const FileVersionItem = ({version, isCurrentVersion, onDownload, onRestore}: FileVersionItemProps) => { + return ( +
+ + ); +}; diff --git a/src/script/components/Modals/FileHistoryModal/hooks/useFileHistoryModal.test.ts b/src/script/components/Modals/FileHistoryModal/hooks/useFileHistoryModal.test.ts new file mode 100644 index 00000000000..3e4bbba427a --- /dev/null +++ b/src/script/components/Modals/FileHistoryModal/hooks/useFileHistoryModal.test.ts @@ -0,0 +1,97 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {renderHook, act} from '@testing-library/react'; + +import {useFileHistoryModal} from './useFileHistoryModal'; + +describe('useFileHistoryModal', () => { + it('should initialize with closed state', () => { + const {result} = renderHook(() => useFileHistoryModal()); + + expect(result.current.isOpen).toBe(false); + expect(result.current.nodeUuid).toBeUndefined(); + }); + + it('should open modal with provided node UUID', () => { + const {result} = renderHook(() => useFileHistoryModal()); + const testUuid = 'test-uuid-123'; + + act(() => { + result.current.showModal(testUuid); + }); + + expect(result.current.isOpen).toBe(true); + expect(result.current.nodeUuid).toBe(testUuid); + }); + + it('should close modal and reset state', () => { + const {result} = renderHook(() => useFileHistoryModal()); + const testUuid = 'test-uuid-123'; + + // First open the modal + act(() => { + result.current.showModal(testUuid); + }); + + expect(result.current.isOpen).toBe(true); + expect(result.current.nodeUuid).toBe(testUuid); + + // Then close it + act(() => { + result.current.hideModal(); + }); + + expect(result.current.isOpen).toBe(false); + expect(result.current.nodeUuid).toBeUndefined(); + }); + + it('should update node UUID when opening modal multiple times', () => { + const {result} = renderHook(() => useFileHistoryModal()); + const firstUuid = 'first-uuid'; + const secondUuid = 'second-uuid'; + + act(() => { + result.current.showModal(firstUuid); + }); + + expect(result.current.nodeUuid).toBe(firstUuid); + + act(() => { + result.current.showModal(secondUuid); + }); + + expect(result.current.nodeUuid).toBe(secondUuid); + expect(result.current.isOpen).toBe(true); + }); + + it('should persist state across multiple renders', () => { + const {result, rerender} = renderHook(() => useFileHistoryModal()); + const testUuid = 'test-uuid-123'; + + act(() => { + result.current.showModal(testUuid); + }); + + rerender(); + + expect(result.current.isOpen).toBe(true); + expect(result.current.nodeUuid).toBe(testUuid); + }); +}); diff --git a/src/script/components/Modals/FileHistoryModal/hooks/useFileHistoryModal.ts b/src/script/components/Modals/FileHistoryModal/hooks/useFileHistoryModal.ts new file mode 100644 index 00000000000..caf1dc54558 --- /dev/null +++ b/src/script/components/Modals/FileHistoryModal/hooks/useFileHistoryModal.ts @@ -0,0 +1,54 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {create} from 'zustand'; + +/** + * Type representing the state of the File History Modal. + */ +type FileHistoryModalState = { + isOpen: boolean; + nodeUuid?: string; + onRestore?: () => void; + showModal: (nodeUuid: string, onRestore?: () => void) => void; + hideModal: () => void; +}; + +/** + * Initial state of the File History Modal. + */ +const initialState: Omit = { + isOpen: false, + nodeUuid: undefined, + onRestore: undefined, +}; + +/** + * Hook to manage the state of the File History Modal. + */ +export const useFileHistoryModal = create(set => ({ + ...initialState, + showModal: (nodeUuid: string, onRestore?: () => void) => + set({ + isOpen: true, + nodeUuid, + onRestore, + }), + hideModal: () => set(() => ({...initialState})), +})); diff --git a/src/script/components/Modals/FileHistoryModal/hooks/useFileVersions.test.ts b/src/script/components/Modals/FileHistoryModal/hooks/useFileVersions.test.ts new file mode 100644 index 00000000000..97d925126f8 --- /dev/null +++ b/src/script/components/Modals/FileHistoryModal/hooks/useFileVersions.test.ts @@ -0,0 +1,421 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {renderHook, waitFor, act} from '@testing-library/react'; + +import {useFileVersions} from './useFileVersions'; + +// Mock the dependencies +const mockGetNode = jest.fn(); +const mockGetNodeVersions = jest.fn(); +const mockPromoteNodeDraft = jest.fn(); +const mockForcedDownloadFile = jest.fn(); + +jest.mock('tsyringe', () => ({ + container: { + resolve: jest.fn(() => ({ + getNode: mockGetNode, + getNodeVersions: mockGetNodeVersions, + promoteNodeDraft: mockPromoteNodeDraft, + })), + }, + singleton: () => () => {}, + injectable: () => () => {}, +})); + +jest.mock('Util/LocalizerUtil', () => ({ + t: (key: string) => key, +})); + +jest.mock('Util/util', () => ({ + forcedDownloadFile: (args: any) => mockForcedDownloadFile(args), + getFileExtension: (path: string) => { + const match = path.match(/\.([^.]+)$/); + return match ? match[1] : ''; + }, + getName: (path: string) => { + const parts = path.split('/'); + return parts[parts.length - 1]; + }, +})); + +jest.mock('../utils/fileVersionUtils', () => ({ + groupVersionsByDate: (versions: any[]) => { + if (!versions || versions.length === 0) { + return {}; + } + return {Today: versions}; + }, +})); + +describe('useFileVersions', () => { + const mockNode = { + Path: '/test/document.txt', + PreSignedGET: { + Url: 'https://example.com/file.txt', + }, + }; + + const mockVersions = [ + { + VersionId: 'version-1', + Timestamp: '2025-12-09T10:00:00Z', + PreSignedGET: { + Url: 'https://example.com/version-1.txt', + }, + }, + { + VersionId: 'version-2', + Timestamp: '2025-12-08T10:00:00Z', + PreSignedGET: { + Url: 'https://example.com/version-2.txt', + }, + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetNode.mockResolvedValue(mockNode); + mockGetNodeVersions.mockResolvedValue(mockVersions); + mockPromoteNodeDraft.mockResolvedValue(undefined); + mockForcedDownloadFile.mockResolvedValue(undefined); + }); + + describe('Initialization', () => { + it('should initialize with default state', () => { + const {result} = renderHook(() => useFileVersions()); + + expect(result.current.fileInfo).toBeUndefined(); + expect(result.current.fileVersions).toEqual({}); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeUndefined(); + expect(result.current.toBeRestoredVersionId).toBeUndefined(); + }); + + it('should not load versions when nodeUuid is not provided', () => { + renderHook(() => useFileVersions()); + + expect(mockGetNode).not.toHaveBeenCalled(); + expect(mockGetNodeVersions).not.toHaveBeenCalled(); + }); + }); + + describe('Loading File Versions', () => { + it('should load file info and versions when nodeUuid is provided', async () => { + const {result} = renderHook(() => useFileVersions('test-uuid')); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockGetNode).toHaveBeenCalledWith({uuid: 'test-uuid'}); + expect(mockGetNodeVersions).toHaveBeenCalledWith({ + uuid: 'test-uuid', + flags: ['WithPreSignedURLs'], + }); + + expect(result.current.fileInfo).toEqual({ + name: 'document.txt', + extension: 'txt', + }); + expect(result.current.fileVersions).toEqual({ + Today: mockVersions, + }); + expect(result.current.error).toBeUndefined(); + }); + + it('should handle error when node data is invalid', async () => { + mockGetNode.mockResolvedValue({Path: null}); + + const {result} = renderHook(() => useFileVersions('test-uuid')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe('fileHistoryModal.invalidNodeData'); + expect(result.current.fileInfo).toBeUndefined(); + expect(result.current.fileVersions).toEqual({}); + }); + + it('should handle error when fetching versions fails', async () => { + const error = new Error('Network error'); + mockGetNodeVersions.mockRejectedValue(error); + + const {result} = renderHook(() => useFileVersions('test-uuid')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe('Network error'); + }); + + it('should reset state when nodeUuid changes to undefined', async () => { + const {result, rerender} = renderHook(({uuid}) => useFileVersions(uuid), { + initialProps: {uuid: 'test-uuid' as string | undefined}, + }); + + await waitFor(() => { + expect(result.current.fileInfo).toBeDefined(); + }); + + rerender({uuid: undefined}); + + expect(result.current.fileInfo).toBeUndefined(); + expect(result.current.fileVersions).toEqual({}); + }); + + it('should reload versions when nodeUuid changes', async () => { + const {rerender} = renderHook(({uuid}) => useFileVersions(uuid), { + initialProps: {uuid: 'test-uuid-1' as string | undefined}, + }); + + await waitFor(() => { + expect(mockGetNode).toHaveBeenCalledWith({uuid: 'test-uuid-1'}); + }); + + rerender({uuid: 'test-uuid-2'}); + + await waitFor(() => { + expect(mockGetNode).toHaveBeenCalledWith({uuid: 'test-uuid-2'}); + }); + + expect(mockGetNode).toHaveBeenCalledTimes(2); + }); + }); + + describe('File Version Restore', () => { + it('should restore a file version successfully', async () => { + const onClose = jest.fn(); + const onRestore = jest.fn(); + const {result} = renderHook(() => useFileVersions('test-uuid', onClose, onRestore)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Set version to restore + act(() => { + result.current.setToBeRestoredVersionId('version-1'); + }); + + expect(result.current.toBeRestoredVersionId).toBe('version-1'); + + // Trigger restore + await act(async () => { + await result.current.handleRestore(); + }); + + expect(mockPromoteNodeDraft).toHaveBeenCalledWith({ + uuid: 'test-uuid', + versionId: 'version-1', + }); + + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + expect(onRestore).toHaveBeenCalled(); + }); + + expect(result.current.toBeRestoredVersionId).toBeUndefined(); + expect(result.current.fileInfo).toBeUndefined(); + expect(result.current.fileVersions).toEqual({}); + }); + + it('should not restore when toBeRestoredVersionId is not set', async () => { + const {result} = renderHook(() => useFileVersions('test-uuid')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.handleRestore(); + }); + + expect(mockPromoteNodeDraft).not.toHaveBeenCalled(); + }); + + it('should not restore when nodeUuid is not set', async () => { + const {result} = renderHook(() => useFileVersions()); + + act(() => { + result.current.setToBeRestoredVersionId('version-1'); + }); + + await act(async () => { + await result.current.handleRestore(); + }); + + expect(mockPromoteNodeDraft).not.toHaveBeenCalled(); + }); + + it('should handle restore error', async () => { + const error = new Error('Restore failed'); + mockPromoteNodeDraft.mockRejectedValue(error); + + const onClose = jest.fn(); + const onRestore = jest.fn(); + const {result} = renderHook(() => useFileVersions('test-uuid', onClose, onRestore)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + act(() => { + result.current.setToBeRestoredVersionId('version-1'); + }); + + await act(async () => { + await result.current.handleRestore(); + }); + + // Callbacks should still be called even on error + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + expect(onRestore).toHaveBeenCalled(); + }); + + // Error is cleared as part of reset, which is expected behavior + expect(mockPromoteNodeDraft).toHaveBeenCalled(); + }); + + it('should reset all state after successful restore', async () => { + const onClose = jest.fn(); + const onRestore = jest.fn(); + const {result} = renderHook(() => useFileVersions('test-uuid', onClose, onRestore)); + + await waitFor(() => { + expect(result.current.fileInfo).toBeDefined(); + }); + + act(() => { + result.current.setToBeRestoredVersionId('version-1'); + }); + + await act(async () => { + await result.current.handleRestore(); + }); + + await waitFor(() => { + expect(result.current.fileInfo).toBeUndefined(); + expect(result.current.fileVersions).toEqual({}); + expect(result.current.isLoading).toBe(false); + expect(result.current.toBeRestoredVersionId).toBeUndefined(); + }); + }); + }); + + describe('File Download', () => { + it('should download file successfully', async () => { + const {result} = renderHook(() => useFileVersions('test-uuid')); + + await waitFor(() => { + expect(result.current.fileInfo).toBeDefined(); + }); + + await act(async () => { + await result.current.handleDownload('https://example.com/file.txt'); + }); + + expect(mockForcedDownloadFile).toHaveBeenCalledWith({ + url: 'https://example.com/file.txt', + name: 'document.txt', + }); + }); + + it('should use default filename when fileInfo is not available', async () => { + const {result} = renderHook(() => useFileVersions()); + + await act(async () => { + await result.current.handleDownload('https://example.com/file.txt'); + }); + + expect(mockForcedDownloadFile).toHaveBeenCalledWith({ + url: 'https://example.com/file.txt', + name: 'file', + }); + }); + }); + + describe('Callback Handling', () => { + it('should call onClose callback during reset', async () => { + const onClose = jest.fn(); + const {result} = renderHook(() => useFileVersions('test-uuid', onClose)); + + await waitFor(() => { + expect(result.current.fileInfo).toBeDefined(); + }); + + act(() => { + result.current.setToBeRestoredVersionId('version-1'); + }); + + await act(async () => { + await result.current.handleRestore(); + }); + + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('should call onRestore callback during reset', async () => { + const onRestore = jest.fn(); + const {result} = renderHook(() => useFileVersions('test-uuid', undefined, onRestore)); + + await waitFor(() => { + expect(result.current.fileInfo).toBeDefined(); + }); + + act(() => { + result.current.setToBeRestoredVersionId('version-1'); + }); + + await act(async () => { + await result.current.handleRestore(); + }); + + await waitFor(() => { + expect(onRestore).toHaveBeenCalled(); + }); + }); + + it('should not throw error when callbacks are not provided', async () => { + const {result} = renderHook(() => useFileVersions('test-uuid')); + + await waitFor(() => { + expect(result.current.fileInfo).toBeDefined(); + }); + + act(() => { + result.current.setToBeRestoredVersionId('version-1'); + }); + + // Should not throw error + await act(async () => { + await result.current.handleRestore(); + }); + + expect(result.current.fileInfo).toBeUndefined(); + }); + }); +}); diff --git a/src/script/components/Modals/FileHistoryModal/hooks/useFileVersions.ts b/src/script/components/Modals/FileHistoryModal/hooks/useFileVersions.ts new file mode 100644 index 00000000000..72c7ab21525 --- /dev/null +++ b/src/script/components/Modals/FileHistoryModal/hooks/useFileVersions.ts @@ -0,0 +1,141 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {useCallback, useEffect, useState} from 'react'; + +import {container} from 'tsyringe'; + +import {CellsRepository} from 'src/script/repositories/cells/CellsRepository'; +import {t} from 'Util/LocalizerUtil'; +import {forcedDownloadFile, getFileExtension, getName} from 'Util/util'; + +import {FileInfo, FileVersion} from '../types'; +import {groupVersionsByDate} from '../utils/fileVersionUtils'; + +/** + * Hook to fetch and manage file versions for a given node UUID. + */ +export const useFileVersions = (nodeUuid?: string, onClose?: () => void, onRestore?: () => void) => { + const [fileInfo, setFileInfo] = useState(); + const [fileVersions, setFileVersions] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + const [error, setError] = useState(); + const [toBeRestoredVersionId, setToBeRestoredVersionId] = useState(); + + useEffect(() => { + if (!nodeUuid) { + setFileInfo(undefined); + setFileVersions({}); + return; + } + + const loadFileVersions = async () => { + setIsLoading(true); + setError(undefined); + const cellsRepository = container.resolve(CellsRepository); + try { + // Fetch the node details and versions in parallel + const [node, versions] = await Promise.all([ + cellsRepository.getNode({uuid: nodeUuid}), + cellsRepository.getNodeVersions({uuid: nodeUuid, flags: ['WithPreSignedURLs']}), + ]); + + // Validate node data + if (!node?.Path) { + throw new Error(t('fileHistoryModal.invalidNodeData')); + } + + // Extract file info from the node + const info: FileInfo = { + name: getName(node.Path), + extension: getFileExtension(node.Path), + }; + + setFileInfo(info); + + const groupedVersions = groupVersionsByDate(versions || []); + setFileVersions(groupedVersions); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : t('fileHistoryModal.failedToLoadVersions'); + setError(errorMessage); + } finally { + setIsLoading(false); + } + }; + + void loadFileVersions(); + }, [nodeUuid]); + + const reset = useCallback(() => { + setFileInfo(undefined); + setFileVersions({}); + setIsLoading(false); + setError(undefined); + setToBeRestoredVersionId(undefined); + onClose?.(); + onRestore?.(); + }, [onClose, onRestore]); + + const handleDownload = useCallback( + async (url: string) => { + if (isDownloading) { + return; + } + setIsDownloading(true); + setError(undefined); + try { + await forcedDownloadFile({url, name: fileInfo?.name || 'file'}); + } finally { + setIsDownloading(false); + } + }, + [isDownloading, fileInfo], + ); + + const handleRestore = useCallback(async () => { + if (!toBeRestoredVersionId || !nodeUuid) { + return; + } + setIsLoading(true); + try { + const cellsRepository = container.resolve(CellsRepository); + await cellsRepository.promoteNodeDraft({ + uuid: nodeUuid, + versionId: toBeRestoredVersionId, + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : t('fileHistoryModal.failedToRestore'); + setError(errorMessage); + } finally { + reset(); + } + }, [toBeRestoredVersionId, nodeUuid, reset]); + + return { + fileInfo, + fileVersions, + isLoading, + error, + handleDownload, + handleRestore, + setToBeRestoredVersionId, + toBeRestoredVersionId, + }; +}; diff --git a/src/script/components/Modals/FileHistoryModal/types.ts b/src/script/components/Modals/FileHistoryModal/types.ts new file mode 100644 index 00000000000..c57ba358ae4 --- /dev/null +++ b/src/script/components/Modals/FileHistoryModal/types.ts @@ -0,0 +1,33 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export interface FileVersion { + versionId: string; + time: string; + ownerName: string; + size: string; + downloadUrl: string; +} +/** + * Type representing the file being viewed in history. + */ +export interface FileInfo { + name: string; + extension: string; +} diff --git a/src/script/components/Modals/FileHistoryModal/utils/fileVersionUtils.ts b/src/script/components/Modals/FileHistoryModal/utils/fileVersionUtils.ts new file mode 100644 index 00000000000..447692e2f25 --- /dev/null +++ b/src/script/components/Modals/FileHistoryModal/utils/fileVersionUtils.ts @@ -0,0 +1,70 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {RestVersion} from 'cells-sdk-ts'; + +import {calculateDaysDifference, formatDateKey, formatTime, getDayPrefix, TIME_IN_MILLIS} from 'Util/TimeUtil'; +import {formatBytes} from 'Util/util'; + +import {FileVersion} from '../types'; + +/** + * Transform a RestVersion to FileVersion + */ +export const transformRestVersionToFileVersion = (version: RestVersion, timestamp: number): FileVersion => { + return { + versionId: version.VersionId || '', + time: formatTime(timestamp), + ownerName: version.OwnerName || '', + size: formatBytes(Number(version.Size) || 0), + downloadUrl: version.PreSignedGET?.Url || '', + }; +}; + +/** + * Group file versions by date + */ +export const groupVersionsByDate = (versions: RestVersion[]): Record => { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + return versions.reduce( + (acc, version) => { + // Skip versions with missing critical data + if (!version.VersionId || !version.MTime) { + return acc; + } + + const timestamp = Number(version.MTime || 0) * TIME_IN_MILLIS.SECOND; + const versionDate = new Date(timestamp); + + const daysDiff = calculateDaysDifference(today, versionDate); + const dayPrefix = getDayPrefix(daysDiff, timestamp); + const dateKey = formatDateKey(timestamp, dayPrefix); + + if (!acc[dateKey]) { + acc[dateKey] = []; + } + + acc[dateKey].push(transformRestVersionToFileVersion(version, timestamp)); + return acc; + }, + {} as Record, + ); +}; diff --git a/src/script/components/Modals/ModalComponent/ModalComponent.styles.tsx b/src/script/components/Modals/ModalComponent/ModalComponent.styles.tsx index 557b4ef904f..d63808890e9 100644 --- a/src/script/components/Modals/ModalComponent/ModalComponent.styles.tsx +++ b/src/script/components/Modals/ModalComponent/ModalComponent.styles.tsx @@ -33,6 +33,7 @@ export const ModalOverlayStyles: CSSObject = { top: 0, transition: 'opacity 0.15s cubic-bezier(0.165, 0.84, 0.44, 1)', zIndex: 10000000, + display: 'flex', }; export const ModalOverlayVisibleStyles: CSSObject = { diff --git a/src/script/components/Modals/ModalComponent/ModalComponent.tsx b/src/script/components/Modals/ModalComponent/ModalComponent.tsx index 131b42f153d..0b2d6aac056 100644 --- a/src/script/components/Modals/ModalComponent/ModalComponent.tsx +++ b/src/script/components/Modals/ModalComponent/ModalComponent.tsx @@ -20,6 +20,7 @@ import React, {useEffect, useId, useRef, useState, useCallback, HTMLProps} from 'react'; import {CSSObject} from '@emotion/react'; +import {createPortal} from 'react-dom'; import {TabIndex} from '@wireapp/react-ui-kit'; @@ -105,33 +106,37 @@ const ModalComponent = ({ } return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-noninteractive-element-interactions - , + document.body, )} -
+ ); }; diff --git a/src/script/components/Modals/PrimaryModal/PrimaryModal.test.tsx b/src/script/components/Modals/PrimaryModal/PrimaryModal.test.tsx index 0cdc4525b51..218349444ff 100644 --- a/src/script/components/Modals/PrimaryModal/PrimaryModal.test.tsx +++ b/src/script/components/Modals/PrimaryModal/PrimaryModal.test.tsx @@ -74,6 +74,16 @@ describe('PrimaryModal', () => { }); }); + describe('Input', () => { + it('should disable the primary button while the input is empty', async () => { + const {getPrimaryActionButton, getInput} = renderPrimaryModal(PrimaryModalType.INPUT); + expect(getPrimaryActionButton()).toHaveProperty('disabled', true); + + fireEvent.change(getInput(), {target: {value: 'Test'}}); + expect(getPrimaryActionButton()).toHaveProperty('disabled', false); + }); + }); + describe('GuestLinkPassword', () => { const action = jest.fn().mockImplementation(); @@ -134,7 +144,7 @@ const renderPrimaryModal = ( secondaryAction = () => {}, hideCloseBtn = false, ) => { - const {getByTestId, queryByTestId} = render(withTheme()); + const {getByTestId, queryByTestId, getByLabelText} = render(withTheme()); act(() => { PrimaryModal.show(type, { primaryAction: { @@ -148,6 +158,7 @@ const renderPrimaryModal = ( text: { message: 'test-message', title: 'test-title', + input: 'test-input', }, hideCloseBtn, copyPassword: true, @@ -162,6 +173,7 @@ const renderPrimaryModal = ( getCloseButton: () => queryByTestId('do-close'), getErrorMessage: () => getByTestId('primary-modals-error-message'), getPasswordInput: () => getByTestId('guest-link-password'), + getInput: () => getByLabelText('test-input'), getGeneratePasswordButton: () => getByTestId('do-generate-password'), getConfirmPasswordInput: () => getByTestId('guest-link-password-confirm'), }; diff --git a/src/script/components/Modals/PrimaryModal/PrimaryModal.tsx b/src/script/components/Modals/PrimaryModal/PrimaryModal.tsx index 48e81e1d663..3f04fee2373 100644 --- a/src/script/components/Modals/PrimaryModal/PrimaryModal.tsx +++ b/src/script/components/Modals/PrimaryModal/PrimaryModal.tsx @@ -143,6 +143,9 @@ export const PrimaryModalComponent: FC = () => { if (isConfirm) { return false; } + if (isInput) { + return !inputActionEnabled; + } return !inputActionEnabled && !actionEnabled; }; diff --git a/src/script/components/TitleBar/TitleBar.tsx b/src/script/components/TitleBar/TitleBar.tsx index f847447904b..69d672de4cf 100644 --- a/src/script/components/TitleBar/TitleBar.tsx +++ b/src/script/components/TitleBar/TitleBar.tsx @@ -30,6 +30,7 @@ import {ConversationVerificationBadges} from 'Components/Badge'; import {useCallAlertState} from 'Components/calling/useCallAlertState'; import * as Icon from 'Components/Icon'; import {LegalHoldDot} from 'Components/LegalHoldDot'; +import {useConversationCall} from 'Hooks/useConversationCall'; import {useNoInternetCallGuard} from 'Hooks/useNoInternetCallGuard/useNoInternetCallGuard'; import {CallState} from 'Repositories/calling/CallState'; import {ConversationFilter} from 'Repositories/conversation/ConversationFilter'; @@ -103,12 +104,24 @@ export const TitleBar = ({ ]); const guardCall = useNoInternetCallGuard(); + const {isCallConnecting, isCallActive} = useConversationCall(conversation); const {isActivatedAccount} = useKoSubscribableChildren(selfUser, ['isActivatedAccount']); const {joinedCall, activeCalls} = useKoSubscribableChildren(callState, ['joinedCall', 'activeCalls']); const currentFocusedElementRef = useRef(null); + // using ref for immediate double-click protection + const isStartingCallRef = useRef(false); + + // Reset local state when a call becomes active or cleared + if (isStartingCallRef && (isCallActive || activeCalls.length === 0)) { + isStartingCallRef.current = false; + } + + // Button is disabled if starting, connecting, or already active + const isCallButtonDisabled = isReadOnlyConversation || isStartingCallRef.current || isCallConnecting || isCallActive; + const badgeLabelCopy = useMemo(() => { if (is1to1 && isRequest) { return ''; @@ -135,7 +148,7 @@ export const TitleBar = ({ const showCallControls = ConversationFilter.showCallControls(conversation, hasCall); - const conversationSubtitle = is1to1 && firstUserEntity?.isFederated ? firstUserEntity?.handle ?? '' : ''; + const conversationSubtitle = is1to1 && firstUserEntity?.isFederated ? (firstUserEntity?.handle ?? '') : ''; const shortcut = Shortcut.getShortcutTooltip(ShortcutType.PEOPLE); const peopleTooltip = t('tooltipConversationPeople', {shortcut}); @@ -200,9 +213,21 @@ export const TitleBar = ({ const onClickDetails = () => showDetails(false); const startCallAndShowAlert = () => { - guardCall(() => { - callActions.startAudio(conversation); - showStartedCallAlert(isGroupOrChannel); + if (isStartingCallRef.current || isCallButtonDisabled) { + return; + } + + isStartingCallRef.current = true; + + guardCall(async () => { + try { + await callActions.startAudio(conversation); + isStartingCallRef.current = false; + showStartedCallAlert(isGroupOrChannel); + } catch (error) { + // Re-enable on error + isStartingCallRef.current = false; + } }); }; @@ -306,7 +331,7 @@ export const TitleBar = ({ startCallAndShowAlert(); }} data-uie-name="do-call" - disabled={isReadOnlyConversation} + disabled={isCallButtonDisabled} > @@ -331,7 +356,7 @@ export const TitleBar = ({ css={{marginBottom: 0}} onClick={onClickStartAudio} data-uie-name="do-call" - disabled={isReadOnlyConversation} + disabled={isCallButtonDisabled} > diff --git a/src/script/components/calling/CallingCell/CallingCell.tsx b/src/script/components/calling/CallingCell/CallingCell.tsx index 7d9e1a44808..e8c4653d7e2 100644 --- a/src/script/components/calling/CallingCell/CallingCell.tsx +++ b/src/script/components/calling/CallingCell/CallingCell.tsx @@ -17,7 +17,7 @@ * */ -import React, {useCallback, useEffect} from 'react'; +import React, {useCallback, useEffect, useRef} from 'react'; import {container} from 'tsyringe'; @@ -33,6 +33,7 @@ import {GroupVideoGrid} from 'Components/calling/GroupVideoGrid'; import {useCallAlertState} from 'Components/calling/useCallAlertState'; import {ConversationClassifiedBar} from 'Components/ClassifiedBar/ClassifiedBar'; import * as Icon from 'Components/Icon'; +import {useConversationCall} from 'Hooks/useConversationCall'; import {useNoInternetCallGuard} from 'Hooks/useNoInternetCallGuard/useNoInternetCallGuard'; import type {Call} from 'Repositories/calling/Call'; import type {CallingRepository} from 'Repositories/calling/CallingRepository'; @@ -114,6 +115,10 @@ export const CallingCell = ({ const {activeCallViewTab, viewMode} = useKoSubscribableChildren(callState, ['activeCallViewTab', 'viewMode']); const guardCall = useNoInternetCallGuard(); + const {isCallConnecting} = useConversationCall(conversation); + + // Ref for immediate synchronous protection from multiple clicks + const isAnsweringRef = useRef(false); const selfParticipant = call.getSelfParticipant(); @@ -138,6 +143,14 @@ export const CallingCell = ({ const isConnecting = state === CALL_STATE.ANSWERED; const isOngoing = state === CALL_STATE.MEDIA_ESTAB; + // Reset local state when call state changes from incoming + if (isAnsweringRef.current && !isIncoming) { + isAnsweringRef.current = false; + } + + // Button is disabled if either local state or call state indicates answering + const isAnswerButtonDisabled = isAnsweringRef.current || isCallConnecting; + const callStatus: Partial> = { [CALL_STATE.OUTGOING]: { dataUieName: 'call-label-outgoing', @@ -222,9 +235,23 @@ export const CallingCell = ({ const {showAlert, clearShowAlert} = useCallAlertState(); const answerCall = () => { - guardCall(() => { - callActions.answer(call); - setCurrentView(ViewType.MOBILE_LEFT_SIDEBAR); + // Check ref first for immediate synchronous protection + if (isAnsweringRef.current || isAnswerButtonDisabled) { + return; + } + + // Immediately disable synchronously + isAnsweringRef.current = true; + + guardCall(async () => { + try { + await callActions.answer(call); + isAnsweringRef.current = false; + setCurrentView(ViewType.MOBILE_LEFT_SIDEBAR); + } catch (error) { + // Re-enable on error + isAnsweringRef.current = false; + } }); }; @@ -404,6 +431,7 @@ export const CallingCell = ({ disableScreenButton={!callingRepository.supportsScreenSharing} teamState={teamState} supportsVideoCall={conversation.supportsVideoCall(call.isConference)} + isAnswerButtonDisabled={isAnswerButtonDisabled} /> )} diff --git a/src/script/components/calling/CallingCell/CallingControls/CallingControls.tsx b/src/script/components/calling/CallingCell/CallingControls/CallingControls.tsx index 649b2ddb261..60a62b69f1b 100644 --- a/src/script/components/calling/CallingCell/CallingControls/CallingControls.tsx +++ b/src/script/components/calling/CallingCell/CallingControls/CallingControls.tsx @@ -54,6 +54,7 @@ interface CallingControlsProps { disableScreenButton: boolean; teamState: TeamState; supportsVideoCall: boolean; + isAnswerButtonDisabled?: boolean; } export const CallingControls = ({ @@ -75,6 +76,7 @@ export const CallingControls = ({ selfParticipant, teamState = container.resolve(TeamState), supportsVideoCall, + isAnswerButtonDisabled = false, }: CallingControlsProps) => { const {isVideoCallingEnabled} = useKoSubscribableChildren(teamState, ['isVideoCallingEnabled']); const {sharesScreen: selfSharesScreen, sharesCamera: selfSharesCamera} = useKoSubscribableChildren(selfParticipant, [ @@ -190,6 +192,7 @@ export const CallingControls = ({ onClick={answerCall} type="button" data-uie-name="do-call-controls-call-join" + disabled={isAnswerButtonDisabled} > {t('callJoin')} @@ -201,6 +204,7 @@ export const CallingControls = ({ title={t('callAccept')} aria-label={t('callAccept')} data-uie-name="do-call-controls-call-accept" + disabled={isAnswerButtonDisabled} > diff --git a/src/script/components/calling/ChooseScreen.tsx b/src/script/components/calling/ChooseScreen.tsx index fdc28ff23f4..317d56ce2f6 100644 --- a/src/script/components/calling/ChooseScreen.tsx +++ b/src/script/components/calling/ChooseScreen.tsx @@ -84,6 +84,8 @@ function ChooseScreen({choose, callState = container.resolve(CallState)}: Choose className="choose-screen-controls-button button-round button-round-dark button-round-md icon-close" data-uie-name="do-choose-screen-cancel" onClick={cancel} + aria-label={t('callChooseScreenCancel')} + title={t('callChooseScreenCancel')} > diff --git a/src/script/components/calling/GroupVideoGridTile.styles.ts b/src/script/components/calling/GroupVideoGridTile.styles.ts new file mode 100644 index 00000000000..55e38e31801 --- /dev/null +++ b/src/script/components/calling/GroupVideoGridTile.styles.ts @@ -0,0 +1,99 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {CSSObject} from '@emotion/react'; + +const participantNameColor = (isActivelySpeaking: boolean, isAudioEstablished: boolean) => { + if (!isAudioEstablished) { + return 'var(--gray-60)'; + } + if (isActivelySpeaking) { + return 'var(--app-bg-secondary)'; + } + return 'var(--white)'; +}; + +export const activelySpeakingBoxShadow = `inset 0px 0px 0px 1px var(--group-video-bg), inset 0px 0px 0px 4px var(--accent-color), inset 0px 0px 0px 7px var(--app-bg-secondary)`; + +export const groupVideoBoxShadow = (participantCount: number): string => + participantCount > 1 ? 'inset 0px 0px 0px 2px var(--group-video-bg)' : 'initial'; + +export const groupVideoTileWrapper: CSSObject = { + alignItems: 'center', + backgroundColor: 'var(--group-video-tile-bg)', + borderRadius: '10px', + display: 'flex', + height: '100%', + justifyContent: 'center', + width: '100%', +}; + +export const groupVideoActiveSpeakerTile = (isActivelySpeaking: boolean, participantCount: number): CSSObject => ({ + borderRadius: '8px', + bottom: 0, + boxShadow: isActivelySpeaking ? activelySpeakingBoxShadow : groupVideoBoxShadow(participantCount), + left: 0, + position: 'absolute', + right: 0, + top: 0, + transition: 'box-shadow 0.3s ease-in-out', +}); + +export const groupVideoActiveSpeaker = (isActivelySpeaking: boolean): CSSObject => ({ + backgroundColor: isActivelySpeaking ? 'var(--accent-color)' : 'var(--black)', +}); + +export const groupVideoParticipantNameWrapper = ( + isActivelySpeaking: boolean, + isAudioEstablished: boolean, +): CSSObject => ({ + overflow: 'hidden', + display: 'flex', + color: participantNameColor(isActivelySpeaking, isAudioEstablished), +}); + +export const groupVideoParticipantName: CSSObject = { + textOverflow: 'ellipsis', + overflow: 'hidden', +}; + +export const groupVideoParticipantAudioStatus = ( + isActivelySpeaking: boolean, + isAudioEstablished: boolean, +): CSSObject => { + const color = participantNameColor(isActivelySpeaking, isAudioEstablished); + return { + color: 'var(--participant-audio-connecting-color)', + flexShrink: 0, + '&::before': { + content: "' • '", + whiteSpace: 'pre', + color, + }, + }; +}; + +export const groupVideoElementVideo = (fitContain: boolean, mirrorSelf: boolean): CSSObject => ({ + objectFit: fitContain ? 'contain' : 'cover', + transform: mirrorSelf ? 'rotateY(180deg)' : 'initial', +}); + +export const groupVideoPauseOverlayLabel = (minimized: boolean): CSSObject => ({ + fontSize: minimized ? '0.6875rem' : '0.875rem', +}); diff --git a/src/script/components/calling/GroupVideoGridTile.tsx b/src/script/components/calling/GroupVideoGridTile.tsx index d64890cce34..afabceeb5a0 100644 --- a/src/script/components/calling/GroupVideoGridTile.tsx +++ b/src/script/components/calling/GroupVideoGridTile.tsx @@ -25,6 +25,16 @@ import {VIDEO_STATE} from '@wireapp/avs'; import {TabIndex} from '@wireapp/react-ui-kit'; import {Avatar, AVATAR_SIZE} from 'Components/Avatar'; +import { + groupVideoActiveSpeaker, + groupVideoActiveSpeakerTile, + groupVideoElementVideo, + groupVideoParticipantAudioStatus, + groupVideoParticipantName, + groupVideoParticipantNameWrapper, + groupVideoPauseOverlayLabel, + groupVideoTileWrapper, +} from 'Components/calling/GroupVideoGridTile.styles'; import * as Icon from 'Components/Icon'; import type {Participant} from 'Repositories/calling/Participant'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; @@ -42,24 +52,6 @@ interface GroupVideoGridTileProps { selfParticipant: Participant; } -const getParticipantNameColor = ({ - isActivelySpeaking, - isAudioEstablished, -}: { - isActivelySpeaking: boolean; - isAudioEstablished: boolean; -}) => { - if (!isAudioEstablished) { - return 'var(--gray-60)'; - } - - if (isActivelySpeaking) { - return 'var(--app-bg-secondary)'; - } - - return 'var(--white)'; -}; - const GroupVideoGridTile = ({ minimized, participant, @@ -95,8 +87,6 @@ const GroupVideoGridTile = ({ const hasPausedVideo = videoState === VIDEO_STATE.PAUSED; const doVideoReconnecting = videoState === VIDEO_STATE.RECONNECTING; const hasActiveVideo = (sharesCamera || sharesScreen) && !!videoStream; - const activelySpeakingBoxShadow = `inset 0px 0px 0px 1px var(--group-video-bg), inset 0px 0px 0px 4px var(--accent-color), inset 0px 0px 0px 7px var(--app-bg-secondary)`; - const groupVideoBoxShadow = participantCount > 1 ? 'inset 0px 0px 0px 2px var(--group-video-bg)' : 'initial'; const handleTileClick = () => onTileDoubleClick(participant?.user.qualifiedId, participant?.clientId); @@ -106,45 +96,21 @@ const GroupVideoGridTile = ({ } }; - const participantNameColor = getParticipantNameColor({isActivelySpeaking, isAudioEstablished}); - const nameContainer = !minimized && ( -
+
{name} {!isAudioEstablished && ( - + {t('videoCallParticipantConnecting')} )} @@ -153,13 +119,18 @@ const GroupVideoGridTile = ({ ); return ( - +
); }; diff --git a/src/script/components/calling/VideoControls/VideoControls.tsx b/src/script/components/calling/VideoControls/VideoControls.tsx index 6b24f8e82a7..4fed6c44bac 100644 --- a/src/script/components/calling/VideoControls/VideoControls.tsx +++ b/src/script/components/calling/VideoControls/VideoControls.tsx @@ -238,10 +238,10 @@ export const VideoControls = ({ const updateAudioOptions = (selectedOption: string, input: boolean) => { const microphone = input - ? audioOptions[0].options.find(({value}) => value === selectedOption) ?? selectedAudioOptions[0] + ? (audioOptions[0].options.find(({value}) => value === selectedOption) ?? selectedAudioOptions[0]) : selectedAudioOptions[0]; const speaker = !input - ? audioOptions[1].options.find(({value}) => value === selectedOption) ?? selectedAudioOptions[1] + ? (audioOptions[1].options.find(({value}) => value === selectedOption) ?? selectedAudioOptions[1]) : selectedAudioOptions[1]; setSelectedAudioOptions([microphone, speaker]); diff --git a/src/script/hooks/useConversationCall.ts b/src/script/hooks/useConversationCall.ts new file mode 100644 index 00000000000..729acbc213a --- /dev/null +++ b/src/script/hooks/useConversationCall.ts @@ -0,0 +1,77 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {useEffect, useMemo, useState} from 'react'; + +import {container} from 'tsyringe'; + +import {STATE as CALL_STATE} from '@wireapp/avs'; + +import {CallState} from 'Repositories/calling/CallState'; +import type {Conversation} from 'Repositories/entity/Conversation'; +import {useKoSubscribableChildren} from 'Util/ComponentUtil'; +import {matchQualifiedIds} from 'Util/QualifiedId'; + +interface ConversationCallState { + /** connecting/joining */ + isCallConnecting: boolean; + /** active/joined */ + isCallActive: boolean; +} + +/** + * Hook to get the call state for a specific conversation + * @param conversation - The conversation to check for calls + * @returns Call state information + */ +export const useConversationCall = (conversation: Conversation): ConversationCallState => { + const callState = container.resolve(CallState); + const {calls} = useKoSubscribableChildren(callState, ['calls']); + + const call = useMemo( + () => calls.find(call => matchQualifiedIds(call.conversation.qualifiedId, conversation.qualifiedId)), + [calls, conversation.qualifiedId], + ); + + const [currentCallState, setCurrentCallState] = useState(() => call?.state() ?? null); + + // Subscribe to the call's state changes + useEffect(() => { + if (!call) { + setCurrentCallState(null); + return () => {}; + } + + setCurrentCallState(call.state()); + + // Subscribe to state changes + const subscription = call.state.subscribe(newState => { + setCurrentCallState(newState); + }); + + return () => { + subscription.dispose(); + }; + }, [call]); + + return { + isCallConnecting: currentCallState === CALL_STATE.ANSWERED, + isCallActive: currentCallState === CALL_STATE.MEDIA_ESTAB, + }; +}; diff --git a/src/script/page/AppMain.tsx b/src/script/page/AppMain.tsx index 84cd3039f02..6c27b5b1d2c 100644 --- a/src/script/page/AppMain.tsx +++ b/src/script/page/AppMain.tsx @@ -32,6 +32,7 @@ import {ChooseScreen} from 'Components/calling/ChooseScreen'; import {ConfigToolbar} from 'Components/ConfigToolbar/ConfigToolbar'; import {ErrorFallback} from 'Components/ErrorFallback'; import {CreateConversationModal} from 'Components/Modals/CreateConversation/CreateConversaionModal'; +import {FileHistoryModal} from 'Components/Modals/FileHistoryModal/FileHistoryModal'; import {GroupCreationModal} from 'Components/Modals/GroupCreation/GroupCreationModal'; import {LegalHoldModal} from 'Components/Modals/LegalHoldModal/LegalHoldModal'; import {PrimaryModal} from 'Components/Modals/PrimaryModal'; @@ -361,6 +362,7 @@ export const AppMain = ({ + = ({ useEffect(() => { const messageTimer = isSelfDeletingMessagesEnforced ? getEnforcedSelfDeletingMessagesTimeout - : globalMessageTimer ?? 0; + : (globalMessageTimer ?? 0); setCurrentMessageTimer(messageTimer); const mappedTimes = EphemeralTimings.VALUES.map(time => ({ diff --git a/src/script/repositories/calling/CallingRepository.ts b/src/script/repositories/calling/CallingRepository.ts index 452a7e95cf4..acd22fd95b8 100644 --- a/src/script/repositories/calling/CallingRepository.ts +++ b/src/script/repositories/calling/CallingRepository.ts @@ -808,7 +808,7 @@ export class CallingRepository { id: `${Date.now()}-${id}`, emoji, left: Math.random() * 500, - from: isSelf ? t('conversationYouAccusative') : senderParticipant?.user.name() ?? '', + from: isSelf ? t('conversationYouAccusative') : (senderParticipant?.user.name() ?? ''), }; }); diff --git a/src/script/repositories/cells/CellsRepository.ts b/src/script/repositories/cells/CellsRepository.ts index 50de5c82163..25fed528c2d 100644 --- a/src/script/repositories/cells/CellsRepository.ts +++ b/src/script/repositories/cells/CellsRepository.ts @@ -156,6 +156,10 @@ export class CellsRepository { return this.apiClient.api.cells.getNode({id: uuid, flags}); } + async getNodeVersions({uuid, flags}: {uuid: string; flags?: NodeFlags[]}) { + return this.apiClient.api.cells.getNodeVersions({uuid, flags}); + } + async lookupNodeByPath({path}: {path: string}) { return this.apiClient.api.cells.lookupNodeByPath({path}); } diff --git a/src/script/repositories/conversation/EventBuilder/EventBuilder.ts b/src/script/repositories/conversation/EventBuilder/EventBuilder.ts index 752df924b51..037daa6ac09 100644 --- a/src/script/repositories/conversation/EventBuilder/EventBuilder.ts +++ b/src/script/repositories/conversation/EventBuilder/EventBuilder.ts @@ -231,8 +231,9 @@ export type CallingTimeoutEvent = ConversationEvent< >; export type FailedToAddUsersMessageEvent = ConversationEvent; -export interface ErrorEvent - extends ConversationEvent { +export interface ErrorEvent extends ConversationEvent< + CONVERSATION.UNABLE_TO_DECRYPT | CONVERSATION.INCOMING_MESSAGE_TOO_BIG +> { error: string; error_code: number | string; id: string; diff --git a/src/script/repositories/tracking/EventTrackingRepository.ts b/src/script/repositories/tracking/EventTrackingRepository.ts index 5dc00027717..4e7e61996cd 100644 --- a/src/script/repositories/tracking/EventTrackingRepository.ts +++ b/src/script/repositories/tracking/EventTrackingRepository.ts @@ -223,7 +223,7 @@ export class EventTrackingRepository { this.isProductReportingActivated = true; - const {COUNTLY_ENABLE_LOGGING, VERSION, COUNTLY_API_KEY} = Config.getConfig(); + const {COUNTLY_ENABLE_LOGGING, VERSION, COUNTLY_API_KEY, COUNTLY_SERVER_URL} = Config.getConfig(); // Initialize telemetry if it is not initialized yet if (!this.telemetryInitialized) { @@ -237,7 +237,7 @@ export class EventTrackingRepository { provider: { apiKey: COUNTLY_API_KEY, enableLogging: COUNTLY_ENABLE_LOGGING, - serverUrl: 'https://countly.wire.com/', + serverUrl: COUNTLY_SERVER_URL, autoErrorTracking: false, }, }); @@ -256,6 +256,11 @@ export class EventTrackingRepository { const device_id = Boolean(trackingId.length) ? trackingId : this.telemetryDeviceId; + if (!device_id) { + this.telemetryLogger.error('Telemetry device id is not defined'); + return; + } + telemetry.changeDeviceId(device_id); telemetry.disableOfflineMode(device_id); diff --git a/src/script/util/KeyboardUtil.ts b/src/script/util/KeyboardUtil.ts index f4529fc4cfd..6aaef27f941 100644 --- a/src/script/util/KeyboardUtil.ts +++ b/src/script/util/KeyboardUtil.ts @@ -90,7 +90,15 @@ export const handleKeyDown = ({ keys: Array<(typeof KEY)[keyof typeof KEY]>; }) => { if (keys.includes(event.key as (typeof KEY)[keyof typeof KEY])) { + if ('preventDefault' in event) { + event.preventDefault(); + } + if ('stopPropagation' in event) { + event.stopPropagation(); + } + callback(event); + return true; } return true; }; diff --git a/src/script/util/TimeUtil.ts b/src/script/util/TimeUtil.ts index cee137e5262..a6ba2019adf 100644 --- a/src/script/util/TimeUtil.ts +++ b/src/script/util/TimeUtil.ts @@ -369,3 +369,49 @@ export const formatCoarseDuration = (duration: number) => { * @returns Duration in milliseconds between now and the given date. */ export const durationFrom = (date: Date | number | string) => Date.now() - new Date(date).getTime(); + +/** + * Calculate the number of days between two dates + */ +export const calculateDaysDifference = (date1: Date, date2: Date): number => { + const day1 = new Date(date1.getFullYear(), date1.getMonth(), date1.getDate()); + const day2 = new Date(date2.getFullYear(), date2.getMonth(), date2.getDate()); + return Math.floor((day1.getTime() - day2.getTime()) / TIME_IN_MILLIS.DAY); +}; + +/** + * Get the day prefix for a version based on days difference + */ +export const getDayPrefix = (daysDiff: number, timestamp: number): string => { + if (daysDiff === 0) { + return t('fileHistoryModal.today'); + } + if (daysDiff === 1) { + return t('fileHistoryModal.yesterday'); + } + return new Intl.DateTimeFormat(navigator.language, { + weekday: 'long', + }).format(timestamp); +}; + +/** + * Format a date key for grouping file versions + */ +export const formatDateKey = (timestamp: number, dayPrefix: string): string => { + const formattedDate = new Intl.DateTimeFormat(navigator.language, { + day: 'numeric', + month: 'short', + year: 'numeric', + }).format(timestamp); + return `${dayPrefix}, ${formattedDate}`; +}; + +/** + * Format time from timestamp + */ +export const formatTime = (timestamp: number): string => { + return new Intl.DateTimeFormat(navigator.language, { + hour: 'numeric', + minute: '2-digit', + }).format(timestamp); +}; diff --git a/src/script/util/messageRenderer.test.ts b/src/script/util/messageRenderer.test.ts index 484d680b33a..ff73d4f5cff 100644 --- a/src/script/util/messageRenderer.test.ts +++ b/src/script/util/messageRenderer.test.ts @@ -181,7 +181,7 @@ describe('renderMessage', () => { it('renders an emoticon of someone shrugging', () => { /* eslint-disable no-useless-escape */ - expect(renderMessage('¯_(ツ)_/¯')).toBe('¯_(ツ)_/¯'); + expect(renderMessage('¯_(ツ)_/¯')).toBe('¯(ツ)/¯'); }); /* eslint-enable no-useless-escape */ diff --git a/src/script/util/util.ts b/src/script/util/util.ts index 378b3821949..1d964653866 100644 --- a/src/script/util/util.ts +++ b/src/script/util/util.ts @@ -143,6 +143,11 @@ export const getFileExtension = (filename: string): string => { return foundExtension || ''; }; +export const getName = (nodePath: string): string => { + const parts = nodePath.split('/'); + return parts[parts.length - 1]; +}; + export const getFileExtensionFromUrl = (url: string): string => { const cleanUrl = url.split(/[?#]/)[0]; return getFileExtension(cleanUrl); diff --git a/src/script/view_model/ListViewModel.ts b/src/script/view_model/ListViewModel.ts index ad283740ceb..2f8be3d251d 100644 --- a/src/script/view_model/ListViewModel.ts +++ b/src/script/view_model/ListViewModel.ts @@ -148,7 +148,7 @@ export class ListViewModel { amplify.subscribe(WebAppEvents.SHORTCUT.SILENCE, this.changeNotificationSetting); // todo: deprecated - remove when user base of wrappers version >= 3.4 is large enough }; - readonly answerCall = (conversationEntity: Conversation): void => { + readonly answerCall = async (conversationEntity: Conversation): Promise => { const call = this.callingRepository.findCall(conversationEntity.qualifiedId); if (!call) { @@ -156,15 +156,15 @@ export class ListViewModel { } if (call.isConference && !this.callingRepository.supportsConferenceCalling) { - PrimaryModal.show(PrimaryModal.type.ACKNOWLEDGE, { + return PrimaryModal.show(PrimaryModal.type.ACKNOWLEDGE, { text: { message: `${t('modalConferenceCallNotSupportedMessage')} ${t('modalConferenceCallNotSupportedJoinMessage')}`, title: t('modalConferenceCallNotSupportedHeadline'), }, }); - } else { - this.callingViewModel.callActions.answer(call); } + + return this.callingViewModel.callActions.answer(call); }; readonly changeNotificationSetting = () => { diff --git a/src/style/common/variables.less b/src/style/common/variables.less index 06ce9999944..37889417cfc 100644 --- a/src/style/common/variables.less +++ b/src/style/common/variables.less @@ -22,7 +22,8 @@ // ---------------------------------------------------------------------------- @font-face { font-family: emoji; - src: local('Apple Color Emoji'), local('Segoe UI Emoji'), local('Android Emoji'), local('Noto Color Emoji'), + src: + local('Apple Color Emoji'), local('Segoe UI Emoji'), local('Android Emoji'), local('Noto Color Emoji'), local('JoyPixels'), local('Twemoji'); // Define a whitelist of glyphs to render with emoji font according to standard. @@ -43,46 +44,47 @@ // 6. Convert everything to lower case. // 7. Prepend each line with `U+`. // 8. Join all lines with comma. - unicode-range: U+1f000-1f003, U+1f004, U+1f005-1f0ce, U+1f0cf, U+1f0d0-1f0ff, U+1f10d-1f10f, U+1f12f, U+1f16c-1f16f, - U+1f170-1f171, U+1f17e-1f17f, U+1f18e, U+1f191-1f19a, U+1f1ad-1f1e5, U+1f1e6-1f1ff, U+1f201, U+1f201-1f202, - U+1f203-1f20f, U+1f21a, U+1f22f, U+1f232-1f236, U+1f232-1f23a, U+1f238-1f23a, U+1f23c-1f23f, U+1f249-1f24f, - U+1f250-1f251, U+1f252-1f2ff, U+1f300-1f320, U+1f300-1f321, U+1f322-1f323, U+1f324-1f393, U+1f32d-1f335, - U+1f337-1f37c, U+1f37e-1f393, U+1f385, U+1f394-1f395, U+1f396-1f397, U+1f398, U+1f399-1f39b, U+1f39c-1f39d, - U+1f39e-1f3f0, U+1f3a0-1f3ca, U+1f3c2-1f3c4, U+1f3c7, U+1f3ca-1f3cc, U+1f3cf-1f3d3, U+1f3e0-1f3f0, U+1f3f1-1f3f2, - U+1f3f3-1f3f5, U+1f3f4, U+1f3f6, U+1f3f7-1f3fa, U+1f3f7-1f4fd, U+1f3f8-1f43e, U+1f3fb-1f3ff, U+1f400-1f4fd, U+1f440, - U+1f442-1f443, U+1f442-1f4fc, U+1f446-1f450, U+1f466-1f478, U+1f47c, U+1f481-1f483, U+1f485-1f487, U+1f48f, U+1f491, - U+1f4aa, U+1f4fe, U+1f4ff-1f53d, U+1f546-1f548, U+1f549-1f54e, U+1f54b-1f54e, U+1f54f, U+1f550-1f567, U+1f568-1f56e, - U+1f56f-1f570, U+1f571-1f572, U+1f573-1f579, U+1f574-1f575, U+1f57a, U+1f57b-1f586, U+1f587, U+1f588-1f589, - U+1f58a-1f58d, U+1f58e-1f58f, U+1f590, U+1f591-1f594, U+1f595-1f596, U+1f597-1f5a3, U+1f5a4, U+1f5a5, U+1f5a6-1f5a7, - U+1f5a8, U+1f5a9-1f5b0, U+1f5b1-1f5b2, U+1f5b3-1f5bb, U+1f5bc, U+1f5bd-1f5c1, U+1f5c2-1f5c4, U+1f5c5-1f5d0, - U+1f5d1-1f5d3, U+1f5d4-1f5db, U+1f5dc-1f5de, U+1f5df-1f5e0, U+1f5e1, U+1f5e2, U+1f5e3, U+1f5e4-1f5e7, U+1f5e8, - U+1f5e9-1f5ee, U+1f5ef, U+1f5f0-1f5f2, U+1f5f3, U+1f5f4-1f5f9, U+1f5fa-1f64f, U+1f5fb-1f64f, U+1f645-1f647, - U+1f64b-1f64f, U+1f680-1f6c5, U+1f6a3, U+1f6b4-1f6b6, U+1f6c0, U+1f6c6-1f6ca, U+1f6cb-1f6d0, U+1f6cc, U+1f6d0, - U+1f6d1-1f6d2, U+1f6d3-1f6d4, U+1f6d5, U+1f6d6-1f6df, U+1f6e0-1f6e5, U+1f6e6-1f6e8, U+1f6e9, U+1f6ea, U+1f6eb-1f6ec, - U+1f6ed-1f6ef, U+1f6f0, U+1f6f1-1f6f2, U+1f6f3, U+1f6f4-1f6f6, U+1f6f7-1f6f8, U+1f6f9, U+1f6fa, U+1f6fb-1f6ff, - U+1f774-1f77f, U+1f7d5-1f7df, U+1f7e0-1f7eb, U+1f7ec-1f7ff, U+1f80c-1f80f, U+1f848-1f84f, U+1f85a-1f85f, - U+1f888-1f88f, U+1f8ae-1f8ff, U+1f90c, U+1f90d-1f90f, U+1f90f, U+1f910-1f918, U+1f918, U+1f919-1f91e, U+1f91f, - U+1f920-1f927, U+1f926, U+1f928-1f92f, U+1f930, U+1f931-1f932, U+1f933-1f939, U+1f933-1f93a, U+1f93c-1f93e, U+1f93f, - U+1f940-1f945, U+1f947-1f94b, U+1f94c, U+1f94d-1f94f, U+1f950-1f95e, U+1f95f-1f96b, U+1f96c-1f970, U+1f971, U+1f972, - U+1f973-1f976, U+1f977-1f979, U+1f97a, U+1f97b, U+1f97c-1f97f, U+1f980-1f984, U+1f985-1f991, U+1f992-1f997, - U+1f998-1f9a2, U+1f9a3-1f9a4, U+1f9a5-1f9aa, U+1f9ab-1f9ad, U+1f9ae-1f9af, U+1f9b0-1f9b3, U+1f9b0-1f9b9, - U+1f9b5-1f9b6, U+1f9b8-1f9b9, U+1f9ba-1f9bf, U+1f9bb, U+1f9c0, U+1f9c1-1f9c2, U+1f9c3-1f9ca, U+1f9cb-1f9cc, - U+1f9cd-1f9cf, U+1f9d0-1f9e6, U+1f9d1-1f9dd, U+1f9e7-1f9ff, U+1fa00-1fa6f, U+1fa70-1fa73, U+1fa74-1fa77, - U+1fa78-1fa7a, U+1fa7b-1fa7f, U+1fa80-1fa82, U+1fa83-1fa8f, U+1fa90-1fa95, U+1fa96-1fffd, U+200d, U+203c, U+2049, - U+20e3, U+2139, U+2194-2199, U+21a9-21aa, U+231a-231b, U+2328, U+2388, U+23cf, U+23e9-23ec, U+23e9-23f3, U+23f0, - U+23f3, U+23f8-23fa, U+24c2, U+25aa-25ab, U+25b6, U+25c0, U+25fb-25fe, U+25fd-25fe, U+2600-2604, U+2605, U+2607-260d, - U+260e, U+260f-2610, U+2611, U+2612, U+2614-2615, U+2616-2617, U+2618, U+2619-261c, U+261d, U+261e-261f, U+2620, - U+2621, U+2622-2623, U+2624-2625, U+2626, U+2627-2629, U+262a, U+262b-262d, U+262e-262f, U+2630-2637, U+2638-263a, - U+263b-263f, U+2640, U+2641, U+2642, U+2643-2647, U+2648-2653, U+2654-265e, U+265f, U+2660, U+2661-2662, U+2663, - U+2664, U+2665-2666, U+2667, U+2668, U+2669-267a, U+267b, U+267c-267d, U+267e, U+267f, U+2680-2685, U+2690-2691, - U+2692-2694, U+2693, U+2695, U+2696-2697, U+2698, U+2699, U+269a, U+269b-269c, U+269d-269f, U+26a0-26a1, U+26a1, - U+26a2-26a9, U+26aa-26ab, U+26ac-26af, U+26b0-26b1, U+26b2-26bc, U+26bd-26be, U+26bf-26c3, U+26c4-26c5, U+26c6-26c7, - U+26c8, U+26c9-26cd, U+26ce, U+26ce-26cf, U+26d0, U+26d1, U+26d2, U+26d3-26d4, U+26d4, U+26d5-26e8, U+26e9-26ea, - U+26ea, U+26eb-26ef, U+26f0-26f5, U+26f2-26f3, U+26f5, U+26f6, U+26f7-26fa, U+26f9, U+26fa, U+26fb-26fc, U+26fd, - U+26fe-2701, U+2702, U+2703-2704, U+2705, U+2708-270d, U+270a-270b, U+270a-270d, U+270e, U+270f, U+2710-2711, U+2712, - U+2714, U+2716, U+271d, U+2721, U+2728, U+2733-2734, U+2744, U+2747, U+274c, U+274e, U+2753-2755, U+2757, - U+2763-2764, U+2765-2767, U+2795-2797, U+27a1, U+27b0, U+27bf, U+2934-2935, U+2b05-2b07, U+2b1b-2b1c, U+2b50, U+2b55, - U+3030, U+303d, U+3297, U+3299, U+e0020-e007f, U+fe0f; + unicode-range: + U+1f000-1f003, U+1f004, U+1f005-1f0ce, U+1f0cf, U+1f0d0-1f0ff, U+1f10d-1f10f, U+1f12f, U+1f16c-1f16f, U+1f170-1f171, + U+1f17e-1f17f, U+1f18e, U+1f191-1f19a, U+1f1ad-1f1e5, U+1f1e6-1f1ff, U+1f201, U+1f201-1f202, U+1f203-1f20f, U+1f21a, + U+1f22f, U+1f232-1f236, U+1f232-1f23a, U+1f238-1f23a, U+1f23c-1f23f, U+1f249-1f24f, U+1f250-1f251, U+1f252-1f2ff, + U+1f300-1f320, U+1f300-1f321, U+1f322-1f323, U+1f324-1f393, U+1f32d-1f335, U+1f337-1f37c, U+1f37e-1f393, U+1f385, + U+1f394-1f395, U+1f396-1f397, U+1f398, U+1f399-1f39b, U+1f39c-1f39d, U+1f39e-1f3f0, U+1f3a0-1f3ca, U+1f3c2-1f3c4, + U+1f3c7, U+1f3ca-1f3cc, U+1f3cf-1f3d3, U+1f3e0-1f3f0, U+1f3f1-1f3f2, U+1f3f3-1f3f5, U+1f3f4, U+1f3f6, U+1f3f7-1f3fa, + U+1f3f7-1f4fd, U+1f3f8-1f43e, U+1f3fb-1f3ff, U+1f400-1f4fd, U+1f440, U+1f442-1f443, U+1f442-1f4fc, U+1f446-1f450, + U+1f466-1f478, U+1f47c, U+1f481-1f483, U+1f485-1f487, U+1f48f, U+1f491, U+1f4aa, U+1f4fe, U+1f4ff-1f53d, + U+1f546-1f548, U+1f549-1f54e, U+1f54b-1f54e, U+1f54f, U+1f550-1f567, U+1f568-1f56e, U+1f56f-1f570, U+1f571-1f572, + U+1f573-1f579, U+1f574-1f575, U+1f57a, U+1f57b-1f586, U+1f587, U+1f588-1f589, U+1f58a-1f58d, U+1f58e-1f58f, U+1f590, + U+1f591-1f594, U+1f595-1f596, U+1f597-1f5a3, U+1f5a4, U+1f5a5, U+1f5a6-1f5a7, U+1f5a8, U+1f5a9-1f5b0, U+1f5b1-1f5b2, + U+1f5b3-1f5bb, U+1f5bc, U+1f5bd-1f5c1, U+1f5c2-1f5c4, U+1f5c5-1f5d0, U+1f5d1-1f5d3, U+1f5d4-1f5db, U+1f5dc-1f5de, + U+1f5df-1f5e0, U+1f5e1, U+1f5e2, U+1f5e3, U+1f5e4-1f5e7, U+1f5e8, U+1f5e9-1f5ee, U+1f5ef, U+1f5f0-1f5f2, U+1f5f3, + U+1f5f4-1f5f9, U+1f5fa-1f64f, U+1f5fb-1f64f, U+1f645-1f647, U+1f64b-1f64f, U+1f680-1f6c5, U+1f6a3, U+1f6b4-1f6b6, + U+1f6c0, U+1f6c6-1f6ca, U+1f6cb-1f6d0, U+1f6cc, U+1f6d0, U+1f6d1-1f6d2, U+1f6d3-1f6d4, U+1f6d5, U+1f6d6-1f6df, + U+1f6e0-1f6e5, U+1f6e6-1f6e8, U+1f6e9, U+1f6ea, U+1f6eb-1f6ec, U+1f6ed-1f6ef, U+1f6f0, U+1f6f1-1f6f2, U+1f6f3, + U+1f6f4-1f6f6, U+1f6f7-1f6f8, U+1f6f9, U+1f6fa, U+1f6fb-1f6ff, U+1f774-1f77f, U+1f7d5-1f7df, U+1f7e0-1f7eb, + U+1f7ec-1f7ff, U+1f80c-1f80f, U+1f848-1f84f, U+1f85a-1f85f, U+1f888-1f88f, U+1f8ae-1f8ff, U+1f90c, U+1f90d-1f90f, + U+1f90f, U+1f910-1f918, U+1f918, U+1f919-1f91e, U+1f91f, U+1f920-1f927, U+1f926, U+1f928-1f92f, U+1f930, + U+1f931-1f932, U+1f933-1f939, U+1f933-1f93a, U+1f93c-1f93e, U+1f93f, U+1f940-1f945, U+1f947-1f94b, U+1f94c, + U+1f94d-1f94f, U+1f950-1f95e, U+1f95f-1f96b, U+1f96c-1f970, U+1f971, U+1f972, U+1f973-1f976, U+1f977-1f979, U+1f97a, + U+1f97b, U+1f97c-1f97f, U+1f980-1f984, U+1f985-1f991, U+1f992-1f997, U+1f998-1f9a2, U+1f9a3-1f9a4, U+1f9a5-1f9aa, + U+1f9ab-1f9ad, U+1f9ae-1f9af, U+1f9b0-1f9b3, U+1f9b0-1f9b9, U+1f9b5-1f9b6, U+1f9b8-1f9b9, U+1f9ba-1f9bf, U+1f9bb, + U+1f9c0, U+1f9c1-1f9c2, U+1f9c3-1f9ca, U+1f9cb-1f9cc, U+1f9cd-1f9cf, U+1f9d0-1f9e6, U+1f9d1-1f9dd, U+1f9e7-1f9ff, + U+1fa00-1fa6f, U+1fa70-1fa73, U+1fa74-1fa77, U+1fa78-1fa7a, U+1fa7b-1fa7f, U+1fa80-1fa82, U+1fa83-1fa8f, + U+1fa90-1fa95, U+1fa96-1fffd, U+200d, U+203c, U+2049, U+20e3, U+2139, U+2194-2199, U+21a9-21aa, U+231a-231b, U+2328, + U+2388, U+23cf, U+23e9-23ec, U+23e9-23f3, U+23f0, U+23f3, U+23f8-23fa, U+24c2, U+25aa-25ab, U+25b6, U+25c0, + U+25fb-25fe, U+25fd-25fe, U+2600-2604, U+2605, U+2607-260d, U+260e, U+260f-2610, U+2611, U+2612, U+2614-2615, + U+2616-2617, U+2618, U+2619-261c, U+261d, U+261e-261f, U+2620, U+2621, U+2622-2623, U+2624-2625, U+2626, + U+2627-2629, U+262a, U+262b-262d, U+262e-262f, U+2630-2637, U+2638-263a, U+263b-263f, U+2640, U+2641, U+2642, + U+2643-2647, U+2648-2653, U+2654-265e, U+265f, U+2660, U+2661-2662, U+2663, U+2664, U+2665-2666, U+2667, U+2668, + U+2669-267a, U+267b, U+267c-267d, U+267e, U+267f, U+2680-2685, U+2690-2691, U+2692-2694, U+2693, U+2695, + U+2696-2697, U+2698, U+2699, U+269a, U+269b-269c, U+269d-269f, U+26a0-26a1, U+26a1, U+26a2-26a9, U+26aa-26ab, + U+26ac-26af, U+26b0-26b1, U+26b2-26bc, U+26bd-26be, U+26bf-26c3, U+26c4-26c5, U+26c6-26c7, U+26c8, U+26c9-26cd, + U+26ce, U+26ce-26cf, U+26d0, U+26d1, U+26d2, U+26d3-26d4, U+26d4, U+26d5-26e8, U+26e9-26ea, U+26ea, U+26eb-26ef, + U+26f0-26f5, U+26f2-26f3, U+26f5, U+26f6, U+26f7-26fa, U+26f9, U+26fa, U+26fb-26fc, U+26fd, U+26fe-2701, U+2702, + U+2703-2704, U+2705, U+2708-270d, U+270a-270b, U+270a-270d, U+270e, U+270f, U+2710-2711, U+2712, U+2714, U+2716, + U+271d, U+2721, U+2728, U+2733-2734, U+2744, U+2747, U+274c, U+274e, U+2753-2755, U+2757, U+2763-2764, U+2765-2767, + U+2795-2797, U+27a1, U+27b0, U+27bf, U+2934-2935, U+2b05-2b07, U+2b1b-2b1c, U+2b50, U+2b55, U+3030, U+303d, U+3297, + U+3299, U+e0020-e007f, U+fe0f; } // ---------------------------------------------------------------------------- diff --git a/src/types/i18n.d.ts b/src/types/i18n.d.ts index 7bdae519da5..f3f0be27027 100644 --- a/src/types/i18n.d.ts +++ b/src/types/i18n.d.ts @@ -274,8 +274,6 @@ declare module 'I18n/en-US.json' { 'authLoginTitle': `Log in`; 'authPlaceholderEmail': `Email`; 'authPlaceholderPassword': `Password`; - 'showTogglePasswordLabel': `Show password`; - 'hideTogglePasswordLabel': `Hide password`; 'authPostedResend': `Resend to {email}`; 'authPostedResendAction': `No email showing up?`; 'authPostedResendDetail': `Check your email inbox and follow the instructions.`; @@ -333,6 +331,7 @@ declare module 'I18n/en-US.json' { 'backupTryAgain': `Try Again`; 'buttonActionError': `Your answer can\'t be sent, please retry`; 'callAccept': `Accept`; + 'callChooseScreenCancel': `Close-screen sharing`; 'callChooseSharedScreen': `Choose a screen to share`; 'callChooseSharedWindow': `Choose a window to share`; 'callConversationAcceptOrDecline': `{conversationName} is calling. Press control + enter to accept the call or press control + shift + enter to decline the call.`; @@ -434,14 +433,15 @@ declare module 'I18n/en-US.json' { 'cells.options.delete': `Delete`; 'cells.options.deletePermanently': `Delete permanently`; 'cells.options.download': `Download`; + 'cells.options.edit': `Edit`; 'cells.options.label': `More options`; 'cells.options.move': `Move to folder`; 'cells.options.open': `Open`; + 'cells.options.versionHistory': `Version History`; 'cells.options.rename': `Rename`; 'cells.options.restore': `Restore`; 'cells.options.share': `Share`; 'cells.options.tags': `Add or Remove Tags`; - 'cells.options.edit': `Edit`; 'cells.pagination.loadMoreResults': `Load More Items`; 'cells.pagination.nextPage': `Next Page`; 'cells.pagination.previousPage': `Previous Page`; @@ -461,6 +461,22 @@ declare module 'I18n/en-US.json' { 'cells.renameNodeModal.nameRequired': `Name is required`; 'cells.renameNodeModal.placeholder': `Enter a name`; 'cells.renameNodeModal.saveButton': `Save`; + 'cells.versionHistory.title': `Version History`; + 'cells.versionHistory.current': `Current`; + 'cells.versionHistory.download': `Download`; + 'cells.versionHistory.restore': `Restore`; + 'cells.versionHistory.downloadAriaLabel': `Download version from {time}`; + 'cells.versionHistory.restoreAriaLabel': `Restore version from {time}`; + 'cells.versionHistory.restoreModal.title': `Restore version`; + 'cells.versionHistory.restoreModal.description': `This copies the restored version and sets it as the current one. All previous versions remain available.`; + 'cells.versionHistory.restoreModal.cancel': `Cancel`; + 'cells.versionHistory.restoreModal.confirm': `Restore`; + 'cells.versionHistory.closeAriaLabel': `Close`; + 'fileHistoryModal.today': `Today`; + 'fileHistoryModal.yesterday': `Yesterday`; + 'fileHistoryModal.failedToLoadVersions': `Failed to load versions`; + 'fileHistoryModal.invalidNodeData': `Invalid file data`; + 'fileHistoryModal.failedToRestore': `Failed to restore file version`; 'cells.restore.error': `Something went wrong, please try again later and refresh the list.`; 'cells.restoreNestedNodeModal.button': `Restore Parent Folder`; 'cells.restoreNestedNodeModal.description1': `You can’t restore folders or files from a deleted folder. To reuse the desired item, you must restore its parent folder.`; @@ -1094,6 +1110,7 @@ declare module 'I18n/en-US.json' { 'guestRoomToggleInfoExtended': `Open this conversation to people outside your team. You can always change it later.`; 'guestRoomToggleInfoHead': `Guest Links`; 'guestRoomToggleName': `Allow Guests`; + 'hideTogglePasswordLabel': `Hide password`; 'historyInfo.learnMore': `Learn more`; 'historyInfo.noHistoryHeadline': `It’s the first time you’re using {brandName} on this device.`; 'historyInfo.noHistoryInfo': `For privacy reasons, your history will not appear here.`; @@ -1825,6 +1842,7 @@ declare module 'I18n/en-US.json' { 'setPassword.button': `Set password`; 'setPassword.headline': `Set password`; 'setPassword.passwordPlaceholder': `Password`; + 'showTogglePasswordLabel': `Show password`; 'ssoLogin.codeInputPlaceholder': `SSO code`; 'ssoLogin.codeOrMailInputPlaceholder': `Email or SSO code`; 'ssoLogin.headline': `Company log in`; @@ -1975,11 +1993,11 @@ declare module 'I18n/en-US.json' { 'userRemainingTimeHours': `{time}h left`; 'userRemainingTimeMinutes': `Less than {time}m left`; 'verify.changeEmail': `Change email`; + 'verify.codeLabel': `Six-digit code`; + 'verify.codePlaceholder': `Input field, enter digit`; 'verify.headline': `You’ve got mail`; 'verify.resendCode': `Resend code`; 'verify.subhead': `Enter the six-digit verification code we sent to{newline}{email}`; - 'verify.codeLabel': `Six-digit code`; - 'verify.codePlaceholder': `Input field, enter digit`; 'videoCallMenuMoreAddReaction': `Add reaction`; 'videoCallMenuMoreAudioSettings': `Audio Settings`; 'videoCallMenuMoreChangeView': `Change view`; diff --git a/test/e2e_tests/README.md b/test/e2e_tests/README.md index 936bf47f3c8..d0314c06507 100644 --- a/test/e2e_tests/README.md +++ b/test/e2e_tests/README.md @@ -1,6 +1,6 @@ ## Please refer to [Playwright doc](https://playwright.dev/docs/intro) for detailed overview of the framework, troubleshooting, and best practices. -# Requirements beyond the base project +## Requirements beyond the base project Have 1Password's cli installed (op) @@ -12,13 +12,22 @@ op inject -i test/e2e_tests/.env.staging.tpl -o test/e2e_tests/.env It will generate .env file with variables from 1Password -# Tests +### Running the Testservice + +Some of the E2E tests require a connection to a running [Testservice](https://github.com/wireapp/kalium/tree/develop/tools/testservice). The default address stored in 1Password is for an instance running on prem. If you're within the Wire VPN you can just use it. + +If you're not in the Wire VPN you can run it locally as a docker container: + +1. Start it by running `docker run -d --platform linux/amd64 -p 8080:8080 -p 8081:8081 quay.io/wire/testservice` +2. Update the env var `TEST_SERVICE_URL` in `test/e2e_tests/.env` to point to it: `TEST_SERVICE_URL=http://localhost:8080`` + +## Tests E2E tests can be found inside [test folder](/test/e2e_tests/). The folder contains [page objects](/test/e2e_tests/pages), [backend classes](/test/e2e_tests/backend), and [credentialsReader.ts](/test//e2e_tests/utils/credentialsReader.ts) for access 1Password credentials. [Playwright config can be found in the root folder of the repo](/playwright.config.ts) -## Running the tests +### Running the tests E2E tests can be run via diff --git a/test/e2e_tests/pageManager/index.ts b/test/e2e_tests/pageManager/index.ts index c0455e0824d..721d0920f40 100644 --- a/test/e2e_tests/pageManager/index.ts +++ b/test/e2e_tests/pageManager/index.ts @@ -88,7 +88,7 @@ const teamManagementPath = process.env.TEAM_MANAGEMENT_URL ?? ''; export class PageManager { private readonly cache = new Map(); - constructor(private readonly page: Page) {} + constructor(readonly page: Page) {} static from(page: Page): PageManager; static from(page: Promise): Promise; @@ -137,10 +137,6 @@ export class PageManager { return this.page.context(); }; - getPage = async () => { - return await this.page; - }; - waitForRequest = (url: string) => { return this.page.waitForRequest(url); }; diff --git a/test/e2e_tests/pageManager/webapp/cells/cellsFileDetailView.modal.ts b/test/e2e_tests/pageManager/webapp/cells/cellsFileDetailView.modal.ts index d64eca9d515..c48be6b4aad 100644 --- a/test/e2e_tests/pageManager/webapp/cells/cellsFileDetailView.modal.ts +++ b/test/e2e_tests/pageManager/webapp/cells/cellsFileDetailView.modal.ts @@ -31,7 +31,7 @@ export class CellsFileDetailViewModal { this.page = page; this.closeButton = page.locator("[aria-label='Close']"); this.downloadButton = page.locator("[aria-label='Download']"); - this.image = page.locator("[role='dialog'][aria-modal='true'][id^=':'][id$=':'] img"); + this.image = page.locator("[role='dialog'][aria-modal='true'] img"); } async isImageVisible() { diff --git a/test/e2e_tests/pageManager/webapp/pages/login.page.ts b/test/e2e_tests/pageManager/webapp/pages/login.page.ts index 11d371ae4be..63c389fc7df 100644 --- a/test/e2e_tests/pageManager/webapp/pages/login.page.ts +++ b/test/e2e_tests/pageManager/webapp/pages/login.page.ts @@ -21,8 +21,6 @@ import type {Page, Locator} from '@playwright/test'; import type {User} from 'test/e2e_tests/data/user'; -import {webAppPath} from '../..'; - export class LoginPage { readonly page: Page; @@ -46,11 +44,5 @@ export class LoginPage { await this.emailInput.fill(user.email); await this.passwordInput.fill(user.password); await this.signInButton.click(); - - /** - * Since the login may take up to 40s we manually wait for it to finish here instead of increasing the timeout on all actions / assertions after this util - * This is an exception to the general best practice of using playwrights web assertions. (See: https://playwright.dev/docs/best-practices#use-web-first-assertions) - */ - await this.page.waitForURL(new RegExp(`^${webAppPath}$`), {timeout: 40_000, waitUntil: 'networkidle'}); } } diff --git a/test/e2e_tests/specs/Accessibility/Accessibility.spec.ts b/test/e2e_tests/specs/Accessibility/Accessibility.spec.ts index 3273a28701a..f9d9a7189c1 100644 --- a/test/e2e_tests/specs/Accessibility/Accessibility.spec.ts +++ b/test/e2e_tests/specs/Accessibility/Accessibility.spec.ts @@ -125,23 +125,27 @@ test.describe('Accessibility', () => { }, ); - test('I want to see collapsed view when app is narrow', {tag: ['@TC-48', '@regression']}, async ({pageManager}) => { - await (await pageManager.getPage()).setViewportSize(narrowViewport); + test( + 'I want to see collapsed view when app is narrow', + {tag: ['@TC-48', '@regression']}, + async ({page, pageManager}) => { + await page.setViewportSize(narrowViewport); - await pageManager.openMainPage(); - await loginUser(memberA, pageManager); - const {components, pages} = pageManager.webapp; + await pageManager.openMainPage(); + await loginUser(memberA, pageManager); + const {components, pages} = pageManager.webapp; - await pages.historyInfo().clickConfirmButton(); - await components.conversationSidebar().sidebar.waitFor({state: 'visible', timeout: loginTimeOut}); + await pages.historyInfo().clickConfirmButton(); + await components.conversationSidebar().sidebar.waitFor({state: 'visible', timeout: loginTimeOut}); - await expect(components.conversationSidebar().sidebar).toHaveAttribute('data-is-collapsed', 'true'); - }); + await expect(components.conversationSidebar().sidebar).toHaveAttribute('data-is-collapsed', 'true'); + }, + ); test( 'I should not lose a drafted message when switching between conversations in collapsed view', {tag: ['@TC-51', '@regression']}, - async ({pageManager}) => { + async ({page, pageManager}) => { const message = 'test'; const {components, modals, pages} = pageManager.webapp; await pageManager.openMainPage(); @@ -153,7 +157,6 @@ test.describe('Accessibility', () => { await createGroup(pages, conversationName, [memberB]); await pages.conversation().typeMessage(message); - const page = await pageManager.getPage(); await components.conversationSidebar().clickConnectButton(); diff --git a/test/e2e_tests/specs/AccountSettingsSpecs/accountSettings.spec.ts b/test/e2e_tests/specs/AccountSettingsSpecs/accountSettings.spec.ts index 36670ebf2ec..1de4cf679b4 100644 --- a/test/e2e_tests/specs/AccountSettingsSpecs/accountSettings.spec.ts +++ b/test/e2e_tests/specs/AccountSettingsSpecs/accountSettings.spec.ts @@ -185,7 +185,7 @@ test.describe('account settings', () => { test( 'Verify I can retrieve calling logs', {tag: ['@TC-1725', '@regression']}, - async ({pageManager: memberPageManagerA, browser, api}) => { + async ({page, pageManager: memberPageManagerA, browser}) => { const consoleMessages: string[] = []; const memberContext = await browser.newContext(); @@ -193,8 +193,6 @@ test.describe('account settings', () => { const memberPageManagerB = new PageManager(memberPage); const {pages, components, modals} = memberPageManagerA.webapp; - const page = await memberPageManagerA.getPage(); - page.on('console', msg => { consoleMessages.push(msg.text()); }); @@ -229,7 +227,7 @@ test.describe('account settings', () => { test( 'I want to see the Full Name wherever my name gets displayed', {tag: ['@TC-1948', '@regression']}, - async ({pageManager, api}) => { + async ({page, pageManager, api}) => { const groupName = 'test group'; const {components, pages} = pageManager.webapp; @@ -258,7 +256,7 @@ test.describe('account settings', () => { await api.brig.unlockChannelFeature(owner.teamId); await api.brig.enableChannelsFeature(owner.teamId); - await (await pageManager.getPage()).reload(); + await page.reload(); await createChannel(pages, 'test', [memberB]); diff --git a/test/e2e_tests/specs/AppLock/AppLock.spec.ts b/test/e2e_tests/specs/AppLock/AppLock.spec.ts index 7763ba0dc6d..21b4ddab9c0 100644 --- a/test/e2e_tests/specs/AppLock/AppLock.spec.ts +++ b/test/e2e_tests/specs/AppLock/AppLock.spec.ts @@ -41,7 +41,7 @@ test.describe('AppLock', () => { test( 'I want to see app lock setup modal on login after app lock has been enforced for the team', {tag: ['@TC-2744', '@TC-2740', '@regression']}, - async ({pageManager}) => { + async ({page, pageManager}) => { const {modals} = pageManager.webapp; await completeLogin(pageManager, memberA); @@ -49,7 +49,6 @@ test.describe('AppLock', () => { await test.step('Web: I should not be able to close app lock setup modal if app lock is enforced', async () => { // click outside the modal - const page = await pageManager.getPage(); await page.mouse.click(200, 350); // check if the modal still there expect(await modals.appLock().isVisible()).toBeTruthy(); @@ -60,9 +59,8 @@ test.describe('AppLock', () => { test( 'Web: App should not lock if I switch back to webapp tab in time (during inactivity timeout)', {tag: ['@TC-2752', '@TC-2753', '@regression']}, - async ({pageManager, browser}) => { + async ({page: webappPageA, pageManager, browser}) => { const {modals} = pageManager.webapp; - const webappPageA = await pageManager.getPage(); await completeLogin(pageManager, memberA); await handleAppLockState(pageManager, appLockPassCode); @@ -87,7 +85,7 @@ test.describe('AppLock', () => { test( 'Web: I want to unlock the app with passphrase after login', {tag: ['@TC-2754', '@TC-2755', '@TC-2758', '@TC-2763', '@regression']}, - async ({pageManager}) => { + async ({page, pageManager}) => { const {modals, pages} = pageManager.webapp; await completeLogin(pageManager, memberA); @@ -110,14 +108,14 @@ test.describe('AppLock', () => { await modals.appLock().clickReset(); await modals.appLock().inputUserPassword('wrong password'); - expect(await checkAnyIndexedDBExists(await pageManager.getPage())).toBeTruthy(); + expect(await checkAnyIndexedDBExists(page)).toBeTruthy(); }); await test.step('I want to wipe database when I forgot my app lock passphrase', async () => { await modals.appLock().inputUserPassword(memberA.password); await expect(pages.singleSignOn().ssoCodeEmailInput).toBeVisible(); - expect(await checkAnyIndexedDBExists(await pageManager.getPage())).toBeFalsy(); + expect(await checkAnyIndexedDBExists(page)).toBeFalsy(); }); }, ); @@ -125,12 +123,11 @@ test.describe('AppLock', () => { test( 'I should not be able to switch off app lock if it is enforced for the team', {tag: ['@TC-2770', '@TC-2767', '@regression']}, - async ({pageManager}) => { + async ({page, pageManager}) => { const {components, pages} = pageManager.webapp; await completeLogin(pageManager, memberA); await handleAppLockState(pageManager, appLockPassCode); await components.conversationSidebar().clickPreferencesButton(); - const page = await pageManager.getPage(); await expect(pages.account().appLockCheckbox).toBeDisabled(); // check here string diff --git a/test/e2e_tests/specs/Authentication/authentication.spec.ts b/test/e2e_tests/specs/Authentication/authentication.spec.ts index 6c8c0c21b5a..82c52f4feed 100644 --- a/test/e2e_tests/specs/Authentication/authentication.spec.ts +++ b/test/e2e_tests/specs/Authentication/authentication.spec.ts @@ -48,12 +48,13 @@ test.describe('Authentication', () => { test( 'I want to be asked to share telemetry data when I log in', {tag: ['@TC-8780', '@regression']}, - async ({pageManager, createUser}) => { + async ({page, pageManager, createUser}) => { const {pages, modals} = pageManager.webapp; const user = await createUser({disableTelemetry: false}); await pageManager.openLoginPage(); await pages.login().login(user); + await page.waitForURL(webAppPath, {timeout: 30_000, waitUntil: 'networkidle'}); await expect(modals.dataShareConsent().modalTitle).toBeVisible(); }, @@ -76,7 +77,7 @@ test.describe('Authentication', () => { test( 'Verify current browser is set as temporary device', {tag: ['@TC-3460', '@regression']}, - async ({pageManager, createUser}) => { + async ({page, pageManager, createUser}) => { const user = await createUser(); const {pages, components} = pageManager.webapp; @@ -85,6 +86,7 @@ test.describe('Authentication', () => { await pages.login().publicComputerCheckbox.click(); await pages.login().login(user); await pages.historyInfo().clickConfirmButton(); + await page.waitForURL(webAppPath, {timeout: 30_000, waitUntil: 'networkidle'}); }); let proteusId: string; @@ -104,6 +106,7 @@ test.describe('Authentication', () => { await test.step('Log in again on non public computer', async () => { await pageManager.openLoginPage(); await pages.login().login(user); + await page.waitForURL(webAppPath, {timeout: 30_000, waitUntil: 'networkidle'}); }); await test.step("Open device settings and ensure the public computer isn't active and the ID was re-generated", async () => { @@ -136,7 +139,7 @@ test.describe('Authentication', () => { test( `I want to keep my history after refreshing the page on ${deviceType} device`, {tag: [tag, '@regression']}, - async ({pageManager, createTeam}) => { + async ({page, pageManager, createTeam}) => { const {pages} = pageManager.webapp; const team = await createTeam('Test Team', {withMembers: 1}); const userA = team.owner; @@ -153,6 +156,7 @@ test.describe('Authentication', () => { await pages.login().login(userA); } + await page.waitForURL(webAppPath, {timeout: 30_000, waitUntil: 'networkidle'}); await connectWithUser(pageManager, userB); }); @@ -192,7 +196,7 @@ test.describe('Authentication', () => { test( 'Make sure user does not see data of user of previous sessions on same browser', {tag: ['@TC-1311', '@regression']}, - async ({pageManager, createTeam}) => { + async ({page, pageManager, createTeam}) => { const {pages, components} = pageManager.webapp; const team = await createTeam('Test Team', {withMembers: 1}); const userA = team.owner; @@ -203,6 +207,7 @@ test.describe('Authentication', () => { await pages.login().publicComputerCheckbox.click(); await pages.login().login(userA); await pages.historyInfo().clickConfirmButton(); + await page.waitForURL(webAppPath, {timeout: 30_000, waitUntil: 'networkidle'}); }); await test.step('Connect with and send message to userB', async () => { @@ -221,6 +226,7 @@ test.describe('Authentication', () => { await pageManager.openLoginPage(); await pages.login().login(userA); await pages.historyInfo().clickConfirmButton(); + await page.waitForURL(webAppPath, {timeout: 30_000, waitUntil: 'networkidle'}); }); await test.step('Verify previously sent message is gone', async () => { @@ -241,8 +247,7 @@ test.describe('Authentication', () => { pages: device2Pages, modals: device2Modals, components: device2Components, - } = PageManager.from(await createPage(withLogin(user))).webapp; - await device2Pages.historyInfo().clickConfirmButton(); + } = PageManager.from(await createPage(withLogin(user, {confirmNewHistory: true}))).webapp; await device2Components.conversationSidebar().clickPreferencesButton(); await device2Pages.settings().devicesButton.click(); diff --git a/test/e2e_tests/specs/CriticalFlow/Cells/uploadingFileInGroupConversation.spec.ts b/test/e2e_tests/specs/CriticalFlow/Cells/uploadingFileInGroupConversation.spec.ts index 8917f673fd6..a2fe989355b 100644 --- a/test/e2e_tests/specs/CriticalFlow/Cells/uploadingFileInGroupConversation.spec.ts +++ b/test/e2e_tests/specs/CriticalFlow/Cells/uploadingFileInGroupConversation.spec.ts @@ -40,7 +40,7 @@ const imageFilePath = getImageFilePath(); test( 'Uploading an file in a group conversation', - {tag: ['@crit-flow-cells']}, + {tag: ['@crit-flow-cells', '@regression']}, async ({pageManager: userAPageManager, browser, api}) => { const {pages: userAPages, modals: userAModals, components: userAComponents} = userAPageManager.webapp; diff --git a/test/e2e_tests/specs/CriticalFlow/addMembersToChat-TC-8631.spec.ts b/test/e2e_tests/specs/CriticalFlow/addMembersToChat-TC-8631.spec.ts index 9cbcb198962..9b07fbcf67a 100644 --- a/test/e2e_tests/specs/CriticalFlow/addMembersToChat-TC-8631.spec.ts +++ b/test/e2e_tests/specs/CriticalFlow/addMembersToChat-TC-8631.spec.ts @@ -43,7 +43,7 @@ let member2Context: BrowserContext | undefined; test( 'Team owner adds whole team to an all team chat', {tag: ['@TC-8631', '@crit-flow-web']}, - async ({pageManager, api, browser}) => { + async ({page, pageManager, api, browser}) => { const {pages, modals} = pageManager.webapp; // Create page managers for members that will be reused across steps @@ -92,7 +92,6 @@ test( await pages.conversationDetails().clickAddPeopleButton(); await pages.conversationDetails().addServiceToConversation('Poll'); // Verify service was added by checking for system message - const page = await pageManager.getPage(); await expect(page.getByText('You added Poll Bot to the')).toBeVisible(); }); diff --git a/test/e2e_tests/specs/CriticalFlow/groupVideoCall-TC-8637.spec.ts b/test/e2e_tests/specs/CriticalFlow/groupVideoCall-TC-8637.spec.ts index b87bb675015..2ecaf6c1e1f 100644 --- a/test/e2e_tests/specs/CriticalFlow/groupVideoCall-TC-8637.spec.ts +++ b/test/e2e_tests/specs/CriticalFlow/groupVideoCall-TC-8637.spec.ts @@ -114,7 +114,7 @@ test( await ownerPages.startUI().selectUser(guestUser.username); await ownerModals.userProfile().clickConnectButton(); expect(await ownerPages.conversationList().isConversationItemVisible(guestUser.fullName)); - await expect(await guestPageManager.getPage()).toHaveTitle('(1) Wire'); + await expect(guestPage).toHaveTitle('(1) Wire'); await guestPages.conversationList().openPendingConnectionRequest(); await guestPages.connectRequest().clickConnectButton(); diff --git a/test/e2e_tests/specs/CriticalFlow/oneOnOneCall-TC-8754.spec.ts b/test/e2e_tests/specs/CriticalFlow/oneOnOneCall-TC-8754.spec.ts index 7635a45b09d..15427d5e2ca 100644 --- a/test/e2e_tests/specs/CriticalFlow/oneOnOneCall-TC-8754.spec.ts +++ b/test/e2e_tests/specs/CriticalFlow/oneOnOneCall-TC-8754.spec.ts @@ -72,7 +72,7 @@ test( await ownerAModals.userProfile().clickConnectButton(); expect(await ownerAPages.conversationList().isConversationItemVisible(ownerB.fullName)); - await expect(await ownerBPageManager.getPage()).toHaveTitle('(1) Wire'); + await expect(ownerBPage).toHaveTitle('(1) Wire'); await ownerBPages.conversationList().openPendingConnectionRequest(); await ownerBPages.connectRequest().clickConnectButton(); diff --git a/test/e2e_tests/specs/Edit/edit.spec.ts b/test/e2e_tests/specs/Edit/edit.spec.ts index dcd0ced94e0..a47ff48dffa 100644 --- a/test/e2e_tests/specs/Edit/edit.spec.ts +++ b/test/e2e_tests/specs/Edit/edit.spec.ts @@ -72,8 +72,7 @@ test.describe('Edit', () => { const deviceA = (await PageManager.from(createPage(withLogin(userA), withConnectedUser(userB)))).webapp.pages; // Device 2 is intentionally created after device 1 to ensure the history info warning is confirmed - const deviceB = (await PageManager.from(createPage(withLogin(userA)))).webapp.pages; - await deviceB.historyInfo().clickConfirmButton(); + const deviceB = (await PageManager.from(createPage(withLogin(userA, {confirmNewHistory: true})))).webapp.pages; await deviceB.conversationList().openConversation(userB.fullName); await deviceA.conversation().sendMessage('Message from device 1'); diff --git a/test/e2e_tests/specs/noInternetCallGuard.spec.ts b/test/e2e_tests/specs/noInternetCallGuard.spec.ts index 40940e735b0..372d637b1fc 100644 --- a/test/e2e_tests/specs/noInternetCallGuard.spec.ts +++ b/test/e2e_tests/specs/noInternetCallGuard.spec.ts @@ -81,7 +81,7 @@ test('Starting call 1:1 call without internet', async ({browser, pageManager: ow await ownerAModals.userProfile().clickConnectButton(); expect(await ownerAPages.conversationList().isConversationItemVisible(ownerB.fullName)); - await expect(await ownerBPageManager.getPage()).toHaveTitle('(1) Wire'); + await expect(ownerBPage).toHaveTitle('(1) Wire'); await ownerBPages.conversationList().openPendingConnectionRequest(); await ownerBPages.connectRequest().clickConnectButton(); diff --git a/test/e2e_tests/test.fixtures.ts b/test/e2e_tests/test.fixtures.ts index fc9343628b1..6e300b34d08 100644 --- a/test/e2e_tests/test.fixtures.ts +++ b/test/e2e_tests/test.fixtures.ts @@ -21,7 +21,7 @@ import {test as baseTest, type BrowserContext, type Page} from '@playwright/test import {ApiManagerE2E} from './backend/apiManager.e2e'; import {getUser, User} from './data/user'; -import {PageManager} from './pageManager'; +import {PageManager, webAppPath} from './pageManager'; import {connectWithUser, sendConnectionRequest} from './utils/userActions'; type PagePlugin = (page: Page) => void | Promise; @@ -144,11 +144,21 @@ export const test = baseTest.extend({ /** PagePlugin to log in as the given user */ export const withLogin = - (user: User | Promise): PagePlugin => + (user: User | Promise, options?: {confirmNewHistory?: boolean}): PagePlugin => async page => { const pageManager = PageManager.from(page); await pageManager.openLoginPage(); await pageManager.webapp.pages.login().login(await user); + + if (options?.confirmNewHistory) { + await pageManager.webapp.pages.historyInfo().clickConfirmButton(); + } + + /** + * Since the login may take up to 40s we manually wait for it to finish here instead of increasing the timeout on all actions / assertions after this util + * This is an exception to the general best practice of using playwrights web assertions. (See: https://playwright.dev/docs/best-practices#use-web-first-assertions) + */ + await page.waitForURL(new RegExp(`^${webAppPath}$`), {timeout: 40_000, waitUntil: 'networkidle'}); }; /** diff --git a/test/e2e_tests/utils/network.util.ts b/test/e2e_tests/utils/network.util.ts index 3f3083990bc..b64d2732fe3 100644 --- a/test/e2e_tests/utils/network.util.ts +++ b/test/e2e_tests/utils/network.util.ts @@ -25,7 +25,7 @@ import {PageManager} from '../pageManager'; * @param pageManager */ export const makeNetworkOffline = async (pageManager: PageManager) => { - const cdpSession = await pageManager.getContext().newCDPSession(await pageManager.getPage()); + const cdpSession = await pageManager.getContext().newCDPSession(pageManager.page); await cdpSession.send('Network.emulateNetworkConditions', { offline: true, latency: 0, @@ -40,7 +40,7 @@ export const makeNetworkOffline = async (pageManager: PageManager) => { * @param pageManager */ export const makeNetworkOnline = async (pageManager: PageManager) => { - const cdpSession = await pageManager.getContext().newCDPSession(await pageManager.getPage()); + const cdpSession = await pageManager.getContext().newCDPSession(pageManager.page); await cdpSession.send('Network.emulateNetworkConditions', { offline: false, latency: 0, diff --git a/yarn.lock b/yarn.lock index afb562afb01..c87b23baba8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2561,6 +2561,15 @@ __metadata: languageName: node linkType: hard +"@csstools/postcss-position-area-property@npm:^1.0.0": + version: 1.0.0 + resolution: "@csstools/postcss-position-area-property@npm:1.0.0" + peerDependencies: + postcss: ^8.4 + checksum: 10/50f1274b8f88d89d90494f7511c2d34736ccc6f48ce650efe85772fb1a355c98bc41b749ba6c7129de24a26536c77166a850a912b650c9c6781665ed9e85321e + languageName: node + linkType: hard + "@csstools/postcss-progressive-custom-properties@npm:^4.2.1": version: 4.2.1 resolution: "@csstools/postcss-progressive-custom-properties@npm:4.2.1" @@ -2637,6 +2646,18 @@ __metadata: languageName: node linkType: hard +"@csstools/postcss-system-ui-font-family@npm:^1.0.0": + version: 1.0.0 + resolution: "@csstools/postcss-system-ui-font-family@npm:1.0.0" + dependencies: + "@csstools/css-parser-algorithms": "npm:^3.0.5" + "@csstools/css-tokenizer": "npm:^3.0.4" + peerDependencies: + postcss: ^8.4 + checksum: 10/6e2eed873ce887e3e3cec8d36d48fb71ef68b9995275ba008b3d5538ce63704eb4c9d4b1bd8e4a9e6d605116d7658a64557abbca7858069c7e81ea386433b8f9 + languageName: node + linkType: hard + "@csstools/postcss-text-decoration-shorthand@npm:^4.0.3": version: 4.0.3 resolution: "@csstools/postcss-text-decoration-shorthand@npm:4.0.3" @@ -6260,17 +6281,7 @@ __metadata: languageName: node linkType: hard -"@types/markdown-it@npm:14.1.1": - version: 14.1.1 - resolution: "@types/markdown-it@npm:14.1.1" - dependencies: - "@types/linkify-it": "npm:^5" - "@types/mdurl": "npm:^2" - checksum: 10/78d6aae11cda6878b9190f9b65095b1d09de77e054148efb6876fca7b3f19505384ec9dd0ceeaf2a52455691c6a55b9baee92e14899994100121a7bebc58df14 - languageName: node - linkType: hard - -"@types/markdown-it@npm:^14.1.1": +"@types/markdown-it@npm:14.1.2, @types/markdown-it@npm:^14.1.1": version: 14.1.2 resolution: "@types/markdown-it@npm:14.1.2" dependencies: @@ -6917,9 +6928,9 @@ __metadata: languageName: node linkType: hard -"@wireapp/api-client@npm:^27.93.0": - version: 27.93.0 - resolution: "@wireapp/api-client@npm:27.93.0" +"@wireapp/api-client@npm:^27.94.0": + version: 27.94.0 + resolution: "@wireapp/api-client@npm:27.94.0" dependencies: "@aws-sdk/client-s3": "npm:3.940.0" "@aws-sdk/lib-storage": "npm:3.940.0" @@ -6938,7 +6949,7 @@ __metadata: uuid: "npm:11.1.0" ws: "npm:8.18.1" zod: "npm:3.24.2" - checksum: 10/1f0447dbde9947b4b89a22813d19328f8508ed759159835c4dbe9c900836afb371e7e75c6c38897a0b8b81b7fc94b794e143d458a9f0473193535990e5d1ac20 + checksum: 10/df9b3aba04a392086f4bba64c33723ec881febb6cd05271ab1ace48a544c073ab4b2966ab6b975e91a0c11a8c6dbf732816059c3f3cc3300aa277814a4d8a283 languageName: node linkType: hard @@ -6967,19 +6978,7 @@ __metadata: languageName: node linkType: hard -"@wireapp/commons@npm:5.4.9": - version: 5.4.9 - resolution: "@wireapp/commons@npm:5.4.9" - dependencies: - ansi-regex: "npm:5.0.1" - fs-extra: "npm:11.3.1" - logdown: "npm:3.3.1" - platform: "npm:1.3.6" - checksum: 10/10804e146e7dbe3f87ad2473a066eafe17faf0e2f8c2e89e378b844e2ff72777b8cb640aa76adb52f17d8ad46bcb37cfb4cffb8311fdea3102d653ff1e49bfa0 - languageName: node - linkType: hard - -"@wireapp/commons@npm:^5.4.10": +"@wireapp/commons@npm:5.4.10, @wireapp/commons@npm:^5.4.10": version: 5.4.10 resolution: "@wireapp/commons@npm:5.4.10" dependencies: @@ -7015,11 +7014,11 @@ __metadata: languageName: node linkType: hard -"@wireapp/core@npm:46.46.8": - version: 46.46.8 - resolution: "@wireapp/core@npm:46.46.8" +"@wireapp/core@npm:46.46.9": + version: 46.46.9 + resolution: "@wireapp/core@npm:46.46.9" dependencies: - "@wireapp/api-client": "npm:^27.93.0" + "@wireapp/api-client": "npm:^27.94.0" "@wireapp/commons": "npm:^5.4.10" "@wireapp/core-crypto": "npm:9.1.2" "@wireapp/cryptobox": "npm:12.8.0" @@ -7037,7 +7036,7 @@ __metadata: long: "npm:^5.2.0" uuid: "npm:9.0.1" zod: "npm:3.24.2" - checksum: 10/c9424d59c0559f87216666fbfc9fefc2f2cb843406b50b3fcd42e3d2e0621760e9fb1e2cd189ec5e24eb1cc146f6541e511d7aeba1ebaf061b4f71144f902f71 + checksum: 10/ee1134775d4ba837bb06b2b2faf6469fad880f3e82812ac54cb4be07afcc537a47a2675e41aae19174e83eea06255b0e0232a3fefb13b41715e22b4fb597a666 languageName: node linkType: hard @@ -7133,14 +7132,7 @@ __metadata: languageName: node linkType: hard -"@wireapp/promise-queue@npm:2.4.9": - version: 2.4.9 - resolution: "@wireapp/promise-queue@npm:2.4.9" - checksum: 10/0c056306b037bcf692f380a2fd34cc372596341c971c1ca494c36a5f54bf8312fc24f9392c40965c07ecc2b122903e8bcf261a8f51052f446740a7cf47c28a86 - languageName: node - linkType: hard - -"@wireapp/promise-queue@npm:^2.4.10": +"@wireapp/promise-queue@npm:2.4.10, @wireapp/promise-queue@npm:^2.4.10": version: 2.4.10 resolution: "@wireapp/promise-queue@npm:2.4.10" checksum: 10/ae3fcab34448cad870c3f9c668e654b01fdeac7d88c038c218c90f790b8236aff027f58382b71db2f792189d16394905df62d751f2e93177091dc6e178e2e869 @@ -7171,9 +7163,9 @@ __metadata: languageName: node linkType: hard -"@wireapp/react-ui-kit@npm:9.69.6": - version: 9.69.6 - resolution: "@wireapp/react-ui-kit@npm:9.69.6" +"@wireapp/react-ui-kit@npm:9.71.0": + version: 9.71.0 + resolution: "@wireapp/react-ui-kit@npm:9.71.0" dependencies: "@radix-ui/react-accordion": "npm:1.2.11" "@radix-ui/react-dropdown-menu": "npm:2.1.14" @@ -7190,7 +7182,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 10/c2b96f7493abfefd869895064754b1bc73bce46cc32ec19d34de2df6c0220646075d6950dfab403f2c61f56c525b637dfb921878bb36159397cefd07a07fe7e7 + checksum: 10/51f74f35f02d1d197400c013b8d3671fa38e8ba40b0a3f15ed10932465b86bfe091f14f35ed237b2427f57ccf12bd4828f2e7502f03a89cbebb3779b06020f9a languageName: node linkType: hard @@ -7214,14 +7206,7 @@ __metadata: languageName: node linkType: hard -"@wireapp/store-engine@npm:5.1.16": - version: 5.1.16 - resolution: "@wireapp/store-engine@npm:5.1.16" - checksum: 10/a6bcf8f59ada6ca02d0274f7a3c0b764f69bd8b04877b5cc35bfc5a25579cb5de069c679c2347e23d4dd38decca57921f892e8a73afda208eb7b7b2ffc50a327 - languageName: node - linkType: hard - -"@wireapp/store-engine@npm:^5.1.17": +"@wireapp/store-engine@npm:5.1.17, @wireapp/store-engine@npm:^5.1.17": version: 5.1.17 resolution: "@wireapp/store-engine@npm:5.1.17" checksum: 10/953d04ca657e49806897d554d63dfb3fea842c8c0964f37b71f5fe20780d275d76df6cd014b0bc3a1cbe90178ecceab9cae6852ee8f410cc9998cd4453fb7fc2 @@ -7841,7 +7826,7 @@ __metadata: languageName: node linkType: hard -"autoprefixer@npm:10.4.22, autoprefixer@npm:^10.4.21": +"autoprefixer@npm:10.4.22, autoprefixer@npm:^10.4.22": version: 10.4.22 resolution: "autoprefixer@npm:10.4.22" dependencies: @@ -8106,6 +8091,15 @@ __metadata: languageName: node linkType: hard +"baseline-browser-mapping@npm:^2.9.0": + version: 2.9.4 + resolution: "baseline-browser-mapping@npm:2.9.4" + bin: + baseline-browser-mapping: dist/cli.js + checksum: 10/71cf80f822e74e0f0109a9ed69d87fdb128d01bf06670a2ef91166a3eb636034e0a013d76cd9915a9d38594f649848c8c1ef6cbe39ed417f38314ff5bd22e393 + languageName: node + linkType: hard + "bazinga64@npm:5.10.0": version: 5.10.0 resolution: "bazinga64@npm:5.10.0" @@ -8236,7 +8230,7 @@ __metadata: languageName: node linkType: hard -"browserslist@npm:^4.0.0, browserslist@npm:^4.24.0, browserslist@npm:^4.25.1, browserslist@npm:^4.26.0, browserslist@npm:^4.26.3, browserslist@npm:^4.27.0, browserslist@npm:^4.28.0": +"browserslist@npm:^4.0.0, browserslist@npm:^4.24.0, browserslist@npm:^4.25.1, browserslist@npm:^4.26.3, browserslist@npm:^4.27.0": version: 4.28.0 resolution: "browserslist@npm:4.28.0" dependencies: @@ -8251,6 +8245,21 @@ __metadata: languageName: node linkType: hard +"browserslist@npm:^4.28.0, browserslist@npm:^4.28.1": + version: 4.28.1 + resolution: "browserslist@npm:4.28.1" + dependencies: + baseline-browser-mapping: "npm:^2.9.0" + caniuse-lite: "npm:^1.0.30001759" + electron-to-chromium: "npm:^1.5.263" + node-releases: "npm:^2.0.27" + update-browserslist-db: "npm:^1.2.0" + bin: + browserslist: cli.js + checksum: 10/64f2a97de4bce8473c0e5ae0af8d76d1ead07a5b05fc6bc87b848678bb9c3a91ae787b27aa98cdd33fc00779607e6c156000bed58fefb9cf8e4c5a183b994cdb + languageName: node + linkType: hard + "bser@npm:2.1.1": version: 2.1.1 resolution: "bser@npm:2.1.1" @@ -8430,6 +8439,13 @@ __metadata: languageName: node linkType: hard +"caniuse-lite@npm:^1.0.30001759": + version: 1.0.30001759 + resolution: "caniuse-lite@npm:1.0.30001759" + checksum: 10/da0ec28dd993dffa99402914903426b9466d2798d41c1dc9341fcb7dd10f58fdd148122e2c65001246c030ba1c939645b7b4597f6321e3246dc792323bb11541 + languageName: node + linkType: hard + "canvas@npm:^3.0.0-rc2": version: 3.1.0 resolution: "canvas@npm:3.1.0" @@ -9161,10 +9177,10 @@ __metadata: languageName: node linkType: hard -"cssdb@npm:^8.4.2": - version: 8.4.2 - resolution: "cssdb@npm:8.4.2" - checksum: 10/09f4d8687289389b67486e921fd6e30cfbd3aa27d23e519f04839969ad55d3f6e5b2bb0f6a455830a9b830b2e50932a52cf3bcb4d3d058dfc4af6b2702e74888 +"cssdb@npm:^8.5.2": + version: 8.5.2 + resolution: "cssdb@npm:8.5.2" + checksum: 10/d3b5d7c44e633428085cc9bc2e1a77c6f5c7962508ae1beb48f4e4c7c3f48c0acef03c72a617a0539b3db84f8245323d787be1980aa4d36c59443c5f95fcc148 languageName: node linkType: hard @@ -9805,6 +9821,13 @@ __metadata: languageName: node linkType: hard +"electron-to-chromium@npm:^1.5.263": + version: 1.5.266 + resolution: "electron-to-chromium@npm:1.5.266" + checksum: 10/2c7e05d1df189013e01b9fa19f5794dc249b80f330ab87f78674fa7416df153e2d32738d16914eee1112b5d8878b6181336e502215a34c63c255da078de5209d + languageName: node + linkType: hard + "emittery@npm:^0.13.1": version: 0.13.1 resolution: "emittery@npm:0.13.1" @@ -14383,23 +14406,7 @@ __metadata: languageName: node linkType: hard -"markdown-it@npm:14.0.0": - version: 14.0.0 - resolution: "markdown-it@npm:14.0.0" - dependencies: - argparse: "npm:^2.0.1" - entities: "npm:^4.4.0" - linkify-it: "npm:^5.0.0" - mdurl: "npm:^2.0.0" - punycode.js: "npm:^2.3.1" - uc.micro: "npm:^2.0.0" - bin: - markdown-it: bin/markdown-it.mjs - checksum: 10/ae319aa4dd02b79d305fa0ff55020f8a3f728793938de5c8849c265a62a558f3968a3c023709d3a25aa0ce3287dc5a1faf33201f41efaf578dcf5d75b20462e8 - languageName: node - linkType: hard - -"markdown-it@npm:^14.1.0": +"markdown-it@npm:14.1.0, markdown-it@npm:^14.1.0": version: 14.1.0 resolution: "markdown-it@npm:14.1.0" dependencies: @@ -16330,9 +16337,9 @@ __metadata: languageName: node linkType: hard -"postcss-preset-env@npm:10.4.0": - version: 10.4.0 - resolution: "postcss-preset-env@npm:10.4.0" +"postcss-preset-env@npm:10.5.0": + version: 10.5.0 + resolution: "postcss-preset-env@npm:10.5.0" dependencies: "@csstools/postcss-alpha-function": "npm:^1.0.1" "@csstools/postcss-cascade-layers": "npm:^5.0.2" @@ -16361,21 +16368,23 @@ __metadata: "@csstools/postcss-nested-calc": "npm:^4.0.0" "@csstools/postcss-normalize-display-values": "npm:^4.0.0" "@csstools/postcss-oklab-function": "npm:^4.0.12" + "@csstools/postcss-position-area-property": "npm:^1.0.0" "@csstools/postcss-progressive-custom-properties": "npm:^4.2.1" "@csstools/postcss-random-function": "npm:^2.0.1" "@csstools/postcss-relative-color-syntax": "npm:^3.0.12" "@csstools/postcss-scope-pseudo-class": "npm:^4.0.1" "@csstools/postcss-sign-functions": "npm:^1.1.4" "@csstools/postcss-stepped-value-functions": "npm:^4.0.9" + "@csstools/postcss-system-ui-font-family": "npm:^1.0.0" "@csstools/postcss-text-decoration-shorthand": "npm:^4.0.3" "@csstools/postcss-trigonometric-functions": "npm:^4.0.9" "@csstools/postcss-unset-value": "npm:^4.0.0" - autoprefixer: "npm:^10.4.21" - browserslist: "npm:^4.26.0" + autoprefixer: "npm:^10.4.22" + browserslist: "npm:^4.28.0" css-blank-pseudo: "npm:^7.0.1" css-has-pseudo: "npm:^7.0.3" css-prefers-color-scheme: "npm:^10.0.0" - cssdb: "npm:^8.4.2" + cssdb: "npm:^8.5.2" postcss-attribute-case-insensitive: "npm:^7.0.1" postcss-clamp: "npm:^4.1.0" postcss-color-functional-notation: "npm:^7.0.12" @@ -16403,7 +16412,7 @@ __metadata: postcss-selector-not: "npm:^8.0.1" peerDependencies: postcss: ^8.4 - checksum: 10/1977c4c1cd8240e2c204d555a0781e88775c99e6552e110737176d59f8eee5ed649175c8259912c6d0ffc04a9b2346f74925bde3a1c95ba7d50144ca5eb47d57 + checksum: 10/15733a1fc07785bc01a00dd5954b43af9dc3cb99a510f3fab79cbd6a69c9b33f75deb77d226c03f36b31b786235ecafd16a8c3e3e19075a7ca8b8073e018b766 languageName: node linkType: hard @@ -16611,7 +16620,7 @@ __metadata: languageName: node linkType: hard -"prettier@npm:*, prettier@npm:3.3.2, prettier@npm:^3": +"prettier@npm:*, prettier@npm:^3": version: 3.3.2 resolution: "prettier@npm:3.3.2" bin: @@ -16620,6 +16629,15 @@ __metadata: languageName: node linkType: hard +"prettier@npm:3.7.4": + version: 3.7.4 + resolution: "prettier@npm:3.7.4" + bin: + prettier: bin/prettier.cjs + checksum: 10/b4d00ea13baed813cb777c444506632fb10faaef52dea526cacd03085f01f6db11fc969ccebedf05bf7d93c3960900994c6adf1b150e28a31afd5cfe7089b313 + languageName: node + linkType: hard + "pretty-bytes@npm:^5.3.0, pretty-bytes@npm:^5.4.1": version: 5.6.0 resolution: "pretty-bytes@npm:5.6.0" @@ -19495,6 +19513,20 @@ __metadata: languageName: node linkType: hard +"update-browserslist-db@npm:^1.2.0": + version: 1.2.2 + resolution: "update-browserslist-db@npm:1.2.2" + dependencies: + escalade: "npm:^3.2.0" + picocolors: "npm:^1.1.1" + peerDependencies: + browserslist: ">= 4.21.0" + bin: + update-browserslist-db: cli.js + checksum: 10/ae2102d3c83fca35e9deb012d82bfde6f734998ced937e34a3bf239a4b67577108fdd144283aafc0e5e3cf38ca1aecd7714906ba6f562896c762d2f2fa391026 + languageName: node + linkType: hard + "uri-js@npm:^4.2.2": version: 4.4.1 resolution: "uri-js@npm:4.4.1" @@ -20220,7 +20252,7 @@ __metadata: "@types/keyboardjs": "npm:2.5.3" "@types/libsodium-wrappers": "npm:0" "@types/linkify-it": "npm:5.0.0" - "@types/markdown-it": "npm:14.1.1" + "@types/markdown-it": "npm:14.1.2" "@types/node": "npm:22.9.0" "@types/open-graph": "npm:0.2.6" "@types/platform": "npm:1.3.6" @@ -20239,15 +20271,15 @@ __metadata: "@types/wicg-file-system-access": "npm:^2023.10.7" "@wireapp/avs": "npm:10.2.19" "@wireapp/avs-debugger": "npm:0.0.7" - "@wireapp/commons": "npm:5.4.9" + "@wireapp/commons": "npm:5.4.10" "@wireapp/copy-config": "npm:2.3.4" - "@wireapp/core": "npm:46.46.8" + "@wireapp/core": "npm:46.46.9" "@wireapp/eslint-config": "npm:3.0.7" "@wireapp/kalium-backup": "npm:0.0.4" "@wireapp/prettier-config": "npm:0.6.9" - "@wireapp/promise-queue": "npm:2.4.9" - "@wireapp/react-ui-kit": "npm:9.69.6" - "@wireapp/store-engine": "npm:5.1.16" + "@wireapp/promise-queue": "npm:2.4.10" + "@wireapp/react-ui-kit": "npm:9.71.0" + "@wireapp/store-engine": "npm:5.1.17" "@wireapp/store-engine-dexie": "npm:2.1.16" "@wireapp/telemetry": "npm:0.3.1" "@wireapp/webapp-events": "npm:0.28.1" @@ -20258,7 +20290,7 @@ __metadata: babel-plugin-transform-import-meta: "npm:2.3.3" baseline-browser-mapping: "npm:^2.8.32" beautiful-react-hooks: "npm:5.0.3" - browserslist: "npm:^4.28.0" + browserslist: "npm:^4.28.1" classnames: "npm:2.5.1" copy-webpack-plugin: "npm:13.0.1" core-js: "npm:3.47.0" @@ -20300,7 +20332,7 @@ __metadata: linkify-it: "npm:5.0.0" lint-staged: "npm:15.5.0" long: "npm:5.3.2" - markdown-it: "npm:14.0.0" + markdown-it: "npm:14.1.0" murmurhash: "npm:2.0.1" oidc-client-ts: "npm:3.4.1" os-browserify: "npm:0.3.0" @@ -20311,9 +20343,9 @@ __metadata: postcss-import: "npm:16.1.1" postcss-less: "npm:6.0.0" postcss-loader: "npm:8.2.0" - postcss-preset-env: "npm:10.4.0" + postcss-preset-env: "npm:10.5.0" postcss-scss: "npm:4.0.9" - prettier: "npm:3.3.2" + prettier: "npm:3.7.4" prism-themes: "npm:^1.9.0" prismjs: "npm:^1.29.0" qrcode-reader: "npm:1.0.4"