Compare commits
88 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f013531d65 | |||
| cb659b1b11 | |||
|
|
e986f4ef9c | ||
|
|
27b8c9b890 | ||
|
|
dce5874400 | ||
| 1d4a8d5b87 | |||
| 1adf27c6a3 | |||
| 6489096ec6 | |||
| 830eb54f09 | |||
|
|
3319aec473 | ||
|
|
82e5ff2ae8 | ||
|
|
5365109ca8 | ||
|
|
6000e69886 | ||
|
|
6f0d538dd7 | ||
|
|
3b3bea56c4 | ||
|
|
1902340017 | ||
|
|
9e30f87a98 | ||
|
|
4b625e3517 | ||
|
|
058b87ee4d | ||
|
|
b4798e78cc | ||
|
|
e977303275 | ||
|
|
c391484cc8 | ||
|
|
1a01c532a1 | ||
|
|
c313f7712f | ||
|
|
46d46790b5 | ||
|
|
7afdaddd2b | ||
|
|
1cea8c6f0c | ||
|
|
c3f83f9028 | ||
|
|
1757654882 | ||
|
|
0a3e701ba5 | ||
|
|
9e0735c00d | ||
|
|
cef108db68 | ||
|
|
d0cc38d549 | ||
|
|
cff12f7182 | ||
|
|
36439bd5f4 | ||
|
|
058d080e9a | ||
|
|
9793383832 | ||
|
|
3735aa6ff8 | ||
|
|
714c3dc769 | ||
|
|
1db5cd456c | ||
|
|
2bf0ffc95f | ||
|
|
dbcf2d9bd5 | ||
|
|
12c8d80f4a | ||
|
|
9699318043 | ||
|
|
f9001ec7cd | ||
|
|
616483edbf | ||
|
|
bf3f1d5241 | ||
|
|
23565b5135 | ||
|
|
61f10310dc | ||
|
|
95d50125e4 | ||
|
|
121980c7e5 | ||
|
|
04de7b9745 | ||
|
|
fe621047ce | ||
|
|
717ea47735 | ||
|
|
4611a350e0 | ||
|
|
882c2ec57b | ||
|
|
a9258543bb | ||
|
|
8732cb9430 | ||
|
|
83e5b6026e | ||
|
|
462d6d5052 | ||
|
|
6b0437a6e3 | ||
|
|
205b7a4aea | ||
|
|
fdc559d70d | ||
|
|
d014856a3a | ||
|
|
fbaf13c7aa | ||
|
|
ca117805be | ||
|
|
ab9d66c8b6 | ||
|
|
20a70ae64d | ||
|
|
98bf396714 | ||
|
|
71d67f27d0 | ||
|
|
a3dc1a1bdc | ||
|
|
6fb109faf1 | ||
|
|
cbf300ffd3 | ||
|
|
678f1b1184 | ||
|
|
bd1dcef010 | ||
|
|
3f284dec55 | ||
|
|
855a6b8e26 | ||
|
|
944a542110 | ||
|
|
e436e1992e | ||
|
|
4324846736 | ||
|
|
c0fc0c868f | ||
|
|
cb3fcc9651 | ||
|
|
5e4b81f77a | ||
|
|
7c8d63ef28 | ||
|
|
97db6d1cc7 | ||
|
|
90135d8580 | ||
|
|
a3248d2a41 | ||
|
|
e39b27be7e |
|
|
@ -52,7 +52,7 @@ runs:
|
||||||
- name: ci/install-pods-dependencies
|
- name: ci/install-pods-dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
INTUNE_ENABLED: ${{ inputs.intune-enabled == 'true' && '1' || '0' }}
|
INTUNE_ENABLED: ${{ inputs.intune-enabled }}
|
||||||
run: |
|
run: |
|
||||||
echo "::group::install-pods-dependencies"
|
echo "::group::install-pods-dependencies"
|
||||||
echo "INTUNE_ENABLED=$INTUNE_ENABLED"
|
echo "INTUNE_ENABLED=$INTUNE_ENABLED"
|
||||||
|
|
|
||||||
21
.github/workflows/build-android-beta.yml
vendored
|
|
@ -24,6 +24,9 @@ jobs:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs:
|
needs:
|
||||||
- test
|
- test
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: ci/checkout-repo
|
- name: ci/checkout-repo
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
@ -58,3 +61,21 @@ jobs:
|
||||||
with:
|
with:
|
||||||
name: android-build-beta-${{ github.run_id }}
|
name: android-build-beta-${{ github.run_id }}
|
||||||
path: "*.apk"
|
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
|
|
@ -24,6 +24,9 @@ jobs:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs:
|
needs:
|
||||||
- test
|
- test
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: ci/checkout-repo
|
- name: ci/checkout-repo
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
@ -58,3 +61,19 @@ jobs:
|
||||||
with:
|
with:
|
||||||
name: android-build-release-${{ github.run_id }}
|
name: android-build-release-${{ github.run_id }}
|
||||||
path: "*.apk"
|
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
|
|
@ -11,7 +11,7 @@ on:
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 22.14.0
|
NODE_VERSION: 22.14.0
|
||||||
TERM: xterm
|
TERM: xterm
|
||||||
INTUNE_ENABLED: 1
|
INTUNE_ENABLED: 'true'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|
@ -27,6 +27,9 @@ jobs:
|
||||||
if: ${{ !contains(github.ref_name, 'beta-ios') }}
|
if: ${{ !contains(github.ref_name, 'beta-ios') }}
|
||||||
needs:
|
needs:
|
||||||
- test
|
- test
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: ci/checkout-repo
|
- name: ci/checkout-repo
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
@ -34,7 +37,7 @@ jobs:
|
||||||
- name: ci/prepare-ios-build
|
- name: ci/prepare-ios-build
|
||||||
uses: ./.github/actions/prepare-ios-build
|
uses: ./.github/actions/prepare-ios-build
|
||||||
with:
|
with:
|
||||||
intune-enabled: 'true'
|
intune-enabled: ${{ env.INTUNE_ENABLED }}
|
||||||
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||||
|
|
||||||
- name: ci/build-ios-simulator
|
- name: ci/build-ios-simulator
|
||||||
|
|
@ -53,11 +56,30 @@ jobs:
|
||||||
name: ios-build-simulator-${{ github.run_id }}
|
name: ios-build-simulator-${{ github.run_id }}
|
||||||
path: Mattermost-simulator-x86_64.app.zip
|
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:
|
build-and-deploy-ios-beta:
|
||||||
runs-on: macos-15-large
|
runs-on: macos-15-large
|
||||||
if: ${{ !contains(github.ref_name, 'beta-sim') }}
|
if: ${{ !contains(github.ref_name, 'beta-sim') }}
|
||||||
needs:
|
needs:
|
||||||
- test
|
- test
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: ci/checkout-repo
|
- name: ci/checkout-repo
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
@ -70,7 +92,7 @@ jobs:
|
||||||
- name: ci/prepare-ios-build
|
- name: ci/prepare-ios-build
|
||||||
uses: ./.github/actions/prepare-ios-build
|
uses: ./.github/actions/prepare-ios-build
|
||||||
with:
|
with:
|
||||||
intune-enabled: 'true'
|
intune-enabled: ${{ env.INTUNE_ENABLED }}
|
||||||
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||||
|
|
||||||
- name: ci/build-and-deploy-ios-beta
|
- name: ci/build-and-deploy-ios-beta
|
||||||
|
|
@ -100,3 +122,20 @@ jobs:
|
||||||
with:
|
with:
|
||||||
name: ios-build-beta-${{ github.run_id }}
|
name: ios-build-beta-${{ github.run_id }}
|
||||||
path: "*.ipa"
|
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
|
|
@ -11,7 +11,7 @@ on:
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 22.14.0
|
NODE_VERSION: 22.14.0
|
||||||
TERM: xterm
|
TERM: xterm
|
||||||
INTUNE_ENABLED: 1
|
INTUNE_ENABLED: 'true'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|
@ -27,6 +27,9 @@ jobs:
|
||||||
if: ${{ !contains(github.ref_name, 'release-sim') }}
|
if: ${{ !contains(github.ref_name, 'release-sim') }}
|
||||||
needs:
|
needs:
|
||||||
- test
|
- test
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: ci/checkout-repo
|
- name: ci/checkout-repo
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
@ -39,13 +42,14 @@ jobs:
|
||||||
- name: ci/prepare-ios-build
|
- name: ci/prepare-ios-build
|
||||||
uses: ./.github/actions/prepare-ios-build
|
uses: ./.github/actions/prepare-ios-build
|
||||||
with:
|
with:
|
||||||
intune-enabled: 'true'
|
intune-enabled: ${{ env.INTUNE_ENABLED }}
|
||||||
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||||
|
|
||||||
- name: ci/build-and-deploy-ios-release
|
- name: ci/build-and-deploy-ios-release
|
||||||
env:
|
env:
|
||||||
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_RELEASE_AWS_ACCESS_KEY_ID }}"
|
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_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 }}"
|
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_RELEASE_MATTERMOST_WEBHOOK_URL }}"
|
||||||
FASTLANE_TEAM_ID: "${{ secrets.MM_MOBILE_FASTLANE_TEAM_ID }}"
|
FASTLANE_TEAM_ID: "${{ secrets.MM_MOBILE_FASTLANE_TEAM_ID }}"
|
||||||
IOS_API_ISSUER_ID: "${{ secrets.MM_MOBILE_IOS_API_ISSUER_ID }}"
|
IOS_API_ISSUER_ID: "${{ secrets.MM_MOBILE_IOS_API_ISSUER_ID }}"
|
||||||
|
|
@ -70,11 +74,31 @@ jobs:
|
||||||
name: ios-build-release-${{ github.run_id }}
|
name: ios-build-release-${{ github.run_id }}
|
||||||
path: "*.ipa"
|
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:
|
build-ios-simulator:
|
||||||
runs-on: macos-15-large
|
runs-on: macos-15-large
|
||||||
if: ${{ !contains(github.ref_name , 'release-ios') }}
|
if: ${{ !contains(github.ref_name , 'release-ios') }}
|
||||||
needs:
|
needs:
|
||||||
- test
|
- test
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: ci/checkout-repo
|
- name: ci/checkout-repo
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
@ -82,7 +106,7 @@ jobs:
|
||||||
- name: ci/prepare-ios-build
|
- name: ci/prepare-ios-build
|
||||||
uses: ./.github/actions/prepare-ios-build
|
uses: ./.github/actions/prepare-ios-build
|
||||||
with:
|
with:
|
||||||
intune-enabled: 'true'
|
intune-enabled: ${{ env.INTUNE_ENABLED }}
|
||||||
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||||
|
|
||||||
- name: ci/build-ios-simulator
|
- name: ci/build-ios-simulator
|
||||||
|
|
@ -100,3 +124,18 @@ jobs:
|
||||||
with:
|
with:
|
||||||
name: ios-build-simulator-${{ github.run_id }}
|
name: ios-build-simulator-${{ github.run_id }}
|
||||||
path: Mattermost-simulator-x86_64.app.zip
|
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 }}"
|
||||||
|
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
|
|
@ -8,7 +8,7 @@ on:
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 22.14.0
|
NODE_VERSION: 22.14.0
|
||||||
TERM: xterm
|
TERM: xterm
|
||||||
INTUNE_ENABLED: 1
|
INTUNE_ENABLED: 'true'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|
@ -41,7 +41,7 @@ jobs:
|
||||||
- name: ci/prepare-ios-build
|
- name: ci/prepare-ios-build
|
||||||
uses: ./.github/actions/prepare-ios-build
|
uses: ./.github/actions/prepare-ios-build
|
||||||
with:
|
with:
|
||||||
intune-enabled: 'true'
|
intune-enabled: ${{ env.INTUNE_ENABLED }}
|
||||||
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||||
|
|
||||||
- name: ci/build-ios-pr
|
- name: ci/build-ios-pr
|
||||||
|
|
|
||||||
2
.github/workflows/e2e-android-template.yml
vendored
|
|
@ -127,7 +127,7 @@ jobs:
|
||||||
device_os_version: ${{ env.SDK_VERSION }}
|
device_os_version: ${{ env.SDK_VERSION }}
|
||||||
|
|
||||||
e2e-android:
|
e2e-android:
|
||||||
name: android-detox-e2e-${{ matrix.runId }}-${{ matrix.deviceName }}-${{ matrix.deviceOsVersion }}
|
name: machine-${{ matrix.runId }}-api-${{ matrix.deviceOsVersion }}
|
||||||
runs-on: ubuntu-latest-8-cores
|
runs-on: ubuntu-latest-8-cores
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
timeout-minutes: 240
|
timeout-minutes: 240
|
||||||
|
|
|
||||||
23
.github/workflows/e2e-detox-pr.yml
vendored
|
|
@ -1,12 +1,11 @@
|
||||||
# Can be used to run Detox E2E tests on pull requests for the Mattermost mobile app with low bandwidth
|
# Can be used to run Detox E2E tests on pull requests for the Mattermost mobile app with low bandwidth
|
||||||
# by using 'E2E iOS tests for PR (LBW 1)' instead.
|
# by using 'E2E iOS tests for PR (LBW 1)' instead.
|
||||||
name: Detox E2E Tests PR
|
name: E2E
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- feature_schedule_posts
|
|
||||||
types:
|
types:
|
||||||
- labeled
|
- labeled
|
||||||
|
|
||||||
|
|
@ -15,7 +14,7 @@ concurrency:
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
INTUNE_ENABLED: 1
|
INTUNE_ENABLED: 'true'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-initial-status-ios:
|
update-initial-status-ios:
|
||||||
|
|
@ -49,7 +48,7 @@ jobs:
|
||||||
|
|
||||||
build-ios-simulator:
|
build-ios-simulator:
|
||||||
if: contains(github.event.label.name, 'E2E iOS tests for PR')
|
if: contains(github.event.label.name, 'E2E iOS tests for PR')
|
||||||
runs-on: macos-15
|
runs-on: macos-26
|
||||||
needs:
|
needs:
|
||||||
- update-initial-status-ios
|
- update-initial-status-ios
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -59,23 +58,25 @@ jobs:
|
||||||
- name: Prepare iOS Build
|
- name: Prepare iOS Build
|
||||||
uses: ./.github/actions/prepare-ios-build
|
uses: ./.github/actions/prepare-ios-build
|
||||||
with:
|
with:
|
||||||
intune-enabled: 'true'
|
intune-enabled: ${{ env.INTUNE_ENABLED }}
|
||||||
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||||
|
|
||||||
|
- name: Set .env with RUNNING_E2E=true
|
||||||
|
run: |
|
||||||
|
echo "RUNNING_E2E=true" > .env
|
||||||
|
|
||||||
- name: Build iOS Simulator
|
- name: Build iOS Simulator
|
||||||
env:
|
env:
|
||||||
TAG: "${{ github.event.pull_request.head.sha }}"
|
TAG: "${{ github.event.pull_request.head.sha }}"
|
||||||
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_BETA_AWS_ACCESS_KEY_ID }}"
|
|
||||||
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_BETA_AWS_SECRET_ACCESS_KEY }}"
|
|
||||||
GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
|
GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
|
||||||
run: bundle exec fastlane ios simulator --env ios.simulator
|
run: bundle exec fastlane ios simulator --env ios.simulator skip_upload_to_s3_bucket:true
|
||||||
working-directory: ./fastlane
|
working-directory: ./fastlane
|
||||||
|
|
||||||
- name: Upload iOS Simulator Build
|
- name: Upload iOS Simulator Build
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
with:
|
with:
|
||||||
name: ios-build-simulator-${{ github.run_id }}
|
name: ios-build-simulator-${{ github.run_id }}
|
||||||
path: Mattermost-simulator-x86_64.app.zip
|
path: Mattermost-simulator-*.app.zip
|
||||||
|
|
||||||
build-android-apk:
|
build-android-apk:
|
||||||
runs-on: ubuntu-latest-8-cores
|
runs-on: ubuntu-latest-8-cores
|
||||||
|
|
@ -122,7 +123,7 @@ jobs:
|
||||||
|
|
||||||
run-ios-tests-on-pr:
|
run-ios-tests-on-pr:
|
||||||
if: contains(github.event.label.name, 'E2E iOS tests for PR')
|
if: contains(github.event.label.name, 'E2E iOS tests for PR')
|
||||||
name: iOS Mobile Tests on PR
|
name: iOS
|
||||||
uses: ./.github/workflows/e2e-ios-template.yml
|
uses: ./.github/workflows/e2e-ios-template.yml
|
||||||
needs:
|
needs:
|
||||||
- build-ios-simulator
|
- build-ios-simulator
|
||||||
|
|
@ -134,7 +135,7 @@ jobs:
|
||||||
|
|
||||||
run-android-tests-on-pr:
|
run-android-tests-on-pr:
|
||||||
if: contains(github.event.label.name, 'E2E Android tests for PR')
|
if: contains(github.event.label.name, 'E2E Android tests for PR')
|
||||||
name: Android Mobile Tests on PR
|
name: Android
|
||||||
uses: ./.github/workflows/e2e-android-template.yml
|
uses: ./.github/workflows/e2e-android-template.yml
|
||||||
needs:
|
needs:
|
||||||
- build-android-apk
|
- build-android-apk
|
||||||
|
|
|
||||||
4
.github/workflows/e2e-detox-release.yml
vendored
|
|
@ -6,7 +6,7 @@ on:
|
||||||
- release-*
|
- release-*
|
||||||
|
|
||||||
env:
|
env:
|
||||||
INTUNE_ENABLED: 1
|
INTUNE_ENABLED: 'true'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-initial-status-ios:
|
update-initial-status-ios:
|
||||||
|
|
@ -46,7 +46,7 @@ jobs:
|
||||||
- name: Prepare iOS Build
|
- name: Prepare iOS Build
|
||||||
uses: ./.github/actions/prepare-ios-build
|
uses: ./.github/actions/prepare-ios-build
|
||||||
with:
|
with:
|
||||||
intune-enabled: 'true'
|
intune-enabled: ${{ env.INTUNE_ENABLED }}
|
||||||
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||||
|
|
||||||
- name: Build iOS Simulator
|
- name: Build iOS Simulator
|
||||||
|
|
|
||||||
4
.github/workflows/e2e-detox-scheduled.yml
vendored
|
|
@ -5,7 +5,7 @@ on:
|
||||||
- cron: "0 0 * * 4,5" # Wednesday and Thursday midnight
|
- cron: "0 0 * * 4,5" # Wednesday and Thursday midnight
|
||||||
|
|
||||||
env:
|
env:
|
||||||
INTUNE_ENABLED: 1
|
INTUNE_ENABLED: 'true'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-initial-status-ios:
|
update-initial-status-ios:
|
||||||
|
|
@ -45,7 +45,7 @@ jobs:
|
||||||
- name: Prepare iOS Build
|
- name: Prepare iOS Build
|
||||||
uses: ./.github/actions/prepare-ios-build
|
uses: ./.github/actions/prepare-ios-build
|
||||||
with:
|
with:
|
||||||
intune-enabled: 'true'
|
intune-enabled: ${{ env.INTUNE_ENABLED }}
|
||||||
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||||
|
|
||||||
- name: Build iOS Simulator
|
- name: Build iOS Simulator
|
||||||
|
|
|
||||||
178
.github/workflows/e2e-ios-template.yml
vendored
|
|
@ -38,12 +38,12 @@ on:
|
||||||
description: "iPhone simulator name"
|
description: "iPhone simulator name"
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
default: "iPhone 15 Pro"
|
default: "iPhone 17 Pro"
|
||||||
ios_device_os_name:
|
ios_device_os_name:
|
||||||
description: "iPhone simulator OS version"
|
description: "iPhone simulator OS version"
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
default: "iOS 17.2"
|
default: "iOS 26.2"
|
||||||
low_bandwidth_mode:
|
low_bandwidth_mode:
|
||||||
description: "Enable low bandwidth mode"
|
description: "Enable low bandwidth mode"
|
||||||
required: false
|
required: false
|
||||||
|
|
@ -121,7 +121,7 @@ jobs:
|
||||||
device_os_version: ${{ env.DEVICE_OS_VERSION }}
|
device_os_version: ${{ env.DEVICE_OS_VERSION }}
|
||||||
|
|
||||||
e2e-ios:
|
e2e-ios:
|
||||||
name: ios-detox-e2e-${{ matrix.runId }}-${{ matrix.deviceName }}-${{ matrix.deviceOsVersion }}
|
name: machine-${{ matrix.runId }}-os-${{ matrix.deviceOsVersion }}
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
timeout-minutes: ${{ inputs.low_bandwidth_mode && 240 || 180 }}
|
timeout-minutes: ${{ inputs.low_bandwidth_mode && 240 || 180 }}
|
||||||
|
|
@ -173,6 +173,82 @@ jobs:
|
||||||
- name: Install Detox Dependencies
|
- name: Install Detox Dependencies
|
||||||
run: cd detox && npm i
|
run: cd detox && npm i
|
||||||
|
|
||||||
|
- name: Pre-boot iOS Simulator
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Looking for simulator: ${{ env.DEVICE_NAME }} with ${{ env.DEVICE_OS_VERSION }}"
|
||||||
|
|
||||||
|
# Find or create the simulator
|
||||||
|
SIMULATOR_ID=$(xcrun simctl list devices | grep "${{ env.DEVICE_NAME }}" | grep "${{ env.DEVICE_OS_VERSION }}" | head -1 | grep -oE '([0-9A-F-]{36})' || true)
|
||||||
|
|
||||||
|
if [ -z "$SIMULATOR_ID" ]; then
|
||||||
|
echo "Simulator not found. Creating simulator ${{ env.DEVICE_NAME }} with ${{ env.DEVICE_OS_VERSION }}"
|
||||||
|
|
||||||
|
# Get device type (use exact match with parentheses to avoid partial matches)
|
||||||
|
DEVICE_TYPE=$(xcrun simctl list devicetypes | grep "${{ env.DEVICE_NAME }} (" | head -1 | awk -F'[()]' '{print $(NF-1)}')
|
||||||
|
if [ -z "$DEVICE_TYPE" ]; then
|
||||||
|
echo "Error: Could not find device type for ${{ env.DEVICE_NAME }}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Found device type: $DEVICE_TYPE"
|
||||||
|
|
||||||
|
# Get runtime (extract the identifier after the last " - ")
|
||||||
|
RUNTIME=$(xcrun simctl list runtimes | grep "${{ env.DEVICE_OS_VERSION }}" | head -1 | sed 's/.* - \(.*\)/\1/')
|
||||||
|
if [ -z "$RUNTIME" ]; then
|
||||||
|
echo "Error: Could not find runtime for ${{ env.DEVICE_OS_VERSION }}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Found runtime: $RUNTIME"
|
||||||
|
|
||||||
|
# Create simulator
|
||||||
|
SIMULATOR_ID=$(xcrun simctl create "CI-${{ env.DEVICE_NAME }}" "$DEVICE_TYPE" "$RUNTIME")
|
||||||
|
echo "Created simulator with ID: $SIMULATOR_ID"
|
||||||
|
else
|
||||||
|
echo "Found existing simulator: $SIMULATOR_ID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Boot simulator first to initialize directory structure
|
||||||
|
echo "Initializing simulator directories (first boot)..."
|
||||||
|
xcrun simctl boot "$SIMULATOR_ID"
|
||||||
|
xcrun simctl bootstatus "$SIMULATOR_ID"
|
||||||
|
|
||||||
|
# Shut down to apply password autofill restrictions
|
||||||
|
echo "Shutting down to apply password autofill restrictions..."
|
||||||
|
xcrun simctl shutdown "$SIMULATOR_ID"
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
echo "Disabling password autofill (simulator is shut down)"
|
||||||
|
|
||||||
|
# Ensure the directory exists (should now exist after first boot)
|
||||||
|
SETTINGS_DIR="$HOME/Library/Developer/CoreSimulator/Devices/$SIMULATOR_ID/data/Containers/Shared/SystemGroup/systemgroup.com.apple.configurationprofiles/Library/ConfigurationProfiles"
|
||||||
|
mkdir -p "$SETTINGS_DIR"
|
||||||
|
|
||||||
|
# Use the JavaScript utility to disable password autofill
|
||||||
|
cd detox
|
||||||
|
node utils/disable_ios_autofill.js --simulator-id "$SIMULATOR_ID"
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "⚠️ Failed to disable password autofill"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo "Booting simulator with password autofill disabled..."
|
||||||
|
xcrun simctl boot "$SIMULATOR_ID"
|
||||||
|
|
||||||
|
# Wait for boot to complete
|
||||||
|
xcrun simctl bootstatus "$SIMULATOR_ID"
|
||||||
|
|
||||||
|
# Open Simulator.app in background to speed up UI rendering
|
||||||
|
open -a Simulator --args -CurrentDeviceUDID "$SIMULATOR_ID"
|
||||||
|
|
||||||
|
# Give Simulator.app time to fully initialize UI (iOS 26.2 needs more time)
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
echo "SIMULATOR_ID=$SIMULATOR_ID" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Start Proxy
|
- name: Start Proxy
|
||||||
if: ${{ inputs.low_bandwidth_mode }}
|
if: ${{ inputs.low_bandwidth_mode }}
|
||||||
id: start-proxy
|
id: start-proxy
|
||||||
|
|
@ -182,35 +258,111 @@ jobs:
|
||||||
|
|
||||||
- name: Set .env with RUNNING_E2E=true
|
- name: Set .env with RUNNING_E2E=true
|
||||||
run: |
|
run: |
|
||||||
cat > .env <<EOF
|
echo "RUNNING_E2E=true" > .env
|
||||||
echo "RUNNING_E2E=true" >> .env
|
|
||||||
|
|
||||||
- name: Run Detox E2E Tests
|
- name: Verify Simulator Health
|
||||||
|
run: |
|
||||||
|
echo "Verifying simulator health before running tests..."
|
||||||
|
|
||||||
|
# Check if simulator is still responsive
|
||||||
|
if ! xcrun simctl bootstatus "$SIMULATOR_ID" -b 2>/dev/null; then
|
||||||
|
echo "⚠️ Simulator not responsive, attempting recovery..."
|
||||||
|
|
||||||
|
# Try to shutdown and reboot
|
||||||
|
xcrun simctl shutdown "$SIMULATOR_ID" || true
|
||||||
|
sleep 3
|
||||||
|
xcrun simctl boot "$SIMULATOR_ID"
|
||||||
|
xcrun simctl bootstatus "$SIMULATOR_ID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Simulator is healthy and ready"
|
||||||
|
|
||||||
|
- name: Start Metro Bundler and Run Detox E2E Tests
|
||||||
continue-on-error: true # We want to run all the tests
|
continue-on-error: true # We want to run all the tests
|
||||||
run: |
|
run: |
|
||||||
# Start the server
|
# Start Metro bundler in background
|
||||||
npm run start &
|
npm run start > metro.log 2>&1 &
|
||||||
sleep 120 # Wait for watchman to finish querying the files
|
METRO_PID=$!
|
||||||
|
echo "Metro PID: $METRO_PID"
|
||||||
|
echo "METRO_PID=$METRO_PID" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
# Wait for Metro to be ready with health check endpoint polling
|
||||||
|
timeout=120
|
||||||
|
elapsed=0
|
||||||
|
echo "Waiting for Metro bundler to be ready..."
|
||||||
|
while [ $elapsed -lt $timeout ]; do
|
||||||
|
# Try health check endpoint first (more reliable)
|
||||||
|
if curl -s http://localhost:8081/status 2>/dev/null | grep -q "packager-status:running"; then
|
||||||
|
echo "✅ Metro bundler is ready after ${elapsed}s (verified via health check)"
|
||||||
|
break
|
||||||
|
# Fallback to log checking
|
||||||
|
elif grep -q "Fast - Scalable - Integrated" metro.log 2>/dev/null; then
|
||||||
|
echo "✅ Metro bundler is ready after ${elapsed}s (verified via logs)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show progress every 10 seconds
|
||||||
|
if [ $((elapsed % 10)) -eq 0 ] && [ $elapsed -gt 0 ]; then
|
||||||
|
echo "Still waiting for Metro... (${elapsed}s elapsed)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
elapsed=$((elapsed + 2))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $elapsed -ge $timeout ]; then
|
||||||
|
echo "⚠️ Warning: Metro bundler may not be fully ready after ${timeout}s, proceeding anyway"
|
||||||
|
echo "Metro process status:"
|
||||||
|
ps aux | grep -E "metro|node.*start" | grep -v grep || echo "Metro process not found"
|
||||||
|
echo ""
|
||||||
|
echo "Last 30 lines of metro.log:"
|
||||||
|
tail -30 metro.log
|
||||||
|
else
|
||||||
|
echo "Metro is serving on http://localhost:8081"
|
||||||
|
# Give Metro extra time to fully initialize for iOS 26.2 (React Native bridge needs more time)
|
||||||
|
echo "Allowing Metro to fully stabilize..."
|
||||||
|
sleep 8
|
||||||
|
fi
|
||||||
|
|
||||||
cd detox
|
cd detox
|
||||||
npm run clean-detox
|
|
||||||
npm run detox:config-gen
|
npm run detox:config-gen
|
||||||
npm run e2e:ios-test -- ${{ matrix.specs }}
|
npm run e2e:ios-test -- ${{ matrix.specs }}
|
||||||
env:
|
env:
|
||||||
DETOX_DISABLE_HIERARCHY_DUMP: "YES"
|
DETOX_DISABLE_HIERARCHY_DUMP: "YES"
|
||||||
DETOX_DISABLE_SCREENSHOT_TRACKING: "YES"
|
DETOX_DISABLE_SCREENSHOT_TRACKING: "YES"
|
||||||
DETOX_LOGLEVEL: "debug"
|
DETOX_LOGLEVEL: "debug"
|
||||||
DETOX_DEVICE_TYPE: ${{ env.DEVICE_NAME }}
|
DETOX_DEVICE_TYPE: ${{ env.DEVICE_NAME }}
|
||||||
DETOX_OS_VERSION: ${{ env.DEVICE_OS_VERSION }}
|
DETOX_OS_VERSION: ${{ env.DEVICE_OS_VERSION }}
|
||||||
LOW_BANDWIDTH_MODE: ${{ inputs.low_bandwidth_mode }}
|
LOW_BANDWIDTH_MODE: ${{ inputs.low_bandwidth_mode }}
|
||||||
|
|
||||||
|
- name: Cleanup Simulator State
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "Cleaning up simulator state after tests..."
|
||||||
|
|
||||||
|
# Stop Metro bundler if still running
|
||||||
|
if [ ! -z "$METRO_PID" ]; then
|
||||||
|
echo "Stopping Metro bundler (PID: $METRO_PID)..."
|
||||||
|
kill -9 "$METRO_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Terminate app gracefully
|
||||||
|
if [ ! -z "$SIMULATOR_ID" ]; then
|
||||||
|
echo "Terminating app on simulator..."
|
||||||
|
xcrun simctl terminate "$SIMULATOR_ID" com.tokilabs.mattermost 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Simulator cleanup complete"
|
||||||
|
|
||||||
- name: reset network settings
|
- name: reset network settings
|
||||||
if: ${{ inputs.low_bandwidth_mode || failure() }}
|
if: ${{ inputs.low_bandwidth_mode || failure() }}
|
||||||
run: |
|
run: |
|
||||||
networksetup -setwebproxystate Ethernet "off"
|
networksetup -setwebproxystate Ethernet "off"
|
||||||
networksetup -setsecurewebproxystate Ethernet "off"
|
networksetup -setsecurewebproxystate Ethernet "off"
|
||||||
|
|
||||||
if (sudo pfctl -q -sa | grep 'Status: Enabled') then sudo pfctl -d; fi
|
if (sudo pfctl -q -sa | grep 'Status: Enabled') then sudo pfctl -d; fi
|
||||||
if (command -v pm2 &> /dev/null) then pm2 stop mitmdump; fi
|
if (command -v pm2 &> /dev/null) then pm2 stop mitmdump; fi
|
||||||
|
|
||||||
sleep 5;
|
sleep 5;
|
||||||
|
|
||||||
|
|
|
||||||
4
.github/workflows/github-release.yml
vendored
|
|
@ -15,7 +15,7 @@ env:
|
||||||
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
||||||
SNYK_VERSION: "1.1297.2"
|
SNYK_VERSION: "1.1297.2"
|
||||||
CYCLONEDX_VERSION: "v0.27.2"
|
CYCLONEDX_VERSION: "v0.27.2"
|
||||||
INTUNE_ENABLED: 1
|
INTUNE_ENABLED: 'true'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|
@ -42,7 +42,7 @@ jobs:
|
||||||
- name: ci/prepare-ios-build
|
- name: ci/prepare-ios-build
|
||||||
uses: ./.github/actions/prepare-ios-build
|
uses: ./.github/actions/prepare-ios-build
|
||||||
with:
|
with:
|
||||||
intune-enabled: 'true'
|
intune-enabled: ${{ env.INTUNE_ENABLED }}
|
||||||
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||||
|
|
||||||
- name: ci/build-ios-unsigned
|
- name: ci/build-ios-unsigned
|
||||||
|
|
|
||||||
312
.gitignore
vendored
|
|
@ -1,135 +1,199 @@
|
||||||
assets/override
|
# Sorted combined gitignore from .gitignore and .gitignore_my
|
||||||
dist
|
|
||||||
build-ios
|
|
||||||
*.zip
|
|
||||||
server.PID
|
|
||||||
mattermost.keystore
|
|
||||||
tmp/
|
|
||||||
.env
|
|
||||||
env.d.ts
|
|
||||||
*.apk
|
|
||||||
*.aab
|
|
||||||
*.ipa
|
|
||||||
.tsbuildinfo.precommit
|
|
||||||
|
|
||||||
*/**/compass-icons.ttf
|
!build/notice-file
|
||||||
|
|
||||||
# OSX
|
|
||||||
#
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# Xcode
|
|
||||||
#
|
|
||||||
ios/build/*
|
|
||||||
*.pbxuser
|
|
||||||
!default.pbxuser
|
|
||||||
*.mode1v3
|
|
||||||
!default.mode1v3
|
|
||||||
*.mode2v3
|
|
||||||
!default.mode2v3
|
|
||||||
*.perspectivev3
|
|
||||||
!default.perspectivev3
|
|
||||||
xcuserdata
|
|
||||||
*.xccheckout
|
|
||||||
*.moved-aside
|
|
||||||
DerivedData
|
|
||||||
*.hmap
|
|
||||||
*.xcuserstate
|
|
||||||
project.xcworkspace
|
|
||||||
ios/Pods
|
|
||||||
.podinstall
|
|
||||||
ios/.xcode.env.local
|
|
||||||
|
|
||||||
# Android/IntelliJ
|
|
||||||
#
|
|
||||||
.idea
|
|
||||||
.gradle
|
|
||||||
local.properties
|
|
||||||
*.iml
|
|
||||||
*.hprof
|
|
||||||
.cxx/
|
|
||||||
*.keystore
|
|
||||||
!debug.keystore
|
!debug.keystore
|
||||||
android/app/bin
|
!default.mode1v3
|
||||||
android/app/build
|
!default.mode2v3
|
||||||
android/build
|
!default.pbxuser
|
||||||
.settings
|
!default.perspectivev3
|
||||||
.project
|
!gradle-wrapper.jar
|
||||||
.classpath
|
!gradle-wrapper.properties
|
||||||
|
!libraries/@mattermost/intune/.gitkeep
|
||||||
# node.js
|
!src/**/build/
|
||||||
#
|
**/build/
|
||||||
node_modules/
|
**/fastlane/.env
|
||||||
npm-debug.log
|
|
||||||
.npminstall
|
|
||||||
yarn-error.log
|
|
||||||
.yarninstall
|
|
||||||
|
|
||||||
# Vim
|
|
||||||
[._]*.s[a-w][a-z]
|
|
||||||
[._]s[a-w][a-z]
|
|
||||||
*.un~
|
|
||||||
Session.vim
|
|
||||||
.netrwhist
|
|
||||||
*~
|
|
||||||
tags
|
|
||||||
|
|
||||||
# fastlane
|
|
||||||
#
|
|
||||||
# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
|
|
||||||
# screenshots whenever they are needed.
|
|
||||||
# For more information about the recommended setup visit:
|
|
||||||
# https://docs.fastlane.tools/best-practices/source-control/
|
|
||||||
|
|
||||||
**/fastlane/report.xml
|
|
||||||
**/fastlane/Preview.html
|
**/fastlane/Preview.html
|
||||||
|
**/fastlane/report.xml
|
||||||
**/fastlane/screenshots
|
**/fastlane/screenshots
|
||||||
**/fastlane/test_output
|
**/fastlane/test_output
|
||||||
**/fastlane/.env
|
*.aab
|
||||||
|
*.apk
|
||||||
# Sentry
|
*.hmap
|
||||||
android/sentry.properties
|
*.hprof
|
||||||
ios/sentry.properties
|
*.iml
|
||||||
|
*.ipa
|
||||||
# Testing
|
*.jks
|
||||||
.nyc_output
|
*.jsbundle
|
||||||
coverage
|
*.keystore
|
||||||
.tmp
|
*.lcov
|
||||||
|
*.log
|
||||||
# E2E testing
|
|
||||||
mattermost-license.txt
|
|
||||||
*.mattermost-license
|
*.mattermost-license
|
||||||
|
*.mode1v3
|
||||||
|
*.mode2v3
|
||||||
|
*.moved-aside
|
||||||
|
*.pbxuser
|
||||||
|
*.perspectivev3
|
||||||
|
*.pid
|
||||||
|
*.pid.lock
|
||||||
|
*.seed
|
||||||
|
*.tgz
|
||||||
|
*.tsbuildinfo
|
||||||
|
*.un~
|
||||||
|
*.xccheckout
|
||||||
|
*.xcscmblueprint
|
||||||
|
*.xcuserstate
|
||||||
|
*.zip
|
||||||
|
*/**/compass-icons.ttf
|
||||||
|
*~
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
.AppleDouble
|
||||||
|
.DS_Store
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.LSOverride
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trash-*
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
._*
|
||||||
|
.aider*
|
||||||
|
.apdisk
|
||||||
|
.buckconfig.local
|
||||||
|
.buckd/
|
||||||
|
.buckversion
|
||||||
|
.bundle
|
||||||
|
.cache
|
||||||
|
.cache/
|
||||||
|
.classpath
|
||||||
|
.claude/settings.local.json
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
.cxx/
|
||||||
|
.directory
|
||||||
|
.docusaurus
|
||||||
|
.dynamodb/
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.local
|
||||||
|
.env.production.local
|
||||||
|
.env.test.local
|
||||||
|
.eslintcache
|
||||||
|
.expo
|
||||||
|
.externalNativeBuild/
|
||||||
|
.fakebuckversion
|
||||||
|
.fseventsd
|
||||||
|
.fuse_hidden*
|
||||||
|
.fusebox/
|
||||||
|
.gradle
|
||||||
|
.gradle/
|
||||||
|
.gradletasknamecache
|
||||||
|
.grunt
|
||||||
|
.idea
|
||||||
|
.idea/
|
||||||
|
.lock-wscript
|
||||||
|
.metro-health-check*
|
||||||
|
.netrwhist
|
||||||
|
.next
|
||||||
|
.nfs*
|
||||||
|
.node_repl_history
|
||||||
|
.npm
|
||||||
|
.npminstall
|
||||||
|
.nuxt
|
||||||
|
.nyc_output
|
||||||
|
.parcel-cache
|
||||||
|
.pnp.*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
.podinstall
|
||||||
|
.project
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
.scannerwork
|
||||||
|
.serverless/
|
||||||
|
.settings
|
||||||
|
.stylelintcache
|
||||||
|
.temp
|
||||||
|
.tern-port
|
||||||
|
.tmp
|
||||||
|
.tsbuildinfo.precommit
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/settings.json
|
||||||
|
.vscode-test
|
||||||
|
.vuepress/dist
|
||||||
|
.yarn-integrity
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarninstall
|
||||||
|
DerivedData
|
||||||
|
Icon
|
||||||
|
Network Trash Folder
|
||||||
|
Session.vim
|
||||||
|
Temporary Items
|
||||||
|
[._]*.s[a-w][a-z]
|
||||||
|
[._]s[a-w][a-z]
|
||||||
|
__generated__
|
||||||
|
android/app/bin
|
||||||
|
android/app/build
|
||||||
|
android/app/src/main/res/raw/*
|
||||||
|
android/build
|
||||||
|
android/sentry.properties
|
||||||
|
assets/override
|
||||||
|
bower_components
|
||||||
|
buck-out/
|
||||||
|
build-ios
|
||||||
|
build/Release
|
||||||
|
captures/
|
||||||
|
coverage
|
||||||
|
deploymentTargetDropDown.xml
|
||||||
detox/artifacts
|
detox/artifacts
|
||||||
detox/detox_pixel_*
|
detox/detox_pixel_*
|
||||||
|
dist
|
||||||
# Bundle artifact
|
env.d.ts
|
||||||
*.jsbundle
|
google-services.json
|
||||||
.bundle
|
gradle-app.setting
|
||||||
|
ios/.xcode.env.local
|
||||||
#editor-settings
|
ios/Pods
|
||||||
.vscode
|
ios/build/*
|
||||||
.scannerwork
|
ios/sentry.properties
|
||||||
launch.json
|
jspm_packages/
|
||||||
|
lerna-debug.log*
|
||||||
# Notice.txt generation
|
lib-cov
|
||||||
!build/notice-file
|
|
||||||
|
|
||||||
# Temporary files created by Metro to check the health of the file watcher
|
|
||||||
.metro-health-check*
|
|
||||||
|
|
||||||
libraries/**/**/build
|
|
||||||
libraries/**/**/.build
|
libraries/**/**/.build
|
||||||
|
libraries/**/**/build
|
||||||
# Android sounds
|
|
||||||
android/app/src/main/res/raw/*
|
|
||||||
.aider*
|
|
||||||
|
|
||||||
# Intune submodule (private repository - internal builds only)
|
|
||||||
# Track submodule reference but ignore content
|
|
||||||
libraries/@mattermost/intune/*
|
libraries/@mattermost/intune/*
|
||||||
!libraries/@mattermost/intune/.gitkeep
|
local.properties
|
||||||
|
logs
|
||||||
# Prevent committing Intune module in node_modules
|
mattermost-license.txt
|
||||||
|
mattermost.keystore
|
||||||
|
misc.xml
|
||||||
|
node_modules/
|
||||||
node_modules/@mattermost/intune
|
node_modules/@mattermost/intune
|
||||||
# Claude Code
|
npm-debug.log
|
||||||
.claude/settings.local.json
|
npm-debug.log*
|
||||||
|
out
|
||||||
|
output.json
|
||||||
|
pids
|
||||||
|
project.xcworkspace
|
||||||
|
render.experimental.xml
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
server.PID
|
||||||
|
tags
|
||||||
|
tmp/
|
||||||
|
web_modules/
|
||||||
|
xcuserdata
|
||||||
|
xcuserdata/
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# 새로 적용된 .gitignore 규칙에 의해 무시되었으나, 기존 프로젝트에서 관리(Tracking)되고 있던 필수 파일들 예외 처리
|
||||||
|
!android/app/debug.keystore
|
||||||
|
!android/app/google-services.json
|
||||||
|
!build/notice-file/Readme.md
|
||||||
|
!build/notice-file/config.yaml
|
||||||
|
!env.d.ts
|
||||||
|
!fastlane/screenshots/README.txt
|
||||||
3
.kilocode/rules/rules.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# 필수 준수 사항
|
||||||
|
- agent-ops/rules/global.md 파일을 읽고 이에 대해 꼭 준수해서 진행해야한다.
|
||||||
|
- global.md 내용을 모르면 추정하지 말고 먼저 해당 파일을 확인한다.
|
||||||
1
.kilocode/system-prompt-architect
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
-
|
||||||
1
.kilocode/system-prompt-ask
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
-
|
||||||
1
.kilocode/system-prompt-code
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
-
|
||||||
1
.kilocode/system-prompt-debug
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
-
|
||||||
1
.kilocode/system-prompt-orchestrator
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
-
|
||||||
1
.kilocode/system-prompt-review
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
-
|
||||||
31
.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Metro: Start",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"runtimeExecutable": "bash",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"-c",
|
||||||
|
"lsof -ti:8081 | xargs kill -9 2>/dev/null; npm start"
|
||||||
|
],
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"internalConsoleOptions": "neverOpen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Android: Run App",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"runtimeExecutable": "bash",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"-c",
|
||||||
|
"adb devices | grep -q emulator || (emulator -avd Pixel_7_Pro &); adb -e wait-for-device && npm run android -- --deviceId emulator-5554"
|
||||||
|
],
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"internalConsoleOptions": "neverOpen"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
17
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"react-native.packager.port": 8081,
|
||||||
|
"terminal.integrated.env.osx": {
|
||||||
|
"NVM_DIR": "${env:HOME}/.nvm",
|
||||||
|
"JAVA_HOME": "/opt/homebrew/opt/openjdk@17",
|
||||||
|
"ANDROID_HOME": "${env:HOME}/Library/Android/sdk",
|
||||||
|
"ANDROID_SDK_ROOT": "${env:HOME}/Library/Android/sdk"
|
||||||
|
},
|
||||||
|
"terminal.integrated.profiles.osx": {
|
||||||
|
"zsh": {
|
||||||
|
"path": "/bin/zsh",
|
||||||
|
"args": ["-l"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"terminal.integrated.defaultProfile.osx": "zsh",
|
||||||
|
"npm.scriptExplorerAction": "run"
|
||||||
|
}
|
||||||
3
AGENTS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# 필수 준수 사항
|
||||||
|
- agent-ops/rules/global.md 파일을 읽고 이에 대해 꼭 준수해서 진행해야한다.
|
||||||
|
- global.md 내용을 모르면 추정하지 말고 먼저 해당 파일을 확인한다.
|
||||||
209
Agent-Ops-SKILL.md
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
---
|
||||||
|
name: scaffold-agent-ops
|
||||||
|
description: 프로젝트 상태를 판별하고 에이전트 진입 파일과 agent-ops 기본 스캐폴드를 생성하기 위한 초기 규칙
|
||||||
|
version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# 목적
|
||||||
|
|
||||||
|
이 스킬은 프로젝트에 Agent-Ops 구조가 없거나 불완전할 때,
|
||||||
|
현재 프로젝트 상태를 분석하여 다음을 세팅한다.
|
||||||
|
|
||||||
|
1. 에이전트 진입 파일
|
||||||
|
2. agent-ops 기본 폴더 구조
|
||||||
|
3. global rule
|
||||||
|
4. domain rule 템플릿
|
||||||
|
5. skill router
|
||||||
|
6. skill 템플릿
|
||||||
|
7. 초기 domain / skill 세팅
|
||||||
|
|
||||||
|
이 스킬은 과도한 초기 생성보다
|
||||||
|
"현재 프로젝트에 맞는 최소 스캐폴드" 생성을 우선한다.
|
||||||
|
|
||||||
|
# 핵심 원칙
|
||||||
|
|
||||||
|
- 기존 구조를 우선한다.
|
||||||
|
- 새 파일 생성은 꼭 필요한 최소 범위로 제한한다.
|
||||||
|
- 에이전트별 md는 얇은 진입점으로 둔다.
|
||||||
|
- 실제 규칙은 agent-ops 아래로 모은다.
|
||||||
|
- 도메인은 발명하지 말고 현재 구조에서 발견한다.
|
||||||
|
- 처음에는 핵심 도메인만 생성한다.
|
||||||
|
- 반복되는 작업만 초기 skill로 만든다.
|
||||||
|
- 불확실한 내용은 후보로 제시한다.
|
||||||
|
|
||||||
|
# 상태 판별
|
||||||
|
|
||||||
|
## 신규 프로젝트
|
||||||
|
다음 조건이 많으면 신규 프로젝트로 본다.
|
||||||
|
|
||||||
|
- 코드/폴더 구조가 단순하다
|
||||||
|
- 도메인 경계가 아직 약하다
|
||||||
|
- Agent-Ops 관련 파일이 없다
|
||||||
|
- 반복 작업 패턴이 아직 적다
|
||||||
|
|
||||||
|
## 운영중 프로젝트
|
||||||
|
다음 조건이 많으면 운영중 프로젝트로 본다.
|
||||||
|
|
||||||
|
- 모듈/폴더/패키지 경계가 보인다
|
||||||
|
- 반복 작업이 드러난다
|
||||||
|
- 기존 규칙/문서/관례의 흔적이 있다
|
||||||
|
- 핵심 책임 경계가 식별된다
|
||||||
|
|
||||||
|
# 생성 대상
|
||||||
|
|
||||||
|
이 스킬은 아래 구조를 기준으로 scaffold를 제안하거나 생성한다.
|
||||||
|
|
||||||
|
- CLAUDE.md
|
||||||
|
- GEMINI.md
|
||||||
|
- .cursorrules
|
||||||
|
- agent-ops/rules/global.md
|
||||||
|
- agent-ops/rules/_templates/domain-rule-template.md
|
||||||
|
- agent-ops/rules/domain/<domain>/rules.md
|
||||||
|
- agent-ops/skills/router.md
|
||||||
|
- agent-ops/skills/_templates/skill-template.md
|
||||||
|
|
||||||
|
필요 시 아래 초기 skill도 함께 제안한다.
|
||||||
|
|
||||||
|
- agent-ops/skills/find-domain/SKILL.md
|
||||||
|
- agent-ops/skills/code-change/SKILL.md
|
||||||
|
|
||||||
|
# 에이전트 진입 파일 원칙
|
||||||
|
|
||||||
|
CLAUDE.md, GEMINI.md, .cursorrules 는 공통적으로 얇게 유지한다.
|
||||||
|
|
||||||
|
각 파일에는 아래만 둔다.
|
||||||
|
|
||||||
|
- 응답 언어
|
||||||
|
- 기존 구조 우선
|
||||||
|
- 새 파일 생성보다 기존 수정 우선
|
||||||
|
- 작업 전 agent-ops/skills/router.md 참조
|
||||||
|
- 관련 rule / skill 만 최소 로드
|
||||||
|
- 세부 규칙은 agent-ops 아래 파일을 따른다
|
||||||
|
|
||||||
|
각 에이전트 진입 파일에 프로젝트별 상세 규칙을 중복 작성하지 않는다.
|
||||||
|
|
||||||
|
# Rule 구조 원칙
|
||||||
|
|
||||||
|
## global.md
|
||||||
|
전 프로젝트 공통 규칙만 둔다.
|
||||||
|
|
||||||
|
포함 항목:
|
||||||
|
- 응답 언어
|
||||||
|
- 기존 구조 우선
|
||||||
|
- 새 파일 생성보다 기존 수정 우선
|
||||||
|
- 코드 변경 전 관련 domain rule 확인
|
||||||
|
- 요청 범위를 넘는 변경 금지
|
||||||
|
- 불확실하면 단정하지 말고 후보 제시
|
||||||
|
- 플랫폼 특화 규칙은 global에 두지 않음
|
||||||
|
|
||||||
|
## domain rule
|
||||||
|
각 도메인 rules.md 는 아래만 둔다.
|
||||||
|
|
||||||
|
- 목적 / 책임
|
||||||
|
- 포함 경로
|
||||||
|
- 제외 경로
|
||||||
|
- 주요 구성 요소
|
||||||
|
- 유지할 패턴
|
||||||
|
- 다른 도메인과의 경계
|
||||||
|
- 금지 사항
|
||||||
|
|
||||||
|
# DDD 기준
|
||||||
|
|
||||||
|
도메인은 아래 기준으로 분류한다.
|
||||||
|
|
||||||
|
## Core Domain
|
||||||
|
핵심 가치와 주요 유즈케이스를 담당하는 영역
|
||||||
|
|
||||||
|
## Supporting Domain
|
||||||
|
핵심 도메인을 지원하는 영역
|
||||||
|
|
||||||
|
## Generic / Common
|
||||||
|
여러 도메인이 공통으로 사용하는 범용 영역
|
||||||
|
|
||||||
|
도메인은 반드시 실제 폴더, 모듈, 패키지, 네임스페이스, 책임 경계와 연결되어야 한다.
|
||||||
|
|
||||||
|
# Skill 구조 원칙
|
||||||
|
|
||||||
|
## router.md
|
||||||
|
router.md 는 작업 유형에 따라 필요한 skill 또는 domain rule만 로드하게 한다.
|
||||||
|
|
||||||
|
최소 라우팅 축:
|
||||||
|
- 구조 분석 / 스캐폴드
|
||||||
|
- 도메인 판별
|
||||||
|
- 코드 변경
|
||||||
|
- 흐름 추적
|
||||||
|
- 문서/릴리즈 정리
|
||||||
|
|
||||||
|
처음부터 너무 많은 분기를 만들지 않는다.
|
||||||
|
|
||||||
|
## skill-template.md
|
||||||
|
각 skill 은 아래 형식을 따른다.
|
||||||
|
|
||||||
|
- 목적
|
||||||
|
- 언제 호출할지
|
||||||
|
- 입력
|
||||||
|
- 먼저 확인할 것
|
||||||
|
- 실행 절차
|
||||||
|
- 출력 형식
|
||||||
|
- 금지 사항
|
||||||
|
|
||||||
|
# 신규 프로젝트 처리
|
||||||
|
|
||||||
|
신규 프로젝트일 때는 템플릿 중심으로 세팅한다.
|
||||||
|
|
||||||
|
1. 언어/구조를 확인한다.
|
||||||
|
2. 에이전트 진입 파일 3개를 얇게 생성한다.
|
||||||
|
3. agent-ops/rules/global.md 생성
|
||||||
|
4. domain-rule-template.md 생성
|
||||||
|
5. skills/router.md 생성
|
||||||
|
6. skill-template.md 생성
|
||||||
|
7. 핵심 domain placeholder 2~4개만 제안
|
||||||
|
8. 초기 skill 은 최소 2개만 제안한다.
|
||||||
|
|
||||||
|
신규 프로젝트에서는 domain rules 를 과도하게 채우지 않는다.
|
||||||
|
|
||||||
|
# 운영중 프로젝트 처리
|
||||||
|
|
||||||
|
운영중 프로젝트일 때는 현재 구조 기반으로 자동 채움한다.
|
||||||
|
|
||||||
|
1. 구조를 분석한다.
|
||||||
|
2. Core / Supporting / Generic 도메인 후보를 식별한다.
|
||||||
|
3. 실제 경로를 반영한 domain rules 초안을 제안한다.
|
||||||
|
4. 반복 작업 기반으로 필요한 초기 skill 을 제안한다.
|
||||||
|
5. router.md 에 기본 라우팅을 채운다.
|
||||||
|
6. 신규 프로젝트보다 한 단계 더 구체적으로 scaffold 한다.
|
||||||
|
|
||||||
|
운영중 프로젝트에서는 실제 코드 구조에 맞는 domain / skill 을 일부 채워 넣는다.
|
||||||
|
|
||||||
|
# 출력 형식
|
||||||
|
|
||||||
|
## 상태 판별
|
||||||
|
- Agent-Ops 상태: 없음 / 부분 적용 / 운영중
|
||||||
|
- 프로젝트 상태: 신규 / 운영중
|
||||||
|
- 근거: 짧게 요약
|
||||||
|
|
||||||
|
## 스캐폴드 계획
|
||||||
|
- 생성할 파일
|
||||||
|
- 바로 채울 파일
|
||||||
|
- placeholder 로 둘 파일
|
||||||
|
|
||||||
|
## 도메인 제안
|
||||||
|
- Core Domain
|
||||||
|
- Supporting Domain
|
||||||
|
- Generic / Common
|
||||||
|
|
||||||
|
## 초기 skill 제안
|
||||||
|
- skill 이름
|
||||||
|
- 필요한 이유
|
||||||
|
|
||||||
|
## 주의사항
|
||||||
|
- 지금 만들지 말아야 할 것
|
||||||
|
- 아직 확정하면 안 되는 것
|
||||||
|
|
||||||
|
# 금지
|
||||||
|
|
||||||
|
- 플랫폼 전용 규칙을 기본 scaffold 에 넣지 않는다.
|
||||||
|
- agent 별 md 에 상세 규칙을 중복 작성하지 않는다.
|
||||||
|
- 실제 구조보다 앞선 추상 구조를 강요하지 않는다.
|
||||||
|
- 처음부터 많은 domain / skill 을 만들지 않는다.
|
||||||
|
- scaffold 단계에서 불필요한 세부 구현까지 생성하지 않는다.
|
||||||
172
CLAUDE-IOS-SIMULATOR.md
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
# iOS Simulator Setup Guide for AI Agents
|
||||||
|
|
||||||
|
This guide documents the process for setting up and running the Mattermost Mobile app on an iOS simulator.
|
||||||
|
|
||||||
|
## Prerequisites Check
|
||||||
|
|
||||||
|
Before starting, verify these are installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check Xcode
|
||||||
|
xcodebuild -version
|
||||||
|
|
||||||
|
# Check CocoaPods
|
||||||
|
pod --version
|
||||||
|
|
||||||
|
# Check Node.js
|
||||||
|
node --version
|
||||||
|
|
||||||
|
# Check npm dependencies
|
||||||
|
test -d node_modules && echo "node_modules exists" || echo "Run: npm install"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 1: iOS Simulator Runtime
|
||||||
|
|
||||||
|
Check if iOS simulators are available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcrun simctl list devices available
|
||||||
|
```
|
||||||
|
|
||||||
|
If no devices are listed or you see "Install Started", you need to download the iOS platform:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodebuild -downloadPlatform iOS
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** This downloads ~8GB and takes several minutes. The command runs in the foreground and shows progress.
|
||||||
|
|
||||||
|
## Step 2: CocoaPods Installation
|
||||||
|
|
||||||
|
### Critical: Set UTF-8 Encoding
|
||||||
|
|
||||||
|
CocoaPods requires UTF-8 encoding. Always set this before running pod commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export LANG=en_US.UTF-8
|
||||||
|
export LC_ALL=en_US.UTF-8
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install Pods (Apple Silicon Mac)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ios
|
||||||
|
RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common CocoaPods Issues
|
||||||
|
|
||||||
|
#### Issue: "Unicode Normalization not appropriate for ASCII-8BIT"
|
||||||
|
**Solution:** Set UTF-8 encoding as shown above.
|
||||||
|
|
||||||
|
#### Issue: Podfile.lock version conflicts
|
||||||
|
**Solution:** Clean install:
|
||||||
|
```bash
|
||||||
|
cd ios
|
||||||
|
rm -rf Pods Podfile.lock build
|
||||||
|
export LANG=en_US.UTF-8 && export LC_ALL=en_US.UTF-8
|
||||||
|
RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Issue: GitHub SSH timeouts during pod install
|
||||||
|
**Symptom:** Errors like "Connection to github.com port 22: Operation timed out"
|
||||||
|
|
||||||
|
**Solution:** Temporarily configure git to use HTTPS instead of SSH:
|
||||||
|
```bash
|
||||||
|
git config --global url."https://github.com/".insteadOf git@github.com:
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT:** Revert this after pod install completes, or the user won't be able to push:
|
||||||
|
```bash
|
||||||
|
git config --global --unset url.https://github.com/.insteadof
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warning:** Never leave git URL rewrites in place without informing the user!
|
||||||
|
|
||||||
|
## Step 3: Start Metro Bundler (if not already running)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait for "Dev server ready" message before proceeding.
|
||||||
|
|
||||||
|
## Step 4: Run iOS App (if not already built)
|
||||||
|
|
||||||
|
### Using npm script
|
||||||
|
```bash
|
||||||
|
npm run ios -- --simulator="iPhone 17 Pro"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Or specify a different simulator
|
||||||
|
List available simulators first:
|
||||||
|
```bash
|
||||||
|
xcrun simctl list devices available | grep -E "iPhone|iPad"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run with your chosen device:
|
||||||
|
```bash
|
||||||
|
npm run ios -- --simulator="iPhone 16e"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build Times
|
||||||
|
|
||||||
|
- **First build:** 10-30 minutes (compiles all native code)
|
||||||
|
- **Subsequent builds:** Much faster (incremental)
|
||||||
|
- **JS/TS changes:** ~3 seconds hot reload (no native rebuild needed)
|
||||||
|
|
||||||
|
## Troubleshooting Build Failures
|
||||||
|
|
||||||
|
### Getting Better Error Messages
|
||||||
|
|
||||||
|
If `npm run ios` fails, run xcodebuild directly for clearer errors:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodebuild -workspace ios/Mattermost.xcworkspace \
|
||||||
|
-configuration Debug \
|
||||||
|
-scheme Mattermost \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
|
||||||
|
2>&1 | grep -E "error:|fatal error"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clean Build
|
||||||
|
|
||||||
|
If builds fail mysteriously, try a clean build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clean Xcode derived data
|
||||||
|
rm -rf ~/Library/Developer/Xcode/DerivedData/Mattermost-*
|
||||||
|
|
||||||
|
# Clean and reinstall pods
|
||||||
|
cd ios
|
||||||
|
rm -rf Pods build
|
||||||
|
export LANG=en_US.UTF-8 && export LC_ALL=en_US.UTF-8
|
||||||
|
RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opening in Xcode
|
||||||
|
|
||||||
|
For complex build issues, open in Xcode for better debugging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open ios/Mattermost.xcworkspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Then select your simulator target and press Cmd+B to build.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full setup sequence (Apple Silicon)
|
||||||
|
export LANG=en_US.UTF-8 && export LC_ALL=en_US.UTF-8
|
||||||
|
cd ios && RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install && cd ..
|
||||||
|
npm start &
|
||||||
|
sleep 10
|
||||||
|
npm run ios -- --simulator="iPhone 17 Pro"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Do NOT Modify
|
||||||
|
|
||||||
|
- **Git configuration:** Never permanently change git URL rewrites without reverting
|
||||||
|
- **Podfile:** Don't add modular_headers to pods unless you understand the implications
|
||||||
|
- **New Architecture:** Keep `RCT_NEW_ARCH_ENABLED=0` as the project uses the old architecture
|
||||||
371
CLAUDE.md
|
|
@ -1,368 +1,3 @@
|
||||||
# CLAUDE.md
|
# 필수 준수 사항
|
||||||
|
- agent-ops/rules/global.md 파일을 읽고 이에 대해 꼭 준수해서 진행해야한다.
|
||||||
## Project Overview
|
- global.md 내용을 모르면 추정하지 말고 먼저 해당 파일을 확인한다.
|
||||||
|
|
||||||
React Native 0.76.9 with **New Architecture disabled** (RCT_NEW_ARCH_ENABLED=0).
|
|
||||||
|
|
||||||
## Important Development Notes
|
|
||||||
- Always run the checks before committing code
|
|
||||||
- Always test any changes using the mobile mcp before calling something complete or committing code
|
|
||||||
- Never commit any planning markdown files
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
### Implementing Multi-Phase Features
|
|
||||||
When implementing complex features with multiple phases:
|
|
||||||
|
|
||||||
1. **Use subagents for each phase**: Launch a subagent (Task tool with `subagent_type: general-purpose`) to implement each phase independently
|
|
||||||
2. **Test after each phase**: Use a subagent to test the feature with mobile MCP after implementation
|
|
||||||
3. **Commit after each phase**: Create a commit for each completed and tested phase
|
|
||||||
4. **Never commit planning files**: Keep `.md` planning files (like `PLAN.md`) out of commits
|
|
||||||
|
|
||||||
### Testing with Mobile MCP
|
|
||||||
When testing features that require mobile device interaction:
|
|
||||||
- **Use subagents**: Launch a subagent to handle mobile MCP testing to avoid excessive context usage
|
|
||||||
- The subagent can launch the app, navigate, take screenshots, and verify functionality
|
|
||||||
- **Don't just verify the app launches** - actually trigger and interact with the feature being tested (e.g., send messages, click buttons, verify state changes)
|
|
||||||
|
|
||||||
## Key Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Setup
|
|
||||||
npm run pod-install # iOS CocoaPods (or pod-install-m1 for M1 Macs)
|
|
||||||
npm run ios-gems # Ruby gems for iOS (or ios-gems-m1 for M1 Macs)
|
|
||||||
|
|
||||||
# Development
|
|
||||||
npm start # Start Metro bundler
|
|
||||||
npm run ios # Run iOS in simulator
|
|
||||||
npm run android # Run Android in emulator
|
|
||||||
|
|
||||||
# TypeScript type checking
|
|
||||||
npm run tsc
|
|
||||||
|
|
||||||
# Fix linting issues
|
|
||||||
npm run fix
|
|
||||||
|
|
||||||
# E2E tests are in separate detox/ package
|
|
||||||
npm run e2e:ios # cd detox && npm run e2e:ios-test
|
|
||||||
npm run e2e:android # cd detox && npm run e2e:android-build && npm run e2e:android-test
|
|
||||||
|
|
||||||
# Builds delegate to Fastlane via shell scripts
|
|
||||||
npm run build:ios # ./scripts/build.sh ipa
|
|
||||||
npm run build:android # ./scripts/build.sh apk
|
|
||||||
|
|
||||||
# Utilities
|
|
||||||
npm run clean # Clean build artifacts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Dual Database System (WatermelonDB)
|
|
||||||
**Critical**: Two separate SQLite databases via WatermelonDB:
|
|
||||||
|
|
||||||
1. **App Database**: One global database for app state, server list (`app/database/models/app/`)
|
|
||||||
2. **Server Databases**: One database per connected server for channels, users, posts (`app/database/models/server/`)
|
|
||||||
|
|
||||||
**DatabaseManager** singleton (`app/database/manager/index.ts`) manages all instances. Data operations go through operators with transformers/handlers/comparators.
|
|
||||||
|
|
||||||
**Database directory**:
|
|
||||||
- iOS: App Group directory (shared with extensions)
|
|
||||||
- Android: `${documentDirectory}/databases/`
|
|
||||||
|
|
||||||
### Network Layer
|
|
||||||
- **NetworkManager** (`app/managers/network_manager.ts`) creates **one Client instance per server**
|
|
||||||
- Uses `@mattermost/react-native-network-client`
|
|
||||||
- **Product-specific API calls** should live in `client/rest.ts` within the product folder using the **ClientMix pattern** (see `app/products/calls/client/rest.ts` or `app/products/agents/client/rest.ts` for examples). Don't call `client.doFetch()` directly from action files.
|
|
||||||
|
|
||||||
### Actions Pattern
|
|
||||||
- **Local Actions** (`app/actions/local/`): Database-only operations
|
|
||||||
- **Remote Actions** (`app/actions/remote/`): Fetch from API → use operators to persist → return `{error}` on failure
|
|
||||||
|
|
||||||
### Query Layer
|
|
||||||
**Query layer** (`app/queries/`) provides both:
|
|
||||||
- `query*`: Returns WatermelonDB Query
|
|
||||||
- `observe*`: Returns RxJS Observable
|
|
||||||
- `get*`: Returns Promise (async)
|
|
||||||
- `prepare*`: Returns prepared records for batch operations
|
|
||||||
|
|
||||||
### State Management
|
|
||||||
**Hybrid approach**:
|
|
||||||
- **WatermelonDB**: Primary data store (persisted)
|
|
||||||
- **Ephemeral Stores** (`app/store/`): In-memory transient UI state
|
|
||||||
|
|
||||||
### Navigation
|
|
||||||
- Uses **`react-native-navigation`** v7 (not react-navigation)
|
|
||||||
- Each screen is a separate registered component
|
|
||||||
- Every independently shown/hidden component must be registered as a screen for proper lifecycle management
|
|
||||||
|
|
||||||
### Products Architecture
|
|
||||||
Modular features in `app/products/` with their own database models:
|
|
||||||
- `agents/`: AI agents
|
|
||||||
- `calls/`: Voice/video calling
|
|
||||||
- `playbooks/`: Playbooks with dedicated DB models
|
|
||||||
|
|
||||||
#### Agents Streaming Architecture
|
|
||||||
**WebSocket Events:**
|
|
||||||
- Agents use `custom_mattermost-ai_postupdate` WebSocket events for streaming (start, message updates, reasoning, tool calls, annotations, end)
|
|
||||||
- NO specific WebSocket event for tool call status changes after approval/rejection
|
|
||||||
- Updates arrive via standard `POST_EDITED` event after backend executes tools
|
|
||||||
|
|
||||||
**Critical Pattern:**
|
|
||||||
- Components must clear local streaming state on `ENDED` event to switch from ephemeral streaming data to persisted database data
|
|
||||||
- Otherwise stale streaming state prevents POST_EDITED updates from displaying
|
|
||||||
|
|
||||||
### Custom Native Modules
|
|
||||||
Located at `libraries/@mattermost/`:
|
|
||||||
- `@mattermost/rnutils` - Native utilities (orientation, notifications)
|
|
||||||
- `@mattermost/rnshare` - Share extension integration
|
|
||||||
- `@mattermost/hardware-keyboard` - External keyboard detection
|
|
||||||
- `@mattermost/secure-pdf-viewer` - Secure PDF viewing
|
|
||||||
|
|
||||||
**Native Bridge patterns** (when modifying native modules):
|
|
||||||
- iOS: `RCT_EXPORT_MODULE()`, `RCT_EXPORT_METHOD()` in Objective-C++
|
|
||||||
- Android: Extend `ReactContextBaseJavaModule`, use `@ReactMethod`
|
|
||||||
|
|
||||||
### TypeScript Path Aliases
|
|
||||||
```typescript
|
|
||||||
@actions/* → app/actions/*
|
|
||||||
@agents/* → app/products/agents/*
|
|
||||||
@calls/* → app/products/calls/*
|
|
||||||
@database/* → app/database/*
|
|
||||||
@queries/* → app/queries/*
|
|
||||||
@store → app/store/index
|
|
||||||
@share/* → share_extension/*
|
|
||||||
@typings/* → types/*
|
|
||||||
// ... (see tsconfig.json for complete list)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Share Extension
|
|
||||||
**Separate bundle** at `share_extension/` for Android system share. Shares code with main app but runs independently.
|
|
||||||
* iOS has its own native implementation for the Share Extension with Swift and SwiftUI
|
|
||||||
|
|
||||||
**Important:** Share extension on Android uses **React Navigation** (not react-native-navigation like main app).
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Test Organization
|
|
||||||
- **Jest coverage excludes** `/components/` and `/screens/` directories
|
|
||||||
- Mock database manager at `app/database/manager/__mocks__/index.ts`
|
|
||||||
- **E2E tests** in separate `detox/` package (not in main package.json)
|
|
||||||
- Add mocks to central `setup.ts` file, not individual test files
|
|
||||||
|
|
||||||
### Testing Patterns
|
|
||||||
|
|
||||||
#### Database Testing
|
|
||||||
- Tests use **real in-memory databases** (LokiJS), not mocks
|
|
||||||
- **Critical**: Set `extraLokiOptions: {autosave: false}` to prevent memory leaks from lingering timeouts
|
|
||||||
- Always clean up: `await DatabaseManager.destroyServerDatabase(serverUrl)` in `afterEach`
|
|
||||||
- Initialize database in `beforeEach`: `await DatabaseManager.init([serverUrl])`
|
|
||||||
|
|
||||||
#### Ephemeral Store Testing
|
|
||||||
- Use `jest.resetModules()` between tests to clear singleton state
|
|
||||||
- Require fresh imports after reset: `const Store = require('./store').default;`
|
|
||||||
- Pattern from `app/store/ephemeral_store.test.ts`
|
|
||||||
|
|
||||||
#### WebSocket Testing
|
|
||||||
- Mock connection objects, not the WebSocket class itself
|
|
||||||
- Create mock with methods: `onOpen`, `onClose`, `onError`, `onMessage`, `send`, `readyState`
|
|
||||||
- Use `client.open()` in tests to trigger `onOpen` callback
|
|
||||||
- Pattern from `app/client/websocket/index.test.ts`
|
|
||||||
|
|
||||||
#### Timer Testing
|
|
||||||
- **Always use**: `jest.useFakeTimers({doNotFake: ['nextTick']})` to let promises resolve
|
|
||||||
- Helper: `advanceTimers(ms)` from `test/timer_helpers.ts` advances time AND waits for promises
|
|
||||||
- Never fake `nextTick` or async operations will hang
|
|
||||||
|
|
||||||
#### Component Rendering
|
|
||||||
- Three helpers in `test/intl-test-helper.tsx`:
|
|
||||||
- `renderWithIntl`: Basic internationalization
|
|
||||||
- `renderWithIntlAndTheme`: + Theme context
|
|
||||||
- `renderWithEverything`: + Database + Server URL
|
|
||||||
- Use `renderWithEverything` when components need database access
|
|
||||||
- Wrap async state updates in `act()` when testing React components
|
|
||||||
|
|
||||||
#### Remote Action Testing
|
|
||||||
- Mock `NetworkManager.getClient` in `beforeAll` to return mock client
|
|
||||||
- Mock client should implement all methods used in tests
|
|
||||||
- Test pattern: Setup DB state → Call action → Assert no error and data returned
|
|
||||||
- Pattern from `app/actions/remote/channel.test.ts`
|
|
||||||
|
|
||||||
#### DeviceEventEmitter Testing
|
|
||||||
- Use `DeviceEventEmitter.addListener()` to spy on events
|
|
||||||
- Remove listener in cleanup: `listener.remove()`
|
|
||||||
- Pattern: Store listener callback in jest.fn(), assert it was called with expected data
|
|
||||||
|
|
||||||
### Test Utilities
|
|
||||||
**TestHelper** singleton at `test/test_helper.ts`:
|
|
||||||
- `generateId()`: Deterministic IDs for tests
|
|
||||||
- `setupServerDatabase()`: Complete DB setup with basic entities
|
|
||||||
- `fakeUser()`, `fakeChannel()`, etc.: Entity generators with sensible defaults
|
|
||||||
- `fakeUserModel()`, `fakeChannelModel()`: WatermelonDB model creators
|
|
||||||
|
|
||||||
### Writing Quality Tests
|
|
||||||
|
|
||||||
**Test names:**
|
|
||||||
- Use `it('should...')` format, not `test('happy path')`
|
|
||||||
- Check array lengths in addition to individual items to ensure no extra elements
|
|
||||||
- Test expectations must match actual implementation behavior - if code deletes state immediately, test for `undefined`, not for modified state values
|
|
||||||
|
|
||||||
**Mocking:**
|
|
||||||
- Use `jest.mocked()` for full implementations
|
|
||||||
- Use `(thing as jest.Mock)` type assertion when mocking partial implementations that don't satisfy the full interface (e.g., `NetworkManager.getClient` returning minimal mock client)
|
|
||||||
- Avoid `any` types in tests - use `typeof import('./module').default` for proper typing of dynamically imported modules
|
|
||||||
|
|
||||||
**Don't test:**
|
|
||||||
- JavaScript operators (`===`, `JSON.stringify()`) or library behavior (React, RxJS)
|
|
||||||
- "Mocks calling mocks" - thin wrapper functions that just pass data through
|
|
||||||
- Multiple input variations when behavior is identical (testing 0, 1, 5 items when function doesn't special-case counts)
|
|
||||||
- Duplicate assertions - if test A verifies X, don't add test B that only verifies X
|
|
||||||
- AI-generated tests are heavily scrutinized and often rejected if they don't demonstrate real intended behavior
|
|
||||||
|
|
||||||
**Do test:**
|
|
||||||
- Business logic, error handling, integration points, side effects
|
|
||||||
- One example per code path is sufficient
|
|
||||||
|
|
||||||
## Development Practices
|
|
||||||
|
|
||||||
### TypeScript
|
|
||||||
- Trust well-formed data - don't add defensive null checks if types guarantee existence
|
|
||||||
- Use `??` (nullish coalescing) instead of `||` for fallbacks when appropriate
|
|
||||||
- Avoid non-null assertions (`!`) - use optional chaining: `metadataRes.metadata?.followers?.length ?? 0`
|
|
||||||
- Type function parameters using `ComponentProps<typeof Component>` instead of `as const` workarounds
|
|
||||||
- JSDoc comments must match actual parameter names and types
|
|
||||||
- **Prefer const objects over enums** for better tree-shaking: `export const Status = { Pending: 0, Done: 1 } as const;` with companion type `export type Status = typeof Status[keyof typeof Status];`
|
|
||||||
|
|
||||||
### React Hooks
|
|
||||||
- **Always explain why dependencies are omitted** from exhaustive-deps with specific comments
|
|
||||||
- Don't over-optimize with `useMemo` - only use for expensive calculations or objects passed as props to children
|
|
||||||
- Use `useCallback` for render functions passed to components, not for functions called within render
|
|
||||||
- Move module-level constants outside components
|
|
||||||
- Replace state variables with derived values when possible
|
|
||||||
- Stable references (refs, dispatch functions) don't need to be in dependency arrays but must have eslint-disable comments
|
|
||||||
- React-Native-Reanimated shared values (`.value`) don't cause re-renders and don't need to be dependencies
|
|
||||||
- **Critical**: When modifying existing `useEffect` hooks, you MUST include ALL dependencies used inside the effect, even if the original code had an incomplete dependency array - ESLint's `react-hooks/exhaustive-deps` rule is enforced strictly
|
|
||||||
- **Memoize callbacks for list items**: When rendering lists with `.map()`, avoid inline arrow functions like `onPress={() => handler(item.id)}`. Instead, have the child component accept the ID and call the callback internally, allowing the parent to pass a single memoized callback reference.
|
|
||||||
|
|
||||||
### React Native & UI
|
|
||||||
- Don't create hooks inside render functions - extract as local components
|
|
||||||
- Prefer `Button` components over `TouchableOpacity` when appropriate
|
|
||||||
- Non-memoized inline styles add render stress - define in stylesheet instead
|
|
||||||
- **`StyleSheet.create` is unnecessary** when using `makeStyleSheetFromTheme` - just return the plain object
|
|
||||||
- **Place `getStyleSheet` at file top** (after imports, before interfaces/components) not at the bottom
|
|
||||||
- Use `Platform.select()` for platform-specific values instead of ternaries
|
|
||||||
- StyleProps support nested lists - no need for custom `concatStyles()`
|
|
||||||
- Use `withTiming()` consistently for both states in animations
|
|
||||||
- Test components with long strings to ensure proper text handling
|
|
||||||
- Use constants from `PREFERENCES.THEMES` instead of hardcoded colors
|
|
||||||
- **Use `react-native-reanimated`** instead of React Native's `Animated` API for all animations (better performance, runs on UI thread)
|
|
||||||
- **Use `usePreventDoubleTap` hook** for button press handlers to prevent accidental double submissions
|
|
||||||
- **Use `useServerUrl()` hook** instead of passing `serverUrl` as a prop - it's available via context
|
|
||||||
- **Use existing components**: Check for `<Loading>` instead of `<ActivityIndicator>`, `safeParseJSON()` instead of try/catch JSON.parse
|
|
||||||
- **Parent checks before mounting**: If a child component would return null for empty data, have the parent conditionally render instead (e.g., `{items.length > 0 && <ItemList items={items} />}`)
|
|
||||||
- **Use `@utils/url` utilities**: `tryOpenURL()` instead of `Linking.openURL()`, `getUrlDomain()` with `urlParse` instead of `new URL()`
|
|
||||||
|
|
||||||
### Code Quality & Linting
|
|
||||||
|
|
||||||
**Import Organization:**
|
|
||||||
- Consolidate all imports from the same module into a single statement
|
|
||||||
- Use inline `type` keyword for type imports: `import {SomeValue, type SomeType} from '@module'`
|
|
||||||
- Example: `import {StreamingEvents, type StreamingState} from '@agents/types'`
|
|
||||||
|
|
||||||
**Unused Code:**
|
|
||||||
- Remove unused imports, parameters, interfaces, and variables completely
|
|
||||||
- ESLint enforces strict no-unused-vars - code with unused declarations will fail CI
|
|
||||||
- When a parameter is truly unused, remove it from the function signature
|
|
||||||
|
|
||||||
**Pre-commit:**
|
|
||||||
- Run `npm run fix` to auto-fix linting issues before committing
|
|
||||||
- Pre-commit hook runs ESLint + TypeScript checking automatically
|
|
||||||
|
|
||||||
### Error Handling & Logging
|
|
||||||
- Use `logError()` instead of `console.error()` or `console.log()`
|
|
||||||
- Use `logDebug()` for debug-level information
|
|
||||||
- Don't ignore potential errors silently - handle them or add intentional comments
|
|
||||||
- Don't log sensitive information
|
|
||||||
- **Add function/class prefix to logs**: e.g., `logError('[ClassName.methodName]', error)` to make debugging easier
|
|
||||||
|
|
||||||
### State Management
|
|
||||||
- Always handle errors from database operations
|
|
||||||
- Consider race conditions in async functions within effects
|
|
||||||
- Local state initialized with prop values won't update when props change - sync with `useEffect` if needed
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
- Create parsers/expensive objects only once, not on every render (use refs or useMemo)
|
|
||||||
- Memoize AST output to avoid regenerating on every render
|
|
||||||
- Use named constants instead of magic numbers and check if one already exists.
|
|
||||||
|
|
||||||
### Localization (i18n)
|
|
||||||
- **CRITICAL**: Only update `en.json` - never modify other language files or Weblate gets corrupted
|
|
||||||
- **Adding new strings**: Define the message ID and defaultMessage in code using `defineMessages()`, then run `npm run i18n-extract` to automatically add them to `en.json`
|
|
||||||
- Default messages in code must match JSON translations exactly, including newlines
|
|
||||||
- Translation IDs should be descriptive enough for translators to understand context
|
|
||||||
- Don't reuse translation IDs
|
|
||||||
- Translate user-facing strings, not debug/error messages
|
|
||||||
|
|
||||||
### Markdown Component Usage
|
|
||||||
**Required props:**
|
|
||||||
- `baseTextStyle`, `value`, `theme`, `location`
|
|
||||||
- Use `Screens.CHANNEL` or appropriate constant from `@constants` for the `location` prop
|
|
||||||
- `textStyles`, `blockStyles`, `enableLatex`, and similar props are auto-generated via HOC
|
|
||||||
|
|
||||||
**Important Limitations:**
|
|
||||||
- **Cannot embed custom React components inline**: The Markdown component renders CommonMark to native components. You cannot insert custom React components (like badges, icons) inline within the markdown text flow
|
|
||||||
- **Link handling**: Links in markdown go through `openLink()` → deep link system → `tryOpenURL()`. Custom URL schemes (e.g., `citation:`, `action:`) will show error alerts unless you implement a full deep link handler in `app/utils/deep_link/`
|
|
||||||
- **Post-processing not possible**: Unlike web, you cannot post-process the rendered markdown JSX tree to replace markers with React components
|
|
||||||
|
|
||||||
### Adding New Post Types
|
|
||||||
When adding custom post types (e.g., for new products):
|
|
||||||
1. Add the type string to `PostType` union in `types/api/posts.d.ts`
|
|
||||||
2. Define constants in your product's constants file (e.g., `app/products/agents/constants.ts`)
|
|
||||||
3. Use the globally-available `Post` type (defined in `types/api/posts.d.ts`) - no import needed
|
|
||||||
|
|
||||||
### Platform-Specific
|
|
||||||
- iOS: Opt out of iOS 18+ features (liquid glass) until UI is properly addressed
|
|
||||||
- Android: Test on multiple API levels (34, 35, 36) to verify behavior with edge-to-edge changes
|
|
||||||
- Use `Set` instead of `Array` for exception lists (faster, ensures uniqueness)
|
|
||||||
|
|
||||||
### testID Convention
|
|
||||||
Components use hierarchical testIDs: `component.subcomponent.element`
|
|
||||||
- Example: `channel_list.category.CATEGORY.channel_item.ID.display_name`
|
|
||||||
- Example: `channel.post_draft.send_action.send.button`
|
|
||||||
|
|
||||||
## Common Mistakes to Avoid
|
|
||||||
|
|
||||||
### JavaScript Compatibility
|
|
||||||
- **Don't use `Object.hasOwn()`** - React Native doesn't support ES2022+ features
|
|
||||||
- ❌ `Object.hasOwn(obj, 'key')`
|
|
||||||
- ✅ `'key' in obj`
|
|
||||||
|
|
||||||
### Anti-Patterns
|
|
||||||
- Conditionals in tests for TypeScript satisfaction
|
|
||||||
- Creating new arrays/objects on every render when passed as props
|
|
||||||
- Over-engineering - follow YAGNI principle
|
|
||||||
- Impossible states with boolean + optional parameters that depend on each other
|
|
||||||
- Redundant checks - if `array.length >= 3`, indices 0,1,2 are guaranteed to exist
|
|
||||||
- Verbose error messages that reveal internal system details
|
|
||||||
- Mass assignment risks - whitelist fields instead of sending `Partial<Model>` objects
|
|
||||||
- Duplicate conditional branches that return identical results - consolidate into a single condition (e.g., `if (A && B)` and `if (A && !B)` both returning same thing should be just `if (A)`)
|
|
||||||
|
|
||||||
### Security
|
|
||||||
- Question whether showing secrets with "eye" toggle is a security concern
|
|
||||||
- Don't commit files that likely contain secrets (.env, credentials.json)
|
|
||||||
- Consider pre-populating sensitive fields with `*********` instead of actual values
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
- **Patches**: Applied via `patch-package` on postinstall (`patches/` directory)
|
|
||||||
- **Self-compiled apps**: Require your own Mattermost Push Notification Service
|
|
||||||
- **Self-signed certificates**: Not supported
|
|
||||||
|
|
||||||
## Development Notes
|
|
||||||
|
|
||||||
### Hot Reload & Build Times
|
|
||||||
- **Hot reload**: ~3 seconds for JS/TS changes; does NOT work for native code changes
|
|
||||||
- **Native rebuilds**: 10-30 minutes (avoid unless necessary)
|
|
||||||
- **Pre-commit hook**: Runs ESLint + incremental TypeScript checking
|
|
||||||
|
|
||||||
### Known Issues
|
|
||||||
- Many components require `theme` prop - check for `useTheme()` hook in parent component
|
|
||||||
3
GEMINI.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# 필수 준수 사항
|
||||||
|
- agent-ops/rules/global.md 파일을 읽고 이에 대해 꼭 준수해서 진행해야한다.
|
||||||
|
- global.md 내용을 모르면 추정하지 말고 먼저 해당 파일을 확인한다.
|
||||||
1
Gemfile
|
|
@ -1,3 +1,4 @@
|
||||||
source "https://rubygems.org"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
gem "cocoapods", "1.16.1"
|
gem "cocoapods", "1.16.1"
|
||||||
|
gem "rexml", ">= 3.4.2"
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ GEM
|
||||||
netrc (0.11.0)
|
netrc (0.11.0)
|
||||||
nkf (0.2.0)
|
nkf (0.2.0)
|
||||||
public_suffix (4.0.7)
|
public_suffix (4.0.7)
|
||||||
rexml (3.3.9)
|
rexml (3.4.4)
|
||||||
ruby-macho (2.5.1)
|
ruby-macho (2.5.1)
|
||||||
securerandom (0.4.0)
|
securerandom (0.4.0)
|
||||||
typhoeus (1.4.1)
|
typhoeus (1.4.1)
|
||||||
|
|
@ -107,6 +107,7 @@ PLATFORMS
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
cocoapods (= 1.16.1)
|
cocoapods (= 1.16.1)
|
||||||
|
rexml (>= 3.4.2)
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.5.11
|
2.5.11
|
||||||
|
|
|
||||||
124
NOTICE.txt
|
|
@ -20,6 +20,21 @@ A spec-compliant polyfill for Intl.DateTimeFormat fully tested by the official E
|
||||||
|
|
||||||
* LICENSE: MIT
|
* LICENSE: MIT
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## @formatjs/intl-displaynames
|
||||||
|
|
||||||
|
This product contains '@formatjs/intl-displaynames' by Linjie Ding.
|
||||||
|
|
||||||
|
Polyfill for: https://tc39.es/proposal-intl-displaynames
|
||||||
|
|
||||||
|
* HOMEPAGE:
|
||||||
|
* https://github.com/formatjs/formatjs
|
||||||
|
|
||||||
|
* LICENSE: MIT
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## @formatjs/intl-getcanonicallocales
|
## @formatjs/intl-getcanonicallocales
|
||||||
|
|
@ -135,6 +150,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
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
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
|
## @mattermost/calls
|
||||||
|
|
@ -1702,6 +1753,43 @@ Lightweight fuzzy-search
|
||||||
limitations under the License.
|
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
|
## html-entities
|
||||||
|
|
@ -2437,6 +2525,42 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
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
|
## react-native-keychain
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Mattermost Mobile v2
|
# Mattermost Mobile v2
|
||||||
|
|
||||||
- **Minimum Server versions:** Current ESR version (10.11.0+)
|
- **Minimum Server versions:** Current ESR version (10.11.0+)
|
||||||
- **Supported iOS versions:** 15.1+
|
- **Supported iOS versions:** 16.0+
|
||||||
- **Supported Android versions:** 7.0+
|
- **Supported Android versions:** 7.0+
|
||||||
|
|
||||||
Mattermost is an open source Slack-alternative used by thousands of companies around the world in 21 languages. Learn more at [https://mattermost.com](https://mattermost.com).
|
Mattermost is an open source Slack-alternative used by thousands of companies around the world in 21 languages. Learn more at [https://mattermost.com](https://mattermost.com).
|
||||||
|
|
@ -47,7 +47,7 @@ This script will:
|
||||||
To help with testing app updates before they're released, you can:
|
To help with testing app updates before they're released, you can:
|
||||||
|
|
||||||
1. Sign up to be a beta tester
|
1. Sign up to be a beta tester
|
||||||
- [Android](https://play.google.com/apps/testing/com.mattermost.rnbeta)
|
- [Android](https://play.google.com/apps/testing/com.tokilabs.mattermost)
|
||||||
- [iOS](https://testflight.apple.com/join/Q7Rx7K9P) - Open this link from your iOS device
|
- [iOS](https://testflight.apple.com/join/Q7Rx7K9P) - Open this link from your iOS device
|
||||||
2. Install the `Mattermost Beta` app. New updates in the Beta app are released periodically. You will receive a notification when the new updates are available.
|
2. Install the `Mattermost Beta` app. New updates in the Beta app are released periodically. You will receive a notification when the new updates are available.
|
||||||
3. File any bugs you find by filing a [GitHub issue](https://github.com/mattermost/mattermost-mobile/issues) with:
|
3. File any bugs you find by filing a [GitHub issue](https://github.com/mattermost/mattermost-mobile/issues) with:
|
||||||
|
|
@ -59,7 +59,7 @@ To help with testing app updates before they're released, you can:
|
||||||
- Join the [Native Mobile Apps channel](https://community.mattermost.com/core/channels/native-mobile-apps) to see what's new and discuss feedback with other contributors and the core team
|
- Join the [Native Mobile Apps channel](https://community.mattermost.com/core/channels/native-mobile-apps) to see what's new and discuss feedback with other contributors and the core team
|
||||||
|
|
||||||
You can leave the Beta testing program at any time:
|
You can leave the Beta testing program at any time:
|
||||||
- On Android, [click this link](https://play.google.com/apps/testing/com.mattermost.rnbeta) while logged in with your Google Play email address used to opt-in for the Beta program, then click **Leave the program**.
|
- On Android, [click this link](https://play.google.com/apps/testing/com.tokilabs.mattermost) while logged in with your Google Play email address used to opt-in for the Beta program, then click **Leave the program**.
|
||||||
- On iOS, access the `Mattermost Beta` app page in TestFlight and click **Stop Testing**.
|
- On iOS, access the `Mattermost Beta` app page in TestFlight and click **Stop Testing**.
|
||||||
|
|
||||||
### Contribute Code
|
### Contribute Code
|
||||||
|
|
|
||||||
32
agent-ops/rules/_templates/domain-rule-template.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
---
|
||||||
|
name: <domain-name>
|
||||||
|
description: <도메인 한 줄 설명>
|
||||||
|
type: core | supporting | generic
|
||||||
|
---
|
||||||
|
|
||||||
|
# <Domain Name>
|
||||||
|
|
||||||
|
## 목적 / 책임
|
||||||
|
<이 도메인이 담당하는 핵심 책임>
|
||||||
|
|
||||||
|
## 포함 경로
|
||||||
|
- `app/<path>/`
|
||||||
|
- `app/actions/local/<file>.ts`
|
||||||
|
- `app/actions/remote/<file>.ts`
|
||||||
|
|
||||||
|
## 제외 경로
|
||||||
|
- <이 도메인에 속하지 않는 경로>
|
||||||
|
|
||||||
|
## 주요 구성 요소
|
||||||
|
- **<component>**: <역할>
|
||||||
|
- **<component>**: <역할>
|
||||||
|
|
||||||
|
## 유지할 패턴
|
||||||
|
- <이 도메인에서 따라야 할 패턴>
|
||||||
|
|
||||||
|
## 다른 도메인과의 경계
|
||||||
|
- **database 도메인**: DB 조작은 database 도메인 패턴을 따른다
|
||||||
|
- **network 도메인**: API 호출은 ClientMix 패턴을 따른다
|
||||||
|
|
||||||
|
## 금지 사항
|
||||||
|
- <이 도메인에서 하면 안 되는 것>
|
||||||
46
agent-ops/rules/domain/database/rules.md
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
---
|
||||||
|
name: database
|
||||||
|
description: WatermelonDB 이중 DB 시스템 - 앱 DB와 서버별 DB
|
||||||
|
type: supporting
|
||||||
|
---
|
||||||
|
|
||||||
|
# Database Domain
|
||||||
|
|
||||||
|
## 목적 / 책임
|
||||||
|
WatermelonDB 기반 이중 데이터베이스를 관리한다. DatabaseManager 싱글톤, operator, 스키마, 모델을 담당한다.
|
||||||
|
|
||||||
|
## 포함 경로
|
||||||
|
- `app/database/` (전체)
|
||||||
|
- `app/database/manager/` - DatabaseManager 싱글톤
|
||||||
|
- `app/database/models/app/` - 앱 전역 모델 (서버 목록 등)
|
||||||
|
- `app/database/models/server/` - 서버별 모델 (채널, 유저, 포스트 등)
|
||||||
|
- `app/database/operator/` - 데이터 쓰기 operator
|
||||||
|
- `app/database/schema/` - DB 스키마 정의
|
||||||
|
- `docs/database/` - DB 스키마 문서
|
||||||
|
|
||||||
|
## 제외 경로
|
||||||
|
- `app/queries/` (Query Layer는 별도 - 여기서 DB를 읽는다)
|
||||||
|
|
||||||
|
## 주요 구성 요소
|
||||||
|
- **DatabaseManager**: 모든 DB 인스턴스 관리 싱글톤 (`app/database/manager/index.ts`)
|
||||||
|
- **App Database**: 서버 목록 등 전역 앱 상태 (하나만 존재)
|
||||||
|
- **Server Database**: 서버별 채널/유저/포스트 데이터 (서버당 하나)
|
||||||
|
- **Operators**: transformer/handler/comparator 패턴으로 데이터 쓰기
|
||||||
|
- **Schema**: 테이블 정의 및 마이그레이션
|
||||||
|
|
||||||
|
## 유지할 패턴
|
||||||
|
- DB 경로: iOS는 App Group, Android는 `${documentDirectory}/databases/`
|
||||||
|
- Operator를 통해 batch 작업으로 데이터 쓰기
|
||||||
|
- Sync handler는 create/update/delete 전체 라이프사이클 처리 필수
|
||||||
|
- 스키마 변경 시 반드시 버전 번호 증가 + 문서(`docs/database/`) 업데이트
|
||||||
|
- Query Layer(`app/queries/`)는 `query*`, `observe*`, `get*`, `prepare*` 패턴
|
||||||
|
|
||||||
|
## 다른 도메인과의 경계
|
||||||
|
- **모든 도메인**: DB 직접 접근보다 Query Layer(`app/queries/`) 사용 권장
|
||||||
|
- **network 도메인**: remote action이 API 응답을 operator에 전달
|
||||||
|
|
||||||
|
## 금지 사항
|
||||||
|
- 스키마 변경 후 `docs/database/` 문서 미업데이트
|
||||||
|
- Sync handler에서 stale 레코드 삭제 누락 (`prepareDestroyPermanently()` 필수)
|
||||||
|
- 테스트에서 `extraLokiOptions: {autosave: false}` 누락 (메모리 누수)
|
||||||
|
- 테스트 후 `DatabaseManager.destroyServerDatabase()` cleanup 누락
|
||||||
45
agent-ops/rules/domain/messaging/rules.md
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
---
|
||||||
|
name: messaging
|
||||||
|
description: 채널, 포스트, 스레드, 드래프트 등 핵심 메시징 기능
|
||||||
|
type: core
|
||||||
|
---
|
||||||
|
|
||||||
|
# Messaging Domain
|
||||||
|
|
||||||
|
## 목적 / 책임
|
||||||
|
Mattermost의 핵심 메시징 기능: 채널, 포스트, 스레드, 리액션, 드래프트, 예약 포스트를 담당한다.
|
||||||
|
|
||||||
|
## 포함 경로
|
||||||
|
- `app/actions/local/channel.ts`, `post.ts`, `thread.ts`, `draft.ts`, `reactions.ts`, `scheduled_post.ts`
|
||||||
|
- `app/actions/remote/channel.ts`, `post.ts`, `thread.ts`, `reactions.ts`, `search.ts`
|
||||||
|
- `app/queries/servers/channel.ts`, `post.ts`, `thread.ts`, `drafts.ts`, `scheduled_post.ts`, `reaction.ts`
|
||||||
|
- `app/database/models/server/channel*.ts`, `post.ts`, `thread*.ts`, `draft.ts`, `reaction.ts`, `scheduled_post.ts`
|
||||||
|
- `app/screens/channel/`, `app/screens/thread/`, `app/screens/edit_post/`
|
||||||
|
- `app/components/post_list/`, `app/components/post_draft/`
|
||||||
|
|
||||||
|
## 제외 경로
|
||||||
|
- `app/products/` (products는 각 product 도메인)
|
||||||
|
- `app/actions/remote/user.ts` (user 도메인)
|
||||||
|
|
||||||
|
## 주요 구성 요소
|
||||||
|
- **Channel**: 채널 CRUD, 멤버십, 카테고리 관리
|
||||||
|
- **Post**: 포스트 생성/수정/삭제, 파일 첨부, 리액션
|
||||||
|
- **Thread**: 스레드 팔로우, 언급, 참여자 관리
|
||||||
|
- **Draft**: 임시저장, 예약 포스트
|
||||||
|
- **Search**: 채널/포스트 검색
|
||||||
|
|
||||||
|
## 유지할 패턴
|
||||||
|
- Remote action: `fetchXxx()` → API 호출 → operator로 DB 저장 → `{error}` 반환
|
||||||
|
- Local action: DB 조작만 수행
|
||||||
|
- Sync handler는 create/update/delete 전체 라이프사이클을 처리한다
|
||||||
|
- `fetchMissingProfilesByIds()` 사용 - 직접 `client.getProfilesByIds()` 호출 금지
|
||||||
|
|
||||||
|
## 다른 도메인과의 경계
|
||||||
|
- **database 도메인**: WatermelonDB 스키마 변경 시 `docs/database/server/server.md` 업데이트 필요
|
||||||
|
- **network 도메인**: API 호출은 `client/rest.ts`의 ClientMix 패턴 사용
|
||||||
|
- **ui 도메인**: 화면 렌더링은 ui 도메인, 데이터 로직은 이 도메인
|
||||||
|
|
||||||
|
## 금지 사항
|
||||||
|
- `console.error()` / `console.log()` 직접 사용 (→ `logError()`, `logDebug()` 사용)
|
||||||
|
- `TouchableOpacity` 사용 (→ `Pressable` 사용)
|
||||||
|
- DB sync handler에서 삭제 누락 (stale 레코드는 `prepareDestroyPermanently()` 처리)
|
||||||
49
agent-ops/rules/domain/products-agents/rules.md
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
---
|
||||||
|
name: products-agents
|
||||||
|
description: AI 에이전트 기능 - WebSocket 스트리밍, AI 봇, 툴 호출
|
||||||
|
type: core
|
||||||
|
---
|
||||||
|
|
||||||
|
# Products/Agents Domain
|
||||||
|
|
||||||
|
## 목적 / 책임
|
||||||
|
AI 에이전트 기능을 담당한다. WebSocket 스트리밍, AI 봇 관리, 툴 호출/승인, 어노테이션을 처리한다.
|
||||||
|
|
||||||
|
## 포함 경로
|
||||||
|
- `app/products/agents/` (전체)
|
||||||
|
- `app/products/agents/actions/`
|
||||||
|
- `app/products/agents/client/rest.ts`
|
||||||
|
- `app/products/agents/components/`
|
||||||
|
- `app/products/agents/constants.ts`
|
||||||
|
- `app/products/agents/hooks/`
|
||||||
|
- `app/products/agents/screens/`
|
||||||
|
- `app/products/agents/store/`
|
||||||
|
- `app/products/agents/types/`
|
||||||
|
- `app/products/agents/websocket/`
|
||||||
|
|
||||||
|
## 제외 경로
|
||||||
|
- `app/actions/remote/` (messaging 도메인)
|
||||||
|
- `app/products/calls/` (calls 도메인)
|
||||||
|
|
||||||
|
## 주요 구성 요소
|
||||||
|
- **WebSocket Handler**: `custom_mattermost-ai_postupdate` 이벤트 처리 (start, message, reasoning, tool_call, annotation, end)
|
||||||
|
- **Streaming Store**: 스트리밍 중 ephemeral 상태 관리
|
||||||
|
- **AI Bot**: AI 봇 목록 조회 및 DB 동기화 (`handleAIBots`)
|
||||||
|
- **Tool Call**: 툴 호출 승인/거절 처리
|
||||||
|
- **Client (ClientMix)**: `app/products/agents/client/rest.ts`
|
||||||
|
|
||||||
|
## 유지할 패턴
|
||||||
|
- **WebSocket 스트리밍**: `ENDED` 이벤트에서 로컬 스트리밍 상태를 반드시 초기화 (stale state 방지)
|
||||||
|
- **ClientMix 패턴**: API 호출은 `client/rest.ts` 내에서만 수행, action에서 직접 `doFetch()` 금지
|
||||||
|
- **PostType 추가 시**: `types/api/posts.d.ts`의 `PostType` union에 타입 추가
|
||||||
|
- **Sync handler**: create/update/delete 모두 처리 (`prepareDestroyPermanently()` 필수)
|
||||||
|
|
||||||
|
## 다른 도메인과의 경계
|
||||||
|
- **messaging 도메인**: 포스트 데이터는 `POST_EDITED` WebSocket 이벤트로 수신
|
||||||
|
- **database 도메인**: Agents 전용 DB 모델 사용
|
||||||
|
- **network 도메인**: ClientMix를 통해 NetworkManager와 연결
|
||||||
|
|
||||||
|
## 금지 사항
|
||||||
|
- 툴 호출 상태 변경을 별도 WebSocket 이벤트로 처리 (→ `POST_EDITED`로 수신)
|
||||||
|
- `ENDED` 이벤트 후 스트리밍 상태 미초기화
|
||||||
|
- action 파일에서 직접 `client.doFetch()` 호출
|
||||||
41
agent-ops/rules/domain/products-calls/rules.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
---
|
||||||
|
name: products-calls
|
||||||
|
description: 음성/화상 통화 기능
|
||||||
|
type: core
|
||||||
|
---
|
||||||
|
|
||||||
|
# Products/Calls Domain
|
||||||
|
|
||||||
|
## 목적 / 책임
|
||||||
|
음성/화상 통화 기능을 담당한다. 통화 연결, 상태 관리, UI를 처리한다.
|
||||||
|
|
||||||
|
## 포함 경로
|
||||||
|
- `app/products/calls/` (전체)
|
||||||
|
- `app/products/calls/actions/`
|
||||||
|
- `app/products/calls/client/rest.ts`
|
||||||
|
- `app/products/calls/components/`
|
||||||
|
- `app/products/calls/constants.ts`
|
||||||
|
- `app/products/calls/hooks/`
|
||||||
|
- `app/products/calls/screens/`
|
||||||
|
- `app/products/calls/store/`
|
||||||
|
- `app/products/calls/types/`
|
||||||
|
|
||||||
|
## 제외 경로
|
||||||
|
- `app/products/agents/` (agents 도메인)
|
||||||
|
- `app/products/playbooks/` (playbooks 도메인)
|
||||||
|
|
||||||
|
## 주요 구성 요소
|
||||||
|
- **Call Connection**: WebRTC 기반 통화 연결 관리
|
||||||
|
- **Call State**: Ephemeral store로 통화 상태 관리
|
||||||
|
- **Client (ClientMix)**: `app/products/calls/client/rest.ts`
|
||||||
|
|
||||||
|
## 유지할 패턴
|
||||||
|
- **ClientMix 패턴**: API 호출은 `client/rest.ts` 내에서만 수행
|
||||||
|
- **Ephemeral Store**: 통화 상태는 ephemeral store로 관리 (DB 아님)
|
||||||
|
|
||||||
|
## 다른 도메인과의 경계
|
||||||
|
- **messaging 도메인**: 채널 정보는 messaging 도메인 쿼리 사용
|
||||||
|
- **network 도메인**: ClientMix를 통해 NetworkManager와 연결
|
||||||
|
|
||||||
|
## 금지 사항
|
||||||
|
- action 파일에서 직접 `client.doFetch()` 호출
|
||||||
53
agent-ops/rules/domain/ui/rules.md
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
---
|
||||||
|
name: ui
|
||||||
|
description: 컴포넌트, 스크린, 네비게이션, 스타일링
|
||||||
|
type: generic
|
||||||
|
---
|
||||||
|
|
||||||
|
# UI Domain
|
||||||
|
|
||||||
|
## 목적 / 책임
|
||||||
|
React Native UI 컴포넌트, 스크린, 네비게이션, 스타일링을 담당한다.
|
||||||
|
|
||||||
|
## 포함 경로
|
||||||
|
- `app/components/` - 재사용 컴포넌트
|
||||||
|
- `app/screens/` - 화면 단위 컴포넌트
|
||||||
|
- `app/hooks/` - 공통 React hooks
|
||||||
|
- `app/utils/` - UI 유틸리티
|
||||||
|
- `share_extension/` - Android 공유 확장 (React Navigation 사용)
|
||||||
|
|
||||||
|
## 제외 경로
|
||||||
|
- `app/products/*/components/` (각 product 도메인)
|
||||||
|
- `app/actions/` (비즈니스 로직은 messaging/product 도메인)
|
||||||
|
|
||||||
|
## 주요 구성 요소
|
||||||
|
- **Navigation**: `react-native-navigation` v7 (메인 앱), `React Navigation` (share extension)
|
||||||
|
- **Components**: 60+ 재사용 컴포넌트
|
||||||
|
- **Screens**: 63+ 화면 컴포넌트 (각각 독립 등록)
|
||||||
|
- **Themes**: `PREFERENCES.THEMES` 상수 사용
|
||||||
|
|
||||||
|
## 유지할 패턴
|
||||||
|
- `Pressable` 사용 (→ `TouchableOpacity` 금지), 항상 pressed 피드백 추가
|
||||||
|
- `typography()` 사용 (→ raw `fontSize`/`fontWeight`/`fontFamily` 금지)
|
||||||
|
- `makeStyleSheetFromTheme` 사용 시 `StyleSheet.create` 불필요
|
||||||
|
- `getStyleSheet` 는 파일 상단(imports 이후)에 위치
|
||||||
|
- 정적 객체(hitSlop 등)는 모듈 레벨 상수로 추출
|
||||||
|
- `FormattedText` 사용 (→ `<Text>{intl.formatMessage(...)}</Text>` 지양)
|
||||||
|
- `useServerUrl()` hook 사용 (→ `serverUrl` prop 전달 지양)
|
||||||
|
- `usePreventDoubleTap` hook 사용 (버튼 중복 클릭 방지)
|
||||||
|
- 리스트 렌더링: 인라인 화살표 함수 금지, 자식 컴포넌트에서 ID 받아 처리
|
||||||
|
- `useDidMount` (→ `useEffect(cb, [])` 대신), `useInitialValue` (→ `useMemo(f, [])` 대신)
|
||||||
|
|
||||||
|
## 다른 도메인과의 경계
|
||||||
|
- **messaging/product 도메인**: UI는 데이터 표시만, 비즈니스 로직은 해당 도메인
|
||||||
|
- **database 도메인**: `observe*` 쿼리를 통해 반응형 데이터 구독
|
||||||
|
|
||||||
|
## 금지 사항
|
||||||
|
- `TouchableOpacity` 사용
|
||||||
|
- raw `fontSize`, `fontWeight`, `fontFamily` 스타일 직접 사용
|
||||||
|
- `Object.hasOwn()` 사용 (ES2022+, RN 미지원 → `'key' in obj` 사용)
|
||||||
|
- `Animated` API 사용 (→ `react-native-reanimated` 사용)
|
||||||
|
- `new URL()` 사용 (→ `urlParse` 사용)
|
||||||
|
- `Linking.openURL()` 직접 사용 (→ `tryOpenURL()` 사용)
|
||||||
|
- `ActivityIndicator` 직접 사용 (→ `<Loading>` 컴포넌트 사용)
|
||||||
|
- 커스텀 React 컴포넌트를 Markdown 컴포넌트 내부에 인라인 삽입
|
||||||
23
agent-ops/rules/global.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
---
|
||||||
|
name: global
|
||||||
|
description: 전 프로젝트 공통 규칙
|
||||||
|
version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Global Rules
|
||||||
|
|
||||||
|
## 응답 언어
|
||||||
|
- 한국어로 응답한다.
|
||||||
|
|
||||||
|
## 기존 구조 우선
|
||||||
|
- 새 파일 생성보다 기존 파일 수정을 우선한다.
|
||||||
|
- 기존 패턴과 컨벤션을 따른다.
|
||||||
|
|
||||||
|
## 코드 변경 원칙
|
||||||
|
- 코드 변경 전 관련 domain rule을 확인한다.
|
||||||
|
- 요청 범위를 넘는 변경을 금지한다.
|
||||||
|
- 불확실하면 단정하지 말고 후보로 제시한다.
|
||||||
|
|
||||||
|
## 작업 전 확인
|
||||||
|
- `agent-ops/skills/router.md` 를 먼저 참조하여 필요한 skill 또는 domain rule만 로드한다.
|
||||||
|
- 관련 rule / skill 만 최소로 로드한다.
|
||||||
35
agent-ops/skills/_templates/skill-template.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
---
|
||||||
|
name: <skill-name>
|
||||||
|
description: <skill 한 줄 설명>
|
||||||
|
version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# <Skill Name>
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
<이 skill이 해결하는 문제>
|
||||||
|
|
||||||
|
## 언제 호출할지
|
||||||
|
- <이 skill을 사용해야 하는 상황>
|
||||||
|
- <이 skill을 사용해야 하는 상황>
|
||||||
|
|
||||||
|
## 입력
|
||||||
|
- `<param>`: <설명> (필수/선택)
|
||||||
|
|
||||||
|
## 먼저 확인할 것
|
||||||
|
- [ ] <사전 확인 항목>
|
||||||
|
- [ ] <사전 확인 항목>
|
||||||
|
|
||||||
|
## 실행 절차
|
||||||
|
1. <단계>
|
||||||
|
2. <단계>
|
||||||
|
3. <단계>
|
||||||
|
|
||||||
|
## 출력 형식
|
||||||
|
```
|
||||||
|
<출력 예시>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 금지 사항
|
||||||
|
- <하면 안 되는 것>
|
||||||
|
- <하면 안 되는 것>
|
||||||
48
agent-ops/skills/code-change/SKILL.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
---
|
||||||
|
name: code-change
|
||||||
|
description: 코드 변경 절차 - 읽기 → 도메인 rule 확인 → 변경 → 검증
|
||||||
|
version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Code Change
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
코드 변경 시 도메인 규칙을 준수하고 기존 패턴을 따른다.
|
||||||
|
|
||||||
|
## 언제 호출할지
|
||||||
|
- 새 기능을 추가할 때
|
||||||
|
- 버그를 수정할 때
|
||||||
|
- 기존 코드를 수정할 때
|
||||||
|
|
||||||
|
## 입력
|
||||||
|
- `변경 대상`: 파일 경로 또는 기능 설명 (필수)
|
||||||
|
- `변경 내용`: 무엇을 어떻게 변경할지 (필수)
|
||||||
|
|
||||||
|
## 먼저 확인할 것
|
||||||
|
- [ ] `find-domain` skill로 대상 도메인 판별
|
||||||
|
- [ ] 해당 domain rule 로드 (`agent-ops/rules/domain/<domain>/rules.md`)
|
||||||
|
- [ ] 변경할 파일을 Read tool로 먼저 읽기
|
||||||
|
- [ ] 기존 패턴과 컨벤션 파악
|
||||||
|
|
||||||
|
## 실행 절차
|
||||||
|
1. 대상 파일 읽기 (Read tool 사용)
|
||||||
|
2. 도메인 rule의 "유지할 패턴"과 "금지 사항" 확인
|
||||||
|
3. 변경 범위를 요청 범위로 제한 (추가 리팩터링 금지)
|
||||||
|
4. 변경 적용 (Edit tool 우선, 전면 재작성은 Write tool)
|
||||||
|
5. TypeScript 타입 확인 (`npm run tsc`)
|
||||||
|
6. 린팅 확인 (`npm run fix`)
|
||||||
|
|
||||||
|
## 출력 형식
|
||||||
|
변경 후:
|
||||||
|
```
|
||||||
|
변경 파일: <path>
|
||||||
|
변경 내용: <요약>
|
||||||
|
확인 필요: <있으면 명시>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 금지 사항
|
||||||
|
- 파일을 읽기 전에 변경하지 않는다
|
||||||
|
- 요청 범위를 넘는 리팩터링, 주석 추가, 타입 어노테이션 추가
|
||||||
|
- 도메인 rule의 금지 사항 위반
|
||||||
|
- `console.log()` / `console.error()` 직접 사용
|
||||||
|
- 다른 언어 파일 수정 (`en.json` 외 i18n 파일 수정 금지)
|
||||||
96
agent-ops/skills/create-domain-rule/SKILL.md
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
---
|
||||||
|
name: create-domain-rule
|
||||||
|
description: 새로운 도메인 rules.md 파일을 생성하기 위한 범용 스킬
|
||||||
|
version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Create Domain Rule
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
`agent-ops/rules/domain/<domain-name>/rules.md` 파일을 올바른 형식으로 생성한다.
|
||||||
|
실제 프로젝트 폴더 구조를 분석하여 경로와 구성 요소를 자동으로 채운다.
|
||||||
|
생성 후 router.md 의 코드 변경 라우팅 테이블에 항목을 추가한다.
|
||||||
|
|
||||||
|
## 언제 호출할지
|
||||||
|
|
||||||
|
- 새로운 도메인 영역의 코드를 처음 변경하기 전에
|
||||||
|
- 기존 domain rule이 없는 경로에 대해 규칙이 필요할 때
|
||||||
|
- 사용자가 특정 도메인의 rule 파일을 만들어 달라고 요청할 때
|
||||||
|
- agent-ops 초기 scaffold 이후 도메인을 추가 확장할 때
|
||||||
|
|
||||||
|
## 입력
|
||||||
|
|
||||||
|
- `domain-name`: 생성할 도메인 이름, kebab-case (필수)
|
||||||
|
- `domain-type`: `core` / `supporting` / `generic` 중 하나 (필수)
|
||||||
|
- `path-hints`: 이 도메인에 해당하는 경로 힌트 목록 (선택, 없으면 자동 탐색)
|
||||||
|
|
||||||
|
## 먼저 확인할 것
|
||||||
|
|
||||||
|
- [ ] `agent-ops/rules/domain/<domain-name>/rules.md` 가 이미 존재하는지 확인
|
||||||
|
- [ ] `agent-ops/rules/_templates/domain-rule-template.md` 를 읽어 최신 템플릿 형식 파악
|
||||||
|
- [ ] `agent-ops/rules/domain/` 하위 기존 domain rule 목록을 확인하여 중복·유사 도메인 여부 판단
|
||||||
|
|
||||||
|
## 실행 절차
|
||||||
|
|
||||||
|
1. **중복 확인**
|
||||||
|
- `agent-ops/rules/domain/<domain-name>/` 폴더 존재 여부 확인
|
||||||
|
- 책임이 겹치는 기존 domain rule 이 있으면 사용자에게 알리고 중단한다
|
||||||
|
|
||||||
|
2. **경로 탐색**
|
||||||
|
- `path-hints` 가 제공된 경우: 해당 경로를 기준으로 폴더 구조 확인
|
||||||
|
- `path-hints` 가 없는 경우: `domain-name` 과 연관된 폴더명·파일명으로 프로젝트를 탐색
|
||||||
|
- 실제로 존재하는 경로만 포함 경로에 기재한다
|
||||||
|
- 존재하지 않는 경로는 기재하지 않는다
|
||||||
|
|
||||||
|
3. **도메인 분석**
|
||||||
|
- 탐색된 경로의 주요 파일·모듈을 읽어 아래를 도출한다
|
||||||
|
- 도메인의 목적 / 책임 한 줄 요약
|
||||||
|
- 포함 경로 목록
|
||||||
|
- 제외 경로 (인접 도메인과 겹칠 수 있는 경로)
|
||||||
|
- 주요 구성 요소 (파일, 모듈, 클래스, 함수 등)
|
||||||
|
- 유지할 패턴 (네이밍 규칙, 아키텍처 패턴 등)
|
||||||
|
- 다른 도메인과의 경계
|
||||||
|
- 금지 사항
|
||||||
|
|
||||||
|
4. **rules.md 생성**
|
||||||
|
- 경로: `agent-ops/rules/domain/<domain-name>/rules.md`
|
||||||
|
- `domain-rule-template.md` 형식을 따른다
|
||||||
|
- 실제 코드에서 확인된 내용만 기재한다
|
||||||
|
- 불확실한 항목은 `<!-- TODO: 확인 필요 -->` 주석으로 남긴다
|
||||||
|
|
||||||
|
5. **router.md 업데이트**
|
||||||
|
- `agent-ops/skills/router.md` 를 읽는다
|
||||||
|
- 코드 변경 섹션의 경로-도메인 라우팅 테이블에 아래 형식으로 추가한다
|
||||||
|
```
|
||||||
|
| `<path-pattern>` | `agent-ops/rules/domain/<domain-name>/rules.md` |
|
||||||
|
```
|
||||||
|
- 경로 패턴은 실제 포함 경로를 기준으로 작성한다
|
||||||
|
- 기존 테이블 항목을 삭제하거나 재정렬하지 않는다
|
||||||
|
|
||||||
|
6. **결과 보고**
|
||||||
|
- 생성한 파일 경로
|
||||||
|
- router.md 에 추가한 항목
|
||||||
|
- 불확실하여 TODO로 남긴 항목 (해당 시)
|
||||||
|
|
||||||
|
## 출력 형식
|
||||||
|
|
||||||
|
```
|
||||||
|
## 생성 완료
|
||||||
|
|
||||||
|
- Domain Rule 경로: agent-ops/rules/domain/<domain-name>/rules.md
|
||||||
|
- 도메인 유형: <core | supporting | generic>
|
||||||
|
- router.md 추가 경로 패턴: <path-pattern>
|
||||||
|
|
||||||
|
## TODO 항목 (확인 필요)
|
||||||
|
- <불확실하여 직접 확인이 필요한 항목> (해당 시)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 금지 사항
|
||||||
|
|
||||||
|
- 이미 존재하는 rules.md 를 덮어쓰지 않는다
|
||||||
|
- 실제 존재하지 않는 경로를 포함 경로에 넣지 않는다
|
||||||
|
- 추측으로 패턴이나 금지 사항을 채우지 않는다 — 확인된 내용만 기재한다
|
||||||
|
- router.md 의 기존 항목을 삭제하거나 재정렬하지 않는다
|
||||||
|
- 하나의 domain rule 에 여러 독립 도메인의 책임을 묶지 않는다
|
||||||
|
- 코드 변경을 수행하지 않는다 — 이 skill 은 rule 파일 생성만 담당한다
|
||||||
83
agent-ops/skills/create-skill/SKILL.md
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
---
|
||||||
|
name: create-skill
|
||||||
|
description: 새로운 SKILL.md 파일을 생성하기 위한 범용 스킬
|
||||||
|
version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Create Skill
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
`agent-ops/skills/<skill-name>/SKILL.md` 파일을 올바른 형식으로 생성한다.
|
||||||
|
기존 skill-template.md 를 기반으로, 요청 목적에 맞는 내용을 채워 넣는다.
|
||||||
|
생성 후 router.md 에 라우팅 항목을 추가한다.
|
||||||
|
|
||||||
|
## 언제 호출할지
|
||||||
|
|
||||||
|
- 새로운 반복 작업 패턴이 생겨 skill로 정의해야 할 때
|
||||||
|
- 기존 skill이 없는 작업 유형을 처음 수행하기 전에
|
||||||
|
- 사용자가 특정 작업을 skill로 만들어 달라고 요청할 때
|
||||||
|
|
||||||
|
## 입력
|
||||||
|
|
||||||
|
- `skill-name`: 생성할 skill 이름, kebab-case (필수)
|
||||||
|
- `purpose`: 이 skill이 해결하는 문제 한 줄 요약 (필수)
|
||||||
|
- `trigger-cases`: 이 skill을 호출해야 하는 상황 목록 (선택)
|
||||||
|
|
||||||
|
## 먼저 확인할 것
|
||||||
|
|
||||||
|
- [ ] `agent-ops/skills/` 하위에 동일 이름의 디렉터리가 이미 존재하는지 확인
|
||||||
|
- [ ] `agent-ops/skills/router.md` 에 이미 유사한 라우팅 항목이 있는지 확인
|
||||||
|
- [ ] `agent-ops/skills/_templates/skill-template.md` 를 읽어 최신 템플릿 형식 파악
|
||||||
|
|
||||||
|
## 실행 절차
|
||||||
|
|
||||||
|
1. **중복 확인**
|
||||||
|
- `agent-ops/skills/<skill-name>/` 폴더 존재 여부 확인
|
||||||
|
- 기능이 겹치는 기존 skill이 있으면 사용자에게 알리고 중단한다
|
||||||
|
|
||||||
|
2. **목적 분석**
|
||||||
|
- `purpose` 와 `trigger-cases` 를 바탕으로 아래 항목을 도출한다
|
||||||
|
- 언제 호출할지 (2~4개)
|
||||||
|
- 필요한 입력 파라미터
|
||||||
|
- 사전 확인 항목
|
||||||
|
- 실행 절차 (3~7단계)
|
||||||
|
- 출력 형식
|
||||||
|
- 금지 사항
|
||||||
|
|
||||||
|
3. **SKILL.md 생성**
|
||||||
|
- 경로: `agent-ops/skills/<skill-name>/SKILL.md`
|
||||||
|
- `skill-template.md` 형식을 따른다
|
||||||
|
- 프로젝트 특화 내용보다 범용 절차를 우선한다
|
||||||
|
- 절차는 구체적이되 지나치게 세부 구현을 기술하지 않는다
|
||||||
|
|
||||||
|
4. **router.md 업데이트**
|
||||||
|
- `agent-ops/skills/router.md` 를 읽는다
|
||||||
|
- 이 skill이 속할 라우팅 축(구조 분석/코드 변경/흐름 추적 등)을 판단한다
|
||||||
|
- 해당 축에 `→ agent-ops/skills/<skill-name>/SKILL.md` 항목을 추가한다
|
||||||
|
- 기존 라우팅 구조를 깨지 않는다
|
||||||
|
|
||||||
|
5. **결과 보고**
|
||||||
|
- 생성한 파일 경로
|
||||||
|
- router.md 에 추가한 항목
|
||||||
|
- 이 skill이 다루지 않는 범위(필요 시)
|
||||||
|
|
||||||
|
## 출력 형식
|
||||||
|
|
||||||
|
```
|
||||||
|
## 생성 완료
|
||||||
|
|
||||||
|
- SKILL 경로: agent-ops/skills/<skill-name>/SKILL.md
|
||||||
|
- router.md 추가 항목: <라우팅 축> → <skill-name>
|
||||||
|
|
||||||
|
## 주의사항 (해당 시)
|
||||||
|
- <이 skill이 다루지 않는 범위 또는 주의할 점>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 금지 사항
|
||||||
|
|
||||||
|
- 이미 존재하는 skill 을 덮어쓰지 않는다
|
||||||
|
- 프로젝트 특화 경로(예: `app/screens/`)를 skill 본문에 하드코딩하지 않는다
|
||||||
|
- skill 생성과 무관한 코드 파일을 수정하지 않는다
|
||||||
|
- router.md 의 기존 항목을 삭제하거나 재정렬하지 않는다
|
||||||
|
- 하나의 skill 에 여러 독립적인 책임을 묶지 않는다
|
||||||
40
agent-ops/skills/find-domain/SKILL.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
---
|
||||||
|
name: find-domain
|
||||||
|
description: 파일 경로 또는 기능으로 해당 도메인을 판별한다
|
||||||
|
version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Find Domain
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
변경하거나 분석할 코드가 어느 도메인에 속하는지 판별한다.
|
||||||
|
|
||||||
|
## 언제 호출할지
|
||||||
|
- 어떤 파일을 수정해야 하는지 불분명할 때
|
||||||
|
- 기능이 어느 도메인에 속하는지 모를 때
|
||||||
|
- 여러 도메인에 걸친 변경이 필요한지 파악할 때
|
||||||
|
|
||||||
|
## 입력
|
||||||
|
- `경로 또는 기능 설명`: 파일 경로, 컴포넌트 이름, 또는 기능 설명 (필수)
|
||||||
|
|
||||||
|
## 먼저 확인할 것
|
||||||
|
- [ ] `agent-ops/skills/router.md`의 경로 패턴 테이블 확인
|
||||||
|
- [ ] CLAUDE.md의 TypeScript Path Aliases 섹션 확인
|
||||||
|
|
||||||
|
## 실행 절차
|
||||||
|
1. 입력이 파일 경로면 → router.md 테이블과 매칭
|
||||||
|
2. 입력이 기능 설명이면 → 관련 파일을 Glob/Grep으로 탐색
|
||||||
|
3. 도메인 후보를 식별하고 해당 domain rule 로드
|
||||||
|
4. 여러 도메인에 걸친 경우 → 주 도메인 + 영향 도메인 명시
|
||||||
|
|
||||||
|
## 출력 형식
|
||||||
|
```
|
||||||
|
도메인: <domain-name>
|
||||||
|
근거: <판별 근거>
|
||||||
|
관련 rule: agent-ops/rules/domain/<domain>/rules.md
|
||||||
|
영향 도메인: <있으면 명시>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 금지 사항
|
||||||
|
- 도메인을 단정하지 않고 불확실하면 후보를 제시한다
|
||||||
|
- 관련 없는 domain rule까지 로드하지 않는다
|
||||||
48
agent-ops/skills/router.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
---
|
||||||
|
name: router
|
||||||
|
description: 작업 유형별 skill / domain rule 라우팅
|
||||||
|
version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill Router
|
||||||
|
|
||||||
|
작업 유형에 따라 필요한 skill 또는 domain rule만 로드한다.
|
||||||
|
|
||||||
|
## 라우팅 축
|
||||||
|
|
||||||
|
### 구조 분석 / 스캐폴드
|
||||||
|
→ `agent-ops/skills/find-domain/SKILL.md`
|
||||||
|
→ `agent-ops/rules/global.md`
|
||||||
|
|
||||||
|
### 새 Skill 생성
|
||||||
|
→ `agent-ops/skills/create-skill/SKILL.md`
|
||||||
|
|
||||||
|
### 새 Domain Rule 생성
|
||||||
|
→ `agent-ops/skills/create-domain-rule/SKILL.md`
|
||||||
|
|
||||||
|
### 도메인 판별
|
||||||
|
→ `agent-ops/skills/find-domain/SKILL.md`
|
||||||
|
|
||||||
|
### 코드 변경
|
||||||
|
→ `agent-ops/skills/code-change/SKILL.md`
|
||||||
|
→ 변경 대상 경로에 해당하는 domain rule 로드
|
||||||
|
|
||||||
|
| 경로 패턴 | Domain Rule |
|
||||||
|
|---|---|
|
||||||
|
| `app/products/agents/` | `agent-ops/rules/domain/products-agents/rules.md` |
|
||||||
|
| `app/products/calls/` | `agent-ops/rules/domain/products-calls/rules.md` |
|
||||||
|
| `app/database/`, `app/queries/` | `agent-ops/rules/domain/database/rules.md` |
|
||||||
|
| `app/components/`, `app/screens/`, `app/hooks/` | `agent-ops/rules/domain/ui/rules.md` |
|
||||||
|
| `app/actions/`, 채널/포스트/스레드 관련 | `agent-ops/rules/domain/messaging/rules.md` |
|
||||||
|
|
||||||
|
### 흐름 추적
|
||||||
|
→ 흐름에 관련된 domain rule 로드
|
||||||
|
→ `agent-ops/skills/find-domain/SKILL.md`로 경로 판별 후 진행
|
||||||
|
|
||||||
|
### 문서 / 릴리즈 정리
|
||||||
|
→ `agent-ops/rules/global.md`
|
||||||
|
→ `docs/` 폴더 직접 확인
|
||||||
|
|
||||||
|
## 원칙
|
||||||
|
- 필요한 rule만 로드한다. 전체를 로드하지 않는다.
|
||||||
|
- 불확실한 도메인은 `find-domain` skill로 먼저 판별한다.
|
||||||
|
|
@ -104,7 +104,7 @@ android {
|
||||||
ndkVersion rootProject.ext.ndkVersion
|
ndkVersion rootProject.ext.ndkVersion
|
||||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||||
namespace "com.mattermost.rnbeta"
|
namespace "com.tokilabs.mattermost"
|
||||||
|
|
||||||
lintOptions {
|
lintOptions {
|
||||||
checkReleaseBuilds false
|
checkReleaseBuilds false
|
||||||
|
|
@ -112,11 +112,11 @@ android {
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.mattermost.rnbeta"
|
applicationId "com.tokilabs.mattermost"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 701
|
versionCode 730
|
||||||
versionName "2.36.0"
|
versionName "2.38.0"
|
||||||
testBuildType System.getProperty('testBuildType', 'debug')
|
testBuildType System.getProperty('testBuildType', 'debug')
|
||||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,104 +1,29 @@
|
||||||
{
|
{
|
||||||
"project_info": {
|
"project_info": {
|
||||||
"project_number": "184930218130",
|
"project_number": "1047648748539",
|
||||||
"firebase_url": "https://api-7231322553409637977-752355.firebaseio.com",
|
"project_id": "mattermost-6ac08",
|
||||||
"project_id": "api-7231322553409637977-752355",
|
"storage_bucket": "mattermost-6ac08.firebasestorage.app"
|
||||||
"storage_bucket": "api-7231322553409637977-752355.appspot.com"
|
|
||||||
},
|
},
|
||||||
"client": [
|
"client": [
|
||||||
{
|
{
|
||||||
"client_info": {
|
"client_info": {
|
||||||
"mobilesdk_app_id": "1:184930218130:android:a0e553d1a1f043b5",
|
"mobilesdk_app_id": "1:1047648748539:android:818bf70bfbb3f9d070415e",
|
||||||
"android_client_info": {
|
"android_client_info": {
|
||||||
"package_name": "com.mattermost.react.native"
|
"package_name": "com.tokilabs.mattermost"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"oauth_client": [
|
"oauth_client": [],
|
||||||
{
|
|
||||||
"client_id": "184930218130-8nahrspll1opff0uogtkh2qsv8coqngo.apps.googleusercontent.com",
|
|
||||||
"client_type": 3
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"api_key": [
|
"api_key": [
|
||||||
{
|
{
|
||||||
"current_key": "AIzaSyCkZkU2ECVg-mmdCG5OoHHQXtKiENuvWPE"
|
"current_key": "AIzaSyAW6j_oPl9MVcm93qS3JgaEPD5ywp-TzZ0"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"services": {
|
"services": {
|
||||||
"analytics_service": {
|
|
||||||
"status": 2
|
|
||||||
},
|
|
||||||
"appinvite_service": {
|
"appinvite_service": {
|
||||||
"status": 1,
|
|
||||||
"other_platform_oauth_client": []
|
"other_platform_oauth_client": []
|
||||||
},
|
|
||||||
"ads_service": {
|
|
||||||
"status": 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"client_info": {
|
|
||||||
"mobilesdk_app_id": "1:184930218130:android:c7debfa7ea3f75a7",
|
|
||||||
"android_client_info": {
|
|
||||||
"package_name": "com.mattermost.rnbeta"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"configuration_version": "1"
|
"configuration_version": "1"
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.mattermost.rnbeta;
|
package com.tokilabs.mattermost;
|
||||||
|
|
||||||
import com.wix.detox.Detox;
|
import com.wix.detox.Detox;
|
||||||
import com.wix.detox.config.DetoxConfig;
|
import com.wix.detox.config.DetoxConfig;
|
||||||
|
|
@ -77,7 +77,7 @@
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="mmauthbeta" />
|
<data android:scheme="mmauth_tokilabs" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<service android:name=".NotificationDismissService"
|
<service android:name=".NotificationDismissService"
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import androidx.core.app.Person;
|
||||||
import androidx.core.app.RemoteInput;
|
import androidx.core.app.RemoteInput;
|
||||||
import androidx.core.graphics.drawable.IconCompat;
|
import androidx.core.graphics.drawable.IconCompat;
|
||||||
|
|
||||||
import com.mattermost.rnbeta.*;
|
import com.tokilabs.mattermost.*;
|
||||||
import com.mattermost.rnutils.helpers.NotificationHelper;
|
import com.mattermost.rnutils.helpers.NotificationHelper;
|
||||||
import com.nozbe.watermelondb.WMDatabase;
|
import com.nozbe.watermelondb.WMDatabase;
|
||||||
import com.mattermost.turbolog.TurboLog;
|
import com.mattermost.turbolog.TurboLog;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.mattermost.rnbeta
|
package com.tokilabs.mattermost
|
||||||
|
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.mattermost.rnbeta
|
package com.tokilabs.mattermost
|
||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.mattermost.rnbeta
|
package com.tokilabs.mattermost
|
||||||
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.mattermost.rnbeta;
|
package com.tokilabs.mattermost;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.mattermost.rnbeta;
|
package com.tokilabs.mattermost;
|
||||||
|
|
||||||
import android.app.Notification;
|
import android.app.Notification;
|
||||||
import android.app.NotificationManager;
|
import android.app.NotificationManager;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.mattermost.rnbeta;
|
package com.tokilabs.mattermost;
|
||||||
|
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 787 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 351 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 345 KiB |
|
Before Width: | Height: | Size: 730 KiB After Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 730 KiB After Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 730 KiB After Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 7.7 KiB |
|
|
@ -10,6 +10,7 @@ import {fetchConfigAndLicense} from '@actions/remote/systems';
|
||||||
import {Events, Preferences, Screens} from '@constants';
|
import {Events, Preferences, Screens} from '@constants';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
import {DEFAULT_LOCALE, getTranslations} from '@i18n';
|
import {DEFAULT_LOCALE, getTranslations} from '@i18n';
|
||||||
|
import {getPreauthSecret} from '@init/credentials';
|
||||||
import SecurityManager from '@managers/security_manager';
|
import SecurityManager from '@managers/security_manager';
|
||||||
import WebsocketManager from '@managers/websocket_manager';
|
import WebsocketManager from '@managers/websocket_manager';
|
||||||
import {getServer, getServerByIdentifier} from '@queries/app/servers';
|
import {getServer, getServerByIdentifier} from '@queries/app/servers';
|
||||||
|
|
@ -26,6 +27,7 @@ jest.mock('@queries/app/servers');
|
||||||
jest.mock('@queries/servers/system');
|
jest.mock('@queries/servers/system');
|
||||||
jest.mock('@database/manager');
|
jest.mock('@database/manager');
|
||||||
jest.mock('@managers/security_manager');
|
jest.mock('@managers/security_manager');
|
||||||
|
jest.mock('@init/credentials');
|
||||||
|
|
||||||
jest.mock('@managers/websocket_manager');
|
jest.mock('@managers/websocket_manager');
|
||||||
jest.mock('@utils/log');
|
jest.mock('@utils/log');
|
||||||
|
|
@ -182,4 +184,54 @@ describe('switchToServerAndLogin', () => {
|
||||||
expect(SecurityManager.isDeviceJailbroken).toHaveBeenCalledWith('serverUrl');
|
expect(SecurityManager.isDeviceJailbroken).toHaveBeenCalledWith('serverUrl');
|
||||||
expect(callback).toHaveBeenCalled();
|
expect(callback).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should retrieve and use pre-auth secret when reconnecting after logout', async () => {
|
||||||
|
const server = {url: 'serverUrl', displayName: 'Server'} as ServersModel;
|
||||||
|
const config = {DiagnosticId: 'diagId'} as ClientConfig;
|
||||||
|
const license = {} as ClientLicense;
|
||||||
|
const preauthSecret = 'test-secret-123';
|
||||||
|
|
||||||
|
jest.mocked(getServer).mockResolvedValueOnce(server);
|
||||||
|
jest.mocked(getPreauthSecret).mockResolvedValueOnce(preauthSecret);
|
||||||
|
jest.mocked(doPing).mockResolvedValueOnce({});
|
||||||
|
jest.mocked(fetchConfigAndLicense).mockResolvedValueOnce({config, license});
|
||||||
|
jest.mocked(getServerByIdentifier).mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
await Actions.switchToServerAndLogin('serverUrl', theme, intl, jest.fn());
|
||||||
|
|
||||||
|
expect(getPreauthSecret).toHaveBeenCalledWith('serverUrl');
|
||||||
|
expect(doPing).toHaveBeenCalledWith('serverUrl', true, 5000, preauthSecret);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work correctly when no pre-auth secret is stored', async () => {
|
||||||
|
const server = {url: 'serverUrl', displayName: 'Server'} as ServersModel;
|
||||||
|
const config = {DiagnosticId: 'diagId'} as ClientConfig;
|
||||||
|
const license = {} as ClientLicense;
|
||||||
|
|
||||||
|
jest.mocked(getServer).mockResolvedValueOnce(server);
|
||||||
|
jest.mocked(getPreauthSecret).mockResolvedValueOnce(undefined);
|
||||||
|
jest.mocked(doPing).mockResolvedValueOnce({});
|
||||||
|
jest.mocked(fetchConfigAndLicense).mockResolvedValueOnce({config, license});
|
||||||
|
jest.mocked(getServerByIdentifier).mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
await Actions.switchToServerAndLogin('serverUrl', theme, intl, jest.fn());
|
||||||
|
|
||||||
|
expect(getPreauthSecret).toHaveBeenCalledWith('serverUrl');
|
||||||
|
expect(doPing).toHaveBeenCalledWith('serverUrl', true, 5000, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass pre-auth secret to doPing even when ping fails', async () => {
|
||||||
|
const server = {url: 'serverUrl'} as ServersModel;
|
||||||
|
const preauthSecret = 'test-secret-456';
|
||||||
|
|
||||||
|
jest.mocked(getServer).mockResolvedValueOnce(server);
|
||||||
|
jest.mocked(getPreauthSecret).mockResolvedValueOnce(preauthSecret);
|
||||||
|
jest.mocked(doPing).mockResolvedValueOnce({error: 'ping error'});
|
||||||
|
|
||||||
|
await Actions.switchToServerAndLogin('serverUrl', theme, intl, jest.fn());
|
||||||
|
|
||||||
|
expect(getPreauthSecret).toHaveBeenCalledWith('serverUrl');
|
||||||
|
expect(doPing).toHaveBeenCalledWith('serverUrl', true, 5000, preauthSecret);
|
||||||
|
expect(alertServerError).toHaveBeenCalledWith(intl, 'ping error');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {doPing} from '@actions/remote/general';
|
||||||
import {fetchConfigAndLicense} from '@actions/remote/systems';
|
import {fetchConfigAndLicense} from '@actions/remote/systems';
|
||||||
import {Screens} from '@constants';
|
import {Screens} from '@constants';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
|
import {getPreauthSecret} from '@init/credentials';
|
||||||
import SecurityManager from '@managers/security_manager';
|
import SecurityManager from '@managers/security_manager';
|
||||||
import WebsocketManager from '@managers/websocket_manager';
|
import WebsocketManager from '@managers/websocket_manager';
|
||||||
import {getServer, getServerByIdentifier} from '@queries/app/servers';
|
import {getServer, getServerByIdentifier} from '@queries/app/servers';
|
||||||
|
|
@ -53,7 +54,10 @@ export async function switchToServerAndLogin(serverUrl: string, theme: Theme, in
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await doPing(server.url, true);
|
// Retrieve pre-auth secret from keychain if it exists
|
||||||
|
const preauthSecret = await getPreauthSecret(server.url);
|
||||||
|
|
||||||
|
const result = await doPing(server.url, true, 5000, preauthSecret);
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
alertServerError(intl, result.error);
|
alertServerError(intl, result.error);
|
||||||
callback?.();
|
callback?.();
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,15 @@
|
||||||
|
|
||||||
import {DeviceEventEmitter} from 'react-native';
|
import {DeviceEventEmitter} from 'react-native';
|
||||||
|
|
||||||
import {Navigation} from '@constants';
|
import {ActionType, Events, Navigation} from '@constants';
|
||||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
import {getMyChannel} from '@queries/servers/channel';
|
import {getMyChannel} from '@queries/servers/channel';
|
||||||
|
import {getPostById} from '@queries/servers/post';
|
||||||
import {getCommonSystemValues, getTeamHistory} from '@queries/servers/system';
|
import {getCommonSystemValues, getTeamHistory} from '@queries/servers/system';
|
||||||
import {getTeamChannelHistory} from '@queries/servers/team';
|
import {getTeamChannelHistory} from '@queries/servers/team';
|
||||||
import {dismissAllModalsAndPopToRoot, dismissAllModalsAndPopToScreen} from '@screens/navigation';
|
import {dismissAllModalsAndPopToRoot, dismissAllModalsAndPopToScreen} from '@screens/navigation';
|
||||||
|
import TestHelper from '@test/test_helper';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
switchToChannel,
|
switchToChannel,
|
||||||
|
|
@ -27,6 +29,8 @@ import {
|
||||||
updateChannelsDisplayName,
|
updateChannelsDisplayName,
|
||||||
showUnreadChannelsOnly,
|
showUnreadChannelsOnly,
|
||||||
updateDmGmDisplayName,
|
updateDmGmDisplayName,
|
||||||
|
deletePostsForChannelsWithAutotranslation,
|
||||||
|
deletePostsForChannel,
|
||||||
} from './channel';
|
} from './channel';
|
||||||
|
|
||||||
import type {ChannelModel, MyChannelModel, SystemModel} from '@database/models/server';
|
import type {ChannelModel, MyChannelModel, SystemModel} from '@database/models/server';
|
||||||
|
|
@ -770,19 +774,19 @@ describe('updateMyChannelFromWebsocket', () => {
|
||||||
const serverUrl = 'baseHandler.test.com';
|
const serverUrl = 'baseHandler.test.com';
|
||||||
const channelId = 'id1';
|
const channelId = 'id1';
|
||||||
const teamId = 'tId1';
|
const teamId = 'tId1';
|
||||||
const channel: Channel = {
|
const channel: Channel = TestHelper.fakeChannel({
|
||||||
id: channelId,
|
id: channelId,
|
||||||
team_id: teamId,
|
team_id: teamId,
|
||||||
total_msg_count: 0,
|
total_msg_count: 0,
|
||||||
delete_at: 0,
|
delete_at: 0,
|
||||||
} as Channel;
|
});
|
||||||
const channelMember: ChannelMembership = {
|
const channelMember: ChannelMembership = TestHelper.fakeChannelMember({
|
||||||
id: 'id',
|
id: 'id',
|
||||||
user_id: 'userid',
|
user_id: 'userid',
|
||||||
channel_id: channelId,
|
channel_id: channelId,
|
||||||
msg_count: 0,
|
msg_count: 0,
|
||||||
roles: '',
|
roles: '',
|
||||||
} as ChannelMembership;
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await DatabaseManager.init([serverUrl]);
|
await DatabaseManager.init([serverUrl]);
|
||||||
|
|
@ -808,6 +812,27 @@ describe('updateMyChannelFromWebsocket', () => {
|
||||||
expect(model).toBeDefined();
|
expect(model).toBeDefined();
|
||||||
expect(model?.roles).toBe('channel_user');
|
expect(model?.roles).toBe('channel_user');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('calls deletePostsForChannel when autotranslation changes', async () => {
|
||||||
|
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
|
||||||
|
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
|
||||||
|
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
|
||||||
|
await operator.handlePosts({actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, order: [post.id], posts: [post], prepareRecordsOnly: false});
|
||||||
|
|
||||||
|
await updateMyChannelFromWebsocket(serverUrl, {...channelMember, autotranslation_disabled: true}, false);
|
||||||
|
|
||||||
|
expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId, teamId: channel.team_id});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates member autotranslation from channelMember', async () => {
|
||||||
|
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
|
||||||
|
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
|
||||||
|
|
||||||
|
await updateMyChannelFromWebsocket(serverUrl, {...channelMember, autotranslation_disabled: true}, false);
|
||||||
|
|
||||||
|
const member = await getMyChannel(operator.database, channelId);
|
||||||
|
expect(member?.autotranslationDisabled).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateChannelInfoFromChannel', () => {
|
describe('updateChannelInfoFromChannel', () => {
|
||||||
|
|
@ -1133,3 +1158,186 @@ describe('updateDmGmDisplayName', () => {
|
||||||
expect((channels![1] as ChannelModel).displayName).toBe(`${user2.username}, ${user3.username}`);
|
expect((channels![1] as ChannelModel).displayName).toBe(`${user2.username}, ${user3.username}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('deletePostsForChannel', () => {
|
||||||
|
const serverUrl = 'baseHandler.test.com';
|
||||||
|
let operator: ServerDataOperator;
|
||||||
|
|
||||||
|
const channelId = 'channelid1';
|
||||||
|
const teamId = 'tId1';
|
||||||
|
const channel: Channel = TestHelper.fakeChannel({
|
||||||
|
id: channelId,
|
||||||
|
team_id: teamId,
|
||||||
|
total_msg_count: 0,
|
||||||
|
});
|
||||||
|
const channelMember: ChannelMembership = TestHelper.fakeChannelMember({
|
||||||
|
id: 'id',
|
||||||
|
channel_id: channelId,
|
||||||
|
msg_count: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await DatabaseManager.init([serverUrl]);
|
||||||
|
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await DatabaseManager.destroyServerDatabase(serverUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handle not found database', async () => {
|
||||||
|
const {models, error} = await deletePostsForChannel('foo', channelId);
|
||||||
|
expect(models).toEqual([]);
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('channel not found', async () => {
|
||||||
|
const {models, error} = await deletePostsForChannel(serverUrl, 'nonexistent');
|
||||||
|
expect(models).toEqual([]);
|
||||||
|
expect(error).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('channel with no posts', async () => {
|
||||||
|
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
|
||||||
|
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
|
||||||
|
|
||||||
|
const {models, error} = await deletePostsForChannel(serverUrl, channelId);
|
||||||
|
expect(models).toEqual([]);
|
||||||
|
expect(error).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('channel with posts - batch written and event emitted', async () => {
|
||||||
|
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
|
||||||
|
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
|
||||||
|
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
|
||||||
|
await operator.handlePosts({
|
||||||
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||||
|
order: [post.id],
|
||||||
|
posts: [post],
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const listener = jest.fn();
|
||||||
|
const subscription = DeviceEventEmitter.addListener(Events.POST_DELETED_FOR_CHANNEL, listener);
|
||||||
|
|
||||||
|
const {models, error} = await deletePostsForChannel(serverUrl, channelId);
|
||||||
|
subscription.remove();
|
||||||
|
|
||||||
|
expect(error).toBeFalsy();
|
||||||
|
expect(models.length).toBeGreaterThan(0);
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
expect(listener).toHaveBeenCalledWith({serverUrl, channelId, teamId: channel.team_id});
|
||||||
|
|
||||||
|
const myChannel = await getMyChannel(operator.database, channelId);
|
||||||
|
expect(myChannel?.lastFetchedAt).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prepareRecordsOnly true - no write and no emit', async () => {
|
||||||
|
const post = TestHelper.fakePost({id: 'postid2', channel_id: channelId, root_id: ''});
|
||||||
|
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
|
||||||
|
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
|
||||||
|
await operator.handlePosts({
|
||||||
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||||
|
order: [post.id],
|
||||||
|
posts: [post],
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const listener = jest.fn();
|
||||||
|
const subscription = DeviceEventEmitter.addListener(Events.POST_DELETED_FOR_CHANNEL, listener);
|
||||||
|
|
||||||
|
const {models, error} = await deletePostsForChannel(serverUrl, channelId, true);
|
||||||
|
subscription.remove();
|
||||||
|
|
||||||
|
expect(error).toBeFalsy();
|
||||||
|
expect(models.length).toBeGreaterThan(0);
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const myChannel = await getMyChannel(operator.database, channelId);
|
||||||
|
expect(myChannel?._preparedState).toBe('update');
|
||||||
|
|
||||||
|
// Batch the records to avoid the invariant error
|
||||||
|
await operator.batchRecords([...models], 'test');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deletePostsForChannelsWithAutotranslation', () => {
|
||||||
|
const serverUrl = 'baseHandler.test.com';
|
||||||
|
let operator: ServerDataOperator;
|
||||||
|
|
||||||
|
const channelId = 'channelid1';
|
||||||
|
const teamId = 'tId1';
|
||||||
|
const channel: Channel = TestHelper.fakeChannel({
|
||||||
|
id: channelId,
|
||||||
|
team_id: teamId,
|
||||||
|
total_msg_count: 0,
|
||||||
|
autotranslation: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await DatabaseManager.init([serverUrl]);
|
||||||
|
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await DatabaseManager.destroyServerDatabase(serverUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no myChannels with autotranslation - no deletes', async () => {
|
||||||
|
const channelMember: ChannelMembership = TestHelper.fakeChannelMember({
|
||||||
|
id: 'id',
|
||||||
|
channel_id: channelId,
|
||||||
|
msg_count: 0,
|
||||||
|
autotranslation_disabled: true,
|
||||||
|
});
|
||||||
|
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
|
||||||
|
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
|
||||||
|
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
|
||||||
|
await operator.handlePosts({
|
||||||
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||||
|
order: [post.id],
|
||||||
|
posts: [post],
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {models, error} = await deletePostsForChannelsWithAutotranslation(serverUrl);
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
expect(models).toEqual([]);
|
||||||
|
|
||||||
|
const databasePost = await getPostById(operator.database, post.id);
|
||||||
|
expect(databasePost).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('myChannels with autotranslation - delete posts', async () => {
|
||||||
|
const channelMember: ChannelMembership = TestHelper.fakeChannelMember({
|
||||||
|
id: 'id',
|
||||||
|
channel_id: channelId,
|
||||||
|
msg_count: 0,
|
||||||
|
autotranslation_disabled: false,
|
||||||
|
});
|
||||||
|
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
|
||||||
|
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
|
||||||
|
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
|
||||||
|
await operator.handlePosts({
|
||||||
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||||
|
order: [post.id],
|
||||||
|
posts: [post],
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {models, error} = await deletePostsForChannelsWithAutotranslation(serverUrl);
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
expect(models.length).toBeGreaterThan(0);
|
||||||
|
const databasePost = await getPostById(operator.database, post.id);
|
||||||
|
expect(databasePost).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handle error', async () => {
|
||||||
|
jest.spyOn(DatabaseManager, 'getServerDatabaseAndOperator').mockImplementationOnce(() => {
|
||||||
|
throw new Error('DB error');
|
||||||
|
});
|
||||||
|
const {models, error} = await deletePostsForChannelsWithAutotranslation(serverUrl);
|
||||||
|
expect(models).toEqual([]);
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
import {DeviceEventEmitter} from 'react-native';
|
import {DeviceEventEmitter} from 'react-native';
|
||||||
|
|
||||||
import {General, Navigation as NavigationConstants, Preferences, Screens} from '@constants';
|
import {Events, General, Navigation as NavigationConstants, Preferences, Screens} from '@constants';
|
||||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
|
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
|
||||||
|
|
@ -13,7 +13,9 @@ import {
|
||||||
prepareDeleteChannel, prepareMyChannelsForTeam, queryAllMyChannel,
|
prepareDeleteChannel, prepareMyChannelsForTeam, queryAllMyChannel,
|
||||||
getMyChannel, getChannelById, queryUsersOnChannel, queryUserChannelsByTypes,
|
getMyChannel, getChannelById, queryUsersOnChannel, queryUserChannelsByTypes,
|
||||||
prepareAllMyChannels,
|
prepareAllMyChannels,
|
||||||
|
queryMyChannelsWithAutotranslation,
|
||||||
} from '@queries/servers/channel';
|
} from '@queries/servers/channel';
|
||||||
|
import {prepareDeletePost, queryPostsInChannel, queryPostsInThread} from '@queries/servers/post';
|
||||||
import {queryDisplayNamePreferences} from '@queries/servers/preference';
|
import {queryDisplayNamePreferences} from '@queries/servers/preference';
|
||||||
import {prepareCommonSystemValues, type PrepareCommonSystemValuesArgs, getCommonSystemValues, getCurrentTeamId, setCurrentChannelId, getCurrentUserId, getConfig, getLicense} from '@queries/servers/system';
|
import {prepareCommonSystemValues, type PrepareCommonSystemValuesArgs, getCommonSystemValues, getCurrentTeamId, setCurrentChannelId, getCurrentUserId, getConfig, getLicense} from '@queries/servers/system';
|
||||||
import {addChannelToTeamHistory, addTeamToTeamHistory, getTeamById, removeChannelFromTeamHistory} from '@queries/servers/team';
|
import {addChannelToTeamHistory, addTeamToTeamHistory, getTeamById, removeChannelFromTeamHistory} from '@queries/servers/team';
|
||||||
|
|
@ -296,9 +298,15 @@ export async function updateMyChannelFromWebsocket(serverUrl: string, channelMem
|
||||||
try {
|
try {
|
||||||
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||||
const member = await getMyChannel(database, channelMember.channel_id);
|
const member = await getMyChannel(database, channelMember.channel_id);
|
||||||
|
|
||||||
|
if (member && Boolean(member.autotranslationDisabled) !== Boolean(channelMember.autotranslation_disabled)) {
|
||||||
|
await deletePostsForChannel(serverUrl, channelMember.channel_id);
|
||||||
|
}
|
||||||
|
|
||||||
if (member) {
|
if (member) {
|
||||||
member.prepareUpdate((m) => {
|
member.prepareUpdate((m) => {
|
||||||
m.roles = channelMember.roles;
|
m.roles = channelMember.roles;
|
||||||
|
m.autotranslationDisabled = channelMember.autotranslation_disabled ?? false;
|
||||||
});
|
});
|
||||||
if (!prepareRecordsOnly) {
|
if (!prepareRecordsOnly) {
|
||||||
operator.batchRecords([member], 'updateMyChannelFromWebsocket');
|
operator.batchRecords([member], 'updateMyChannelFromWebsocket');
|
||||||
|
|
@ -485,3 +493,86 @@ export const updateDmGmDisplayName = async (serverUrl: string) => {
|
||||||
return {error};
|
return {error};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function deletePostsForChannel(serverUrl: string, channelId: string, prepareRecordsOnly = false): Promise<{models: Model[]; error?: unknown}> {
|
||||||
|
try {
|
||||||
|
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||||
|
const channel = await getChannelById(database, channelId);
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
|
return {models: []};
|
||||||
|
}
|
||||||
|
|
||||||
|
const posts = await channel.posts.fetch();
|
||||||
|
if (!posts.length) {
|
||||||
|
return {models: []};
|
||||||
|
}
|
||||||
|
|
||||||
|
const preparedPostsPromises = posts.map((post) => prepareDeletePost(post));
|
||||||
|
const preparedPostsArrays = await Promise.all(preparedPostsPromises);
|
||||||
|
const preparedModels: Model[] = preparedPostsArrays.flat();
|
||||||
|
|
||||||
|
const postsInChannel = await queryPostsInChannel(database, channelId);
|
||||||
|
if (postsInChannel.length) {
|
||||||
|
for (const postRange of postsInChannel) {
|
||||||
|
const preparedPostRanges = postRange.prepareDestroyPermanently();
|
||||||
|
preparedModels.push(preparedPostRanges);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadPromises = posts.filter((post) => post.rootId === '').map((post) => {
|
||||||
|
return queryPostsInThread(database, post.id).fetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadRanges = (await Promise.all(threadPromises)).flat();
|
||||||
|
for (const threadRange of threadRanges) {
|
||||||
|
const preparedThreadRange = threadRange.prepareDestroyPermanently();
|
||||||
|
preparedModels.push(preparedThreadRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
const myChannel = await getMyChannel(database, channelId);
|
||||||
|
if (myChannel) {
|
||||||
|
myChannel.prepareUpdate((v) => {
|
||||||
|
v.lastFetchedAt = 0;
|
||||||
|
});
|
||||||
|
preparedModels.push(myChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preparedModels.length && !prepareRecordsOnly) {
|
||||||
|
await operator.batchRecords(preparedModels, 'deletePostsForChannel');
|
||||||
|
DeviceEventEmitter.emit(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId, teamId: channel.teamId});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {error: false, models: preparedModels};
|
||||||
|
} catch (error) {
|
||||||
|
logError('Failed deletePostsForChannel', error);
|
||||||
|
return {error, models: []};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePostsForChannelsWithAutotranslation(serverUrl: string, prepareRecordsOnly = false) {
|
||||||
|
try {
|
||||||
|
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||||
|
const myChannels = await queryMyChannelsWithAutotranslation(database).fetch();
|
||||||
|
|
||||||
|
const deleteResults = await Promise.all(
|
||||||
|
myChannels.map((myChannel) => deletePostsForChannel(serverUrl, myChannel.id, prepareRecordsOnly)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const allModels: Model[] = [];
|
||||||
|
for (const result of deleteResults) {
|
||||||
|
if (result.models) {
|
||||||
|
allModels.push(...result.models);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allModels.length && !prepareRecordsOnly) {
|
||||||
|
await operator.batchRecords(allModels, 'deletePostsForChannelsWithAutotranslation');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {error: undefined, models: allModels};
|
||||||
|
} catch (error) {
|
||||||
|
logError('Failed deletePostsForChannelsWithAutotranslation', error);
|
||||||
|
return {error, models: []};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {DeviceEventEmitter} from 'react-native';
|
||||||
import {Navigation, Screens} from '@constants';
|
import {Navigation, Screens} from '@constants';
|
||||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||||
import {DRAFT_SCREEN_TAB_DRAFTS, DRAFT_SCREEN_TAB_SCHEDULED_POSTS} from '@constants/draft';
|
import {DRAFT_SCREEN_TAB_DRAFTS, DRAFT_SCREEN_TAB_SCHEDULED_POSTS} from '@constants/draft';
|
||||||
|
import {PostTypes} from '@constants/post';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
import {goToScreen, popTo} from '@screens/navigation';
|
import {goToScreen, popTo} from '@screens/navigation';
|
||||||
import NavigationStore from '@store/navigation_store';
|
import NavigationStore from '@store/navigation_store';
|
||||||
|
|
@ -19,6 +20,7 @@ import {
|
||||||
addFilesToDraft,
|
addFilesToDraft,
|
||||||
removeDraft,
|
removeDraft,
|
||||||
updateDraftPriority,
|
updateDraftPriority,
|
||||||
|
updateDraftBoRConfig,
|
||||||
updateDraftMarkdownImageMetadata,
|
updateDraftMarkdownImageMetadata,
|
||||||
} from './draft';
|
} from './draft';
|
||||||
|
|
||||||
|
|
@ -405,3 +407,47 @@ describe('updateDraftMarkdownImageMetadata', () => {
|
||||||
expect(result.draft.metadata?.images?.image1).toEqual(postImageData);
|
expect(result.draft.metadata?.images?.image1).toEqual(postImageData);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('updateDraftBoRConfig', () => {
|
||||||
|
const postBoRConfig: PostBoRConfig = {
|
||||||
|
enabled: true,
|
||||||
|
borDurationSeconds: 300,
|
||||||
|
borMaximumTimeToLiveSeconds: 3600,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('handle not found database', async () => {
|
||||||
|
const result = await updateDraftBoRConfig('foo', channelId, '', postBoRConfig) as {draft: unknown; error: unknown};
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handle no draft', async () => {
|
||||||
|
const models = await updateDraftBoRConfig(serverUrl, channelId, '', postBoRConfig) as DraftModel[];
|
||||||
|
expect(models).toBeDefined();
|
||||||
|
expect(models.length).toBe(1);
|
||||||
|
expect(models[0].metadata?.borConfig?.enabled).toBe(postBoRConfig.enabled);
|
||||||
|
expect(models[0].metadata?.borConfig?.borDurationSeconds).toBe(postBoRConfig.borDurationSeconds);
|
||||||
|
expect(models[0].type).toBe(PostTypes.BURN_ON_READ);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update draft BoR config with enabled true', async () => {
|
||||||
|
await operator.handleDraft({drafts: [draft], prepareRecordsOnly: false});
|
||||||
|
|
||||||
|
const result = await updateDraftBoRConfig(serverUrl, channelId, '', postBoRConfig) as {draft: DraftModel; error: unknown};
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.draft).toBeDefined();
|
||||||
|
expect(result.draft.metadata?.borConfig?.enabled).toBe(postBoRConfig.enabled);
|
||||||
|
expect(result.draft.metadata?.borConfig?.borDurationSeconds).toBe(postBoRConfig.borDurationSeconds);
|
||||||
|
expect(result.draft.type).toBe('burn_on_read');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update draft BoR config with enabled false', async () => {
|
||||||
|
await operator.handleDraft({drafts: [draft], prepareRecordsOnly: false});
|
||||||
|
|
||||||
|
const disabledBoRConfig = {...postBoRConfig, enabled: false};
|
||||||
|
const result = await updateDraftBoRConfig(serverUrl, channelId, '', disabledBoRConfig) as {draft: DraftModel; error: unknown};
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.draft).toBeDefined();
|
||||||
|
expect(result.draft.metadata?.borConfig?.enabled).toBe(false);
|
||||||
|
expect(result.draft.type).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {Image} from 'expo-image';
|
||||||
import {DeviceEventEmitter} from 'react-native';
|
import {DeviceEventEmitter} from 'react-native';
|
||||||
|
|
||||||
import {Navigation, Screens} from '@constants';
|
import {Navigation, Screens} from '@constants';
|
||||||
|
import {PostTypes} from '@constants/post';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
import {getDraft} from '@queries/servers/drafts';
|
import {getDraft} from '@queries/servers/drafts';
|
||||||
import {getCurrentChannelId, getCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system';
|
import {getCurrentChannelId, getCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system';
|
||||||
|
|
@ -265,6 +266,53 @@ export async function updateDraftPriority(serverUrl: string, channelId: string,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateDraftBoRConfig(serverUrl: string, channelId: string, rootId: string, postBoRConfig: PostBoRConfig, prepareRecordsOnly = false) {
|
||||||
|
try {
|
||||||
|
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||||
|
const draft = await getDraft(database, channelId, rootId);
|
||||||
|
if (!draft) {
|
||||||
|
const newDraft: Draft = {
|
||||||
|
channel_id: channelId,
|
||||||
|
root_id: rootId,
|
||||||
|
update_at: Date.now(),
|
||||||
|
metadata: {
|
||||||
|
borConfig: postBoRConfig,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (postBoRConfig.enabled) {
|
||||||
|
newDraft.type = PostTypes.BURN_ON_READ;
|
||||||
|
} else {
|
||||||
|
newDraft.type = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return operator.handleDraft({drafts: [newDraft], prepareRecordsOnly});
|
||||||
|
}
|
||||||
|
|
||||||
|
draft?.prepareUpdate((d) => {
|
||||||
|
d.metadata = {
|
||||||
|
...d.metadata,
|
||||||
|
borConfig: postBoRConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (postBoRConfig.enabled) {
|
||||||
|
d.type = PostTypes.BURN_ON_READ;
|
||||||
|
} else {
|
||||||
|
d.type = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!prepareRecordsOnly) {
|
||||||
|
await operator.batchRecords([draft], 'updateDraftBoRConfig');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {draft};
|
||||||
|
} catch (error) {
|
||||||
|
logError('Failed updateDraftBoRConfig', error);
|
||||||
|
return {error};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateDraftMarkdownImageMetadata({
|
export async function updateDraftMarkdownImageMetadata({
|
||||||
serverUrl,
|
serverUrl,
|
||||||
channelId,
|
channelId,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import {ActionType, Post} from '@constants';
|
import {ActionType, Post} from '@constants';
|
||||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
|
import {getPostById} from '@queries/servers/post';
|
||||||
import TestHelper from '@test/test_helper';
|
import TestHelper from '@test/test_helper';
|
||||||
import {COMBINED_USER_ACTIVITY} from '@utils/post_list';
|
import {COMBINED_USER_ACTIVITY} from '@utils/post_list';
|
||||||
|
|
||||||
|
|
@ -18,6 +19,7 @@ import {
|
||||||
removePostAcknowledgement,
|
removePostAcknowledgement,
|
||||||
deletePosts,
|
deletePosts,
|
||||||
getUsersCountFromMentions,
|
getUsersCountFromMentions,
|
||||||
|
updatePostTranslation,
|
||||||
} from './post';
|
} from './post';
|
||||||
|
|
||||||
import type ServerDataOperator from '@database/operator/server_data_operator';
|
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||||
|
|
@ -352,3 +354,49 @@ describe('getUsersCountFromMentions', () => {
|
||||||
expect(num).toBe(1);
|
expect(num).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('updatePostTranslation', () => {
|
||||||
|
const post = TestHelper.fakePost({id: 'postid4', channel_id: channelId});
|
||||||
|
|
||||||
|
it('post not found', async () => {
|
||||||
|
const translation: PostTranslation = {object: {message: 'Hola'}, state: 'ready', source_lang: 'en'};
|
||||||
|
const result = await updatePostTranslation(serverUrl, 'nonexistent', 'es', translation);
|
||||||
|
expect(result.error).toBe('Post not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates post metadata with translation', async () => {
|
||||||
|
await operator.handlePosts({
|
||||||
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||||
|
order: [post.id],
|
||||||
|
posts: [post],
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const translation: PostTranslation = {object: {message: 'Hola'}, state: 'ready', source_lang: 'en'};
|
||||||
|
const result = await updatePostTranslation(serverUrl, post.id, 'es', translation);
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
|
||||||
|
const updatedPost = await getPostById(operator.database, post.id);
|
||||||
|
expect(updatedPost).toBeDefined();
|
||||||
|
expect(updatedPost?.metadata?.translations?.es).toEqual(translation);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handle database write error', async () => {
|
||||||
|
await operator.handlePosts({
|
||||||
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||||
|
order: [post.id],
|
||||||
|
posts: [post],
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalWrite = operator.database.write;
|
||||||
|
operator.database.write = jest.fn().mockRejectedValue(new Error('Write failed'));
|
||||||
|
|
||||||
|
const translation: PostTranslation = {object: {message: 'Hola'}, state: 'ready', source_lang: 'en'};
|
||||||
|
const result = await updatePostTranslation(serverUrl, post.id, 'es', translation);
|
||||||
|
|
||||||
|
operator.database.write = originalWrite;
|
||||||
|
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {countUsersFromMentions, getPostById, prepareDeletePost, queryPostsById}
|
||||||
import {getCurrentUserId} from '@queries/servers/system';
|
import {getCurrentUserId} from '@queries/servers/system';
|
||||||
import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread';
|
import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread';
|
||||||
import {generateId} from '@utils/general';
|
import {generateId} from '@utils/general';
|
||||||
|
import {safeParseJSON} from '@utils/helpers';
|
||||||
import {logError} from '@utils/log';
|
import {logError} from '@utils/log';
|
||||||
import {getLastFetchedAtFromPosts} from '@utils/post';
|
import {getLastFetchedAtFromPosts} from '@utils/post';
|
||||||
import {getPostIdsForCombinedUserActivityPost} from '@utils/post_list';
|
import {getPostIdsForCombinedUserActivityPost} from '@utils/post_list';
|
||||||
|
|
@ -390,3 +391,31 @@ export function getUsersCountFromMentions(serverUrl: string, mentions: string[])
|
||||||
return Promise.resolve(0);
|
return Promise.resolve(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const updatePostTranslation = async (serverUrl: string, postId: string, language: string, translation: PostTranslation) => {
|
||||||
|
try {
|
||||||
|
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||||
|
const post = await getPostById(database, postId);
|
||||||
|
if (!post) {
|
||||||
|
return {error: 'Post not found'};
|
||||||
|
}
|
||||||
|
await database.write(async () => {
|
||||||
|
post.update((v) => {
|
||||||
|
// At this point, the metadata is a string, so we need to parse it
|
||||||
|
let metadata: PostMetadata = safeParseJSON(v.metadata as string) as PostMetadata;
|
||||||
|
if (!metadata) {
|
||||||
|
metadata = {};
|
||||||
|
}
|
||||||
|
if (!metadata.translations) {
|
||||||
|
metadata.translations = {};
|
||||||
|
}
|
||||||
|
metadata.translations[language] = translation;
|
||||||
|
v.metadata = JSON.stringify(metadata) as any;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return {error: undefined};
|
||||||
|
} catch (error) {
|
||||||
|
logError('Failed updatePostTranslation', error);
|
||||||
|
return {error};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -342,5 +342,29 @@ describe('session actions', () => {
|
||||||
|
|
||||||
expect(resetMomentLocale).toHaveBeenCalledWith();
|
expect(resetMomentLocale).toHaveBeenCalledWith();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should remove user credentials but preserve pre-auth secret on logout (removeServer=false)', async () => {
|
||||||
|
await terminateSession(mockServerUrl, false);
|
||||||
|
|
||||||
|
// Verify user credentials are removed
|
||||||
|
expect(removeServerCredentials).toHaveBeenCalledWith(mockServerUrl);
|
||||||
|
|
||||||
|
// Verify database is deleted (not destroyed)
|
||||||
|
expect(DatabaseManager.deleteServerDatabase).toHaveBeenCalledWith(mockServerUrl);
|
||||||
|
expect(DatabaseManager.destroyServerDatabase).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Note: removeServerCredentials now only removes the token, not the pre-auth secret
|
||||||
|
// The pre-auth secret is only removed when destroyServerDatabase is called
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call destroyServerDatabase which removes pre-auth secret on server removal (removeServer=true)', async () => {
|
||||||
|
await terminateSession(mockServerUrl, true);
|
||||||
|
|
||||||
|
// Verify database is destroyed (which will remove pre-auth secret internally)
|
||||||
|
expect(DatabaseManager.destroyServerDatabase).toHaveBeenCalledWith(mockServerUrl);
|
||||||
|
expect(DatabaseManager.deleteServerDatabase).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Note: destroyServerDatabase internally calls removePreauthSecret
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import PostModel from '@typings/database/models/servers/post';
|
||||||
import {isExpiredBoRPost} from '@utils/bor';
|
import {isExpiredBoRPost} from '@utils/bor';
|
||||||
import {logError} from '@utils/log';
|
import {logError} from '@utils/log';
|
||||||
|
|
||||||
|
import {deletePostsForChannelsWithAutotranslation} from './channel';
|
||||||
import {deletePosts} from './post';
|
import {deletePosts} from './post';
|
||||||
|
|
||||||
import type {DataRetentionPoliciesRequest} from '@actions/remote/systems';
|
import type {DataRetentionPoliciesRequest} from '@actions/remote/systems';
|
||||||
|
|
@ -71,6 +72,9 @@ export async function storeConfig(serverUrl: string, config: ClientConfig | unde
|
||||||
const configsToUpdate: IdValue[] = [];
|
const configsToUpdate: IdValue[] = [];
|
||||||
const configsToDelete: IdValue[] = [];
|
const configsToDelete: IdValue[] = [];
|
||||||
|
|
||||||
|
// Check if EnableAutoTranslation changed from enabled to disabled
|
||||||
|
const enableAutoTranslationChanged = (currentConfig?.EnableAutoTranslation === 'true') !== (config.EnableAutoTranslation === 'true');
|
||||||
|
|
||||||
let k: keyof ClientConfig;
|
let k: keyof ClientConfig;
|
||||||
for (k in config) {
|
for (k in config) {
|
||||||
if (currentConfig?.[k] !== config[k]) {
|
if (currentConfig?.[k] !== config[k]) {
|
||||||
|
|
@ -92,6 +96,12 @@ export async function storeConfig(serverUrl: string, config: ClientConfig | unde
|
||||||
if (configsToDelete.length || configsToUpdate.length) {
|
if (configsToDelete.length || configsToUpdate.length) {
|
||||||
const results = await operator.handleConfigs({configs: configsToUpdate, configsToDelete, prepareRecordsOnly});
|
const results = await operator.handleConfigs({configs: configsToUpdate, configsToDelete, prepareRecordsOnly});
|
||||||
DeviceEventEmitter.emit(Events.CONFIG_CHANGED, {serverUrl, config});
|
DeviceEventEmitter.emit(Events.CONFIG_CHANGED, {serverUrl, config});
|
||||||
|
|
||||||
|
// If EnableAutoTranslation was disabled, delete posts and disable user autotranslation
|
||||||
|
if (enableAutoTranslationChanged) {
|
||||||
|
await deletePostsForChannelsWithAutotranslation(serverUrl, prepareRecordsOnly);
|
||||||
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
import {defineMessages} from 'react-intl';
|
import {defineMessages} from 'react-intl';
|
||||||
import {DeviceEventEmitter} from 'react-native';
|
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 DatabaseManager from '@database/manager';
|
||||||
import {getTranslations} from '@i18n';
|
import {getTranslations} from '@i18n';
|
||||||
import {getChannelById} from '@queries/servers/channel';
|
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);
|
subtitle = subtitle.replace('{channelName}', channel.displayName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DeviceEventEmitter.emit(Events.CLOSE_INPUT_ACCESSORY_VIEW);
|
||||||
|
|
||||||
goToScreen(Screens.THREAD, '', {rootId}, {
|
goToScreen(Screens.THREAD, '', {rootId}, {
|
||||||
topBar: {
|
topBar: {
|
||||||
title: {
|
title: {
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@
|
||||||
/* eslint-disable max-lines */
|
/* eslint-disable max-lines */
|
||||||
|
|
||||||
import {createIntl} from 'react-intl';
|
import {createIntl} from 'react-intl';
|
||||||
|
import {DeviceEventEmitter} from 'react-native';
|
||||||
|
|
||||||
import {DeepLink} from '@constants';
|
import {ActionType, DeepLink, Events} from '@constants';
|
||||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
import NetworkManager from '@managers/network_manager';
|
import NetworkManager from '@managers/network_manager';
|
||||||
|
import TestHelper from '@test/test_helper';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
removeMemberFromChannel,
|
removeMemberFromChannel,
|
||||||
|
|
@ -55,6 +57,8 @@ import {
|
||||||
handleKickFromChannel,
|
handleKickFromChannel,
|
||||||
fetchGroupMessageMembersCommonTeams,
|
fetchGroupMessageMembersCommonTeams,
|
||||||
convertGroupMessageToPrivateChannel,
|
convertGroupMessageToPrivateChannel,
|
||||||
|
setChannelAutotranslation,
|
||||||
|
setMyChannelAutotranslation,
|
||||||
} from './channel';
|
} from './channel';
|
||||||
|
|
||||||
import type ServerDataOperator from '@database/operator/server_data_operator';
|
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||||
|
|
@ -134,6 +138,8 @@ const mockClient = {
|
||||||
posts: [],
|
posts: [],
|
||||||
previousPostId: '',
|
previousPostId: '',
|
||||||
})),
|
})),
|
||||||
|
setChannelAutotranslation: jest.fn(),
|
||||||
|
setMyChannelAutotranslation: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const teamId = 'teamid1';
|
const teamId = 'teamid1';
|
||||||
|
|
@ -145,7 +151,6 @@ const intl = createIntl({
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
// eslint-disable-next-line
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
NetworkManager.getClient = () => mockClient;
|
NetworkManager.getClient = () => mockClient;
|
||||||
});
|
});
|
||||||
|
|
@ -765,3 +770,59 @@ describe('direct and group', () => {
|
||||||
expect(updatedChannel).toBeDefined();
|
expect(updatedChannel).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('setChannelAutotranslation', () => {
|
||||||
|
it('should update channel and call deletePostsForChannel when disabling autotranslation', async () => {
|
||||||
|
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
|
||||||
|
const channelWithAutotranslation = TestHelper.fakeChannel({id: channelId, team_id: teamId, autotranslation: true});
|
||||||
|
await operator.handleChannel({channels: [channelWithAutotranslation], prepareRecordsOnly: false});
|
||||||
|
await operator.handlePosts({actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, order: [post.id], posts: [post], prepareRecordsOnly: false});
|
||||||
|
jest.mocked(mockClient.setChannelAutotranslation).mockResolvedValue({id: channelId, autotranslation: false} as Channel);
|
||||||
|
|
||||||
|
const result = await setChannelAutotranslation(serverUrl, channelId, false);
|
||||||
|
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.channel).toBeDefined();
|
||||||
|
expect(result.channel!.autotranslation).toBe(false);
|
||||||
|
expect(mockClient.setChannelAutotranslation).toHaveBeenCalledWith(channelId, false);
|
||||||
|
|
||||||
|
expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId, teamId});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle client error', async () => {
|
||||||
|
jest.mocked(mockClient.setChannelAutotranslation).mockRejectedValueOnce(new Error('API error'));
|
||||||
|
|
||||||
|
const result = await setChannelAutotranslation(serverUrl, channelId, true);
|
||||||
|
|
||||||
|
expect(result.error).toBeDefined();
|
||||||
|
expect(DeviceEventEmitter.emit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setMyChannelAutotranslation', () => {
|
||||||
|
it('should update myChannel and call deletePostsForChannel when autotranslation changes', async () => {
|
||||||
|
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
|
||||||
|
const channel = TestHelper.fakeChannel({id: channelId, team_id: teamId});
|
||||||
|
const myChannelMember = TestHelper.fakeChannelMember({channel_id: channelId, autotranslation_disabled: false});
|
||||||
|
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
|
||||||
|
await operator.handleMyChannel({channels: [channel], myChannels: [myChannelMember], prepareRecordsOnly: false});
|
||||||
|
await operator.handlePosts({actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, order: [post.id], posts: [post], prepareRecordsOnly: false});
|
||||||
|
jest.mocked(mockClient.setMyChannelAutotranslation).mockResolvedValue({} as ChannelMembership);
|
||||||
|
|
||||||
|
const result = await setMyChannelAutotranslation(serverUrl, channelId, false);
|
||||||
|
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.data).toBe(true);
|
||||||
|
expect(mockClient.setMyChannelAutotranslation).toHaveBeenCalledWith(channelId, false);
|
||||||
|
expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId, teamId});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle client error', async () => {
|
||||||
|
jest.mocked(mockClient.setMyChannelAutotranslation).mockRejectedValueOnce(new Error('API error'));
|
||||||
|
|
||||||
|
const result = await setMyChannelAutotranslation(serverUrl, channelId, true);
|
||||||
|
|
||||||
|
expect(result.error).toBeDefined();
|
||||||
|
expect(DeviceEventEmitter.emit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
import {DeviceEventEmitter} from 'react-native';
|
import {DeviceEventEmitter} from 'react-native';
|
||||||
|
|
||||||
import {addChannelToDefaultCategory, handleConvertedGMCategories, storeCategories} from '@actions/local/category';
|
import {addChannelToDefaultCategory, handleConvertedGMCategories, storeCategories} from '@actions/local/category';
|
||||||
import {markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeAllMyChannels, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel';
|
import {markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeAllMyChannels, storeMyChannelsForTeam, switchToChannel, deletePostsForChannel} from '@actions/local/channel';
|
||||||
import {switchToGlobalDrafts} from '@actions/local/draft';
|
import {switchToGlobalDrafts} from '@actions/local/draft';
|
||||||
import {switchToGlobalThreads} from '@actions/local/thread';
|
import {switchToGlobalThreads} from '@actions/local/thread';
|
||||||
import {loadCallForChannel} from '@calls/actions/calls';
|
import {loadCallForChannel} from '@calls/actions/calls';
|
||||||
|
|
@ -272,18 +272,21 @@ export async function patchChannel(serverUrl: string, channelId: string, channel
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = await getChannelById(database, channelData.id);
|
const channel = await getChannelById(database, channelData.id);
|
||||||
if (channel && (channel.displayName !== channelData.display_name || channel.type !== channelData.type)) {
|
if (channel && (channel.displayName !== channelData.display_name || channel.type !== channelData.type || channel.autotranslation !== channelData.autotranslation)) {
|
||||||
channel.prepareUpdate((v) => {
|
channel.prepareUpdate((v) => {
|
||||||
// DM and GM display names cannot be patched and are formatted client-side; do not overwrite
|
// DM and GM display names cannot be patched and are formatted client-side; do not overwrite
|
||||||
if (channelData.type !== General.DM_CHANNEL && channelData.type !== General.GM_CHANNEL) {
|
if (channelData.type !== General.DM_CHANNEL && channelData.type !== General.GM_CHANNEL) {
|
||||||
v.displayName = channelData.display_name;
|
v.displayName = channelData.display_name;
|
||||||
}
|
}
|
||||||
v.type = channelData.type;
|
v.type = channelData.type;
|
||||||
|
if (channelData.autotranslation !== undefined) {
|
||||||
|
v.autotranslation = channelData.autotranslation;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
models.push(channel);
|
models.push(channel);
|
||||||
}
|
}
|
||||||
if (models?.length) {
|
if (models?.length) {
|
||||||
await operator.batchRecords(models.flat(), 'patchChannel');
|
await operator.batchRecords(models, 'patchChannel');
|
||||||
}
|
}
|
||||||
return {channel: channelData};
|
return {channel: channelData};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -1243,6 +1246,76 @@ export const updateChannelNotifyProps = async (serverUrl: string, channelId: str
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const setChannelAutotranslation = async (serverUrl: string, channelId: string, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
|
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||||
|
|
||||||
|
const channelData = await client.setChannelAutotranslation(channelId, enabled);
|
||||||
|
const models = [];
|
||||||
|
|
||||||
|
const channel = await getChannelById(database, channelData.id);
|
||||||
|
const autotranslationDisabled = channel && channel.autotranslation && !channelData.autotranslation;
|
||||||
|
|
||||||
|
if (channel && channel.autotranslation !== channelData.autotranslation) {
|
||||||
|
channel.prepareUpdate((v) => {
|
||||||
|
v.autotranslation = channelData.autotranslation ?? false;
|
||||||
|
});
|
||||||
|
models.push(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (models?.length) {
|
||||||
|
await operator.batchRecords(models, 'setChannelAutotranslation');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete posts when autotranslation setting changes
|
||||||
|
if (autotranslationDisabled) {
|
||||||
|
await deletePostsForChannel(serverUrl, channelData.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {channel: channelData};
|
||||||
|
} catch (error) {
|
||||||
|
logDebug('error on setChannelAutotranslation', getFullErrorMessage(error));
|
||||||
|
forceLogoutIfNecessary(serverUrl, error);
|
||||||
|
return {error};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setMyChannelAutotranslation = async (serverUrl: string, channelId: string, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
|
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||||
|
|
||||||
|
await client.setMyChannelAutotranslation(channelId, enabled);
|
||||||
|
const models = [];
|
||||||
|
|
||||||
|
const myChannel = await getMyChannel(database, channelId);
|
||||||
|
const autotranslationChanged = myChannel && myChannel.autotranslationDisabled !== !enabled;
|
||||||
|
|
||||||
|
if (myChannel && autotranslationChanged) {
|
||||||
|
myChannel.prepareUpdate((v) => {
|
||||||
|
v.autotranslationDisabled = !enabled;
|
||||||
|
});
|
||||||
|
models.push(myChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (models?.length) {
|
||||||
|
await operator.batchRecords(models, 'setMyChannelAutotranslation');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete posts when autotranslation setting changes
|
||||||
|
if (autotranslationChanged) {
|
||||||
|
await deletePostsForChannel(serverUrl, channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {data: true};
|
||||||
|
} catch (error) {
|
||||||
|
logDebug('error on setMyChannelAutotranslation', getFullErrorMessage(error));
|
||||||
|
forceLogoutIfNecessary(serverUrl, error);
|
||||||
|
return {error};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const toggleMuteChannel = async (serverUrl: string, channelId: string, showSnackBar = false) => {
|
export const toggleMuteChannel = async (serverUrl: string, channelId: string, showSnackBar = false) => {
|
||||||
try {
|
try {
|
||||||
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||||
|
|
@ -1395,6 +1468,42 @@ export const fetchGroupMessageMembersCommonTeams = async (serverUrl: string, cha
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function switchToChannelByName(serverUrl: string, channelName: string, teamName?: string) {
|
||||||
|
try {
|
||||||
|
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||||
|
|
||||||
|
let teamId = '';
|
||||||
|
if (teamName) {
|
||||||
|
const team = await getTeamByName(database, teamName);
|
||||||
|
teamId = team?.id || '';
|
||||||
|
}
|
||||||
|
if (!teamId) {
|
||||||
|
teamId = await getCurrentTeamId(database) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let channelId = '';
|
||||||
|
const channel = await getChannelByName(database, teamId, channelName);
|
||||||
|
if (channel) {
|
||||||
|
channelId = channel.id;
|
||||||
|
} else {
|
||||||
|
const fetchResult = await fetchChannelByName(serverUrl, teamId, channelName, true);
|
||||||
|
if (fetchResult.channel) {
|
||||||
|
channelId = fetchResult.channel.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channelId) {
|
||||||
|
await switchToChannelById(serverUrl, channelId, teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
} catch (error) {
|
||||||
|
logDebug('error on switchToChannelByName', getFullErrorMessage(error));
|
||||||
|
forceLogoutIfNecessary(serverUrl, error);
|
||||||
|
return {error};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const convertGroupMessageToPrivateChannel = async (serverUrl: string, channelId: string, targetTeamId: string, displayName: string) => {
|
export const convertGroupMessageToPrivateChannel = async (serverUrl: string, channelId: string, targetTeamId: string, displayName: string) => {
|
||||||
try {
|
try {
|
||||||
const name = generateChannelNameFromDisplayName(displayName);
|
const name = generateChannelNameFromDisplayName(displayName);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {fetchAgents} from '@agents/actions/remote/agents';
|
||||||
|
|
||||||
import {setLastServerVersionCheck} from '@actions/local/systems';
|
import {setLastServerVersionCheck} from '@actions/local/systems';
|
||||||
import {fetchConfigAndLicense} from '@actions/remote/systems';
|
import {fetchConfigAndLicense} from '@actions/remote/systems';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
|
|
@ -35,6 +37,9 @@ export async function appEntry(serverUrl: string, since = 0) {
|
||||||
|
|
||||||
verifyPushProxy(serverUrl);
|
verifyPushProxy(serverUrl);
|
||||||
|
|
||||||
|
// Fetch agents to determine if AI features are available
|
||||||
|
fetchAgents(serverUrl);
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import {nativeApplicationVersion} from 'expo-application';
|
import {nativeApplicationVersion} from 'expo-application';
|
||||||
import {RESULTS, checkNotifications} from 'react-native-permissions';
|
import {RESULTS, checkNotifications} from 'react-native-permissions';
|
||||||
|
|
||||||
|
import {deletePostsForChannel, deletePostsForChannelsWithAutotranslation} from '@actions/local/channel';
|
||||||
import {fetchChannelById, fetchMyChannelsForTeam, handleKickFromChannel, type MyChannelsRequest} from '@actions/remote/channel';
|
import {fetchChannelById, fetchMyChannelsForTeam, handleKickFromChannel, type MyChannelsRequest} from '@actions/remote/channel';
|
||||||
import {type MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference';
|
import {type MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference';
|
||||||
import {fetchConfigAndLicense, fetchDataRetentionPolicy} from '@actions/remote/systems';
|
import {fetchConfigAndLicense, fetchDataRetentionPolicy} from '@actions/remote/systems';
|
||||||
|
|
@ -18,11 +19,12 @@ import {selectDefaultTeam} from '@helpers/api/team';
|
||||||
import {DEFAULT_LOCALE} from '@i18n';
|
import {DEFAULT_LOCALE} from '@i18n';
|
||||||
import NetworkManager from '@managers/network_manager';
|
import NetworkManager from '@managers/network_manager';
|
||||||
import {getDeviceToken} from '@queries/app/global';
|
import {getDeviceToken} from '@queries/app/global';
|
||||||
import {getChannelById} from '@queries/servers/channel';
|
import {getChannelById, queryChannelsById, queryMyChannelsByChannelIds} from '@queries/servers/channel';
|
||||||
import {prepareEntryModels, truncateCrtRelatedTables} from '@queries/servers/entry';
|
import {prepareEntryModels, truncateCrtRelatedTables} from '@queries/servers/entry';
|
||||||
import {getHasCRTChanged} from '@queries/servers/preference';
|
import {getHasCRTChanged} from '@queries/servers/preference';
|
||||||
import {getCurrentChannelId, getCurrentTeamId, getIsDataRetentionEnabled, getPushVerificationStatus, getLastFullSync, setCurrentTeamAndChannelId, getConfigValue} from '@queries/servers/system';
|
import {getCurrentChannelId, getCurrentTeamId, getIsDataRetentionEnabled, getPushVerificationStatus, getLastFullSync, setCurrentTeamAndChannelId, getConfigValue} from '@queries/servers/system';
|
||||||
import {getTeamChannelHistory} from '@queries/servers/team';
|
import {getTeamChannelHistory} from '@queries/servers/team';
|
||||||
|
import {getCurrentUser} from '@queries/servers/user';
|
||||||
import NavigationStore from '@store/navigation_store';
|
import NavigationStore from '@store/navigation_store';
|
||||||
import {isDefaultChannel, isDMorGM, sortChannelsByDisplayName} from '@utils/channel';
|
import {isDefaultChannel, isDMorGM, sortChannelsByDisplayName} from '@utils/channel';
|
||||||
import {getFullErrorMessage} from '@utils/errors';
|
import {getFullErrorMessage} from '@utils/errors';
|
||||||
|
|
@ -179,6 +181,8 @@ const entryRest = async (serverUrl: string, teamId?: string, channelId?: string,
|
||||||
|
|
||||||
const dt = Date.now();
|
const dt = Date.now();
|
||||||
|
|
||||||
|
await handleAutotranslationChanges(serverUrl, meData, chData);
|
||||||
|
|
||||||
const modelPromises = await prepareEntryModels({operator, teamData: initialTeamData, chData, prefData, meData, isCRTEnabled});
|
const modelPromises = await prepareEntryModels({operator, teamData: initialTeamData, chData, prefData, meData, isCRTEnabled});
|
||||||
const models = (await Promise.all(modelPromises)).flat();
|
const models = (await Promise.all(modelPromises)).flat();
|
||||||
logDebug('Process models on entry', groupLabel, models.length, `${Date.now() - dt}ms`);
|
logDebug('Process models on entry', groupLabel, models.length, `${Date.now() - dt}ms`);
|
||||||
|
|
@ -190,6 +194,65 @@ const entryRest = async (serverUrl: string, teamId?: string, channelId?: string,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function handleAutotranslationChanges(serverUrl: string, meData: MyUserRequest | undefined, chData: MyChannelsRequest | undefined) {
|
||||||
|
try {
|
||||||
|
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||||
|
|
||||||
|
// Config changes already handled in storeConfigAndLicense
|
||||||
|
const enableAutoTranslation = (await getConfigValue(operator.database, 'EnableAutoTranslation')) === 'true';
|
||||||
|
if (!enableAutoTranslation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user locale changed, delete all the posts for all channels with autotranslation enabled
|
||||||
|
// so the posts can be refetch with the new translation
|
||||||
|
if (meData) {
|
||||||
|
const currentUser = await getCurrentUser(database);
|
||||||
|
if (currentUser && meData.user?.locale !== currentUser.locale) {
|
||||||
|
await deletePostsForChannelsWithAutotranslation(serverUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chData) {
|
||||||
|
// If a channel stop being autotranslated by the admin, delete the posts for that channel
|
||||||
|
// so the posts can be refetch without the translation
|
||||||
|
const newChannels = chData.channels || [];
|
||||||
|
const channels = await queryChannelsById(database, newChannels.map((c) => c.id)).fetch();
|
||||||
|
const chMap = new Map(channels.map((c) => [c.id, c]));
|
||||||
|
const promises = [];
|
||||||
|
for (const ch of newChannels) {
|
||||||
|
const channel = chMap.get(ch.id);
|
||||||
|
if (channel && channel.autotranslation && !ch.autotranslation) { // Autotranslation disabled
|
||||||
|
promises.push(deletePostsForChannel(serverUrl, ch.id, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const chModels = (await Promise.all(promises)).map((m) => m.models).flat();
|
||||||
|
if (chModels.length) {
|
||||||
|
await operator.batchRecords(chModels, 'handleAutotranslationChanges');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a channel starts or stops being autotranslated by the user, delete the posts for that channel
|
||||||
|
// so the posts can be refetch with or without the translation
|
||||||
|
const newMemberships = chData.memberships || [];
|
||||||
|
const memberships = await queryMyChannelsByChannelIds(database, newMemberships.map((m) => m.channel_id)).fetch();
|
||||||
|
const membershipMap = new Map(memberships.map((m) => [m.id, m]));
|
||||||
|
for (const m of newMemberships) {
|
||||||
|
const membership = membershipMap.get(m.channel_id);
|
||||||
|
if (membership && membership.autotranslationDisabled !== Boolean(m.autotranslation_disabled)) { // Autotranslation modified
|
||||||
|
promises.push(deletePostsForChannel(serverUrl, m.channel_id, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const membershipModels = (await Promise.all(promises)).map((m) => m.models).flat();
|
||||||
|
if (membershipModels.length) {
|
||||||
|
await operator.batchRecords(membershipModels, 'handleAutotranslationChanges');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError('handleAutotranslationChanges', getFullErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function entryInitialChannelId(database: Database, requestedChannelId = '', requestedTeamId = '', initialTeamId: string, locale: string, channels?: Channel[], memberships?: ChannelMember[]) {
|
export async function entryInitialChannelId(database: Database, requestedChannelId = '', requestedTeamId = '', initialTeamId: string, locale: string, channels?: Channel[], memberships?: ChannelMember[]) {
|
||||||
const membershipIds = new Set(memberships?.map((m) => m.channel_id));
|
const membershipIds = new Set(memberships?.map((m) => m.channel_id));
|
||||||
const requestedChannel = channels?.find((c) => (c.id === requestedChannelId) && membershipIds.has(c.id));
|
const requestedChannel = channels?.find((c) => (c.id === requestedChannelId) && membershipIds.has(c.id));
|
||||||
|
|
|
||||||