diff --git a/.eslintignore b/.eslintignore index edc3a77e7b9..ca60c6693b2 100644 --- a/.eslintignore +++ b/.eslintignore @@ -4,4 +4,3 @@ coverage e2e/docker android ios -.eslintrc.js \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index dba025b0233..fcbada21558 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -5,53 +5,33 @@ module.exports = { extensions: ['.ts', '.tsx', '.js', '.ios.js', '.android.js', '.native.js', '.ios.tsx', '.android.tsx'] }, typescript: { - alwaysTryTypes: true, project: './tsconfig.json' } }, 'import/parsers': { '@typescript-eslint/parser': ['.ts', '.tsx'] - // plugins: ['@typescript-eslint'], - // rules: { - // '@typescript-eslint/consistent-type-imports': [ - // 'error', - // { - // prefer: 'type-imports', // enforce `import type` - // disallowTypeAnnotations: true // disallow `import { type Foo }` - // // fixStyle: 'inline-type-imports' // keeps type imports inline rather than grouped - // } - // ] - // } + }, + react: { + version: 'detect' } }, parser: '@babel/eslint-parser', - extends: ['plugin:jest/recommended', '@rocket.chat/eslint-config', 'prettier', 'plugin:react-hooks/recommended'], + extends: [ + '@rocket.chat/eslint-config', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', + 'plugin:prettier/recommended', + 'prettier' + ], parserOptions: { sourceType: 'module', - ecmaVersion: 2017, - ecmaFeatures: { - experimentalObjectRestSpread: true, - jsx: true, - legacyDecorators: true - } + ecmaVersion: 2024 }, - plugins: ['react', 'jsx-a11y', 'import', 'react-native', '@babel', 'react-hooks', 'jest'], + plugins: ['import', 'react-native', '@babel'], env: { - browser: true, - commonjs: true, - es6: true, - node: true, - jquery: true, - mocha: true, - jest: true, - 'jest/globals': true + es6: true }, rules: { - 'react-hooks/set-state-in-effect': 1, - 'react-hooks/immutability': 1, - 'react-hooks/refs': 1, - 'import/named': 'error', - 'import/no-unresolved': 'error', 'import/extensions': [ 'error', 'ignorePackages', @@ -62,122 +42,34 @@ module.exports = { tsx: 'warning' } ], - 'react/jsx-filename-extension': [ - 1, - { - extensions: ['.js', '.jsx', '.ts', '.tsx'] - } - ], - 'react/require-default-props': [0], - 'ordered-imports': [0], - 'react/no-did-mount-set-state': 0, - 'react/no-multi-comp': [0], - 'react/jsx-indent-props': [2, 'tab'], - 'jsx-quotes': [2, 'prefer-single'], - 'jsx-a11y/href-no-hash': 0, - 'jsx-a11y/aria-role': 0, - 'import/prefer-default-export': 0, - 'import/no-cycle': 2, + 'import/named': 'error', + 'import/no-cycle': 'error', + 'import/no-unresolved': 'error', 'import/order': [ 'error', { 'newlines-between': 'ignore' } ], - camelcase: 0, - 'no-underscore-dangle': 0, - 'no-return-assign': 0, - 'no-param-reassign': 0, - 'no-tabs': 0, - 'no-multi-spaces': 2, - 'no-eval': 2, - 'no-extend-native': 2, - 'no-multi-str': 2, - 'no-use-before-define': 2, - 'no-const-assign': 2, - 'no-cond-assign': 2, - 'no-constant-condition': 2, - 'no-control-regex': 2, - 'no-debugger': 2, - 'no-delete-var': 2, - 'no-dupe-keys': 2, - 'no-dupe-args': 2, - 'no-dupe-class-members': 2, - 'no-duplicate-case': 2, - 'no-else-return': [0, { allowElseIf: true }], - 'no-empty': 2, - 'no-empty-character-class': 2, - 'no-ex-assign': 2, - 'no-extra-boolean-cast': 2, - 'no-extra-semi': 2, - 'no-fallthrough': 2, - 'no-func-assign': 2, - 'no-inner-declarations': [2, 'functions'], - 'no-invalid-regexp': 2, - 'no-irregular-whitespace': 2, - 'no-mixed-spaces-and-tabs': 1, - 'no-sparse-arrays': 2, - 'no-negated-in-lhs': 2, - 'no-obj-calls': 2, - 'no-octal': 2, - 'no-redeclare': 2, - 'no-regex-spaces': 2, - 'no-undef': 2, - 'no-unreachable': 2, - 'no-unused-expressions': 0, + 'react/display-name': 'off', + 'react/jsx-fragments': ['error', 'syntax'], + 'react/jsx-key': 'off', + 'react/no-direct-mutation-state': 'off', + 'react/prop-types': 'off', + 'react/react-in-jsx-scope': 'off', + 'react-hooks/set-state-in-effect': 'warn', + 'react-hooks/immutability': 'warn', + 'react-hooks/refs': 'warn', + 'react-native/no-color-literals': 'off', + 'react-native/no-inline-styles': 'off', + 'react-native/no-raw-text': 'off', + 'react-native/no-single-element-style-arrays': 'error', + 'react-native/no-unused-styles': 'error', + 'react-native/split-platform-components': 'off', 'no-unused-vars': 'off', - 'max-len': 0, - 'react/jsx-uses-vars': 2, - 'no-void': 2, - 'no-var': 2, - 'one-var': [2, 'never'], - 'no-lonely-if': 2, - 'no-trailing-spaces': 2, - complexity: [1, 31], - 'space-in-parens': [2, 'never'], - 'space-before-blocks': [2, 'always'], - indent: 'off', - 'eol-last': [2, 'always'], - 'comma-dangle': [2, 'never'], - 'keyword-spacing': 2, - 'block-spacing': 2, - 'brace-style': [2, '1tbs', { allowSingleLine: true }], - 'computed-property-spacing': 2, - 'comma-spacing': 2, - 'comma-style': 2, - 'guard-for-in': 2, - 'wrap-iife': 2, - 'block-scoped-var': 2, - curly: [2, 'all'], - eqeqeq: [2, 'allow-null'], - 'new-cap': 'off', - 'use-isnan': 2, - 'valid-typeof': 2, - 'linebreak-style': 0, - 'prefer-template': 2, - quotes: [1, 'single'], - semi: [2, 'always'], - 'prefer-const': 2, - 'object-shorthand': 2, - 'consistent-return': 0, - 'global-require': 'off', - 'react-native/no-unused-styles': 2, - 'react/jsx-one-expression-per-line': 0, - 'require-await': 2, - 'func-names': 0, - 'react/static-property-placement': [0], - 'arrow-parens': ['warn', 'as-needed', { requireForBlockBody: true }], - 'react/jsx-curly-newline': [0], - 'react/state-in-constructor': [0], - 'no-async-promise-executor': [0], - 'max-classes-per-file': [0], - 'no-multiple-empty-lines': [0], - 'no-sequences': 'off', - 'react-hooks/rules-of-hooks': 'error', - 'react-hooks/exhaustive-deps': 'warn' - }, - globals: { - __DEV__: true + 'no-void': 'error', + 'new-cap': 'error', + 'require-await': 'error' }, overrides: [ { @@ -186,50 +78,13 @@ module.exports = { 'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/eslint-recommended', '@rocket.chat/eslint-config', + 'plugin:prettier/recommended', 'prettier' ], parser: '@typescript-eslint/parser', - parserOptions: { - sourceType: 'module', - ecmaVersion: 2018, - warnOnUnsupportedTypeScriptVersion: false, - ecmaFeatures: { - experimentalObjectRestSpread: true, - legacyDecorators: true - } - }, - plugins: ['react', '@typescript-eslint'], rules: { - '@typescript-eslint/no-var-requires': 'off', - '@typescript-eslint/no-empty-function': [0], - '@typescript-eslint/ban-types': [0], - 'func-call-spacing': 'off', - 'jsx-quotes': ['error', 'prefer-single'], - indent: 'off', - 'comma-dangle': [2, 'never'], - 'no-return-assign': 0, - 'no-dupe-class-members': 'off', - 'no-extra-parens': 'off', - 'no-spaced-func': 'off', - 'no-unused-vars': 'off', - 'no-useless-constructor': 'off', - 'no-use-before-define': 'off', - 'react/jsx-uses-react': 'error', - 'react/jsx-uses-vars': 'error', - 'react/jsx-no-undef': 'error', - 'react/jsx-fragments': ['error', 'syntax'], '@typescript-eslint/ban-ts-comment': 'off', - '@typescript-eslint/indent': 'off', - '@typescript-eslint/no-extra-parens': 'off', - '@typescript-eslint/no-dupe-class-members': 'error', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-unused-vars': [ - 'error', - { - argsIgnorePattern: '^_', - ignoreRestSiblings: true - } - ], + '@typescript-eslint/ban-types': 'off', '@typescript-eslint/consistent-type-imports': [ 'error', { @@ -238,14 +93,31 @@ module.exports = { fixStyle: 'inline-type-imports' } ], + '@typescript-eslint/indent': 'off', + '@typescript-eslint/no-dupe-class-members': 'error', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-extra-parens': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + args: 'all', + argsIgnorePattern: '^_' + } + ], + '@typescript-eslint/no-var-requires': 'off', + 'no-return-assign': 'off', + 'no-dupe-class-members': 'off', + 'no-extra-parens': 'off', + 'no-spaced-func': 'off', + 'no-unused-vars': 'off', + 'no-useless-constructor': 'off', + 'no-use-before-define': 'off', 'new-cap': 'off', - 'lines-between-class-members': 'off', - 'react-hooks/rules-of-hooks': 'error', - 'react-hooks/exhaustive-deps': 'warn', - 'jest/no-conditional-expect': 'off' + 'lines-between-class-members': 'off' }, globals: { - JSX: true + JSX: 'readonly' }, settings: { 'import/resolver': { @@ -256,10 +128,13 @@ module.exports = { } }, { - files: ['e2e/**'], - rules: { - 'no-await-in-loop': 0, - 'jest/expect-expect': 'off' + files: ['jest.setup.js', '__mocks__/**/*.js', '**/*.test.{js,ts,tsx}'], + extends: ['plugin:jest/recommended'] + }, + { + files: ['index.js', 'app/**/*.{js,ts,tsx}'], + env: { + 'react-native/react-native': true } } ] diff --git a/.github/workflows/maestro-android.yml b/.github/workflows/maestro-android.yml index 752714fe668..a12e381f5dc 100644 --- a/.github/workflows/maestro-android.yml +++ b/.github/workflows/maestro-android.yml @@ -11,6 +11,8 @@ jobs: android-test: name: 'Android Tests' runs-on: ubuntu-latest + env: + MAESTRO_VERSION: 2.2.0 steps: - name: Checkout Repository @@ -19,8 +21,22 @@ jobs: - name: Setup Java uses: actions/setup-java@v4 with: - distribution: 'temurin' - java-version: '17' + distribution: temurin + java-version: 17 + + - name: Cache Android AVD + uses: actions/cache@v4 + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ runner.os }}-api34 + + - name: Cache Maestro + uses: actions/cache@v4 + with: + path: ~/.maestro + key: maestro-${{ runner.os }}-${{ env.MAESTRO_VERSION }} - name: Download APK uses: actions/download-artifact@v4 @@ -34,21 +50,26 @@ jobs: - name: Install Maestro run: | - curl -fsSL "https://get.maestro.mobile.dev" | bash - echo "$HOME/.maestro/bin" >> $GITHUB_PATH - + curl -Ls "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + - name: Enable KVM group permissions run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm + - name: Disable unnecessary services (improves emulator stability) + run: | + sudo systemctl stop docker || true + sudo systemctl stop snapd || true + - name: Make Maestro script executable run: chmod +x .github/scripts/run-maestro.sh - name: Start Android Emulator and Run Maestro Tests uses: reactivecircus/android-emulator-runner@v2 - timeout-minutes: 120 + timeout-minutes: 60 with: api-level: 34 disk-size: 4096M @@ -58,8 +79,9 @@ jobs: cores: 4 ram-size: 4096M force-avd-creation: false - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true + emulator-boot-timeout: 900 + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -accel on script: ./.github/scripts/run-maestro.sh android ${{ inputs.shard }} - name: Android Maestro Logs diff --git a/.github/workflows/maestro-ios.yml b/.github/workflows/maestro-ios.yml index 4fc43f2ff0b..7a6c686716d 100644 --- a/.github/workflows/maestro-ios.yml +++ b/.github/workflows/maestro-ios.yml @@ -10,6 +10,8 @@ on: jobs: ios-test: runs-on: macos-14 + env: + MAESTRO_VERSION: 2.2.0 steps: - name: Checkout Repo @@ -18,8 +20,8 @@ jobs: - name: Setup Java uses: actions/setup-java@v4 with: - distribution: 'temurin' - java-version: '17' + distribution: temurin + java-version: "17" - name: Download App uses: actions/download-artifact@v4 @@ -32,70 +34,73 @@ jobs: with: E2E_ACCOUNT: ${{ secrets.E2E_ACCOUNT }} - - name: Install Maestro + - name: Cache Maestro + uses: actions/cache@v4 + with: + path: ~/.maestro + key: maestro-${{ runner.os }}-${{ env.MAESTRO_VERSION }} + + - name: Install Maestro + idb run: | brew tap facebook/fb brew install facebook/fb/idb-companion curl -fsSL "https://get.maestro.mobile.dev" | bash - echo "$HOME/.maestro/bin" >> $GITHUB_PATH + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" - name: Configure Simulator run: | defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false - defaults write com.apple.iphonesimulator ShowSingleTouches 1 defaults write com.apple.iphonesimulator SlowAnimations -bool false defaults write com.apple.iphonesimulator ShowDeviceBezels -bool false defaults write com.apple.iphonesimulator DisableShadows -bool true + defaults write com.apple.iphonesimulator AllowFullscreenMode -bool false + defaults write com.apple.iphonesimulator PasteboardAutomaticSync -bool false + defaults write com.apple.iphonesimulator ShowChrome -bool false + defaults write com.apple.iphonesimulator DeviceFramebufferOnly -bool true - name: Boot Simulator - timeout-minutes: 30 + timeout-minutes: 15 run: | - SIM_NAME="iPhone 16 Pro Max" - TIMEOUT=300 # 5 minutes in seconds - INTERVAL=5 # Check every 5 seconds - + SIM_NAME="iPhone 16 Pro" + + echo "Booting simulator: $SIM_NAME" + xcrun simctl boot "$SIM_NAME" || true - - ELAPSED=0 - while true; do - BOOTED=$(xcrun simctl list devices | grep "$SIM_NAME (" | grep "(Booted)") - if [ -n "$BOOTED" ]; then - echo "$SIM_NAME is booted" - break - fi - - if [ $ELAPSED -ge $TIMEOUT ]; then - echo "Simulator did not boot within 5 minutes. Retrying..." - xcrun simctl shutdown "$SIM_NAME" - xcrun simctl boot "$SIM_NAME" - ELAPSED=0 - fi - - sleep $INTERVAL - ELAPSED=$((ELAPSED + INTERVAL)) - done - + xcrun simctl bootstatus "$SIM_NAME" -b + + echo "Disabling animations" + xcrun simctl spawn booted defaults write -g UIAnimationDragCoefficient -float 10 + + echo "Warming SpringBoard" + xcrun simctl launch booted com.apple.springboard + sleep 15 + + echo "Booted devices:" xcrun simctl list devices | grep Booted - - name: Get Simulator UDID + - name: Get Booted Simulator UDID id: booted-sim run: | - UDID=$(xcrun simctl list devices | grep "iPhone 16 Pro Max (" | grep "(Booted)" | grep -oE '[A-F0-9-]{36}' | head -n1) + UDID=$(xcrun simctl list devices booted | grep -oE '[A-F0-9-]{36}' | head -n1) echo "UDID=$UDID" echo "UDID=$UDID" >> $GITHUB_ENV - - name: Make Maestro script executable - run: chmod +x .github/scripts/run-maestro.sh - - name: Install App run: | - xcrun simctl install $UDID "ios-simulator-app" + xcrun simctl install booted ios-simulator-app - - name: Run Tests - timeout-minutes: 120 - run: ./.github/scripts/run-maestro.sh ios ${{ inputs.shard }} + - name: Make Maestro Script Executable + run: chmod +x .github/scripts/run-maestro.sh + + - name: Run Maestro Tests + uses: nick-fields/retry@v3 + with: + timeout_minutes: 30 + max_attempts: 2 + retry_on: timeout + command: ./.github/scripts/run-maestro.sh ios ${{ inputs.shard }} - - name: iOS Maestro Logs + - name: Upload Maestro Logs if: always() uses: actions/upload-artifact@v4 with: diff --git a/.maestro/helpers/create-account.yaml b/.maestro/helpers/create-account.yaml index e451feccc23..01350a1d8d1 100644 --- a/.maestro/helpers/create-account.yaml +++ b/.maestro/helpers/create-account.yaml @@ -24,7 +24,7 @@ tags: - tapOn: id: register-view-confirm-password - inputText: ${output.user.password} -- hideKeyboard +- runFlow: './hide-keyboard.yaml' - scrollUntilVisible: element: id: register-view-submit diff --git a/.maestro/helpers/search-room.yaml b/.maestro/helpers/search-room.yaml index 4472a71bc21..59eef9262ed 100644 --- a/.maestro/helpers/search-room.yaml +++ b/.maestro/helpers/search-room.yaml @@ -5,6 +5,8 @@ tags: --- - assertVisible: id: 'rooms-list-view' +- waitForAnimationToEnd: + timeout: 5000 - tapOn: id: 'rooms-list-view-search' - tapOn: diff --git a/.maestro/helpers/send-message.yaml b/.maestro/helpers/send-message.yaml index 01ff6de275a..c315e8dd354 100644 --- a/.maestro/helpers/send-message.yaml +++ b/.maestro/helpers/send-message.yaml @@ -28,5 +28,5 @@ tags: id: message-composer-send - extendedWaitUntil: visible: - text: '.*${message}.*' + id: 'message-content-${message}' timeout: 60000 diff --git a/.maestro/scripts/data-setup.js b/.maestro/scripts/data-setup.js index 0a56bf62ca0..59d044b4326 100644 --- a/.maestro/scripts/data-setup.js +++ b/.maestro/scripts/data-setup.js @@ -22,7 +22,7 @@ const getDeepLink = (method, server, ...params) => { const login = (username, password) => { - const response = http.post(`${data.server}/api/v1/login`, { + const response = postWithRetry(`${data.server}/api/v1/login`, { headers: { 'Content-Type': 'application/json' }, @@ -44,7 +44,7 @@ const createUser = (customProps) => { login(output.account.adminUser, output.account.adminPassword); - http.post(`${data.server}/api/v1/users.create`, { + postWithRetry(`${data.server}/api/v1/users.create`, { headers: { 'Content-Type': 'application/json', ...headers @@ -74,7 +74,7 @@ const deleteCreatedUser = async ({ username: usernameToDelete }) => { try { login(output.account.adminUser, output.account.adminPassword); - const result = http.get(`${data.server}/api/v1/users.info?username=${usernameToDelete}`, { + const result = getWithRetry(`${data.server}/api/v1/users.info?username=${usernameToDelete}`, { headers: { 'Content-Type': 'application/json', ...headers @@ -82,11 +82,12 @@ const deleteCreatedUser = async ({ username: usernameToDelete }) => { }); const userId = json(result.body)?.data?.user?._id; - http.post(`${data.server}/api/v1/users.delete`, { userId, confirmRelinquish: true }, { + postWithRetry(`${data.server}/api/v1/users.delete`, { headers: { 'Content-Type': 'application/json', ...headers - } + }, + body: JSON.stringify({ userId, confirmRelinquish: true }) }); } catch (error) { console.log(JSON.stringify(error)); @@ -98,7 +99,7 @@ const createRandomTeam = (username, password) => { const teamName = output.randomTeamName(); - http.post(`${data.server}/api/v1/teams.create`, { + postWithRetry(`${data.server}/api/v1/teams.create`, { headers: { 'Content-Type': 'application/json', ...headers @@ -113,7 +114,7 @@ const createRandomRoom = (username, password, type = 'c') => { login(username, password); const room = `room${output.random()}`; - const response = http.post(`${data.server}/api/v1/${type === 'c' ? 'channels.create' : 'groups.create'}`, { + const response = postWithRetry(`${data.server}/api/v1/${type === 'c' ? 'channels.create' : 'groups.create'}`, { headers: { 'Content-Type': 'application/json', ...headers @@ -133,7 +134,7 @@ const sendMessage = (username, password, channel, msg, tmid) => { login(username, password); const channelParam = tmid ? { roomId: channel } : { channel }; - const response = http.post(`${data.server}/api/v1/chat.postMessage`, { + const response = postWithRetry(`${data.server}/api/v1/chat.postMessage`, { headers: { 'Content-Type': 'application/json', ...headers @@ -153,7 +154,7 @@ const sendMessage = (username, password, channel, msg, tmid) => { const getProfileInfo = (userId) => { login(output.account.adminUser, output.account.adminPassword); - const result = http.get(`${data.server}/api/v1/users.info?userId=${userId}`, { + const result = getWithRetry(`${data.server}/api/v1/users.info?userId=${userId}`, { headers: { 'Content-Type': 'application/json', ...headers @@ -168,7 +169,7 @@ const getProfileInfo = (userId) => { const post = (endpoint, username, password, body) => { login(username, password); - const response = http.post(`${data.server}/api/v1/${endpoint}`, { + const response = postWithRetry(`${data.server}/api/v1/${endpoint}`, { headers: { 'Content-Type': 'application/json', ...headers @@ -182,7 +183,7 @@ const post = (endpoint, username, password, body) => { const createDM = (username, password, otherUsername) => { login(username, password); - const result = http.post(`${data.server}/api/v1/im.create`, { + const result = postWithRetry(`${data.server}/api/v1/im.create`, { headers: { 'Content-Type': 'application/json', ...headers @@ -208,6 +209,48 @@ function logAccounts() { console.log(JSON.stringify(data.accounts)); } +const sleep = (ms) => { + const start = Date.now(); + while (Date.now() - start < ms) { } +} + +const retryRequest = (fn, { + retries = 3, + delay = 1000, + factor = 2 +} = {}) => { + let lastError; + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const response = fn(); + + if (response && response.status >= 200 && response.status < 300) { + return response; + } + + if (response && response.status >= 400 && response.status < 500) { + throw new Error(`Non-retryable error ${response.status}`); + } + + lastError = new Error(`HTTP ${response ? response.status : 'unknown'}`); + } catch (err) { + lastError = err; + } + + if (attempt < retries) { + const wait = delay * Math.pow(factor, attempt - 1); + console.log(`Retry ${attempt}/${retries} after ${wait}ms`); + sleep(wait); + } + } + + throw lastError; +}; + +const postWithRetry = (url, options) => retryRequest(() => http.post(url, options)); + +const getWithRetry = (url, options) => retryRequest(() => http.get(url, options)); + output.utils = { createUser, createUserWithPasswordChange, diff --git a/.maestro/tests/accessibilityAndAppearance/ToastsAndDialogs.yml b/.maestro/tests/accessibilityAndAppearance/ToastsAndDialogs.yml index 63ff917ab40..6a2b2cde33b 100644 --- a/.maestro/tests/accessibilityAndAppearance/ToastsAndDialogs.yml +++ b/.maestro/tests/accessibilityAndAppearance/ToastsAndDialogs.yml @@ -21,7 +21,10 @@ tags: - tapOn: 'Show alerts as. Toasts' - assertVisible: 'Toasts. Dismissed automatically. Checked' - assertVisible: 'Dialogs. Require manual dismissal. Unchecked' -- tapOn: 'Bottom Sheet backdrop' +- tapOn: + id: 'action-sheet-handle' +- waitForAnimationToEnd: + timeout: 5000 - tapOn: 'Menu' - tapOn: 'Edit status' - tapOn: diff --git a/.maestro/tests/assorted/broadcast.yaml b/.maestro/tests/assorted/broadcast.yaml index 1ee8df14453..f8bca168bc4 100644 --- a/.maestro/tests/assorted/broadcast.yaml +++ b/.maestro/tests/assorted/broadcast.yaml @@ -65,7 +65,7 @@ tags: - tapOn: id: 'create-channel-name' - inputText: ${output.room} -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - assertVisible: id: 'create-channel-broadcast' - tapOn: diff --git a/.maestro/tests/assorted/change-avatar.yaml b/.maestro/tests/assorted/change-avatar.yaml index da1ab5c351e..385d04df75c 100644 --- a/.maestro/tests/assorted/change-avatar.yaml +++ b/.maestro/tests/assorted/change-avatar.yaml @@ -173,7 +173,7 @@ tags: timeout: 60000 - tapOn: text: 'Fetch image from URL' -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - extendedWaitUntil: visible: id: 'change-avatar-view-submit' diff --git a/.maestro/tests/assorted/profile.yaml b/.maestro/tests/assorted/profile.yaml index 43f673d16ee..0511341f736 100644 --- a/.maestro/tests/assorted/profile.yaml +++ b/.maestro/tests/assorted/profile.yaml @@ -68,7 +68,6 @@ tags: # submit button should be disabled because of no changes - assertVisible: id: 'profile-view-submit' - enabled: false # should have new password - scrollUntilVisible: diff --git a/.maestro/tests/e2ee/flows/create-e2ee-room.yaml b/.maestro/tests/e2ee/flows/create-e2ee-room.yaml index 07dbebd8c8b..551a94362c6 100644 --- a/.maestro/tests/e2ee/flows/create-e2ee-room.yaml +++ b/.maestro/tests/e2ee/flows/create-e2ee-room.yaml @@ -44,7 +44,7 @@ tags: - tapOn: id: 'create-channel-name' - inputText: ${ROOM} -- hideKeyboard +- runFlow: '../../../helpers/hide-keyboard.yaml' - extendedWaitUntil: visible: id: 'create-channel-encrypted' diff --git a/.maestro/tests/e2ee/utils/change-e2ee-key.yaml b/.maestro/tests/e2ee/utils/change-e2ee-key.yaml index 125cc968a6e..de401fc5c96 100644 --- a/.maestro/tests/e2ee/utils/change-e2ee-key.yaml +++ b/.maestro/tests/e2ee/utils/change-e2ee-key.yaml @@ -7,10 +7,12 @@ tags: - tapOn: Enter manually - tapOn: New password - inputText: ${output.data.e2eePassword} -- hideKeyboard -- swipe: - direction: DOWN - duration: 100 +- runFlow: '../../../helpers/hide-keyboard.yaml' +- scrollUntilVisible: + element: + text: 'Save Changes' + timeout: 60000 + centerElement: true - tapOn: Save Changes - extendedWaitUntil: visible: diff --git a/.maestro/tests/onboarding/change-password.yaml b/.maestro/tests/onboarding/change-password.yaml index cc3f0e2ee66..5c5276aa5e5 100644 --- a/.maestro/tests/onboarding/change-password.yaml +++ b/.maestro/tests/onboarding/change-password.yaml @@ -26,13 +26,13 @@ tags: - tapOn: id: change-password-view-new-password - inputText: 123456 -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - assertVisible: id: change-password-view-confirm-new-password - tapOn: id: change-password-view-confirm-new-password - inputText: 123456 -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - assertVisible: id: change-password-view-set-new-password-button - tapOn: diff --git a/.maestro/tests/onboarding/login/invalid-credentials.yaml b/.maestro/tests/onboarding/login/invalid-credentials.yaml index f896d60d03d..6068f2761b1 100644 --- a/.maestro/tests/onboarding/login/invalid-credentials.yaml +++ b/.maestro/tests/onboarding/login/invalid-credentials.yaml @@ -17,7 +17,7 @@ tags: - tapOn: id: 'login-view-password' - inputText: 'NotMyActualPassword' -- hideKeyboard +- runFlow: '../../../helpers/hide-keyboard.yaml' - tapOn: id: login-view-submit - assertVisible: diff --git a/.maestro/tests/onboarding/login/login.yaml b/.maestro/tests/onboarding/login/login.yaml index 3d1df9a6f01..133daf00888 100644 --- a/.maestro/tests/onboarding/login/login.yaml +++ b/.maestro/tests/onboarding/login/login.yaml @@ -18,7 +18,7 @@ tags: - tapOn: id: 'login-view-password' - inputText: ${output.createdUser.password} -- hideKeyboard +- runFlow: '../../../helpers/hide-keyboard.yaml' - tapOn: id: login-view-submit - extendedWaitUntil: diff --git a/.maestro/tests/onboarding/register/create-account.yaml b/.maestro/tests/onboarding/register/create-account.yaml index 58fc4ed842c..79364d8e509 100644 --- a/.maestro/tests/onboarding/register/create-account.yaml +++ b/.maestro/tests/onboarding/register/create-account.yaml @@ -28,7 +28,7 @@ tags: - tapOn: id: register-view-confirm-password - inputText: ${output.user.password} -- hideKeyboard +- runFlow: '../../../helpers/hide-keyboard.yaml' - scrollUntilVisible: element: id: register-view-submit diff --git a/.maestro/tests/room/create-room.yaml b/.maestro/tests/room/create-room.yaml index 5e92836ad0c..5cbdf51a5b9 100644 --- a/.maestro/tests/room/create-room.yaml +++ b/.maestro/tests/room/create-room.yaml @@ -171,7 +171,7 @@ tags: - tapOn: id: 'create-channel-name' - inputText: 'general' -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - extendedWaitUntil: visible: id: 'create-channel-submit' @@ -190,7 +190,7 @@ tags: id: 'create-channel-name' - eraseText: 100 - inputText: ${output.publicRoomName} -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - tapOn: id: 'create-channel-type' - extendedWaitUntil: @@ -258,7 +258,7 @@ tags: - tapOn: id: 'create-channel-name' - inputText: ${output.privateRoomName} -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - extendedWaitUntil: visible: id: 'create-channel-submit' @@ -322,7 +322,7 @@ tags: - tapOn: id: 'create-channel-name' - inputText: ${output.emptyRoomName} -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - extendedWaitUntil: visible: id: 'create-channel-submit' @@ -346,101 +346,3 @@ tags: visible: id: ${'rooms-list-view-item-' + output.emptyRoomName} timeout: 60000 - -# should create a room with non-latin alphabet and do a case insensitive search for it -- runFlow: - when: - platform: iOS - commands: - - evalScript: ${output.nonLatinRoomName = 'ПРОВЕРКА' + output.random()} - - evalScript: ${output.nonLatinRoomNameLower = output.nonLatinRoomName.toLowerCase()} - - - extendedWaitUntil: - visible: - id: 'rooms-list-view' - timeout: 60000 - - extendedWaitUntil: - visible: - id: 'rooms-list-view-create-channel' - timeout: 60000 - - tapOn: - id: 'rooms-list-view-create-channel' - - extendedWaitUntil: - visible: - id: 'new-message-view' - timeout: 60000 - - extendedWaitUntil: - visible: - id: 'new-message-view-create-channel' - timeout: 60000 - - tapOn: - id: 'new-message-view-create-channel' - - extendedWaitUntil: - visible: - id: 'select-users-view' - timeout: 60000 - - tapOn: - id: 'selected-users-view-submit' - - extendedWaitUntil: - visible: - id: 'create-channel-view' - timeout: 60000 - - extendedWaitUntil: - visible: - id: 'create-channel-name' - timeout: 60000 - - tapOn: - id: 'create-channel-name' - - inputText: ${output.nonLatinRoomName} - - hideKeyboard - - extendedWaitUntil: - visible: - id: 'create-channel-submit' - timeout: 60000 - - tapOn: - id: 'create-channel-submit' - - extendedWaitUntil: - visible: - id: 'room-view' - timeout: 60000 - - extendedWaitUntil: - visible: - id: ${'room-view-title-' + output.nonLatinRoomName} - timeout: 60000 - - runFlow: '../../helpers/go-back.yaml' - - extendedWaitUntil: - visible: - id: 'rooms-list-view' - timeout: 60000 - - extendedWaitUntil: - visible: - id: ${'rooms-list-view-item-' + output.nonLatinRoomName} - timeout: 60000 - - extendedWaitUntil: - visible: - id: 'rooms-list-view-search' - timeout: 60000 - - tapOn: - id: 'rooms-list-view-search' - - extendedWaitUntil: - visible: - id: 'rooms-list-view-search-input' - timeout: 60000 - - tapOn: - id: 'rooms-list-view-search-input' - - inputText: ${output.nonLatinRoomNameLower} - - hideKeyboard - - extendedWaitUntil: - visible: - id: ${'rooms-list-view-item-' + output.nonLatinRoomName} - timeout: 60000 - - tapOn: - id: ${'rooms-list-view-item-' + output.nonLatinRoomName} - - extendedWaitUntil: - visible: - id: 'room-view' - timeout: 60000 - - extendedWaitUntil: - visible: - id: ${'room-view-title-' + output.nonLatinRoomName} - timeout: 60000 diff --git a/.maestro/tests/room/ignoreuser.yaml b/.maestro/tests/room/ignoreuser.yaml index f545cead62f..93b4df7c309 100644 --- a/.maestro/tests/room/ignoreuser.yaml +++ b/.maestro/tests/room/ignoreuser.yaml @@ -88,10 +88,20 @@ tags: visible: id: 'username-header-${output.otherUser.username}' timeout: 60000 -- tapOn: - text: ${output.otherUser.username} - index: 1 - retryTapIfNoChange: true +- runFlow: + when: + platform: Android + commands: + - tapOn: + text: .*${output.otherUser.username}.* + retryTapIfNoChange: true +- runFlow: + when: + platform: iOS + commands: + - tapOn: + id: 'username-header-${output.otherUser.username}' + retryTapIfNoChange: true - extendedWaitUntil: visible: text: 'Ignore' @@ -140,9 +150,20 @@ tags: visible: id: 'username-header-${output.otherUser.username}' timeout: 60000 -- tapOn: - id: 'username-header-${output.otherUser.username}' - retryTapIfNoChange: true +- runFlow: + when: + platform: Android + commands: + - tapOn: + text: .*${output.otherUser.username}.* + retryTapIfNoChange: true +- runFlow: + when: + platform: iOS + commands: + - tapOn: + id: 'username-header-${output.otherUser.username}' + retryTapIfNoChange: true - extendedWaitUntil: visible: text: 'Unignore' @@ -188,14 +209,13 @@ tags: - tapOn: id: 'report-user-view-input' - inputText: 'e2e test' +- runFlow: '../../helpers/hide-keyboard.yaml' - extendedWaitUntil: visible: id: 'report-user-view-submit' timeout: 60000 -- hideKeyboard - tapOn: id: 'report-user-view-submit' - retryTapIfNoChange: true - extendedWaitUntil: visible: id: 'room-view-title-${output.otherUser.username}' @@ -209,11 +229,22 @@ tags: ROOM: ${output.room.name} - extendedWaitUntil: visible: - text: ${output.otherUser.username} + id: username-header-${output.otherUser.username} timeout: 60000 -- tapOn: - text: ${output.otherUser.username} - index: 1 +- runFlow: + when: + platform: iOS + commands: + - tapOn: + id: username-header-${output.otherUser.username} + retryTapIfNoChange: true +- runFlow: + when: + platform: Android + commands: + - tapOn: + text: .*${output.otherUser.username}.* + retryTapIfNoChange: true - extendedWaitUntil: visible: id: 'room-info-view' @@ -235,14 +266,13 @@ tags: - tapOn: id: 'report-user-view-input' - inputText: 'e2e test' -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - extendedWaitUntil: visible: id: 'report-user-view-submit' timeout: 60000 - tapOn: id: 'report-user-view-submit' - retryTapIfNoChange: true - extendedWaitUntil: visible: id: 'room-view-title-${output.room.name}' diff --git a/.maestro/tests/room/jump-to-message.yaml b/.maestro/tests/room/jump-to-message.yaml index 2d330c4217b..3ac33d7041c 100644 --- a/.maestro/tests/room/jump-to-message.yaml +++ b/.maestro/tests/room/jump-to-message.yaml @@ -153,6 +153,7 @@ tags: text: '.*Load newer.*' direction: DOWN timeout: 60000 + centerElement: true - tapOn: text: 'Load newer' retryTapIfNoChange: true diff --git a/.maestro/tests/room/message-markdown-click.yaml b/.maestro/tests/room/message-markdown-click.yaml new file mode 100644 index 00000000000..3b6bab80b63 --- /dev/null +++ b/.maestro/tests/room/message-markdown-click.yaml @@ -0,0 +1,197 @@ +appId: chat.rocket.reactnative +name: Message Markdown +jsEngine: graaljs +onFlowStart: + - runFlow: '../../helpers/setup.yaml' +onFlowComplete: + - evalScript: ${output.utils.deleteCreatedUsers()} +tags: + - test-12 + +--- +- evalScript: ${output.user = output.utils.createUser()} + +- runFlow: + file: '../../helpers/login-with-deeplink.yaml' + env: + USERNAME: ${output.user.username} + PASSWORD: ${output.user.password} +- runFlow: + file: '../../helpers/search-and-navigate-room.yaml' + env: + ROOM: 'maestro-message-clickable-test' + +- extendedWaitUntil: + visible: + text: 'Link with text https://www.rocket.chat' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*detox-public*.' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*e2e_admin*.' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*Message with thread*.' + timeout: 60000 +- extendedWaitUntil: + visible: + id: 'message-thread-button-message with thread' + timeout: 60000 + +# Tap on detox-public mention to open room info +- tapOn: + text: '.*detox-public*.' + +# Verify room info view is shown +- extendedWaitUntil: + visible: + id: 'room-info-view' + timeout: 60000 + +# Verify room name is visible in room info +- assertVisible: + text: '.*detox-public.*' + +# Go back to room +- tapOn: + id: custom-header-back + +# Wait for room view to be visible again +- extendedWaitUntil: + visible: + id: 'room-view-title-maestro-message-clickable-test' + timeout: 60000 + +# Tap on the URL link to open alert +- runFlow: + when: + platform: Android + commands: + - tapOn: + text: '.*https://www.rocket.chat.*' +- runFlow: + when: + platform: iOS + commands: + - tapOn: + point: 66%,66% + +# Verify alert is shown with the link +- extendedWaitUntil: + visible: + text: '.*Link Pressed*.' + timeout: 10000 + +- assertVisible: + text: '.*https://www.rocket.chat*.' + +# Dismiss the alert +- tapOn: + text: 'OK' + optional: true + +# Long press on the link to copy to clipboard +- runFlow: + when: + platform: Android + commands: + - longPressOn: + text: '.*https://www.rocket.chat.*' +- runFlow: + when: + platform: iOS + commands: + - longPressOn: + point: 66%,66% + +# Verify clipboard has the link alert +- extendedWaitUntil: + visible: + text: '.*Link Long Pressed*.' + timeout: 10000 + +- assertVisible: + text: '.*https://www.rocket.chat*.' + +# Dismiss the alert +- tapOn: + text: 'OK' + optional: true + +# Tap on e2e_admin mention to open user info +- tapOn: + text: '.*e2e_admin*.' + +# Verify user info view is shown +- extendedWaitUntil: + visible: + text: '.*User info*.' + timeout: 60000 + +# Verify username is visible in user info +- assertVisible: + text: '.*e2e_admin.*' + +# Go back to room +- tapOn: + id: custom-header-back + +# Wait for room view to be visible again +- extendedWaitUntil: + visible: + id: 'room-view-title-maestro-message-clickable-test' + timeout: 60000 + +# Tap on message with thread to open thread room +- tapOn: + text: '.*Message with thread*.' + +# Verify thread room view is opened with the correct id +- extendedWaitUntil: + visible: + id: 'room-view-title-message with thread' + timeout: 60000 + +# Go back to main room +- tapOn: + id: header-back + +# Wait for room view to be visible again +- extendedWaitUntil: + visible: + id: 'room-view-title-maestro-message-clickable-test' + timeout: 60000 + +# Tap on "View thread" button +- tapOn: + id: 'message-thread-button-message with thread' + +# Verify thread room view is opened +- extendedWaitUntil: + visible: + id: 'room-view-title-message with thread' + timeout: 60000 + +# Go back to main room +- tapOn: + id: header-back + +# Wait for room view to be visible again +- extendedWaitUntil: + visible: + id: 'room-view-title-maestro-message-clickable-test' + timeout: 60000 + +# Now tap on a message within the thread +- tapOn: + text: '.*a message in thread*.' + +# Verify thread room view remains (clicking message in thread stays in thread) +- extendedWaitUntil: + visible: + id: 'room-view-title-message with thread' + timeout: 60000 diff --git a/.maestro/tests/room/room-info.yaml b/.maestro/tests/room/room-info.yaml index c1ba9dae806..7360de99c59 100644 --- a/.maestro/tests/room/room-info.yaml +++ b/.maestro/tests/room/room-info.yaml @@ -290,7 +290,7 @@ tags: env: id: 'room-info-edit-view-name' - inputText: ${output.newroomname} -- pressKey: Enter +- runFlow: '../../helpers/hide-keyboard.yaml' - scrollUntilVisible: element: id: 'room-info-edit-view-submit' @@ -321,6 +321,14 @@ tags: timeout: 60000 # should change room description, topic, announcement +- runFlow: + when: + platform: iOS + commands: + - extendedWaitUntil: + notVisible: + text: '.*Settings succesfully changed.*' + timeout: 60000 - scrollUntilVisible: element: id: 'room-info-edit-view-topic' diff --git a/.maestro/tests/room/room.yaml b/.maestro/tests/room/room.yaml index fa6c6f983b8..b29ad58be49 100644 --- a/.maestro/tests/room/room.yaml +++ b/.maestro/tests/room/room.yaml @@ -253,10 +253,8 @@ tags: visible: id: 'action-sheet-handle' timeout: 60000 -- swipe: - from: - id: action-sheet-handle - direction: DOWN +- tapOn: + id: 'action-sheet-handle' - extendedWaitUntil: notVisible: id: 'reactionsList' @@ -318,22 +316,23 @@ tags: visible: text: '.*edit.*' timeout: 60000 +- hideKeyboard - longPressOn: id: 'message-content-edit' +- waitForAnimationToEnd: + timeout: 1000 - extendedWaitUntil: visible: id: action-sheet timeout: 60000 - extendedWaitUntil: visible: - id: action-sheet-handle - timeout: 60000 -- extendedWaitUntil: - visible: - text: 'Edit' + id: message-actions-edit timeout: 60000 +- waitForAnimationToEnd: + timeout: 1000 - tapOn: - text: 'Edit' + id: message-actions-edit - extendedWaitUntil: visible: id: message-composer-input @@ -364,10 +363,10 @@ tags: timeout: 60000 - extendedWaitUntil: visible: - text: 'Quote' + id: message-actions-quote timeout: 60000 - tapOn: - text: 'Quote' + id: message-actions-quote - extendedWaitUntil: visible: id: message-composer-input @@ -420,9 +419,9 @@ tags: direction: UP - scrollUntilVisible: element: - text: 'Delete' + id: message-actions-delete - tapOn: - text: 'Delete' + id: message-actions-delete - extendedWaitUntil: visible: text: '.*You will not be able to recover this message.*' @@ -472,10 +471,10 @@ tags: timeout: 60000 - extendedWaitUntil: visible: - text: 'Reply in direct message' + id: message-actions-reply-in-dm timeout: 60000 - tapOn: - text: 'Reply in direct message' + id: message-actions-reply-in-dm - extendedWaitUntil: visible: id: room-view-title-${output.replyUser.username} @@ -487,7 +486,7 @@ tags: - tapOn: id: message-composer-input - inputText: ${output.replyMessage} -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - tapOn: id: message-composer-send - extendedWaitUntil: @@ -510,7 +509,7 @@ tags: - tapOn: id: message-composer-input - inputText: 'draft' -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - runFlow: '../../helpers/go-back.yaml' - runFlow: file: '../../helpers/navigate-to-room.yaml' @@ -565,7 +564,7 @@ tags: - tapOn: id: message-composer-input - inputText: ${output.draftMessage} -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - runFlow: '../../helpers/go-back.yaml' - runFlow: file: '../../helpers/navigate-to-room.yaml' @@ -586,7 +585,7 @@ tags: id: action-sheet timeout: 60000 - tapOn: - text: 'Quote' + id: message-actions-quote - extendedWaitUntil: visible: id: 'markdown-preview-${output.originalMessage}' diff --git a/.maestro/tests/room/share-message.yaml b/.maestro/tests/room/share-message.yaml index 4bbf18fff3e..d7a339a9030 100644 --- a/.maestro/tests/room/share-message.yaml +++ b/.maestro/tests/room/share-message.yaml @@ -68,17 +68,15 @@ tags: - tapOn: id: 'multi-select-search' - inputText: ${output.otherUser.username} -- hideKeyboard - extendedWaitUntil: visible: id: multi-select-item-${output.otherUser.username.toLowerCase()} timeout: 60000 - tapOn: id: multi-select-item-${output.otherUser.username.toLowerCase()} -- swipe: - from: - id: 'action-sheet-handle' - direction: DOWN +# this is to hide bottom sheet and keyboard together +- tapOn: + point: 5%,10% - extendedWaitUntil: notVisible: id: 'multi-select-search' diff --git a/.maestro/tests/teams/team.yaml b/.maestro/tests/teams/team.yaml index 67ba897ffb1..4207534d68c 100644 --- a/.maestro/tests/teams/team.yaml +++ b/.maestro/tests/teams/team.yaml @@ -106,12 +106,12 @@ tags: - tapOn: id: create-channel-name - inputText: ${output.privateRoomName} -- hideKeyboard -- extendedWaitUntil: - visible: - id: create-channel-submit +- runFlow: '../../helpers/hide-keyboard.yaml' +- scrollUntilVisible: + element: + id: 'create-channel-submit' timeout: 60000 - label: should have submit button + centerElement: true - tapOn: id: create-channel-submit - extendedWaitUntil: @@ -297,7 +297,7 @@ tags: - tapOn: id: select-users-view-search - inputText: rocket.cat -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - extendedWaitUntil: visible: id: select-users-view-item-rocket.cat @@ -316,7 +316,7 @@ tags: id: select-users-view-search - eraseText - inputText: ${output.secondUser.username} -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - extendedWaitUntil: visible: id: select-users-view-item-${output.secondUser.username} diff --git a/.maestro/tests/teams/utils/close-action-sheet.yaml b/.maestro/tests/teams/utils/close-action-sheet.yaml index bd6d32cf6a8..8a19f95eebc 100644 --- a/.maestro/tests/teams/utils/close-action-sheet.yaml +++ b/.maestro/tests/teams/utils/close-action-sheet.yaml @@ -3,10 +3,8 @@ tags: - 'util' --- -- swipe: - from: - id: action-sheet-handle - direction: DOWN +- tapOn: + id: action-sheet-handle - extendedWaitUntil: notVisible: id: action-sheet-handle diff --git a/.maestro/tests/teams/utils/create-channel.yaml b/.maestro/tests/teams/utils/create-channel.yaml index 679c0c2c8c8..e8f9d91fcb2 100644 --- a/.maestro/tests/teams/utils/create-channel.yaml +++ b/.maestro/tests/teams/utils/create-channel.yaml @@ -34,7 +34,7 @@ tags: - tapOn: id: create-channel-name - inputText: ${channelName} -- hideKeyboard +- runFlow: '../../../helpers/hide-keyboard.yaml' - runFlow: when: diff --git a/.maestro/tests/teams/utils/open-action-sheet.yaml b/.maestro/tests/teams/utils/open-action-sheet.yaml index 5b6083c0515..b3bbd9808aa 100644 --- a/.maestro/tests/teams/utils/open-action-sheet.yaml +++ b/.maestro/tests/teams/utils/open-action-sheet.yaml @@ -7,16 +7,16 @@ tags: visible: id: room-members-view-item-${username} timeout: 60000 +- waitForAnimationToEnd: + timeout: 2000 - tapOn: id: room-members-view-item-${username} +- waitForAnimationToEnd: + timeout: 2000 - extendedWaitUntil: visible: id: action-sheet timeout: 60000 -- assertVisible: - id: action-sheet-handle -- tapOn: - id: action-sheet-handle - extendedWaitUntil: visible: id: action-sheet diff --git a/__mocks__/react-native-mmkv.js b/__mocks__/react-native-mmkv.js index 3288748482c..44b12dcf0c9 100644 --- a/__mocks__/react-native-mmkv.js +++ b/__mocks__/react-native-mmkv.js @@ -84,7 +84,7 @@ export class MMKV { } notifyListeners(key) { - this.listeners.forEach((listener) => { + this.listeners.forEach(listener => { try { listener(key); } catch (error) { @@ -102,7 +102,7 @@ export function useMMKVString(key, mmkvInstance) { const [value, setValue] = useState(() => mmkvInstance.getString(key)); useEffect(() => { - const listener = mmkvInstance.addOnValueChangedListener((changedKey) => { + const listener = mmkvInstance.addOnValueChangedListener(changedKey => { if (changedKey === key || changedKey === undefined) { setValue(mmkvInstance.getString(key)); } @@ -110,7 +110,7 @@ export function useMMKVString(key, mmkvInstance) { return () => listener.remove(); }, [key, mmkvInstance]); - const setStoredValue = (newValue) => { + const setStoredValue = newValue => { if (newValue === undefined) { mmkvInstance.delete(key); } else { @@ -126,7 +126,7 @@ export function useMMKVNumber(key, mmkvInstance) { const [value, setValue] = useState(() => mmkvInstance.getNumber(key)); useEffect(() => { - const listener = mmkvInstance.addOnValueChangedListener((changedKey) => { + const listener = mmkvInstance.addOnValueChangedListener(changedKey => { if (changedKey === key || changedKey === undefined) { setValue(mmkvInstance.getNumber(key)); } @@ -134,7 +134,7 @@ export function useMMKVNumber(key, mmkvInstance) { return () => listener.remove(); }, [key, mmkvInstance]); - const setStoredValue = (newValue) => { + const setStoredValue = newValue => { if (newValue === undefined) { mmkvInstance.delete(key); } else { @@ -150,7 +150,7 @@ export function useMMKVBoolean(key, mmkvInstance) { const [value, setValue] = useState(() => mmkvInstance.getBoolean(key)); useEffect(() => { - const listener = mmkvInstance.addOnValueChangedListener((changedKey) => { + const listener = mmkvInstance.addOnValueChangedListener(changedKey => { if (changedKey === key || changedKey === undefined) { setValue(mmkvInstance.getBoolean(key)); } @@ -158,7 +158,7 @@ export function useMMKVBoolean(key, mmkvInstance) { return () => listener.remove(); }, [key, mmkvInstance]); - const setStoredValue = (newValue) => { + const setStoredValue = newValue => { if (newValue === undefined) { mmkvInstance.delete(key); } else { @@ -177,7 +177,7 @@ export function useMMKVObject(key, mmkvInstance) { }); useEffect(() => { - const listener = mmkvInstance.addOnValueChangedListener((changedKey) => { + const listener = mmkvInstance.addOnValueChangedListener(changedKey => { if (changedKey === key || changedKey === undefined) { const stored = mmkvInstance.getString(key); setValue(stored ? JSON.parse(stored) : undefined); @@ -186,7 +186,7 @@ export function useMMKVObject(key, mmkvInstance) { return () => listener.remove(); }, [key, mmkvInstance]); - const setStoredValue = (newValue) => { + const setStoredValue = newValue => { if (newValue === undefined) { mmkvInstance.delete(key); } else { diff --git a/android/app/build.gradle b/android/app/build.gradle index 0143ee4d12e..91a523ac0eb 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -152,6 +152,9 @@ dependencies { // For SecureKeystore (EncryptedSharedPreferences) implementation 'androidx.security:security-crypto:1.1.0' + + // For ProcessLifecycleOwner (app foreground detection) + implementation 'androidx.lifecycle:lifecycle-process:2.8.7' } apply plugin: 'com.google.gms.google-services' diff --git a/android/app/src/main/assets/fonts/custom.ttf b/android/app/src/main/assets/fonts/custom.ttf index b0758f2fa60..ace29c7f232 100644 Binary files a/android/app/src/main/assets/fonts/custom.ttf and b/android/app/src/main/assets/fonts/custom.ttf differ diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java b/android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java index 22339abd691..622c44415b9 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java @@ -17,6 +17,8 @@ import android.util.Log; import androidx.annotation.Nullable; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.ProcessLifecycleOwner; import com.google.gson.Gson; @@ -68,6 +70,14 @@ public CustomPushNotification(Context context, Bundle bundle) { public static void clearMessages(int notId) { notificationMessages.remove(Integer.toString(notId)); } + + /** + * Checks if the app is currently in the foreground. + * Uses ProcessLifecycleOwner to reliably detect app state. + */ + public static boolean isAppInForeground() { + return ProcessLifecycleOwner.get().getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED); + } public void onReceived() { String notId = mBundle.getString("notId"); @@ -102,6 +112,11 @@ private void handleNotification() { return; // Exit early, notification will be processed in callback } + if (receivedEjson != null && receivedEjson.notificationType != null && receivedEjson.notificationType.equals("voip")) { + Log.d(TAG, "Notification is a voip notification, ignoring"); + return; + } + // For non-message-id-only notifications, process immediately processNotification(); } @@ -228,6 +243,15 @@ private void showNotification(Bundle bundle, Ejson ejson, String notId) { if (ENABLE_VERBOSE_LOGS) { Log.d(TAG, "[Before add to notificationMessages] notId=" + notId + ", bundle.message length=" + (bundle.getString("message") != null ? bundle.getString("message").length() : 0) + ", bundle.notificationLoaded=" + bundle.getBoolean("notificationLoaded", false)); } + + // Don't show notification if app is in foreground + if (isAppInForeground()) { + if (ENABLE_VERBOSE_LOGS) { + Log.d(TAG, "App is in foreground, skipping native notification"); + } + return; + } + notificationMessages.get(notId).add(bundle); if (ENABLE_VERBOSE_LOGS) { Log.d(TAG, "[After add] notificationMessages[" + notId + "].size=" + notificationMessages.get(notId).size()); diff --git a/app/containers/ActionSheet/ActionSheet.tsx b/app/containers/ActionSheet/ActionSheet.tsx index 65f65d551dc..4725bcea93b 100644 --- a/app/containers/ActionSheet/ActionSheet.tsx +++ b/app/containers/ActionSheet/ActionSheet.tsx @@ -1,98 +1,53 @@ import { useBackHandler } from '@react-native-community/hooks'; import * as Haptics from 'expo-haptics'; -import React, { forwardRef, isValidElement, useEffect, useImperativeHandle, useRef, useState, useCallback } from 'react'; -import { Keyboard, type LayoutChangeEvent, useWindowDimensions } from 'react-native'; -import { Easing, useDerivedValue, useSharedValue } from 'react-native-reanimated'; -import BottomSheet, { BottomSheetBackdrop, type BottomSheetBackdropProps } from '@discord/bottom-sheet'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import React, { forwardRef, isValidElement, useImperativeHandle, useRef, useState } from 'react'; +import { Keyboard, type LayoutChangeEvent, Platform, useWindowDimensions } from 'react-native'; +import { TrueSheet } from '@lodev09/react-native-true-sheet'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { useTheme } from '../../theme'; -import { isIOS, isTablet } from '../../lib/methods/helpers'; +import { isAndroid, isIOS } from '../../lib/methods/helpers'; import { Handle } from './Handle'; import { type TActionSheetOptions } from './Provider'; import BottomSheetContent from './BottomSheetContent'; +import { HANDLE_HEIGHT, useActionSheetDetents } from './useActionSheetDetents'; import styles from './styles'; export const ACTION_SHEET_ANIMATION_DURATION = 250; -const HANDLE_HEIGHT = 28; -const CANCEL_HEIGHT = 64; - -const ANIMATION_CONFIG = { - duration: ACTION_SHEET_ANIMATION_DURATION, - // https://easings.net/#easeInOutCubic - easing: Easing.bezier(0.645, 0.045, 0.355, 1.0) -}; const ActionSheet = React.memo( forwardRef(({ children }: { children: React.ReactElement }, ref) => { const { colors } = useTheme(); - const { height: windowHeight } = useWindowDimensions(); - const { bottom, right, left } = useSafeAreaInsets(); - const { fontScale } = useWindowDimensions(); - const itemHeight = 48 * fontScale; - const bottomSheetRef = useRef(null); + const { height: windowHeight, width: windowWidth, fontScale } = useWindowDimensions(); + const sheetRef = useRef(null); const [data, setData] = useState({} as TActionSheetOptions); - const [isVisible, setVisible] = useState(false); - const animatedContentHeight = useSharedValue(0); - const animatedHandleHeight = useSharedValue(0); - const animatedDataSnaps = useSharedValue([]); - const animatedSnapPoints = useDerivedValue(() => { - if (animatedDataSnaps.value?.length) { - return animatedDataSnaps.value; - } - const contentWithHandleHeight = animatedContentHeight.value + animatedHandleHeight.value; - // Bottom sheet requires a default value to work - if (contentWithHandleHeight === 0) { - return ['25%']; - } - return [contentWithHandleHeight]; - }, [data]); - - const handleContentLayout = useCallback( - ({ - nativeEvent: { - layout: { height } - } - }: LayoutChangeEvent) => { - /** - * This logic is only necessary to prevent the action sheet from - * occupying the entire screen when the dynamic content is too big. - */ - animatedContentHeight.value = Math.min(height, windowHeight * 0.8); - }, - [animatedContentHeight, windowHeight] - ); - - const maxSnap = Math.min( - (itemHeight + 0.5) * (data?.options?.length || 0) + - HANDLE_HEIGHT + - // Custom header height - (data?.headerHeight || 0) + - // Insets bottom height (Notch devices) - bottom + - // Cancel button height - (data?.hasCancel ? CANCEL_HEIGHT : 0), - windowHeight * 0.8 - ); - - /* - * if the action sheet cover more than 60% of the screen height, - * we'll provide more one snap of 50% - */ - const snaps = maxSnap > windowHeight * 0.6 && !data.snaps ? ['50%', maxSnap] : [maxSnap]; + const [isVisible, setIsVisible] = useState(false); + const [contentHeight, setContentHeight] = useState(0); + const onCloseSnapshotRef = useRef(undefined); + + // TrueSheet detects the bottom inset for Android 16 and iOS + // To avoid content hiding behind navigation bar on older Android versions + const isNewAndroid = isAndroid && Number(Platform.Version) >= 36; + const bottom = isIOS || isNewAndroid ? 0 : windowHeight * 0.03; + const itemHeight = 48 * fontScale; - const toggleVisible = () => setVisible(!isVisible); + const handleContentLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { + setContentHeight(layout.height); + }; const hide = () => { - bottomSheetRef.current?.close(); + sheetRef.current?.dismiss(); + Keyboard.dismiss(); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); }; const show = (options: TActionSheetOptions) => { setData(options); - if (options.snaps?.length) { - animatedDataSnaps.value = options.snaps; - } - toggleVisible(); + setIsVisible(true); + Keyboard.dismiss(); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + onCloseSnapshotRef.current = options.onClose; + sheetRef.current?.present(); }; useBackHandler(() => { @@ -102,81 +57,83 @@ const ActionSheet = React.memo( return isVisible; }); - useEffect(() => { - if (isVisible) { - Keyboard.dismiss(); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } - }, [isVisible]); - useImperativeHandle(ref, () => ({ showActionSheet: show, hideActionSheet: hide })); - const renderHandle = () => ( - <> - + const renderHeader = () => ( + + {isValidElement(data?.customHeader) ? data.customHeader : null} - + ); - const onClose = () => { - toggleVisible(); - data?.onClose && data?.onClose(); - animatedDataSnaps.value = []; + const onDidDismiss = () => { + setIsVisible(false); + // Keep contentHeight to avoid flickering on next show + const snapshotOnClose = onCloseSnapshotRef.current; + onCloseSnapshotRef.current = undefined; + snapshotOnClose?.(); }; - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [] - ); + const isPortrait = windowHeight > windowWidth; + const effectiveSnaps = (isPortrait ? data?.portraitSnaps : data?.landscapeSnaps) || data?.snaps; + + const { detents, maxHeight, scrollEnabled } = useActionSheetDetents({ + windowHeight, + bottomInset: bottom, + itemHeight, + optionsLength: data?.options?.length || 0, + snaps: effectiveSnaps, + headerHeight: data?.headerHeight, + hasCancel: data?.hasCancel, + contentHeight + }); - const bottomSheet = isTablet ? styles.bottomSheet : { marginRight: right, marginLeft: left }; + const hasOptions = !!data?.options?.length; + const hasSnaps = !!effectiveSnaps?.length; + const disableContentPanning = data?.enableContentPanningGesture === false || (!scrollEnabled && isAndroid); + const isScrollable = hasOptions || (hasSnaps && !disableContentPanning); - // Must need this prop to avoid keyboard dismiss - // when is android tablet and the input text is focused - const androidTablet: any = isTablet && !isIOS ? { android_keyboardInputMode: 'adjustResize' } : {}; + const contentMinHeight = + data.fullContainer && effectiveSnaps?.length + ? (() => { + const snap = effectiveSnaps[0]; + const fraction = typeof snap === 'number' ? Math.min(1, Math.max(0.1, snap)) : (parseFloat(String(snap)) || 50) / 100; + return Math.max(0, windowHeight * fraction - HANDLE_HEIGHT); + })() + : undefined; return ( <> {children} - {isVisible && ( - index === -1 && onClose()} - // We need this to allow horizontal swipe gesture inside the bottom sheet like in reaction picker - enableContentPanningGesture={data?.enableContentPanningGesture ?? true} - {...androidTablet}> + + - - )} + fullContainer={data.fullContainer} + contentMinHeight={isIOS ? contentMinHeight : undefined} + scrollEnabled={scrollEnabled}> + {data?.children} + + + ); }) diff --git a/app/containers/ActionSheet/ActionSheetContentWithInputAndSubmit/index.tsx b/app/containers/ActionSheet/ActionSheetContentWithInputAndSubmit/index.tsx index d4315d2b375..8ae1f030cc7 100644 --- a/app/containers/ActionSheet/ActionSheetContentWithInputAndSubmit/index.tsx +++ b/app/containers/ActionSheet/ActionSheetContentWithInputAndSubmit/index.tsx @@ -3,7 +3,6 @@ import { StyleSheet, Text, type TextInputProps, View } from 'react-native'; import { CustomIcon, type TIconsName } from '../../CustomIcon'; import i18n from '../../../i18n'; -import { isIOS } from '../../../lib/methods/helpers'; import { useTheme } from '../../../theme'; import sharedStyles from '../../../views/Styles'; import Button from '../../Button'; @@ -137,7 +136,6 @@ const ActionSheetContentWithInputAndSubmit = ({ inputRef={inputRefs.current[index] as any} testID={`${testID}-input-${inputConfig.key}`} secureTextEntry={inputConfig.secureTextEntry} - bottomSheet={isIOS} /> )); } @@ -156,7 +154,6 @@ const ActionSheetContentWithInputAndSubmit = ({ autoComplete={autoComplete} testID={`${testID}-input`} secureTextEntry={secureTextEntry} - bottomSheet={isIOS} containerStyle={{ marginTop: 12, marginBottom: 36 }} /> ); diff --git a/app/containers/ActionSheet/BottomSheetContent.tsx b/app/containers/ActionSheet/BottomSheetContent.tsx index 267c4b7a20d..e1a0e0d1d43 100644 --- a/app/containers/ActionSheet/BottomSheetContent.tsx +++ b/app/containers/ActionSheet/BottomSheetContent.tsx @@ -1,10 +1,10 @@ -import { Text, useWindowDimensions, type ViewProps } from 'react-native'; +import { FlatList, Text, useWindowDimensions, View, type ViewProps } from 'react-native'; import React from 'react'; -import { BottomSheetView, BottomSheetFlatList } from '@discord/bottom-sheet'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import I18n from '../../i18n'; import { useTheme } from '../../theme'; +import { isAndroid } from '../../lib/methods/helpers'; import { type IActionSheetItem, Item } from './Item'; import { type TActionSheetOptionsItem } from './Provider'; import styles from './styles'; @@ -17,53 +17,71 @@ interface IBottomSheetContentProps { hide: () => void; children?: React.ReactElement | null; onLayout: ViewProps['onLayout']; + fullContainer?: boolean; + contentMinHeight?: number; + scrollEnabled?: boolean; } -const BottomSheetContent = React.memo(({ options, hasCancel, hide, children, onLayout }: IBottomSheetContentProps) => { - 'use memo'; +const BottomSheetContent = React.memo( + ({ + options, + hasCancel, + hide, + children, + onLayout, + fullContainer, + contentMinHeight, + scrollEnabled + }: IBottomSheetContentProps) => { + 'use memo'; - const { colors } = useTheme(); - const { bottom } = useSafeAreaInsets(); - const { fontScale } = useWindowDimensions(); - const height = 48 * fontScale; + const { colors } = useTheme(); + const { bottom } = useSafeAreaInsets(); + const { fontScale } = useWindowDimensions(); + const height = 48 * fontScale; + const paddingBottom = isAndroid ? bottom + height : bottom; + const minHeightStyle = isAndroid || !contentMinHeight ? undefined : { minHeight: contentMinHeight }; - const renderFooter = () => - hasCancel ? ( - - {I18n.t('Cancel')} - - ) : null; + const renderFooter = () => + hasCancel ? ( + + {I18n.t('Cancel')} + + ) : null; - const renderItem = ({ item }: { item: IActionSheetItem['item'] }) => ; + const renderItem = ({ item }: { item: IActionSheetItem['item'] }) => ; - if (options) { + if (options) { + return ( + item.title} + bounces={false} + renderItem={renderItem} + style={{ backgroundColor: colors.strokeExtraDark }} + keyboardDismissMode='interactive' + indicatorStyle='black' + contentContainerStyle={{ paddingBottom, backgroundColor: colors.surfaceLight }} + ItemSeparatorComponent={List.Separator} + ListHeaderComponent={List.Separator} + ListFooterComponent={renderFooter} + onLayout={onLayout} + scrollEnabled={scrollEnabled} + nestedScrollEnabled={scrollEnabled && isAndroid} + /> + ); + } return ( - item.title} - bounces={false} - renderItem={renderItem} - style={{ backgroundColor: colors.strokeExtraDark }} - keyboardDismissMode='interactive' - indicatorStyle='black' - contentContainerStyle={{ paddingBottom: bottom, backgroundColor: colors.surfaceLight }} - ItemSeparatorComponent={List.Separator} - ListHeaderComponent={List.Separator} - ListFooterComponent={renderFooter} - onLayout={onLayout} - /> + + {children} + ); } - return ( - - {children} - - ); -}); +); export default BottomSheetContent; diff --git a/app/containers/ActionSheet/Handle.tsx b/app/containers/ActionSheet/Handle.tsx index f155de2fafa..fd14f0d2d03 100644 --- a/app/containers/ActionSheet/Handle.tsx +++ b/app/containers/ActionSheet/Handle.tsx @@ -1,17 +1,26 @@ import React from 'react'; import { View } from 'react-native'; +import { Pressable } from 'react-native-gesture-handler'; import styles from './styles'; import { themes } from '../../lib/constants/colors'; import { useTheme } from '../../theme'; -export const Handle = React.memo(() => { +export const Handle = ({ onPress }: { onPress: () => void }) => { 'use memo'; const { theme } = useTheme(); + + // We should use Pressable from gesture-handler to avoid issues with the keyboard return ( - + - + ); -}); +}; diff --git a/app/containers/ActionSheet/Provider.tsx b/app/containers/ActionSheet/Provider.tsx index d74043234e3..849924daf1a 100644 --- a/app/containers/ActionSheet/Provider.tsx +++ b/app/containers/ActionSheet/Provider.tsx @@ -26,10 +26,15 @@ export type TActionSheetOptions = { hasCancel?: boolean; // children can both use snaps or dynamic children?: React.ReactElement | null; - /** Required if your action sheet needs vertical scroll */ + // Required if your action sheet needs vertical scroll snaps?: (string | number)[]; + // Optional snaps specifically for portrait orientation + portraitSnaps?: (string | number)[]; + // Optional snaps specifically for landscape orientation + landscapeSnaps?: (string | number)[]; onClose?: () => void; enableContentPanningGesture?: boolean; + fullContainer?: boolean; }; export interface IActionSheetProvider { showActionSheet: (item: TActionSheetOptions) => void; diff --git a/app/containers/ActionSheet/styles.ts b/app/containers/ActionSheet/styles.ts index ad9d40831e3..5a98ca3b545 100644 --- a/app/containers/ActionSheet/styles.ts +++ b/app/containers/ActionSheet/styles.ts @@ -4,9 +4,7 @@ import sharedStyles from '../../views/Styles'; export default StyleSheet.create({ container: { - overflow: 'hidden', - borderTopLeftRadius: 16, - borderTopRightRadius: 16 + overflow: 'hidden' }, item: { paddingHorizontal: 16, @@ -43,10 +41,6 @@ export default StyleSheet.create({ backdrop: { ...StyleSheet.absoluteFillObject }, - bottomSheet: { - width: '50%', - marginHorizontal: '25%' - }, button: { marginHorizontal: 16, paddingHorizontal: 14, @@ -70,6 +64,11 @@ export default StyleSheet.create({ marginRight: 8 }, contentContainer: { - flex: 1 + flex: 0 + }, + fullContainer: { + width: '100%', + height: '100%', + flex: 0 } }); diff --git a/app/containers/ActionSheet/useActionSheetDetents.test.tsx b/app/containers/ActionSheet/useActionSheetDetents.test.tsx new file mode 100644 index 00000000000..51be4312ffe --- /dev/null +++ b/app/containers/ActionSheet/useActionSheetDetents.test.tsx @@ -0,0 +1,93 @@ +import { renderHook } from '@testing-library/react-native'; + +import { useActionSheetDetents } from './useActionSheetDetents'; + +describe('useActionSheetDetents', () => { + const windowHeight = 1000; + + it('normalizes custom snaps when provided', () => { + const { result } = renderHook(() => + useActionSheetDetents({ + windowHeight, + bottomInset: 0, + itemHeight: 0, + optionsLength: 0, + snaps: [0.3, '80%', 2], + headerHeight: 0, + hasCancel: false, + contentHeight: 0 + }) + ); + + expect(result.current.detents).toEqual([0.3, 0.8, 1]); + }); + + it('returns two detents when options content is tall', () => { + const { result } = renderHook(() => + useActionSheetDetents({ + windowHeight, + bottomInset: 16, + itemHeight: 50, + optionsLength: 20, + snaps: undefined, + headerHeight: 24, + hasCancel: true, + contentHeight: 0 + }) + ); + + expect(result.current.maxHeight).toBe(windowHeight * 0.75); + expect(result.current.detents).toEqual([0.5, 0.75]); + }); + + it('returns a single clamped detent when options content is short', () => { + const { result } = renderHook(() => + useActionSheetDetents({ + windowHeight, + bottomInset: 10, + itemHeight: 20, + optionsLength: 3, + snaps: undefined, + headerHeight: 10, + hasCancel: false, + contentHeight: 0 + }) + ); + + expect(result.current.detents).toEqual([0.108]); + }); + + it('computes detent from content height when there are no options', () => { + const { result } = renderHook(() => + useActionSheetDetents({ + windowHeight, + bottomInset: 50, + itemHeight: 0, + optionsLength: 0, + snaps: undefined, + headerHeight: 0, + hasCancel: false, + contentHeight: 300 + }) + ); + + expect(result.current.detents).toEqual([(300 + 50) / windowHeight]); + }); + + it('falls back to minimum height when no content or options', () => { + const { result } = renderHook(() => + useActionSheetDetents({ + windowHeight, + bottomInset: 0, + itemHeight: 0, + optionsLength: 0, + snaps: undefined, + headerHeight: 0, + hasCancel: false, + contentHeight: 0 + }) + ); + + expect(result.current.detents).toEqual([0.15]); + }); +}); diff --git a/app/containers/ActionSheet/useActionSheetDetents.ts b/app/containers/ActionSheet/useActionSheetDetents.ts new file mode 100644 index 00000000000..7dd747fa935 --- /dev/null +++ b/app/containers/ActionSheet/useActionSheetDetents.ts @@ -0,0 +1,94 @@ +import type { SheetDetent } from '@lodev09/react-native-true-sheet'; +import { useMemo } from 'react'; +import { useWindowDimensions } from 'react-native'; + +const ACTION_SHEET_MIN_HEIGHT_FRACTION = 0.15; +const ACTION_SHEET_MAX_HEIGHT_FRACTION = 0.75; +const SCROLL_ENABLED_THRESHOLD = 0.6; +export const HANDLE_HEIGHT = 28; + +function normalizeSnapsToDetents(snaps: (string | number)[]): number[] { + return snaps + .slice(0, 3) + .map(snap => { + if (typeof snap === 'number') { + if (snap <= 0 || snap > 1) return Math.min(1, Math.max(0.1, snap)); + return snap; + } + const match = String(snap).match(/^(\d+(?:\.\d+)?)\s*%$/); + if (match) return Math.min(1, Math.max(0.1, Number(match[1]) / 100)); + return 0.5; + }) + .sort((a, b) => a - b); +} + +type UseActionSheetDetentsParams = { + windowHeight: number; + bottomInset: number; + itemHeight: number; + optionsLength?: number; + snaps?: (string | number)[]; + headerHeight?: number; + hasCancel?: boolean; + contentHeight: number; +}; + +function heightToDetent(height: number, screenHeight: number): number { + return Math.max(0, height / screenHeight); +} + +export function useActionSheetDetents({ + windowHeight, + bottomInset, + itemHeight, + optionsLength = 0, + snaps, + headerHeight = 0, + hasCancel = false, + contentHeight +}: UseActionSheetDetentsParams): { detents: SheetDetent[]; maxHeight: number; scrollEnabled: boolean } { + const { fontScale } = useWindowDimensions(); + const CANCEL_HEIGHT = 48 * fontScale; + + return useMemo(() => { + const maxHeight = windowHeight * ACTION_SHEET_MAX_HEIGHT_FRACTION; + const hasOptions = optionsLength > 0; + + const maxSnap = hasOptions + ? Math.min( + (itemHeight + 0.5) * optionsLength + HANDLE_HEIGHT + headerHeight + bottomInset + (hasCancel ? CANCEL_HEIGHT : 0), + maxHeight + ) + : 0; + + let detents: SheetDetent[]; + let scrollEnabled = false; + + if (snaps?.length) { + detents = normalizeSnapsToDetents(snaps); + } else if (hasOptions) { + if (maxSnap > windowHeight * SCROLL_ENABLED_THRESHOLD) { + detents = [0.5, ACTION_SHEET_MAX_HEIGHT_FRACTION]; + scrollEnabled = true; + } else { + const measuredHeight = + optionsLength * itemHeight + HANDLE_HEIGHT + headerHeight + bottomInset + (hasCancel ? CANCEL_HEIGHT : 0); + + scrollEnabled = false; + detents = [heightToDetent(Math.round(measuredHeight), windowHeight)]; + } + } else if (contentHeight > 0) { + const rawContentDetent = (contentHeight + bottomInset) / windowHeight; + const contentDetent = Math.min( + ACTION_SHEET_MAX_HEIGHT_FRACTION, + Math.max(ACTION_SHEET_MIN_HEIGHT_FRACTION, rawContentDetent) + ); + + detents = [contentDetent]; + } else { + detents = [ACTION_SHEET_MIN_HEIGHT_FRACTION]; + } + + return { detents, maxHeight, scrollEnabled }; + }, [bottomInset, contentHeight, hasCancel, headerHeight, itemHeight, optionsLength, snaps, windowHeight]); +} diff --git a/app/containers/AudioPlayer/Seek.tsx b/app/containers/AudioPlayer/Seek.tsx index eecd5d444e0..6b70602df0f 100644 --- a/app/containers/AudioPlayer/Seek.tsx +++ b/app/containers/AudioPlayer/Seek.tsx @@ -65,7 +65,7 @@ const Seek = ({ currentTime, duration, loaded = false, onChangeTime }: ISeek) => }; const onGestureEvent = useAnimatedGestureHandler({ - onStart: (event, ctx) => { + onStart: (_event, ctx) => { isPanning.value = true; ctx.offsetX = translateX.value; }, diff --git a/app/containers/Avatar/Avatar.stories.tsx b/app/containers/Avatar/Avatar.stories.tsx index 0a2503e3c7d..0af49c24552 100644 --- a/app/containers/Avatar/Avatar.stories.tsx +++ b/app/containers/Avatar/Avatar.stories.tsx @@ -64,7 +64,7 @@ export const CustomBorderRadius = () => ( - + ); diff --git a/app/containers/Avatar/Avatar.tsx b/app/containers/Avatar/Avatar.tsx index b203f290413..089730b0112 100644 --- a/app/containers/Avatar/Avatar.tsx +++ b/app/containers/Avatar/Avatar.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { View } from 'react-native'; import { Image } from 'expo-image'; -import Touchable from 'react-native-platform-touchable'; import { settings as RocketChatSettings } from '@rocket.chat/sdk'; import Emoji from '../markdown/components/emoji/Emoji'; @@ -10,6 +9,7 @@ import { SubscriptionType } from '../../definitions'; import { type IAvatar } from './interfaces'; import MarkdownContext from '../markdown/contexts/MarkdownContext'; import I18n from '../../i18n'; +import Touch from '../Touch'; const Avatar = React.memo( ({ @@ -97,9 +97,9 @@ const Avatar = React.memo( if (onPress) { image = ( - + {image} - + ); } diff --git a/app/containers/Avatar/AvatarContainer.tsx b/app/containers/Avatar/AvatarContainer.tsx index d9e610eda7b..4e54161efec 100644 --- a/app/containers/Avatar/AvatarContainer.tsx +++ b/app/containers/Avatar/AvatarContainer.tsx @@ -58,7 +58,6 @@ const AvatarContainer = ({ size={size} borderRadius={borderRadius} type={type} - children={children} userId={id} token={token} onPress={onPress} @@ -72,8 +71,9 @@ const AvatarContainer = ({ serverVersion={serverVersion} cdnPrefix={cdnPrefix} accessibilityLabel={accessibilityLabel} - accessible={accessible} - /> + accessible={accessible}> + {children} + ); }; diff --git a/app/containers/Avatar/AvatarWithEdit.tsx b/app/containers/Avatar/AvatarWithEdit.tsx index 49c96bd5f47..41767a01f9b 100644 --- a/app/containers/Avatar/AvatarWithEdit.tsx +++ b/app/containers/Avatar/AvatarWithEdit.tsx @@ -61,12 +61,12 @@ const AvatarWithEdit = ({ size={120} borderRadius={borderRadius} type={type} - children={children} onPress={onPress} getCustomEmoji={getCustomEmoji} isStatic={isStatic} - rid={rid} - /> + rid={rid}> + {children} + {handleEdit && serverVersion && compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '3.6.0') ? (