Compare commits
23 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c7732bae8 | ||
|
|
7a5e27e5d4 | ||
|
|
d448433381 | ||
|
|
fd6a542dd1 | ||
|
|
7d1a06f079 | ||
|
|
6a94e5d67f | ||
|
|
fa45bd9d91 | ||
|
|
545b8e9a4b | ||
|
|
37c4de6bb4 | ||
|
|
4ef7d5d6fe | ||
|
|
76031bf086 | ||
|
|
fc6eeec6c7 | ||
|
|
63dcc2fce3 | ||
|
|
bfeabe1c4e | ||
|
|
2214f06e88 | ||
|
|
73a83b6d5a | ||
|
|
6142021573 | ||
|
|
22700cb3cb | ||
|
|
c9269ceb58 | ||
|
|
033acd2baa | ||
|
|
1c4368ed7d | ||
|
|
755e9475fc | ||
|
|
e74b07051e |
116 changed files with 7619 additions and 914 deletions
|
|
@ -52,7 +52,7 @@ runs:
|
|||
- name: ci/install-pods-dependencies
|
||||
shell: bash
|
||||
env:
|
||||
INTUNE_ENABLED: ${{ inputs.intune-enabled == 'true' && '1' || '0' }}
|
||||
INTUNE_ENABLED: ${{ inputs.intune-enabled }}
|
||||
run: |
|
||||
echo "::group::install-pods-dependencies"
|
||||
echo "INTUNE_ENABLED=$INTUNE_ENABLED"
|
||||
|
|
|
|||
21
.github/workflows/build-android-beta.yml
vendored
21
.github/workflows/build-android-beta.yml
vendored
|
|
@ -24,6 +24,9 @@ jobs:
|
|||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- test
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: ci/checkout-repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
|
@ -58,3 +61,21 @@ jobs:
|
|||
with:
|
||||
name: android-build-beta-${{ github.run_id }}
|
||||
path: "*.apk"
|
||||
|
||||
# New AWS bucket deployment steps
|
||||
- name: Configure AWS credentials new bucket
|
||||
id: configure-aws-credentials-new-bucket
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1
|
||||
with:
|
||||
aws-region: us-east-1
|
||||
role-to-assume: ${{ secrets.MM_MOBILE_RELEASE_AWS_ROLE_TO_ASSUME }}
|
||||
|
||||
- name: ci/upload-android-beta-new-bucket
|
||||
env:
|
||||
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_RELEASE_AWS_NEW_BUCKET_NAME }}"
|
||||
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_BETA_MATTERMOST_WEBHOOK_URL }}"
|
||||
run: |
|
||||
echo "::group::Upload to new S3 bucket"
|
||||
bundle exec fastlane android upload_artifacts_to_s3 --env android.beta
|
||||
echo "::endgroup::"
|
||||
working-directory: ./fastlane
|
||||
|
|
|
|||
19
.github/workflows/build-android-release.yml
vendored
19
.github/workflows/build-android-release.yml
vendored
|
|
@ -24,6 +24,9 @@ jobs:
|
|||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- test
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: ci/checkout-repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
|
@ -58,3 +61,19 @@ jobs:
|
|||
with:
|
||||
name: android-build-release-${{ github.run_id }}
|
||||
path: "*.apk"
|
||||
|
||||
- name: Configure AWS credentials new bucket
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1
|
||||
with:
|
||||
aws-region: us-east-1
|
||||
role-to-assume: ${{ secrets.MM_MOBILE_RELEASE_AWS_ROLE_TO_ASSUME }}
|
||||
|
||||
- name: ci/upload-android-release-new-bucket
|
||||
env:
|
||||
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_RELEASE_AWS_NEW_BUCKET_NAME }}"
|
||||
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_RELEASE_MATTERMOST_WEBHOOK_URL }}"
|
||||
run: |
|
||||
echo "::group::Upload to new S3 bucket"
|
||||
bundle exec fastlane android upload_artifacts_to_s3 --env android.release
|
||||
echo "::endgroup::"
|
||||
working-directory: ./fastlane
|
||||
|
|
|
|||
45
.github/workflows/build-ios-beta.yml
vendored
45
.github/workflows/build-ios-beta.yml
vendored
|
|
@ -11,7 +11,7 @@ on:
|
|||
env:
|
||||
NODE_VERSION: 22.14.0
|
||||
TERM: xterm
|
||||
INTUNE_ENABLED: 1
|
||||
INTUNE_ENABLED: 'true'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
|
@ -27,6 +27,9 @@ jobs:
|
|||
if: ${{ !contains(github.ref_name, 'beta-ios') }}
|
||||
needs:
|
||||
- test
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: ci/checkout-repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
|
@ -34,7 +37,7 @@ jobs:
|
|||
- name: ci/prepare-ios-build
|
||||
uses: ./.github/actions/prepare-ios-build
|
||||
with:
|
||||
intune-enabled: 'true'
|
||||
intune-enabled: ${{ env.INTUNE_ENABLED }}
|
||||
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||
|
||||
- name: ci/build-ios-simulator
|
||||
|
|
@ -53,11 +56,30 @@ jobs:
|
|||
name: ios-build-simulator-${{ github.run_id }}
|
||||
path: Mattermost-simulator-x86_64.app.zip
|
||||
|
||||
# Logic for new bucket upload
|
||||
- name: Configure AWS credentials new bucket
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1
|
||||
with:
|
||||
aws-region: us-east-1
|
||||
role-to-assume: ${{ secrets.MM_MOBILE_RELEASE_AWS_ROLE_TO_ASSUME }}
|
||||
|
||||
- name: ci/upload-ios-simulator-new-bucket
|
||||
env:
|
||||
TAG: "${{ github.ref_name }}"
|
||||
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_RELEASE_AWS_NEW_BUCKET_NAME }}"
|
||||
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_BETA_MATTERMOST_WEBHOOK_URL }}"
|
||||
GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
|
||||
run: bundle exec fastlane ios upload_artifacts_to_s3 --env ios.simulator output_file:"Mattermost-simulator-x86_64.app.zip"
|
||||
working-directory: ./fastlane
|
||||
|
||||
build-and-deploy-ios-beta:
|
||||
runs-on: macos-15-large
|
||||
if: ${{ !contains(github.ref_name, 'beta-sim') }}
|
||||
needs:
|
||||
- test
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: ci/checkout-repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
|
@ -70,7 +92,7 @@ jobs:
|
|||
- name: ci/prepare-ios-build
|
||||
uses: ./.github/actions/prepare-ios-build
|
||||
with:
|
||||
intune-enabled: 'true'
|
||||
intune-enabled: ${{ env.INTUNE_ENABLED }}
|
||||
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||
|
||||
- name: ci/build-and-deploy-ios-beta
|
||||
|
|
@ -100,3 +122,20 @@ jobs:
|
|||
with:
|
||||
name: ios-build-beta-${{ github.run_id }}
|
||||
path: "*.ipa"
|
||||
|
||||
# Logic for new bucket upload
|
||||
- name: Configure AWS credentials new bucket
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1
|
||||
with:
|
||||
aws-region: us-east-1
|
||||
role-to-assume: ${{ secrets.MM_MOBILE_RELEASE_AWS_ROLE_TO_ASSUME }}
|
||||
|
||||
- name: ci/upload-ios-beta-new-bucket
|
||||
env:
|
||||
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_RELEASE_AWS_NEW_BUCKET_NAME }}"
|
||||
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_BETA_MATTERMOST_WEBHOOK_URL }}"
|
||||
run: |
|
||||
echo "::group::Upload to new S3 bucket"
|
||||
bundle exec fastlane ios upload_artifacts_to_s3 --env ios.beta
|
||||
echo "::endgroup::"
|
||||
working-directory: ./fastlane
|
||||
|
|
|
|||
45
.github/workflows/build-ios-release.yml
vendored
45
.github/workflows/build-ios-release.yml
vendored
|
|
@ -11,7 +11,7 @@ on:
|
|||
env:
|
||||
NODE_VERSION: 22.14.0
|
||||
TERM: xterm
|
||||
INTUNE_ENABLED: 1
|
||||
INTUNE_ENABLED: 'true'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
|
@ -27,6 +27,9 @@ jobs:
|
|||
if: ${{ !contains(github.ref_name, 'release-sim') }}
|
||||
needs:
|
||||
- test
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: ci/checkout-repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
|
@ -39,13 +42,14 @@ jobs:
|
|||
- name: ci/prepare-ios-build
|
||||
uses: ./.github/actions/prepare-ios-build
|
||||
with:
|
||||
intune-enabled: 'true'
|
||||
intune-enabled: ${{ env.INTUNE_ENABLED }}
|
||||
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||
|
||||
- name: ci/build-and-deploy-ios-release
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_RELEASE_AWS_ACCESS_KEY_ID }}"
|
||||
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_RELEASE_AWS_SECRET_ACCESS_KEY }}"
|
||||
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_RELEASE_AWS_NEW_BUCKET_NAME }}"
|
||||
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_RELEASE_MATTERMOST_WEBHOOK_URL }}"
|
||||
FASTLANE_TEAM_ID: "${{ secrets.MM_MOBILE_FASTLANE_TEAM_ID }}"
|
||||
IOS_API_ISSUER_ID: "${{ secrets.MM_MOBILE_IOS_API_ISSUER_ID }}"
|
||||
|
|
@ -70,11 +74,31 @@ jobs:
|
|||
name: ios-build-release-${{ github.run_id }}
|
||||
path: "*.ipa"
|
||||
|
||||
# Upload artifacts to new bucket
|
||||
- name: Configure AWS credentials new bucket
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1
|
||||
with:
|
||||
aws-region: us-east-1
|
||||
role-to-assume: ${{ secrets.MM_MOBILE_RELEASE_AWS_ROLE_TO_ASSUME }}
|
||||
|
||||
- name: ci/upload-ios-release-to-new-bucket
|
||||
env:
|
||||
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_RELEASE_AWS_NEW_BUCKET_NAME }}"
|
||||
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_RELEASE_MATTERMOST_WEBHOOK_URL }}"
|
||||
run: |
|
||||
echo "::group::Build"
|
||||
bundle exec fastlane ios upload_artifacts_to_s3 --env ios.release
|
||||
echo "::endgroup::"
|
||||
working-directory: ./fastlane
|
||||
|
||||
build-ios-simulator:
|
||||
runs-on: macos-15-large
|
||||
if: ${{ !contains(github.ref_name , 'release-ios') }}
|
||||
needs:
|
||||
- test
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: ci/checkout-repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
|
@ -82,7 +106,7 @@ jobs:
|
|||
- name: ci/prepare-ios-build
|
||||
uses: ./.github/actions/prepare-ios-build
|
||||
with:
|
||||
intune-enabled: 'true'
|
||||
intune-enabled: ${{ env.INTUNE_ENABLED }}
|
||||
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||
|
||||
- name: ci/build-ios-simulator
|
||||
|
|
@ -100,3 +124,18 @@ jobs:
|
|||
with:
|
||||
name: ios-build-simulator-${{ github.run_id }}
|
||||
path: Mattermost-simulator-x86_64.app.zip
|
||||
|
||||
# Logic for new bucket upload
|
||||
- name: Configure AWS credentials new bucket
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1
|
||||
with:
|
||||
aws-region: us-east-1
|
||||
role-to-assume: ${{ secrets.MM_MOBILE_RELEASE_AWS_ROLE_TO_ASSUME }}
|
||||
|
||||
- name: ci/upload-ios-simulator-new-bucket
|
||||
env:
|
||||
TAG: "${{ github.ref_name }}"
|
||||
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_BETA_AWS_NEW_BUCKET_NAME }}"
|
||||
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_BETA_MATTERMOST_WEBHOOK_URL }}"
|
||||
run: bundle exec fastlane ios simulator --env ios.simulator output_file:"Mattermost-simulator-x86_64.app.zip"
|
||||
working-directory: ./fastlane
|
||||
|
|
|
|||
4
.github/workflows/build-pr.yml
vendored
4
.github/workflows/build-pr.yml
vendored
|
|
@ -8,7 +8,7 @@ on:
|
|||
env:
|
||||
NODE_VERSION: 22.14.0
|
||||
TERM: xterm
|
||||
INTUNE_ENABLED: 1
|
||||
INTUNE_ENABLED: 'true'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
|
@ -41,7 +41,7 @@ jobs:
|
|||
- name: ci/prepare-ios-build
|
||||
uses: ./.github/actions/prepare-ios-build
|
||||
with:
|
||||
intune-enabled: 'true'
|
||||
intune-enabled: ${{ env.INTUNE_ENABLED }}
|
||||
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||
|
||||
- name: ci/build-ios-pr
|
||||
|
|
|
|||
4
.github/workflows/e2e-detox-pr.yml
vendored
4
.github/workflows/e2e-detox-pr.yml
vendored
|
|
@ -15,7 +15,7 @@ concurrency:
|
|||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
INTUNE_ENABLED: 1
|
||||
INTUNE_ENABLED: 'true'
|
||||
|
||||
jobs:
|
||||
update-initial-status-ios:
|
||||
|
|
@ -59,7 +59,7 @@ jobs:
|
|||
- name: Prepare iOS Build
|
||||
uses: ./.github/actions/prepare-ios-build
|
||||
with:
|
||||
intune-enabled: 'true'
|
||||
intune-enabled: ${{ env.INTUNE_ENABLED }}
|
||||
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||
|
||||
- name: Build iOS Simulator
|
||||
|
|
|
|||
4
.github/workflows/e2e-detox-release.yml
vendored
4
.github/workflows/e2e-detox-release.yml
vendored
|
|
@ -6,7 +6,7 @@ on:
|
|||
- release-*
|
||||
|
||||
env:
|
||||
INTUNE_ENABLED: 1
|
||||
INTUNE_ENABLED: 'true'
|
||||
|
||||
jobs:
|
||||
update-initial-status-ios:
|
||||
|
|
@ -46,7 +46,7 @@ jobs:
|
|||
- name: Prepare iOS Build
|
||||
uses: ./.github/actions/prepare-ios-build
|
||||
with:
|
||||
intune-enabled: 'true'
|
||||
intune-enabled: ${{ env.INTUNE_ENABLED }}
|
||||
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||
|
||||
- name: Build iOS Simulator
|
||||
|
|
|
|||
4
.github/workflows/e2e-detox-scheduled.yml
vendored
4
.github/workflows/e2e-detox-scheduled.yml
vendored
|
|
@ -5,7 +5,7 @@ on:
|
|||
- cron: "0 0 * * 4,5" # Wednesday and Thursday midnight
|
||||
|
||||
env:
|
||||
INTUNE_ENABLED: 1
|
||||
INTUNE_ENABLED: 'true'
|
||||
|
||||
jobs:
|
||||
update-initial-status-ios:
|
||||
|
|
@ -45,7 +45,7 @@ jobs:
|
|||
- name: Prepare iOS Build
|
||||
uses: ./.github/actions/prepare-ios-build
|
||||
with:
|
||||
intune-enabled: 'true'
|
||||
intune-enabled: ${{ env.INTUNE_ENABLED }}
|
||||
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||
|
||||
- name: Build iOS Simulator
|
||||
|
|
|
|||
4
.github/workflows/github-release.yml
vendored
4
.github/workflows/github-release.yml
vendored
|
|
@ -15,7 +15,7 @@ env:
|
|||
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
||||
SNYK_VERSION: "1.1297.2"
|
||||
CYCLONEDX_VERSION: "v0.27.2"
|
||||
INTUNE_ENABLED: 1
|
||||
INTUNE_ENABLED: 'true'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
- name: ci/prepare-ios-build
|
||||
uses: ./.github/actions/prepare-ios-build
|
||||
with:
|
||||
intune-enabled: 'true'
|
||||
intune-enabled: ${{ env.INTUNE_ENABLED }}
|
||||
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||
|
||||
- name: ci/build-ios-unsigned
|
||||
|
|
|
|||
109
NOTICE.txt
109
NOTICE.txt
|
|
@ -135,6 +135,42 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
## @gorhom/portal
|
||||
|
||||
This product contains '@gorhom/portal' by Mo Gorhom.
|
||||
|
||||
A simplified portal implementation for ⭕️ React Native ⭕️
|
||||
|
||||
* HOMEPAGE:
|
||||
* https://github.com/gorhom/react-native-portal#readme
|
||||
|
||||
* LICENSE: MIT
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Mo Gorhom
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## @mattermost/calls
|
||||
|
|
@ -1702,6 +1738,43 @@ Lightweight fuzzy-search
|
|||
limitations under the License.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## grapheme-splitter
|
||||
|
||||
This product contains 'grapheme-splitter' by Orlin Georgiev.
|
||||
|
||||
A JavaScript library that breaks strings into their individual user-perceived characters. It supports emojis!
|
||||
|
||||
* HOMEPAGE:
|
||||
* https://github.com/orling/grapheme-splitter
|
||||
|
||||
* LICENSE: MIT
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Orlin Georgiev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## html-entities
|
||||
|
|
@ -2437,6 +2510,42 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|||
SOFTWARE.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## react-native-keyboard-controller
|
||||
|
||||
This product contains 'react-native-keyboard-controller' by Kiryl Ziusko.
|
||||
|
||||
Keyboard manager which works in identical way on both iOS and Android
|
||||
|
||||
* HOMEPAGE:
|
||||
* https://kirillzyusko.github.io/react-native-keyboard-controller/
|
||||
|
||||
* LICENSE: MIT
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Kiryl Ziusko
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## react-native-keychain
|
||||
|
|
|
|||
|
|
@ -115,8 +115,8 @@ android {
|
|||
applicationId "com.mattermost.rnbeta"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 701
|
||||
versionName "2.36.0"
|
||||
versionCode 727
|
||||
versionName "2.37.2"
|
||||
testBuildType System.getProperty('testBuildType', 'debug')
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@
|
|||
"project_number": "184930218130",
|
||||
"firebase_url": "https://api-7231322553409637977-752355.firebaseio.com",
|
||||
"project_id": "api-7231322553409637977-752355",
|
||||
"storage_bucket": "api-7231322553409637977-752355.appspot.com"
|
||||
"storage_bucket": "api-7231322553409637977-752355.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:184930218130:android:a0e553d1a1f043b5",
|
||||
"mobilesdk_app_id": "1:184930218130:android:2db4058a5b5d6506",
|
||||
"android_client_info": {
|
||||
"package_name": "com.mattermost.react.native"
|
||||
"package_name": "com.mattermost.rn"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
|
|
@ -21,19 +21,24 @@
|
|||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyCkZkU2ECVg-mmdCG5OoHHQXtKiENuvWPE"
|
||||
"current_key": "AIzaSyASRwdI3DKpyjt_gMbL1MpViI3BTHB9rFg"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"analytics_service": {
|
||||
"status": 2
|
||||
},
|
||||
"appinvite_service": {
|
||||
"status": 1,
|
||||
"other_platform_oauth_client": []
|
||||
},
|
||||
"ads_service": {
|
||||
"status": 2
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "109751306267633962395",
|
||||
"client_type": 3
|
||||
},
|
||||
{
|
||||
"client_id": "184930218130-1kq3fsu90eppbhh508f667og6qpqrpip.apps.googleusercontent.com",
|
||||
"client_type": 2,
|
||||
"ios_info": {
|
||||
"bundle_id": "com.mattermost.rn"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -52,53 +57,27 @@
|
|||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyCkZkU2ECVg-mmdCG5OoHHQXtKiENuvWPE"
|
||||
"current_key": "AIzaSyASRwdI3DKpyjt_gMbL1MpViI3BTHB9rFg"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"analytics_service": {
|
||||
"status": 2
|
||||
},
|
||||
"appinvite_service": {
|
||||
"status": 1,
|
||||
"other_platform_oauth_client": []
|
||||
},
|
||||
"ads_service": {
|
||||
"status": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:184930218130:android:2db4058a5b5d6506",
|
||||
"android_client_info": {
|
||||
"package_name": "com.mattermost.rn"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "184930218130-8nahrspll1opff0uogtkh2qsv8coqngo.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyCkZkU2ECVg-mmdCG5OoHHQXtKiENuvWPE"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"analytics_service": {
|
||||
"status": 2
|
||||
},
|
||||
"appinvite_service": {
|
||||
"status": 1,
|
||||
"other_platform_oauth_client": []
|
||||
},
|
||||
"ads_service": {
|
||||
"status": 2
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "109751306267633962395",
|
||||
"client_type": 3
|
||||
},
|
||||
{
|
||||
"client_id": "184930218130-1kq3fsu90eppbhh508f667og6qpqrpip.apps.googleusercontent.com",
|
||||
"client_type": 2,
|
||||
"ios_info": {
|
||||
"bundle_id": "com.mattermost.rn"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
import {defineMessages} from 'react-intl';
|
||||
import {DeviceEventEmitter} from 'react-native';
|
||||
|
||||
import {ActionType, General, Navigation, Screens} from '@constants';
|
||||
import {ActionType, Events, General, Navigation, Screens} from '@constants';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {getTranslations} from '@i18n';
|
||||
import {getChannelById} from '@queries/servers/channel';
|
||||
|
|
@ -135,6 +135,8 @@ export const switchToThread = async (serverUrl: string, rootId: string, isFromNo
|
|||
subtitle = subtitle.replace('{channelName}', channel.displayName);
|
||||
}
|
||||
|
||||
DeviceEventEmitter.emit(Events.CLOSE_INPUT_ACCESSORY_VIEW);
|
||||
|
||||
goToScreen(Screens.THREAD, '', {rootId}, {
|
||||
topBar: {
|
||||
title: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Portal} from '@gorhom/portal';
|
||||
import React, {useMemo, useState} from 'react';
|
||||
import {Platform, type StyleProp, useWindowDimensions, type ViewStyle} from 'react-native';
|
||||
import Animated, {type SharedValue, useAnimatedStyle, useDerivedValue} from 'react-native-reanimated';
|
||||
|
|
@ -65,6 +66,7 @@ type Props = {
|
|||
autocompleteProviders?: AutocompleteProviders;
|
||||
useAllAvailableSpace?: boolean;
|
||||
horizontalPadding?: number;
|
||||
usePortal?: boolean;
|
||||
}
|
||||
|
||||
type AutocompleteProviders = {
|
||||
|
|
@ -101,6 +103,7 @@ const Autocomplete = ({
|
|||
autocompleteProviders = defaultAutocompleteProviders,
|
||||
useAllAvailableSpace = false,
|
||||
horizontalPadding = 8,
|
||||
usePortal = false,
|
||||
}: Props) => {
|
||||
const theme = useTheme();
|
||||
const isTablet = useIsTablet();
|
||||
|
|
@ -146,7 +149,7 @@ const Autocomplete = ({
|
|||
return s;
|
||||
}, [style.base, style.borders, style.shadow, horizontalPadding, containerAnimatedStyle, hasElements, containerStyle]);
|
||||
|
||||
return (
|
||||
const component = (
|
||||
<Animated.View
|
||||
testID='autocomplete'
|
||||
style={containerStyles}
|
||||
|
|
@ -224,6 +227,16 @@ const Autocomplete = ({
|
|||
</>)}
|
||||
</Animated.View>
|
||||
);
|
||||
|
||||
if (usePortal && Platform.OS === 'android') {
|
||||
return (
|
||||
<Portal>
|
||||
{component}
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
return component;
|
||||
};
|
||||
|
||||
export default Autocomplete;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
|||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginHorizontal: 2,
|
||||
},
|
||||
icon: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.56),
|
||||
|
|
@ -54,3 +55,4 @@ const EmojiCategoryBarIcon = ({currentIndex, icon, index, scrollToIndex, theme}:
|
|||
};
|
||||
|
||||
export default EmojiCategoryBarIcon;
|
||||
|
||||
177
app/components/emoji_category_bar/index.tsx
Normal file
177
app/components/emoji_category_bar/index.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {ScrollView, View} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {selectEmojiCategoryBarSection, useEmojiCategoryBar} from '@hooks/emoji_category_bar';
|
||||
import {useFocusAfterEmojiDismiss} from '@hooks/useFocusAfterEmojiDismiss';
|
||||
import {usePreventDoubleTap} from '@hooks/utils';
|
||||
import {deleteLastGrapheme} from '@utils/grapheme';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import EmojiCategoryBarIcon from './icon';
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
container: {
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
height: 55,
|
||||
paddingHorizontal: 12,
|
||||
borderTopColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||
borderTopWidth: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
actionIcon: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.56),
|
||||
},
|
||||
scrollableContainer: {
|
||||
flex: 1,
|
||||
marginHorizontal: 8,
|
||||
},
|
||||
categoriesContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
separator: {
|
||||
width: 1,
|
||||
height: 20,
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||
marginHorizontal: 8,
|
||||
},
|
||||
}));
|
||||
|
||||
type Props = {
|
||||
onSelect?: (index: number | undefined) => void;
|
||||
}
|
||||
|
||||
const EmojiCategoryBar = ({onSelect}: Props) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
const {currentIndex, icons} = useEmojiCategoryBar();
|
||||
const keyboardContext = useKeyboardAnimationContext();
|
||||
const {
|
||||
focusInput,
|
||||
inputRef,
|
||||
updateValue,
|
||||
updateCursorPosition,
|
||||
cursorPositionRef,
|
||||
} = keyboardContext;
|
||||
|
||||
const {focus: focusWithEmojiDismiss} = useFocusAfterEmojiDismiss(inputRef, focusInput);
|
||||
|
||||
// Only show keyboard/delete buttons if we're in input accessory view mode
|
||||
// Check if updateValue is available (not null) to determine if we're in input accessory context
|
||||
const showActionButtons = updateValue !== null;
|
||||
|
||||
const scrollToIndex = useCallback((index: number) => {
|
||||
if (onSelect) {
|
||||
onSelect(index);
|
||||
return;
|
||||
}
|
||||
|
||||
selectEmojiCategoryBarSection(index);
|
||||
}, [onSelect]);
|
||||
|
||||
const handleKeyboardPress = usePreventDoubleTap(useCallback(() => {
|
||||
focusWithEmojiDismiss();
|
||||
}, [focusWithEmojiDismiss]));
|
||||
|
||||
const deleteCharFromCurrentCursorPosition = useCallback(() => {
|
||||
if (!updateValue || !updateCursorPosition || !cursorPositionRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCursorPosition = cursorPositionRef.current;
|
||||
if (currentCursorPosition === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateValue((value: string) => {
|
||||
const result = deleteLastGrapheme(value, currentCursorPosition);
|
||||
cursorPositionRef.current = result.cursorPosition;
|
||||
updateCursorPosition(result.cursorPosition);
|
||||
return result.text;
|
||||
});
|
||||
}, [updateValue, updateCursorPosition, cursorPositionRef]);
|
||||
|
||||
if (!icons) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
testID='emoji_picker.category_bar'
|
||||
>
|
||||
{showActionButtons && (
|
||||
<>
|
||||
<TouchableWithFeedback
|
||||
onPress={handleKeyboardPress}
|
||||
style={styles.actionButton}
|
||||
type='opacity'
|
||||
testID='emoji_picker.keyboard_button'
|
||||
>
|
||||
<CompassIcon
|
||||
name='keyboard-outline'
|
||||
size={20}
|
||||
style={styles.actionIcon}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
|
||||
<View style={styles.separator}/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ScrollView
|
||||
horizontal={true}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.scrollableContainer}
|
||||
contentContainerStyle={styles.categoriesContainer}
|
||||
>
|
||||
{icons.map((icon, index) => (
|
||||
<EmojiCategoryBarIcon
|
||||
currentIndex={currentIndex}
|
||||
key={icon.key}
|
||||
icon={icon.icon}
|
||||
index={index}
|
||||
scrollToIndex={scrollToIndex}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{showActionButtons && (
|
||||
<>
|
||||
<View style={styles.separator}/>
|
||||
|
||||
<TouchableWithFeedback
|
||||
onPress={deleteCharFromCurrentCursorPosition}
|
||||
style={styles.actionButton}
|
||||
type='opacity'
|
||||
testID='emoji_picker.delete_button'
|
||||
>
|
||||
<CompassIcon
|
||||
name='backspace-outline'
|
||||
size={20}
|
||||
style={styles.actionIcon}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiCategoryBar;
|
||||
|
||||
|
|
@ -6,6 +6,7 @@ import React, {forwardRef, useMemo} from 'react';
|
|||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import {useServerUrl} from '@context/server';
|
||||
import NetworkManager from '@managers/network_manager';
|
||||
import {urlSafeBase64Encode} from '@utils/security';
|
||||
|
||||
type ExpoImagePropsWithId = ImageProps & {id: string};
|
||||
|
|
@ -16,8 +17,36 @@ type ExpoImageBackgroundPropsWithId = ImageBackgroundProps & {id: string};
|
|||
type ExpoImageBackgroundPropsMemoryOnly = ImageBackgroundProps & {cachePolicy: 'memory'; id?: string};
|
||||
type ExpoImageBackgroundProps = ExpoImageBackgroundPropsWithId | ExpoImageBackgroundPropsMemoryOnly;
|
||||
|
||||
function shouldAttachServerAuthHeaders(uri: string | undefined, serverUrl: string) {
|
||||
if (!uri) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const requestUrl = new URL(uri);
|
||||
const serverBaseUrl = new URL(serverUrl);
|
||||
|
||||
if (requestUrl.origin !== serverBaseUrl.origin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return requestUrl.pathname.startsWith('/api/v4/');
|
||||
} catch {
|
||||
// On any parsing error, do not attach auth headers
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const ExpoImage = forwardRef<Image, ExpoImageProps>(({id, ...props}, ref) => {
|
||||
const serverUrl = useServerUrl();
|
||||
const requestHeaders = useMemo(() => {
|
||||
try {
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
return client.getRequestHeaders('GET');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, [serverUrl]);
|
||||
|
||||
/**
|
||||
* SECURITY NOTE: cachePath uses base64 encoding for URL safety, NOT encryption.
|
||||
|
|
@ -30,17 +59,24 @@ const ExpoImage = forwardRef<Image, ExpoImageProps>(({id, ...props}, ref) => {
|
|||
return props.source;
|
||||
}
|
||||
|
||||
const sourceHeaders = shouldAttachServerAuthHeaders(props.source?.uri, serverUrl) && requestHeaders ? {...requestHeaders, ...props.source?.headers} : props.source?.headers;
|
||||
delete sourceHeaders?.Accept;
|
||||
|
||||
// Only add cacheKey and cachePath if id is provided (i.e., not memory-only caching)
|
||||
if (id) {
|
||||
return {
|
||||
...props.source,
|
||||
headers: sourceHeaders,
|
||||
cacheKey: id,
|
||||
cachePath,
|
||||
};
|
||||
}
|
||||
|
||||
return props.source;
|
||||
}, [id, props.source, cachePath]);
|
||||
return {
|
||||
...props.source,
|
||||
headers: sourceHeaders,
|
||||
};
|
||||
}, [id, props.source, cachePath, requestHeaders, serverUrl]);
|
||||
|
||||
// Process placeholder to add cachePath and cacheKey if it has a uri
|
||||
const placeholder: ImageSource | undefined = useMemo(() => {
|
||||
|
|
@ -48,17 +84,24 @@ const ExpoImage = forwardRef<Image, ExpoImageProps>(({id, ...props}, ref) => {
|
|||
return props.placeholder;
|
||||
}
|
||||
|
||||
const placeholderHeaders = shouldAttachServerAuthHeaders(props.placeholder?.uri, serverUrl) && requestHeaders ? {...requestHeaders, ...props.placeholder?.headers} : props.placeholder?.headers;
|
||||
delete placeholderHeaders?.Accept;
|
||||
|
||||
// If placeholder has a uri and id is provided, add cachePath and cacheKey
|
||||
if (props.placeholder.uri && id) {
|
||||
return {
|
||||
...props.placeholder,
|
||||
headers: placeholderHeaders,
|
||||
cacheKey: `${id}-thumb`,
|
||||
cachePath,
|
||||
};
|
||||
}
|
||||
|
||||
return props.placeholder;
|
||||
}, [props.placeholder, id, cachePath]);
|
||||
return {
|
||||
...props.placeholder,
|
||||
headers: placeholderHeaders,
|
||||
};
|
||||
}, [props.placeholder, id, cachePath, requestHeaders, serverUrl]);
|
||||
|
||||
return (
|
||||
<Image
|
||||
|
|
|
|||
6
app/components/input_accessory_view/index.ts
Normal file
6
app/components/input_accessory_view/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {default as InputAccessoryViewContainer} from './input_accessory_view_container';
|
||||
export {default as InputAccessoryViewContent} from './input_accessory_view_content';
|
||||
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {type ReactNode} from 'react';
|
||||
import Animated, {useAnimatedStyle, type SharedValue} from 'react-native-reanimated';
|
||||
|
||||
import {useTheme} from '@context/theme';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
animatedHeight: SharedValue<number>;
|
||||
};
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
container: {
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* InputAccessoryViewContainer - Container for input accessory view content
|
||||
*/
|
||||
const InputAccessoryViewContainer = ({
|
||||
children,
|
||||
animatedHeight,
|
||||
}: Props) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
height: animatedHeight.value,
|
||||
};
|
||||
|
||||
// Shared values don't need to be in dependencies - they're stable references
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
animatedStyle,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputAccessoryViewContainer;
|
||||
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import CustomEmojiPicker from '@components/post_draft/custom_emoji_picker';
|
||||
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
|
||||
|
||||
/**
|
||||
* InputAccessoryViewContent - Content for input accessory view
|
||||
*/
|
||||
const InputAccessoryViewContent = () => {
|
||||
const {inputAccessoryViewAnimatedHeight, isEmojiSearchFocused, setIsEmojiSearchFocused} = useKeyboardAnimationContext();
|
||||
|
||||
return (
|
||||
<CustomEmojiPicker
|
||||
height={inputAccessoryViewAnimatedHeight}
|
||||
isEmojiSearchFocused={isEmojiSearchFocused}
|
||||
setIsEmojiSearchFocused={setIsEmojiSearchFocused}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputAccessoryViewContent;
|
||||
|
||||
584
app/components/keyboard_aware_post_draft_container.tsx
Normal file
584
app/components/keyboard_aware_post_draft_container.tsx
Normal file
|
|
@ -0,0 +1,584 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode} from 'react';
|
||||
import {BackHandler, DeviceEventEmitter, Keyboard, Platform, type StyleProp, StyleSheet, type ViewStyle, View, type LayoutChangeEvent, type GestureResponderEvent} from 'react-native';
|
||||
import {KeyboardGestureArea} from 'react-native-keyboard-controller';
|
||||
import Animated, {runOnJS, useAnimatedReaction, useSharedValue, withTiming} from 'react-native-reanimated';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import {InputAccessoryViewContainer, InputAccessoryViewContent} from '@components/input_accessory_view';
|
||||
import {Events} from '@constants';
|
||||
import {KeyboardAnimationProvider} from '@context/keyboard_animation';
|
||||
import {useIsTablet, useWindowDimensions} from '@hooks/device';
|
||||
import {useInputAccessoryView} from '@hooks/useInputAccessoryView';
|
||||
import {useKeyboardAwarePostDraft} from '@hooks/useKeyboardAwarePostDraft';
|
||||
|
||||
const isIOS = Platform.OS === 'ios';
|
||||
const Wrapper = isIOS ? KeyboardGestureArea : View;
|
||||
|
||||
type RenderListProps = {
|
||||
listRef: ReturnType<typeof useKeyboardAwarePostDraft>['listRef'];
|
||||
contentInset: ReturnType<typeof useKeyboardAwarePostDraft>['contentInset'];
|
||||
onScroll: ReturnType<typeof useKeyboardAwarePostDraft>['onScroll'];
|
||||
keyboardCurrentHeight: ReturnType<typeof useKeyboardAwarePostDraft>['keyboardTranslateY'];
|
||||
postInputContainerHeight: number;
|
||||
onTouchMove?: (event: GestureResponderEvent) => void;
|
||||
onTouchEnd?: () => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
renderList: (props: RenderListProps) => ReactNode;
|
||||
textInputNativeID: string;
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
isThreadView?: boolean;
|
||||
enabled?: boolean;
|
||||
onEmojiSearchFocusChange?: (focused: boolean) => void;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
gestureArea: {
|
||||
justifyContent: 'flex-end',
|
||||
flex: 1,
|
||||
},
|
||||
inputContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Wrapper component that provides keyboard-aware behavior for post draft screens
|
||||
* Handles keyboard animations, scroll adjustments, and input container positioning
|
||||
*/
|
||||
export const KeyboardAwarePostDraftContainer = ({
|
||||
children,
|
||||
renderList,
|
||||
textInputNativeID,
|
||||
containerStyle,
|
||||
isThreadView = false,
|
||||
enabled = true,
|
||||
onEmojiSearchFocusChange,
|
||||
}: Props) => {
|
||||
const {height: windowHeight} = useWindowDimensions();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isTablet = useIsTablet();
|
||||
|
||||
const effectiveWindowHeight = isTablet ? windowHeight : windowHeight - insets.bottom;
|
||||
|
||||
const {
|
||||
keyboardTranslateY: keyboardCurrentHeight,
|
||||
listRef,
|
||||
inputRef,
|
||||
contentInset: bottomInset,
|
||||
onScroll,
|
||||
postInputContainerHeight,
|
||||
setPostInputContainerHeight,
|
||||
inputContainerAnimatedStyle,
|
||||
keyboardHeight,
|
||||
scrollOffset,
|
||||
scrollPosition,
|
||||
blurInput,
|
||||
focusInput,
|
||||
blurAndDismissKeyboard,
|
||||
isKeyboardFullyOpen,
|
||||
isKeyboardFullyClosed,
|
||||
isKeyboardInTransition,
|
||||
isInputAccessoryViewMode,
|
||||
isTransitioningFromCustomView,
|
||||
} = useKeyboardAwarePostDraft(isThreadView, enabled);
|
||||
|
||||
const {
|
||||
showInputAccessoryView,
|
||||
setShowInputAccessoryView,
|
||||
lastKeyboardHeight,
|
||||
inputAccessoryViewAnimatedHeight,
|
||||
} = useInputAccessoryView({
|
||||
keyboardHeight,
|
||||
isKeyboardFullyOpen,
|
||||
});
|
||||
|
||||
const [isEmojiSearchFocused, setIsEmojiSearchFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
onEmojiSearchFocusChange?.(isEmojiSearchFocused);
|
||||
}, [isEmojiSearchFocused, onEmojiSearchFocusChange]);
|
||||
|
||||
// Ref to store cursor position from PostInput
|
||||
const cursorPositionRef = useRef<number>(0);
|
||||
|
||||
// Function to register cursor position updates from PostInput
|
||||
const registerCursorPosition = useCallback((cursorPosition: number) => {
|
||||
cursorPositionRef.current = cursorPosition;
|
||||
}, []);
|
||||
|
||||
// Refs to store PostInput callbacks
|
||||
const updateValueRef = useRef<React.Dispatch<React.SetStateAction<string>> | null>(null);
|
||||
const updateCursorPositionRef = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null);
|
||||
|
||||
// Function to register PostInput callbacks
|
||||
const registerPostInputCallbacks = useCallback((
|
||||
updateValueFn: React.Dispatch<React.SetStateAction<string>>,
|
||||
updateCursorPositionFn: React.Dispatch<React.SetStateAction<number>>,
|
||||
) => {
|
||||
updateValueRef.current = updateValueFn;
|
||||
updateCursorPositionRef.current = updateCursorPositionFn;
|
||||
|
||||
if (updateValueFn) {
|
||||
updateValueFn((currentValue: string) => {
|
||||
cursorPositionRef.current = currentValue.length;
|
||||
return currentValue;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Ref to track if a layout update is already scheduled
|
||||
const layoutUpdateScheduledRef = useRef(false);
|
||||
const pendingHeightRef = useRef<number | null>(null);
|
||||
|
||||
// Helper to apply the batched height update
|
||||
const applyBatchedHeightUpdate = useCallback(() => {
|
||||
layoutUpdateScheduledRef.current = false;
|
||||
|
||||
if (pendingHeightRef.current !== null) {
|
||||
const heightToSet = pendingHeightRef.current;
|
||||
pendingHeightRef.current = null;
|
||||
|
||||
// Only update if the rounded height changed by more than 0.5px (a real change).
|
||||
// This prevents jitter in FlatList paddingTop and improves performance.
|
||||
setPostInputContainerHeight((prevHeight) => {
|
||||
const roundedPrevHeight = Math.round(prevHeight);
|
||||
if (roundedPrevHeight !== heightToSet) {
|
||||
return heightToSet;
|
||||
}
|
||||
return prevHeight;
|
||||
});
|
||||
}
|
||||
}, [setPostInputContainerHeight]);
|
||||
|
||||
const onLayout = useCallback((e: LayoutChangeEvent) => {
|
||||
const newHeight = e.nativeEvent.layout.height;
|
||||
const roundedHeight = Math.round(newHeight);
|
||||
|
||||
// Store the latest height value
|
||||
pendingHeightRef.current = roundedHeight;
|
||||
|
||||
// If an update is already scheduled, skip scheduling another one
|
||||
// This batches all layout updates during animations to a single update per frame
|
||||
if (layoutUpdateScheduledRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule update for next frame to batch rapid layout changes during animations
|
||||
layoutUpdateScheduledRef.current = true;
|
||||
requestAnimationFrame(applyBatchedHeightUpdate);
|
||||
}, [applyBatchedHeightUpdate]);
|
||||
|
||||
// Refs for tracking emoji picker swipe-to-dismiss gesture
|
||||
const previousTouchYRef = useRef<number | null>(null);
|
||||
const lastDistanceFromBottomRef = useRef<number | null>(null);
|
||||
const lastIsSwipingDownRef = useRef<boolean | null>(null);
|
||||
const originalEmojiPickerHeightRef = useRef<number>(0);
|
||||
const isGestureActiveRef = useRef<boolean>(false);
|
||||
const gestureStartedInEmojiPickerRef = useRef<boolean>(false);
|
||||
|
||||
// Shared value to track scroll adjustment during emoji picker animation
|
||||
const animatedScrollAdjustment = useSharedValue(0);
|
||||
|
||||
// Callback to perform scroll adjustment
|
||||
const performScrollAdjustment = useCallback((targetOffset: number) => {
|
||||
listRef.current?.scrollToOffset({
|
||||
offset: targetOffset,
|
||||
animated: false,
|
||||
});
|
||||
}, [listRef]);
|
||||
|
||||
// React to animatedScrollAdjustment changes and scroll the list accordingly
|
||||
// This enables smooth scrolling as emoji picker animates
|
||||
useAnimatedReaction(
|
||||
() => animatedScrollAdjustment.value,
|
||||
(current, previous) => {
|
||||
// Only scroll if value actually changed and is valid
|
||||
if (previous !== null && current !== previous && current !== 0) {
|
||||
runOnJS(performScrollAdjustment)(current);
|
||||
}
|
||||
},
|
||||
[animatedScrollAdjustment],
|
||||
);
|
||||
|
||||
// Handle touch move: track finger position and adjust emoji picker height
|
||||
const handleTouchMove = useCallback((event: GestureResponderEvent) => {
|
||||
if (!showInputAccessoryView || Keyboard.isVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get finger Y position on screen
|
||||
const fingerY = event.nativeEvent.pageY;
|
||||
if (fingerY == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// On first touch, check if gesture started within emoji picker bounds
|
||||
if (!isGestureActiveRef.current) {
|
||||
const currentEmojiPickerHeight = inputAccessoryViewAnimatedHeight.value;
|
||||
const emojiPickerTopEdge = effectiveWindowHeight - postInputContainerHeight - currentEmojiPickerHeight;
|
||||
const emojiPickerBottomEdge = effectiveWindowHeight - postInputContainerHeight;
|
||||
|
||||
// Check if touch is within emoji picker area
|
||||
const isTouchInEmojiPicker = fingerY >= emojiPickerTopEdge && fingerY <= emojiPickerBottomEdge;
|
||||
|
||||
if (!isTouchInEmojiPicker) {
|
||||
return;
|
||||
}
|
||||
|
||||
isGestureActiveRef.current = true;
|
||||
gestureStartedInEmojiPickerRef.current = true;
|
||||
}
|
||||
|
||||
// Only process if gesture started in emoji picker
|
||||
if (!gestureStartedInEmojiPickerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const distanceFromBottom = effectiveWindowHeight - fingerY;
|
||||
|
||||
// Subtract input container height to get emoji picker height
|
||||
const emojiPickerHeight = distanceFromBottom - postInputContainerHeight;
|
||||
const maxHeight = originalEmojiPickerHeightRef.current;
|
||||
const clampedHeight = emojiPickerHeight < 0 ? 0 : Math.min(emojiPickerHeight, maxHeight);
|
||||
|
||||
inputAccessoryViewAnimatedHeight.value = clampedHeight;
|
||||
bottomInset.value = clampedHeight;
|
||||
lastDistanceFromBottomRef.current = clampedHeight;
|
||||
lastIsSwipingDownRef.current = previousTouchYRef.current !== null && fingerY > previousTouchYRef.current;
|
||||
previousTouchYRef.current = fingerY;
|
||||
}, [showInputAccessoryView, postInputContainerHeight, inputAccessoryViewAnimatedHeight, bottomInset, effectiveWindowHeight]);
|
||||
|
||||
// Callback to dismiss emoji picker after animation completes
|
||||
const dismissEmojiPicker = useCallback(() => {
|
||||
// Reset emoji search focus when dismissing emoji picker
|
||||
setIsEmojiSearchFocused(false);
|
||||
setShowInputAccessoryView(false);
|
||||
isInputAccessoryViewMode.value = false;
|
||||
bottomInset.value = 0;
|
||||
scrollOffset.value = 0;
|
||||
keyboardHeight.value = 0;
|
||||
}, [setShowInputAccessoryView, isInputAccessoryViewMode, bottomInset, scrollOffset, keyboardHeight, setIsEmojiSearchFocused]);
|
||||
|
||||
const closeInputAccessoryView = useCallback(() => {
|
||||
// Reset emoji search focus when closing emoji picker
|
||||
setIsEmojiSearchFocused(false);
|
||||
setShowInputAccessoryView(false);
|
||||
isInputAccessoryViewMode.value = false;
|
||||
isTransitioningFromCustomView.value = false;
|
||||
|
||||
inputAccessoryViewAnimatedHeight.value = withTiming(0, {duration: 200});
|
||||
bottomInset.value = withTiming(0, {duration: 200});
|
||||
scrollOffset.value = withTiming(0, {duration: 200});
|
||||
keyboardHeight.value = 0;
|
||||
}, [inputAccessoryViewAnimatedHeight, bottomInset, scrollOffset, keyboardHeight, setShowInputAccessoryView, isInputAccessoryViewMode, isTransitioningFromCustomView, setIsEmojiSearchFocused]);
|
||||
|
||||
const scrollToEnd = useCallback(() => {
|
||||
const activeHeight = Math.max(keyboardHeight.value, inputAccessoryViewAnimatedHeight.value);
|
||||
const targetOffset = -activeHeight;
|
||||
|
||||
listRef.current?.scrollToOffset({offset: targetOffset, animated: true});
|
||||
}, [listRef, keyboardHeight, inputAccessoryViewAnimatedHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== 'android') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
if (showInputAccessoryView) {
|
||||
closeInputAccessoryView();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => backHandler.remove();
|
||||
}, [showInputAccessoryView, closeInputAccessoryView]);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = DeviceEventEmitter.addListener(Events.CLOSE_INPUT_ACCESSORY_VIEW, () => {
|
||||
closeInputAccessoryView();
|
||||
});
|
||||
|
||||
return () => listener.remove();
|
||||
}, [closeInputAccessoryView]);
|
||||
|
||||
// Handle touch end: decide whether to collapse or expand emoji picker
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
isGestureActiveRef.current = false;
|
||||
|
||||
// Only process if gesture started in emoji picker
|
||||
if (!gestureStartedInEmojiPickerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastDistanceFromBottomRef.current !== null && lastIsSwipingDownRef.current !== null) {
|
||||
const currentInsetHeight = lastDistanceFromBottomRef.current;
|
||||
const currentScrollValue = scrollPosition.value;
|
||||
|
||||
if (lastIsSwipingDownRef.current) {
|
||||
// User was swiping DOWN → Collapse and dismiss emoji picker
|
||||
// Calculate scroll positions: as bottomInset decreases from current to 0,
|
||||
// list should scroll from current position to final position
|
||||
const startScrollOffset = -currentInsetHeight + currentScrollValue;
|
||||
const endScrollOffset = currentScrollValue;
|
||||
|
||||
// Animate emoji picker height to 0
|
||||
inputAccessoryViewAnimatedHeight.value = withTiming(
|
||||
0,
|
||||
{duration: 250},
|
||||
() => {
|
||||
runOnJS(dismissEmojiPicker)();
|
||||
},
|
||||
);
|
||||
bottomInset.value = withTiming(0, {duration: 250});
|
||||
|
||||
// Animate scroll position from start to end - this makes list scroll down smoothly
|
||||
animatedScrollAdjustment.value = startScrollOffset;
|
||||
animatedScrollAdjustment.value = withTiming(endScrollOffset, {
|
||||
duration: 250,
|
||||
}, () => {
|
||||
animatedScrollAdjustment.value = 0;
|
||||
});
|
||||
} else {
|
||||
// User was swiping UP → Expand to full height
|
||||
const targetHeight = originalEmojiPickerHeightRef.current;
|
||||
|
||||
// Calculate scroll positions: as bottomInset increases from current to targetHeight,
|
||||
// list should scroll from current position to final position
|
||||
const startScrollOffset = -currentInsetHeight + currentScrollValue;
|
||||
const endScrollOffset = -targetHeight + currentScrollValue;
|
||||
|
||||
inputAccessoryViewAnimatedHeight.value = withTiming(targetHeight, {duration: 250});
|
||||
bottomInset.value = withTiming(targetHeight, {duration: 250});
|
||||
|
||||
// Animate scroll position from start to end - this makes list scroll up smoothly
|
||||
animatedScrollAdjustment.value = startScrollOffset;
|
||||
animatedScrollAdjustment.value = withTiming(endScrollOffset, {
|
||||
duration: 250,
|
||||
}, () => {
|
||||
animatedScrollAdjustment.value = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
previousTouchYRef.current = null;
|
||||
lastDistanceFromBottomRef.current = null;
|
||||
lastIsSwipingDownRef.current = null;
|
||||
gestureStartedInEmojiPickerRef.current = false;
|
||||
}, [inputAccessoryViewAnimatedHeight, dismissEmojiPicker, bottomInset, scrollPosition, animatedScrollAdjustment]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (showInputAccessoryView && Platform.OS === 'ios') {
|
||||
keyboardHeight.value = 0;
|
||||
}
|
||||
}, [showInputAccessoryView, keyboardHeight]);
|
||||
|
||||
// After emoji picker renders, adjust heights and scroll to keep messages visible
|
||||
useEffect(() => {
|
||||
if (showInputAccessoryView) {
|
||||
// Wait one frame to ensure emoji picker has rendered
|
||||
requestAnimationFrame(() => {
|
||||
const emojiPickerHeight = inputAccessoryViewAnimatedHeight.value;
|
||||
const currentScroll = scrollPosition.value;
|
||||
|
||||
originalScrollBeforeEmojiPicker.value = currentScroll;
|
||||
originalEmojiPickerHeightRef.current = emojiPickerHeight;
|
||||
|
||||
// For inverted list: when bottomInset increases, content shifts UP visually. Scroll UP to compensate.
|
||||
const targetContentOffset = currentScroll - emojiPickerHeight;
|
||||
|
||||
bottomInset.value = emojiPickerHeight;
|
||||
scrollOffset.value = emojiPickerHeight;
|
||||
|
||||
listRef.current?.scrollToOffset({
|
||||
offset: targetContentOffset,
|
||||
animated: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Only depend on showInputAccessoryView - the effect should only run when emoji picker visibility changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showInputAccessoryView]);
|
||||
|
||||
// Track if we've already restored scroll for current emoji picker closing (Android only)
|
||||
// Use SharedValue instead of ref so it can be accessed in worklets
|
||||
const hasRestoredScrollForEmojiPicker = useSharedValue(false);
|
||||
|
||||
// Store the original scroll value when emoji picker opens, so we can restore it when closing
|
||||
const originalScrollBeforeEmojiPicker = useSharedValue(0);
|
||||
|
||||
// Reset restoration flag when emoji picker opens
|
||||
useEffect(() => {
|
||||
if (showInputAccessoryView) {
|
||||
hasRestoredScrollForEmojiPicker.value = false;
|
||||
originalScrollBeforeEmojiPicker.value = 0;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showInputAccessoryView]);
|
||||
|
||||
// Callback to restore scroll when emoji picker closes (called from worklet)
|
||||
const restoreScrollAfterEmojiPickerClose = useCallback((previousHeight: number, currentScroll: number) => {
|
||||
if (listRef.current && previousHeight > 0) {
|
||||
listRef.current.scrollToOffset({
|
||||
offset: currentScroll,
|
||||
animated: false,
|
||||
});
|
||||
}
|
||||
|
||||
// ref is not required to be in deps because it is a stable reference
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Android: Watch for emoji picker closing and restore scroll position when both height and bottomInset reach 0
|
||||
const isAndroid = Platform.OS === 'android';
|
||||
useAnimatedReaction(
|
||||
() => ({
|
||||
height: inputAccessoryViewAnimatedHeight.value,
|
||||
bottomInset: bottomInset.value,
|
||||
}),
|
||||
(current, previous) => {
|
||||
if (!isAndroid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// When emoji picker closes: height goes to 0 AND bottomInset reaches 0. Check previous.bottomInset > 0 because bottomInset affects scroll.
|
||||
const shouldRestoreScroll = previous !== null &&
|
||||
previous.bottomInset !== undefined &&
|
||||
previous.bottomInset > 0 &&
|
||||
current.height === 0 &&
|
||||
current.bottomInset === 0 &&
|
||||
!hasRestoredScrollForEmojiPicker.value;
|
||||
|
||||
if (shouldRestoreScroll) {
|
||||
hasRestoredScrollForEmojiPicker.value = true;
|
||||
const currentScroll = scrollPosition.value;
|
||||
const emojiPickerHeight = previous.bottomInset;
|
||||
|
||||
runOnJS(restoreScrollAfterEmojiPickerClose)(emojiPickerHeight, currentScroll);
|
||||
}
|
||||
},
|
||||
[inputAccessoryViewAnimatedHeight, bottomInset, scrollPosition, restoreScrollAfterEmojiPickerClose],
|
||||
);
|
||||
|
||||
const keyboardAnimationValues = useMemo(() => ({
|
||||
keyboardTranslateY: keyboardCurrentHeight,
|
||||
bottomInset,
|
||||
scrollOffset,
|
||||
keyboardHeight,
|
||||
scrollPosition,
|
||||
onScroll,
|
||||
postInputContainerHeight,
|
||||
inputRef,
|
||||
blurInput,
|
||||
focusInput,
|
||||
blurAndDismissKeyboard,
|
||||
isKeyboardFullyOpen,
|
||||
isKeyboardFullyClosed,
|
||||
isKeyboardInTransition,
|
||||
isInputAccessoryViewMode,
|
||||
showInputAccessoryView,
|
||||
setShowInputAccessoryView,
|
||||
lastKeyboardHeight,
|
||||
inputAccessoryViewAnimatedHeight,
|
||||
isTransitioningFromCustomView,
|
||||
closeInputAccessoryView,
|
||||
scrollToEnd,
|
||||
isEmojiSearchFocused,
|
||||
setIsEmojiSearchFocused,
|
||||
cursorPositionRef,
|
||||
registerCursorPosition,
|
||||
updateValue: updateValueRef.current,
|
||||
updateCursorPosition: updateCursorPositionRef.current,
|
||||
registerPostInputCallbacks,
|
||||
|
||||
// Shared values don't need to be in dependencies - they're stable references
|
||||
// Only include non-shared-value dependencies that can actually change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}), [
|
||||
onScroll,
|
||||
postInputContainerHeight,
|
||||
inputRef,
|
||||
blurInput,
|
||||
focusInput,
|
||||
blurAndDismissKeyboard,
|
||||
showInputAccessoryView,
|
||||
setShowInputAccessoryView,
|
||||
lastKeyboardHeight,
|
||||
closeInputAccessoryView,
|
||||
scrollToEnd,
|
||||
isEmojiSearchFocused,
|
||||
setIsEmojiSearchFocused,
|
||||
registerCursorPosition,
|
||||
registerPostInputCallbacks,
|
||||
]);
|
||||
|
||||
const wrapperProps = useMemo(() => {
|
||||
if (isIOS) {
|
||||
return {
|
||||
textInputNativeID,
|
||||
offset: postInputContainerHeight,
|
||||
style: styles.gestureArea,
|
||||
};
|
||||
}
|
||||
return {style: styles.gestureArea};
|
||||
}, [textInputNativeID, postInputContainerHeight]);
|
||||
|
||||
// On iOS, use KeyboardGestureArea for interactive keyboard dismissal
|
||||
// On Android, KeyboardGestureArea is Android 11+ only, but we want native behavior
|
||||
// So we conditionally use it only on iOS
|
||||
// KeyboardGestureArea will be a no-op on Android if rendered, but we avoid it for clarity
|
||||
const content = (
|
||||
<>
|
||||
<View style={containerStyle}>
|
||||
{renderList({
|
||||
keyboardCurrentHeight,
|
||||
listRef,
|
||||
contentInset: bottomInset,
|
||||
onScroll,
|
||||
postInputContainerHeight,
|
||||
onTouchMove: handleTouchMove,
|
||||
onTouchEnd: handleTouchEnd,
|
||||
})}
|
||||
</View>
|
||||
<Animated.View
|
||||
style={[
|
||||
inputContainerAnimatedStyle,
|
||||
styles.inputContainer,
|
||||
]}
|
||||
>
|
||||
<View onLayout={onLayout}>
|
||||
{children}
|
||||
</View>
|
||||
{showInputAccessoryView && (
|
||||
<InputAccessoryViewContainer
|
||||
animatedHeight={inputAccessoryViewAnimatedHeight}
|
||||
>
|
||||
<InputAccessoryViewContent/>
|
||||
</InputAccessoryViewContainer>
|
||||
)}
|
||||
</Animated.View>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<KeyboardAnimationProvider value={keyboardAnimationValues}>
|
||||
<Wrapper {...wrapperProps}>
|
||||
{content}
|
||||
</Wrapper>
|
||||
</KeyboardAnimationProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import Fuse from 'fuse.js';
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {FlatList, Platform, Text, View, type ListRenderItemInfo} from 'react-native';
|
||||
import Animated, {useAnimatedStyle} from 'react-native-reanimated';
|
||||
|
||||
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {getEmojis, searchEmojis} from '@utils/emoji/helpers';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import EmojiItem from './emoji_item';
|
||||
|
||||
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
|
||||
|
||||
type Props = {
|
||||
customEmojis: CustomEmojiModel[];
|
||||
skinTone: string;
|
||||
searchTerm: string;
|
||||
onEmojiPress: (emojiName: string) => void;
|
||||
hideEmojiNames?: boolean;
|
||||
};
|
||||
|
||||
export const SEARCH_BAR_HEIGHT = 56; // paddingTop: 12 + paddingBottom: 12 + search bar ~32px
|
||||
export const SEARCH_CONTAINER_PADDING = 8; // paddingVertical: 4 * 2
|
||||
export const SEARCH_VISIBILITY_OFFSET = 40; // Extra height to ensure search values are visible
|
||||
export const SEARCH_VISIBILITY_OFFSET_ANDROID = 60;
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
listContainer: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
noResultContainer: {
|
||||
height: 40,
|
||||
margin: 10,
|
||||
},
|
||||
noResultText: {
|
||||
color: theme.centerChannelColor,
|
||||
opacity: 0.72,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const EmojiFiltered: React.FC<Props> = ({
|
||||
customEmojis,
|
||||
skinTone,
|
||||
searchTerm = '',
|
||||
onEmojiPress,
|
||||
hideEmojiNames = false,
|
||||
}) => {
|
||||
const {keyboardHeight} = useKeyboardAnimationContext();
|
||||
const emojis = useMemo(() => getEmojis(skinTone, customEmojis), [skinTone, customEmojis]);
|
||||
const intl = useIntl();
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
// Calculate dynamic height: keyboardHeight + search bar height + search container padding + visibility offset
|
||||
// On Android, use increased visibility offset to account for bottom safe area
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const calculatedKeyboardHeight = Math.max(keyboardHeight.value, 0);
|
||||
const visibilityOffset = Platform.OS === 'android' ? SEARCH_VISIBILITY_OFFSET_ANDROID : SEARCH_VISIBILITY_OFFSET;
|
||||
const calculatedHeight = calculatedKeyboardHeight + SEARCH_BAR_HEIGHT + SEARCH_CONTAINER_PADDING + visibilityOffset;
|
||||
return {
|
||||
height: calculatedHeight,
|
||||
};
|
||||
}, [keyboardHeight]);
|
||||
|
||||
const fuse = useMemo(() => {
|
||||
const options = {findAllMatches: true, ignoreLocation: true, includeMatches: true, shouldSort: false, includeScore: true};
|
||||
return new Fuse(emojis, options);
|
||||
}, [emojis]);
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (!searchTerm) {
|
||||
return emojis;
|
||||
}
|
||||
|
||||
return searchEmojis(fuse, searchTerm);
|
||||
}, [emojis, fuse, searchTerm]);
|
||||
|
||||
const keyExtractor = (item: string) => item;
|
||||
|
||||
const renderEmpty = useCallback(() => {
|
||||
return (
|
||||
<View style={style.noResultContainer}>
|
||||
<Text style={style.noResultText}>
|
||||
{intl.formatMessage({
|
||||
id: 'custom_emoji_picker.search.no_results',
|
||||
defaultMessage: 'No results',
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}, [style, intl]);
|
||||
|
||||
const renderItem = useCallback(({item}: ListRenderItemInfo<string>) => {
|
||||
return (
|
||||
<EmojiItem
|
||||
onEmojiPress={onEmojiPress}
|
||||
name={item}
|
||||
hideName={hideEmojiNames}
|
||||
shouldDismissKeyboard={false}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
onEmojiPress,
|
||||
hideEmojiNames,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Animated.View style={[style.container, animatedStyle]}>
|
||||
<FlatList
|
||||
data={data}
|
||||
initialNumToRender={30}
|
||||
keyboardDismissMode='interactive'
|
||||
keyboardShouldPersistTaps='always'
|
||||
keyExtractor={keyExtractor}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
renderItem={renderItem}
|
||||
removeClippedSubviews={false}
|
||||
horizontal={true}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={data.length === 0 && style.listContainer}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiFiltered;
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {memo, useCallback} from 'react';
|
||||
import {Keyboard, Text, TouchableOpacity, View} from 'react-native';
|
||||
|
||||
import Emoji from '@components/emoji';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
type TouchableEmojiProps = {
|
||||
name: string;
|
||||
onEmojiPress: (emojiName: string) => void;
|
||||
hideName?: boolean;
|
||||
shouldDismissKeyboard?: boolean;
|
||||
}
|
||||
|
||||
const getStyleSheetFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
height: 40,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
emojiContainer: {
|
||||
marginRight: 5,
|
||||
},
|
||||
emoji: {
|
||||
color: '#000',
|
||||
},
|
||||
emojiText: {
|
||||
fontSize: 13,
|
||||
color: theme.centerChannelColor,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const EmojiTouchable = ({name, onEmojiPress, hideName = false, shouldDismissKeyboard = true}: TouchableEmojiProps) => {
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheetFromTheme(theme);
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
if (shouldDismissKeyboard && Keyboard.isVisible()) {
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
onEmojiPress(name);
|
||||
}, [name, onEmojiPress, shouldDismissKeyboard]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
style={style.container}
|
||||
>
|
||||
<View style={style.emojiContainer}>
|
||||
<Emoji
|
||||
emojiName={name}
|
||||
textStyle={style.emoji}
|
||||
size={30}
|
||||
/>
|
||||
</View>
|
||||
{!hideName && <Text style={style.emojiText}>{`:${name}:`}</Text>}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(EmojiTouchable);
|
||||
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {Preferences} from '@constants';
|
||||
import {queryEmojiPreferences} from '@queries/servers/preference';
|
||||
|
||||
import EmojiFiltered from './emoji_filtered';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
|
||||
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
|
||||
skinTone: queryEmojiPreferences(database, Preferences.EMOJI_SKINTONE).
|
||||
observeWithColumns(['value']).pipe(
|
||||
switchMap((prefs) => of$(prefs?.[0]?.value ?? 'default')),
|
||||
),
|
||||
}));
|
||||
|
||||
export default withDatabase(enhanced(EmojiFiltered));
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useRef, useState} from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
|
||||
import {searchCustomEmojis} from '@actions/remote/custom_emoji';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {debounce} from '@helpers/api/general';
|
||||
import {getKeyboardAppearanceFromTheme} from '@utils/theme';
|
||||
|
||||
import EmojiFiltered from '../emoji_filtered';
|
||||
import EmojiPickerHeader from '../emoji_picker_header';
|
||||
import CustomEmojiSections from '../emoji_sections';
|
||||
|
||||
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
|
||||
import type {SharedValue} from 'react-native-reanimated';
|
||||
|
||||
type Props = {
|
||||
customEmojis: CustomEmojiModel[];
|
||||
customEmojisEnabled: boolean;
|
||||
onEmojiPress: (emoji: string) => void;
|
||||
imageUrl?: string;
|
||||
file?: ExtractedFileInfo;
|
||||
recentEmojis: string[];
|
||||
testID?: string;
|
||||
isEmojiSearchFocused: boolean;
|
||||
setIsEmojiSearchFocused: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
emojiPickerHeight: SharedValue<number>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const EmojiPicker: React.FC<Props> = ({
|
||||
customEmojisEnabled,
|
||||
imageUrl,
|
||||
file,
|
||||
recentEmojis,
|
||||
customEmojis,
|
||||
onEmojiPress,
|
||||
testID = '',
|
||||
isEmojiSearchFocused,
|
||||
setIsEmojiSearchFocused,
|
||||
emojiPickerHeight,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const serverUrl = useServerUrl();
|
||||
const [searchTerm, setSearchTerm] = useState<string|undefined>();
|
||||
const isSelectingEmoji = useRef(false);
|
||||
|
||||
const onCancelSearch = useCallback(() => {
|
||||
setSearchTerm(undefined);
|
||||
setIsEmojiSearchFocused(false);
|
||||
isSelectingEmoji.current = false;
|
||||
}, [setIsEmojiSearchFocused]);
|
||||
|
||||
const searchCustom = debounce((text: string) => {
|
||||
if (text && text.length > 1) {
|
||||
searchCustomEmojis(serverUrl, text);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
const onChangeSearchTerm = useCallback((text: string) => {
|
||||
setSearchTerm(text);
|
||||
searchCustom(text.replace(/^:|:$/g, '').trim());
|
||||
}, [searchCustom]);
|
||||
|
||||
const term = searchTerm?.replace(/^:|:$/g, '').trim() || '';
|
||||
|
||||
// Wrap onEmojiPress to set flag before emoji selection
|
||||
const handleEmojiPress = useCallback((emojiName: string) => {
|
||||
isSelectingEmoji.current = true;
|
||||
onEmojiPress(emojiName);
|
||||
}, [onEmojiPress]);
|
||||
|
||||
const EmojiList = (
|
||||
<EmojiFiltered
|
||||
customEmojis={customEmojis}
|
||||
searchTerm={term}
|
||||
onEmojiPress={handleEmojiPress}
|
||||
hideEmojiNames={isEmojiSearchFocused}
|
||||
/>
|
||||
);
|
||||
|
||||
const EmojiSection = (
|
||||
<CustomEmojiSections
|
||||
customEmojis={customEmojis}
|
||||
customEmojisEnabled={customEmojisEnabled}
|
||||
imageUrl={imageUrl}
|
||||
file={file}
|
||||
onEmojiPress={onEmojiPress}
|
||||
recentEmojis={recentEmojis}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.flex}
|
||||
testID='emoji_picker'
|
||||
>
|
||||
<EmojiPickerHeader
|
||||
autoCapitalize='none'
|
||||
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
|
||||
onCancel={onCancelSearch}
|
||||
onChangeText={onChangeSearchTerm}
|
||||
testID={`${testID}.search_bar`}
|
||||
value={searchTerm}
|
||||
setIsEmojiSearchFocused={setIsEmojiSearchFocused}
|
||||
emojiPickerHeight={emojiPickerHeight}
|
||||
isSelectingEmojiRef={isSelectingEmoji}
|
||||
/>
|
||||
{!isEmojiSearchFocused && EmojiSection}
|
||||
{isEmojiSearchFocused && EmojiList}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiPicker;
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
||||
|
||||
import {queryAllCustomEmojis} from '@queries/servers/custom_emoji';
|
||||
import {observeConfigBooleanValue, observeRecentReactions} from '@queries/servers/system';
|
||||
|
||||
import EmojiPicker from './emoji_picker';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
|
||||
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
|
||||
customEmojisEnabled: observeConfigBooleanValue(database, 'EnableCustomEmoji'),
|
||||
customEmojis: queryAllCustomEmojis(database).observe(),
|
||||
recentEmojis: observeRecentReactions(database),
|
||||
}));
|
||||
|
||||
export default withDatabase(enhanced(EmojiPicker));
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {DeviceEventEmitter, Platform, View, type LayoutChangeEvent} from 'react-native';
|
||||
import {useKeyboardState} from 'react-native-keyboard-controller';
|
||||
import {Easing, useAnimatedReaction, useSharedValue, withTiming, type SharedValue} from 'react-native-reanimated';
|
||||
|
||||
import SearchBar, {type SearchProps, type SearchRef} from '@components/search';
|
||||
import {Events} from '@constants';
|
||||
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {setEmojiSkinTone} from '@hooks/emoji_category_bar';
|
||||
import {DEFAULT_INPUT_ACCESSORY_HEIGHT} from '@hooks/useInputAccessoryView';
|
||||
import SkinToneSelector from '@screens/emoji_picker/picker/header/skintone_selector';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
const SEARCH_BAR_HEIGHT = 56; // paddingTop: 12 + paddingBottom: 12 + search bar ~32px
|
||||
const SEARCH_CONTAINER_PADDING = 8; // paddingVertical: 4 * 2
|
||||
const SEARCH_VISIBILITY_OFFSET = 40; // Extra height to ensure search values are visible
|
||||
const SEARCH_VISIBILITY_OFFSET_ANDROID = 60;
|
||||
|
||||
type Props = SearchProps & {
|
||||
skinTone: string;
|
||||
setIsEmojiSearchFocused: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
emojiPickerHeight: SharedValue<number>;
|
||||
isSelectingEmojiRef?: React.MutableRefObject<boolean>;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
flex: {flex: 1},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
}));
|
||||
|
||||
const EmojiPickerHeader: React.FC<Props> = ({
|
||||
skinTone,
|
||||
emojiPickerHeight,
|
||||
setIsEmojiSearchFocused,
|
||||
isSelectingEmojiRef,
|
||||
...props
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
const {lastKeyboardHeight: contextLastKeyboardHeight, showInputAccessoryView, isInputAccessoryViewMode} = useKeyboardAnimationContext();
|
||||
const containerWidth = useSharedValue(0);
|
||||
const isSearching = useSharedValue(false);
|
||||
const [showKeyboard, setShowKeyboard] = useState(false);
|
||||
const keyboardTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isReducingHeight = useRef(false);
|
||||
|
||||
const searchRef = useRef<SearchRef>(null);
|
||||
|
||||
// Use useKeyboardState to get keyboard height
|
||||
const {height: keyboardHeight} = useKeyboardState();
|
||||
const keyboardHeightShared = useSharedValue(keyboardHeight);
|
||||
|
||||
useEffect(() => {
|
||||
const req = requestAnimationFrame(() => {
|
||||
setEmojiSkinTone(skinTone);
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(req);
|
||||
}, [skinTone]);
|
||||
|
||||
// Update SharedValue when keyboard height changes
|
||||
useEffect(() => {
|
||||
keyboardHeightShared.value = keyboardHeight;
|
||||
}, [keyboardHeight, keyboardHeightShared]);
|
||||
|
||||
// Reset search state when emoji picker is dismissed to prevent reaction from firing
|
||||
useEffect(() => {
|
||||
if (!showInputAccessoryView) {
|
||||
isSearching.value = false;
|
||||
setIsEmojiSearchFocused(false);
|
||||
setShowKeyboard(false);
|
||||
isReducingHeight.current = false;
|
||||
if (keyboardTimeoutRef.current) {
|
||||
clearTimeout(keyboardTimeoutRef.current);
|
||||
keyboardTimeoutRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [showInputAccessoryView, isSearching, setIsEmojiSearchFocused]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (keyboardTimeoutRef.current) {
|
||||
clearTimeout(keyboardTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update emoji picker height when keyboard height changes and search is focused
|
||||
useAnimatedReaction(
|
||||
() => keyboardHeightShared.value,
|
||||
(currentKeyboardH, previousKeyboardH) => {
|
||||
// Only update if:
|
||||
// 1. Search is focused
|
||||
// 2. Emoji picker is still showing (not being dismissed)
|
||||
// 3. Keyboard height actually changed
|
||||
if (
|
||||
isSearching.value &&
|
||||
isInputAccessoryViewMode.value &&
|
||||
previousKeyboardH !== undefined &&
|
||||
previousKeyboardH !== currentKeyboardH
|
||||
) {
|
||||
// Skip if keyboard height is 0 (onFocus handler already set correct height using lastKeyboardHeight)
|
||||
if (currentKeyboardH === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate total height: keyboardHeight + search bar height + search container padding + visibility offset
|
||||
const visibilityOffset = Platform.OS === 'android' ? SEARCH_VISIBILITY_OFFSET_ANDROID : SEARCH_VISIBILITY_OFFSET;
|
||||
const targetHeight = Platform.OS === 'android' ? SEARCH_BAR_HEIGHT + SEARCH_CONTAINER_PADDING + visibilityOffset : currentKeyboardH + SEARCH_BAR_HEIGHT + SEARCH_CONTAINER_PADDING + visibilityOffset;
|
||||
|
||||
emojiPickerHeight.value = withTiming(targetHeight, {
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.ease),
|
||||
});
|
||||
}
|
||||
},
|
||||
[keyboardHeightShared, isSearching, emojiPickerHeight, isInputAccessoryViewMode],
|
||||
);
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
// Ignore blur events during height reduction process
|
||||
if (isReducingHeight.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSelectingEmojiRef?.current) {
|
||||
isSelectingEmojiRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEmojiSearchFocused(false);
|
||||
isSearching.value = false;
|
||||
setShowKeyboard(false);
|
||||
|
||||
DeviceEventEmitter.emit(Events.EMOJI_PICKER_SEARCH_FOCUSED, false);
|
||||
|
||||
// Clear any pending keyboard timeout
|
||||
if (keyboardTimeoutRef.current) {
|
||||
clearTimeout(keyboardTimeoutRef.current);
|
||||
keyboardTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Only restore height if emoji picker is still showing
|
||||
// If emoji picker is being dismissed (showInputAccessoryView is false),
|
||||
// PostInput.onFocus already handles setting height to 0
|
||||
if (showInputAccessoryView) {
|
||||
// Restore to original emoji picker height (without search-related additions)
|
||||
const originalHeight = contextLastKeyboardHeight > 0 ? contextLastKeyboardHeight : DEFAULT_INPUT_ACCESSORY_HEIGHT;
|
||||
emojiPickerHeight.value = withTiming(originalHeight, {duration: 250});
|
||||
}
|
||||
}, [emojiPickerHeight, isSearching, setIsEmojiSearchFocused, contextLastKeyboardHeight, showInputAccessoryView, isSelectingEmojiRef]);
|
||||
|
||||
const onFocus = useCallback(() => {
|
||||
if (Platform.OS === 'android' && showKeyboard) {
|
||||
isReducingHeight.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEmojiSearchFocused(true);
|
||||
|
||||
DeviceEventEmitter.emit(Events.EMOJI_PICKER_SEARCH_FOCUSED, true);
|
||||
|
||||
// Use last keyboard height from context if available, otherwise use default input accessory height
|
||||
// The useAnimatedReaction will handle real-time updates when keyboard actually opens
|
||||
const keyboardH = contextLastKeyboardHeight > 0 ? contextLastKeyboardHeight : DEFAULT_INPUT_ACCESSORY_HEIGHT;
|
||||
const visibilityOffset = Platform.OS === 'android' ? SEARCH_VISIBILITY_OFFSET_ANDROID : SEARCH_VISIBILITY_OFFSET;
|
||||
const targetHeight = Platform.OS === 'android' ? SEARCH_BAR_HEIGHT + SEARCH_CONTAINER_PADDING + visibilityOffset : keyboardH + SEARCH_BAR_HEIGHT + SEARCH_CONTAINER_PADDING + visibilityOffset;
|
||||
|
||||
if (Platform.OS === 'android') {
|
||||
setShowKeyboard(false);
|
||||
isReducingHeight.current = true;
|
||||
}
|
||||
|
||||
// Set height immediately (without animation) to prevent jump
|
||||
emojiPickerHeight.value = withTiming(targetHeight, {duration: 250});
|
||||
isSearching.value = true;
|
||||
|
||||
// On Android, delay keyboard opening to allow height reduction to render first
|
||||
if (Platform.OS === 'android') {
|
||||
if (keyboardTimeoutRef.current) {
|
||||
clearTimeout(keyboardTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Blur immediately to prevent keyboard from opening
|
||||
searchRef.current?.blur();
|
||||
|
||||
const handleDelayedFocus = () => {
|
||||
setShowKeyboard(true);
|
||||
requestAnimationFrame(() => {
|
||||
searchRef.current?.focus();
|
||||
});
|
||||
keyboardTimeoutRef.current = null;
|
||||
};
|
||||
|
||||
keyboardTimeoutRef.current = setTimeout(handleDelayedFocus, 500);
|
||||
}
|
||||
}, [emojiPickerHeight, isSearching, setIsEmojiSearchFocused, contextLastKeyboardHeight, showKeyboard]);
|
||||
|
||||
const onLayout = useCallback((e: LayoutChangeEvent) => {
|
||||
containerWidth.value = e.nativeEvent.layout.width;
|
||||
}, [containerWidth]);
|
||||
|
||||
return (
|
||||
<View
|
||||
onLayout={onLayout}
|
||||
style={styles.row}
|
||||
>
|
||||
<View style={styles.flex}>
|
||||
<SearchBar
|
||||
{...props}
|
||||
onBlur={onBlur}
|
||||
onFocus={onFocus}
|
||||
ref={searchRef}
|
||||
showSoftInputOnFocus={Platform.OS !== 'android' || showKeyboard}
|
||||
/>
|
||||
</View>
|
||||
<SkinToneSelector
|
||||
skinTone={skinTone}
|
||||
containerWidth={containerWidth}
|
||||
isSearching={isSearching}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiPickerHeader;
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {Preferences} from '@constants';
|
||||
import {queryEmojiPreferences} from '@queries/servers/preference';
|
||||
|
||||
import EmojiPickerHeader from './emoji_picker_header';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
|
||||
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
|
||||
skinTone: queryEmojiPreferences(database, Preferences.EMOJI_SKINTONE).
|
||||
observeWithColumns(['value']).pipe(
|
||||
switchMap((prefs) => of$(prefs?.[0]?.value ?? 'default')),
|
||||
),
|
||||
}));
|
||||
|
||||
export default withDatabase(enhanced(EmojiPickerHeader));
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {FlashList, type ListRenderItemInfo} from '@shopify/flash-list';
|
||||
import {chunk} from 'lodash';
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {View, StyleSheet} from 'react-native';
|
||||
|
||||
import {fetchCustomEmojis} from '@actions/remote/custom_emoji';
|
||||
import EmojiCategoryBar from '@components/emoji_category_bar';
|
||||
import {EMOJI_CATEGORY_ICONS, EMOJI_ROW_MARGIN, EMOJI_SIZE, EMOJIS_PER_PAGE, EMOJIS_PER_ROW, EMOJIS_PER_ROW_TABLET} from '@constants/emoji';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {setEmojiCategoryBarIcons, setEmojiCategoryBarSection, useEmojiCategoryBar} from '@hooks/emoji_category_bar';
|
||||
import EmojiRow, {type EmojiSectionRow} from '@screens/emoji_picker/picker/sections/emoji_row';
|
||||
import SectionFooter from '@screens/emoji_picker/picker/sections/section_footer';
|
||||
import SectionHeader, {type EmojiSection} from '@screens/emoji_picker/picker/sections/section_header';
|
||||
import {CategoryNames, EmojiIndicesByCategory, CategoryTranslations, CategoryMessage} from '@utils/emoji';
|
||||
import {fillEmoji} from '@utils/emoji/helpers';
|
||||
|
||||
import type {CustomEmojiModel} from '@database/models/server';
|
||||
|
||||
type SectionListItem = EmojiSection | EmojiSectionRow;
|
||||
|
||||
const categoryToI18n: Record<string, CategoryTranslation> = {};
|
||||
|
||||
const emptyEmoji: EmojiAlias = {
|
||||
name: '',
|
||||
short_name: '',
|
||||
aliases: [],
|
||||
};
|
||||
|
||||
const keyExtractor = (item: SectionListItem) => {
|
||||
return (item.type === 'section' ? `${item.key}` : `${item.sectionIndex}-${item.index}-${item.category}`);
|
||||
};
|
||||
|
||||
const getItemType = (item: SectionListItem) => item.type;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {flex: 1},
|
||||
containerStyle: {paddingBottom: 50, paddingHorizontal: 12},
|
||||
});
|
||||
|
||||
type Props = {
|
||||
customEmojis: CustomEmojiModel[];
|
||||
customEmojisEnabled: boolean;
|
||||
imageUrl?: string;
|
||||
file?: ExtractedFileInfo;
|
||||
onEmojiPress: (emoji: string) => void;
|
||||
recentEmojis: string[];
|
||||
};
|
||||
|
||||
CategoryNames.forEach((name: string) => {
|
||||
if (CategoryTranslations.has(name) && CategoryMessage.has(name)) {
|
||||
categoryToI18n[name] = {
|
||||
id: CategoryTranslations.get(name)!,
|
||||
defaultMessage: CategoryMessage.get(name)!,
|
||||
icon: EMOJI_CATEGORY_ICONS[name],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export default function CustomEmojiSections({customEmojis, customEmojisEnabled, file, imageUrl, onEmojiPress, recentEmojis}: Props) {
|
||||
const [customEmojiPage, setCustomEmojiPage] = useState(() => Math.ceil(customEmojis.length / EMOJIS_PER_PAGE));
|
||||
const [fetchingCustomEmojis, setFetchingCustomEmojis] = useState(false);
|
||||
const [loadedAllCustomEmojis, setLoadedAllCustomEmojis] = useState(false);
|
||||
const scrollingToIndex = useRef(false);
|
||||
const serverUrl = useServerUrl();
|
||||
const isTablet = useIsTablet();
|
||||
const {currentIndex, selectedIndex} = useEmojiCategoryBar();
|
||||
|
||||
const list = useRef<FlashList<SectionListItem> | null>(null);
|
||||
|
||||
const sections: SectionListItem[] = useMemo(() => {
|
||||
const emojisPerRow = isTablet ? EMOJIS_PER_ROW_TABLET : EMOJIS_PER_ROW;
|
||||
const sectionsArray = CategoryNames.map<EmojiSection>((category) => {
|
||||
return {
|
||||
type: 'section',
|
||||
...categoryToI18n[category],
|
||||
key: category,
|
||||
};
|
||||
});
|
||||
|
||||
if (imageUrl || file) {
|
||||
sectionsArray.unshift({
|
||||
type: 'section',
|
||||
id: 'emoji_picker.default',
|
||||
defaultMessage: 'Default',
|
||||
icon: 'bookmark-outline',
|
||||
key: 'default',
|
||||
});
|
||||
}
|
||||
|
||||
return sectionsArray.reduce<SectionListItem[]>((acc, section, sectionIndex) => {
|
||||
acc.push(section);
|
||||
const emojiIndices = EmojiIndicesByCategory.get('default')?.get(section.key);
|
||||
let emojiArray: EmojiAlias[][];
|
||||
switch (section.key) {
|
||||
case 'custom': {
|
||||
const builtInCustom = emojiIndices.map(fillEmoji.bind(null, 'custom'));
|
||||
const mapCustom = (ce: CustomEmojiModel) => ({
|
||||
aliases: [],
|
||||
name: ce.name,
|
||||
short_name: '',
|
||||
});
|
||||
const custom = customEmojisEnabled ? customEmojis.map(mapCustom) : [];
|
||||
emojiArray = chunk<EmojiAlias>(builtInCustom.concat(custom), emojisPerRow);
|
||||
break;
|
||||
}
|
||||
case 'recent': {
|
||||
const recentMap = (emoji: string) => ({
|
||||
aliases: [],
|
||||
name: emoji,
|
||||
short_name: '',
|
||||
});
|
||||
if (recentEmojis.length === 0) {
|
||||
acc.pop();
|
||||
return acc;
|
||||
}
|
||||
emojiArray = chunk<EmojiAlias>(recentEmojis.map(recentMap), emojisPerRow);
|
||||
break;
|
||||
}
|
||||
case 'default':
|
||||
acc.push({
|
||||
type: 'row',
|
||||
emojis: [{
|
||||
aliases: [],
|
||||
name: imageUrl || file?.name || '',
|
||||
short_name: imageUrl || file?.name || '',
|
||||
category: 'image',
|
||||
}],
|
||||
sectionIndex,
|
||||
category: section.key,
|
||||
index: 0,
|
||||
});
|
||||
return acc;
|
||||
default:
|
||||
emojiArray = chunk(emojiIndices.map(fillEmoji.bind(null, section.key)), emojisPerRow);
|
||||
break;
|
||||
}
|
||||
|
||||
for (let index = 0; index < emojiArray.length; index++) {
|
||||
const d = emojiArray[index];
|
||||
const emojis = d.length < emojisPerRow ? d.concat(new Array(emojisPerRow - d.length).fill(emptyEmoji)) : d;
|
||||
acc.push({
|
||||
type: 'row',
|
||||
emojis,
|
||||
sectionIndex,
|
||||
category: section.key,
|
||||
index,
|
||||
});
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}, [customEmojis, customEmojisEnabled, file, imageUrl, isTablet, recentEmojis]);
|
||||
|
||||
const stickyHeaderIndices = useMemo(() =>
|
||||
sections.
|
||||
map((item, index) => (item.type === 'section' ? index : undefined)).
|
||||
filter((item) => item !== undefined) as number[],
|
||||
[sections]);
|
||||
|
||||
const renderItem = useCallback(({item}: ListRenderItemInfo<SectionListItem>) => {
|
||||
if (item.type === 'section') {
|
||||
return (
|
||||
<SectionHeader
|
||||
key={item.key}
|
||||
section={item}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EmojiRow
|
||||
key={`${item.sectionIndex}-${item.index}-${item.category}`}
|
||||
emojis={item.emojis}
|
||||
file={file}
|
||||
imageUrl={imageUrl}
|
||||
onEmojiPress={onEmojiPress}
|
||||
/>
|
||||
);
|
||||
}, [file, imageUrl, onEmojiPress]);
|
||||
|
||||
const scrollToIndex = useCallback((index: number) => {
|
||||
scrollingToIndex.current = true;
|
||||
list.current?.scrollToIndex({animated: false, index: stickyHeaderIndices[index], viewOffset: 0});
|
||||
setEmojiCategoryBarSection(index);
|
||||
setTimeout(() => {
|
||||
scrollingToIndex.current = false;
|
||||
}, 250);
|
||||
}, [stickyHeaderIndices]);
|
||||
|
||||
const loadMoreCustomEmojis = useCallback(async () => {
|
||||
if (!customEmojisEnabled || fetchingCustomEmojis || loadedAllCustomEmojis) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFetchingCustomEmojis(true);
|
||||
const {data, error} = await fetchCustomEmojis(serverUrl, customEmojiPage, EMOJIS_PER_PAGE);
|
||||
if (data?.length) {
|
||||
setCustomEmojiPage(customEmojiPage + 1);
|
||||
} else if (!error && (data && data.length < EMOJIS_PER_PAGE)) {
|
||||
setLoadedAllCustomEmojis(true);
|
||||
}
|
||||
|
||||
setFetchingCustomEmojis(false);
|
||||
}, [customEmojisEnabled, fetchingCustomEmojis, loadedAllCustomEmojis, serverUrl, customEmojiPage]);
|
||||
|
||||
const handleStickyHeaderIndexChanged = useCallback((index: number) => {
|
||||
if (scrollingToIndex.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stickyIndex = stickyHeaderIndices.indexOf(index);
|
||||
if (stickyIndex !== -1 && currentIndex !== stickyIndex) {
|
||||
requestAnimationFrame(() => {
|
||||
setEmojiCategoryBarSection(stickyIndex);
|
||||
});
|
||||
}
|
||||
}, [currentIndex, stickyHeaderIndices]);
|
||||
|
||||
const renderFooter = useMemo(() => {
|
||||
return fetchingCustomEmojis ? <SectionFooter/> : null;
|
||||
}, [fetchingCustomEmojis]);
|
||||
|
||||
useEffect(() => {
|
||||
setEmojiCategoryBarIcons(sections.filter((s) => s.type === 'section').map((s) => ({
|
||||
key: s.key,
|
||||
icon: s.icon,
|
||||
})));
|
||||
}, [sections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIndex != null) {
|
||||
scrollToIndex(selectedIndex);
|
||||
}
|
||||
|
||||
// do not include scrollToIndex in dependencies
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedIndex]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlashList
|
||||
contentContainerStyle={styles.containerStyle}
|
||||
data={sections}
|
||||
estimatedItemSize={EMOJI_SIZE + EMOJI_ROW_MARGIN}
|
||||
getItemType={getItemType}
|
||||
keyExtractor={keyExtractor}
|
||||
ListFooterComponent={renderFooter}
|
||||
onEndReachedThreshold={0.5}
|
||||
onEndReached={loadMoreCustomEmojis}
|
||||
onStickyHeaderIndexChanged={handleStickyHeaderIndexChanged}
|
||||
ref={list}
|
||||
renderItem={renderItem}
|
||||
stickyHeaderIndices={stickyHeaderIndices}
|
||||
/>
|
||||
<EmojiCategoryBar/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
95
app/components/post_draft/custom_emoji_picker/index.tsx
Normal file
95
app/components/post_draft/custom_emoji_picker/index.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {type SharedValue} from 'react-native-reanimated';
|
||||
|
||||
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
|
||||
import {EmojiIndicesByAlias, Emojis} from '@utils/emoji';
|
||||
|
||||
import EmojiPicker from './emoji_picker';
|
||||
|
||||
type Props = {
|
||||
height: SharedValue<number>;
|
||||
onEmojiPress?: (emoji: string) => void;
|
||||
setIsEmojiSearchFocused: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isEmojiSearchFocused: boolean;
|
||||
}
|
||||
|
||||
const CustomEmojiPicker: React.FC<Props> = ({
|
||||
height,
|
||||
isEmojiSearchFocused,
|
||||
onEmojiPress,
|
||||
setIsEmojiSearchFocused,
|
||||
}) => {
|
||||
const {cursorPositionRef, updateValue, updateCursorPosition} = useKeyboardAnimationContext();
|
||||
|
||||
const handleEmojiPress = useCallback((emojiName: string) => {
|
||||
// If onEmojiPress prop is provided, use it (for other use cases)
|
||||
if (onEmojiPress) {
|
||||
onEmojiPress(emojiName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, use context-based insertion (for input accessory view)
|
||||
if (!updateValue || !updateCursorPosition || !cursorPositionRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = emojiName.trim();
|
||||
|
||||
// Calculate the inserted text first to determine its length
|
||||
let insertedText = '';
|
||||
if (EmojiIndicesByAlias.get(name)) {
|
||||
const emoji = Emojis[EmojiIndicesByAlias.get(name)!];
|
||||
if (emoji.category === 'custom') {
|
||||
insertedText = ` :${emojiName}: `;
|
||||
} else {
|
||||
const unicode = emoji.image;
|
||||
if (unicode) {
|
||||
const codeArray = unicode.split('-');
|
||||
const convertToUnicode = (acc: string, c: string) => {
|
||||
return acc + String.fromCodePoint(parseInt(c, 16));
|
||||
};
|
||||
insertedText = codeArray.reduce(convertToUnicode, '');
|
||||
} else {
|
||||
insertedText = ` :${emojiName}: `;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
insertedText = ` :${emojiName}: `;
|
||||
}
|
||||
|
||||
// Read cursor position and calculate new position immediately
|
||||
// This ensures rapid clicks use the correct position
|
||||
const currentCursorPosition = cursorPositionRef.current;
|
||||
const insertedTextLength = insertedText.length;
|
||||
const newCursorPosition = currentCursorPosition + insertedTextLength;
|
||||
|
||||
// Update cursorPositionRef IMMEDIATELY (before React processes the update)
|
||||
// This ensures the next rapid click uses the updated position
|
||||
cursorPositionRef.current = newCursorPosition;
|
||||
|
||||
// Update cursor position state (for Android and to keep state in sync)
|
||||
updateCursorPosition(newCursorPosition);
|
||||
|
||||
const insertEmoji = (v: string): string => {
|
||||
// Use the captured cursor position from when the function was created
|
||||
return v.slice(0, currentCursorPosition) + insertedText + v.slice(currentCursorPosition);
|
||||
};
|
||||
|
||||
updateValue(insertEmoji);
|
||||
}, [onEmojiPress, updateValue, updateCursorPosition, cursorPositionRef]);
|
||||
|
||||
return (
|
||||
<EmojiPicker
|
||||
onEmojiPress={handleEmojiPress}
|
||||
testID='custom_emoji_picker'
|
||||
setIsEmojiSearchFocused={setIsEmojiSearchFocused}
|
||||
isEmojiSearchFocused={isEmojiSearchFocused}
|
||||
emojiPickerHeight={height}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomEmojiPicker;
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useRef} from 'react';
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Keyboard, type LayoutChangeEvent, Platform, ScrollView, View} from 'react-native';
|
||||
import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
|
||||
|
||||
import {Screens} from '@constants';
|
||||
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
|
|
@ -23,8 +24,6 @@ import Uploads from '../uploads';
|
|||
|
||||
import Header from './header';
|
||||
|
||||
import type {PasteInputRef} from '@mattermost/react-native-paste-input';
|
||||
|
||||
export type Props = {
|
||||
testID?: string;
|
||||
channelId: string;
|
||||
|
|
@ -60,9 +59,8 @@ export type Props = {
|
|||
scheduledPostsEnabled: boolean;
|
||||
}
|
||||
|
||||
const SAFE_AREA_VIEW_EDGES: Edge[] = ['left', 'right'];
|
||||
|
||||
const SCHEDULED_POST_PICKER_BUTTON = 'close-scheduled-post-picker';
|
||||
const SAFE_AREA_VIEW_EDGES: Edge[] = ['left', 'right'];
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
|
|
@ -140,14 +138,11 @@ function DraftInput({
|
|||
const theme = useTheme();
|
||||
const isTablet = useIsTablet();
|
||||
|
||||
const {inputRef, focusInput: focus} = useKeyboardAnimationContext();
|
||||
|
||||
const handleLayout = useCallback((e: LayoutChangeEvent) => {
|
||||
updatePostInputTop(e.nativeEvent.layout.height);
|
||||
}, []);
|
||||
|
||||
const inputRef = useRef<PasteInputRef>();
|
||||
const focus = useCallback(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
}, [updatePostInputTop]);
|
||||
|
||||
// Render
|
||||
const postInputTestID = `${testID}.post.input`;
|
||||
|
|
|
|||
|
|
@ -2,13 +2,10 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {Platform} from 'react-native';
|
||||
|
||||
import Autocomplete from '@components/autocomplete';
|
||||
import {ExtraKeyboard} from '@context/extra_keyboard';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useAutocompleteDefaultAnimatedValues} from '@hooks/autocomplete';
|
||||
import {useKeyboardHeight} from '@hooks/device';
|
||||
import {useDefaultHeaderHeight} from '@hooks/header';
|
||||
|
||||
import Archived from './archived';
|
||||
|
|
@ -55,8 +52,6 @@ function PostDraft({
|
|||
const [cursorPosition, setCursorPosition] = useState(message.length);
|
||||
const [postInputTop, setPostInputTop] = useState(0);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const keyboardHeight = useKeyboardHeight();
|
||||
const kbHeight = Platform.OS === 'ios' ? keyboardHeight : 0; // useKeyboardHeight is already deducting the keyboard height on Android
|
||||
const headerHeight = useDefaultHeaderHeight();
|
||||
const serverUrl = useServerUrl();
|
||||
|
||||
|
|
@ -64,9 +59,9 @@ function PostDraft({
|
|||
useEffect(() => {
|
||||
setValue(message);
|
||||
setCursorPosition(message.length);
|
||||
}, [channelId, rootId]);
|
||||
}, [channelId, message, rootId]);
|
||||
|
||||
const autocompletePosition = AUTOCOMPLETE_ADJUST + kbHeight + postInputTop;
|
||||
const autocompletePosition = postInputTop + AUTOCOMPLETE_ADJUST;
|
||||
const autocompleteAvailableSpace = containerHeight - autocompletePosition - (isChannelScreen ? headerHeight : 0);
|
||||
const [animatedAutocompletePosition, animatedAutocompleteAvailableSpace] = useAutocompleteDefaultAnimatedValues(autocompletePosition, autocompleteAvailableSpace);
|
||||
|
||||
|
|
@ -120,6 +115,7 @@ function PostDraft({
|
|||
shouldDirectlyReact={!Boolean(files?.length)}
|
||||
availableSpace={animatedAutocompleteAvailableSpace}
|
||||
serverUrl={serverUrl}
|
||||
usePortal={true}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
|
|
@ -127,7 +123,6 @@ function PostDraft({
|
|||
<>
|
||||
{draftHandler}
|
||||
{autoComplete}
|
||||
{Platform.OS !== 'android' && <ExtraKeyboard/>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,15 +10,18 @@ import {
|
|||
Alert, AppState, type AppStateStatus, DeviceEventEmitter, type EmitterSubscription, Keyboard,
|
||||
type NativeSyntheticEvent, Platform, type TextInputSelectionChangeEventData,
|
||||
} from 'react-native';
|
||||
import {runOnUI} from 'react-native-reanimated';
|
||||
|
||||
import {updateDraftMessage} from '@actions/local/draft';
|
||||
import {userTyping} from '@actions/websocket/users';
|
||||
import {Events, Screens} from '@constants';
|
||||
import {useExtraKeyboardContext} from '@context/extra_keyboard';
|
||||
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {useInputPropagation} from '@hooks/input';
|
||||
import {useFocusAfterEmojiDismiss} from '@hooks/useFocusAfterEmojiDismiss';
|
||||
import {DEFAULT_INPUT_ACCESSORY_HEIGHT} from '@hooks/useInputAccessoryView';
|
||||
import NavigationStore from '@store/navigation_store';
|
||||
import {handleDraftUpdate} from '@utils/draft';
|
||||
import {extractFileInfo} from '@utils/file';
|
||||
|
|
@ -122,7 +125,41 @@ export default function PostInput({
|
|||
const style = getStyleSheet(theme);
|
||||
const serverUrl = useServerUrl();
|
||||
const managedConfig = useManagedConfig<ManagedConfig>();
|
||||
const keyboardContext = useExtraKeyboardContext();
|
||||
|
||||
const {
|
||||
setShowInputAccessoryView,
|
||||
showInputAccessoryView,
|
||||
isInputAccessoryViewMode,
|
||||
inputAccessoryViewAnimatedHeight,
|
||||
keyboardTranslateY,
|
||||
isTransitioningFromCustomView,
|
||||
setIsEmojiSearchFocused,
|
||||
isEmojiSearchFocused,
|
||||
keyboardHeight,
|
||||
lastKeyboardHeight,
|
||||
bottomInset,
|
||||
scrollOffset,
|
||||
registerCursorPosition,
|
||||
registerPostInputCallbacks,
|
||||
} = useKeyboardAnimationContext();
|
||||
|
||||
// Register cursor position updates with context
|
||||
useEffect(() => {
|
||||
if (showInputAccessoryView) {
|
||||
return;
|
||||
}
|
||||
if (registerCursorPosition) {
|
||||
registerCursorPosition(cursorPosition);
|
||||
}
|
||||
}, [registerCursorPosition, cursorPosition, showInputAccessoryView]);
|
||||
|
||||
// Register updateValue and updateCursorPosition with context
|
||||
useEffect(() => {
|
||||
if (registerPostInputCallbacks) {
|
||||
registerPostInputCallbacks(updateValue, updateCursorPosition);
|
||||
}
|
||||
}, [registerPostInputCallbacks, updateValue, updateCursorPosition]);
|
||||
|
||||
const [propagateValue, shouldProcessEvent] = useInputPropagation();
|
||||
|
||||
const lastTypingEventSent = useRef(0);
|
||||
|
|
@ -132,22 +169,25 @@ export default function PostInput({
|
|||
|
||||
const [longMessageAlertShown, setLongMessageAlertShown] = useState(false);
|
||||
|
||||
// Handle focus after emoji picker dismissal
|
||||
const focusInput = useCallback(() => {
|
||||
inputRef.current?.focus();
|
||||
}, [inputRef]);
|
||||
|
||||
const {
|
||||
focus: focusWithEmojiDismiss,
|
||||
isDismissingEmojiPicker,
|
||||
focusTimeoutRef,
|
||||
isManuallyFocusingAfterEmojiDismiss,
|
||||
} = useFocusAfterEmojiDismiss(inputRef, focusInput);
|
||||
|
||||
const disableCopyAndPaste = managedConfig.copyAndPasteProtection === 'true';
|
||||
const maxHeight = isTablet ? 150 : 88;
|
||||
const pasteInputStyle = useMemo(() => {
|
||||
return {...style.input, maxHeight};
|
||||
}, [maxHeight, style.input]);
|
||||
|
||||
const handleAndroidKeyboardHide = () => {
|
||||
onBlur();
|
||||
};
|
||||
|
||||
const handleAndroidKeyboardShow = () => {
|
||||
onFocus();
|
||||
};
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
keyboardContext?.registerTextInputBlur();
|
||||
handleDraftUpdate({
|
||||
serverUrl,
|
||||
channelId,
|
||||
|
|
@ -155,12 +195,111 @@ export default function PostInput({
|
|||
value,
|
||||
});
|
||||
setIsFocused(false);
|
||||
}, [keyboardContext, serverUrl, channelId, rootId, value, setIsFocused]);
|
||||
}, [serverUrl, channelId, rootId, value, setIsFocused]);
|
||||
|
||||
// Handle press event (fires BEFORE onFocus) - dismiss emoji picker before keyboard opens
|
||||
const handlePress = useCallback(() => {
|
||||
focusWithEmojiDismiss();
|
||||
}, [focusWithEmojiDismiss]);
|
||||
|
||||
const onFocus = useCallback(() => {
|
||||
keyboardContext?.registerTextInputFocus();
|
||||
// Ignore focus events during emoji picker dismissal - handled manually
|
||||
if (Platform.OS === 'android' && (isDismissingEmojiPicker.current || focusTimeoutRef.current || isManuallyFocusingAfterEmojiDismiss)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// On Android, ignore focus events when emoji search is focused
|
||||
// This prevents the emoji picker from closing when the search bar gets focus
|
||||
if (Platform.OS === 'android' && isEmojiSearchFocused) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFocused(true);
|
||||
}, [setIsFocused, keyboardContext]);
|
||||
|
||||
// Reset emoji search focus immediately to prevent jumping
|
||||
// This must happen before closing the emoji picker
|
||||
setIsEmojiSearchFocused(false);
|
||||
|
||||
// Close emoji picker immediately
|
||||
setShowInputAccessoryView(false);
|
||||
|
||||
if (Platform.OS === 'android') {
|
||||
|
||||
keyboardTranslateY.value = inputAccessoryViewAnimatedHeight.value;
|
||||
inputAccessoryViewAnimatedHeight.value = 0;
|
||||
isInputAccessoryViewMode.value = false;
|
||||
|
||||
// IMPORTANT: Reset isTransitioningFromCustomView when keyboard opens
|
||||
// This ensures emoji picker can be opened again after keyboard appears
|
||||
isTransitioningFromCustomView.value = false;
|
||||
|
||||
// Reset bottomInset and scrollOffset so the scroll restoration can trigger when emoji picker closes
|
||||
bottomInset.value = 0;
|
||||
scrollOffset.value = 0;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Transition from emoji picker to keyboard
|
||||
if (showInputAccessoryView) {
|
||||
// Use actual keyboard height instead of emoji picker height to ensure consistency
|
||||
// This prevents height accumulation when transitioning multiple times
|
||||
// Use default keyboard height if no keyboard height has been recorded yet
|
||||
// This prevents input container from going to bottom when keyboard hasn't been opened
|
||||
const targetKeyboardHeight = keyboardHeight.value || lastKeyboardHeight || DEFAULT_INPUT_ACCESSORY_HEIGHT;
|
||||
|
||||
// Set transition flag FIRST synchronously to prevent keyboard handlers from interfering
|
||||
// This must be set before disabling input accessory view mode to avoid race conditions
|
||||
isTransitioningFromCustomView.value = true;
|
||||
|
||||
// Collapse emoji picker instantly
|
||||
inputAccessoryViewAnimatedHeight.value = 0;
|
||||
|
||||
// Set input container height to keyboard height to ensure correct final position
|
||||
// This ensures the height always matches the keyboard, preventing accumulation
|
||||
keyboardTranslateY.value = targetKeyboardHeight;
|
||||
|
||||
// Use runOnUI to disable input accessory view mode atomically
|
||||
// This ensures the transition flag is visible when keyboard handlers start processing
|
||||
runOnUI(() => {
|
||||
'worklet';
|
||||
|
||||
// Disable custom view mode to allow keyboard handlers to work
|
||||
// This is done AFTER setting transition flag to prevent race conditions
|
||||
isInputAccessoryViewMode.value = false;
|
||||
})();
|
||||
|
||||
// Safety net: In rare cases (app backgrounding, system interruptions, rapid toggling),
|
||||
// the keyboard onEnd event might not fire, leaving us stuck in transition state.
|
||||
// This timeout ensures we recover after 1 second if that happens.
|
||||
setTimeout(() => {
|
||||
if (isTransitioningFromCustomView.value) {
|
||||
isTransitioningFromCustomView.value = false;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Shared values don't need to be in dependencies - they're stable references
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
isDismissingEmojiPicker,
|
||||
focusTimeoutRef,
|
||||
isManuallyFocusingAfterEmojiDismiss,
|
||||
isEmojiSearchFocused,
|
||||
setIsFocused,
|
||||
setIsEmojiSearchFocused,
|
||||
setShowInputAccessoryView,
|
||||
showInputAccessoryView,
|
||||
lastKeyboardHeight,
|
||||
]);
|
||||
|
||||
const handleAndroidKeyboardHide = useCallback(() => {
|
||||
onBlur();
|
||||
}, [onBlur]);
|
||||
|
||||
const handleAndroidKeyboardShow = useCallback(() => {
|
||||
onFocus();
|
||||
}, [onFocus]);
|
||||
|
||||
const checkMessageLength = useCallback((newValue: string) => {
|
||||
const valueLength = newValue.trim().length;
|
||||
|
|
@ -189,10 +328,13 @@ export default function PostInput({
|
|||
}, [intl, longMessageAlertShown, maxMessageLength]);
|
||||
|
||||
const handlePostDraftSelectionChanged = useCallback((event: NativeSyntheticEvent<TextInputSelectionChangeEventData> | null, fromHandleTextChange = false) => {
|
||||
if (showInputAccessoryView && !fromHandleTextChange) {
|
||||
return;
|
||||
}
|
||||
const cp = fromHandleTextChange ? cursorPosition : event!.nativeEvent.selection.end;
|
||||
|
||||
updateCursorPosition(cp);
|
||||
}, [updateCursorPosition, cursorPosition]);
|
||||
}, [showInputAccessoryView, cursorPosition, updateCursorPosition]);
|
||||
|
||||
const handleTextChange = useCallback((newValue: string) => {
|
||||
if (!shouldProcessEvent(newValue)) {
|
||||
|
|
@ -286,7 +428,7 @@ export default function PostInput({
|
|||
keyboardShowListener?.remove();
|
||||
keyboardHideListener?.remove();
|
||||
});
|
||||
}, []);
|
||||
}, [handleAndroidKeyboardHide, handleAndroidKeyboardShow]);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = AppState.addEventListener('change', onAppStateChange);
|
||||
|
|
@ -311,6 +453,12 @@ export default function PostInput({
|
|||
listener.remove();
|
||||
updateDraftMessage(serverUrl, channelId, rootId, lastNativeValue.current); // safe draft on unmount
|
||||
};
|
||||
|
||||
// - updateValue, updateCursorPosition, propagateValue are stable setState/hook functions
|
||||
// - inputRef is a ref (stable reference, doesn't need to be in deps)
|
||||
// - serverUrl, value, lastNativeValue are either stable or we want their latest values when event fires
|
||||
// - We need to recreate the listener when channelId/rootId changes to check the correct source screen
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [updateValue, channelId, rootId]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -318,6 +466,10 @@ export default function PostInput({
|
|||
propagateValue(value);
|
||||
lastNativeValue.current = value;
|
||||
}
|
||||
|
||||
// - propagateValue is from useInputPropagation hook (stable reference, doesn't need to be in deps)
|
||||
// - lastNativeValue is a ref (stable reference, doesn't need to be in deps)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
const events = useMemo(() => ({
|
||||
|
|
@ -336,11 +488,13 @@ export default function PostInput({
|
|||
onBlur={onBlur}
|
||||
onChangeText={handleTextChange}
|
||||
onFocus={onFocus}
|
||||
onPress={Platform.OS === 'android' ? handlePress : undefined}
|
||||
onPaste={onPaste}
|
||||
onSelectionChange={handlePostDraftSelectionChanged}
|
||||
placeholder={intl.formatMessage(getPlaceHolder(rootId), {channelDisplayName})}
|
||||
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
|
||||
ref={inputRef}
|
||||
showSoftInputOnFocus={Platform.OS === 'android' ? (!showInputAccessoryView || isManuallyFocusingAfterEmojiDismiss) : true}
|
||||
smartPunctuation='disable'
|
||||
submitBehavior='newline'
|
||||
style={pasteInputStyle}
|
||||
|
|
@ -349,6 +503,7 @@ export default function PostInput({
|
|||
textContentType='none'
|
||||
value={value}
|
||||
autoCapitalize='sentences'
|
||||
nativeID={testID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,306 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {fireEvent, waitFor} from '@testing-library/react-native';
|
||||
import {KeyboardController} from 'react-native-keyboard-controller';
|
||||
|
||||
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
|
||||
import {openAttachmentOptions} from '@screens/navigation';
|
||||
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
|
||||
|
||||
import AttachmentQuickAction from '.';
|
||||
|
||||
jest.mock('react-native-keyboard-controller', () => ({
|
||||
KeyboardController: {
|
||||
dismiss: jest.fn(() => Promise.resolve()),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@context/keyboard_animation', () => ({
|
||||
useKeyboardAnimationContext: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@screens/navigation', () => ({
|
||||
openAttachmentOptions: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('AttachmentQuickAction', () => {
|
||||
const mockCloseInputAccessoryView = jest.fn();
|
||||
const mockKeyboardControllerDismiss = jest.mocked(KeyboardController.dismiss);
|
||||
const mockOpenAttachmentOptions = jest.mocked(openAttachmentOptions);
|
||||
const mockUseKeyboardAnimationContext = jest.mocked(useKeyboardAnimationContext);
|
||||
|
||||
const baseProps = {
|
||||
disabled: false,
|
||||
fileCount: 0,
|
||||
onUploadFiles: jest.fn(),
|
||||
maxFilesReached: false,
|
||||
maxFileCount: 10,
|
||||
testID: 'test-attachment',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseKeyboardAnimationContext.mockReturnValue({
|
||||
closeInputAccessoryView: mockCloseInputAccessoryView,
|
||||
} as unknown as ReturnType<typeof useKeyboardAnimationContext>);
|
||||
});
|
||||
|
||||
describe('user interactions', () => {
|
||||
it('should call onUploadFiles when button is pressed', async () => {
|
||||
const onUploadFiles = jest.fn();
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentQuickAction
|
||||
{...baseProps}
|
||||
onUploadFiles={onUploadFiles}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = getByTestId('test-attachment');
|
||||
fireEvent.press(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCloseInputAccessoryView).toHaveBeenCalledTimes(1);
|
||||
expect(mockKeyboardControllerDismiss).toHaveBeenCalledTimes(1);
|
||||
expect(mockOpenAttachmentOptions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call onUploadFiles when button is disabled', async () => {
|
||||
const onUploadFiles = jest.fn();
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentQuickAction
|
||||
{...baseProps}
|
||||
disabled={true}
|
||||
onUploadFiles={onUploadFiles}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = getByTestId('test-attachment.disabled');
|
||||
fireEvent.press(button);
|
||||
|
||||
// Should not trigger any actions when disabled
|
||||
expect(mockCloseInputAccessoryView).not.toHaveBeenCalled();
|
||||
expect(mockKeyboardControllerDismiss).not.toHaveBeenCalled();
|
||||
expect(mockOpenAttachmentOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should close input accessory view before opening bottom sheet', async () => {
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentQuickAction {...baseProps}/>,
|
||||
);
|
||||
|
||||
const button = getByTestId('test-attachment');
|
||||
fireEvent.press(button);
|
||||
|
||||
await waitFor(() => {
|
||||
// closeInputAccessoryView should be called before openAttachmentOptions
|
||||
expect(mockCloseInputAccessoryView).toHaveBeenCalledTimes(1);
|
||||
expect(mockOpenAttachmentOptions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should dismiss keyboard before opening bottom sheet', async () => {
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentQuickAction {...baseProps}/>,
|
||||
);
|
||||
|
||||
const button = getByTestId('test-attachment');
|
||||
fireEvent.press(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockKeyboardControllerDismiss).toHaveBeenCalledTimes(1);
|
||||
expect(mockOpenAttachmentOptions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('bottom sheet opening', () => {
|
||||
it('should call openAttachmentOptions with intl, theme, and props', async () => {
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentQuickAction {...baseProps}/>,
|
||||
);
|
||||
|
||||
const button = getByTestId('test-attachment');
|
||||
fireEvent.press(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenAttachmentOptions).toHaveBeenCalledTimes(1);
|
||||
expect(mockOpenAttachmentOptions).toHaveBeenCalledWith(
|
||||
expect.any(Object), // intl
|
||||
expect.any(Object), // theme
|
||||
expect.objectContaining({
|
||||
onUploadFiles: baseProps.onUploadFiles,
|
||||
maxFilesReached: false,
|
||||
canUploadFiles: true,
|
||||
testID: 'test-attachment',
|
||||
fileCount: 0,
|
||||
maxFileCount: 10,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass correct props to openAttachmentOptions', async () => {
|
||||
const onUploadFiles = jest.fn();
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentQuickAction
|
||||
{...baseProps}
|
||||
onUploadFiles={onUploadFiles}
|
||||
fileCount={5}
|
||||
maxFilesReached={false}
|
||||
maxFileCount={10}
|
||||
disabled={false}
|
||||
testID='custom-test-id'
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = getByTestId('custom-test-id');
|
||||
fireEvent.press(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenAttachmentOptions).toHaveBeenCalledWith(
|
||||
expect.any(Object), // intl
|
||||
expect.any(Object), // theme
|
||||
expect.objectContaining({
|
||||
onUploadFiles,
|
||||
fileCount: 5,
|
||||
maxFilesReached: false,
|
||||
canUploadFiles: true,
|
||||
testID: 'custom-test-id',
|
||||
maxFileCount: 10,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass canUploadFiles as false when disabled', async () => {
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentQuickAction
|
||||
{...baseProps}
|
||||
disabled={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = getByTestId('test-attachment.disabled');
|
||||
fireEvent.press(button);
|
||||
|
||||
// When disabled, openAttachmentOptions should not be called
|
||||
expect(mockOpenAttachmentOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass intl and theme to openAttachmentOptions', async () => {
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentQuickAction {...baseProps}/>,
|
||||
);
|
||||
|
||||
const button = getByTestId('test-attachment');
|
||||
fireEvent.press(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenAttachmentOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
formatMessage: expect.any(Function),
|
||||
}), // intl
|
||||
expect.objectContaining({
|
||||
centerChannelColor: expect.anything(),
|
||||
}), // theme
|
||||
expect.any(Object), // props
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle fileCount prop correctly', async () => {
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentQuickAction
|
||||
{...baseProps}
|
||||
fileCount={3}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = getByTestId('test-attachment');
|
||||
fireEvent.press(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenAttachmentOptions).toHaveBeenCalledWith(
|
||||
expect.any(Object), // intl
|
||||
expect.any(Object), // theme
|
||||
expect.objectContaining({
|
||||
fileCount: 3,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle maxFilesReached prop correctly', async () => {
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentQuickAction
|
||||
{...baseProps}
|
||||
maxFilesReached={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = getByTestId('test-attachment');
|
||||
fireEvent.press(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenAttachmentOptions).toHaveBeenCalledWith(
|
||||
expect.any(Object), // intl
|
||||
expect.any(Object), // theme
|
||||
expect.objectContaining({
|
||||
maxFilesReached: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle maxFileCount prop correctly', async () => {
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentQuickAction
|
||||
{...baseProps}
|
||||
maxFileCount={20}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = getByTestId('test-attachment');
|
||||
fireEvent.press(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenAttachmentOptions).toHaveBeenCalledWith(
|
||||
expect.any(Object), // intl
|
||||
expect.any(Object), // theme
|
||||
expect.objectContaining({
|
||||
maxFileCount: 20,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle default fileCount when not provided', async () => {
|
||||
const propsWithoutFileCount = {
|
||||
...baseProps,
|
||||
};
|
||||
delete (propsWithoutFileCount as {fileCount?: number}).fileCount;
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentQuickAction {...propsWithoutFileCount}/>,
|
||||
);
|
||||
|
||||
const button = getByTestId('test-attachment');
|
||||
fireEvent.press(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenAttachmentOptions).toHaveBeenCalledWith(
|
||||
expect.any(Object), // intl
|
||||
expect.any(Object), // theme
|
||||
expect.objectContaining({
|
||||
fileCount: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {ICON_SIZE} from '@constants/post_draft';
|
||||
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {openAttachmentOptions} from '@screens/navigation';
|
||||
import {dismissKeyboard} from '@utils/keyboard';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import type {QuickActionAttachmentProps} from '@typings/components/post_draft_quick_action';
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
iconContainer: {
|
||||
display: 'flex',
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
alignSelf: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 40,
|
||||
height: 40,
|
||||
marginRight: 8,
|
||||
},
|
||||
icon: {
|
||||
alignSelf: 'center',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default function AttachmentQuickAction({
|
||||
disabled,
|
||||
fileCount = 0,
|
||||
onUploadFiles,
|
||||
maxFilesReached,
|
||||
maxFileCount,
|
||||
testID = '',
|
||||
}: QuickActionAttachmentProps) {
|
||||
const intl = useIntl();
|
||||
const theme = useTheme();
|
||||
const {closeInputAccessoryView} = useKeyboardAnimationContext();
|
||||
const style = getStyleSheet(theme);
|
||||
const iconColor = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);
|
||||
|
||||
const openFileAttachmentOptions = useCallback(async () => {
|
||||
closeInputAccessoryView();
|
||||
await dismissKeyboard();
|
||||
|
||||
openAttachmentOptions(intl, theme, {
|
||||
onUploadFiles,
|
||||
maxFilesReached,
|
||||
canUploadFiles: !disabled,
|
||||
testID,
|
||||
fileCount,
|
||||
maxFileCount,
|
||||
});
|
||||
}, [closeInputAccessoryView, intl, theme, onUploadFiles, maxFilesReached, disabled, testID, fileCount, maxFileCount]);
|
||||
|
||||
const actionTestID = disabled ? `${testID}.disabled` : testID;
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
testID={actionTestID}
|
||||
disabled={disabled}
|
||||
onPress={openFileAttachmentOptions}
|
||||
style={style.iconContainer}
|
||||
type={'opacity'}
|
||||
>
|
||||
<CompassIcon
|
||||
name='plus'
|
||||
size={ICON_SIZE}
|
||||
color={iconColor}
|
||||
style={style.icon}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {View} from 'react-native';
|
||||
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import SlideUpPanelItem from '@components/slide_up_panel_item';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {dismissBottomSheet} from '@screens/navigation';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import type {CameraOptions} from 'react-native-image-picker';
|
||||
|
||||
type Props = {
|
||||
onPress: (options: CameraOptions) => void;
|
||||
}
|
||||
|
||||
const getStyle = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
title: {
|
||||
color: theme.centerChannelColor,
|
||||
...typography('Heading', 600, 'SemiBold'),
|
||||
marginBottom: 8,
|
||||
},
|
||||
|
||||
}));
|
||||
|
||||
const CameraType = ({onPress}: Props) => {
|
||||
const theme = useTheme();
|
||||
const isTablet = useIsTablet();
|
||||
const style = getStyle(theme);
|
||||
const intl = useIntl();
|
||||
|
||||
const onPhoto = async () => {
|
||||
const options: CameraOptions = {
|
||||
quality: 0.8,
|
||||
mediaType: 'photo',
|
||||
saveToPhotos: true,
|
||||
};
|
||||
|
||||
await dismissBottomSheet();
|
||||
onPress(options);
|
||||
};
|
||||
|
||||
const onVideo = async () => {
|
||||
const options: CameraOptions = {
|
||||
videoQuality: 'high',
|
||||
mediaType: 'video',
|
||||
saveToPhotos: true,
|
||||
};
|
||||
|
||||
await dismissBottomSheet();
|
||||
onPress(options);
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
{!isTablet &&
|
||||
<FormattedText
|
||||
id='mobile.camera_type.title'
|
||||
defaultMessage='Camera options'
|
||||
style={style.title}
|
||||
/>
|
||||
}
|
||||
<SlideUpPanelItem
|
||||
leftIcon='camera-outline'
|
||||
onPress={onPhoto}
|
||||
testID='camera_type.photo'
|
||||
text={intl.formatMessage({id: 'camera_type.photo.option', defaultMessage: 'Capture Photo'})}
|
||||
/>
|
||||
<SlideUpPanelItem
|
||||
leftIcon='video-outline'
|
||||
onPress={onVideo}
|
||||
testID='camera_type.video'
|
||||
text={intl.formatMessage({id: 'camera_type.video.option', defaultMessage: 'Record Video'})}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default CameraType;
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Alert, StyleSheet} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {ITEM_HEIGHT} from '@components/slide_up_panel_item';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {ICON_SIZE} from '@constants/post_draft';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {TITLE_HEIGHT} from '@screens/bottom_sheet/content';
|
||||
import {bottomSheet} from '@screens/navigation';
|
||||
import {fileMaxWarning} from '@utils/file';
|
||||
import PickerUtil from '@utils/file/file_picker';
|
||||
import {bottomSheetSnapPoint} from '@utils/helpers';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
||||
import CameraType from './camera_type';
|
||||
|
||||
import type {QuickActionAttachmentProps} from '@typings/components/post_draft_quick_action';
|
||||
import type {CameraOptions} from 'react-native-image-picker';
|
||||
|
||||
const style = StyleSheet.create({
|
||||
icon: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 10,
|
||||
},
|
||||
});
|
||||
|
||||
export default function CameraQuickAction({
|
||||
disabled,
|
||||
onUploadFiles,
|
||||
maxFilesReached,
|
||||
maxFileCount,
|
||||
testID,
|
||||
}: QuickActionAttachmentProps) {
|
||||
const intl = useIntl();
|
||||
const theme = useTheme();
|
||||
|
||||
const handleButtonPress = useCallback((options: CameraOptions) => {
|
||||
const picker = new PickerUtil(intl,
|
||||
onUploadFiles);
|
||||
|
||||
picker.attachFileFromCamera(options);
|
||||
}, [intl, onUploadFiles]);
|
||||
|
||||
const renderContent = useCallback(() => {
|
||||
return (
|
||||
<CameraType
|
||||
onPress={handleButtonPress}
|
||||
/>
|
||||
);
|
||||
}, [handleButtonPress]);
|
||||
|
||||
const openSelectorModal = useCallback(() => {
|
||||
if (maxFilesReached) {
|
||||
Alert.alert(
|
||||
intl.formatMessage({
|
||||
id: 'mobile.link.error.title',
|
||||
defaultMessage: 'Error',
|
||||
}),
|
||||
fileMaxWarning(intl, maxFileCount),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
bottomSheet({
|
||||
title: intl.formatMessage({id: 'mobile.camera_type.title', defaultMessage: 'Camera options'}),
|
||||
renderContent,
|
||||
snapPoints: [1, bottomSheetSnapPoint(2, ITEM_HEIGHT) + TITLE_HEIGHT],
|
||||
theme,
|
||||
closeButtonId: 'camera-close-id',
|
||||
});
|
||||
}, [intl, theme, renderContent, maxFilesReached, maxFileCount]);
|
||||
|
||||
const actionTestID = disabled ? `${testID}.disabled` : testID;
|
||||
const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
testID={actionTestID}
|
||||
disabled={disabled}
|
||||
onPress={openSelectorModal}
|
||||
style={style.icon}
|
||||
type={'opacity'}
|
||||
>
|
||||
<CompassIcon
|
||||
color={color}
|
||||
name='camera-outline'
|
||||
size={ICON_SIZE}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useRef} from 'react';
|
||||
import {Platform, StyleSheet} from 'react-native';
|
||||
import {runOnJS, runOnUI} from 'react-native-reanimated';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {ICON_SIZE} from '@constants/post_draft';
|
||||
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {DEFAULT_INPUT_ACCESSORY_HEIGHT} from '@hooks/useInputAccessoryView';
|
||||
import {usePreventDoubleTap} from '@hooks/utils';
|
||||
import {dismissKeyboard, isKeyboardVisible} from '@utils/keyboard';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
||||
type Props = {
|
||||
testID?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const style = StyleSheet.create({
|
||||
icon: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 10,
|
||||
},
|
||||
});
|
||||
|
||||
export default function EmojiQuickAction({
|
||||
testID,
|
||||
disabled,
|
||||
}: Props) {
|
||||
const theme = useTheme();
|
||||
const {
|
||||
isInputAccessoryViewMode,
|
||||
isTransitioningFromCustomView,
|
||||
keyboardHeight,
|
||||
lastKeyboardHeight,
|
||||
inputAccessoryViewAnimatedHeight,
|
||||
showInputAccessoryView,
|
||||
setShowInputAccessoryView,
|
||||
isKeyboardFullyClosed,
|
||||
} = useKeyboardAnimationContext();
|
||||
|
||||
const showEmojiPicker = useCallback(() => {
|
||||
setShowInputAccessoryView(true);
|
||||
}, [setShowInputAccessoryView]);
|
||||
|
||||
const checkCallbackRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const scheduleKeyboardCheck = useCallback(() => {
|
||||
const checkKeyboard = () => {
|
||||
runOnUI(() => {
|
||||
'worklet';
|
||||
const currentKeyboardHeight = keyboardHeight.value;
|
||||
const targetHeight = currentKeyboardHeight || lastKeyboardHeight || DEFAULT_INPUT_ACCESSORY_HEIGHT;
|
||||
|
||||
if (isKeyboardFullyClosed.value || keyboardHeight.value === 0) {
|
||||
// Match iOS order: Set SharedValues first, then trigger React render
|
||||
// This ensures values are set before emoji picker component mounts
|
||||
isInputAccessoryViewMode.value = true;
|
||||
inputAccessoryViewAnimatedHeight.value = targetHeight;
|
||||
|
||||
// Trigger React render to mount emoji picker component
|
||||
runOnJS(showEmojiPicker)();
|
||||
} else if (checkCallbackRef.current) {
|
||||
runOnJS(checkCallbackRef.current)();
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
checkCallbackRef.current = () => {
|
||||
requestAnimationFrame(checkKeyboard);
|
||||
};
|
||||
|
||||
checkKeyboard();
|
||||
|
||||
// Shared values don't need to be in dependencies - they're stable references
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lastKeyboardHeight, showEmojiPicker]);
|
||||
|
||||
const handleButtonPress = usePreventDoubleTap(useCallback(() => {
|
||||
// Prevent opening if already showing or transitioning
|
||||
if (disabled || showInputAccessoryView || isTransitioningFromCustomView.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.OS === 'android' && isKeyboardVisible()) {
|
||||
dismissKeyboard();
|
||||
|
||||
// Wait for keyboard to be fully dismissed before showing emoji picker
|
||||
// This prevents the emoji picker from appearing above the keyboard
|
||||
// Start checking after a small delay to give keyboard dismissal time to start
|
||||
setTimeout(scheduleKeyboardCheck, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
// CRITICAL: Execute all shared value updates atomically on UI thread.
|
||||
// Why? When KeyboardController.dismiss() is called, it immediately fires keyboard events
|
||||
// (onStart, onMove, onEnd) on the UI thread. If we set isInputAccessoryViewMode on the
|
||||
// JS thread, there's a race condition - the keyboard handlers might fire BEFORE the flag
|
||||
// propagates to the UI thread, causing them to process the dismiss event incorrectly.
|
||||
// By running everything on UI thread, we guarantee isInputAccessoryViewMode is set
|
||||
// BEFORE keyboard events fire, ensuring they are properly ignored.
|
||||
runOnUI(() => {
|
||||
'worklet';
|
||||
|
||||
// Determine target height for emoji picker
|
||||
const currentKeyboardHeight = keyboardHeight.value;
|
||||
const targetHeight = currentKeyboardHeight || lastKeyboardHeight || DEFAULT_INPUT_ACCESSORY_HEIGHT;
|
||||
|
||||
// Enable custom view mode before keyboard dismissal to prevent keyboard handlers from interfering
|
||||
isInputAccessoryViewMode.value = true;
|
||||
|
||||
// Set emoji picker height to target
|
||||
// Note: Don't modify `height` yet - it will be set to 0 after emoji picker mounts
|
||||
// This keeps the input container at the keyboard position while emoji picker renders
|
||||
inputAccessoryViewAnimatedHeight.value = targetHeight;
|
||||
|
||||
// Trigger React render to mount emoji picker component
|
||||
runOnJS(setShowInputAccessoryView)(true);
|
||||
|
||||
// Dismiss keyboard
|
||||
runOnJS(dismissKeyboard)();
|
||||
})();
|
||||
|
||||
// Shared values don't need to be in dependencies - they're stable references
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [disabled, showInputAccessoryView, lastKeyboardHeight, setShowInputAccessoryView, scheduleKeyboardCheck]));
|
||||
|
||||
const actionTestID = disabled ? `${testID}.disabled` : testID;
|
||||
const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
testID={actionTestID}
|
||||
disabled={disabled}
|
||||
onPress={handleButtonPress}
|
||||
style={style.icon}
|
||||
type={'opacity'}
|
||||
>
|
||||
<CompassIcon
|
||||
color={color}
|
||||
name='emoticon-happy-outline'
|
||||
size={ICON_SIZE}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Alert, StyleSheet} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {ICON_SIZE} from '@constants/post_draft';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {fileMaxWarning} from '@utils/file';
|
||||
import PickerUtil from '@utils/file/file_picker';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
||||
import type {QuickActionAttachmentProps} from '@typings/components/post_draft_quick_action';
|
||||
|
||||
const style = StyleSheet.create({
|
||||
icon: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 10,
|
||||
},
|
||||
});
|
||||
|
||||
export default function FileQuickAction({
|
||||
disabled,
|
||||
onUploadFiles,
|
||||
maxFilesReached,
|
||||
maxFileCount,
|
||||
testID = '',
|
||||
}: QuickActionAttachmentProps) {
|
||||
const intl = useIntl();
|
||||
const theme = useTheme();
|
||||
|
||||
const handleButtonPress = useCallback(() => {
|
||||
if (maxFilesReached) {
|
||||
Alert.alert(
|
||||
intl.formatMessage({
|
||||
id: 'mobile.link.error.title',
|
||||
defaultMessage: 'Error',
|
||||
}),
|
||||
fileMaxWarning(intl, maxFileCount),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const picker = new PickerUtil(intl,
|
||||
onUploadFiles);
|
||||
|
||||
picker.attachFileFromFiles(undefined, true);
|
||||
}, [intl, maxFileCount, maxFilesReached, onUploadFiles]);
|
||||
|
||||
const actionTestID = disabled ? `${testID}.disabled` : testID;
|
||||
const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
testID={actionTestID}
|
||||
disabled={disabled}
|
||||
onPress={handleButtonPress}
|
||||
style={style.icon}
|
||||
type={'opacity'}
|
||||
>
|
||||
<CompassIcon
|
||||
color={color}
|
||||
name='paperclip'
|
||||
size={ICON_SIZE}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Alert, StyleSheet} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {ICON_SIZE} from '@constants/post_draft';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {fileMaxWarning} from '@utils/file';
|
||||
import PickerUtil from '@utils/file/file_picker';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
||||
import type {QuickActionAttachmentProps} from '@typings/components/post_draft_quick_action';
|
||||
|
||||
const style = StyleSheet.create({
|
||||
icon: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 10,
|
||||
},
|
||||
});
|
||||
|
||||
export default function ImageQuickAction({
|
||||
disabled,
|
||||
fileCount = 0,
|
||||
onUploadFiles,
|
||||
maxFilesReached,
|
||||
maxFileCount,
|
||||
testID = '',
|
||||
}: QuickActionAttachmentProps) {
|
||||
const intl = useIntl();
|
||||
const theme = useTheme();
|
||||
|
||||
const handleButtonPress = useCallback(() => {
|
||||
if (maxFilesReached) {
|
||||
Alert.alert(
|
||||
intl.formatMessage({
|
||||
id: 'mobile.link.error.title',
|
||||
defaultMessage: 'Error',
|
||||
}),
|
||||
fileMaxWarning(intl, maxFileCount),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const picker = new PickerUtil(intl,
|
||||
onUploadFiles);
|
||||
|
||||
picker.attachFileFromPhotoGallery(maxFileCount - fileCount);
|
||||
}, [onUploadFiles, fileCount, maxFileCount]);
|
||||
|
||||
const actionTestID = disabled ? `${testID}.disabled` : testID;
|
||||
const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
testID={actionTestID}
|
||||
disabled={disabled}
|
||||
onPress={handleButtonPress}
|
||||
style={style.icon}
|
||||
type={'opacity'}
|
||||
>
|
||||
<CompassIcon
|
||||
color={color}
|
||||
name='image-outline'
|
||||
size={ICON_SIZE}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,55 +1,337 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
|
||||
import {fireEvent, renderWithIntlAndTheme} from '@test/intl-test-helper';
|
||||
|
||||
import InputQuickAction from '.';
|
||||
|
||||
jest.mock('@context/keyboard_animation', () => ({
|
||||
useKeyboardAnimationContext: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@hooks/useFocusAfterEmojiDismiss', () => ({
|
||||
useFocusAfterEmojiDismiss: jest.fn((inputRef, focusInput) => ({
|
||||
focus: focusInput,
|
||||
isDismissingEmojiPicker: {current: false},
|
||||
focusTimeoutRef: {current: null},
|
||||
isManuallyFocusingAfterEmojiDismiss: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('InputQuickAction', () => {
|
||||
it('should add If theres existing text and it doesnt end with a space, add a space before @', () => {
|
||||
const updateValue = jest.fn();
|
||||
const testID = 'test-id';
|
||||
const inputType = 'at';
|
||||
const focus = jest.fn();
|
||||
const mockUseKeyboardAnimationContext = jest.mocked(useKeyboardAnimationContext);
|
||||
const mockUpdateCursorPosition = jest.fn();
|
||||
const mockInputRef = {current: undefined};
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<InputQuickAction
|
||||
testID={testID}
|
||||
updateValue={updateValue}
|
||||
inputType={inputType}
|
||||
focus={focus}
|
||||
/>);
|
||||
|
||||
const icon = getByTestId('test-id');
|
||||
fireEvent.press(icon);
|
||||
|
||||
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
|
||||
const updateFunction = updateValue.mock.calls[0][0];
|
||||
expect(updateFunction('')).toBe('@');
|
||||
expect(focus).toHaveBeenCalled();
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should add space before @ if there is existing text and it doesnt end with a space', () => {
|
||||
const updateValue = jest.fn();
|
||||
const testID = 'test-id';
|
||||
const inputType = 'at';
|
||||
const focus = jest.fn();
|
||||
describe('fallback behavior (no cursor position context)', () => {
|
||||
beforeEach(() => {
|
||||
mockUseKeyboardAnimationContext.mockReturnValue({
|
||||
inputRef: mockInputRef,
|
||||
cursorPositionRef: undefined,
|
||||
updateCursorPosition: undefined,
|
||||
} as unknown as ReturnType<typeof useKeyboardAnimationContext>);
|
||||
});
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<InputQuickAction
|
||||
testID={testID}
|
||||
updateValue={updateValue}
|
||||
inputType={inputType}
|
||||
focus={focus}
|
||||
/>);
|
||||
it('should add @ to empty string', () => {
|
||||
const updateValue = jest.fn();
|
||||
const testID = 'test-id';
|
||||
const inputType = 'at';
|
||||
const focus = jest.fn();
|
||||
|
||||
const icon = getByTestId('test-id');
|
||||
fireEvent.press(icon);
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<InputQuickAction
|
||||
testID={testID}
|
||||
updateValue={updateValue}
|
||||
inputType={inputType}
|
||||
focus={focus}
|
||||
/>);
|
||||
|
||||
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
|
||||
const updateFunction = updateValue.mock.calls[0][0];
|
||||
expect(updateFunction('Hello')).toBe('Hello @');
|
||||
expect(focus).toHaveBeenCalled();
|
||||
const icon = getByTestId('test-id');
|
||||
fireEvent.press(icon);
|
||||
|
||||
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
|
||||
const updateFunction = updateValue.mock.calls[0][0];
|
||||
expect(updateFunction('')).toBe('@');
|
||||
expect(focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add space before @ if there is existing text and it doesnt end with a space', () => {
|
||||
const updateValue = jest.fn();
|
||||
const testID = 'test-id';
|
||||
const inputType = 'at';
|
||||
const focus = jest.fn();
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<InputQuickAction
|
||||
testID={testID}
|
||||
updateValue={updateValue}
|
||||
inputType={inputType}
|
||||
focus={focus}
|
||||
/>);
|
||||
|
||||
const icon = getByTestId('test-id');
|
||||
fireEvent.press(icon);
|
||||
|
||||
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
|
||||
const updateFunction = updateValue.mock.calls[0][0];
|
||||
expect(updateFunction('Hello')).toBe('Hello @');
|
||||
expect(focus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cursor position insertion', () => {
|
||||
let cursorPositionRef: {current: number};
|
||||
|
||||
beforeEach(() => {
|
||||
cursorPositionRef = {current: 0};
|
||||
mockUseKeyboardAnimationContext.mockReturnValue({
|
||||
inputRef: mockInputRef,
|
||||
cursorPositionRef,
|
||||
updateCursorPosition: mockUpdateCursorPosition,
|
||||
} as unknown as ReturnType<typeof useKeyboardAnimationContext>);
|
||||
});
|
||||
|
||||
describe('@ input type', () => {
|
||||
it('should insert @ at cursor position at the beginning', () => {
|
||||
const updateValue = jest.fn();
|
||||
const testID = 'test-id';
|
||||
const inputType = 'at';
|
||||
const focus = jest.fn();
|
||||
cursorPositionRef.current = 0;
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<InputQuickAction
|
||||
testID={testID}
|
||||
updateValue={updateValue}
|
||||
inputType={inputType}
|
||||
focus={focus}
|
||||
/>);
|
||||
|
||||
const icon = getByTestId('test-id');
|
||||
fireEvent.press(icon);
|
||||
|
||||
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
|
||||
const updateFunction = updateValue.mock.calls[0][0];
|
||||
expect(updateFunction('Hello')).toBe('@Hello');
|
||||
expect(cursorPositionRef.current).toBe(1);
|
||||
expect(mockUpdateCursorPosition).toHaveBeenCalledWith(1);
|
||||
expect(focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should insert @ at cursor position in the middle', () => {
|
||||
const updateValue = jest.fn();
|
||||
const testID = 'test-id';
|
||||
const inputType = 'at';
|
||||
const focus = jest.fn();
|
||||
cursorPositionRef.current = 3;
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<InputQuickAction
|
||||
testID={testID}
|
||||
updateValue={updateValue}
|
||||
inputType={inputType}
|
||||
focus={focus}
|
||||
/>);
|
||||
|
||||
const icon = getByTestId('test-id');
|
||||
fireEvent.press(icon);
|
||||
|
||||
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
|
||||
const updateFunction = updateValue.mock.calls[0][0];
|
||||
expect(updateFunction('Hello')).toBe('Hel@lo');
|
||||
expect(cursorPositionRef.current).toBe(4);
|
||||
expect(mockUpdateCursorPosition).toHaveBeenCalledWith(4);
|
||||
expect(focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should insert @ at cursor position at the end', () => {
|
||||
const updateValue = jest.fn();
|
||||
const testID = 'test-id';
|
||||
const inputType = 'at';
|
||||
const focus = jest.fn();
|
||||
cursorPositionRef.current = 5;
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<InputQuickAction
|
||||
testID={testID}
|
||||
updateValue={updateValue}
|
||||
inputType={inputType}
|
||||
focus={focus}
|
||||
/>);
|
||||
|
||||
const icon = getByTestId('test-id');
|
||||
fireEvent.press(icon);
|
||||
|
||||
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
|
||||
const updateFunction = updateValue.mock.calls[0][0];
|
||||
expect(updateFunction('Hello')).toBe('Hello @');
|
||||
expect(cursorPositionRef.current).toBe(7);
|
||||
expect(mockUpdateCursorPosition).toHaveBeenCalledWith(7);
|
||||
expect(focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add space before @ when cursor is at the end and previous char is not space', () => {
|
||||
const updateValue = jest.fn();
|
||||
const testID = 'test-id';
|
||||
const inputType = 'at';
|
||||
const focus = jest.fn();
|
||||
cursorPositionRef.current = 5;
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<InputQuickAction
|
||||
testID={testID}
|
||||
updateValue={updateValue}
|
||||
inputType={inputType}
|
||||
focus={focus}
|
||||
/>);
|
||||
|
||||
const icon = getByTestId('test-id');
|
||||
fireEvent.press(icon);
|
||||
|
||||
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
|
||||
const updateFunction = updateValue.mock.calls[0][0];
|
||||
expect(updateFunction('Hello')).toBe('Hello @');
|
||||
expect(cursorPositionRef.current).toBe(7);
|
||||
expect(mockUpdateCursorPosition).toHaveBeenCalledWith(7);
|
||||
expect(focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not add space before @ when cursor is at the end and previous char is space', () => {
|
||||
const updateValue = jest.fn();
|
||||
const testID = 'test-id';
|
||||
const inputType = 'at';
|
||||
const focus = jest.fn();
|
||||
cursorPositionRef.current = 6;
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<InputQuickAction
|
||||
testID={testID}
|
||||
updateValue={updateValue}
|
||||
inputType={inputType}
|
||||
focus={focus}
|
||||
/>);
|
||||
|
||||
const icon = getByTestId('test-id');
|
||||
fireEvent.press(icon);
|
||||
|
||||
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
|
||||
const updateFunction = updateValue.mock.calls[0][0];
|
||||
expect(updateFunction('Hello ')).toBe('Hello @');
|
||||
expect(cursorPositionRef.current).toBe(7);
|
||||
expect(mockUpdateCursorPosition).toHaveBeenCalledWith(7);
|
||||
expect(focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not add space before @ when cursor is at the beginning', () => {
|
||||
const updateValue = jest.fn();
|
||||
const testID = 'test-id';
|
||||
const inputType = 'at';
|
||||
const focus = jest.fn();
|
||||
cursorPositionRef.current = 0;
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<InputQuickAction
|
||||
testID={testID}
|
||||
updateValue={updateValue}
|
||||
inputType={inputType}
|
||||
focus={focus}
|
||||
/>);
|
||||
|
||||
const icon = getByTestId('test-id');
|
||||
fireEvent.press(icon);
|
||||
|
||||
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
|
||||
const updateFunction = updateValue.mock.calls[0][0];
|
||||
expect(updateFunction('Hello')).toBe('@Hello');
|
||||
expect(cursorPositionRef.current).toBe(1);
|
||||
expect(mockUpdateCursorPosition).toHaveBeenCalledWith(1);
|
||||
expect(focus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('slash input type', () => {
|
||||
it('should insert / at cursor position at the beginning', () => {
|
||||
const updateValue = jest.fn();
|
||||
const testID = 'test-id';
|
||||
const inputType = 'slash';
|
||||
const focus = jest.fn();
|
||||
cursorPositionRef.current = 0;
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<InputQuickAction
|
||||
testID={testID}
|
||||
updateValue={updateValue}
|
||||
inputType={inputType}
|
||||
focus={focus}
|
||||
/>);
|
||||
|
||||
const icon = getByTestId('test-id');
|
||||
fireEvent.press(icon);
|
||||
|
||||
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
|
||||
const updateFunction = updateValue.mock.calls[0][0];
|
||||
expect(updateFunction('Hello')).toBe('/Hello');
|
||||
expect(cursorPositionRef.current).toBe(1);
|
||||
expect(mockUpdateCursorPosition).toHaveBeenCalledWith(1);
|
||||
expect(focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should insert / at cursor position in the middle', () => {
|
||||
const updateValue = jest.fn();
|
||||
const testID = 'test-id';
|
||||
const inputType = 'slash';
|
||||
const focus = jest.fn();
|
||||
cursorPositionRef.current = 3;
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<InputQuickAction
|
||||
testID={testID}
|
||||
updateValue={updateValue}
|
||||
inputType={inputType}
|
||||
focus={focus}
|
||||
/>);
|
||||
|
||||
const icon = getByTestId('test-id');
|
||||
fireEvent.press(icon);
|
||||
|
||||
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
|
||||
const updateFunction = updateValue.mock.calls[0][0];
|
||||
expect(updateFunction('Hello')).toBe('Hel/lo');
|
||||
expect(cursorPositionRef.current).toBe(4);
|
||||
expect(mockUpdateCursorPosition).toHaveBeenCalledWith(4);
|
||||
expect(focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should insert / at cursor position at the end', () => {
|
||||
const updateValue = jest.fn();
|
||||
const testID = 'test-id';
|
||||
const inputType = 'slash';
|
||||
const focus = jest.fn();
|
||||
cursorPositionRef.current = 5;
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<InputQuickAction
|
||||
testID={testID}
|
||||
updateValue={updateValue}
|
||||
inputType={inputType}
|
||||
focus={focus}
|
||||
/>);
|
||||
|
||||
const icon = getByTestId('test-id');
|
||||
fireEvent.press(icon);
|
||||
|
||||
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
|
||||
const updateFunction = updateValue.mock.calls[0][0];
|
||||
expect(updateFunction('Hello')).toBe('Hello/');
|
||||
expect(cursorPositionRef.current).toBe(6);
|
||||
expect(mockUpdateCursorPosition).toHaveBeenCalledWith(6);
|
||||
expect(focus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import React, {useCallback} from 'react';
|
|||
import CompassIcon from '@components/compass_icon';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {ICON_SIZE} from '@constants/post_draft';
|
||||
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useFocusAfterEmojiDismiss} from '@hooks/useFocusAfterEmojiDismiss';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
type Props = {
|
||||
|
|
@ -38,19 +40,54 @@ export default function InputQuickAction({
|
|||
focus,
|
||||
}: Props) {
|
||||
const theme = useTheme();
|
||||
const {inputRef, cursorPositionRef, updateCursorPosition} = useKeyboardAnimationContext();
|
||||
|
||||
// Use hook to handle focus after emoji picker dismissal
|
||||
const {focus: focusWithEmojiDismiss} = useFocusAfterEmojiDismiss(inputRef, focus);
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
updateValue((v) => {
|
||||
if (inputType === 'at') {
|
||||
// If there's existing text and it doesn't end with a space, add a space before @
|
||||
if (v.length > 0 && !v.endsWith(' ')) {
|
||||
return `${v} @`;
|
||||
if (cursorPositionRef && updateCursorPosition) {
|
||||
const currentCursorPosition = cursorPositionRef.current;
|
||||
|
||||
updateValue((v) => {
|
||||
if (inputType === 'at') {
|
||||
let insertedText = '@';
|
||||
const charBeforeCursor = currentCursorPosition > 0 ? v[currentCursorPosition - 1] : '';
|
||||
|
||||
if (currentCursorPosition === v.length && currentCursorPosition > 0 && charBeforeCursor !== ' ') {
|
||||
insertedText = ' @';
|
||||
}
|
||||
|
||||
const newValue = v.slice(0, currentCursorPosition) + insertedText + v.slice(currentCursorPosition);
|
||||
const newCursorPosition = currentCursorPosition + insertedText.length;
|
||||
|
||||
cursorPositionRef.current = newCursorPosition;
|
||||
updateCursorPosition(newCursorPosition);
|
||||
|
||||
return newValue;
|
||||
}
|
||||
return `${v}@`;
|
||||
}
|
||||
return '/';
|
||||
});
|
||||
focus();
|
||||
}, [inputType, updateValue, focus]);
|
||||
|
||||
const newValue = v.slice(0, currentCursorPosition) + '/' + v.slice(currentCursorPosition);
|
||||
const newCursorPosition = currentCursorPosition + 1;
|
||||
|
||||
cursorPositionRef.current = newCursorPosition;
|
||||
updateCursorPosition(newCursorPosition);
|
||||
|
||||
return newValue;
|
||||
});
|
||||
} else {
|
||||
updateValue((v) => {
|
||||
if (inputType === 'at') {
|
||||
if (v.length > 0 && !v.endsWith(' ')) {
|
||||
return `${v} @`;
|
||||
}
|
||||
return `${v}@`;
|
||||
}
|
||||
return '/';
|
||||
});
|
||||
}
|
||||
focusWithEmojiDismiss();
|
||||
}, [inputType, updateValue, focusWithEmojiDismiss, cursorPositionRef, updateCursorPosition]);
|
||||
|
||||
const actionTestID = disabled ?
|
||||
`${testID}.disabled` :
|
||||
|
|
|
|||
|
|
@ -3,15 +3,17 @@
|
|||
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Keyboard, StyleSheet} from 'react-native';
|
||||
import {StyleSheet} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {Screens} from '@constants';
|
||||
import {ICON_SIZE} from '@constants/post_draft';
|
||||
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {openAsBottomSheet} from '@screens/navigation';
|
||||
import {dismissKeyboard} from '@utils/keyboard';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
||||
type Props = {
|
||||
|
|
@ -38,9 +40,11 @@ export default function PostPriorityAction({
|
|||
const intl = useIntl();
|
||||
const isTablet = useIsTablet();
|
||||
const theme = useTheme();
|
||||
const {closeInputAccessoryView} = useKeyboardAnimationContext();
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
Keyboard.dismiss();
|
||||
const onPress = useCallback(async () => {
|
||||
closeInputAccessoryView();
|
||||
await dismissKeyboard();
|
||||
|
||||
const title = isTablet ? intl.formatMessage({id: 'post_priority.picker.title', defaultMessage: 'Message priority'}) : '';
|
||||
|
||||
|
|
@ -55,7 +59,7 @@ export default function PostPriorityAction({
|
|||
closeButtonId: POST_PRIORITY_PICKER_BUTTON,
|
||||
},
|
||||
});
|
||||
}, [isTablet, intl, theme, postPriority, updatePostPriority]);
|
||||
}, [closeInputAccessoryView, isTablet, intl, theme, postPriority, updatePostPriority]);
|
||||
|
||||
const iconName = 'alert-circle-outline';
|
||||
const iconColor = changeOpacity(theme.centerChannelColor, 0.64);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import QuickActions from './quick_actions';
|
|||
|
||||
describe('Quick Actions', () => {
|
||||
const baseProps: Parameters<typeof QuickActions>[0] = {
|
||||
testID: 'test-quick-actions',
|
||||
canUploadFiles: true,
|
||||
fileCount: 0,
|
||||
isPostPriorityEnabled: true,
|
||||
|
|
@ -24,21 +25,149 @@ describe('Quick Actions', () => {
|
|||
focus: jest.fn(),
|
||||
};
|
||||
|
||||
it('Should render slash command if canShowSlashCommands is true', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
canShowSlashCommands: true,
|
||||
};
|
||||
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
|
||||
expect(queryByTestId('slash-input-action')).toBeDefined();
|
||||
describe('slash commands', () => {
|
||||
it('should render slash command if canShowSlashCommands is true', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
canShowSlashCommands: true,
|
||||
};
|
||||
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
|
||||
expect(queryByTestId('test-quick-actions.slash_input_action')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not render slash command if canShowSlashCommands is false', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
canShowSlashCommands: false,
|
||||
};
|
||||
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
|
||||
expect(queryByTestId('test-quick-actions.slash_input_action')).toBeNull();
|
||||
});
|
||||
|
||||
it('should render slash command by default when canShowSlashCommands is not provided', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
};
|
||||
delete (props as {canShowSlashCommands?: boolean}).canShowSlashCommands;
|
||||
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
|
||||
expect(queryByTestId('test-quick-actions.slash_input_action')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('Should not render slash command if canShowSlashCommands is false', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
canShowSlashCommands: false,
|
||||
};
|
||||
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
|
||||
expect(queryByTestId('slash-input-action')).toBeNull();
|
||||
describe('post priority', () => {
|
||||
it('should render post priority action when both isPostPriorityEnabled and canShowPostPriority are true', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
isPostPriorityEnabled: true,
|
||||
canShowPostPriority: true,
|
||||
};
|
||||
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
|
||||
expect(queryByTestId('test-quick-actions.post_priority_action')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not render post priority action when isPostPriorityEnabled is false', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
isPostPriorityEnabled: false,
|
||||
canShowPostPriority: true,
|
||||
};
|
||||
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
|
||||
expect(queryByTestId('test-quick-actions.post_priority_action')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not render post priority action when canShowPostPriority is false', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
isPostPriorityEnabled: true,
|
||||
canShowPostPriority: false,
|
||||
};
|
||||
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
|
||||
expect(queryByTestId('test-quick-actions.post_priority_action')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not render post priority action when canShowPostPriority is undefined', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
isPostPriorityEnabled: true,
|
||||
};
|
||||
delete (props as {canShowPostPriority?: boolean}).canShowPostPriority;
|
||||
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
|
||||
expect(queryByTestId('test-quick-actions.post_priority_action')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('input action disabled states', () => {
|
||||
it('should disable at input action when value ends with @', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
value: 'test @',
|
||||
};
|
||||
const {getByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
|
||||
const atAction = getByTestId('test-quick-actions.at_input_action.disabled');
|
||||
expect(atAction).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should enable at input action when value does not end with @', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
value: 'test message',
|
||||
};
|
||||
const {getByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
|
||||
const atAction = getByTestId('test-quick-actions.at_input_action');
|
||||
expect(atAction).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('should enable at input action when value is empty', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
value: '',
|
||||
};
|
||||
const {getByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
|
||||
const atAction = getByTestId('test-quick-actions.at_input_action');
|
||||
expect(atAction).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable slash input action when value is not empty', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
value: 'test message',
|
||||
};
|
||||
const {getByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
|
||||
const slashAction = getByTestId('test-quick-actions.slash_input_action.disabled');
|
||||
expect(slashAction).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should enable slash input action when value is empty', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
value: '',
|
||||
};
|
||||
const {getByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
|
||||
const slashAction = getByTestId('test-quick-actions.slash_input_action');
|
||||
expect(slashAction).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachment action props', () => {
|
||||
it('should pass disabled=true when canUploadFiles is false', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
canUploadFiles: false,
|
||||
};
|
||||
const {getByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
|
||||
const attachmentAction = getByTestId('test-quick-actions.attachment_action.disabled');
|
||||
expect(attachmentAction).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should pass disabled=false when canUploadFiles is true', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
canUploadFiles: true,
|
||||
};
|
||||
const {getByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
|
||||
const attachmentAction = getByTestId('test-quick-actions.attachment_action');
|
||||
expect(attachmentAction).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@
|
|||
import React from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
|
||||
import CameraAction from './camera_quick_action';
|
||||
import FileAction from './file_quick_action';
|
||||
import ImageAction from './image_quick_action';
|
||||
import AttachmentAction from './attachment_quick_action';
|
||||
import EmojiAction from './emoji_quick_action';
|
||||
import InputAction from './input_quick_action';
|
||||
import PostPriorityAction from './post_priority_action';
|
||||
|
||||
|
|
@ -17,6 +16,7 @@ type Props = {
|
|||
isPostPriorityEnabled: boolean;
|
||||
canShowPostPriority?: boolean;
|
||||
canShowSlashCommands?: boolean;
|
||||
canShowEmojiPicker?: boolean;
|
||||
maxFileCount: number;
|
||||
|
||||
// Draft Handler
|
||||
|
|
@ -35,6 +35,7 @@ const style = StyleSheet.create({
|
|||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
height: QUICK_ACTIONS_HEIGHT,
|
||||
marginLeft: 8,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -46,6 +47,7 @@ export default function QuickActions({
|
|||
isPostPriorityEnabled,
|
||||
canShowSlashCommands = true,
|
||||
canShowPostPriority,
|
||||
canShowEmojiPicker = true,
|
||||
maxFileCount,
|
||||
updateValue,
|
||||
addFiles,
|
||||
|
|
@ -58,9 +60,8 @@ export default function QuickActions({
|
|||
|
||||
const atInputActionTestID = `${testID}.at_input_action`;
|
||||
const slashInputActionTestID = `${testID}.slash_input_action`;
|
||||
const fileActionTestID = `${testID}.file_action`;
|
||||
const imageActionTestID = `${testID}.image_action`;
|
||||
const cameraActionTestID = `${testID}.camera_action`;
|
||||
const emojiActionTestID = `${testID}.emoji_action`;
|
||||
const attachmentActionTestID = `${testID}.attachment_action`;
|
||||
const postPriorityActionTestID = `${testID}.post_priority_action`;
|
||||
|
||||
const uploadProps = {
|
||||
|
|
@ -76,6 +77,10 @@ export default function QuickActions({
|
|||
testID={testID}
|
||||
style={style.quickActionsContainer}
|
||||
>
|
||||
<AttachmentAction
|
||||
testID={attachmentActionTestID}
|
||||
{...uploadProps}
|
||||
/>
|
||||
<InputAction
|
||||
testID={atInputActionTestID}
|
||||
disabled={atDisabled}
|
||||
|
|
@ -92,18 +97,11 @@ export default function QuickActions({
|
|||
focus={focus}
|
||||
/>
|
||||
)}
|
||||
<FileAction
|
||||
testID={fileActionTestID}
|
||||
{...uploadProps}
|
||||
/>
|
||||
<ImageAction
|
||||
testID={imageActionTestID}
|
||||
{...uploadProps}
|
||||
/>
|
||||
<CameraAction
|
||||
testID={cameraActionTestID}
|
||||
{...uploadProps}
|
||||
/>
|
||||
{canShowEmojiPicker && (
|
||||
<EmojiAction
|
||||
testID={emojiActionTestID}
|
||||
/>
|
||||
)}
|
||||
{isPostPriorityEnabled && canShowPostPriority && (
|
||||
<PostPriorityAction
|
||||
testID={postPriorityActionTestID}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ exports[`AttachmentAuthor it matches snapshot when both name and icon are provid
|
|||
{
|
||||
"cacheKey": "attachment-author-icon-aHR0cHM6Ly9pbWFnZXMuY29tL2ltYWdlLnBuZw==",
|
||||
"cachePath": "",
|
||||
"headers": undefined,
|
||||
"uri": "https://images.com/image.png",
|
||||
},
|
||||
]
|
||||
|
|
@ -95,6 +96,7 @@ exports[`AttachmentAuthor it matches snapshot when only icon is provided 1`] = `
|
|||
{
|
||||
"cacheKey": "attachment-author-icon-aHR0cHM6Ly9pbWFnZXMuY29tL2ltYWdlLnBuZw==",
|
||||
"cachePath": "",
|
||||
"headers": undefined,
|
||||
"uri": "https://images.com/image.png",
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ exports[`AttachmentFooter it matches snapshot when both footer and footer_icon a
|
|||
{
|
||||
"cacheKey": "attachment-footer-icon-aHR0cHM6Ly9pbWFnZXMuY29tL2ltYWdlLnBuZw==",
|
||||
"cachePath": "",
|
||||
"headers": undefined,
|
||||
"uri": "https://images.com/image.png",
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {Text, TouchableOpacity, View} from 'react-native';
|
|||
import {fetchAndSwitchToThread} from '@actions/remote/thread';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {SEARCH} from '@constants/screens';
|
||||
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {usePreventDoubleTap} from '@hooks/utils';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
|
@ -44,11 +45,14 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
|||
const HeaderReply = ({commentCount, location, post, theme}: HeaderReplyProps) => {
|
||||
const style = getStyleSheet(theme);
|
||||
const serverUrl = useServerUrl();
|
||||
const {blurAndDismissKeyboard} = useKeyboardAnimationContext();
|
||||
|
||||
const onPress = usePreventDoubleTap(useCallback(async () => {
|
||||
await blurAndDismissKeyboard();
|
||||
|
||||
const onPress = usePreventDoubleTap(useCallback(() => {
|
||||
const rootId = post.rootId || post.id;
|
||||
fetchAndSwitchToThread(serverUrl, rootId);
|
||||
}, [post.id, post.rootId, serverUrl]));
|
||||
}, [blurAndDismissKeyboard, post.id, post.rootId, serverUrl]));
|
||||
|
||||
return (
|
||||
<View
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import AgentPost from '@agents/components/agent_post';
|
|||
import {isAgentPost} from '@agents/utils';
|
||||
import React, {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Keyboard, Platform, type StyleProp, View, type ViewStyle, TouchableHighlight} from 'react-native';
|
||||
import {Platform, type StyleProp, View, type ViewStyle, TouchableHighlight} from 'react-native';
|
||||
import {KeyboardController} from 'react-native-keyboard-controller';
|
||||
|
||||
import {removePost} from '@actions/local/post';
|
||||
import {showPermalink} from '@actions/remote/permalink';
|
||||
|
|
@ -17,7 +18,7 @@ import SystemAvatar from '@components/system_avatar';
|
|||
import SystemHeader from '@components/system_header';
|
||||
import {POST_TIME_TO_FAIL} from '@constants/post';
|
||||
import * as Screens from '@constants/screens';
|
||||
import {useHideExtraKeyboardIfNeeded} from '@context/extra_keyboard';
|
||||
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
|
|
@ -155,6 +156,7 @@ const Post = ({
|
|||
const serverUrl = useServerUrl();
|
||||
const theme = useTheme();
|
||||
const isTablet = useIsTablet();
|
||||
const {blurAndDismissKeyboard, closeInputAccessoryView, showInputAccessoryView} = useKeyboardAnimationContext();
|
||||
const styles = getStyleSheet(theme);
|
||||
const isAutoResponder = fromAutoResponder(post);
|
||||
const isPendingOrFailed = isPostPendingOrFailed(post);
|
||||
|
|
@ -185,7 +187,7 @@ const Post = ({
|
|||
return false;
|
||||
}, [customEmojiNames, post.message]);
|
||||
|
||||
const handlePostPress = useCallback(() => {
|
||||
const handlePostPress = useCallback(async () => {
|
||||
if ([Screens.SAVED_MESSAGES, Screens.MENTIONS, Screens.SEARCH, Screens.PINNED_MESSAGES].includes(location)) {
|
||||
showPermalink(serverUrl, '', post.id);
|
||||
return;
|
||||
|
|
@ -197,6 +199,7 @@ const Post = ({
|
|||
} else if (isValidSystemMessage && !hasBeenDeleted && !isPendingOrFailed) {
|
||||
// BoR posts cannot have replies, so don't open threads screen for them
|
||||
if (!borPost && [Screens.CHANNEL, Screens.PERMALINK].includes(location)) {
|
||||
await blurAndDismissKeyboard();
|
||||
const postRootId = post.rootId || post.id;
|
||||
fetchAndSwitchToThread(serverUrl, postRootId);
|
||||
}
|
||||
|
|
@ -205,17 +208,19 @@ const Post = ({
|
|||
setTimeout(() => {
|
||||
pressDetected.current = false;
|
||||
}, 300);
|
||||
}, [location, isAutoResponder, isSystemPost, isEphemeral, hasBeenDeleted, isPendingOrFailed, serverUrl, post, borPost]);
|
||||
}, [location, isAutoResponder, isSystemPost, isEphemeral, hasBeenDeleted, isPendingOrFailed, serverUrl, post, borPost, blurAndDismissKeyboard]);
|
||||
|
||||
const handlePress = useHideExtraKeyboardIfNeeded(() => {
|
||||
const handlePress = useCallback(() => {
|
||||
pressDetected.current = true;
|
||||
|
||||
KeyboardController.dismiss();
|
||||
|
||||
if (post) {
|
||||
setTimeout(handlePostPress, 300);
|
||||
}
|
||||
}, [handlePostPress, post]);
|
||||
|
||||
const showPostOptions = useHideExtraKeyboardIfNeeded(() => {
|
||||
const showPostOptions = useCallback(async () => {
|
||||
if (!post) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -228,7 +233,11 @@ const Post = ({
|
|||
return;
|
||||
}
|
||||
|
||||
Keyboard.dismiss();
|
||||
if (showInputAccessoryView) {
|
||||
closeInputAccessoryView();
|
||||
}
|
||||
|
||||
await blurAndDismissKeyboard();
|
||||
const passProps = {sourceScreen: location, post, showAddReaction, serverUrl};
|
||||
const title = isTablet ? intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}) : '';
|
||||
|
||||
|
|
@ -239,11 +248,7 @@ const Post = ({
|
|||
title,
|
||||
props: passProps,
|
||||
});
|
||||
}, [
|
||||
canDelete, hasBeenDeleted, intl,
|
||||
isEphemeral, isPendingOrFailed, isTablet, isSystemPost,
|
||||
location, post, serverUrl, showAddReaction, theme,
|
||||
]);
|
||||
}, [post, isSystemPost, canDelete, hasBeenDeleted, isPendingOrFailed, isEphemeral, blurAndDismissKeyboard, closeInputAccessoryView, showInputAccessoryView, location, showAddReaction, serverUrl, isTablet, intl, theme]);
|
||||
|
||||
const [, rerender] = useState(false);
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import {act} from '@testing-library/react-native';
|
||||
import React, {type ComponentProps} from 'react';
|
||||
import {DeviceEventEmitter, Platform} from 'react-native';
|
||||
import React, {createRef, type ComponentProps} from 'react';
|
||||
import {DeviceEventEmitter, type FlatList, Platform} from 'react-native';
|
||||
|
||||
import * as localPostFunctions from '@actions/local/post';
|
||||
import * as postFunctions from '@actions/remote/post';
|
||||
|
|
@ -75,11 +75,11 @@ describe('components/post_list/PostList', () => {
|
|||
customEmojiNames: [],
|
||||
lastViewedAt: 0,
|
||||
location: Screens.CHANNEL,
|
||||
nativeID: 'post-list',
|
||||
posts: mockPosts,
|
||||
savedPostIds: new Set(),
|
||||
testID: 'post_list',
|
||||
shouldShowJoinLeaveMessages: false,
|
||||
listRef: createRef<FlatList<string | PostModel>>(),
|
||||
};
|
||||
|
||||
it('renders correctly with basic props', () => {
|
||||
|
|
@ -370,7 +370,7 @@ describe('components/post_list/PostList', () => {
|
|||
|
||||
// which causes the content offset to shift
|
||||
act(() => {
|
||||
flatList.props.onScroll({
|
||||
flatList.props.onMomentumScrollEnd({
|
||||
nativeEvent: {
|
||||
contentOffset: {y: 200},
|
||||
...unrelatedNativeEventsAttributes,
|
||||
|
|
@ -383,7 +383,7 @@ describe('components/post_list/PostList', () => {
|
|||
|
||||
// if user post an image, scroll to bottom being called to push offset to 0, which causes the "New Messages" message to disappear
|
||||
act(() => {
|
||||
flatList.props.onScroll({
|
||||
flatList.props.onMomentumScrollEnd({
|
||||
nativeEvent: {
|
||||
contentOffset: {y: 0},
|
||||
...unrelatedNativeEventsAttributes,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@
|
|||
|
||||
import {FlatList} from '@stream-io/flat-list-mvcp';
|
||||
import React, {type ReactElement, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {DeviceEventEmitter, type ListRenderItemInfo, Platform, type StyleProp, StyleSheet, type ViewStyle, type NativeSyntheticEvent, type NativeScrollEvent} from 'react-native';
|
||||
import Animated, {type AnimatedStyle} from 'react-native-reanimated';
|
||||
import {DeviceEventEmitter, type GestureResponderEvent, type ListRenderItemInfo, Platform, type StyleProp, StyleSheet, type ViewStyle, type NativeSyntheticEvent, type NativeScrollEvent} from 'react-native';
|
||||
import {useKeyboardState} from 'react-native-keyboard-controller';
|
||||
import Animated, {runOnJS, useAnimatedProps, useAnimatedReaction, useSharedValue, type AnimatedStyle} from 'react-native-reanimated';
|
||||
|
||||
import {removePost} from '@actions/local/post';
|
||||
import {fetchPosts, fetchPostThread} from '@actions/remote/post';
|
||||
|
|
@ -15,8 +16,10 @@ import Post from '@components/post_list/post';
|
|||
import ThreadOverview from '@components/post_list/thread_overview';
|
||||
import {Events, Screens} from '@constants';
|
||||
import {PostTypes} from '@constants/post';
|
||||
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {DEFAULT_INPUT_ACCESSORY_HEIGHT} from '@hooks/useInputAccessoryView';
|
||||
import {getDateForDateLine, preparePostList} from '@utils/post_list';
|
||||
|
||||
import {INITIAL_BATCH_TO_RENDER, SCROLL_POSITION_CONFIG, VIEWABILITY_CONFIG} from './config';
|
||||
|
|
@ -42,7 +45,6 @@ type Props = {
|
|||
isPostAcknowledgementEnabled?: boolean;
|
||||
lastViewedAt: number;
|
||||
location: AvailableScreens;
|
||||
nativeID: string;
|
||||
onEndReached?: () => void;
|
||||
posts: PostModel[];
|
||||
rootId?: string;
|
||||
|
|
@ -55,6 +57,9 @@ type Props = {
|
|||
testID: string;
|
||||
currentCallBarVisible?: boolean;
|
||||
savedPostIds: Set<string>;
|
||||
listRef?: React.RefObject<FlatList<string | PostModel>>;
|
||||
onTouchMove?: (event: GestureResponderEvent) => void;
|
||||
onTouchEnd?: () => void;
|
||||
}
|
||||
|
||||
type onScrollEndIndexListenerEvent = (endIndex: number) => void;
|
||||
|
|
@ -96,7 +101,6 @@ const PostList = ({
|
|||
isPostAcknowledgementEnabled,
|
||||
lastViewedAt,
|
||||
location,
|
||||
nativeID,
|
||||
onEndReached,
|
||||
posts,
|
||||
rootId,
|
||||
|
|
@ -106,18 +110,60 @@ const PostList = ({
|
|||
showNewMessageLine = true,
|
||||
testID,
|
||||
savedPostIds,
|
||||
listRef,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
}: Props) => {
|
||||
const firstIdInPosts = posts[0]?.id;
|
||||
|
||||
const listRef = useRef<FlatList<string | PostModel>>(null);
|
||||
const {
|
||||
keyboardTranslateY: keyboardHeightValue,
|
||||
bottomInset: contentInset,
|
||||
onScroll: onScrollProp,
|
||||
postInputContainerHeight,
|
||||
keyboardHeight,
|
||||
isKeyboardFullyOpen,
|
||||
isKeyboardFullyClosed,
|
||||
inputAccessoryViewAnimatedHeight,
|
||||
isInputAccessoryViewMode,
|
||||
} = useKeyboardAnimationContext();
|
||||
|
||||
const onScrollEndIndexListener = useRef<onScrollEndIndexListenerEvent>();
|
||||
const onViewableItemsChangedListener = useRef<ViewableItemsChangedListenerEvent>();
|
||||
const scrolledToHighlighted = useRef(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [showScrollToEndBtn, setShowScrollToEndBtn] = useState(false);
|
||||
const [lastPostId, setLastPostId] = useState<string | undefined>(firstIdInPosts);
|
||||
const [progressViewOffset, setProgressViewOffset] = useState(postInputContainerHeight);
|
||||
const [emojiPickerPadding, setEmojiPickerPadding] = useState(0);
|
||||
const theme = useTheme();
|
||||
const serverUrl = useServerUrl();
|
||||
const {isVisible: isKeyboardVisible} = useKeyboardState();
|
||||
|
||||
// Update progressViewOffset to position RefreshControl correctly when keyboard-aware props are applied.
|
||||
// Only update when keyboard state changes (fully open ↔ fully closed) to prevent flickering during animation.
|
||||
const prevIsFullyOpen = useSharedValue(false);
|
||||
const prevIsFullyClosed = useSharedValue(true);
|
||||
useAnimatedReaction(
|
||||
() => ({
|
||||
isFullyOpen: isKeyboardFullyOpen.value,
|
||||
isFullyClosed: isKeyboardFullyClosed.value,
|
||||
keyboardTranslateY: keyboardHeightValue.value,
|
||||
}),
|
||||
({isFullyOpen, isFullyClosed, keyboardTranslateY}) => {
|
||||
// Only update when state actually changes (transition detected)
|
||||
const stateChanged = (prevIsFullyClosed.value !== isFullyClosed) || (prevIsFullyOpen.value !== isFullyOpen);
|
||||
|
||||
if (stateChanged && (isFullyOpen || isFullyClosed)) {
|
||||
const offset = postInputContainerHeight + keyboardTranslateY;
|
||||
runOnJS(setProgressViewOffset)(offset);
|
||||
}
|
||||
prevIsFullyOpen.value = isFullyOpen;
|
||||
prevIsFullyClosed.value = isFullyClosed;
|
||||
},
|
||||
[postInputContainerHeight],
|
||||
);
|
||||
|
||||
const orderedPosts = useMemo(() => {
|
||||
return preparePostList(posts, lastViewedAt, showNewMessageLine, currentUserId, currentUsername, shouldShowJoinLeaveMessages, currentTimezone, location === Screens.THREAD, savedPostIds);
|
||||
}, [posts, lastViewedAt, showNewMessageLine, currentUserId, currentUsername, shouldShowJoinLeaveMessages, currentTimezone, location, savedPostIds]);
|
||||
|
|
@ -129,8 +175,16 @@ const PostList = ({
|
|||
const isNewMessage = lastPostId ? firstIdInPosts !== lastPostId : false;
|
||||
|
||||
const scrollToEnd = useCallback(() => {
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: true});
|
||||
}, []);
|
||||
const activeHeight = Math.max(keyboardHeight.value, inputAccessoryViewAnimatedHeight.value);
|
||||
const targetOffset = -activeHeight;
|
||||
|
||||
listRef?.current?.scrollToOffset({offset: targetOffset, animated: true});
|
||||
|
||||
setShowScrollToEndBtn(false);
|
||||
|
||||
// Shared values don't need to be in dependencies - they're stable references
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [listRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
|
|
@ -182,15 +236,19 @@ const PostList = ({
|
|||
}, [disablePullToRefresh, location, channelId, rootId, posts, serverUrl]);
|
||||
|
||||
const scrollToIndex = useCallback((index: number, animated = true, applyOffset = true) => {
|
||||
listRef.current?.scrollToIndex({
|
||||
if (index < 0 || !listRef?.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
listRef.current.scrollToIndex({
|
||||
animated,
|
||||
index,
|
||||
viewOffset: applyOffset ? Platform.select({ios: -45, default: 0}) : 0,
|
||||
viewPosition: 1, // 0 is at bottom
|
||||
});
|
||||
}, []);
|
||||
}, [listRef]);
|
||||
|
||||
const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const internalOnScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const {y} = event.nativeEvent.contentOffset;
|
||||
const isThresholdReached = y > CONTENT_OFFSET_THRESHOLD;
|
||||
|
||||
|
|
@ -332,7 +390,7 @@ const PostList = ({
|
|||
scrolledToHighlighted.current = true;
|
||||
// eslint-disable-next-line max-nested-callbacks
|
||||
const index = orderedPosts.findIndex((p) => p.type === 'post' && p.value.currentPost.id === highlightedId);
|
||||
if (index >= 0 && listRef.current) {
|
||||
if (index >= 0 && listRef?.current) {
|
||||
listRef.current?.scrollToIndex({
|
||||
animated: true,
|
||||
index,
|
||||
|
|
@ -344,12 +402,51 @@ const PostList = ({
|
|||
}, 500);
|
||||
|
||||
return () => clearTimeout(t);
|
||||
|
||||
// - listRef is a ref (stable reference, doesn't need to be in deps)
|
||||
// - scrolledToHighlighted is a ref (stable reference, doesn't need to be in deps)
|
||||
// - We only need to re-run when the posts list changes or the highlighted post changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [orderedPosts, highlightedId]);
|
||||
|
||||
// Sync emoji picker padding from SharedValue to React state
|
||||
// This ensures the padding updates when SharedValues change
|
||||
useAnimatedReaction(
|
||||
() => {
|
||||
const shouldAddEmojiPickerPadding = Platform.OS === 'android' && !isKeyboardVisible && isInputAccessoryViewMode.value;
|
||||
const emojiPickerHeight = shouldAddEmojiPickerPadding ? (inputAccessoryViewAnimatedHeight.value || DEFAULT_INPUT_ACCESSORY_HEIGHT) : 0;
|
||||
return emojiPickerHeight;
|
||||
},
|
||||
(emojiPickerHeight) => {
|
||||
runOnJS(setEmojiPickerPadding)(emojiPickerHeight);
|
||||
},
|
||||
[isKeyboardVisible],
|
||||
);
|
||||
|
||||
// Combine contentContainerStyle with padding style
|
||||
// Use regular style with state value synced from SharedValues
|
||||
const contentContainerStyleWithPadding = useMemo(() => {
|
||||
const paddingStyle = {paddingTop: location === Screens.PERMALINK ? 0 : postInputContainerHeight + emojiPickerPadding};
|
||||
return contentContainerStyle ? [contentContainerStyle, paddingStyle] : paddingStyle;
|
||||
}, [location, postInputContainerHeight, emojiPickerPadding, contentContainerStyle]);
|
||||
|
||||
// contentInset only for dynamic keyboard height
|
||||
const animatedProps = useAnimatedProps(
|
||||
() => ({
|
||||
contentInset: {
|
||||
top: contentInset.value, // For inverted FlatList, applies to visual bottom
|
||||
},
|
||||
}),
|
||||
[contentInset],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Animated.FlatList
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
animatedProps={animatedProps}
|
||||
automaticallyAdjustContentInsets={false}
|
||||
contentInsetAdjustmentBehavior='never'
|
||||
contentContainerStyle={contentContainerStyleWithPadding}
|
||||
data={orderedPosts}
|
||||
keyboardDismissMode='interactive'
|
||||
keyboardShouldPersistTaps='handled'
|
||||
|
|
@ -359,12 +456,13 @@ const PostList = ({
|
|||
ListFooterComponent={footer}
|
||||
maintainVisibleContentPosition={SCROLL_POSITION_CONFIG}
|
||||
maxToRenderPerBatch={10}
|
||||
nativeID={nativeID}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.9}
|
||||
onScroll={onScroll}
|
||||
onScroll={onScrollProp}
|
||||
onMomentumScrollEnd={internalOnScroll}
|
||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||
onViewableItemsChanged={onViewableItemsChanged}
|
||||
progressViewOffset={progressViewOffset}
|
||||
ref={listRef}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={renderItem}
|
||||
|
|
@ -374,6 +472,8 @@ const PostList = ({
|
|||
testID={`${testID}.flat_list`}
|
||||
inverted={true}
|
||||
refreshing={refreshing}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
{location !== Screens.PERMALINK &&
|
||||
|
|
|
|||
|
|
@ -3,14 +3,13 @@
|
|||
|
||||
import React, {useRef} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Platform, Pressable, Text, useWindowDimensions, View, type ViewStyle} from 'react-native';
|
||||
import {Pressable, Text, View, type ViewStyle} from 'react-native';
|
||||
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {Screens} from '@constants';
|
||||
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useIsTablet, useKeyboardHeight, useViewPosition} from '@hooks/device';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
|
|
@ -25,7 +24,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
|||
buttonStyle: {
|
||||
position: 'absolute',
|
||||
alignSelf: 'center',
|
||||
bottom: -100,
|
||||
bottom: 0,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
shadow: {
|
||||
|
|
@ -65,6 +64,10 @@ type Props = {
|
|||
testID?: string;
|
||||
};
|
||||
|
||||
const SCROLL_TO_END_BOTTOM_OFFSET = 15;
|
||||
const SCROLL_TO_END_BUTTON_WIDTH = 40;
|
||||
const SCROLL_TO_END_BADGE_MAX_WIDTH = 169;
|
||||
|
||||
const ScrollToEndView = ({
|
||||
onPress,
|
||||
isNewMessage,
|
||||
|
|
@ -74,36 +77,31 @@ const ScrollToEndView = ({
|
|||
}: Props) => {
|
||||
const intl = useIntl();
|
||||
const theme = useTheme();
|
||||
const isTablet = useIsTablet();
|
||||
const styles = getStyleFromTheme(theme);
|
||||
|
||||
const {keyboardTranslateY, postInputContainerHeight, inputAccessoryViewAnimatedHeight} = useKeyboardAnimationContext();
|
||||
|
||||
// On iOS we have to take account of the keyboard.
|
||||
// We cannot use `useKeyboardOverlap` here because of the positioning of the element.
|
||||
const guidingViewRef = useRef<View>(null);
|
||||
const keyboardHeight = useKeyboardHeight();
|
||||
const viewPosition = useViewPosition(guidingViewRef, []);
|
||||
const dimensions = useWindowDimensions();
|
||||
const bottomSpace = (dimensions.height - viewPosition);
|
||||
const keyboardOverlap = Platform.select({ios: Math.max(0, keyboardHeight - bottomSpace), default: 0});
|
||||
|
||||
// Thread view on iPads has to take into account the insets
|
||||
const insets = useSafeAreaInsets();
|
||||
const shouldAdjustBottom = (Platform.OS === 'ios') && isTablet && (location === Screens.THREAD) && !keyboardHeight;
|
||||
const bottomAdjustment = shouldAdjustBottom ? insets.bottom : 0;
|
||||
|
||||
const message = location === Screens.THREAD ? intl.formatMessage({id: 'postList.scrollToBottom.newReplies', defaultMessage: 'New replies'}) : intl.formatMessage({id: 'postList.scrollToBottom.newMessages', defaultMessage: 'New messages'});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(
|
||||
() => ({
|
||||
transform: [
|
||||
{
|
||||
translateY: withTiming(showScrollToEndBtn ? -100 - keyboardOverlap - bottomAdjustment : -15, {duration: 300}),
|
||||
},
|
||||
],
|
||||
maxWidth: withTiming(isNewMessage ? 169 : 40, {duration: 300}),
|
||||
opacity: withTiming(showScrollToEndBtn ? 1 : 0),
|
||||
}),
|
||||
[showScrollToEndBtn, isNewMessage, keyboardOverlap, bottomAdjustment],
|
||||
() => {
|
||||
const activeHeight = Math.max(keyboardTranslateY.value, inputAccessoryViewAnimatedHeight.value);
|
||||
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: showScrollToEndBtn ? -postInputContainerHeight - activeHeight - SCROLL_TO_END_BOTTOM_OFFSET : -SCROLL_TO_END_BOTTOM_OFFSET,
|
||||
},
|
||||
],
|
||||
maxWidth: withTiming(isNewMessage ? SCROLL_TO_END_BADGE_MAX_WIDTH : SCROLL_TO_END_BUTTON_WIDTH, {duration: 300}),
|
||||
opacity: withTiming(showScrollToEndBtn ? 1 : 0),
|
||||
};
|
||||
},
|
||||
[showScrollToEndBtn, isNewMessage, keyboardTranslateY, inputAccessoryViewAnimatedHeight, postInputContainerHeight],
|
||||
);
|
||||
|
||||
const scrollButtonStyles = isNewMessage ? styles.scrollToEndBadge : styles.scrollToEndButton;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ type Props = {
|
|||
category?: string;
|
||||
name: string;
|
||||
onEmojiPress: (emoji: string) => void;
|
||||
preventDoubleTap?: boolean;
|
||||
size?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
|
@ -22,14 +23,16 @@ const CATEGORIES_WITH_SKINS = ['people-body'];
|
|||
|
||||
const hitSlop = {top: 10, bottom: 10, left: 10, right: 10};
|
||||
|
||||
const TouchableEmoji = ({category, name, onEmojiPress, size = 30, style}: Props) => {
|
||||
const onPress = usePreventDoubleTap(useCallback(() => onEmojiPress(name), [name, onEmojiPress]));
|
||||
const TouchableEmoji = ({category, name, onEmojiPress, preventDoubleTap = true, size = 30, style}: Props) => {
|
||||
const handlePress = useCallback(() => onEmojiPress(name), [name, onEmojiPress]);
|
||||
const onPress = preventDoubleTap ? usePreventDoubleTap(handlePress) : handlePress;
|
||||
|
||||
if (category && CATEGORIES_WITH_SKINS.includes(category)) {
|
||||
return (
|
||||
<SkinnedEmoji
|
||||
name={name}
|
||||
onEmojiPress={onEmojiPress}
|
||||
preventDoubleTap={preventDoubleTap}
|
||||
size={size}
|
||||
style={style}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -14,13 +14,14 @@ import {isValidNamedEmoji} from '@utils/emoji/helpers';
|
|||
type Props = {
|
||||
name: string;
|
||||
onEmojiPress: (emoji: string) => void;
|
||||
preventDoubleTap?: boolean;
|
||||
size?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const hitSlop = {top: 10, bottom: 10, left: 10, right: 10};
|
||||
|
||||
const SkinnedEmoji = ({name, onEmojiPress, size = 30, style}: Props) => {
|
||||
const SkinnedEmoji = ({name, onEmojiPress, preventDoubleTap = true, size = 30, style}: Props) => {
|
||||
const skinTone = useEmojiSkinTone();
|
||||
const emojiName = useMemo(() => {
|
||||
const skinnedEmoji = `${name}_${skinCodes[skinTone]}`;
|
||||
|
|
@ -30,9 +31,10 @@ const SkinnedEmoji = ({name, onEmojiPress, size = 30, style}: Props) => {
|
|||
return skinnedEmoji;
|
||||
}, [name, skinTone]);
|
||||
|
||||
const onPress = usePreventDoubleTap(useCallback(() => {
|
||||
const handlePress = useCallback(() => {
|
||||
onEmojiPress(emojiName);
|
||||
}, [emojiName, onEmojiPress]));
|
||||
}, [emojiName, onEmojiPress]);
|
||||
const onPress = preventDoubleTap ? usePreventDoubleTap(handlePress) : handlePress;
|
||||
|
||||
return (
|
||||
<View
|
||||
|
|
|
|||
|
|
@ -809,6 +809,7 @@ exports[`components/channel_list_row should show results and tutorial 1`] = `
|
|||
{
|
||||
"cacheKey": "user-1-123456",
|
||||
"cachePath": "",
|
||||
"headers": undefined,
|
||||
"uri": "https://community.mattermost.com/api/v4/users/1/image?_=123456",
|
||||
},
|
||||
]
|
||||
|
|
@ -1152,6 +1153,7 @@ exports[`components/channel_list_row should show results no tutorial 1`] = `
|
|||
{
|
||||
"cacheKey": "user-1-123456",
|
||||
"cachePath": "",
|
||||
"headers": undefined,
|
||||
"uri": "https://community.mattermost.com/api/v4/users/1/image?_=123456",
|
||||
},
|
||||
]
|
||||
|
|
@ -1533,6 +1535,7 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`]
|
|||
{
|
||||
"cacheKey": "user-1-123456",
|
||||
"cachePath": "",
|
||||
"headers": undefined,
|
||||
"uri": "https://community.mattermost.com/api/v4/users/1/image?_=123456",
|
||||
},
|
||||
]
|
||||
|
|
@ -1775,6 +1778,7 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`]
|
|||
{
|
||||
"cacheKey": "user-2-123456",
|
||||
"cachePath": "",
|
||||
"headers": undefined,
|
||||
"uri": "https://community.mattermost.com/api/v4/users/2/image?_=123456",
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -37,4 +37,7 @@ export default keyMirror({
|
|||
ACTIVE_SCREEN: null,
|
||||
ACTIVE_SERVER_CHANGED: null,
|
||||
FILE_ADD_REMOVED: null,
|
||||
KEYBOARD_STATE_CHANGED: null,
|
||||
CLOSE_INPUT_ACCESSORY_VIEW: null,
|
||||
EMOJI_PICKER_SEARCH_FOCUSED: null,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import PLAYBOOKS_SCREENS from '@playbooks/constants/screens';
|
|||
export const ABOUT = 'About';
|
||||
export const ACCOUNT = 'Account';
|
||||
export const APPS_FORM = 'AppForm';
|
||||
export const ATTACHMENT_OPTIONS = 'AttachmentOptions';
|
||||
export const BOTTOM_SHEET = 'BottomSheet';
|
||||
export const BROWSE_CHANNELS = 'BrowseChannels';
|
||||
export const CALL = 'Call';
|
||||
|
|
@ -94,6 +95,7 @@ export default {
|
|||
ABOUT,
|
||||
ACCOUNT,
|
||||
APPS_FORM,
|
||||
ATTACHMENT_OPTIONS,
|
||||
BOTTOM_SHEET,
|
||||
BROWSE_CHANNELS,
|
||||
CALL,
|
||||
|
|
@ -206,6 +208,7 @@ export const SCREENS_WITH_TRANSPARENT_BACKGROUND = new Set<string>([
|
|||
]);
|
||||
|
||||
export const SCREENS_AS_BOTTOM_SHEET = new Set<string>([
|
||||
ATTACHMENT_OPTIONS,
|
||||
BOTTOM_SHEET,
|
||||
DRAFT_SCHEDULED_POST_OPTIONS,
|
||||
EMOJI_PICKER,
|
||||
|
|
|
|||
171
app/context/keyboard_animation.tsx
Normal file
171
app/context/keyboard_animation.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {createContext, useContext, useMemo, useRef, useCallback, type ReactNode} from 'react';
|
||||
import {useSharedValue, type SharedValue} from 'react-native-reanimated';
|
||||
|
||||
import type {PasteInputRef} from '@mattermost/react-native-paste-input';
|
||||
|
||||
interface KeyboardAnimationContextType {
|
||||
keyboardTranslateY: SharedValue<number>;
|
||||
bottomInset: SharedValue<number>;
|
||||
scrollOffset: SharedValue<number>;
|
||||
keyboardHeight: SharedValue<number>;
|
||||
scrollPosition: SharedValue<number>;
|
||||
onScroll: (event: unknown) => void;
|
||||
postInputContainerHeight: number;
|
||||
inputRef: React.MutableRefObject<PasteInputRef | undefined>;
|
||||
blurInput: () => void;
|
||||
focusInput: () => void;
|
||||
blurAndDismissKeyboard: () => Promise<void>;
|
||||
isKeyboardFullyOpen: SharedValue<boolean>;
|
||||
isKeyboardFullyClosed: SharedValue<boolean>;
|
||||
isKeyboardInTransition: SharedValue<boolean>;
|
||||
showInputAccessoryView: boolean;
|
||||
isInputAccessoryViewMode: SharedValue<boolean>;
|
||||
setShowInputAccessoryView: (show: boolean) => void;
|
||||
lastKeyboardHeight: number;
|
||||
inputAccessoryViewAnimatedHeight: SharedValue<number>;
|
||||
isTransitioningFromCustomView: SharedValue<boolean>;
|
||||
closeInputAccessoryView: () => void;
|
||||
scrollToEnd: () => void;
|
||||
isEmojiSearchFocused: boolean;
|
||||
setIsEmojiSearchFocused: (focused: boolean) => void;
|
||||
cursorPositionRef: React.MutableRefObject<number>;
|
||||
registerCursorPosition: (cursorPosition: number) => void;
|
||||
updateValue: React.Dispatch<React.SetStateAction<string>> | null;
|
||||
updateCursorPosition: React.Dispatch<React.SetStateAction<number>> | null;
|
||||
registerPostInputCallbacks: (
|
||||
updateValueFn: React.Dispatch<React.SetStateAction<string>>,
|
||||
updateCursorPositionFn: React.Dispatch<React.SetStateAction<number>>
|
||||
) => void;
|
||||
}
|
||||
|
||||
const KeyboardAnimationContext = createContext<KeyboardAnimationContextType | null>(null);
|
||||
|
||||
export const KeyboardAnimationProvider = ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
value: KeyboardAnimationContextType;
|
||||
}) => {
|
||||
return (
|
||||
<KeyboardAnimationContext.Provider value={value}>
|
||||
{children}
|
||||
</KeyboardAnimationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const DEFAULT_POST_INPUT_HEIGHT = 91;
|
||||
|
||||
export const useKeyboardAnimationContext = () => {
|
||||
const context = useContext(KeyboardAnimationContext);
|
||||
|
||||
// Always create default values (hooks must be called unconditionally)
|
||||
const defaultKeyboardTranslateY = useSharedValue(0);
|
||||
const defaultBottomInset = useSharedValue(0);
|
||||
const defaultScrollOffset = useSharedValue(0);
|
||||
const defaultKeyboardHeight = useSharedValue(0);
|
||||
const defaultScrollPosition = useSharedValue(0);
|
||||
const defaultIsKeyboardFullyOpen = useSharedValue(false);
|
||||
const defaultIsKeyboardFullyClosed = useSharedValue(true);
|
||||
const defaultIsKeyboardInTransition = useSharedValue(false);
|
||||
const defaultInputRef = useRef<PasteInputRef | undefined>(undefined);
|
||||
const defaultIsInputAccessoryViewMode = useSharedValue(false);
|
||||
const defaultIsTransitioningFromCustomView = useSharedValue(false);
|
||||
const defaultInputAccessoryViewAnimatedHeight = useSharedValue(0);
|
||||
const defaultOnScroll = useCallback(() => {
|
||||
// No-op fallback
|
||||
}, []);
|
||||
const defaultBlurInput = useCallback(() => {
|
||||
// No-op fallback
|
||||
}, []);
|
||||
const defaultFocusInput = useCallback(() => {
|
||||
// No-op fallback
|
||||
}, []);
|
||||
const defaultBlurAndDismissKeyboard = useCallback(async () => {
|
||||
// No-op fallback
|
||||
}, []);
|
||||
|
||||
const defaultSetShowInputAccessoryView = useCallback(() => {
|
||||
// No-op fallback
|
||||
}, []);
|
||||
|
||||
const defaultCloseInputAccessoryView = useCallback(() => {
|
||||
// No-op fallback
|
||||
}, []);
|
||||
|
||||
const defaultScrollToEnd = useCallback(() => {
|
||||
// No-op fallback
|
||||
}, []);
|
||||
|
||||
const defaultSetIsEmojiSearchFocused = useCallback(() => {
|
||||
// No-op fallback
|
||||
}, []);
|
||||
|
||||
const defaultCursorPositionRef = useRef<number>(0);
|
||||
const defaultRegisterCursorPosition = useCallback(() => {
|
||||
// No-op fallback
|
||||
}, []);
|
||||
|
||||
const defaultUpdateValue = useRef<React.Dispatch<React.SetStateAction<string>> | null>(null);
|
||||
const defaultUpdateCursorPosition = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null);
|
||||
const defaultRegisterPostInputCallbacks = useCallback(() => {
|
||||
// No-op fallback
|
||||
}, []);
|
||||
|
||||
const fallbackValue = useMemo(() => ({
|
||||
keyboardTranslateY: defaultKeyboardTranslateY,
|
||||
bottomInset: defaultBottomInset,
|
||||
scrollOffset: defaultScrollOffset,
|
||||
keyboardHeight: defaultKeyboardHeight,
|
||||
scrollPosition: defaultScrollPosition,
|
||||
onScroll: defaultOnScroll,
|
||||
postInputContainerHeight: DEFAULT_POST_INPUT_HEIGHT,
|
||||
inputRef: defaultInputRef,
|
||||
blurInput: defaultBlurInput,
|
||||
focusInput: defaultFocusInput,
|
||||
blurAndDismissKeyboard: defaultBlurAndDismissKeyboard,
|
||||
isKeyboardFullyOpen: defaultIsKeyboardFullyOpen,
|
||||
isKeyboardFullyClosed: defaultIsKeyboardFullyClosed,
|
||||
isKeyboardInTransition: defaultIsKeyboardInTransition,
|
||||
setShowInputAccessoryView: defaultSetShowInputAccessoryView,
|
||||
showInputAccessoryView: false,
|
||||
lastKeyboardHeight: 0,
|
||||
isInputAccessoryViewMode: defaultIsInputAccessoryViewMode,
|
||||
inputAccessoryViewAnimatedHeight: defaultInputAccessoryViewAnimatedHeight,
|
||||
isTransitioningFromCustomView: defaultIsTransitioningFromCustomView,
|
||||
closeInputAccessoryView: defaultCloseInputAccessoryView,
|
||||
scrollToEnd: defaultScrollToEnd,
|
||||
isEmojiSearchFocused: false,
|
||||
setIsEmojiSearchFocused: defaultSetIsEmojiSearchFocused,
|
||||
cursorPositionRef: defaultCursorPositionRef,
|
||||
registerCursorPosition: defaultRegisterCursorPosition,
|
||||
updateValue: defaultUpdateValue.current,
|
||||
updateCursorPosition: defaultUpdateCursorPosition.current,
|
||||
registerPostInputCallbacks: defaultRegisterPostInputCallbacks,
|
||||
|
||||
// Shared values don't need to be in dependencies - they're stable references
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}), [
|
||||
defaultOnScroll,
|
||||
defaultInputRef,
|
||||
defaultBlurInput,
|
||||
defaultFocusInput,
|
||||
defaultBlurAndDismissKeyboard,
|
||||
defaultSetShowInputAccessoryView,
|
||||
defaultCloseInputAccessoryView,
|
||||
defaultScrollToEnd,
|
||||
defaultSetIsEmojiSearchFocused,
|
||||
defaultCursorPositionRef,
|
||||
defaultRegisterCursorPosition,
|
||||
defaultUpdateValue,
|
||||
defaultUpdateCursorPosition,
|
||||
defaultRegisterPostInputCallbacks,
|
||||
]);
|
||||
|
||||
// If context exists, return it; otherwise return fallback
|
||||
return context || fallbackValue;
|
||||
};
|
||||
|
||||
490
app/hooks/keyboardAnimation.test.ts
Normal file
490
app/hooks/keyboardAnimation.test.ts
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {renderHook, act} from '@testing-library/react-hooks';
|
||||
import {useKeyboardHandler} from 'react-native-keyboard-controller';
|
||||
|
||||
import {BOTTOM_TAB_HEIGHT} from '@constants/view';
|
||||
|
||||
import {useKeyboardAnimation} from './keyboardAnimation';
|
||||
|
||||
jest.mock('react-native-keyboard-controller', () => ({
|
||||
useKeyboardHandler: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-native-reanimated', () => {
|
||||
const sharedValues = new Map();
|
||||
|
||||
return {
|
||||
...jest.requireActual('react-native-reanimated/mock'),
|
||||
useSharedValue: jest.fn((initial) => {
|
||||
const key = Math.random();
|
||||
const sv = {
|
||||
value: initial,
|
||||
_key: key,
|
||||
};
|
||||
sharedValues.set(key, sv);
|
||||
return sv;
|
||||
}),
|
||||
useDerivedValue: jest.fn((fn) => ({
|
||||
value: fn(),
|
||||
})),
|
||||
useAnimatedScrollHandler: jest.fn((handlers) => (event: {contentOffset: {y: number}}) => {
|
||||
if (handlers.onScroll) {
|
||||
handlers.onScroll(event);
|
||||
}
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('useKeyboardAnimation', () => {
|
||||
const mockUseKeyboardHandler = useKeyboardHandler as jest.Mock;
|
||||
let keyboardHandlerCallbacks: {
|
||||
onStart?: (e: {height: number; progress: number}) => void;
|
||||
onInteractive?: (e: {height: number; progress: number}) => void;
|
||||
onMove?: (e: {height: number; progress: number}) => void;
|
||||
onEnd?: (e: {height: number; progress: number}) => void;
|
||||
} = {};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
keyboardHandlerCallbacks = {};
|
||||
mockUseKeyboardHandler.mockImplementation((callbacks) => {
|
||||
keyboardHandlerCallbacks = callbacks;
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with default values', () => {
|
||||
const {result} = renderHook(() =>
|
||||
useKeyboardAnimation(100, true, false, 0, false, true),
|
||||
);
|
||||
|
||||
expect(result.current.keyboardTranslateY.value).toBe(0);
|
||||
expect(result.current.bottomInset.value).toBe(0);
|
||||
expect(result.current.scrollOffset.value).toBe(0);
|
||||
expect(result.current.scrollPosition.value).toBe(0);
|
||||
expect(result.current.keyboardHeight.value).toBe(0);
|
||||
expect(result.current.isKeyboardFullyOpen.value).toBe(false);
|
||||
expect(result.current.isKeyboardFullyClosed.value).toBe(true);
|
||||
expect(result.current.isKeyboardInTransition.value).toBe(false);
|
||||
expect(result.current.onScroll).toBeDefined();
|
||||
});
|
||||
|
||||
it('should call useKeyboardHandler with callbacks', () => {
|
||||
renderHook(() => useKeyboardAnimation(100, true, false, 0, false, true));
|
||||
|
||||
expect(mockUseKeyboardHandler).toHaveBeenCalled();
|
||||
expect(keyboardHandlerCallbacks.onStart).toBeDefined();
|
||||
expect(keyboardHandlerCallbacks.onInteractive).toBeDefined();
|
||||
expect(keyboardHandlerCallbacks.onMove).toBeDefined();
|
||||
expect(keyboardHandlerCallbacks.onEnd).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('enabled prop', () => {
|
||||
it('should skip processing when enabled is false', () => {
|
||||
const {result} = renderHook(() =>
|
||||
useKeyboardAnimation(100, true, false, 0, false, false),
|
||||
);
|
||||
|
||||
const initialHeight = result.current.keyboardTranslateY.value;
|
||||
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onStart?.({
|
||||
height: 300,
|
||||
progress: 1,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.keyboardTranslateY.value).toBe(initialHeight);
|
||||
});
|
||||
|
||||
it('should process events when enabled is true', () => {
|
||||
const {result} = renderHook(() =>
|
||||
useKeyboardAnimation(100, true, false, 0, false, true),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onStart?.({
|
||||
height: 300,
|
||||
progress: 1,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.keyboardTranslateY.value).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableAnimation prop', () => {
|
||||
it('should skip processing when enableAnimation is false', () => {
|
||||
const {result} = renderHook(() =>
|
||||
useKeyboardAnimation(100, false, false, 0, false, true),
|
||||
);
|
||||
|
||||
const initialHeight = result.current.keyboardTranslateY.value;
|
||||
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onStart?.({
|
||||
height: 300,
|
||||
progress: 1,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.keyboardTranslateY.value).toBe(initialHeight);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onStart callback', () => {
|
||||
it('should update keyboard height and state on start', () => {
|
||||
const {result} = renderHook(() =>
|
||||
useKeyboardAnimation(100, true, false, 0, false, true),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onStart?.({
|
||||
height: 300,
|
||||
progress: 1,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.keyboardHeight.value).toBe(300);
|
||||
expect(result.current.keyboardTranslateY.value).toBe(300);
|
||||
expect(result.current.bottomInset.value).toBe(300);
|
||||
|
||||
// isKeyboardFullyOpen is set to false in onStart to prevent jerky behavior
|
||||
// It will be set to true in onEnd when animation completes
|
||||
expect(result.current.isKeyboardFullyOpen.value).toBe(false);
|
||||
expect(result.current.isKeyboardFullyClosed.value).toBe(false);
|
||||
expect(result.current.isKeyboardInTransition.value).toBe(false);
|
||||
|
||||
// Call onEnd to finalize the state
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onEnd?.({
|
||||
height: 300,
|
||||
progress: 1,
|
||||
});
|
||||
});
|
||||
|
||||
// After onEnd, keyboard should be marked as fully open
|
||||
expect(result.current.isKeyboardFullyOpen.value).toBe(true);
|
||||
expect(result.current.isKeyboardFullyClosed.value).toBe(false);
|
||||
expect(result.current.isKeyboardInTransition.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle partial progress on start', () => {
|
||||
const {result} = renderHook(() =>
|
||||
useKeyboardAnimation(100, true, false, 0, false, true),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onStart?.({
|
||||
height: 150,
|
||||
progress: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.keyboardTranslateY.value).toBe(150);
|
||||
expect(result.current.isKeyboardFullyOpen.value).toBe(false);
|
||||
expect(result.current.isKeyboardInTransition.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore adjustment events from KeyboardGestureArea', () => {
|
||||
const {result} = renderHook(() =>
|
||||
useKeyboardAnimation(100, true, false, 0, false, true),
|
||||
);
|
||||
|
||||
// Set initial keyboard height
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onStart?.({
|
||||
height: 300,
|
||||
progress: 1,
|
||||
});
|
||||
});
|
||||
|
||||
const initialHeight = result.current.keyboardTranslateY.value;
|
||||
|
||||
// Try to trigger adjustment event (height = keyboardHeight - postInputContainerHeight)
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onStart?.({
|
||||
height: 200, // 300 - 100
|
||||
progress: 1,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.keyboardTranslateY.value).toBe(initialHeight);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onInteractive callback', () => {
|
||||
it('should update values during interactive drag', () => {
|
||||
const {result} = renderHook(() =>
|
||||
useKeyboardAnimation(100, true, false, 0, false, true),
|
||||
);
|
||||
|
||||
// First call onStart to set initial height (required for onInteractive to work)
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onStart?.({
|
||||
height: 300,
|
||||
progress: 1,
|
||||
});
|
||||
});
|
||||
|
||||
// Then call onInteractive to simulate interactive drag
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onInteractive?.({
|
||||
height: 250,
|
||||
progress: 0.8,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.keyboardTranslateY.value).toBe(250);
|
||||
expect(result.current.scrollOffset.value).toBe(250);
|
||||
expect(result.current.bottomInset.value).toBe(250);
|
||||
});
|
||||
|
||||
it('should track keyboard closing state', () => {
|
||||
const {result} = renderHook(() =>
|
||||
useKeyboardAnimation(100, true, false, 0, false, true),
|
||||
);
|
||||
|
||||
// Set initial height
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onStart?.({
|
||||
height: 300,
|
||||
progress: 1,
|
||||
});
|
||||
});
|
||||
|
||||
// Simulate closing (height decreasing)
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onInteractive?.({
|
||||
height: 200,
|
||||
progress: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
// isKeyboardClosing is internal state, not exposed in return value
|
||||
// We verify closing behavior by checking that height decreases
|
||||
expect(result.current.keyboardTranslateY.value).toBeLessThan(300);
|
||||
});
|
||||
|
||||
it('should ignore events matching postInputContainerHeight', () => {
|
||||
const {result} = renderHook(() =>
|
||||
useKeyboardAnimation(100, true, false, 0, false, true),
|
||||
);
|
||||
|
||||
const initialHeight = result.current.keyboardTranslateY.value;
|
||||
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onInteractive?.({
|
||||
height: 100, // matches postInputContainerHeight
|
||||
progress: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.keyboardTranslateY.value).toBe(initialHeight);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMove callback', () => {
|
||||
it('should update values during keyboard movement', () => {
|
||||
const {result} = renderHook(() =>
|
||||
useKeyboardAnimation(100, true, false, 0, false, true),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onStart?.({
|
||||
height: 250,
|
||||
progress: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onMove?.({
|
||||
height: 280,
|
||||
progress: 0.9,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.keyboardTranslateY.value).toBe(280);
|
||||
expect(result.current.scrollOffset.value).toBe(280);
|
||||
expect(result.current.bottomInset.value).toBe(280);
|
||||
});
|
||||
|
||||
it('should ignore onMove events when keyboard is closing', () => {
|
||||
const {result} = renderHook(() =>
|
||||
useKeyboardAnimation(100, true, false, 0, false, true),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onStart?.({
|
||||
height: 300,
|
||||
progress: 1,
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onInteractive?.({
|
||||
height: 200,
|
||||
progress: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onMove?.({
|
||||
height: 150,
|
||||
progress: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.keyboardTranslateY.value).toBe(200);
|
||||
expect(result.current.scrollOffset.value).toBe(200);
|
||||
expect(result.current.bottomInset.value).toBe(200);
|
||||
});
|
||||
|
||||
it('should handle negative heights from programmatic dismiss', () => {
|
||||
const {result} = renderHook(() =>
|
||||
useKeyboardAnimation(100, true, false, 0, false, true),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onMove?.({
|
||||
height: -50, // negative height from KeyboardController.dismiss()
|
||||
progress: 0,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.keyboardTranslateY.value).toBe(50); // Math.abs applied
|
||||
});
|
||||
});
|
||||
|
||||
describe('onEnd callback', () => {
|
||||
it('should finalize state when keyboard fully opens', () => {
|
||||
const {result} = renderHook(() =>
|
||||
useKeyboardAnimation(100, true, false, 0, false, true),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onStart?.({
|
||||
height: 300,
|
||||
progress: 1,
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onEnd?.({
|
||||
height: 300,
|
||||
progress: 1,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.isKeyboardFullyOpen.value).toBe(true);
|
||||
expect(result.current.isKeyboardFullyClosed.value).toBe(false);
|
||||
expect(result.current.isKeyboardInTransition.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should finalize state when keyboard fully closes', () => {
|
||||
const {result} = renderHook(() =>
|
||||
useKeyboardAnimation(100, true, false, 0, false, true),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onStart?.({
|
||||
height: 0,
|
||||
progress: 0,
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onEnd?.({
|
||||
height: 0,
|
||||
progress: 0,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.isKeyboardFullyOpen.value).toBe(false);
|
||||
expect(result.current.isKeyboardFullyClosed.value).toBe(true);
|
||||
expect(result.current.isKeyboardInTransition.value).toBe(false);
|
||||
expect(result.current.keyboardTranslateY.value).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tablet and tab bar adjustment', () => {
|
||||
it('should apply tab bar adjustment for tablets in non-thread view', () => {
|
||||
const safeAreaBottom = 20;
|
||||
const {result} = renderHook(() =>
|
||||
useKeyboardAnimation(100, true, true, safeAreaBottom, false, true),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onStart?.({
|
||||
height: 300,
|
||||
progress: 1,
|
||||
});
|
||||
});
|
||||
|
||||
const expectedAdjustment = BOTTOM_TAB_HEIGHT + safeAreaBottom;
|
||||
expect(result.current.keyboardTranslateY.value).toBe(300 - expectedAdjustment);
|
||||
});
|
||||
|
||||
it('should apply safeAreaBottom adjustment for thread view', () => {
|
||||
const safeAreaBottom = 20;
|
||||
const {result} = renderHook(() =>
|
||||
useKeyboardAnimation(100, true, true, safeAreaBottom, true, true),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onStart?.({
|
||||
height: 300,
|
||||
progress: 1,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.keyboardTranslateY.value).toBe(300);
|
||||
});
|
||||
|
||||
it('should apply safeAreaBottom adjustment for mobile', () => {
|
||||
const safeAreaBottom = 20;
|
||||
const {result} = renderHook(() =>
|
||||
useKeyboardAnimation(100, true, false, safeAreaBottom, false, true),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onStart?.({
|
||||
height: 300,
|
||||
progress: 1,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.keyboardTranslateY.value).toBe(300 - safeAreaBottom);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scroll handler', () => {
|
||||
it('should track scroll position with bottomInset', () => {
|
||||
const {result} = renderHook(() =>
|
||||
useKeyboardAnimation(100, true, false, 0, false, true),
|
||||
);
|
||||
|
||||
// Set bottomInset first
|
||||
act(() => {
|
||||
keyboardHandlerCallbacks.onStart?.({
|
||||
height: 200,
|
||||
progress: 1,
|
||||
});
|
||||
});
|
||||
|
||||
const mockScrollEvent = {
|
||||
contentOffset: {y: 100},
|
||||
} as unknown as Parameters<typeof result.current.onScroll>[0];
|
||||
|
||||
act(() => {
|
||||
result.current.onScroll(mockScrollEvent);
|
||||
});
|
||||
|
||||
expect(result.current.scrollPosition.value).toBe(100 + result.current.bottomInset.value);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
525
app/hooks/keyboardAnimation.ts
Normal file
525
app/hooks/keyboardAnimation.ts
Normal file
|
|
@ -0,0 +1,525 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useKeyboardHandler} from 'react-native-keyboard-controller';
|
||||
import {useAnimatedScrollHandler, useDerivedValue, useSharedValue} from 'react-native-reanimated';
|
||||
|
||||
import {BOTTOM_TAB_HEIGHT} from '@constants/view';
|
||||
|
||||
export const useKeyboardAnimation = (
|
||||
postInputContainerHeight: number,
|
||||
enableAnimation = true,
|
||||
isTablet = false,
|
||||
safeAreaBottom = 0,
|
||||
isThreadView = false,
|
||||
enabled = true,
|
||||
) => {
|
||||
/**
|
||||
* progress: Keyboard animation progress (0 = closed, 1 = fully open)
|
||||
* Used for: Tracking keyboard animation state
|
||||
*/
|
||||
const progress = useSharedValue(0);
|
||||
|
||||
/**
|
||||
* keyboardTranslateY: Keyboard height (adjusted for tab bar) used to animate input container position
|
||||
*
|
||||
* How it works: This value represents the keyboard height and is used with negative translateY
|
||||
* in useKeyboardAwarePostDraft.ts (transform: [{translateY: -keyboardTranslateY.value}]) to move the input
|
||||
* container UP by the keyboard height amount. Higher keyboard height = input moves up more.
|
||||
*
|
||||
* Smoothed during interactive gestures to prevent jerky movements
|
||||
*/
|
||||
const keyboardTranslateY = useSharedValue(0);
|
||||
|
||||
/**
|
||||
* bottomInset: Bottom inset for the scroll view
|
||||
* Adds padding at the bottom of scroll content so it doesn't hide behind keyboard
|
||||
*/
|
||||
const bottomInset = useSharedValue(0);
|
||||
|
||||
/**
|
||||
* scrollOffset: Scroll offset adjustment when keyboard opens
|
||||
* Ensures the scroll view scrolls to the right position when keyboard appears
|
||||
*/
|
||||
const scrollOffset = useSharedValue(0);
|
||||
|
||||
/**
|
||||
* scroll: Tracks the current scroll position of the ScrollView
|
||||
* Used to calculate proper offset when keyboard opens
|
||||
*/
|
||||
const scrollPosition = useSharedValue(0);
|
||||
|
||||
/**
|
||||
* keyboardHeight: The exact height of the keyboard from events
|
||||
* Always reflects the true keyboard height without smoothing
|
||||
*/
|
||||
const keyboardHeight = useSharedValue(0);
|
||||
|
||||
/**
|
||||
* isKeyboardClosing: Tracks if keyboard is currently closing (detected in onInteractive)
|
||||
* Used to prevent height jumps in onMove when user releases finger mid-swipe
|
||||
*/
|
||||
const isKeyboardClosing = useSharedValue(false);
|
||||
|
||||
/**
|
||||
* isInteractiveGesture: Tracks if we're in an interactive gesture (user touching keyboard)
|
||||
* Used to: Distinguish between normal keyboard opening vs stale events after gesture
|
||||
*/
|
||||
const isInteractiveGesture = useSharedValue(false);
|
||||
|
||||
/**
|
||||
* isKeyboardFullyOpen: True when keyboard is fully open (height > 0 and progress === 1)
|
||||
*/
|
||||
const isKeyboardFullyOpen = useSharedValue(false);
|
||||
|
||||
/**
|
||||
* isKeyboardFullyClosed: True when keyboard is fully closed (height === 0)
|
||||
*/
|
||||
const isKeyboardFullyClosed = useSharedValue(true);
|
||||
|
||||
/**
|
||||
* isKeyboardInTransition: True when keyboard is animating between open/closed states
|
||||
*/
|
||||
const isKeyboardInTransition = useSharedValue(false);
|
||||
|
||||
/**
|
||||
* isEnabled: Shared value to track if keyboard handling is enabled for this screen
|
||||
* Used to prevent processing keyboard events when screen is not visible
|
||||
* useDerivedValue automatically tracks the enabled prop and updates reactively
|
||||
*/
|
||||
const isEnabled = useDerivedValue(() => enabled, [enabled]);
|
||||
|
||||
// Calculate tab bar adjustment (only for tablets, not in thread view)
|
||||
// This accounts for the tab bar height + safe area bottom that gets hidden when keyboard opens
|
||||
// Thread views don't have a tab bar, so no adjustment is needed
|
||||
// For tablets in thread view, also don't subtract safeAreaBottom since the input container
|
||||
// should move up by the full keyboard height to clear the keyboard completely
|
||||
let tabBarAdjustment: number;
|
||||
if (isTablet && !isThreadView) {
|
||||
// Channel view on tablet: account for tab bar + safe area
|
||||
tabBarAdjustment = BOTTOM_TAB_HEIGHT + safeAreaBottom;
|
||||
} else if (isTablet && isThreadView) {
|
||||
// Thread view on tablet: no adjustment needed (no tab bar, full keyboard height)
|
||||
tabBarAdjustment = 0;
|
||||
} else {
|
||||
// Mobile devices: account for safe area only
|
||||
tabBarAdjustment = safeAreaBottom;
|
||||
}
|
||||
|
||||
/**
|
||||
* isInputAccessoryViewMode: Whether we're showing input accessory view (emoji picker) instead of keyboard
|
||||
* When true, keyboard handlers are ignored to prevent interference with the emoji picker
|
||||
*/
|
||||
const isInputAccessoryViewMode = useSharedValue(false);
|
||||
|
||||
/**
|
||||
* isTransitioningFromCustomView: Special mode when transitioning from custom view to keyboard
|
||||
* Prevents height updates during the transition to avoid jumps
|
||||
*/
|
||||
const isTransitioningFromCustomView = useSharedValue(false);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// KEYBOARD EVENT HANDLERS
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* useKeyboardHandler: Subscribe to keyboard lifecycle events
|
||||
*
|
||||
* IMPORTANT: All callbacks here are WORKLETS (run on UI thread)
|
||||
* The "worklet" directive tells Reanimated to compile this function for the UI thread
|
||||
* This enables 60fps smooth animations without JavaScript thread delays
|
||||
*
|
||||
* Event lifecycle:
|
||||
* 1. onStart: Keyboard animation begins
|
||||
* 2. onInteractive: User is dragging keyboard interactively
|
||||
* 3. onMove: Keyboard position changes during animation
|
||||
* 4. onEnd: Keyboard animation completes
|
||||
*/
|
||||
useKeyboardHandler({
|
||||
|
||||
/**
|
||||
* onStart: Called when keyboard animation starts
|
||||
* @param e - Event object with keyboard information
|
||||
*/
|
||||
onStart: (e) => {
|
||||
'worklet';
|
||||
|
||||
// Ignore keyboard events when showing custom view (emoji picker)
|
||||
if (isInputAccessoryViewMode.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore keyboard events during transition from custom view to keyboard
|
||||
if (isTransitioningFromCustomView.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip processing if screen is not enabled/visible or if animations are disabled
|
||||
if (!isEnabled.value || !enableAnimation) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore adjustment event from KeyboardGestureArea (fires after keyboard fully opens)
|
||||
// After keyboard reaches full height, KeyboardGestureArea sends offset-adjusted event
|
||||
// We use both offset prop AND manual animation, so this would cause flicker
|
||||
// Example: keyboard opens at 346px → then adjustment event at 229px (346 - 117 offset)
|
||||
if (parseInt(e.height.toString()) === keyboardHeight.value - postInputContainerHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
progress.value = e.progress;
|
||||
|
||||
// Store the exact keyboard height
|
||||
keyboardHeight.value = e.height;
|
||||
|
||||
const adjustedHeight = e.height - (tabBarAdjustment * e.progress);
|
||||
|
||||
// CRITICAL FIX: On real iOS devices, onStart can fire with progress: 1 before animation completes
|
||||
// Even if progress is 1, we should NOT set isKeyboardFullyOpen to true in onStart because:
|
||||
// 1. onMove events may still come with lower progress values
|
||||
// 2. Only onEnd with progress === 1 should mark the keyboard as fully open
|
||||
// This prevents jerky behavior where the input container jumps down and back up
|
||||
const wasAlreadyOpen = keyboardTranslateY.value > 0;
|
||||
|
||||
// Always treat as transitioning if we might receive more events (even with progress: 1)
|
||||
// Only exception: if height is 0 (keyboard closing)
|
||||
const shouldTreatAsTransitioning = e.height > 0 && (e.progress < 1 || wasAlreadyOpen);
|
||||
|
||||
keyboardTranslateY.value = adjustedHeight;
|
||||
|
||||
// Update keyboard state flags
|
||||
// NEVER mark as fully open in onStart - always wait for onEnd to confirm
|
||||
isKeyboardFullyClosed.value = e.height === 0;
|
||||
isKeyboardFullyOpen.value = false; // Always false in onStart - onEnd will set it correctly
|
||||
isKeyboardInTransition.value = e.height > 0 && shouldTreatAsTransitioning;
|
||||
|
||||
// When keyboard closes (height: 0), preserve isKeyboardClosing flag if it was true
|
||||
// This prevents onMove events from processing stale closing animation values
|
||||
// Only reset if keyboard was actually open (not already closing)
|
||||
if (e.height === 0) {
|
||||
// If keyboard was closing, keep the flag true so onMove knows to ignore stale events
|
||||
// If keyboard was opening, reset the flag
|
||||
if (!isKeyboardClosing.value) {
|
||||
isKeyboardClosing.value = false;
|
||||
}
|
||||
|
||||
// Always reset interactive gesture flag when keyboard closes
|
||||
isInteractiveGesture.value = false;
|
||||
}
|
||||
|
||||
// Update scroll view insets and offsets
|
||||
// bottomInset: Adds bottom padding to scroll content
|
||||
bottomInset.value = adjustedHeight;
|
||||
|
||||
// CRITICAL FIX: Don't update scrollOffset in onStart if progress === 1 and keyboard wasn't already open
|
||||
// On real iOS devices, onStart can fire with progress: 1 BEFORE the animation starts
|
||||
// This causes scrollOffset to jump immediately to the final value, triggering a scroll
|
||||
// Then onMove events come with intermediate values, causing jerky "down then up" behavior
|
||||
// Solution: Only update scrollOffset in onStart if progress < 1 OR keyboard was already open
|
||||
// Let onMove handle the smooth animation for opening keyboards
|
||||
// Note: wasAlreadyOpen is checked BEFORE keyboardTranslateY.value is updated, so use that
|
||||
if (e.progress < 1 || wasAlreadyOpen || e.height === 0) {
|
||||
// scrollOffset: Ensures the scroll view scrolls with the keyboard animation
|
||||
scrollOffset.value = adjustedHeight;
|
||||
} else {
|
||||
// Don't update scrollOffset yet - wait for onMove to animate smoothly
|
||||
// This prevents the initial jump when keyboard opens
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* onInteractive: Called continuously while user drags keyboard interactively
|
||||
* This provides smooth real-time updates as the user swipes
|
||||
*
|
||||
* @param e - Event object with keyboard information
|
||||
*/
|
||||
onInteractive: (e) => {
|
||||
'worklet';
|
||||
|
||||
// Ignore keyboard events when showing custom view (emoji picker)
|
||||
if (isInputAccessoryViewMode.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore keyboard events during transition from custom view to keyboard
|
||||
if (isTransitioningFromCustomView.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// On Android, use native keyboard behavior (no custom animations)
|
||||
if (!isEnabled.value || !enableAnimation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parseInt(e.height.toString()) === postInputContainerHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore stale interactive events during screen navigation
|
||||
// When navigating from channel (keyboard open) to thread, the channel's keyboard dismissal
|
||||
// can trigger stale onInteractive events that arrive at the newly mounted thread screen.
|
||||
// If keyboard is fully closed (keyboardTranslateY === 0), any onInteractive event is stale and should be ignored.
|
||||
if (keyboardTranslateY.value === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousKeyboardTranslateY = keyboardTranslateY.value;
|
||||
|
||||
if (keyboardTranslateY.value > 0) {
|
||||
isInteractiveGesture.value = true;
|
||||
}
|
||||
|
||||
// Calculate adjusted height first to compare properly
|
||||
const adjustedHeight = e.height - (tabBarAdjustment * e.progress);
|
||||
|
||||
// Track if keyboard is closing (adjusted height decreasing) or opening (adjusted height increasing)
|
||||
// Compare adjusted heights, not raw event height vs adjusted height
|
||||
// This detects direction changes mid-gesture for smooth animations
|
||||
if (adjustedHeight < previousKeyboardTranslateY) {
|
||||
// Keyboard is closing - height is decreasing
|
||||
isKeyboardClosing.value = true;
|
||||
} else if (adjustedHeight > previousKeyboardTranslateY) {
|
||||
// User changed direction - swiped back up
|
||||
isKeyboardClosing.value = false;
|
||||
}
|
||||
|
||||
// If adjustedHeight === previousKeyboardTranslateY, keep current isKeyboardClosing state
|
||||
|
||||
keyboardTranslateY.value = adjustedHeight;
|
||||
scrollOffset.value = adjustedHeight;
|
||||
bottomInset.value = adjustedHeight;
|
||||
|
||||
// Update keyboard state flags
|
||||
isKeyboardFullyClosed.value = e.height === 0;
|
||||
isKeyboardFullyOpen.value = e.height > 0 && e.progress === 1;
|
||||
isKeyboardInTransition.value = e.height > 0 && e.progress < 1;
|
||||
},
|
||||
|
||||
/**
|
||||
* onMove: Called continuously as keyboard animates (programmatic or gesture)
|
||||
* Similar to onInteractive but for all keyboard movements
|
||||
*
|
||||
* @param e - Event object with keyboard information
|
||||
*/
|
||||
onMove: (e) => {
|
||||
'worklet';
|
||||
|
||||
// Ignore keyboard events when showing custom view (emoji picker)
|
||||
if (isInputAccessoryViewMode.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// During transition from custom view, don't update keyboardHeight from onMove events
|
||||
// These events can be stale/out-of-order. Wait for onEnd to get the final correct height.
|
||||
if (isTransitioningFromCustomView.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// On Android, use native keyboard behavior (no custom animations)
|
||||
if (!isEnabled.value || !enableAnimation) {
|
||||
return;
|
||||
}
|
||||
|
||||
// CRITICAL FIX: Ignore stale onMove events after keyboard closes
|
||||
// When keyboard closes, onStart fires with height: 0, but onMove may still fire with stale values
|
||||
// If keyboardHeight is 0 (closed), ignore any onMove events that try to set positive heights
|
||||
if (keyboardHeight.value === 0 && e.height > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If keyboard is closing (detected in onInteractive), ignore onMove events
|
||||
// This prevents stale closing animation values from causing jumps
|
||||
if (isKeyboardClosing.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore adjustment event from KeyboardGestureArea (fires after keyboard fully opens)
|
||||
// After keyboard reaches full height, KeyboardGestureArea sends offset-adjusted event
|
||||
// We use both offset prop AND manual animation, so this would cause flicker
|
||||
// Example: keyboard opens at 346px → then adjustment event at 229px (346 - 117 offset)
|
||||
if (parseInt(e.height.toString()) === keyboardHeight.value - postInputContainerHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
// CRITICAL FIX: Ignore invalid onMove events with progress > 1
|
||||
// On real iOS devices, sometimes onMove fires with progress > 1 (e.g., 1.266)
|
||||
// These are invalid events that can cause the input container to jump
|
||||
// Progress should always be between 0 and 1 for valid keyboard animation events
|
||||
if (e.progress > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const absHeight = Math.abs(e.height) - (tabBarAdjustment * e.progress); // Use Math.abs because programmatic dismiss (KeyboardController.dismiss()) reports negative heights
|
||||
|
||||
// CRITICAL FIX: On real iOS devices, onStart can fire with progress: 1 before animation completes
|
||||
// Then onMove events come with lower heights/progress, causing jerky behavior
|
||||
// If we've already reached the final keyboard height (isKeyboardFullyOpen or keyboardHeight matches final),
|
||||
// ignore onMove events that would reduce keyboardTranslateY below the final value
|
||||
// BUT: Always allow scrollOffset and bottomInset to update during animation for smooth scrolling
|
||||
const finalHeight = keyboardHeight.value;
|
||||
const finalAdjustedHeight = finalHeight > 0 ? finalHeight - tabBarAdjustment : 0;
|
||||
const hasReachedFinalState = finalHeight > 0 && keyboardTranslateY.value >= finalAdjustedHeight * 0.95; // 95% threshold to account for rounding
|
||||
|
||||
if (hasReachedFinalState && absHeight < keyboardTranslateY.value && e.progress < 1) {
|
||||
// Still update scrollOffset and bottomInset even if we ignore keyboardTranslateY
|
||||
// This ensures the list scrolls smoothly with the keyboard animation
|
||||
scrollOffset.value = absHeight;
|
||||
bottomInset.value = absHeight;
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore stale/incorrect events ONLY during/after interactive gestures
|
||||
// After user releases finger mid-swipe up, onMove sometimes gets wrong height values
|
||||
// Example: keyboard at 346px, user releases, onMove reports 80px (stale from earlier)
|
||||
// This check only applies during interactive gestures, not during normal keyboard opening
|
||||
if (isInteractiveGesture.value && absHeight < keyboardTranslateY.value && e.progress < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
keyboardTranslateY.value = absHeight;
|
||||
scrollOffset.value = absHeight;
|
||||
bottomInset.value = absHeight;
|
||||
|
||||
// Update keyboard state flags
|
||||
isKeyboardFullyClosed.value = absHeight === 0;
|
||||
isKeyboardFullyOpen.value = absHeight > 0 && e.progress === 1;
|
||||
isKeyboardInTransition.value = absHeight > 0 && e.progress < 1;
|
||||
},
|
||||
|
||||
onEnd: (e) => {
|
||||
'worklet';
|
||||
|
||||
// Skip processing if screen is not enabled/visible or if animations are disabled
|
||||
if (!isEnabled.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore keyboard events when showing custom view (emoji picker)
|
||||
if (isInputAccessoryViewMode.value) {
|
||||
isKeyboardClosing.value = false;
|
||||
isTransitioningFromCustomView.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// On Android, use native keyboard behavior (no custom animations)
|
||||
if (!enableAnimation) {
|
||||
isKeyboardClosing.value = false;
|
||||
isTransitioningFromCustomView.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore adjustment event from KeyboardGestureArea (can fire in onEnd too)
|
||||
// After keyboard reaches full height, KeyboardGestureArea sends offset-adjusted event
|
||||
// Example: keyboard opens at 346px → then adjustment event at 255px (346 - 91 offset)
|
||||
if (parseInt(e.height.toString()) === keyboardHeight.value - postInputContainerHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store if we were transitioning from custom view before clearing the flag
|
||||
const wasTransitioningFromCustomView = isTransitioningFromCustomView.value;
|
||||
|
||||
// Reset state flags
|
||||
isKeyboardClosing.value = false;
|
||||
isTransitioningFromCustomView.value = false;
|
||||
isInteractiveGesture.value = false;
|
||||
|
||||
// Use e.progress (from event) not progress.value (shared value might be stale)
|
||||
if (e.progress === 1) {
|
||||
// CRITICAL FIX: Update keyboardHeight FIRST from onEnd event (most reliable source)
|
||||
// During transition, onMove events can be stale/out-of-order, so onEnd's height is authoritative
|
||||
// Store previous value for stale check before updating
|
||||
const previousKeyboardHeight = keyboardHeight.value;
|
||||
keyboardHeight.value = e.height;
|
||||
|
||||
// Use same calculation as onInteractive/onMove for consistency
|
||||
const adjustedHeight = e.height - (tabBarAdjustment * e.progress);
|
||||
|
||||
// Ignore stale/out-of-order events
|
||||
// If keyboard is supposed to be closed (previousKeyboardHeight = 0) but we get an open event,
|
||||
// it's a stale event from before the close - ignore it
|
||||
if (previousKeyboardHeight === 0 && e.height > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If transitioning from custom view, always update keyboardTranslateY to match keyboard
|
||||
// This ensures correct positioning when emoji picker height != keyboard height
|
||||
if (wasTransitioningFromCustomView) {
|
||||
keyboardTranslateY.value = adjustedHeight;
|
||||
} else if (Math.abs(keyboardTranslateY.value - adjustedHeight) > 1) {
|
||||
// For normal keyboard opening, only adjust if significantly different
|
||||
keyboardTranslateY.value = adjustedHeight;
|
||||
}
|
||||
|
||||
// Update bottomInset and scrollOffset to match final keyboard height
|
||||
// This ensures messages don't get hidden behind keyboard
|
||||
bottomInset.value = adjustedHeight;
|
||||
scrollOffset.value = adjustedHeight;
|
||||
|
||||
isKeyboardFullyOpen.value = true;
|
||||
isKeyboardFullyClosed.value = false;
|
||||
isKeyboardInTransition.value = false;
|
||||
}
|
||||
|
||||
// Use e.progress (from event) not progress.value (shared value might be stale)
|
||||
if (e.progress === 0) {
|
||||
// Only set to 0 if not already close to 0
|
||||
if (Math.abs(keyboardTranslateY.value) > 0.5) {
|
||||
keyboardTranslateY.value = 0;
|
||||
}
|
||||
isKeyboardFullyOpen.value = false;
|
||||
isKeyboardFullyClosed.value = true;
|
||||
isKeyboardInTransition.value = false;
|
||||
scrollOffset.value = 0;
|
||||
|
||||
// CRITICAL FIX: Reset scrollPosition when keyboard closes
|
||||
// scrollPosition = contentOffsetY + bottomInset
|
||||
// Before bottomInset becomes 0, calculate actual content offset: scrollPosition - bottomInset
|
||||
// This preserves the user's scroll position even if iOS resets the list to 0
|
||||
const currentBottomInset = bottomInset.value;
|
||||
if (scrollPosition.value > currentBottomInset) {
|
||||
scrollPosition.value = scrollPosition.value - currentBottomInset;
|
||||
}
|
||||
|
||||
bottomInset.value = 0;
|
||||
|
||||
// Reset closing flag when keyboard fully closes
|
||||
isKeyboardClosing.value = false;
|
||||
isInteractiveGesture.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// SCROLL HANDLER
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handle scroll events on the UI thread for smooth performance
|
||||
* Tracks scroll position to calculate proper keyboard offset
|
||||
*/
|
||||
const onScroll = useAnimatedScrollHandler({
|
||||
onScroll: (e) => {
|
||||
const newScrollPosition = e.contentOffset.y + bottomInset.value;
|
||||
|
||||
// Preserve scrollPosition when keyboard is closed and list resets to 0
|
||||
// This prevents iOS's list reset from overwriting the preserved scroll position
|
||||
if (bottomInset.value > 0 || e.contentOffset.y > 0 || scrollPosition.value === 0) {
|
||||
scrollPosition.value = newScrollPosition;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
keyboardTranslateY,
|
||||
onScroll,
|
||||
bottomInset,
|
||||
scrollOffset,
|
||||
keyboardHeight,
|
||||
scrollPosition,
|
||||
isKeyboardFullyOpen,
|
||||
isKeyboardFullyClosed,
|
||||
isKeyboardInTransition,
|
||||
isInputAccessoryViewMode,
|
||||
isTransitioningFromCustomView,
|
||||
};
|
||||
};
|
||||
121
app/hooks/useFocusAfterEmojiDismiss.ts
Normal file
121
app/hooks/useFocusAfterEmojiDismiss.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {Platform} from 'react-native';
|
||||
import {runOnUI} from 'react-native-reanimated';
|
||||
|
||||
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
|
||||
|
||||
import type {PasteInputRef} from '@mattermost/react-native-paste-input';
|
||||
|
||||
/**
|
||||
* Hook to handle focusing input after emoji picker is dismissed on Android.
|
||||
* On Android, when the emoji picker is open and user tries to focus the input,
|
||||
* the emoji picker closes but the keyboard doesn't open. This hook handles
|
||||
* the delayed focus logic to ensure the keyboard opens properly.
|
||||
*
|
||||
*/
|
||||
export const useFocusAfterEmojiDismiss = (
|
||||
inputRef: React.MutableRefObject<PasteInputRef | undefined>,
|
||||
focusInput: () => void,
|
||||
) => {
|
||||
const {
|
||||
showInputAccessoryView,
|
||||
setShowInputAccessoryView,
|
||||
isInputAccessoryViewMode,
|
||||
inputAccessoryViewAnimatedHeight,
|
||||
keyboardTranslateY,
|
||||
isTransitioningFromCustomView,
|
||||
setIsEmojiSearchFocused,
|
||||
} = useKeyboardAnimationContext();
|
||||
|
||||
const [isManuallyFocusingAfterEmojiDismiss, setIsManuallyFocusingAfterEmojiDismiss] = useState(false);
|
||||
const isDismissingEmojiPicker = useRef(false);
|
||||
const focusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Handle focus after emoji picker is dismissed
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'android' && isManuallyFocusingAfterEmojiDismiss && !showInputAccessoryView) {
|
||||
isDismissingEmojiPicker.current = false;
|
||||
|
||||
if (focusTimeoutRef.current) {
|
||||
clearTimeout(focusTimeoutRef.current);
|
||||
}
|
||||
|
||||
inputRef.current?.blur();
|
||||
|
||||
// Use requestAnimationFrame to ensure state updates have been applied
|
||||
requestAnimationFrame(() => {
|
||||
const handleDelayedFocus = () => {
|
||||
focusInput();
|
||||
setIsManuallyFocusingAfterEmojiDismiss(false);
|
||||
focusTimeoutRef.current = null;
|
||||
};
|
||||
|
||||
focusTimeoutRef.current = setTimeout(handleDelayedFocus, 200);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (focusTimeoutRef.current) {
|
||||
clearTimeout(focusTimeoutRef.current);
|
||||
focusTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [isManuallyFocusingAfterEmojiDismiss, showInputAccessoryView, inputRef, focusInput]);
|
||||
|
||||
// Wrapped focus function that handles emoji picker dismissal
|
||||
const focusWithEmojiDismiss = useCallback(() => {
|
||||
if (Platform.OS === 'android' && showInputAccessoryView) {
|
||||
isDismissingEmojiPicker.current = true;
|
||||
setIsManuallyFocusingAfterEmojiDismiss(true);
|
||||
|
||||
runOnUI(() => {
|
||||
'worklet';
|
||||
inputAccessoryViewAnimatedHeight.value = 0;
|
||||
keyboardTranslateY.value = 0;
|
||||
isInputAccessoryViewMode.value = false;
|
||||
isTransitioningFromCustomView.value = true;
|
||||
})();
|
||||
|
||||
setIsEmojiSearchFocused(false);
|
||||
setShowInputAccessoryView(false);
|
||||
|
||||
if (focusTimeoutRef.current) {
|
||||
clearTimeout(focusTimeoutRef.current);
|
||||
}
|
||||
|
||||
inputRef.current?.blur();
|
||||
|
||||
// Schedule focus after emoji picker closes
|
||||
focusTimeoutRef.current = setTimeout(() => {
|
||||
focusInput();
|
||||
setIsManuallyFocusingAfterEmojiDismiss(false);
|
||||
isDismissingEmojiPicker.current = false;
|
||||
focusTimeoutRef.current = null;
|
||||
}, 200);
|
||||
} else {
|
||||
focusInput();
|
||||
}
|
||||
}, [
|
||||
showInputAccessoryView,
|
||||
inputAccessoryViewAnimatedHeight,
|
||||
setShowInputAccessoryView,
|
||||
isInputAccessoryViewMode,
|
||||
keyboardTranslateY,
|
||||
isTransitioningFromCustomView,
|
||||
setIsEmojiSearchFocused,
|
||||
inputRef,
|
||||
focusInput,
|
||||
]);
|
||||
|
||||
return {
|
||||
focus: focusWithEmojiDismiss,
|
||||
isDismissingEmojiPicker,
|
||||
focusTimeoutRef,
|
||||
isManuallyFocusingAfterEmojiDismiss,
|
||||
};
|
||||
};
|
||||
50
app/hooks/useInputAccessoryView.ts
Normal file
50
app/hooks/useInputAccessoryView.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useState} from 'react';
|
||||
import {runOnJS, useAnimatedReaction, useSharedValue, type SharedValue} from 'react-native-reanimated';
|
||||
|
||||
/**
|
||||
* Default height for the input accessory view when keyboard height is unknown
|
||||
*/
|
||||
export const DEFAULT_INPUT_ACCESSORY_HEIGHT = 335;
|
||||
|
||||
type UseInputAccessoryViewParams = {
|
||||
keyboardHeight: SharedValue<number>;
|
||||
isKeyboardFullyOpen: SharedValue<boolean>;
|
||||
};
|
||||
|
||||
export const useInputAccessoryView = ({
|
||||
keyboardHeight,
|
||||
isKeyboardFullyOpen,
|
||||
}: UseInputAccessoryViewParams) => {
|
||||
const [showInputAccessoryView, setShowInputAccessoryView] = useState(false);
|
||||
const [lastKeyboardHeight, setLastKeyboardHeight] = useState(0);
|
||||
const inputAccessoryViewAnimatedHeight = useSharedValue(0);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => ({
|
||||
height: keyboardHeight.value,
|
||||
isFullyOpen: isKeyboardFullyOpen.value,
|
||||
}),
|
||||
(current, previous) => {
|
||||
if (
|
||||
current.isFullyOpen &&
|
||||
current.height > 0 &&
|
||||
(!previous || !previous.isFullyOpen || previous.height !== current.height)
|
||||
) {
|
||||
runOnJS(setLastKeyboardHeight)(current.height);
|
||||
}
|
||||
},
|
||||
[keyboardHeight, isKeyboardFullyOpen],
|
||||
);
|
||||
|
||||
return {
|
||||
showInputAccessoryView,
|
||||
setShowInputAccessoryView,
|
||||
lastKeyboardHeight,
|
||||
setLastKeyboardHeight,
|
||||
inputAccessoryViewAnimatedHeight,
|
||||
};
|
||||
};
|
||||
|
||||
360
app/hooks/useKeyboardAwarePostDraft.test.ts
Normal file
360
app/hooks/useKeyboardAwarePostDraft.test.ts
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {renderHook, act} from '@testing-library/react-hooks';
|
||||
import {KeyboardController} from 'react-native-keyboard-controller';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
|
||||
import {useKeyboardAnimation} from './keyboardAnimation';
|
||||
import {useKeyboardAwarePostDraft} from './useKeyboardAwarePostDraft';
|
||||
import {useKeyboardScrollAdjustment} from './useKeyboardScrollAdjustment';
|
||||
|
||||
jest.mock('react-native/Libraries/Utilities/Platform', () => ({
|
||||
OS: 'ios',
|
||||
select: jest.fn((obj) => obj.ios),
|
||||
}));
|
||||
|
||||
jest.mock('react-native-keyboard-controller', () => ({
|
||||
KeyboardController: {
|
||||
dismiss: jest.fn(() => Promise.resolve()),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('react-native-reanimated', () => {
|
||||
const sharedValues = new Map();
|
||||
|
||||
return {
|
||||
...jest.requireActual('react-native-reanimated/mock'),
|
||||
useSharedValue: jest.fn((initial) => {
|
||||
const key = Math.random();
|
||||
const sv = {
|
||||
value: initial,
|
||||
_key: key,
|
||||
};
|
||||
sharedValues.set(key, sv);
|
||||
return sv;
|
||||
}),
|
||||
useDerivedValue: jest.fn((fn) => ({
|
||||
value: fn(),
|
||||
})),
|
||||
useAnimatedStyle: jest.fn((fn) => fn()),
|
||||
useAnimatedScrollHandler: jest.fn((handlers) => (event: {contentOffset: {y: number}}) => {
|
||||
if (handlers.onScroll) {
|
||||
handlers.onScroll(event);
|
||||
}
|
||||
}),
|
||||
useAnimatedReaction: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@hooks/device', () => ({
|
||||
useIsTablet: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./keyboardAnimation', () => ({
|
||||
useKeyboardAnimation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./useKeyboardScrollAdjustment', () => ({
|
||||
useKeyboardScrollAdjustment: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('useKeyboardAwarePostDraft', () => {
|
||||
const mockUseKeyboardAnimation = useKeyboardAnimation as jest.Mock;
|
||||
const mockUseKeyboardScrollAdjustment = useKeyboardScrollAdjustment as jest.Mock;
|
||||
const mockUseIsTablet = useIsTablet as jest.Mock;
|
||||
const mockUseSafeAreaInsets = useSafeAreaInsets as jest.Mock;
|
||||
const mockKeyboardControllerDismiss = KeyboardController.dismiss as jest.Mock;
|
||||
|
||||
const mockKeyboardAnimationReturn = {
|
||||
keyboardTranslateY: {value: 0},
|
||||
bottomInset: {value: 0},
|
||||
scrollOffset: {value: 0},
|
||||
scrollPosition: {value: 0},
|
||||
onScroll: jest.fn(),
|
||||
isKeyboardFullyOpen: {value: false},
|
||||
isKeyboardFullyClosed: {value: true},
|
||||
isKeyboardInTransition: {value: false},
|
||||
isInputAccessoryViewMode: {value: false},
|
||||
isTransitioningFromCustomView: {value: false},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseIsTablet.mockReturnValue(false);
|
||||
mockUseSafeAreaInsets.mockReturnValue({bottom: 0, top: 0, left: 0, right: 0});
|
||||
mockUseKeyboardAnimation.mockReturnValue(mockKeyboardAnimationReturn);
|
||||
mockUseKeyboardScrollAdjustment.mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with default values', () => {
|
||||
const {result} = renderHook(() => useKeyboardAwarePostDraft());
|
||||
|
||||
expect(result.current.postInputContainerHeight).toBe(91);
|
||||
expect(result.current.listRef.current).toBeNull();
|
||||
expect(result.current.inputRef.current).toBeUndefined();
|
||||
expect(result.current.keyboardTranslateY).toBe(mockKeyboardAnimationReturn.keyboardTranslateY);
|
||||
expect(result.current.contentInset).toBe(mockKeyboardAnimationReturn.bottomInset);
|
||||
expect(result.current.onScroll).toBe(mockKeyboardAnimationReturn.onScroll);
|
||||
expect(result.current.blurInput).toBeDefined();
|
||||
expect(result.current.focusInput).toBeDefined();
|
||||
expect(result.current.blurAndDismissKeyboard).toBeDefined();
|
||||
});
|
||||
|
||||
it('should call useKeyboardAnimation with correct parameters', () => {
|
||||
renderHook(() => useKeyboardAwarePostDraft(false, true));
|
||||
|
||||
expect(mockUseKeyboardAnimation).toHaveBeenCalledWith(
|
||||
91, // DEFAULT_POST_INPUT_HEIGHT
|
||||
true, // isIOS
|
||||
false, // isTablet
|
||||
0, // insets.bottom
|
||||
false, // isThreadView
|
||||
true, // enabled
|
||||
);
|
||||
});
|
||||
|
||||
it('should call useKeyboardScrollAdjustment with correct parameters', () => {
|
||||
const {result} = renderHook(() => useKeyboardAwarePostDraft());
|
||||
|
||||
expect(mockUseKeyboardScrollAdjustment).toHaveBeenCalledWith(
|
||||
result.current.listRef,
|
||||
mockKeyboardAnimationReturn.scrollPosition,
|
||||
mockKeyboardAnimationReturn.scrollOffset,
|
||||
true, // isIOS
|
||||
mockKeyboardAnimationReturn.isInputAccessoryViewMode,
|
||||
mockKeyboardAnimationReturn.isTransitioningFromCustomView,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isThreadView parameter', () => {
|
||||
it('should pass isThreadView=true to useKeyboardAnimation', () => {
|
||||
renderHook(() => useKeyboardAwarePostDraft(true));
|
||||
|
||||
expect(mockUseKeyboardAnimation).toHaveBeenCalledWith(
|
||||
91,
|
||||
true,
|
||||
false,
|
||||
0,
|
||||
true, // isThreadView
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enabled parameter', () => {
|
||||
it('should pass enabled=false to useKeyboardAnimation', () => {
|
||||
renderHook(() => useKeyboardAwarePostDraft(false, false));
|
||||
|
||||
expect(mockUseKeyboardAnimation).toHaveBeenCalledWith(
|
||||
91,
|
||||
true,
|
||||
false,
|
||||
0,
|
||||
false,
|
||||
false, // enabled
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('postInputContainerHeight', () => {
|
||||
it('should update postInputContainerHeight when setPostInputContainerHeight is called', () => {
|
||||
const {result} = renderHook(() => useKeyboardAwarePostDraft());
|
||||
|
||||
act(() => {
|
||||
result.current.setPostInputContainerHeight(150);
|
||||
});
|
||||
|
||||
expect(result.current.postInputContainerHeight).toBe(150);
|
||||
});
|
||||
|
||||
it('should update postInputContainerHeight state', () => {
|
||||
const {result} = renderHook(() => useKeyboardAwarePostDraft());
|
||||
|
||||
act(() => {
|
||||
result.current.setPostInputContainerHeight(150);
|
||||
});
|
||||
|
||||
expect(result.current.postInputContainerHeight).toBe(150);
|
||||
});
|
||||
});
|
||||
|
||||
describe('input ref callbacks', () => {
|
||||
it('should call blur on inputRef when blurInput is called', () => {
|
||||
const {result} = renderHook(() => useKeyboardAwarePostDraft());
|
||||
const mockBlur = jest.fn();
|
||||
|
||||
// @ts-expect-error Test mock - only need blur method
|
||||
result.current.inputRef.current = {blur: mockBlur};
|
||||
|
||||
act(() => {
|
||||
result.current.blurInput();
|
||||
});
|
||||
|
||||
expect(mockBlur).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not throw when blurInput is called without inputRef', () => {
|
||||
const {result} = renderHook(() => useKeyboardAwarePostDraft());
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.blurInput();
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should call focus on inputRef when focusInput is called', () => {
|
||||
const {result} = renderHook(() => useKeyboardAwarePostDraft());
|
||||
const mockFocus = jest.fn();
|
||||
|
||||
// @ts-expect-error Test mock - only need focus method
|
||||
result.current.inputRef.current = {focus: mockFocus};
|
||||
|
||||
act(() => {
|
||||
result.current.focusInput();
|
||||
});
|
||||
|
||||
expect(mockFocus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not throw when focusInput is called without inputRef', () => {
|
||||
const {result} = renderHook(() => useKeyboardAwarePostDraft());
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.focusInput();
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('blurAndDismissKeyboard', () => {
|
||||
it('should reset shared values and dismiss keyboard', async () => {
|
||||
const mockKeyboardTranslateY = {value: 300};
|
||||
const mockBottomInset = {value: 300};
|
||||
const mockScrollOffset = {value: 300};
|
||||
const mockBlur = jest.fn();
|
||||
|
||||
mockUseKeyboardAnimation.mockReturnValue({
|
||||
...mockKeyboardAnimationReturn,
|
||||
keyboardTranslateY: mockKeyboardTranslateY,
|
||||
bottomInset: mockBottomInset,
|
||||
scrollOffset: mockScrollOffset,
|
||||
});
|
||||
|
||||
const {result} = renderHook(() => useKeyboardAwarePostDraft());
|
||||
|
||||
// @ts-expect-error Test mock - only need blur method
|
||||
result.current.inputRef.current = {blur: mockBlur};
|
||||
|
||||
await act(async () => {
|
||||
await result.current.blurAndDismissKeyboard();
|
||||
});
|
||||
|
||||
expect(mockKeyboardTranslateY.value).toBe(0);
|
||||
expect(mockBottomInset.value).toBe(0);
|
||||
expect(mockScrollOffset.value).toBe(0);
|
||||
expect(mockBlur).toHaveBeenCalled();
|
||||
expect(mockKeyboardControllerDismiss).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle missing inputRef gracefully', async () => {
|
||||
const mockKeyboardTranslateY = {value: 300};
|
||||
const mockBottomInset = {value: 300};
|
||||
const mockScrollOffset = {value: 300};
|
||||
|
||||
mockUseKeyboardAnimation.mockReturnValue({
|
||||
...mockKeyboardAnimationReturn,
|
||||
keyboardTranslateY: mockKeyboardTranslateY,
|
||||
bottomInset: mockBottomInset,
|
||||
scrollOffset: mockScrollOffset,
|
||||
});
|
||||
|
||||
const {result} = renderHook(() => useKeyboardAwarePostDraft());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.blurAndDismissKeyboard();
|
||||
});
|
||||
|
||||
expect(mockKeyboardTranslateY.value).toBe(0);
|
||||
expect(mockBottomInset.value).toBe(0);
|
||||
expect(mockScrollOffset.value).toBe(0);
|
||||
expect(mockKeyboardControllerDismiss).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('inputContainerAnimatedStyle', () => {
|
||||
it('should return animated style with translateY on iOS', () => {
|
||||
const mockKeyboardTranslateY = {value: 200};
|
||||
mockUseKeyboardAnimation.mockReturnValue({
|
||||
...mockKeyboardAnimationReturn,
|
||||
keyboardTranslateY: mockKeyboardTranslateY,
|
||||
});
|
||||
|
||||
const {result} = renderHook(() => useKeyboardAwarePostDraft());
|
||||
|
||||
const style = result.current.inputContainerAnimatedStyle;
|
||||
expect(style).toEqual({
|
||||
transform: [{translateY: -200}],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tablet and safe area handling', () => {
|
||||
it('should pass tablet and safe area values to useKeyboardAnimation', () => {
|
||||
mockUseIsTablet.mockReturnValue(true);
|
||||
mockUseSafeAreaInsets.mockReturnValue({bottom: 20, top: 0, left: 0, right: 0});
|
||||
|
||||
renderHook(() => useKeyboardAwarePostDraft());
|
||||
|
||||
expect(mockUseKeyboardAnimation).toHaveBeenCalledWith(
|
||||
91,
|
||||
true,
|
||||
true, // isTablet
|
||||
20, // insets.bottom
|
||||
false,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('return values', () => {
|
||||
it('should return all expected properties', () => {
|
||||
const {result} = renderHook(() => useKeyboardAwarePostDraft());
|
||||
|
||||
expect(result.current).toHaveProperty('keyboardTranslateY');
|
||||
expect(result.current).toHaveProperty('listRef');
|
||||
expect(result.current).toHaveProperty('inputRef');
|
||||
expect(result.current).toHaveProperty('contentInset');
|
||||
expect(result.current).toHaveProperty('onScroll');
|
||||
expect(result.current).toHaveProperty('postInputContainerHeight');
|
||||
expect(result.current).toHaveProperty('setPostInputContainerHeight');
|
||||
expect(result.current).toHaveProperty('inputContainerAnimatedStyle');
|
||||
expect(result.current).toHaveProperty('keyboardHeight');
|
||||
expect(result.current).toHaveProperty('scrollOffset');
|
||||
expect(result.current).toHaveProperty('scrollPosition');
|
||||
expect(result.current).toHaveProperty('blurInput');
|
||||
expect(result.current).toHaveProperty('focusInput');
|
||||
expect(result.current).toHaveProperty('blurAndDismissKeyboard');
|
||||
expect(result.current).toHaveProperty('isKeyboardFullyOpen');
|
||||
expect(result.current).toHaveProperty('isKeyboardFullyClosed');
|
||||
expect(result.current).toHaveProperty('isKeyboardInTransition');
|
||||
});
|
||||
|
||||
it('should return keyboardHeight as alias for keyboardTranslateY', () => {
|
||||
const {result} = renderHook(() => useKeyboardAwarePostDraft());
|
||||
|
||||
expect(result.current.keyboardHeight).toBe(result.current.keyboardTranslateY);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
97
app/hooks/useKeyboardAwarePostDraft.ts
Normal file
97
app/hooks/useKeyboardAwarePostDraft.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useCallback, useRef, useState} from 'react';
|
||||
import {FlatList, Platform} from 'react-native';
|
||||
import {KeyboardController} from 'react-native-keyboard-controller';
|
||||
import {useAnimatedStyle} from 'react-native-reanimated';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
|
||||
import {useKeyboardAnimation} from './keyboardAnimation';
|
||||
import {useKeyboardScrollAdjustment} from './useKeyboardScrollAdjustment';
|
||||
|
||||
import type {PasteInputRef} from '@mattermost/react-native-paste-input';
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
|
||||
/**
|
||||
* Default height for post input container
|
||||
* Empirically measured value for iOS to prevent content shifting on initial render
|
||||
*/
|
||||
const DEFAULT_POST_INPUT_HEIGHT = 91;
|
||||
|
||||
const isIOS = Platform.OS === 'ios';
|
||||
|
||||
export const useKeyboardAwarePostDraft = (isThreadView = false, enabled = true) => {
|
||||
const [postInputContainerHeight, setPostInputContainerHeight] = useState(DEFAULT_POST_INPUT_HEIGHT);
|
||||
const listRef = useRef<FlatList<string | PostModel>>(null);
|
||||
const inputRef = useRef<PasteInputRef>();
|
||||
const isTablet = useIsTablet();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const {
|
||||
keyboardTranslateY,
|
||||
bottomInset,
|
||||
scrollOffset,
|
||||
scrollPosition,
|
||||
onScroll,
|
||||
isKeyboardFullyOpen,
|
||||
isKeyboardFullyClosed,
|
||||
isKeyboardInTransition,
|
||||
isInputAccessoryViewMode,
|
||||
isTransitioningFromCustomView,
|
||||
} = useKeyboardAnimation(postInputContainerHeight, isIOS, isTablet, insets.bottom, isThreadView, enabled);
|
||||
|
||||
// Only apply scroll adjustment on iOS, Android uses native keyboard handling
|
||||
// Also pass isInputAccessoryViewMode and isTransitioningFromCustomView to control scroll behavior
|
||||
useKeyboardScrollAdjustment(listRef, scrollPosition, scrollOffset, isIOS, isInputAccessoryViewMode, isTransitioningFromCustomView);
|
||||
|
||||
const inputContainerAnimatedStyle = useAnimatedStyle(
|
||||
() => {
|
||||
return {
|
||||
transform: [{translateY: isIOS ? -keyboardTranslateY.value : 0}],
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const blurInput = useCallback(() => {
|
||||
inputRef.current?.blur();
|
||||
}, []);
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const blurAndDismissKeyboard = useCallback(async () => {
|
||||
bottomInset.value = 0;
|
||||
scrollOffset.value = 0;
|
||||
keyboardTranslateY.value = 0;
|
||||
inputRef.current?.blur();
|
||||
await KeyboardController.dismiss();
|
||||
}, [keyboardTranslateY, bottomInset, scrollOffset]);
|
||||
|
||||
return {
|
||||
keyboardTranslateY,
|
||||
listRef,
|
||||
inputRef,
|
||||
contentInset: bottomInset,
|
||||
onScroll,
|
||||
postInputContainerHeight,
|
||||
setPostInputContainerHeight,
|
||||
inputContainerAnimatedStyle,
|
||||
keyboardHeight: keyboardTranslateY,
|
||||
scrollOffset,
|
||||
scrollPosition,
|
||||
blurInput,
|
||||
focusInput,
|
||||
blurAndDismissKeyboard,
|
||||
isKeyboardFullyOpen,
|
||||
isKeyboardFullyClosed,
|
||||
isKeyboardInTransition,
|
||||
isInputAccessoryViewMode,
|
||||
isTransitioningFromCustomView,
|
||||
};
|
||||
};
|
||||
|
||||
64
app/hooks/useKeyboardScrollAdjustment.ts
Normal file
64
app/hooks/useKeyboardScrollAdjustment.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useCallback} from 'react';
|
||||
import {runOnJS, useAnimatedReaction, type SharedValue} from 'react-native-reanimated';
|
||||
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
import type {FlatList} from 'react-native';
|
||||
|
||||
/**
|
||||
* Custom hook to handle automatic scrolling when keyboard opens
|
||||
* Keeps messages in the same relative position to the input container
|
||||
* Only enabled on iOS - Android uses native keyboard handling
|
||||
*/
|
||||
export const useKeyboardScrollAdjustment = (
|
||||
scrollViewRef: React.RefObject<FlatList<string | PostModel>>,
|
||||
scrollPosition: SharedValue<number>,
|
||||
scrollOffset: SharedValue<number>,
|
||||
enabled = true,
|
||||
isInputAccessoryViewMode?: SharedValue<boolean>,
|
||||
isTransitioningFromCustomView?: SharedValue<boolean>,
|
||||
) => {
|
||||
// Callback to scroll the view (needs to be stable reference for runOnJS)
|
||||
const scrollToOffset = useCallback(
|
||||
(offsetValue: number, scrollValue: number) => {
|
||||
scrollViewRef.current?.scrollToOffset({
|
||||
offset: -offsetValue + scrollValue,
|
||||
animated: false,
|
||||
});
|
||||
},
|
||||
[scrollViewRef],
|
||||
);
|
||||
|
||||
// Watch for scrollOffset changes and adjust scroll position accordingly
|
||||
useAnimatedReaction(
|
||||
() => ({
|
||||
scrollOffset: scrollOffset.value,
|
||||
scrollPosition: scrollPosition.value,
|
||||
isInputAccessoryViewMode: isInputAccessoryViewMode?.value || false,
|
||||
isTransitioning: isTransitioningFromCustomView?.value || false,
|
||||
}),
|
||||
(current, previous) => {
|
||||
'worklet';
|
||||
|
||||
// Skip scroll adjustment when:
|
||||
// - Input Accessory view (emoji picker) is active
|
||||
// - Transitioning from custom view to keyboard (heights are same, no scroll needed)
|
||||
// - scrollOffset hasn't actually changed (prevents re-scrolling after transition ends)
|
||||
const scrollOffsetChanged = previous === null || Math.abs(current.scrollOffset - (previous?.scrollOffset || 0)) > 0.5;
|
||||
|
||||
// Don't scroll when keyboard closes - let the list naturally return to position
|
||||
if (current.scrollOffset === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform scroll adjustment when keyboard is opening or height changes
|
||||
// scrollPosition is preserved (not reset to 0) by keyboardAnimation.ts
|
||||
if (enabled && !current.isInputAccessoryViewMode && !current.isTransitioning && scrollOffsetChanged) {
|
||||
runOnJS(scrollToOffset)(current.scrollOffset, current.scrollPosition);
|
||||
}
|
||||
},
|
||||
[scrollPosition, scrollOffset, enabled, isInputAccessoryViewMode, isTransitioningFromCustomView],
|
||||
);
|
||||
};
|
||||
248
app/hooks/use_screen_visibility.test.ts
Normal file
248
app/hooks/use_screen_visibility.test.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {renderHook, act} from '@testing-library/react-hooks';
|
||||
import {BehaviorSubject} from 'rxjs';
|
||||
|
||||
import {Screens} from '@constants';
|
||||
import NavigationStore from '@store/navigation_store';
|
||||
|
||||
import {useIsScreenVisible} from './use_screen_visibility';
|
||||
|
||||
const mockSubject = new BehaviorSubject<string | undefined>(undefined);
|
||||
|
||||
jest.mock('@store/navigation_store', () => ({
|
||||
getVisibleScreen: jest.fn(() => mockSubject.value),
|
||||
getSubject: jest.fn(() => mockSubject),
|
||||
}));
|
||||
|
||||
describe('useIsScreenVisible', () => {
|
||||
const mockNavigationStore = NavigationStore as jest.Mocked<typeof NavigationStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSubject.next(undefined);
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should return false when componentId is undefined', () => {
|
||||
const {result} = renderHook(() => useIsScreenVisible(undefined));
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when componentId matches visible screen on mount', () => {
|
||||
mockSubject.next(Screens.CHANNEL);
|
||||
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
|
||||
|
||||
const {result} = renderHook(() => useIsScreenVisible(Screens.CHANNEL));
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when componentId does not match visible screen on mount', () => {
|
||||
mockSubject.next(Screens.CHANNEL);
|
||||
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
|
||||
|
||||
const {result} = renderHook(() => useIsScreenVisible(Screens.THREAD));
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscription updates', () => {
|
||||
it('should update when visible screen changes to match componentId', () => {
|
||||
mockSubject.next(Screens.CHANNEL);
|
||||
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
|
||||
|
||||
const {result} = renderHook(() => useIsScreenVisible(Screens.THREAD));
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
mockSubject.next(Screens.THREAD);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should update when visible screen changes away from componentId', () => {
|
||||
mockSubject.next(Screens.THREAD);
|
||||
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.THREAD);
|
||||
|
||||
const {result} = renderHook(() => useIsScreenVisible(Screens.THREAD));
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
mockSubject.next(Screens.CHANNEL);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle multiple screen changes', () => {
|
||||
mockSubject.next(Screens.CHANNEL);
|
||||
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
|
||||
|
||||
const {result} = renderHook(() => useIsScreenVisible(Screens.THREAD));
|
||||
|
||||
act(() => {
|
||||
mockSubject.next(Screens.THREAD);
|
||||
});
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
mockSubject.next(Screens.CHANNEL);
|
||||
});
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
mockSubject.next(Screens.THREAD);
|
||||
});
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle undefined visible screen', () => {
|
||||
mockSubject.next(Screens.THREAD);
|
||||
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.THREAD);
|
||||
|
||||
const {result} = renderHook(() => useIsScreenVisible(Screens.THREAD));
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
mockSubject.next(undefined);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('componentId changes', () => {
|
||||
it('should update when componentId changes', () => {
|
||||
mockSubject.next(Screens.CHANNEL);
|
||||
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
|
||||
|
||||
const {result, rerender} = renderHook(
|
||||
({componentId}: {componentId?: typeof Screens.CHANNEL | typeof Screens.THREAD}) => useIsScreenVisible(componentId),
|
||||
{initialProps: {componentId: Screens.CHANNEL}},
|
||||
);
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
rerender({componentId: Screens.THREAD});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should set to false when componentId changes to undefined', () => {
|
||||
mockSubject.next(Screens.CHANNEL);
|
||||
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
|
||||
|
||||
const {result, rerender} = renderHook(
|
||||
({componentId}: {componentId?: typeof Screens.CHANNEL}) => useIsScreenVisible(componentId),
|
||||
{initialProps: {componentId: Screens.CHANNEL}},
|
||||
);
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
rerender({componentId: undefined});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should subscribe to new componentId when it changes', () => {
|
||||
mockSubject.next(Screens.CHANNEL);
|
||||
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
|
||||
|
||||
const {result, rerender} = renderHook(
|
||||
({componentId}: {componentId?: typeof Screens.CHANNEL | typeof Screens.THREAD}) => useIsScreenVisible(componentId),
|
||||
{initialProps: {componentId: Screens.CHANNEL}},
|
||||
);
|
||||
|
||||
rerender({componentId: Screens.THREAD});
|
||||
|
||||
act(() => {
|
||||
mockSubject.next(Screens.THREAD);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should unsubscribe when component unmounts', () => {
|
||||
const unsubscribe = jest.fn();
|
||||
const subscribeSpy = jest.spyOn(mockSubject, 'subscribe').mockReturnValue({
|
||||
unsubscribe,
|
||||
} as unknown as ReturnType<typeof mockSubject.subscribe>);
|
||||
|
||||
const {unmount} = renderHook(() => useIsScreenVisible(Screens.CHANNEL));
|
||||
|
||||
unmount();
|
||||
|
||||
expect(unsubscribe).toHaveBeenCalled();
|
||||
|
||||
subscribeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should unsubscribe when componentId changes', () => {
|
||||
const unsubscribe1 = jest.fn();
|
||||
const unsubscribe2 = jest.fn();
|
||||
let callCount = 0;
|
||||
const subscribeSpy = jest.spyOn(mockSubject, 'subscribe').mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return {unsubscribe: unsubscribe1} as unknown as ReturnType<typeof mockSubject.subscribe>;
|
||||
}
|
||||
return {unsubscribe: unsubscribe2} as unknown as ReturnType<typeof mockSubject.subscribe>;
|
||||
});
|
||||
|
||||
const {rerender} = renderHook(
|
||||
({componentId}: {componentId?: typeof Screens.CHANNEL | typeof Screens.THREAD}) => useIsScreenVisible(componentId),
|
||||
{initialProps: {componentId: Screens.CHANNEL}},
|
||||
);
|
||||
|
||||
rerender({componentId: Screens.THREAD as typeof Screens.CHANNEL | typeof Screens.THREAD});
|
||||
|
||||
expect(unsubscribe1).toHaveBeenCalled();
|
||||
|
||||
subscribeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle rapid screen changes', () => {
|
||||
mockSubject.next(Screens.CHANNEL);
|
||||
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
|
||||
|
||||
const {result} = renderHook(() => useIsScreenVisible(Screens.THREAD));
|
||||
|
||||
act(() => {
|
||||
mockSubject.next(Screens.THREAD);
|
||||
mockSubject.next(Screens.CHANNEL);
|
||||
mockSubject.next(Screens.THREAD);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle same screen being set multiple times', () => {
|
||||
mockSubject.next(Screens.CHANNEL);
|
||||
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
|
||||
|
||||
const {result} = renderHook(() => useIsScreenVisible(Screens.CHANNEL));
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
mockSubject.next(Screens.CHANNEL);
|
||||
mockSubject.next(Screens.CHANNEL);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
36
app/hooks/use_screen_visibility.ts
Normal file
36
app/hooks/use_screen_visibility.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
import NavigationStore from '@store/navigation_store';
|
||||
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
export const useIsScreenVisible = (componentId?: AvailableScreens): boolean => {
|
||||
const [isVisible, setIsVisible] = useState(() => {
|
||||
if (!componentId) {
|
||||
return false;
|
||||
}
|
||||
return NavigationStore.getVisibleScreen() === componentId;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!componentId) {
|
||||
setIsVisible(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setIsVisible(NavigationStore.getVisibleScreen() === componentId);
|
||||
const subscription = NavigationStore.getSubject().subscribe((visibleScreen) => {
|
||||
setIsVisible(visibleScreen === componentId);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [componentId]);
|
||||
|
||||
return isVisible;
|
||||
};
|
||||
|
||||
430
app/screens/attachment_options/attachment_options.test.tsx
Normal file
430
app/screens/attachment_options/attachment_options.test.tsx
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {fireEvent, waitFor} from '@testing-library/react-native';
|
||||
import {Alert} from 'react-native';
|
||||
|
||||
import {Screens} from '@constants';
|
||||
import {dismissBottomSheet} from '@screens/navigation';
|
||||
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
|
||||
import PickerUtil from '@utils/file/file_picker';
|
||||
|
||||
import AttachmentOptions from './attachment_options';
|
||||
|
||||
jest.mock('@screens/navigation', () => ({
|
||||
dismissBottomSheet: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock('@utils/file/file_picker');
|
||||
|
||||
jest.mock('@utils/file', () => ({
|
||||
fileMaxWarning: jest.fn((...args) => `Maximum ${args[1]} files allowed`),
|
||||
uploadDisabledWarning: jest.fn(() => 'File uploads are disabled'),
|
||||
}));
|
||||
|
||||
jest.mock('@hooks/device', () => ({
|
||||
useIsTablet: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
describe('AttachmentOptions', () => {
|
||||
const mockDismissBottomSheet = dismissBottomSheet as jest.Mock;
|
||||
const mockAttachFileFromPhotoGallery = jest.fn();
|
||||
const mockAttachFileFromCamera = jest.fn();
|
||||
const mockAttachFileFromFiles = jest.fn();
|
||||
|
||||
const baseProps = {
|
||||
componentId: 'Channel' as const,
|
||||
onUploadFiles: jest.fn(),
|
||||
maxFileCount: 10,
|
||||
fileCount: 0,
|
||||
maxFilesReached: false,
|
||||
canUploadFiles: true,
|
||||
testID: 'test-attachment-options',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(Alert, 'alert');
|
||||
|
||||
(PickerUtil as jest.Mock).mockImplementation(() => ({
|
||||
attachFileFromPhotoGallery: mockAttachFileFromPhotoGallery,
|
||||
attachFileFromCamera: mockAttachFileFromCamera,
|
||||
attachFileFromFiles: mockAttachFileFromFiles,
|
||||
}));
|
||||
});
|
||||
|
||||
describe('action handlers', () => {
|
||||
it('should call picker.attachFileFromPhotoGallery when photo library is selected', async () => {
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentOptions {...baseProps}/>,
|
||||
);
|
||||
|
||||
const photoLibraryItem = getByTestId('file_attachment.photo_library');
|
||||
fireEvent.press(photoLibraryItem);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
|
||||
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalledWith(10);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call picker.attachFileFromCamera when take photo is selected', async () => {
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentOptions {...baseProps}/>,
|
||||
);
|
||||
|
||||
const takePhotoItem = getByTestId('file_attachment.take_photo');
|
||||
fireEvent.press(takePhotoItem);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
|
||||
expect(mockAttachFileFromCamera).toHaveBeenCalledWith({
|
||||
quality: 0.8,
|
||||
mediaType: 'photo',
|
||||
saveToPhotos: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should call picker.attachFileFromCamera when take video is selected', async () => {
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentOptions {...baseProps}/>,
|
||||
);
|
||||
|
||||
const takeVideoItem = getByTestId('file_attachment.take_video');
|
||||
fireEvent.press(takeVideoItem);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
|
||||
expect(mockAttachFileFromCamera).toHaveBeenCalledWith({
|
||||
quality: 0.8,
|
||||
videoQuality: 'high',
|
||||
mediaType: 'video',
|
||||
saveToPhotos: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should call picker.attachFileFromFiles when attach file is selected', async () => {
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentOptions {...baseProps}/>,
|
||||
);
|
||||
|
||||
const attachFileItem = getByTestId('file_attachment.attach_file');
|
||||
fireEvent.press(attachFileItem);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
|
||||
expect(mockAttachFileFromFiles).toHaveBeenCalledWith(undefined, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation - canUploadFiles', () => {
|
||||
it('should show alert and not call picker when canUploadFiles is false for photo library', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
canUploadFiles: false,
|
||||
};
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentOptions {...props}/>,
|
||||
);
|
||||
|
||||
const photoLibraryItem = getByTestId('file_attachment.photo_library');
|
||||
fireEvent.press(photoLibraryItem);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'Error',
|
||||
'File uploads are disabled',
|
||||
);
|
||||
expect(mockAttachFileFromPhotoGallery).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show alert and not call picker when canUploadFiles is false for camera', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
canUploadFiles: false,
|
||||
};
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentOptions {...props}/>,
|
||||
);
|
||||
|
||||
const takePhotoItem = getByTestId('file_attachment.take_photo');
|
||||
fireEvent.press(takePhotoItem);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'Error',
|
||||
'File uploads are disabled',
|
||||
);
|
||||
expect(mockAttachFileFromCamera).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show alert and not call picker when canUploadFiles is false for attach file', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
canUploadFiles: false,
|
||||
};
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentOptions {...props}/>,
|
||||
);
|
||||
|
||||
const attachFileItem = getByTestId('file_attachment.attach_file');
|
||||
fireEvent.press(attachFileItem);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'Error',
|
||||
'File uploads are disabled',
|
||||
);
|
||||
expect(mockAttachFileFromFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation - maxFilesReached', () => {
|
||||
it('should show alert and not call picker when maxFilesReached is true for photo library', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
maxFilesReached: true,
|
||||
maxFileCount: 10,
|
||||
};
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentOptions {...props}/>,
|
||||
);
|
||||
|
||||
const photoLibraryItem = getByTestId('file_attachment.photo_library');
|
||||
fireEvent.press(photoLibraryItem);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'Error',
|
||||
'Maximum 10 files allowed',
|
||||
);
|
||||
expect(mockAttachFileFromPhotoGallery).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show alert and not call picker when maxFilesReached is true for camera', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
maxFilesReached: true,
|
||||
maxFileCount: 10,
|
||||
};
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentOptions {...props}/>,
|
||||
);
|
||||
|
||||
const takePhotoItem = getByTestId('file_attachment.take_photo');
|
||||
fireEvent.press(takePhotoItem);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'Error',
|
||||
'Maximum 10 files allowed',
|
||||
);
|
||||
expect(mockAttachFileFromCamera).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show alert and not call picker when maxFilesReached is true for attach file', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
maxFilesReached: true,
|
||||
maxFileCount: 10,
|
||||
};
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentOptions {...props}/>,
|
||||
);
|
||||
|
||||
const attachFileItem = getByTestId('file_attachment.attach_file');
|
||||
fireEvent.press(attachFileItem);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'Error',
|
||||
'Maximum 10 files allowed',
|
||||
);
|
||||
expect(mockAttachFileFromFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not show alert when maxFilesReached is true but maxFileCount is undefined', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
maxFilesReached: true,
|
||||
maxFileCount: undefined,
|
||||
};
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentOptions {...props}/>,
|
||||
);
|
||||
|
||||
const photoLibraryItem = getByTestId('file_attachment.photo_library');
|
||||
fireEvent.press(photoLibraryItem);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
|
||||
expect(Alert.alert).not.toHaveBeenCalled();
|
||||
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('selection limit calculation', () => {
|
||||
it('should pass correct selection limit when fileCount is less than maxFileCount', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
fileCount: 3,
|
||||
maxFileCount: 10,
|
||||
};
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentOptions {...props}/>,
|
||||
);
|
||||
|
||||
const photoLibraryItem = getByTestId('file_attachment.photo_library');
|
||||
fireEvent.press(photoLibraryItem);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalledWith(7);
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass undefined selection limit when maxFileCount is not provided', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
maxFileCount: undefined,
|
||||
};
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentOptions {...props}/>,
|
||||
);
|
||||
|
||||
const photoLibraryItem = getByTestId('file_attachment.photo_library');
|
||||
fireEvent.press(photoLibraryItem);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass undefined selection limit when fileCount equals maxFileCount', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
fileCount: 10,
|
||||
maxFileCount: 10,
|
||||
};
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentOptions {...props}/>,
|
||||
);
|
||||
|
||||
const photoLibraryItem = getByTestId('file_attachment.photo_library');
|
||||
fireEvent.press(photoLibraryItem);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should use default closeButtonId when not provided', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
};
|
||||
delete (props as {closeButtonId?: string}).closeButtonId;
|
||||
|
||||
renderWithIntlAndTheme(<AttachmentOptions {...props}/>);
|
||||
|
||||
// Component should render without errors
|
||||
});
|
||||
|
||||
it('should use default fileCount when not provided', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
};
|
||||
delete (props as {fileCount?: number}).fileCount;
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentOptions {...props}/>,
|
||||
);
|
||||
|
||||
const photoLibraryItem = getByTestId('file_attachment.photo_library');
|
||||
fireEvent.press(photoLibraryItem);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalledWith(10);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default maxFilesReached when not provided', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
};
|
||||
delete (props as {maxFilesReached?: boolean}).maxFilesReached;
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentOptions {...props}/>,
|
||||
);
|
||||
|
||||
const photoLibraryItem = getByTestId('file_attachment.photo_library');
|
||||
fireEvent.press(photoLibraryItem);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalled();
|
||||
expect(Alert.alert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default canUploadFiles when not provided', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
};
|
||||
delete (props as {canUploadFiles?: boolean}).canUploadFiles;
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<AttachmentOptions {...props}/>,
|
||||
);
|
||||
|
||||
const photoLibraryItem = getByTestId('file_attachment.photo_library');
|
||||
fireEvent.press(photoLibraryItem);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalled();
|
||||
expect(Alert.alert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tablet rendering', () => {
|
||||
it('should not render title when isTablet is true', () => {
|
||||
const useIsTablet = require('@hooks/device').useIsTablet;
|
||||
useIsTablet.mockReturnValue(true);
|
||||
|
||||
const {queryByText} = renderWithIntlAndTheme(
|
||||
<AttachmentOptions {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(queryByText('Files and media')).toBeNull();
|
||||
});
|
||||
|
||||
it('should render title when isTablet is false', () => {
|
||||
const useIsTablet = require('@hooks/device').useIsTablet;
|
||||
useIsTablet.mockReturnValue(false);
|
||||
|
||||
const {getByText} = renderWithIntlAndTheme(
|
||||
<AttachmentOptions {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(getByText('Files and media')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
188
app/screens/attachment_options/attachment_options.tsx
Normal file
188
app/screens/attachment_options/attachment_options.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Alert, View} from 'react-native';
|
||||
import {type CameraOptions} from 'react-native-image-picker';
|
||||
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
|
||||
import {Screens} from '@constants';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import BottomSheet from '@screens/bottom_sheet';
|
||||
import {dismissBottomSheet} from '@screens/navigation';
|
||||
import {fileMaxWarning, uploadDisabledWarning} from '@utils/file';
|
||||
import PickerUtil from '@utils/file/file_picker';
|
||||
import {bottomSheetSnapPoint} from '@utils/helpers';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
const TITLE_HEIGHT = 54;
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
title: {
|
||||
color: theme.centerChannelColor,
|
||||
...typography('Heading', 600, 'SemiBold'),
|
||||
marginBottom: 8,
|
||||
},
|
||||
}));
|
||||
|
||||
type Props = {
|
||||
componentId: AvailableScreens;
|
||||
closeButtonId?: string;
|
||||
onUploadFiles: (files: ExtractedFileInfo[]) => void;
|
||||
maxFileCount?: number;
|
||||
fileCount?: number;
|
||||
maxFilesReached?: boolean;
|
||||
canUploadFiles?: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const AttachmentOptions: React.FC<Props> = ({
|
||||
componentId,
|
||||
closeButtonId = 'attachment-close-id',
|
||||
onUploadFiles,
|
||||
maxFileCount,
|
||||
fileCount = 0,
|
||||
maxFilesReached = false,
|
||||
canUploadFiles = true,
|
||||
testID,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isTablet = useIsTablet();
|
||||
const intl = useIntl();
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
const picker = useMemo(() => new PickerUtil(intl, onUploadFiles), [intl, onUploadFiles]);
|
||||
|
||||
const checkCanUpload = () => {
|
||||
if (!canUploadFiles) {
|
||||
Alert.alert(
|
||||
intl.formatMessage({
|
||||
id: 'mobile.link.error.title',
|
||||
defaultMessage: 'Error',
|
||||
}),
|
||||
uploadDisabledWarning(intl),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const checkMaxFiles = () => {
|
||||
if (maxFilesReached && maxFileCount) {
|
||||
Alert.alert(
|
||||
intl.formatMessage({
|
||||
id: 'mobile.link.error.title',
|
||||
defaultMessage: 'Error',
|
||||
}),
|
||||
fileMaxWarning(intl, maxFileCount),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const onChooseFromPhotoLibrary = async () => {
|
||||
await dismissBottomSheet(Screens.ATTACHMENT_OPTIONS);
|
||||
if (!checkCanUpload() || checkMaxFiles()) {
|
||||
return;
|
||||
}
|
||||
const selectionLimit = maxFileCount ? maxFileCount - fileCount : undefined;
|
||||
picker.attachFileFromPhotoGallery(selectionLimit);
|
||||
};
|
||||
|
||||
const onTakePhoto = async () => {
|
||||
await dismissBottomSheet(Screens.ATTACHMENT_OPTIONS);
|
||||
if (!checkCanUpload() || checkMaxFiles()) {
|
||||
return;
|
||||
}
|
||||
const options: CameraOptions = {
|
||||
quality: 0.8,
|
||||
mediaType: 'photo',
|
||||
saveToPhotos: true,
|
||||
};
|
||||
picker.attachFileFromCamera(options);
|
||||
};
|
||||
|
||||
const onTakeVideo = async () => {
|
||||
await dismissBottomSheet(Screens.ATTACHMENT_OPTIONS);
|
||||
if (!checkCanUpload() || checkMaxFiles()) {
|
||||
return;
|
||||
}
|
||||
const options: CameraOptions = {
|
||||
quality: 0.8,
|
||||
videoQuality: 'high',
|
||||
mediaType: 'video',
|
||||
saveToPhotos: true,
|
||||
};
|
||||
picker.attachFileFromCamera(options);
|
||||
};
|
||||
|
||||
const onAttachFile = async () => {
|
||||
await dismissBottomSheet(Screens.ATTACHMENT_OPTIONS);
|
||||
if (!checkCanUpload() || checkMaxFiles()) {
|
||||
return;
|
||||
}
|
||||
picker.attachFileFromFiles(undefined, true);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
return (
|
||||
<View>
|
||||
{!isTablet &&
|
||||
<FormattedText
|
||||
id='mobile.file_attachment.title'
|
||||
defaultMessage='Files and media'
|
||||
style={styles.title}
|
||||
/>
|
||||
}
|
||||
<SlideUpPanelItem
|
||||
leftIcon='image-outline'
|
||||
onPress={onChooseFromPhotoLibrary}
|
||||
testID='file_attachment.photo_library'
|
||||
text={intl.formatMessage({id: 'mobile.file_upload.library', defaultMessage: 'Choose from photo library'})}
|
||||
/>
|
||||
<SlideUpPanelItem
|
||||
leftIcon='camera-outline'
|
||||
onPress={onTakePhoto}
|
||||
testID='file_attachment.take_photo'
|
||||
text={intl.formatMessage({id: 'mobile.file_upload.camera_photo', defaultMessage: 'Take a photo'})}
|
||||
/>
|
||||
<SlideUpPanelItem
|
||||
leftIcon='video-outline'
|
||||
onPress={onTakeVideo}
|
||||
testID='file_attachment.take_video'
|
||||
text={intl.formatMessage({id: 'mobile.file_upload.camera_video', defaultMessage: 'Take a video'})}
|
||||
/>
|
||||
<SlideUpPanelItem
|
||||
leftIcon='paperclip'
|
||||
onPress={onAttachFile}
|
||||
testID='file_attachment.attach_file'
|
||||
text={intl.formatMessage({id: 'mobile.file_upload.browse', defaultMessage: 'Attach a file'})}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const snapPoints = useMemo(() => {
|
||||
const componentHeight = TITLE_HEIGHT + bottomSheetSnapPoint(4, ITEM_HEIGHT);
|
||||
return [1, componentHeight];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BottomSheet
|
||||
componentId={componentId}
|
||||
renderContent={renderContent}
|
||||
closeButtonId={closeButtonId}
|
||||
snapPoints={snapPoints}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentOptions;
|
||||
|
||||
5
app/screens/attachment_options/index.tsx
Normal file
5
app/screens/attachment_options/index.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {default} from './attachment_options';
|
||||
|
||||
|
|
@ -1,27 +1,25 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
import {type LayoutChangeEvent, StyleSheet, View} from 'react-native';
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {Platform, type LayoutChangeEvent, StyleSheet} from 'react-native';
|
||||
import {KeyboardProvider} from 'react-native-keyboard-controller';
|
||||
import {type Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import {storeLastViewedChannelIdAndServer, removeLastViewedChannelIdAndServer} from '@actions/app/global';
|
||||
import FloatingCallContainer from '@calls/components/floating_call_container';
|
||||
import FreezeScreen from '@components/freeze_screen';
|
||||
import PostDraft from '@components/post_draft';
|
||||
import ScheduledPostIndicator from '@components/scheduled_post_indicator';
|
||||
import {Screens} from '@constants';
|
||||
import {ExtraKeyboardProvider} from '@context/extra_keyboard';
|
||||
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||
import {useChannelSwitch} from '@hooks/channel_switch';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {useDefaultHeaderHeight} from '@hooks/header';
|
||||
import {useTeamSwitch} from '@hooks/team_switch';
|
||||
import {useIsScreenVisible} from '@hooks/use_screen_visibility';
|
||||
import SecurityManager from '@managers/security_manager';
|
||||
import {popTopScreen} from '@screens/navigation';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
|
||||
import ChannelPostList from './channel_post_list';
|
||||
import ChannelContent from './channel_content';
|
||||
import ChannelHeader from './header';
|
||||
import useGMasDMNotice from './use_gm_as_dm_notice';
|
||||
|
||||
|
|
@ -46,8 +44,6 @@ type ChannelProps = {
|
|||
scheduledPostCount: number;
|
||||
};
|
||||
|
||||
const edges: Edge[] = ['left', 'right'];
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
|
|
@ -80,6 +76,19 @@ const Channel = ({
|
|||
const defaultHeight = useDefaultHeaderHeight();
|
||||
const [containerHeight, setContainerHeight] = useState(0);
|
||||
const shouldRender = !switchingTeam && !switchingChannels && shouldRenderPosts && Boolean(channelId);
|
||||
const isVisible = useIsScreenVisible(componentId);
|
||||
const [isEmojiSearchFocused, setIsEmojiSearchFocused] = useState(false);
|
||||
|
||||
const safeAreaViewEdges: Edge[] = useMemo(() => {
|
||||
if (isTablet) {
|
||||
return ['left', 'right'];
|
||||
}
|
||||
if (isEmojiSearchFocused) {
|
||||
return ['left', 'right'];
|
||||
}
|
||||
return ['left', 'right', 'bottom'];
|
||||
}, [isTablet, isEmojiSearchFocused]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
popTopScreen(componentId);
|
||||
}, [componentId]);
|
||||
|
|
@ -120,7 +129,7 @@ const Channel = ({
|
|||
<SafeAreaView
|
||||
style={styles.flex}
|
||||
mode='margin'
|
||||
edges={edges}
|
||||
edges={safeAreaViewEdges}
|
||||
testID='channel.screen'
|
||||
onLayout={onLayout}
|
||||
nativeID={componentId ? SecurityManager.getShieldScreenId(componentId) : undefined}
|
||||
|
|
@ -134,29 +143,31 @@ const Channel = ({
|
|||
shouldRenderBookmarks={shouldRender}
|
||||
shouldRenderChannelBanner={includeChannelBanner}
|
||||
/>
|
||||
{shouldRender &&
|
||||
<ExtraKeyboardProvider>
|
||||
<View style={[styles.flex, {marginTop}]}>
|
||||
<ChannelPostList
|
||||
{Platform.OS === 'ios' ? (
|
||||
<KeyboardProvider>
|
||||
{shouldRender && (
|
||||
<ChannelContent
|
||||
channelId={channelId}
|
||||
marginTop={marginTop}
|
||||
scheduledPostCount={scheduledPostCount}
|
||||
containerHeight={containerHeight}
|
||||
enabled={isVisible || shouldRender}
|
||||
onEmojiSearchFocusChange={setIsEmojiSearchFocused}
|
||||
/>
|
||||
)}
|
||||
</KeyboardProvider>
|
||||
) : (
|
||||
shouldRender && (
|
||||
<ChannelContent
|
||||
channelId={channelId}
|
||||
nativeID={channelId}
|
||||
marginTop={marginTop}
|
||||
scheduledPostCount={scheduledPostCount}
|
||||
containerHeight={containerHeight}
|
||||
enabled={isVisible || shouldRender}
|
||||
onEmojiSearchFocusChange={setIsEmojiSearchFocused}
|
||||
/>
|
||||
</View>
|
||||
<>
|
||||
{scheduledPostCount > 0 &&
|
||||
<ScheduledPostIndicator scheduledPostCount={scheduledPostCount}/>
|
||||
}
|
||||
</>
|
||||
<PostDraft
|
||||
channelId={channelId}
|
||||
testID='channel.post_draft'
|
||||
containerHeight={containerHeight}
|
||||
isChannelScreen={true}
|
||||
canShowPostPriority={true}
|
||||
location={Screens.CHANNEL}
|
||||
/>
|
||||
</ExtraKeyboardProvider>
|
||||
}
|
||||
)
|
||||
)}
|
||||
{showFloatingCallContainer && shouldRender &&
|
||||
<FloatingCallContainer
|
||||
channelId={channelId}
|
||||
|
|
|
|||
76
app/screens/channel/channel_content.tsx
Normal file
76
app/screens/channel/channel_content.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {PortalProvider} from '@gorhom/portal';
|
||||
import React from 'react';
|
||||
import {StyleSheet} from 'react-native';
|
||||
|
||||
import {KeyboardAwarePostDraftContainer} from '@components/keyboard_aware_post_draft_container';
|
||||
import PostDraft from '@components/post_draft';
|
||||
import ScheduledPostIndicator from '@components/scheduled_post_indicator';
|
||||
import {Screens} from '@constants';
|
||||
|
||||
import ChannelPostList from './channel_post_list';
|
||||
|
||||
type ChannelContentProps = {
|
||||
channelId: string;
|
||||
marginTop: number;
|
||||
scheduledPostCount: number;
|
||||
containerHeight: number;
|
||||
enabled?: boolean;
|
||||
onEmojiSearchFocusChange?: (focused: boolean) => void;
|
||||
}
|
||||
|
||||
const CHANNEL_POST_DRAFT_TESTID = 'channel.post_draft';
|
||||
|
||||
// This follows the same pattern as draft_input.tsx: `${testID}.post.input`
|
||||
const CHANNEL_POST_INPUT_NATIVE_ID = `${CHANNEL_POST_DRAFT_TESTID}.post.input`;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const ChannelContent = ({
|
||||
channelId,
|
||||
marginTop,
|
||||
scheduledPostCount,
|
||||
containerHeight,
|
||||
enabled = true,
|
||||
onEmojiSearchFocusChange,
|
||||
}: ChannelContentProps) => {
|
||||
return (
|
||||
<PortalProvider>
|
||||
<KeyboardAwarePostDraftContainer
|
||||
textInputNativeID={CHANNEL_POST_INPUT_NATIVE_ID}
|
||||
containerStyle={[styles.flex, {marginTop}]}
|
||||
enabled={enabled}
|
||||
onEmojiSearchFocusChange={onEmojiSearchFocusChange}
|
||||
renderList={({listRef, onTouchMove, onTouchEnd}) => (
|
||||
<ChannelPostList
|
||||
channelId={channelId}
|
||||
listRef={listRef}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{scheduledPostCount > 0 &&
|
||||
<ScheduledPostIndicator scheduledPostCount={scheduledPostCount}/>
|
||||
}
|
||||
<PostDraft
|
||||
channelId={channelId}
|
||||
testID={CHANNEL_POST_DRAFT_TESTID}
|
||||
containerHeight={containerHeight}
|
||||
isChannelScreen={true}
|
||||
canShowPostPriority={true}
|
||||
location={Screens.CHANNEL}
|
||||
/>
|
||||
</KeyboardAwarePostDraftContainer>
|
||||
</PortalProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelContent;
|
||||
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {type StyleProp, StyleSheet, type ViewStyle, DeviceEventEmitter} from 'react-native';
|
||||
import {type StyleProp, StyleSheet, type ViewStyle, DeviceEventEmitter, type FlatList, type GestureResponderEvent} from 'react-native';
|
||||
import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
|
||||
|
||||
import {markChannelAsRead, unsetActiveChannelOnServer} from '@actions/remote/channel';
|
||||
|
|
@ -26,9 +26,11 @@ type Props = {
|
|||
contentContainerStyle?: StyleProp<AnimatedStyle<ViewStyle>>;
|
||||
isCRTEnabled: boolean;
|
||||
lastViewedAt: number;
|
||||
nativeID: string;
|
||||
posts: PostModel[];
|
||||
shouldShowJoinLeaveMessages: boolean;
|
||||
listRef: React.RefObject<FlatList<string | PostModel>>;
|
||||
onTouchMove?: (event: GestureResponderEvent) => void;
|
||||
onTouchEnd?: () => void;
|
||||
}
|
||||
|
||||
const edges: Edge[] = [];
|
||||
|
|
@ -39,7 +41,8 @@ const styles = StyleSheet.create({
|
|||
|
||||
const ChannelPostList = ({
|
||||
channelId, contentContainerStyle, isCRTEnabled,
|
||||
lastViewedAt, nativeID, posts, shouldShowJoinLeaveMessages,
|
||||
lastViewedAt, posts, shouldShowJoinLeaveMessages,
|
||||
listRef, onTouchMove, onTouchEnd,
|
||||
}: Props) => {
|
||||
const appState = useAppState();
|
||||
const isTablet = useIsTablet();
|
||||
|
|
@ -122,12 +125,14 @@ const ChannelPostList = ({
|
|||
footer={intro}
|
||||
lastViewedAt={lastViewedAt}
|
||||
location={Screens.CHANNEL}
|
||||
nativeID={nativeID}
|
||||
onEndReached={onEndReached}
|
||||
posts={posts}
|
||||
shouldShowJoinLeaveMessages={shouldShowJoinLeaveMessages}
|
||||
showMoreMessages={true}
|
||||
testID='channel.post_list'
|
||||
listRef={listRef}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {act, waitFor} from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
import {Alert} from 'react-native';
|
||||
|
||||
import DraftEditPostUploadManager from '@managers/draft_upload_manager';
|
||||
import * as Navigation from '@screens/navigation';
|
||||
import {fireEvent, renderWithEverything} from '@test/intl-test-helper';
|
||||
import TestHelper from '@test/test_helper';
|
||||
import PickerUtil from '@utils/file/file_picker';
|
||||
|
|
@ -13,6 +15,7 @@ import EditPost from './edit_post';
|
|||
|
||||
import type {Database} from '@nozbe/watermelondb';
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
import type {IntlShape} from 'react-intl';
|
||||
|
||||
jest.mock('@utils/file/file_picker');
|
||||
jest.mock('@managers/draft_upload_manager', () => ({
|
||||
|
|
@ -22,6 +25,13 @@ jest.mock('@managers/draft_upload_manager', () => ({
|
|||
registerErrorHandler: jest.fn(() => jest.fn()),
|
||||
cancel: jest.fn(),
|
||||
}));
|
||||
jest.mock('@screens/navigation', () => ({
|
||||
openAttachmentOptions: jest.fn(),
|
||||
buildNavigationButton: jest.fn((id: string, testID: string) => ({id, testID})),
|
||||
dismissBottomSheet: jest.fn(() => Promise.resolve()),
|
||||
dismissModal: jest.fn(),
|
||||
setButtons: jest.fn(),
|
||||
}));
|
||||
|
||||
const TEST_CONFIG = {
|
||||
serverUrl: 'baseHandler.test.com',
|
||||
|
|
@ -110,6 +120,14 @@ describe('Edit Post', () => {
|
|||
onUploadFiles?.([file as ExtractedFileInfo]);
|
||||
return Promise.resolve({error: undefined});
|
||||
}),
|
||||
attachFileFromPhotoGallery: jest.fn(() => {
|
||||
onUploadFiles?.([file as ExtractedFileInfo]);
|
||||
return Promise.resolve({error: undefined});
|
||||
}),
|
||||
attachFileFromCamera: jest.fn(() => {
|
||||
onUploadFiles?.([file as ExtractedFileInfo]);
|
||||
return Promise.resolve({error: undefined});
|
||||
}),
|
||||
}) as unknown as PickerUtil);
|
||||
};
|
||||
|
||||
|
|
@ -120,12 +138,30 @@ describe('Edit Post', () => {
|
|||
});
|
||||
};
|
||||
|
||||
const triggerFileUpload = (screen: ReturnType<typeof renderEditPost>) => {
|
||||
fireEvent.press(screen.getByTestId('edit_post.quick_actions.file_action'));
|
||||
const triggerFileUpload = async (screen: ReturnType<typeof renderEditPost>) => {
|
||||
let onUploadFilesCallback: ((files: ExtractedFileInfo[]) => void) | undefined;
|
||||
jest.mocked(Navigation.openAttachmentOptions).mockImplementation((intl, theme, props) => {
|
||||
onUploadFilesCallback = props?.onUploadFiles;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.press(screen.getByTestId('edit_post.quick_actions.attachment_action'));
|
||||
});
|
||||
|
||||
if (onUploadFilesCallback) {
|
||||
await act(async () => {
|
||||
const mockIntl = {formatMessage: jest.fn()} as unknown as IntlShape;
|
||||
const mockPicker = new PickerUtil(mockIntl, onUploadFilesCallback as (files: ExtractedFileInfo[]) => void);
|
||||
await mockPicker.attachFileFromFiles(undefined, true);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const triggerFileRemoval = (screen: ReturnType<typeof renderEditPost>, fileId: string) => {
|
||||
fireEvent.press(screen.getByTestId(`remove-button-${fileId}`));
|
||||
const triggerFileRemoval = async (screen: ReturnType<typeof renderEditPost>, fileId: string) => {
|
||||
await act(async () => {
|
||||
fireEvent.press(screen.getByTestId(`remove-button-${fileId}`));
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
|
|
@ -159,53 +195,61 @@ describe('Edit Post', () => {
|
|||
});
|
||||
|
||||
describe('File Upload Validation', () => {
|
||||
it('should show error when file uploads are disabled', () => {
|
||||
it('should show error when file uploads are disabled', async () => {
|
||||
setupPickerMock(TEST_FILES.smallFile);
|
||||
const props = {...baseProps, canUploadFiles: false};
|
||||
const screen = renderEditPost(props);
|
||||
triggerFileUpload(screen);
|
||||
await triggerFileUpload(screen);
|
||||
|
||||
expect(screen.getByText(ERROR_MESSAGES.uploadsDisabled)).toBeVisible();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(ERROR_MESSAGES.uploadsDisabled)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error when maximum file count is reached', () => {
|
||||
it('should show error when maximum file count is reached', async () => {
|
||||
setupPickerMock(TEST_FILES.smallFile);
|
||||
const props = {...baseProps, maxFileCount: 1};
|
||||
const screen = renderEditPost(props);
|
||||
triggerFileUpload(screen);
|
||||
await triggerFileUpload(screen);
|
||||
|
||||
expect(screen.getByText(ERROR_MESSAGES.maxFilesReached)).toBeVisible();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(ERROR_MESSAGES.maxFilesReached)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error when file size exceeds limit', () => {
|
||||
it('should show error when file size exceeds limit', async () => {
|
||||
setupPickerMock(TEST_FILES.largeFile);
|
||||
const props = {...baseProps, maxFileSize: 1000};
|
||||
const screen = renderEditPost(props);
|
||||
triggerFileUpload(screen);
|
||||
await triggerFileUpload(screen);
|
||||
|
||||
expect(screen.getByText(ERROR_MESSAGES.fileTooLarge)).toBeVisible();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(ERROR_MESSAGES.fileTooLarge)).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('File Upload Integration', () => {
|
||||
it('should integrate with DraftEditPostUploadManager for successful uploads', () => {
|
||||
it('should integrate with DraftEditPostUploadManager for successful uploads', async () => {
|
||||
setupPickerMock(TEST_FILES.newFile);
|
||||
const screen = renderEditPost();
|
||||
triggerFileUpload(screen);
|
||||
await triggerFileUpload(screen);
|
||||
|
||||
expect(DraftEditPostUploadManager.prepareUpload).toHaveBeenCalledWith(
|
||||
TEST_CONFIG.serverUrl,
|
||||
TEST_FILES.newFile,
|
||||
baseProps.post.channelId,
|
||||
baseProps.post.rootId,
|
||||
0,
|
||||
true,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(DraftEditPostUploadManager.registerErrorHandler).toHaveBeenCalledWith(
|
||||
TEST_FILES.newFile.clientId,
|
||||
expect.any(Function),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(DraftEditPostUploadManager.prepareUpload).toHaveBeenCalledWith(
|
||||
TEST_CONFIG.serverUrl,
|
||||
TEST_FILES.newFile,
|
||||
baseProps.post.channelId,
|
||||
baseProps.post.rootId,
|
||||
0,
|
||||
true,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(DraftEditPostUploadManager.registerErrorHandler).toHaveBeenCalledWith(
|
||||
TEST_FILES.newFile.clientId,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -215,49 +259,57 @@ describe('Edit Post', () => {
|
|||
setupPickerMock(TEST_FILES.newFile);
|
||||
});
|
||||
|
||||
it('should remove newly uploaded files without confirmation', () => {
|
||||
it('should remove newly uploaded files without confirmation', async () => {
|
||||
const screen = renderEditPost();
|
||||
triggerFileUpload(screen);
|
||||
triggerFileRemoval(screen, TEST_FILES.newFile.id);
|
||||
await triggerFileUpload(screen);
|
||||
await triggerFileRemoval(screen, TEST_FILES.newFile.id);
|
||||
expect(Alert.alert).not.toHaveBeenCalled();
|
||||
expect(DraftEditPostUploadManager.cancel).toHaveBeenCalledWith(TEST_FILES.newFile.clientId);
|
||||
await waitFor(() => {
|
||||
expect(DraftEditPostUploadManager.cancel).toHaveBeenCalledWith(TEST_FILES.newFile.clientId);
|
||||
});
|
||||
});
|
||||
|
||||
it('should show confirmation dialog for existing files', () => {
|
||||
it('should show confirmation dialog for existing files', async () => {
|
||||
const screen = renderEditPost();
|
||||
triggerFileRemoval(screen, TEST_FILES.existingFile1.id);
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
ERROR_MESSAGES.confirmDelete,
|
||||
'Are you sure you want to remove test-1?',
|
||||
expect.any(Array),
|
||||
);
|
||||
await triggerFileRemoval(screen, TEST_FILES.existingFile1.id);
|
||||
await waitFor(() => {
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
ERROR_MESSAGES.confirmDelete,
|
||||
'Are you sure you want to remove test-1?',
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Display', () => {
|
||||
it('should display upload error in PostError component when file upload fails', () => {
|
||||
it('should display upload error in PostError component when file upload fails', async () => {
|
||||
setupPickerMock(TEST_FILES.largeFile);
|
||||
const props = {...baseProps, maxFileSize: 1000};
|
||||
const screen = renderEditPost(props);
|
||||
|
||||
// Trigger file upload that will cause size error
|
||||
triggerFileUpload(screen);
|
||||
await triggerFileUpload(screen);
|
||||
|
||||
// Verify that the PostError component is displayed with the error
|
||||
expect(screen.getByTestId('edit_post.message.input.error')).toBeVisible();
|
||||
expect(screen.getByText(ERROR_MESSAGES.fileTooLarge)).toBeVisible();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('edit_post.message.input.error')).toBeVisible();
|
||||
expect(screen.getByText(ERROR_MESSAGES.fileTooLarge)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display upload error with divider when error is present', () => {
|
||||
it('should display upload error with divider when error is present', async () => {
|
||||
setupPickerMock(TEST_FILES.smallFile);
|
||||
const props = {...baseProps, canUploadFiles: false};
|
||||
const screen = renderEditPost(props);
|
||||
|
||||
// Trigger file upload that will cause upload disabled error
|
||||
triggerFileUpload(screen);
|
||||
await triggerFileUpload(screen);
|
||||
|
||||
// Verify that the error is displayed
|
||||
expect(screen.getByText(ERROR_MESSAGES.uploadsDisabled)).toBeVisible();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(ERROR_MESSAGES.uploadsDisabled)).toBeVisible();
|
||||
});
|
||||
|
||||
// Verify that the PostError component has the hasError styling applied
|
||||
const editPostInput = screen.getByTestId('edit_post.message.input');
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Alert, Keyboard, type LayoutChangeEvent, Platform, View, StyleSheet} from 'react-native';
|
||||
import {KeyboardProvider} from 'react-native-keyboard-controller';
|
||||
import {SafeAreaView, type Edge} from 'react-native-safe-area-context';
|
||||
|
||||
import {deletePost, editPost} from '@actions/remote/post';
|
||||
|
|
@ -123,6 +124,9 @@ const EditPost = ({
|
|||
|
||||
useEffect(() => {
|
||||
toggleSaveButton(false);
|
||||
|
||||
// No dependencies to avoid unnecessary re-renders since this is a one-time effect
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -450,44 +454,88 @@ const EditPost = ({
|
|||
onLayout={onLayout}
|
||||
nativeID={SecurityManager.getShieldScreenId(componentId)}
|
||||
>
|
||||
<ExtraKeyboardProvider>
|
||||
<View
|
||||
style={styles.body}
|
||||
ref={mainView}
|
||||
>
|
||||
{Boolean((errorLine || errorExtra)) &&
|
||||
<PostError
|
||||
errorExtra={errorExtra}
|
||||
errorLine={errorLine}
|
||||
/>
|
||||
}
|
||||
<View style={styles.inputContainer}>
|
||||
<EditPostInput
|
||||
hasError={Boolean(errorLine || errorExtra)}
|
||||
message={postMessage}
|
||||
onChangeText={onInputChangeText}
|
||||
onTextSelectionChange={onTextSelectionChange}
|
||||
inputRef={postInputRef}
|
||||
post={post}
|
||||
postFiles={postFiles}
|
||||
addFiles={addFiles}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ExtraKeyboardProvider>
|
||||
{Platform.OS === 'ios' ? (
|
||||
<KeyboardProvider>
|
||||
<ExtraKeyboardProvider>
|
||||
<View
|
||||
style={styles.body}
|
||||
ref={mainView}
|
||||
>
|
||||
{Boolean((errorLine || errorExtra)) &&
|
||||
<PostError
|
||||
errorExtra={errorExtra}
|
||||
errorLine={errorLine}
|
||||
/>
|
||||
}
|
||||
<View style={styles.inputContainer}>
|
||||
<EditPostInput
|
||||
hasError={Boolean(errorLine || errorExtra)}
|
||||
message={postMessage}
|
||||
onChangeText={onInputChangeText}
|
||||
onTextSelectionChange={onTextSelectionChange}
|
||||
inputRef={postInputRef}
|
||||
post={post}
|
||||
postFiles={postFiles}
|
||||
addFiles={addFiles}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ExtraKeyboardProvider>
|
||||
<Autocomplete
|
||||
channelId={post.channelId}
|
||||
shouldDirectlyReact={false}
|
||||
nestedScrollEnabled={true}
|
||||
rootId={post.rootId}
|
||||
updateValue={onAutocompleteChangeText}
|
||||
value={postMessage}
|
||||
cursorPosition={cursorPosition}
|
||||
position={animatedAutocompletePosition}
|
||||
availableSpace={animatedAutocompleteAvailableSpace}
|
||||
serverUrl={serverUrl}
|
||||
/>
|
||||
</KeyboardProvider>
|
||||
) : (
|
||||
<>
|
||||
<ExtraKeyboardProvider>
|
||||
<View
|
||||
style={styles.body}
|
||||
ref={mainView}
|
||||
>
|
||||
{Boolean((errorLine || errorExtra)) &&
|
||||
<PostError
|
||||
errorExtra={errorExtra}
|
||||
errorLine={errorLine}
|
||||
/>
|
||||
}
|
||||
<View style={styles.inputContainer}>
|
||||
<EditPostInput
|
||||
hasError={Boolean(errorLine || errorExtra)}
|
||||
message={postMessage}
|
||||
onChangeText={onInputChangeText}
|
||||
onTextSelectionChange={onTextSelectionChange}
|
||||
inputRef={postInputRef}
|
||||
post={post}
|
||||
postFiles={postFiles}
|
||||
addFiles={addFiles}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ExtraKeyboardProvider>
|
||||
<Autocomplete
|
||||
channelId={post.channelId}
|
||||
shouldDirectlyReact={false}
|
||||
nestedScrollEnabled={true}
|
||||
rootId={post.rootId}
|
||||
updateValue={onAutocompleteChangeText}
|
||||
value={postMessage}
|
||||
cursorPosition={cursorPosition}
|
||||
position={animatedAutocompletePosition}
|
||||
availableSpace={animatedAutocompleteAvailableSpace}
|
||||
serverUrl={serverUrl}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
<Autocomplete
|
||||
channelId={post.channelId}
|
||||
shouldDirectlyReact={false}
|
||||
nestedScrollEnabled={true}
|
||||
rootId={post.rootId}
|
||||
updateValue={onAutocompleteChangeText}
|
||||
value={postMessage}
|
||||
cursorPosition={cursorPosition}
|
||||
position={animatedAutocompletePosition}
|
||||
availableSpace={animatedAutocompleteAvailableSpace}
|
||||
serverUrl={serverUrl}
|
||||
/>
|
||||
</EditPostProvider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -91,9 +91,7 @@ describe('EditPostInput', () => {
|
|||
const {queryByTestId} = renderWithEverything(<EditPostInput {...props}/>, {database, serverUrl});
|
||||
expect(queryByTestId('edit_post.quick_actions')).toBeNull();
|
||||
expect(queryByTestId('edit_post.quick_actions.at_input_action')).toBeNull();
|
||||
expect(queryByTestId('edit_post.quick_actions.file_action')).toBeNull();
|
||||
expect(queryByTestId('edit_post.quick_actions.image_action')).toBeNull();
|
||||
expect(queryByTestId('edit_post.quick_actions.camera_action')).toBeNull();
|
||||
expect(queryByTestId('edit_post.quick_actions.attachment_action')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not render both uploads and quick actions when server version is unsupported', () => {
|
||||
|
|
@ -106,10 +104,8 @@ describe('EditPostInput', () => {
|
|||
it('should render quick actions in edit mode', () => {
|
||||
const {getByTestId, queryByTestId} = renderWithEverything(<EditPostInput {...baseProps}/>, {database, serverUrl});
|
||||
expect(getByTestId('edit_post.quick_actions')).toBeVisible();
|
||||
expect(getByTestId('edit_post.quick_actions.attachment_action')).toBeVisible();
|
||||
expect(getByTestId('edit_post.quick_actions.at_input_action')).toBeVisible();
|
||||
expect(getByTestId('edit_post.quick_actions.file_action')).toBeVisible();
|
||||
expect(getByTestId('edit_post.quick_actions.image_action')).toBeVisible();
|
||||
expect(getByTestId('edit_post.quick_actions.camera_action')).toBeVisible();
|
||||
expect(queryByTestId('edit_post.quick_actions.slash_input_action')).toBeNull();
|
||||
expect(queryByTestId('edit_post.quick_actions.post_priority_action')).toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ const EditPostInput = ({
|
|||
const disableCopyAndPaste = managedConfig.copyAndPasteProtection === 'true';
|
||||
const focus = useCallback(() => {
|
||||
inputRef.current?.focus();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const updateValue = useCallback((valueOrUpdater: string | ((prevValue: string) => string)) => {
|
||||
|
|
@ -144,6 +145,7 @@ const EditPostInput = ({
|
|||
canShowPostPriority={false}
|
||||
postPriority={INITIAL_PRIORITY}
|
||||
canShowSlashCommands={false}
|
||||
canShowEmojiPicker={false}
|
||||
focus={focus}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {View} from 'react-native';
|
||||
|
||||
import {useTheme} from '@context/theme';
|
||||
import {selectEmojiCategoryBarSection, useEmojiCategoryBar} from '@hooks/emoji_category_bar';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import EmojiCategoryBarIcon from './icon';
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
container: {
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
height: 55,
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 11,
|
||||
borderTopColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||
borderTopWidth: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
}));
|
||||
|
||||
type Props = {
|
||||
onSelect?: (index: number | undefined) => void;
|
||||
}
|
||||
|
||||
const EmojiCategoryBar = ({onSelect}: Props) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
const {currentIndex, icons} = useEmojiCategoryBar();
|
||||
|
||||
const scrollToIndex = useCallback((index: number) => {
|
||||
if (onSelect) {
|
||||
onSelect(index);
|
||||
return;
|
||||
}
|
||||
|
||||
selectEmojiCategoryBarSection(index);
|
||||
}, []);
|
||||
|
||||
if (!icons) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
testID='emoji_picker.category_bar'
|
||||
>
|
||||
{icons.map((icon, index) => (
|
||||
<EmojiCategoryBarIcon
|
||||
currentIndex={currentIndex}
|
||||
key={icon.key}
|
||||
icon={icon.icon}
|
||||
index={index}
|
||||
scrollToIndex={scrollToIndex}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiCategoryBar;
|
||||
|
|
@ -6,12 +6,11 @@ import React, {useCallback} from 'react';
|
|||
import {Platform} from 'react-native';
|
||||
import Animated, {useAnimatedStyle, withTiming, type SharedValue} from 'react-native-reanimated';
|
||||
|
||||
import EmojiCategoryBar from '@components/emoji_category_bar';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useKeyboardHeight} from '@hooks/device';
|
||||
import {selectEmojiCategoryBarSection} from '@hooks/emoji_category_bar';
|
||||
|
||||
import EmojiCategoryBar from '../emoji_category_bar';
|
||||
|
||||
function waitForSheetExtended(animatedSheetState: SharedValue<number>, callback: () => void, depth = 250) {
|
||||
if (animatedSheetState.value === SHEET_STATE.EXTENDED) {
|
||||
callback();
|
||||
|
|
@ -36,6 +35,9 @@ const PickerFooter = (props: BottomSheetFooterProps) => {
|
|||
waitForSheetExtended(animatedSheetState, () => {
|
||||
selectEmojiCategoryBarSection(index);
|
||||
});
|
||||
|
||||
// animatedSheetState and expand are stable and don't need to be in dependencies to avoid unnecessary callback recreation
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
|
|
|
|||
|
|
@ -129,6 +129,9 @@ const SkinToneSelector = ({skinTone = 'default', containerWidth, isSearching, tu
|
|||
setTooltipVisible(true);
|
||||
}
|
||||
});
|
||||
|
||||
// tutorialWatched is not a dependency because it is not used in the effect
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
|
@ -145,7 +148,7 @@ const SkinToneSelector = ({skinTone = 'default', containerWidth, isSearching, tu
|
|||
>
|
||||
<Animated.View
|
||||
style={widthAnimatedStyle}
|
||||
exiting={Platform.OS === 'android' ? undefined : FadeOut /* https://mattermost.atlassian.net/browse/MM-63814?focusedCommentId=178584 */}
|
||||
exiting={Platform.select({ios: FadeOut})} /* https://mattermost.atlassian.net/browse/MM-63814?focusedCommentId=178584 */
|
||||
entering={FadeIn}
|
||||
>
|
||||
<Animated.View style={[styles.container, opacityStyle]}>
|
||||
|
|
@ -162,7 +165,7 @@ const SkinToneSelector = ({skinTone = 'default', containerWidth, isSearching, tu
|
|||
<Animated.View
|
||||
style={styles.expanded}
|
||||
entering={entering}
|
||||
exiting={exiting}
|
||||
exiting={Platform.select({ios: exiting})} /* https://mattermost.atlassian.net/browse/MM-63814?focusedCommentId=178584 */
|
||||
>
|
||||
{!isTablet && <CloseButton collapse={collapse}/>}
|
||||
<SkinSelector
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export default function EmojiRow({emojis, file, imageUrl, onEmojiPress}: EmojiRo
|
|||
name={emoji.name}
|
||||
onEmojiPress={onEmojiPress}
|
||||
category={emoji.category}
|
||||
preventDoubleTap={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
|||
import {View, StyleSheet} from 'react-native';
|
||||
|
||||
import {fetchCustomEmojis} from '@actions/remote/custom_emoji';
|
||||
import EmojiCategoryBar from '@components/emoji_category_bar';
|
||||
import {EMOJI_CATEGORY_ICONS, EMOJI_ROW_MARGIN, EMOJI_SIZE, EMOJIS_PER_PAGE, EMOJIS_PER_ROW, EMOJIS_PER_ROW_TABLET} from '@constants/emoji';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
|
|
@ -15,8 +16,6 @@ import {setEmojiCategoryBarIcons, setEmojiCategoryBarSection, useEmojiCategoryBa
|
|||
import {CategoryNames, EmojiIndicesByCategory, CategoryTranslations, CategoryMessage} from '@utils/emoji';
|
||||
import {fillEmoji} from '@utils/emoji/helpers';
|
||||
|
||||
import EmojiCategoryBar from '../emoji_category_bar';
|
||||
|
||||
import EmojiRow, {type EmojiSectionRow} from './emoji_row';
|
||||
import SectionFooter from './section_footer';
|
||||
import SectionHeader, {type EmojiSection} from './section_header';
|
||||
|
|
|
|||
|
|
@ -29,9 +29,10 @@ const getStyleSheetFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
|||
height: SECTION_HEADER_HEIGHT,
|
||||
justifyContent: 'center',
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
sectionTitle: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.2),
|
||||
color: changeOpacity(theme.centerChannelColor, 0.56),
|
||||
textTransform: 'uppercase',
|
||||
...typography('Heading', 75, 'SemiBold'),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -157,6 +157,10 @@ const ChannelListScreen = (props: ChannelProps) => {
|
|||
if (!props.hasCurrentUser || !props.currentUserId) {
|
||||
refetchCurrentUser(serverUrl, props.currentUserId);
|
||||
}
|
||||
|
||||
// - serverUrl is stable from useServerUrl hook
|
||||
// - We only need to re-run when the current user state changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.currentUserId, props.hasCurrentUser]);
|
||||
|
||||
// Init the rate app. Only run the effect on the first render if ToS is not open
|
||||
|
|
@ -168,12 +172,12 @@ const ChannelListScreen = (props: ChannelProps) => {
|
|||
if (!NavigationStore.isToSOpen()) {
|
||||
tryRunAppReview(props.launchType, props.coldStart);
|
||||
}
|
||||
}, []);
|
||||
}, [props.launchType, props.coldStart]);
|
||||
|
||||
useEffect(() => {
|
||||
PerformanceMetricsManager.finishLoad('HOME', serverUrl);
|
||||
PerformanceMetricsManager.measureTimeToInteraction();
|
||||
}, []);
|
||||
}, [serverUrl]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {NavigationContainer, DefaultTheme} from '@react-navigation/native';
|
|||
import React, {useCallback, useEffect, useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {DeviceEventEmitter, Platform, StyleSheet, View} from 'react-native';
|
||||
import {useKeyboardState} from 'react-native-keyboard-controller';
|
||||
import {enableFreeze, enableScreens} from 'react-native-screens';
|
||||
|
||||
import {autoUpdateTimezone} from '@actions/remote/user';
|
||||
|
|
@ -66,11 +67,18 @@ export function HomeScreen(props: HomeProps) {
|
|||
const theme = useTheme();
|
||||
const intl = useIntl();
|
||||
const appState = useAppState();
|
||||
const keyboardState = useKeyboardState();
|
||||
const [isEmojiSearchFocused, setIsEmojiSearchFocused] = React.useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
SecurityManager.start();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Hide tab bar when keyboard opens, show when it closes
|
||||
DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, !keyboardState.isVisible);
|
||||
}, [keyboardState.isVisible]);
|
||||
|
||||
const handleFindChannels = useCallback(() => {
|
||||
if (!NavigationStore.getScreensInStack().includes(Screens.FIND_CHANNELS)) {
|
||||
findChannels(
|
||||
|
|
@ -147,6 +155,28 @@ export function HomeScreen(props: HomeProps) {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = DeviceEventEmitter.addListener(Events.EMOJI_PICKER_SEARCH_FOCUSED, (focused: boolean) => {
|
||||
setIsEmojiSearchFocused(focused);
|
||||
});
|
||||
|
||||
return () => listener.remove();
|
||||
}, []);
|
||||
|
||||
const TabBarComponent = (tabProps: BottomTabBarProps) => {
|
||||
if (isEmojiSearchFocused) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TabBar
|
||||
{...tabProps}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
};
|
||||
TabBarComponent.displayName = 'TabBarComponent';
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.flex}
|
||||
|
|
@ -170,11 +200,7 @@ export function HomeScreen(props: HomeProps) {
|
|||
<Tab.Navigator
|
||||
screenOptions={{headerShown: false, freezeOnBlur: false, lazy: true}}
|
||||
backBehavior='none'
|
||||
tabBar={(tabProps: BottomTabBarProps) => (
|
||||
<TabBar
|
||||
{...tabProps}
|
||||
theme={theme}
|
||||
/>)}
|
||||
tabBar={TabBarComponent}
|
||||
>
|
||||
<Tab.Screen
|
||||
name={Screens.HOME}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
|
|||
case Screens.APPS_FORM:
|
||||
screen = withServerDatabase(require('@screens/apps_form').default);
|
||||
break;
|
||||
case Screens.ATTACHMENT_OPTIONS:
|
||||
screen = withServerDatabase(require('@screens/attachment_options').default);
|
||||
break;
|
||||
case Screens.BOTTOM_SHEET:
|
||||
screen = withServerDatabase(require('@screens/bottom_sheet').default);
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -5,12 +5,29 @@ import {Navigation} from 'react-native-navigation';
|
|||
|
||||
import {Events, Preferences, Screens} from '@constants';
|
||||
import NavigationStore from '@store/navigation_store';
|
||||
import {isTablet} from '@utils/helpers';
|
||||
|
||||
import {openAsBottomSheet} from './navigation';
|
||||
import {openAsBottomSheet, openAttachmentOptions} from './navigation';
|
||||
|
||||
import type {FirstArgument} from '@typings/utils/utils';
|
||||
import type {IntlShape} from 'react-intl';
|
||||
|
||||
jest.mock('@utils/helpers', () => ({
|
||||
...jest.requireActual('@utils/helpers'),
|
||||
isTablet: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@components/compass_icon', () => {
|
||||
function CompassIcon() {
|
||||
return null;
|
||||
}
|
||||
CompassIcon.getImageSourceSync = jest.fn().mockReturnValue({});
|
||||
return {
|
||||
__esModule: true,
|
||||
default: CompassIcon,
|
||||
};
|
||||
});
|
||||
|
||||
function expectShowModalCalledWith(screen: string, title: string, props?: Record<string, unknown>) {
|
||||
expect(Navigation.showModal).toHaveBeenCalledWith({
|
||||
stack: {
|
||||
|
|
@ -33,8 +50,8 @@ function expectShowModalOverCurrentContext(screen: string, props?: Record<string
|
|||
expectShowModalCalledWith(screen, '', props);
|
||||
}
|
||||
|
||||
function expectOpenAsBottomSheetCalledWith(props: FirstArgument<typeof openAsBottomSheet>, isTablet: boolean) {
|
||||
if (isTablet) {
|
||||
function expectOpenAsBottomSheetCalledWith(props: FirstArgument<typeof openAsBottomSheet>, isTabletDevice: boolean) {
|
||||
if (isTabletDevice) {
|
||||
expectShowModalCalledWith(props.screen, props.title, {closeButtonId: props.closeButtonId, ...props.props});
|
||||
} else {
|
||||
expectShowModalOverCurrentContext(props.screen, props.props);
|
||||
|
|
@ -107,3 +124,68 @@ describe('openUserProfileModal', () => {
|
|||
}, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openAttachmentOptions', () => {
|
||||
const intl = {
|
||||
formatMessage: jest.fn(({defaultMessage}) => defaultMessage),
|
||||
} as unknown as IntlShape;
|
||||
const theme = Preferences.THEMES.denim;
|
||||
const mockOnUploadFiles = jest.fn();
|
||||
const props = {
|
||||
onUploadFiles: mockOnUploadFiles,
|
||||
maxFilesReached: false,
|
||||
canUploadFiles: true,
|
||||
testID: 'test-attachment',
|
||||
fileCount: 0,
|
||||
maxFileCount: 5,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(isTablet).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should call openAsBottomSheet with correct parameters on non-tablet', () => {
|
||||
openAttachmentOptions(intl, theme, props);
|
||||
|
||||
expectOpenAsBottomSheetCalledWith({
|
||||
screen: Screens.ATTACHMENT_OPTIONS,
|
||||
title: 'Files and media',
|
||||
closeButtonId: 'attachment-close-id',
|
||||
theme,
|
||||
props,
|
||||
}, false);
|
||||
});
|
||||
|
||||
it('should call openAsBottomSheet with correct parameters on tablet', () => {
|
||||
jest.mocked(isTablet).mockReturnValue(true);
|
||||
|
||||
openAttachmentOptions(intl, theme, props);
|
||||
|
||||
expectOpenAsBottomSheetCalledWith({
|
||||
screen: Screens.ATTACHMENT_OPTIONS,
|
||||
title: 'Files and media',
|
||||
closeButtonId: 'attachment-close-id',
|
||||
theme,
|
||||
props,
|
||||
}, true);
|
||||
});
|
||||
|
||||
it('should handle optional props correctly', () => {
|
||||
const minimalProps = {
|
||||
onUploadFiles: mockOnUploadFiles,
|
||||
maxFilesReached: true,
|
||||
canUploadFiles: false,
|
||||
};
|
||||
|
||||
openAttachmentOptions(intl, theme, minimalProps);
|
||||
|
||||
expectOpenAsBottomSheetCalledWith({
|
||||
screen: Screens.ATTACHMENT_OPTIONS,
|
||||
title: 'Files and media',
|
||||
closeButtonId: 'attachment-close-id',
|
||||
theme,
|
||||
props: minimalProps,
|
||||
}, false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import type {LaunchProps} from '@typings/launch';
|
|||
import type {AvailableScreens, NavButtons} from '@typings/screens/navigation';
|
||||
import type {ComponentProps} from 'react';
|
||||
import type {IntlShape} from 'react-intl';
|
||||
import type {Asset} from 'react-native-image-picker';
|
||||
|
||||
const alpha = {
|
||||
from: 0,
|
||||
|
|
@ -913,6 +914,28 @@ export function openAsBottomSheet({closeButtonId, screen, theme, title, props}:
|
|||
}
|
||||
}
|
||||
|
||||
export function openAttachmentOptions(
|
||||
intl: IntlShape,
|
||||
theme: Theme,
|
||||
props: {
|
||||
onUploadFiles: (files: Asset[]) => void;
|
||||
maxFilesReached: boolean;
|
||||
canUploadFiles: boolean;
|
||||
testID?: string;
|
||||
fileCount?: number;
|
||||
maxFileCount?: number;
|
||||
},
|
||||
) {
|
||||
const title = intl.formatMessage({id: 'mobile.file_attachment.title', defaultMessage: 'Files and media'});
|
||||
openAsBottomSheet({
|
||||
closeButtonId: 'attachment-close-id',
|
||||
screen: Screens.ATTACHMENT_OPTIONS,
|
||||
theme,
|
||||
title,
|
||||
props,
|
||||
});
|
||||
}
|
||||
|
||||
export const showAppForm = async (form: AppForm, context: AppContext) => {
|
||||
const passProps = {form, context};
|
||||
showModal(Screens.APPS_FORM, form.title || '', passProps);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Alert, Text, TouchableOpacity, View} from 'react-native';
|
||||
import {Alert, Platform, Text, TouchableOpacity, View} from 'react-native';
|
||||
import {KeyboardProvider} from 'react-native-keyboard-controller';
|
||||
import Animated from 'react-native-reanimated';
|
||||
import {type Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
|
|
@ -17,7 +18,6 @@ import FormattedText from '@components/formatted_text';
|
|||
import Loading from '@components/loading';
|
||||
import PostList from '@components/post_list';
|
||||
import {Screens} from '@constants';
|
||||
import {ExtraKeyboardProvider} from '@context/extra_keyboard';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import DatabaseManager from '@database/manager';
|
||||
|
|
@ -263,6 +263,12 @@ function Permalink({
|
|||
});
|
||||
setLoading(false);
|
||||
})();
|
||||
|
||||
// - serverUrl is stable from useServerUrl hook (doesn't need to be in deps)
|
||||
// - postId, isTeamMember, currentTeamId are props that don't change for a given permalink screen
|
||||
// - setError, setLoading, setChannelId, setPosts are stable setState functions
|
||||
// - We only need to re-run when channelId, rootId, isCRTEnabled, or teamName changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [channelId, rootId, isCRTEnabled, teamName]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
|
|
@ -314,8 +320,8 @@ function Permalink({
|
|||
/>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<ExtraKeyboardProvider>
|
||||
const postListContent = (
|
||||
<>
|
||||
<View style={style.postList}>
|
||||
<PostList
|
||||
highlightedId={postId}
|
||||
|
|
@ -327,7 +333,6 @@ function Permalink({
|
|||
channelId={channel!.id}
|
||||
rootId={rootId}
|
||||
testID='permalink.post_list'
|
||||
nativeID={Screens.PERMALINK}
|
||||
highlightPinnedOrSaved={false}
|
||||
/>
|
||||
</View>
|
||||
|
|
@ -340,7 +345,15 @@ function Permalink({
|
|||
testID='permalink.jump_to_recent_messages.button'
|
||||
/>
|
||||
</View>
|
||||
</ExtraKeyboardProvider>
|
||||
</>
|
||||
);
|
||||
|
||||
content = Platform.OS === 'ios' ? (
|
||||
<KeyboardProvider>
|
||||
{postListContent}
|
||||
</KeyboardProvider>
|
||||
) : (
|
||||
postListContent
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,18 @@ const SSO = ({
|
|||
const intl = useIntl();
|
||||
const [loginError, setLoginError] = useState<string>('');
|
||||
|
||||
// Validate serverUrl is provided
|
||||
if (!serverUrl) {
|
||||
return (
|
||||
<View
|
||||
nativeID={SecurityManager.getShieldScreenId(componentId, false, true)}
|
||||
style={styles.flex}
|
||||
>
|
||||
<Background theme={theme}/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
let loginUrl = '';
|
||||
let shouldUseNativeEntra = false;
|
||||
|
||||
|
|
@ -109,7 +121,7 @@ const SSO = ({
|
|||
}, [intl]);
|
||||
|
||||
const doSSOLogin = async (bearerToken: string, csrfToken: string) => {
|
||||
const result: LoginActionResponse = await ssoLogin(serverUrl!, serverDisplayName, config.DiagnosticId!, bearerToken, csrfToken, serverPreauthSecret);
|
||||
const result: LoginActionResponse = await ssoLogin(serverUrl, serverDisplayName, config.DiagnosticId!, bearerToken, csrfToken, serverPreauthSecret);
|
||||
if (result?.error && result.failed) {
|
||||
onLoadEndError(result.error);
|
||||
return;
|
||||
|
|
@ -118,7 +130,7 @@ const SSO = ({
|
|||
};
|
||||
|
||||
const doSSOCodeExchange = async (loginCode: string, samlChallenge: {codeVerifier: string; state: string}) => {
|
||||
const result: LoginActionResponse = await ssoLoginWithCodeExchange(serverUrl!, serverDisplayName, config.DiagnosticId!, loginCode, samlChallenge, serverPreauthSecret);
|
||||
const result: LoginActionResponse = await ssoLoginWithCodeExchange(serverUrl, serverDisplayName, config.DiagnosticId!, loginCode, samlChallenge, serverPreauthSecret);
|
||||
if (result?.error && result.failed) {
|
||||
onLoadEndError(result.error);
|
||||
return;
|
||||
|
|
@ -133,7 +145,7 @@ const SSO = ({
|
|||
|
||||
const doEntraLogin = useCallback(async () => {
|
||||
const result = await nativeEntraLogin(
|
||||
serverUrl!,
|
||||
serverUrl,
|
||||
serverDisplayName,
|
||||
config.DiagnosticId!,
|
||||
config.IntuneScope!,
|
||||
|
|
@ -173,6 +185,7 @@ const SSO = ({
|
|||
doSSOCodeExchange,
|
||||
loginError,
|
||||
loginUrl,
|
||||
serverUrl,
|
||||
setLoginError,
|
||||
theme,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import SSOAuthentication from './sso_authentication';
|
|||
jest.mock('@utils/url', () => {
|
||||
return {
|
||||
tryOpenURL: () => null,
|
||||
sanitizeUrl: (url: string) => url.replace(/\/+$/, '').toLowerCase(),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ describe('SSO with redirect url', () => {
|
|||
intl: {},
|
||||
loginError: '',
|
||||
loginUrl: '',
|
||||
serverUrl: 'https://example.mattermost.com',
|
||||
setLoginError: jest.fn(),
|
||||
theme: Preferences.THEMES.denim,
|
||||
};
|
||||
|
|
@ -49,3 +51,48 @@ describe('SSO with redirect url', () => {
|
|||
expect(browser).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Server origin verification', () => {
|
||||
// Test the URL normalization logic used in server origin verification
|
||||
const {sanitizeUrl} = jest.requireMock('@utils/url');
|
||||
|
||||
test('should normalize URLs by removing trailing slashes', () => {
|
||||
expect(sanitizeUrl('https://example.com/')).toBe('https://example.com');
|
||||
expect(sanitizeUrl('https://example.com')).toBe('https://example.com');
|
||||
});
|
||||
|
||||
test('should normalize URLs to lowercase', () => {
|
||||
expect(sanitizeUrl('https://EXAMPLE.COM')).toBe('https://example.com');
|
||||
expect(sanitizeUrl('https://Example.Mattermost.Com')).toBe('https://example.mattermost.com');
|
||||
});
|
||||
|
||||
test('should match identical normalized URLs', () => {
|
||||
const serverUrl = 'https://example.mattermost.com';
|
||||
const srvParam = 'https://example.mattermost.com';
|
||||
expect(sanitizeUrl(serverUrl)).toBe(sanitizeUrl(srvParam));
|
||||
});
|
||||
|
||||
test('should match URLs with different casing', () => {
|
||||
const serverUrl = 'https://example.mattermost.com';
|
||||
const srvParam = 'https://EXAMPLE.MATTERMOST.COM';
|
||||
expect(sanitizeUrl(serverUrl)).toBe(sanitizeUrl(srvParam));
|
||||
});
|
||||
|
||||
test('should match URLs with trailing slash differences', () => {
|
||||
const serverUrl = 'https://example.mattermost.com';
|
||||
const srvParam = 'https://example.mattermost.com/';
|
||||
expect(sanitizeUrl(serverUrl)).toBe(sanitizeUrl(srvParam));
|
||||
});
|
||||
|
||||
test('should not match different server URLs', () => {
|
||||
const serverUrl = 'https://legitimate.mattermost.com';
|
||||
const srvParam = 'https://malicious.attacker.com';
|
||||
expect(sanitizeUrl(serverUrl)).not.toBe(sanitizeUrl(srvParam));
|
||||
});
|
||||
|
||||
test('should not match when server URL contains attacker URL as substring', () => {
|
||||
const serverUrl = 'https://legitimate.mattermost.com';
|
||||
const srvParam = 'https://legitimate.mattermost.com.attacker.com';
|
||||
expect(sanitizeUrl(serverUrl)).not.toBe(sanitizeUrl(srvParam));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import urlParse from 'url-parse';
|
|||
import {Sso} from '@constants';
|
||||
import {isBetaApp} from '@utils/general';
|
||||
import {createSamlChallenge} from '@utils/saml_challenge';
|
||||
import {sanitizeUrl} from '@utils/url';
|
||||
|
||||
import AuthError from './components/auth_error';
|
||||
import AuthRedirect from './components/auth_redirect';
|
||||
|
|
@ -21,6 +22,7 @@ interface SSOAuthenticationProps {
|
|||
doSSOCodeExchange: (loginCode: string, samlChallenge: {codeVerifier: string; state: string}) => void;
|
||||
loginError: string;
|
||||
loginUrl: string;
|
||||
serverUrl: string;
|
||||
setLoginError: (value: string) => void;
|
||||
theme: Theme;
|
||||
}
|
||||
|
|
@ -32,7 +34,7 @@ const style = StyleSheet.create({
|
|||
},
|
||||
});
|
||||
|
||||
const SSOAuthentication = ({doSSOLogin, doSSOCodeExchange, loginError, loginUrl, setLoginError, theme}: SSOAuthenticationProps) => {
|
||||
const SSOAuthentication = ({doSSOLogin, doSSOCodeExchange, loginError, loginUrl, serverUrl, setLoginError, theme}: SSOAuthenticationProps) => {
|
||||
const [error, setError] = useState<string>('');
|
||||
const [loginSuccess, setLoginSuccess] = useState(false);
|
||||
const intl = useIntl();
|
||||
|
|
@ -43,6 +45,17 @@ const SSOAuthentication = ({doSSOLogin, doSSOCodeExchange, loginError, loginUrl,
|
|||
|
||||
const redirectUrl = customUrlScheme + 'callback';
|
||||
const samlChallenge = useMemo(() => createSamlChallenge(), []);
|
||||
|
||||
// Verify that the srv parameter from the callback matches the expected server
|
||||
const verifyServerOrigin = useCallback((srvParam: string | undefined): boolean => {
|
||||
if (!srvParam) {
|
||||
// Old servers don't send srv parameter - allow for backwards compatibility
|
||||
return true;
|
||||
}
|
||||
const normalizedExpected = sanitizeUrl(serverUrl);
|
||||
const normalizedActual = sanitizeUrl(srvParam);
|
||||
return normalizedExpected === normalizedActual;
|
||||
}, [serverUrl]);
|
||||
const init = useCallback(async (resetErrors = true) => {
|
||||
setLoginSuccess(false);
|
||||
if (resetErrors !== false) {
|
||||
|
|
@ -62,6 +75,19 @@ const SSOAuthentication = ({doSSOLogin, doSSOCodeExchange, loginError, loginUrl,
|
|||
const result = await openAuthSessionAsync(url, null, {preferEphemeralSession: true, createTask: false});
|
||||
if ('url' in result && result.url) {
|
||||
const resultUrl = urlParse(result.url, true);
|
||||
const srvParam = resultUrl.query?.srv as string | undefined;
|
||||
|
||||
// Verify server origin before accepting credentials
|
||||
if (!verifyServerOrigin(srvParam)) {
|
||||
setError(
|
||||
intl.formatMessage({
|
||||
id: 'mobile.oauth.server_mismatch',
|
||||
defaultMessage: 'Login failed: Unable to complete authentication with this server. Please try again.',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const loginCode = resultUrl.query?.login_code as string | undefined;
|
||||
if (loginCode) {
|
||||
// Prefer code exchange when available
|
||||
|
|
@ -83,7 +109,7 @@ const SSOAuthentication = ({doSSOLogin, doSSOCodeExchange, loginError, loginUrl,
|
|||
}),
|
||||
);
|
||||
}
|
||||
}, [doSSOCodeExchange, doSSOLogin, intl, loginUrl, samlChallenge, redirectUrl, setLoginError]);
|
||||
}, [doSSOCodeExchange, doSSOLogin, intl, loginUrl, samlChallenge, redirectUrl, setLoginError, verifyServerOrigin]);
|
||||
|
||||
useEffect(() => {
|
||||
let listener: EventSubscription | null = null;
|
||||
|
|
@ -93,6 +119,19 @@ const SSOAuthentication = ({doSSOLogin, doSSOCodeExchange, loginError, loginUrl,
|
|||
setError('');
|
||||
if (url && url.startsWith(redirectUrl)) {
|
||||
const parsedUrl = urlParse(url, true);
|
||||
const srvParam = parsedUrl.query?.srv as string | undefined;
|
||||
|
||||
// Verify server origin before accepting credentials
|
||||
if (!verifyServerOrigin(srvParam)) {
|
||||
setError(
|
||||
intl.formatMessage({
|
||||
id: 'mobile.oauth.server_mismatch',
|
||||
defaultMessage: 'Login failed: Unable to complete authentication with this server. Please try again.',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const loginCode = parsedUrl.query?.login_code as string | undefined;
|
||||
if (loginCode) {
|
||||
setLoginSuccess(true);
|
||||
|
|
@ -126,7 +165,7 @@ const SSOAuthentication = ({doSSOLogin, doSSOCodeExchange, loginError, loginUrl,
|
|||
clearTimeout(timeout);
|
||||
listener?.remove();
|
||||
};
|
||||
}, [doSSOCodeExchange, doSSOLogin, init, intl, samlChallenge, redirectUrl]);
|
||||
}, [doSSOCodeExchange, doSSOLogin, init, intl, samlChallenge, redirectUrl, verifyServerOrigin]);
|
||||
|
||||
let content;
|
||||
if (loginSuccess) {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {isBetaApp} from '@utils/general';
|
|||
import {createSamlChallenge} from '@utils/saml_challenge';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
import {tryOpenURL} from '@utils/url';
|
||||
import {sanitizeUrl, tryOpenURL} from '@utils/url';
|
||||
|
||||
import AuthError from './components/auth_error';
|
||||
import AuthRedirect from './components/auth_redirect';
|
||||
|
|
@ -24,6 +24,7 @@ interface SSOWithRedirectURLProps {
|
|||
doSSOCodeExchange: (loginCode: string, samlChallenge: {codeVerifier: string; state: string}) => void;
|
||||
loginError: string;
|
||||
loginUrl: string;
|
||||
serverUrl: string;
|
||||
setLoginError: (value: string) => void;
|
||||
theme: Theme;
|
||||
}
|
||||
|
|
@ -59,7 +60,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
|||
};
|
||||
});
|
||||
|
||||
const SSOAuthenticationWithExternalBrowser = ({doSSOLogin, doSSOCodeExchange, loginError, loginUrl, setLoginError, theme}: SSOWithRedirectURLProps) => {
|
||||
const SSOAuthenticationWithExternalBrowser = ({doSSOLogin, doSSOCodeExchange, loginError, loginUrl, serverUrl, setLoginError, theme}: SSOWithRedirectURLProps) => {
|
||||
const [error, setError] = useState<string>('');
|
||||
const [loginSuccess, setLoginSuccess] = useState(false);
|
||||
const style = getStyleSheet(theme);
|
||||
|
|
@ -71,6 +72,17 @@ const SSOAuthenticationWithExternalBrowser = ({doSSOLogin, doSSOCodeExchange, lo
|
|||
|
||||
const redirectUrl = customUrlScheme + 'callback';
|
||||
const samlChallenge = useMemo(() => createSamlChallenge(), []);
|
||||
|
||||
// Verify that the srv parameter from the callback matches the expected server
|
||||
const verifyServerOrigin = useCallback((srvParam: string | undefined): boolean => {
|
||||
if (!srvParam) {
|
||||
// Old servers don't send srv parameter - allow for backwards compatibility
|
||||
return true;
|
||||
}
|
||||
const normalizedExpected = sanitizeUrl(serverUrl);
|
||||
const normalizedActual = sanitizeUrl(srvParam);
|
||||
return normalizedExpected === normalizedActual;
|
||||
}, [serverUrl]);
|
||||
const init = useCallback((resetErrors = true) => {
|
||||
setLoginSuccess(false);
|
||||
if (resetErrors !== false) {
|
||||
|
|
@ -114,6 +126,19 @@ const SSOAuthenticationWithExternalBrowser = ({doSSOLogin, doSSOCodeExchange, lo
|
|||
const onURLChange = ({url}: { url: string }) => {
|
||||
if (url && url.startsWith(redirectUrl)) {
|
||||
const parsedUrl = urlParse(url, true);
|
||||
const srvParam = parsedUrl.query?.srv as string | undefined;
|
||||
|
||||
// Verify server origin before accepting credentials
|
||||
if (!verifyServerOrigin(srvParam)) {
|
||||
setError(
|
||||
intl.formatMessage({
|
||||
id: 'mobile.oauth.server_mismatch',
|
||||
defaultMessage: 'Login failed: Unable to complete authentication with this server. Please try again.',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const loginCode = parsedUrl.query?.login_code as string | undefined;
|
||||
if (loginCode) {
|
||||
setLoginSuccess(true);
|
||||
|
|
@ -148,7 +173,7 @@ const SSOAuthenticationWithExternalBrowser = ({doSSOLogin, doSSOCodeExchange, lo
|
|||
listener.remove();
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [doSSOCodeExchange, doSSOLogin, init, intl, samlChallenge, redirectUrl]);
|
||||
}, [doSSOCodeExchange, doSSOLogin, init, intl, samlChallenge, redirectUrl, verifyServerOrigin]);
|
||||
|
||||
let content;
|
||||
if (loginSuccess) {
|
||||
|
|
|
|||
|
|
@ -2,26 +2,26 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import {uniqueId} from 'lodash';
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
import {type LayoutChangeEvent, StyleSheet, View} from 'react-native';
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {Platform, type LayoutChangeEvent, StyleSheet} from 'react-native';
|
||||
import {KeyboardProvider} from 'react-native-keyboard-controller';
|
||||
import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
|
||||
|
||||
import {storeLastViewedThreadIdAndServer, removeLastViewedThreadIdAndServer} from '@actions/app/global';
|
||||
import FloatingCallContainer from '@calls/components/floating_call_container';
|
||||
import FreezeScreen from '@components/freeze_screen';
|
||||
import PostDraft from '@components/post_draft';
|
||||
import RoundedHeaderContext from '@components/rounded_header_context';
|
||||
import ScheduledPostIndicator from '@components/scheduled_post_indicator';
|
||||
import {Screens} from '@constants';
|
||||
import {ExtraKeyboardProvider} from '@context/extra_keyboard';
|
||||
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import useDidUpdate from '@hooks/did_update';
|
||||
import {useIsScreenVisible} from '@hooks/use_screen_visibility';
|
||||
import SecurityManager from '@managers/security_manager';
|
||||
import {popTopScreen, setButtons} from '@screens/navigation';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
import NavigationStore from '@store/navigation_store';
|
||||
|
||||
import ThreadPostList from './thread_post_list';
|
||||
import ThreadContent from './thread_content';
|
||||
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
|
@ -37,8 +37,6 @@ type ThreadProps = {
|
|||
scheduledPostCount: number;
|
||||
};
|
||||
|
||||
const edges: Edge[] = ['left', 'right'];
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {flex: 1},
|
||||
});
|
||||
|
|
@ -54,6 +52,20 @@ const Thread = ({
|
|||
scheduledPostCount,
|
||||
}: ThreadProps) => {
|
||||
const [containerHeight, setContainerHeight] = useState(0);
|
||||
const isVisible = useIsScreenVisible(componentId);
|
||||
const isTablet = useIsTablet();
|
||||
const [isEmojiSearchFocused, setIsEmojiSearchFocused] = useState(false);
|
||||
|
||||
// Remove bottom safe area when emoji search is focused to eliminate gap between emoji picker and keyboard
|
||||
const safeAreaViewEdges: Edge[] = useMemo(() => {
|
||||
if (isTablet) {
|
||||
return ['left', 'right'];
|
||||
}
|
||||
if (isEmojiSearchFocused) {
|
||||
return ['left', 'right'];
|
||||
}
|
||||
return ['left', 'right', 'bottom'];
|
||||
}, [isTablet, isEmojiSearchFocused]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
popTopScreen(componentId);
|
||||
|
|
@ -96,7 +108,7 @@ const Thread = ({
|
|||
}
|
||||
setButtons(componentId, {rightButtons: []});
|
||||
};
|
||||
}, [rootId]);
|
||||
}, [rootId, componentId, isCRTEnabled]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
if (!rootPost) {
|
||||
|
|
@ -115,37 +127,34 @@ const Thread = ({
|
|||
<SafeAreaView
|
||||
style={styles.flex}
|
||||
mode='margin'
|
||||
edges={edges}
|
||||
edges={safeAreaViewEdges}
|
||||
testID='thread.screen'
|
||||
onLayout={onLayout}
|
||||
nativeID={SecurityManager.getShieldScreenId(componentId)}
|
||||
>
|
||||
<RoundedHeaderContext/>
|
||||
{Boolean(rootPost) &&
|
||||
<ExtraKeyboardProvider>
|
||||
<View style={styles.flex}>
|
||||
<ThreadPostList
|
||||
nativeID={rootId}
|
||||
(Platform.OS === 'ios' ? (
|
||||
<KeyboardProvider>
|
||||
<ThreadContent
|
||||
rootId={rootId}
|
||||
rootPost={rootPost!}
|
||||
scheduledPostCount={scheduledPostCount}
|
||||
containerHeight={containerHeight}
|
||||
enabled={isVisible}
|
||||
onEmojiSearchFocusChange={setIsEmojiSearchFocused}
|
||||
/>
|
||||
</View>
|
||||
<>
|
||||
{scheduledPostCount > 0 &&
|
||||
<ScheduledPostIndicator
|
||||
isThread={true}
|
||||
scheduledPostCount={scheduledPostCount}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
<PostDraft
|
||||
channelId={rootPost!.channelId}
|
||||
</KeyboardProvider>
|
||||
) : (
|
||||
<ThreadContent
|
||||
rootId={rootId}
|
||||
testID='thread.post_draft'
|
||||
rootPost={rootPost!}
|
||||
scheduledPostCount={scheduledPostCount}
|
||||
containerHeight={containerHeight}
|
||||
isChannelScreen={false}
|
||||
location={Screens.THREAD}
|
||||
enabled={isVisible}
|
||||
onEmojiSearchFocusChange={setIsEmojiSearchFocused}
|
||||
/>
|
||||
</ExtraKeyboardProvider>
|
||||
))
|
||||
}
|
||||
{showFloatingCallContainer &&
|
||||
<FloatingCallContainer
|
||||
|
|
|
|||
82
app/screens/thread/thread_content.tsx
Normal file
82
app/screens/thread/thread_content.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {PortalProvider} from '@gorhom/portal';
|
||||
import React from 'react';
|
||||
import {StyleSheet} from 'react-native';
|
||||
|
||||
import {KeyboardAwarePostDraftContainer} from '@components/keyboard_aware_post_draft_container';
|
||||
import PostDraft from '@components/post_draft';
|
||||
import ScheduledPostIndicator from '@components/scheduled_post_indicator';
|
||||
import {Screens} from '@constants';
|
||||
|
||||
import ThreadPostList from './thread_post_list';
|
||||
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
|
||||
type ThreadContentProps = {
|
||||
rootId: string;
|
||||
rootPost: PostModel;
|
||||
scheduledPostCount: number;
|
||||
containerHeight: number;
|
||||
enabled?: boolean;
|
||||
onEmojiSearchFocusChange?: (focused: boolean) => void;
|
||||
}
|
||||
|
||||
const THREAD_POST_DRAFT_TESTID = 'thread.post_draft';
|
||||
|
||||
// This follows the same pattern as draft_input.tsx: `${testID}.post.input`
|
||||
const THREAD_POST_INPUT_NATIVE_ID = `${THREAD_POST_DRAFT_TESTID}.post.input`;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const ThreadContent = ({
|
||||
rootId,
|
||||
rootPost,
|
||||
scheduledPostCount,
|
||||
containerHeight,
|
||||
enabled = true,
|
||||
onEmojiSearchFocusChange,
|
||||
}: ThreadContentProps) => {
|
||||
return (
|
||||
<PortalProvider>
|
||||
<KeyboardAwarePostDraftContainer
|
||||
textInputNativeID={THREAD_POST_INPUT_NATIVE_ID}
|
||||
containerStyle={styles.flex}
|
||||
isThreadView={true}
|
||||
enabled={enabled}
|
||||
onEmojiSearchFocusChange={onEmojiSearchFocusChange}
|
||||
renderList={({listRef, onTouchMove, onTouchEnd}) => (
|
||||
<ThreadPostList
|
||||
rootPost={rootPost}
|
||||
listRef={listRef}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{scheduledPostCount > 0 &&
|
||||
<ScheduledPostIndicator
|
||||
isThread={true}
|
||||
scheduledPostCount={scheduledPostCount}
|
||||
/>
|
||||
}
|
||||
<PostDraft
|
||||
channelId={rootPost.channelId}
|
||||
rootId={rootId}
|
||||
testID={THREAD_POST_DRAFT_TESTID}
|
||||
containerHeight={containerHeight}
|
||||
isChannelScreen={false}
|
||||
location={Screens.THREAD}
|
||||
/>
|
||||
</KeyboardAwarePostDraftContainer>
|
||||
</PortalProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThreadContent;
|
||||
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
|
||||
import {ActivityIndicator, StyleSheet, View} from 'react-native';
|
||||
import {ActivityIndicator, type FlatList, type GestureResponderEvent, StyleSheet, View} from 'react-native';
|
||||
|
||||
import {fetchPostThread} from '@actions/remote/post';
|
||||
import {markThreadAsRead} from '@actions/remote/thread';
|
||||
|
|
@ -22,12 +22,14 @@ import type ThreadModel from '@typings/database/models/servers/thread';
|
|||
type Props = {
|
||||
channelLastViewedAt: number;
|
||||
isCRTEnabled: boolean;
|
||||
nativeID: string;
|
||||
posts: PostModel[];
|
||||
rootPost: PostModel;
|
||||
teamId: string;
|
||||
thread?: ThreadModel;
|
||||
version?: string;
|
||||
listRef: React.RefObject<FlatList<string | PostModel>>;
|
||||
onTouchMove?: (event: GestureResponderEvent) => void;
|
||||
onTouchEnd?: () => void;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
|
@ -38,7 +40,8 @@ const styles = StyleSheet.create({
|
|||
|
||||
const ThreadPostList = ({
|
||||
channelLastViewedAt, isCRTEnabled,
|
||||
nativeID, posts, rootPost, teamId, thread, version,
|
||||
posts, rootPost, teamId, thread, version,
|
||||
listRef, onTouchMove, onTouchEnd,
|
||||
}: Props) => {
|
||||
const appState = useAppState();
|
||||
const serverUrl = useServerUrl();
|
||||
|
|
@ -103,7 +106,6 @@ const ThreadPostList = ({
|
|||
isCRTEnabled={isCRTEnabled}
|
||||
lastViewedAt={lastViewedAt}
|
||||
location={Screens.THREAD}
|
||||
nativeID={nativeID}
|
||||
onEndReached={onEndReached}
|
||||
posts={threadPosts}
|
||||
rootId={rootPost.id}
|
||||
|
|
@ -112,6 +114,9 @@ const ThreadPostList = ({
|
|||
header={header}
|
||||
footer={<View style={styles.footer}/>}
|
||||
testID='thread.post_list'
|
||||
listRef={listRef}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -83,6 +83,13 @@ class NavigationStoreSingleton {
|
|||
if (index > -1) {
|
||||
this.modalsInStack.splice(index, 1);
|
||||
}
|
||||
|
||||
// Restore the previous screen as visible when modal is dismissed
|
||||
// This ensures that the underlying screen (e.g., Channel/Thread) becomes enabled again
|
||||
const visibleScreen = this.screensInStack[0];
|
||||
if (visibleScreen) {
|
||||
this.subject.next(visibleScreen);
|
||||
}
|
||||
};
|
||||
|
||||
setToSOpen = (open: boolean) => {
|
||||
|
|
|
|||
163
app/utils/grapheme.test.ts
Normal file
163
app/utils/grapheme.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {deleteLastGrapheme} from './grapheme';
|
||||
|
||||
describe('deleteLastGrapheme', () => {
|
||||
describe('Simple ASCII characters', () => {
|
||||
it('should delete a single character', () => {
|
||||
const result = deleteLastGrapheme('Hello', 5);
|
||||
expect(result.text).toBe('Hell');
|
||||
expect(result.cursorPosition).toBe(4);
|
||||
});
|
||||
|
||||
it('should delete character in the middle', () => {
|
||||
const result = deleteLastGrapheme('Hello World', 5);
|
||||
expect(result.text).toBe('Hell World');
|
||||
expect(result.cursorPosition).toBe(4);
|
||||
});
|
||||
|
||||
it('should delete character at the start', () => {
|
||||
const result = deleteLastGrapheme('Hello', 1);
|
||||
expect(result.text).toBe('ello');
|
||||
expect(result.cursorPosition).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Basic emojis', () => {
|
||||
it('should delete a simple emoji', () => {
|
||||
const result = deleteLastGrapheme('Hello 😀', 8);
|
||||
expect(result.text).toBe('Hello ');
|
||||
expect(result.cursorPosition).toBe(6);
|
||||
});
|
||||
|
||||
it('should delete emoji in the middle', () => {
|
||||
const result = deleteLastGrapheme('😀 World', 2);
|
||||
expect(result.text).toBe(' World');
|
||||
expect(result.cursorPosition).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete emoji at the start', () => {
|
||||
const result = deleteLastGrapheme('😀 Hello', 2);
|
||||
expect(result.text).toBe(' Hello');
|
||||
expect(result.cursorPosition).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete multiple emojis correctly', () => {
|
||||
const result = deleteLastGrapheme('😀❤️🎉', 6);
|
||||
expect(result.text).toBe('😀❤️');
|
||||
expect(result.cursorPosition).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex Unicode characters', () => {
|
||||
it('should delete flag emoji (🇺🇸)', () => {
|
||||
const result = deleteLastGrapheme('Hello 🇺🇸', 10);
|
||||
expect(result.text).toBe('Hello ');
|
||||
expect(result.cursorPosition).toBe(6);
|
||||
});
|
||||
|
||||
it('should delete flag emoji at start', () => {
|
||||
const result = deleteLastGrapheme('🇺🇸 Hello', 4);
|
||||
expect(result.text).toBe(' Hello');
|
||||
expect(result.cursorPosition).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete family emoji (👨👩👧👦)', () => {
|
||||
const result = deleteLastGrapheme('Family 👨👩👧👦', 18);
|
||||
expect(result.text).toBe('Family ');
|
||||
expect(result.cursorPosition).toBe(7);
|
||||
});
|
||||
|
||||
it('should delete emoji with skin tone modifier (👨🏿)', () => {
|
||||
const result = deleteLastGrapheme('Hello 👨🏿', 10);
|
||||
expect(result.text).toBe('Hello ');
|
||||
expect(result.cursorPosition).toBe(6);
|
||||
});
|
||||
|
||||
it('should delete emoji with zero-width joiner', () => {
|
||||
const result = deleteLastGrapheme('👨👩👧👦', 11);
|
||||
expect(result.text).toBe('');
|
||||
expect(result.cursorPosition).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mixed text and emojis', () => {
|
||||
it('should delete emoji from mixed content', () => {
|
||||
const result = deleteLastGrapheme('Hello 😀 World', 8);
|
||||
expect(result.text).toBe('Hello World');
|
||||
expect(result.cursorPosition).toBe(6);
|
||||
});
|
||||
|
||||
it('should delete text character before emoji', () => {
|
||||
const result = deleteLastGrapheme('Hello😀', 5);
|
||||
expect(result.text).toBe('Hell😀');
|
||||
expect(result.cursorPosition).toBe(4);
|
||||
});
|
||||
|
||||
it('should delete emoji between text', () => {
|
||||
const result = deleteLastGrapheme('Hello 😀 World', 8);
|
||||
expect(result.text).toBe('Hello World');
|
||||
expect(result.cursorPosition).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty string', () => {
|
||||
const result = deleteLastGrapheme('', 0);
|
||||
expect(result.text).toBe('');
|
||||
expect(result.cursorPosition).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle cursor at start', () => {
|
||||
const result = deleteLastGrapheme('Hello', 0);
|
||||
expect(result.text).toBe('Hello');
|
||||
expect(result.cursorPosition).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle cursor beyond text length', () => {
|
||||
const result = deleteLastGrapheme('Hello', 10);
|
||||
expect(result.text).toBe('Hell');
|
||||
expect(result.cursorPosition).toBe(4);
|
||||
});
|
||||
|
||||
it('should handle single character', () => {
|
||||
const result = deleteLastGrapheme('H', 1);
|
||||
expect(result.text).toBe('');
|
||||
expect(result.cursorPosition).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle single emoji', () => {
|
||||
const result = deleteLastGrapheme('😀', 2);
|
||||
expect(result.text).toBe('');
|
||||
expect(result.cursorPosition).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle only whitespace', () => {
|
||||
const result = deleteLastGrapheme(' ', 2);
|
||||
expect(result.text).toBe(' ');
|
||||
expect(result.cursorPosition).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-world scenarios', () => {
|
||||
it('should handle message with emojis', () => {
|
||||
const result = deleteLastGrapheme('Great job! 🎉👏', 15);
|
||||
expect(result.text).toBe('Great job! 🎉');
|
||||
expect(result.cursorPosition).toBe(13);
|
||||
});
|
||||
|
||||
it('should handle message with flag', () => {
|
||||
const result = deleteLastGrapheme('From 🇺🇸', 9);
|
||||
expect(result.text).toBe('From ');
|
||||
expect(result.cursorPosition).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle message with mixed content', () => {
|
||||
const result = deleteLastGrapheme('Hello 👨👩👧👦 family!', 17);
|
||||
expect(result.text).toBe('Hello family!');
|
||||
expect(result.cursorPosition).toBe(6);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
43
app/utils/grapheme.ts
Normal file
43
app/utils/grapheme.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import GraphemeSplitter from 'grapheme-splitter';
|
||||
|
||||
/**
|
||||
* Deletes the last grapheme cluster (character) from a string before the cursor position.
|
||||
* Handles complex Unicode characters like emojis, flags, and modifiers correctly.
|
||||
*
|
||||
* @param text - The text to delete from
|
||||
* @param cursorPosition - The cursor position (0-based index)
|
||||
* @returns Object with updated text and new cursor position
|
||||
*/
|
||||
export function deleteLastGrapheme(text: string, cursorPosition: number): {
|
||||
text: string;
|
||||
cursorPosition: number;
|
||||
} {
|
||||
if (cursorPosition === 0) {
|
||||
return {text, cursorPosition: 0};
|
||||
}
|
||||
|
||||
const adjustedCursorPosition = cursorPosition > text.length ? text.length : cursorPosition;
|
||||
const splitter = new GraphemeSplitter();
|
||||
const textBeforeCursor = text.slice(0, adjustedCursorPosition);
|
||||
const clusters = splitter.splitGraphemes(textBeforeCursor);
|
||||
|
||||
if (clusters.length === 0) {
|
||||
return {text, cursorPosition};
|
||||
}
|
||||
|
||||
// Remove last grapheme cluster
|
||||
clusters.pop();
|
||||
|
||||
const joinedClusters = clusters.join('');
|
||||
const updatedText = joinedClusters + text.slice(adjustedCursorPosition);
|
||||
const newCursorPosition = joinedClusters.length;
|
||||
|
||||
return {
|
||||
text: updatedText,
|
||||
cursorPosition: newCursorPosition,
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue