Compare commits

..

12 commits

Author SHA1 Message Date
unified-ci-app[bot]
d066425e6d
Bump app build number to 703 (#9404)
Some checks failed
github-release / test (push) Has been cancelled
github-release / build-ios-unsigned (push) Has been cancelled
github-release / build-android-unsigned (push) Has been cancelled
github-release / generate-consolidated-sbom (push) Has been cancelled
github-release / release (push) Has been cancelled
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-01-08 13:54:52 +02:00
Mattermost Build
105b5e0568
Fix android logout (#9398) (#9403)
(cherry picked from commit 1a563a7917)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2026-01-08 13:31:26 +02:00
unified-ci-app[bot]
87b6c31689
Bump app build number to 702 (#9400)
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-01-07 13:27:06 +02:00
Mattermost Build
bb6cfab2ef
use correct clientId for Intune in the beta builds (#9395) (#9397)
(cherry picked from commit a57d7aa926)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2026-01-07 13:01:18 +02:00
Mattermost Build
e844023bcf
Fix login with Intune using the Authenticator app (#9349) (#9396)
(cherry picked from commit 23fec945a4)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2026-01-07 12:53:30 +02:00
unified-ci-app[bot]
55d457785a
Bump app build number to 700 (#9392)
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-01-07 08:40:18 +02:00
Mattermost Build
87e9774bb5
[MM-66923] Fix scroll and private channel icon on invite user (#9363) (#9391)
* [MM-66923] Fix scroll and private channel icon on invite user

* Address feedback

* Fix test

(cherry picked from commit 2111c8fb2c)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2026-01-07 08:26:30 +02:00
unified-ci-app[bot]
34c6df6203
Bump app build number to 696 (#9373)
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2025-12-19 09:15:03 +02:00
Mattermost Build
23f3d11524
[MM-66711] Verify push notifications on magic link login (#9322) (#9369)
* [MM-66711] Verify push notifications on magic link login

* Address copilot suggestion

* Add missing await

---------


(cherry picked from commit 4934920e10)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2025-12-18 15:17:31 +02:00
Mattermost Build
d7aa424f37
Bor handle burned ws event (#9346) (#9358)
* handled the websocket event when a user burns a BoR post as receiver

* test: add comprehensive tests for handleBoRPostBurnedEvent function

* Added tests

* reset unintended changes

* Minor fix

(cherry picked from commit 49efb4f470)

Co-authored-by: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com>
2025-12-17 08:36:28 +02:00
unified-ci-app[bot]
970e9fe3c7
Bump app build and version number (#9351)
* Bump app build number to 695

* Bump app version number to 2.36.0

---------

Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2025-12-12 13:33:22 +02:00
Mattermost Build
5cd2363b3b
Add guest invites (#9288) (#9347)
* Add guest invites

* Fix tests and address copilot feedback

* Fix test

* Add magic link changes

(cherry picked from commit d76791ebe6)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2025-12-11 18:32:41 +02:00
974 changed files with 7855 additions and 50658 deletions

View file

@ -12,13 +12,6 @@ runs:
- name: ci/prepare-mobile-build
uses: ./.github/actions/prepare-mobile-build
- name: ci/apply-16kb-pagesize-patch
shell: bash
run: |
echo "::group::Apply 16KB page size compatibility patch"
npm run apply-16kb-pagesize-patch
echo "::endgroup::"
- name: ci/setup-java
uses: actions/setup-java@v4
with:

View file

@ -45,14 +45,15 @@ runs:
path: |
ios/Pods
libraries/@mattermost/intune/ios/Frameworks
key: ${{ runner.os }}-pods-v4-intune-${{ inputs.intune-enabled }}-${{ steps.intune-hash.outputs.hash }}-${{ hashFiles('ios/Podfile.lock') }}-${{ github.ref_name }}
key: ${{ runner.os }}-pods-v3-intune-${{ inputs.intune-enabled }}-${{ steps.intune-hash.outputs.hash }}-${{ hashFiles('ios/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-pods-v4-intune-${{ inputs.intune-enabled }}-${{ steps.intune-hash.outputs.hash }}-${{ hashFiles('ios/Podfile.lock') }}-
${{ runner.os }}-pods-v3-intune-${{ inputs.intune-enabled }}-${{ steps.intune-hash.outputs.hash }}-
${{ runner.os }}-pods-v3-intune-${{ inputs.intune-enabled }}-
- name: ci/install-pods-dependencies
shell: bash
env:
INTUNE_ENABLED: ${{ inputs.intune-enabled }}
INTUNE_ENABLED: ${{ inputs.intune-enabled == 'true' && '1' || '0' }}
run: |
echo "::group::install-pods-dependencies"
echo "INTUNE_ENABLED=$INTUNE_ENABLED"

View file

@ -24,9 +24,6 @@ jobs:
runs-on: ubuntu-22.04
needs:
- test
permissions:
id-token: write
contents: read
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -61,21 +58,3 @@ jobs:
with:
name: android-build-beta-${{ github.run_id }}
path: "*.apk"
# New AWS bucket deployment steps
- name: Configure AWS credentials new bucket
id: configure-aws-credentials-new-bucket
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1
with:
aws-region: us-east-1
role-to-assume: ${{ secrets.MM_MOBILE_RELEASE_AWS_ROLE_TO_ASSUME }}
- name: ci/upload-android-beta-new-bucket
env:
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_RELEASE_AWS_NEW_BUCKET_NAME }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_BETA_MATTERMOST_WEBHOOK_URL }}"
run: |
echo "::group::Upload to new S3 bucket"
bundle exec fastlane android upload_artifacts_to_s3 --env android.beta
echo "::endgroup::"
working-directory: ./fastlane

View file

@ -24,9 +24,6 @@ jobs:
runs-on: ubuntu-22.04
needs:
- test
permissions:
id-token: write
contents: read
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -61,19 +58,3 @@ jobs:
with:
name: android-build-release-${{ github.run_id }}
path: "*.apk"
- name: Configure AWS credentials new bucket
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1
with:
aws-region: us-east-1
role-to-assume: ${{ secrets.MM_MOBILE_RELEASE_AWS_ROLE_TO_ASSUME }}
- name: ci/upload-android-release-new-bucket
env:
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_RELEASE_AWS_NEW_BUCKET_NAME }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_RELEASE_MATTERMOST_WEBHOOK_URL }}"
run: |
echo "::group::Upload to new S3 bucket"
bundle exec fastlane android upload_artifacts_to_s3 --env android.release
echo "::endgroup::"
working-directory: ./fastlane

View file

@ -11,7 +11,7 @@ on:
env:
NODE_VERSION: 22.14.0
TERM: xterm
INTUNE_ENABLED: 'true'
INTUNE_ENABLED: 1
jobs:
test:
@ -27,9 +27,6 @@ jobs:
if: ${{ !contains(github.ref_name, 'beta-ios') }}
needs:
- test
permissions:
id-token: write
contents: read
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -37,7 +34,7 @@ jobs:
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-enabled: 'true'
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: ci/build-ios-simulator
@ -56,30 +53,11 @@ jobs:
name: ios-build-simulator-${{ github.run_id }}
path: Mattermost-simulator-x86_64.app.zip
# Logic for new bucket upload
- name: Configure AWS credentials new bucket
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1
with:
aws-region: us-east-1
role-to-assume: ${{ secrets.MM_MOBILE_RELEASE_AWS_ROLE_TO_ASSUME }}
- name: ci/upload-ios-simulator-new-bucket
env:
TAG: "${{ github.ref_name }}"
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_RELEASE_AWS_NEW_BUCKET_NAME }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_BETA_MATTERMOST_WEBHOOK_URL }}"
GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
run: bundle exec fastlane ios upload_artifacts_to_s3 --env ios.simulator output_file:"Mattermost-simulator-x86_64.app.zip"
working-directory: ./fastlane
build-and-deploy-ios-beta:
runs-on: macos-15-large
if: ${{ !contains(github.ref_name, 'beta-sim') }}
needs:
- test
permissions:
id-token: write
contents: read
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -92,7 +70,7 @@ jobs:
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-enabled: 'true'
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: ci/build-and-deploy-ios-beta
@ -122,20 +100,3 @@ jobs:
with:
name: ios-build-beta-${{ github.run_id }}
path: "*.ipa"
# Logic for new bucket upload
- name: Configure AWS credentials new bucket
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1
with:
aws-region: us-east-1
role-to-assume: ${{ secrets.MM_MOBILE_RELEASE_AWS_ROLE_TO_ASSUME }}
- name: ci/upload-ios-beta-new-bucket
env:
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_RELEASE_AWS_NEW_BUCKET_NAME }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_BETA_MATTERMOST_WEBHOOK_URL }}"
run: |
echo "::group::Upload to new S3 bucket"
bundle exec fastlane ios upload_artifacts_to_s3 --env ios.beta
echo "::endgroup::"
working-directory: ./fastlane

View file

@ -11,7 +11,7 @@ on:
env:
NODE_VERSION: 22.14.0
TERM: xterm
INTUNE_ENABLED: 'true'
INTUNE_ENABLED: 1
jobs:
test:
@ -27,9 +27,6 @@ jobs:
if: ${{ !contains(github.ref_name, 'release-sim') }}
needs:
- test
permissions:
id-token: write
contents: read
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -42,14 +39,13 @@ jobs:
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-enabled: 'true'
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: ci/build-and-deploy-ios-release
env:
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_RELEASE_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_RELEASE_AWS_SECRET_ACCESS_KEY }}"
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_RELEASE_AWS_NEW_BUCKET_NAME }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_RELEASE_MATTERMOST_WEBHOOK_URL }}"
FASTLANE_TEAM_ID: "${{ secrets.MM_MOBILE_FASTLANE_TEAM_ID }}"
IOS_API_ISSUER_ID: "${{ secrets.MM_MOBILE_IOS_API_ISSUER_ID }}"
@ -74,31 +70,11 @@ jobs:
name: ios-build-release-${{ github.run_id }}
path: "*.ipa"
# Upload artifacts to new bucket
- name: Configure AWS credentials new bucket
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1
with:
aws-region: us-east-1
role-to-assume: ${{ secrets.MM_MOBILE_RELEASE_AWS_ROLE_TO_ASSUME }}
- name: ci/upload-ios-release-to-new-bucket
env:
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_RELEASE_AWS_NEW_BUCKET_NAME }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_RELEASE_MATTERMOST_WEBHOOK_URL }}"
run: |
echo "::group::Build"
bundle exec fastlane ios upload_artifacts_to_s3 --env ios.release
echo "::endgroup::"
working-directory: ./fastlane
build-ios-simulator:
runs-on: macos-15-large
if: ${{ !contains(github.ref_name , 'release-ios') }}
needs:
- test
permissions:
id-token: write
contents: read
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -106,7 +82,7 @@ jobs:
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-enabled: 'true'
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: ci/build-ios-simulator
@ -124,18 +100,3 @@ jobs:
with:
name: ios-build-simulator-${{ github.run_id }}
path: Mattermost-simulator-x86_64.app.zip
# Logic for new bucket upload
- name: Configure AWS credentials new bucket
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1
with:
aws-region: us-east-1
role-to-assume: ${{ secrets.MM_MOBILE_RELEASE_AWS_ROLE_TO_ASSUME }}
- name: ci/upload-ios-simulator-new-bucket
env:
TAG: "${{ github.ref_name }}"
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_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

View file

@ -8,7 +8,7 @@ on:
env:
NODE_VERSION: 22.14.0
TERM: xterm
INTUNE_ENABLED: 'true'
INTUNE_ENABLED: 1
jobs:
test:
@ -41,7 +41,7 @@ jobs:
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-enabled: 'true'
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: ci/build-ios-pr

View file

@ -127,7 +127,7 @@ jobs:
device_os_version: ${{ env.SDK_VERSION }}
e2e-android:
name: machine-${{ matrix.runId }}-api-${{ matrix.deviceOsVersion }}
name: android-detox-e2e-${{ matrix.runId }}-${{ matrix.deviceName }}-${{ matrix.deviceOsVersion }}
runs-on: ubuntu-latest-8-cores
continue-on-error: true
timeout-minutes: 240

View file

@ -1,11 +1,12 @@
# 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.
name: E2E
name: Detox E2E Tests PR
on:
pull_request:
branches:
- main
- feature_schedule_posts
types:
- labeled
@ -14,7 +15,7 @@ concurrency:
cancel-in-progress: true
env:
INTUNE_ENABLED: 'true'
INTUNE_ENABLED: 1
jobs:
update-initial-status-ios:
@ -48,7 +49,7 @@ jobs:
build-ios-simulator:
if: contains(github.event.label.name, 'E2E iOS tests for PR')
runs-on: macos-26
runs-on: macos-15
needs:
- update-initial-status-ios
steps:
@ -58,25 +59,23 @@ jobs:
- name: Prepare iOS Build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-enabled: 'true'
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
env:
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 }}"
run: bundle exec fastlane ios simulator --env ios.simulator skip_upload_to_s3_bucket:true
run: bundle exec fastlane ios simulator --env ios.simulator
working-directory: ./fastlane
- name: Upload iOS Simulator Build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ios-build-simulator-${{ github.run_id }}
path: Mattermost-simulator-*.app.zip
path: Mattermost-simulator-x86_64.app.zip
build-android-apk:
runs-on: ubuntu-latest-8-cores
@ -123,7 +122,7 @@ jobs:
run-ios-tests-on-pr:
if: contains(github.event.label.name, 'E2E iOS tests for PR')
name: iOS
name: iOS Mobile Tests on PR
uses: ./.github/workflows/e2e-ios-template.yml
needs:
- build-ios-simulator
@ -135,7 +134,7 @@ jobs:
run-android-tests-on-pr:
if: contains(github.event.label.name, 'E2E Android tests for PR')
name: Android
name: Android Mobile Tests on PR
uses: ./.github/workflows/e2e-android-template.yml
needs:
- build-android-apk

View file

@ -6,7 +6,7 @@ on:
- release-*
env:
INTUNE_ENABLED: 'true'
INTUNE_ENABLED: 1
jobs:
update-initial-status-ios:
@ -46,7 +46,7 @@ jobs:
- name: Prepare iOS Build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-enabled: 'true'
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: Build iOS Simulator

View file

@ -5,7 +5,7 @@ on:
- cron: "0 0 * * 4,5" # Wednesday and Thursday midnight
env:
INTUNE_ENABLED: 'true'
INTUNE_ENABLED: 1
jobs:
update-initial-status-ios:
@ -45,7 +45,7 @@ jobs:
- name: Prepare iOS Build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-enabled: 'true'
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: Build iOS Simulator

View file

@ -38,12 +38,12 @@ on:
description: "iPhone simulator name"
required: false
type: string
default: "iPhone 17 Pro"
default: "iPhone 15 Pro"
ios_device_os_name:
description: "iPhone simulator OS version"
required: false
type: string
default: "iOS 26.2"
default: "iOS 17.2"
low_bandwidth_mode:
description: "Enable low bandwidth mode"
required: false
@ -121,7 +121,7 @@ jobs:
device_os_version: ${{ env.DEVICE_OS_VERSION }}
e2e-ios:
name: machine-${{ matrix.runId }}-os-${{ matrix.deviceOsVersion }}
name: ios-detox-e2e-${{ matrix.runId }}-${{ matrix.deviceName }}-${{ matrix.deviceOsVersion }}
runs-on: macos-15
continue-on-error: true
timeout-minutes: ${{ inputs.low_bandwidth_mode && 240 || 180 }}
@ -173,82 +173,6 @@ jobs:
- name: Install Detox Dependencies
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
if: ${{ inputs.low_bandwidth_mode }}
id: start-proxy
@ -258,111 +182,35 @@ jobs:
- name: Set .env with RUNNING_E2E=true
run: |
echo "RUNNING_E2E=true" > .env
cat > .env <<EOF
echo "RUNNING_E2E=true" >> .env
- 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
- name: Run Detox E2E Tests
continue-on-error: true # We want to run all the tests
run: |
# Start Metro bundler in background
npm run start > metro.log 2>&1 &
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
# Start the server
npm run start &
sleep 120 # Wait for watchman to finish querying the files
cd detox
npm run clean-detox
npm run detox:config-gen
npm run e2e:ios-test -- ${{ matrix.specs }}
env:
DETOX_DISABLE_HIERARCHY_DUMP: "YES"
DETOX_DISABLE_SCREENSHOT_TRACKING: "YES"
DETOX_DISABLE_SCREENSHOT_TRACKING: "YES"
DETOX_LOGLEVEL: "debug"
DETOX_DEVICE_TYPE: ${{ env.DEVICE_NAME }}
DETOX_OS_VERSION: ${{ env.DEVICE_OS_VERSION }}
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
if: ${{ inputs.low_bandwidth_mode || failure() }}
run: |
networksetup -setwebproxystate Ethernet "off"
networksetup -setsecurewebproxystate Ethernet "off"
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 (sudo pfctl -q -sa | grep 'Status: Enabled') then sudo pfctl -d; fi
if (command -v pm2 &> /dev/null) then pm2 stop mitmdump; fi
sleep 5;

View file

@ -15,7 +15,7 @@ env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
SNYK_VERSION: "1.1297.2"
CYCLONEDX_VERSION: "v0.27.2"
INTUNE_ENABLED: 'true'
INTUNE_ENABLED: 1
jobs:
test:
@ -42,7 +42,7 @@ jobs:
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-enabled: 'true'
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: ci/build-ios-unsigned

310
.gitignore vendored
View file

@ -1,199 +1,135 @@
# Sorted combined gitignore from .gitignore and .gitignore_my
!build/notice-file
!debug.keystore
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
!gradle-wrapper.jar
!gradle-wrapper.properties
!libraries/@mattermost/intune/.gitkeep
!src/**/build/
**/build/
**/fastlane/.env
**/fastlane/Preview.html
**/fastlane/report.xml
**/fastlane/screenshots
**/fastlane/test_output
*.aab
*.apk
*.hmap
*.hprof
*.iml
*.ipa
*.jks
*.jsbundle
*.keystore
*.lcov
*.log
*.mattermost-license
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
*.pid
*.pid.lock
*.seed
*.tgz
*.tsbuildinfo
*.un~
*.xccheckout
*.xcscmblueprint
*.xcuserstate
assets/override
dist
build-ios
*.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/
server.PID
mattermost.keystore
tmp/
.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
env.d.ts
*.apk
*.aab
*.ipa
.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
*/**/compass-icons.ttf
# OSX
#
.DS_Store
# Xcode
#
ios/build/*
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
Icon
Network Trash Folder
Session.vim
Temporary Items
[._]*.s[a-w][a-z]
[._]s[a-w][a-z]
__generated__
*.hmap
*.xcuserstate
project.xcworkspace
ios/Pods
.podinstall
ios/.xcode.env.local
# Android/IntelliJ
#
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
*.keystore
!debug.keystore
android/app/bin
android/app/build
android/app/src/main/res/raw/*
android/build
.settings
.project
.classpath
# node.js
#
node_modules/
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/screenshots
**/fastlane/test_output
**/fastlane/.env
# Sentry
android/sentry.properties
assets/override
bower_components
buck-out/
build-ios
build/Release
captures/
ios/sentry.properties
# Testing
.nyc_output
coverage
deploymentTargetDropDown.xml
.tmp
# E2E testing
mattermost-license.txt
*.mattermost-license
detox/artifacts
detox/detox_pixel_*
dist
env.d.ts
google-services.json
gradle-app.setting
ios/.xcode.env.local
ios/Pods
ios/build/*
ios/sentry.properties
jspm_packages/
lerna-debug.log*
lib-cov
libraries/**/**/.build
libraries/**/**/build
libraries/@mattermost/intune/*
local.properties
logs
mattermost-license.txt
mattermost.keystore
misc.xml
node_modules/
node_modules/@mattermost/intune
npm-debug.log
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
# Bundle artifact
*.jsbundle
.bundle
#editor-settings
.vscode
.scannerwork
launch.json
# Notice.txt generation
!build/notice-file
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*
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/.gitkeep
# Prevent committing Intune module in node_modules
node_modules/@mattermost/intune
# Claude Code
.claude/settings.local.json

View file

@ -1,3 +0,0 @@
# 필수 준수 사항
- agent-ops/rules/global.md 파일을 읽고 이에 대해 꼭 준수해서 진행해야한다.
- global.md 내용을 모르면 추정하지 말고 먼저 해당 파일을 확인한다.

View file

@ -1 +0,0 @@
-

View file

@ -1 +0,0 @@
-

View file

@ -1 +0,0 @@
-

View file

@ -1 +0,0 @@
-

View file

@ -1 +0,0 @@
-

View file

@ -1 +0,0 @@
-

31
.vscode/launch.json vendored
View file

@ -1,31 +0,0 @@
{
"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
View file

@ -1,17 +0,0 @@
{
"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"
}

View file

@ -1,3 +0,0 @@
# 필수 준수 사항
- agent-ops/rules/global.md 파일을 읽고 이에 대해 꼭 준수해서 진행해야한다.
- global.md 내용을 모르면 추정하지 말고 먼저 해당 파일을 확인한다.

View file

@ -1,209 +0,0 @@
---
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 단계에서 불필요한 세부 구현까지 생성하지 않는다.

View file

@ -1,172 +0,0 @@
# 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

369
CLAUDE.md
View file

@ -1,3 +1,366 @@
# 필수 준수 사항
- agent-ops/rules/global.md 파일을 읽고 이에 대해 꼭 준수해서 진행해야한다.
- global.md 내용을 모르면 추정하지 말고 먼저 해당 파일을 확인한다.
# CLAUDE.md
## Project Overview
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
- Default messages in code must match JSON translations exactly, including newlines
- Translation IDs should be descriptive enough for translators to understand context
- 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

View file

@ -1,3 +0,0 @@
# 필수 준수 사항
- agent-ops/rules/global.md 파일을 읽고 이에 대해 꼭 준수해서 진행해야한다.
- global.md 내용을 모르면 추정하지 말고 먼저 해당 파일을 확인한다.

View file

@ -1,4 +1,3 @@
source "https://rubygems.org"
gem "cocoapods", "1.16.1"
gem "rexml", ">= 3.4.2"

View file

@ -87,7 +87,7 @@ GEM
netrc (0.11.0)
nkf (0.2.0)
public_suffix (4.0.7)
rexml (3.4.4)
rexml (3.3.9)
ruby-macho (2.5.1)
securerandom (0.4.0)
typhoeus (1.4.1)
@ -107,7 +107,6 @@ PLATFORMS
DEPENDENCIES
cocoapods (= 1.16.1)
rexml (>= 3.4.2)
BUNDLED WITH
2.5.11

View file

@ -20,21 +20,6 @@ A spec-compliant polyfill for Intl.DateTimeFormat fully tested by the official E
* 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
@ -150,42 +135,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## @gorhom/portal
This product contains '@gorhom/portal' by Mo Gorhom.
A simplified portal implementation for ⭕️ React Native ⭕️
* HOMEPAGE:
* https://github.com/gorhom/react-native-portal#readme
* LICENSE: MIT
MIT License
Copyright (c) 2020 Mo Gorhom
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## @mattermost/calls
@ -1753,43 +1702,6 @@ Lightweight fuzzy-search
limitations under the License.
---
## grapheme-splitter
This product contains 'grapheme-splitter' by Orlin Georgiev.
A JavaScript library that breaks strings into their individual user-perceived characters. It supports emojis!
* HOMEPAGE:
* https://github.com/orling/grapheme-splitter
* LICENSE: MIT
The MIT License (MIT)
Copyright (c) 2015 Orlin Georgiev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## html-entities
@ -2525,42 +2437,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## react-native-keyboard-controller
This product contains 'react-native-keyboard-controller' by Kiryl Ziusko.
Keyboard manager which works in identical way on both iOS and Android
* HOMEPAGE:
* https://kirillzyusko.github.io/react-native-keyboard-controller/
* LICENSE: MIT
MIT License
Copyright (c) 2021 Kiryl Ziusko
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## react-native-keychain

View file

@ -1,7 +1,7 @@
# Mattermost Mobile v2
- **Minimum Server versions:** Current ESR version (10.11.0+)
- **Supported iOS versions:** 16.0+
- **Supported iOS versions:** 15.1+
- **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).
@ -12,34 +12,6 @@ We plan on releasing monthly updates with new features - check the [changelog](h
**Important:** If you self-compile the Mattermost Mobile apps you also need to deploy your own [Mattermost Push Notification Service](https://github.com/mattermost/mattermost-push-proxy/releases).
# Android 16KB Page Size Support
**Temporary Requirement:** To comply with Google Play's 16KB page size requirement for Android devices, this project includes a compatibility patch that must be applied before building for Android.
### When to Apply the Patch
- **CI/CD Builds:** The patch is automatically applied in GitHub Actions for Android builds
- **Local Development:** If you're building Android locally and encounter 16KB page size related issues, run:
```bash
npm run apply-16kb-pagesize-patch
```
This script will:
1. Update package dependencies to compatible versions
2. Apply necessary code changes for 16KB page size support
3. Update patch files for modified dependencies
4. Regenerate `package-lock.json`
### ⚠️ Important Warnings
- **DO NOT commit the changes** applied by this patch to the repository
- **These changes will break iOS builds** if committed
- The patch is designed to be applied only during Android CI builds
- For local development, revert all changes after building Android
**Note:** This is a temporary solution until all dependencies natively support 16KB page sizes.
# How to Contribute
### Testing
@ -47,7 +19,7 @@ This script will:
To help with testing app updates before they're released, you can:
1. Sign up to be a beta tester
- [Android](https://play.google.com/apps/testing/com.tokilabs.mattermost)
- [Android](https://play.google.com/apps/testing/com.mattermost.rnbeta)
- [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.
3. File any bugs you find by filing a [GitHub issue](https://github.com/mattermost/mattermost-mobile/issues) with:
@ -59,7 +31,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
You can leave the Beta testing program at any time:
- 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 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 iOS, access the `Mattermost Beta` app page in TestFlight and click **Stop Testing**.
### Contribute Code

View file

@ -1,32 +0,0 @@
---
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 패턴을 따른다
## 금지 사항
- < 도메인에서 하면 되는 >

View file

@ -1,46 +0,0 @@
---
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 누락

View file

@ -1,45 +0,0 @@
---
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()` 처리)

View file

@ -1,49 +0,0 @@
---
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()` 호출

View file

@ -1,41 +0,0 @@
---
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()` 호출

View file

@ -1,53 +0,0 @@
---
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 컴포넌트 내부에 인라인 삽입

View file

@ -1,23 +0,0 @@
---
name: global
description: 전 프로젝트 공통 규칙
version: 1.0.0
---
# Global Rules
## 응답 언어
- 한국어로 응답한다.
## 기존 구조 우선
- 새 파일 생성보다 기존 파일 수정을 우선한다.
- 기존 패턴과 컨벤션을 따른다.
## 코드 변경 원칙
- 코드 변경 전 관련 domain rule을 확인한다.
- 요청 범위를 넘는 변경을 금지한다.
- 불확실하면 단정하지 말고 후보로 제시한다.
## 작업 전 확인
- `agent-ops/skills/router.md` 를 먼저 참조하여 필요한 skill 또는 domain rule만 로드한다.
- 관련 rule / skill 만 최소로 로드한다.

View file

@ -1,35 +0,0 @@
---
name: <skill-name>
description: <skill 설명>
version: 1.0.0
---
# <Skill Name>
## 목적
< skill이 해결하는 문제>
## 언제 호출할지
- < skill을 사용해야 하는 상황>
- < skill을 사용해야 하는 상황>
## 입력
- `<param>`: <설명> (필수/선택)
## 먼저 확인할 것
- [ ] <사전 확인 항목>
- [ ] <사전 확인 항목>
## 실행 절차
1. <단계>
2. <단계>
3. <단계>
## 출력 형식
```
<출력 예시>
```
## 금지 사항
- <하면 되는 >
- <하면 되는 >

View file

@ -1,48 +0,0 @@
---
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 파일 수정 금지)

View file

@ -1,96 +0,0 @@
---
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 파일 생성만 담당한다

View file

@ -1,83 +0,0 @@
---
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 에 여러 독립적인 책임을 묶지 않는다

View file

@ -1,40 +0,0 @@
---
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까지 로드하지 않는다

View file

@ -1,48 +0,0 @@
---
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로 먼저 판별한다.

View file

@ -104,7 +104,7 @@ android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdkVersion rootProject.ext.compileSdkVersion
namespace "com.tokilabs.mattermost"
namespace "com.mattermost.rnbeta"
lintOptions {
checkReleaseBuilds false
@ -112,11 +112,11 @@ android {
}
defaultConfig {
applicationId "com.tokilabs.mattermost"
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 730
versionName "2.38.0"
versionCode 703
versionName "2.36.0"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}

View file

@ -1,29 +1,104 @@
{
"project_info": {
"project_number": "1047648748539",
"project_id": "mattermost-6ac08",
"storage_bucket": "mattermost-6ac08.firebasestorage.app"
"project_number": "184930218130",
"firebase_url": "https://api-7231322553409637977-752355.firebaseio.com",
"project_id": "api-7231322553409637977-752355",
"storage_bucket": "api-7231322553409637977-752355.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:1047648748539:android:818bf70bfbb3f9d070415e",
"mobilesdk_app_id": "1:184930218130:android:a0e553d1a1f043b5",
"android_client_info": {
"package_name": "com.tokilabs.mattermost"
"package_name": "com.mattermost.react.native"
}
},
"oauth_client": [],
"oauth_client": [
{
"client_id": "184930218130-8nahrspll1opff0uogtkh2qsv8coqngo.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyAW6j_oPl9MVcm93qS3JgaEPD5ywp-TzZ0"
"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: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"
}
}

View file

@ -1,4 +1,4 @@
package com.tokilabs.mattermost;
package com.mattermost.rnbeta;
import com.wix.detox.Detox;
import com.wix.detox.config.DetoxConfig;

View file

@ -77,7 +77,7 @@
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mmauth_tokilabs" />
<data android:scheme="mmauthbeta" />
</intent-filter>
</activity>
<service android:name=".NotificationDismissService"

View file

@ -28,7 +28,7 @@ import androidx.core.app.Person;
import androidx.core.app.RemoteInput;
import androidx.core.graphics.drawable.IconCompat;
import com.tokilabs.mattermost.*;
import com.mattermost.rnbeta.*;
import com.mattermost.rnutils.helpers.NotificationHelper;
import com.nozbe.watermelondb.WMDatabase;
import com.mattermost.turbolog.TurboLog;

View file

@ -135,22 +135,21 @@ class PushNotificationDataRunnable {
threadsArray.add(thread)
}
for(i in 0 until it.size()) {
it.getMap(i)?.let { thread ->
val threadId = thread.getString("id")
if (threadId != null) {
if (threadIds.contains(threadId)) {
// replace the values for participants and is_following
val index = threadsArray.indexOfFirst { el -> el.getString("id") == threadId }
val prev = threadsArray[index]
val merge = Arguments.createMap()
merge.merge(prev)
merge.putBoolean("is_following", thread.getBoolean("is_following"))
merge.putArray("participants", thread.getArray("participants"))
threadsArray[index] = merge
} else {
threadsArray.add(thread)
threadIds.add(threadId)
}
val thread = it.getMap(i)
val threadId = thread.getString("id")
if (threadId != null) {
if (threadIds.contains(threadId)) {
// replace the values for participants and is_following
val index = threadsArray.indexOfFirst { el -> el.getString("id") == threadId }
val prev = threadsArray[index]
val merge = Arguments.createMap()
merge.merge(prev)
merge.putBoolean("is_following", thread.getBoolean("is_following"))
merge.putArray("participants", thread.getArray("participants"))
threadsArray[index] = merge
} else {
threadsArray.add(thread)
threadIds.add(threadId)
}
}
}

View file

@ -54,14 +54,12 @@ fun insertCategoriesWithChannels(db: WMDatabase, orderCategories: ReadableMap) {
val categories = orderCategories.getArray("categories") ?: return
for (i in 0 until categories.size()) {
val category = categories.getMap(i)
category?.let { cat ->
val id = cat.getString("id")
val teamId = cat.getString("team_id")
val channelIds = cat.getArray("channel_ids")
insertCategory(db, cat)
if (id != null && teamId != null) {
channelIds?.let { insertCategoryChannels(db, id, teamId, it) }
}
val id = category.getString("id")
val teamId = category.getString("team_id")
val channelIds = category.getArray("channel_ids")
insertCategory(db, category)
if (id != null && teamId != null) {
channelIds?.let { insertCategoryChannels(db, id, teamId, it) }
}
}
}
@ -69,22 +67,19 @@ fun insertCategoriesWithChannels(db: WMDatabase, orderCategories: ReadableMap) {
fun insertChannelToDefaultCategory(db: WMDatabase, categoryChannels: ReadableArray) {
try {
for (i in 0 until categoryChannels.size()) {
categoryChannels.getMap(i)?.let { cc ->
val id = cc.getString("id")
val categoryId = cc.getString("category_id")
val channelId = cc.getString("channel_id")
if (id != null && categoryId != null && channelId != null) {
val count = countByColumn(db, "CategoryChannel", "category_id", categoryId)
db.execute(
"""
INSERT INTO CategoryChannel
(id, category_id, channel_id, sort_order, _changed, _status)
VALUES (?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(id, categoryId, channelId, if (count > 0) count + 1 else count)
)
}
}
val cc = categoryChannels.getMap(i)
val id = cc.getString("id")
val categoryId = cc.getString("category_id")
val channelId = cc.getString("channel_id")
val count = countByColumn(db, "CategoryChannel", "category_id", categoryId)
db.execute(
"""
INSERT INTO CategoryChannel
(id, category_id, channel_id, sort_order, _changed, _status)
VALUES (?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(id, categoryId, channelId, if (count > 0) count + 1 else count)
)
}
} catch (e: Exception) {
e.printStackTrace()

View file

@ -26,7 +26,7 @@ internal fun DatabaseHelper.saveToDatabase(db: WMDatabase, data: ReadableMap, te
data.getArray("threads")?.let {
val threadsArray = ArrayList<ReadableMap>()
for (i in 0 until it.size()) {
it.getMap(i)?.let { map -> threadsArray.add(map) }
threadsArray.add(it.getMap(i))
}
handleThreads(db, threadsArray, teamId)
}

View file

@ -10,10 +10,8 @@ import com.nozbe.watermelondb.WMDatabase
internal fun findPostInChannel(chunks: ReadableArray, earliest: Double, latest: Double): ReadableMap? {
for (i in 0 until chunks.size()) {
val chunk = chunks.getMap(i)
chunk?.let {
if (earliest >= it.getDouble("earliest") || latest <= it.getDouble("latest")) {
return it
}
if (earliest >= chunk.getDouble("earliest") || latest <= chunk.getDouble("latest")) {
return chunk
}
}
@ -47,12 +45,10 @@ internal fun mergePostsInChannel(db: WMDatabase, existingChunks: ReadableArray,
for (i in 0 until existingChunks.size()) {
try {
val chunk = existingChunks.getMap(i)
chunk?.let {
if (newChunk.getDouble("earliest") <= it.getDouble("earliest") &&
newChunk.getDouble("latest") >= it.getDouble("latest")) {
db.execute("DELETE FROM PostsInChannel WHERE id = ?", arrayOf(it.getString("id")))
return
}
if (newChunk.getDouble("earliest") <= chunk.getDouble("earliest") &&
newChunk.getDouble("latest") >= chunk.getDouble("latest")) {
db.execute("DELETE FROM PostsInChannel WHERE id = ?", arrayOf(chunk.getString("id")))
break
}
} catch (e: Exception) {
e.printStackTrace()

View file

@ -83,17 +83,15 @@ internal fun insertThreadParticipants(db: WMDatabase, threadId: String, particip
for (i in 0 until participants.size()) {
try {
val participant = participants.getMap(i)
participant?.let {
val id = RandomId.generate()
db.execute(
"""
INSERT INTO ThreadParticipant
(id, thread_id, user_id, _changed, _status)
VALUES (?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(id, threadId, it.getString("id"))
)
}
val id = RandomId.generate()
db.execute(
"""
INSERT INTO ThreadParticipant
(id, thread_id, user_id, _changed, _status)
VALUES (?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(id, threadId, participant.getString("id"))
)
} catch (e: Exception) {
e.printStackTrace()
}

View file

@ -48,40 +48,38 @@ fun getCurrentUserLocale(db: WMDatabase): String {
fun handleUsers(db: WMDatabase, users: ReadableArray) {
for (i in 0 until users.size()) {
val user = users.getMap(i)
user?.let { u ->
val roles = u.getString("roles") ?: ""
val isBot = try {
u.getBoolean("is_bot")
} catch (e: NoSuchKeyException) {
false
}
val roles = user.getString("roles") ?: ""
val isBot = try {
user.getBoolean("is_bot")
} catch (e: NoSuchKeyException) {
false
}
val lastPictureUpdate = try { u.getDouble("last_picture_update") } catch (e: NoSuchKeyException) { 0 }
val lastPictureUpdate = try { user.getDouble("last_picture_update") } catch (e: NoSuchKeyException) { 0 }
try {
db.execute(
"""
INSERT INTO User (id, auth_service, update_at, delete_at, email, first_name, is_bot, is_guest,
last_name, last_picture_update, locale, nickname, position, roles, status, username, notify_props,
props, timezone, _changed, _status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(
u.getString("id"),
u.getString("auth_service"), u.getDouble("update_at"), u.getDouble("delete_at"),
u.getString("email"), u.getString("first_name"), isBot,
roles.contains("system_guest"), u.getString("last_name"), lastPictureUpdate,
u.getString("locale"), u.getString("nickname"), u.getString("position"),
roles, "", u.getString("username"), "{}",
ReadableMapUtils.toJSONObject(u.getMap("props")
?: Arguments.createMap()).toString(),
ReadableMapUtils.toJSONObject(u.getMap("timezone")
?: Arguments.createMap()).toString(),
)
)
} catch (e: Exception) {
e.printStackTrace()
}
try {
db.execute(
"""
INSERT INTO User (id, auth_service, update_at, delete_at, email, first_name, is_bot, is_guest,
last_name, last_picture_update, locale, nickname, position, roles, status, username, notify_props,
props, timezone, _changed, _status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(
user.getString("id"),
user.getString("auth_service"), user.getDouble("update_at"), user.getDouble("delete_at"),
user.getString("email"), user.getString("first_name"), isBot,
roles.contains("system_guest"), user.getString("last_name"), lastPictureUpdate,
user.getString("locale"), user.getString("nickname"), user.getString("position"),
roles, "", user.getString("username"), "{}",
ReadableMapUtils.toJSONObject(user.getMap("props")
?: Arguments.createMap()).toString(),
ReadableMapUtils.toJSONObject(user.getMap("timezone")
?: Arguments.createMap()).toString(),
)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
}

View file

@ -26,13 +26,11 @@ suspend fun PushNotificationDataRunnable.Companion.fetchMyChannel(db: WMDatabase
"D" -> {
profilesArray = fetchProfileInChannel(db, serverUrl, channelId)
if ((profilesArray?.size() ?: 0) > 0) {
profilesArray?.getMap(0)?.let { profile ->
val displayName = displayUsername(profile, displayNameSetting)
val data = Arguments.createMap()
data.merge(channelData)
data.putString("display_name", displayName)
channelData = data
}
val displayName = displayUsername(profilesArray!!.getMap(0), displayNameSetting)
val data = Arguments.createMap()
data.merge(channelData)
data.putString("display_name", displayName)
channelData = data
}
}
"G" -> {
@ -119,10 +117,8 @@ private suspend fun PushNotificationDataRunnable.Companion.fetchProfileInChannel
if (profilesArray != null) {
for (i in 0 until profilesArray.size()) {
val profile = profilesArray.getMap(i)
profile?.let {
if (it.getString("id") != currentUserId) {
result.pushMap(it)
}
if (profile.getString("id") != currentUserId) {
result.pushMap(profile)
}
}
}
@ -156,9 +152,7 @@ private fun PushNotificationDataRunnable.Companion.displayGroupMessageName(profi
val names = ArrayList<String>()
for (i in 0 until profilesArray.size()) {
val profile = profilesArray.getMap(i)
profile?.let {
names.add(displayUsername(it, displayNameSetting))
}
names.add(displayUsername(profile, displayNameSetting))
}
return names.sortedWith { s1, s2 ->

View file

@ -62,7 +62,7 @@ internal suspend fun PushNotificationDataRunnable.Companion.fetchPosts(
val userIdsAlreadyLoaded = mutableListOf<String>()
if (loadedProfiles != null) {
for (i in 0 until loadedProfiles.size()) {
loadedProfiles.getMap(i)?.getString("id")?.let { userIdsAlreadyLoaded.add(it) }
loadedProfiles.getMap(i).getString("id")?.let { userIdsAlreadyLoaded.add(it) }
}
}
@ -95,12 +95,10 @@ internal suspend fun PushNotificationDataRunnable.Companion.fetchPosts(
if (attachments != null) {
for (i in 0 until attachments.size()) {
val attachment = attachments.getMap(i)
attachment?.let {
val pretext = it.getString("pretext")
val text = it.getString("text")
findNeededUsernames(pretext)
findNeededUsernames(text)
}
val pretext = attachment.getString("pretext")
val text = attachment.getString("text")
findNeededUsernames(pretext)
findNeededUsernames(text)
}
}
@ -134,23 +132,22 @@ internal suspend fun PushNotificationDataRunnable.Companion.fetchPosts(
participants?.let {
for (i in 0 until it.size()) {
val participant = it.getMap(i)
participant?.let { p ->
val participantId = p.getString("id")
if (participantId != currentUserId && participantId != null) {
if (!threadParticipantUserIds.contains(participantId) && !userIdsAlreadyLoaded.contains(participantId)) {
threadParticipantUserIds.add(participantId)
}
if (!threadParticipantUsers.containsKey(participantId)) {
threadParticipantUsers[participantId] = p
}
val participantId = participant.getString("id")
if (participantId != currentUserId && participantId != null) {
if (!threadParticipantUserIds.contains(participantId) && !userIdsAlreadyLoaded.contains(participantId)) {
threadParticipantUserIds.add(participantId)
}
val username = p.getString("username")
if (username != null && username != currentUsername && !threadParticipantUsernames.contains(username)) {
threadParticipantUsernames.add(username)
if (!threadParticipantUsers.containsKey(participantId)) {
threadParticipantUsers[participantId] = participant
}
}
val username = participant.getString("username")
if (username != null && username != currentUsername && !threadParticipantUsernames.contains(username)) {
threadParticipantUsernames.add(username)
}
}
}
}

View file

@ -56,6 +56,6 @@ internal suspend fun PushNotificationDataRunnable.Companion.fetchNeededUsers(ser
internal fun PushNotificationDataRunnable.Companion.addUsersToList(users: ReadableArray, list: ArrayList<Any>) {
for (i in 0 until users.size()) {
users.getMap(i)?.let { list.add(it) }
list.add(users.getMap(i))
}
}

View file

@ -1,4 +1,4 @@
package com.tokilabs.mattermost
package com.mattermost.rnbeta
import android.app.PendingIntent
import android.content.Context

View file

@ -1,4 +1,4 @@
package com.tokilabs.mattermost
package com.mattermost.rnbeta
import android.content.res.Configuration
import android.os.Bundle

View file

@ -1,4 +1,4 @@
package com.tokilabs.mattermost
package com.mattermost.rnbeta
import android.annotation.SuppressLint

View file

@ -1,4 +1,4 @@
package com.tokilabs.mattermost;
package com.mattermost.rnbeta;
import android.content.Context;
import android.content.Intent;

View file

@ -1,4 +1,4 @@
package com.tokilabs.mattermost;
package com.mattermost.rnbeta;
import android.app.Notification;
import android.app.NotificationManager;

View file

@ -1,4 +1,4 @@
package com.tokilabs.mattermost;
package com.mattermost.rnbeta;
import android.os.Bundle;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 787 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 345 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 730 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 730 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 730 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -5,11 +5,11 @@ buildscript {
compileSdkVersion = 35
targetSdkVersion = 35
supportLibVersion = "35.0.0"
kotlinVersion = "2.0.21"
kotlinVersion = "1.9.25"
kotlin_version = kotlinVersion
RNNKotlinVersion = kotlinVersion
firebaseVersion = "24.1.0"
ndkVersion = "27.1.12297006"
ndkVersion = "26.1.10909125"
}
repositories {
mavenCentral()

View file

@ -1,50 +1,49 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
androidx.databinding:databinding-common:8.7.2=classpath
androidx.databinding:databinding-compiler-common:8.7.2=classpath
com.android.databinding:baseLibrary:8.7.2=classpath
com.android.tools.analytics-library:crash:31.7.2=classpath
com.android.tools.analytics-library:protos:31.7.2=classpath
com.android.tools.analytics-library:shared:31.7.2=classpath
com.android.tools.analytics-library:tracker:31.7.2=classpath
androidx.databinding:databinding-common:8.6.0=classpath
androidx.databinding:databinding-compiler-common:8.6.0=classpath
com.android.databinding:baseLibrary:8.6.0=classpath
com.android.tools.analytics-library:crash:31.6.0=classpath
com.android.tools.analytics-library:protos:31.6.0=classpath
com.android.tools.analytics-library:shared:31.6.0=classpath
com.android.tools.analytics-library:tracker:31.6.0=classpath
com.android.tools.build.jetifier:jetifier-core:1.0.0-beta10=classpath
com.android.tools.build.jetifier:jetifier-processor:1.0.0-beta10=classpath
com.android.tools.build:aapt2-proto:8.7.2-12006047=classpath
com.android.tools.build:aaptcompiler:8.7.2=classpath
com.android.tools.build:apksig:8.7.2=classpath
com.android.tools.build:apkzlib:8.7.2=classpath
com.android.tools.build:builder-model:8.7.2=classpath
com.android.tools.build:builder-test-api:8.7.2=classpath
com.android.tools.build:builder:8.7.2=classpath
com.android.tools.build:bundletool:1.17.1=classpath
com.android.tools.build:gradle-api:8.7.2=classpath
com.android.tools.build:gradle-settings-api:8.7.2=classpath
com.android.tools.build:gradle:8.7.2=classpath
com.android.tools.build:manifest-merger:31.7.2=classpath
com.android.tools.build:aapt2-proto:8.6.0-11315950=classpath
com.android.tools.build:aaptcompiler:8.6.0=classpath
com.android.tools.build:apksig:8.6.0=classpath
com.android.tools.build:apkzlib:8.6.0=classpath
com.android.tools.build:builder-model:8.6.0=classpath
com.android.tools.build:builder-test-api:8.6.0=classpath
com.android.tools.build:builder:8.6.0=classpath
com.android.tools.build:bundletool:1.16.0=classpath
com.android.tools.build:gradle-api:8.6.0=classpath
com.android.tools.build:gradle-settings-api:8.6.0=classpath
com.android.tools.build:gradle:8.6.0=classpath
com.android.tools.build:manifest-merger:31.6.0=classpath
com.android.tools.build:transform-api:2.0.0-deprecated-use-gradle-api=classpath
com.android.tools.ddms:ddmlib:31.7.2=classpath
com.android.tools.layoutlib:layoutlib-api:31.7.2=classpath
com.android.tools.lint:lint-model:31.7.2=classpath
com.android.tools.lint:lint-typedef-remover:31.7.2=classpath
com.android.tools.utp:android-device-provider-ddmlib-proto:31.7.2=classpath
com.android.tools.utp:android-device-provider-gradle-proto:31.7.2=classpath
com.android.tools.utp:android-device-provider-profile-proto:31.7.2=classpath
com.android.tools.utp:android-test-plugin-host-additional-test-output-proto:31.7.2=classpath
com.android.tools.utp:android-test-plugin-host-apk-installer-proto:31.7.2=classpath
com.android.tools.utp:android-test-plugin-host-coverage-proto:31.7.2=classpath
com.android.tools.utp:android-test-plugin-host-emulator-control-proto:31.7.2=classpath
com.android.tools.utp:android-test-plugin-host-logcat-proto:31.7.2=classpath
com.android.tools.utp:android-test-plugin-host-retention-proto:31.7.2=classpath
com.android.tools.utp:android-test-plugin-result-listener-gradle-proto:31.7.2=classpath
com.android.tools:annotations:31.7.2=classpath
com.android.tools:common:31.7.2=classpath
com.android.tools:dvlib:31.7.2=classpath
com.android.tools:repository:31.7.2=classpath
com.android.tools:sdk-common:31.7.2=classpath
com.android.tools:sdklib:31.7.2=classpath
com.android:signflinger:8.7.2=classpath
com.android:zipflinger:8.7.2=classpath
com.android.tools.ddms:ddmlib:31.6.0=classpath
com.android.tools.layoutlib:layoutlib-api:31.6.0=classpath
com.android.tools.lint:lint-model:31.6.0=classpath
com.android.tools.lint:lint-typedef-remover:31.6.0=classpath
com.android.tools.utp:android-device-provider-ddmlib-proto:31.6.0=classpath
com.android.tools.utp:android-device-provider-gradle-proto:31.6.0=classpath
com.android.tools.utp:android-test-plugin-host-additional-test-output-proto:31.6.0=classpath
com.android.tools.utp:android-test-plugin-host-apk-installer-proto:31.6.0=classpath
com.android.tools.utp:android-test-plugin-host-coverage-proto:31.6.0=classpath
com.android.tools.utp:android-test-plugin-host-emulator-control-proto:31.6.0=classpath
com.android.tools.utp:android-test-plugin-host-logcat-proto:31.6.0=classpath
com.android.tools.utp:android-test-plugin-host-retention-proto:31.6.0=classpath
com.android.tools.utp:android-test-plugin-result-listener-gradle-proto:31.6.0=classpath
com.android.tools:annotations:31.6.0=classpath
com.android.tools:common:31.6.0=classpath
com.android.tools:dvlib:31.6.0=classpath
com.android.tools:repository:31.6.0=classpath
com.android.tools:sdk-common:31.6.0=classpath
com.android.tools:sdklib:31.6.0=classpath
com.android:signflinger:8.6.0=classpath
com.android:zipflinger:8.6.0=classpath
com.google.android.gms:strict-version-matcher-plugin:1.2.4=classpath
com.google.android:annotations:4.1.1.4=classpath
com.google.api.grpc:proto-google-common-protos:2.17.0=classpath
@ -114,29 +113,34 @@ org.glassfish.jaxb:jaxb-runtime:2.3.2=classpath
org.glassfish.jaxb:txw2:2.3.2=classpath
org.jdom:jdom2:2.0.6=classpath
org.jetbrains.intellij.deps:trove4j:1.0.20200330=classpath
org.jetbrains.kotlin:kotlin-build-statistics:2.0.21=classpath
org.jetbrains.kotlin:kotlin-build-tools-api:2.0.21=classpath
org.jetbrains.kotlin:kotlin-compiler-embeddable:2.0.21=classpath
org.jetbrains.kotlin:kotlin-compiler-runner:2.0.21=classpath
org.jetbrains.kotlin:kotlin-daemon-client:2.0.21=classpath
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.0.21=classpath
org.jetbrains.kotlin:kotlin-gradle-plugin-annotations:2.0.21=classpath
org.jetbrains.kotlin:kotlin-gradle-plugin-api:2.0.21=classpath
org.jetbrains.kotlin:kotlin-gradle-plugin-idea-proto:2.0.21=classpath
org.jetbrains.kotlin:kotlin-gradle-plugin-idea:2.0.21=classpath
org.jetbrains.kotlin:kotlin-gradle-plugin-model:2.0.21=classpath
org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21=classpath
org.jetbrains.kotlin:kotlin-gradle-plugins-bom:2.0.21=classpath
org.jetbrains.kotlin:kotlin-klib-commonizer-api:2.0.21=classpath
org.jetbrains.kotlin:kotlin-native-utils:2.0.21=classpath
org.jetbrains.kotlin:kotlin-android-extensions:1.9.25=classpath
org.jetbrains.kotlin:kotlin-build-tools-api:1.9.25=classpath
org.jetbrains.kotlin:kotlin-compiler-embeddable:1.9.25=classpath
org.jetbrains.kotlin:kotlin-compiler-runner:1.9.25=classpath
org.jetbrains.kotlin:kotlin-daemon-client:1.9.25=classpath
org.jetbrains.kotlin:kotlin-daemon-embeddable:1.9.25=classpath
org.jetbrains.kotlin:kotlin-gradle-plugin-annotations:1.9.25=classpath
org.jetbrains.kotlin:kotlin-gradle-plugin-api:1.9.25=classpath
org.jetbrains.kotlin:kotlin-gradle-plugin-idea-proto:1.9.25=classpath
org.jetbrains.kotlin:kotlin-gradle-plugin-idea:1.9.25=classpath
org.jetbrains.kotlin:kotlin-gradle-plugin-model:1.9.25=classpath
org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25=classpath
org.jetbrains.kotlin:kotlin-gradle-plugins-bom:1.9.25=classpath
org.jetbrains.kotlin:kotlin-klib-commonizer-api:1.9.25=classpath
org.jetbrains.kotlin:kotlin-native-utils:1.9.25=classpath
org.jetbrains.kotlin:kotlin-project-model:1.9.25=classpath
org.jetbrains.kotlin:kotlin-reflect:1.9.20=classpath
org.jetbrains.kotlin:kotlin-scripting-common:1.9.25=classpath
org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:1.9.25=classpath
org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:1.9.25=classpath
org.jetbrains.kotlin:kotlin-scripting-jvm:1.9.25=classpath
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.20=classpath
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.20=classpath
org.jetbrains.kotlin:kotlin-stdlib:2.0.21=classpath
org.jetbrains.kotlin:kotlin-tooling-core:2.0.21=classpath
org.jetbrains.kotlin:kotlin-util-io:2.0.21=classpath
org.jetbrains.kotlin:kotlin-util-klib:2.0.21=classpath
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.4=classpath
org.jetbrains.kotlin:kotlin-stdlib:1.9.25=classpath
org.jetbrains.kotlin:kotlin-tooling-core:1.9.25=classpath
org.jetbrains.kotlin:kotlin-util-io:1.9.25=classpath
org.jetbrains.kotlin:kotlin-util-klib:1.9.25=classpath
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.5.0=classpath
org.jetbrains:annotations:23.0.0=classpath
org.jvnet.staxex:stax-ex:1.8.1=classpath
org.ow2.asm:asm-analysis:9.6=classpath

View file

@ -10,7 +10,6 @@ import {fetchConfigAndLicense} from '@actions/remote/systems';
import {Events, Preferences, Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {DEFAULT_LOCALE, getTranslations} from '@i18n';
import {getPreauthSecret} from '@init/credentials';
import SecurityManager from '@managers/security_manager';
import WebsocketManager from '@managers/websocket_manager';
import {getServer, getServerByIdentifier} from '@queries/app/servers';
@ -27,7 +26,6 @@ jest.mock('@queries/app/servers');
jest.mock('@queries/servers/system');
jest.mock('@database/manager');
jest.mock('@managers/security_manager');
jest.mock('@init/credentials');
jest.mock('@managers/websocket_manager');
jest.mock('@utils/log');
@ -184,54 +182,4 @@ describe('switchToServerAndLogin', () => {
expect(SecurityManager.isDeviceJailbroken).toHaveBeenCalledWith('serverUrl');
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');
});
});

View file

@ -7,7 +7,6 @@ import {doPing} from '@actions/remote/general';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import {Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {getPreauthSecret} from '@init/credentials';
import SecurityManager from '@managers/security_manager';
import WebsocketManager from '@managers/websocket_manager';
import {getServer, getServerByIdentifier} from '@queries/app/servers';
@ -54,10 +53,7 @@ export async function switchToServerAndLogin(serverUrl: string, theme: Theme, in
return;
}
// Retrieve pre-auth secret from keychain if it exists
const preauthSecret = await getPreauthSecret(server.url);
const result = await doPing(server.url, true, 5000, preauthSecret);
const result = await doPing(server.url, true);
if (result.error) {
alertServerError(intl, result.error);
callback?.();

View file

@ -5,15 +5,13 @@
import {DeviceEventEmitter} from 'react-native';
import {ActionType, Events, Navigation} from '@constants';
import {Navigation} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getMyChannel} from '@queries/servers/channel';
import {getPostById} from '@queries/servers/post';
import {getCommonSystemValues, getTeamHistory} from '@queries/servers/system';
import {getTeamChannelHistory} from '@queries/servers/team';
import {dismissAllModalsAndPopToRoot, dismissAllModalsAndPopToScreen} from '@screens/navigation';
import TestHelper from '@test/test_helper';
import {
switchToChannel,
@ -29,8 +27,6 @@ import {
updateChannelsDisplayName,
showUnreadChannelsOnly,
updateDmGmDisplayName,
deletePostsForChannelsWithAutotranslation,
deletePostsForChannel,
} from './channel';
import type {ChannelModel, MyChannelModel, SystemModel} from '@database/models/server';
@ -774,19 +770,19 @@ describe('updateMyChannelFromWebsocket', () => {
const serverUrl = 'baseHandler.test.com';
const channelId = 'id1';
const teamId = 'tId1';
const channel: Channel = TestHelper.fakeChannel({
const channel: Channel = {
id: channelId,
team_id: teamId,
total_msg_count: 0,
delete_at: 0,
});
const channelMember: ChannelMembership = TestHelper.fakeChannelMember({
} as Channel;
const channelMember: ChannelMembership = {
id: 'id',
user_id: 'userid',
channel_id: channelId,
msg_count: 0,
roles: '',
});
} as ChannelMembership;
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
@ -812,27 +808,6 @@ describe('updateMyChannelFromWebsocket', () => {
expect(model).toBeDefined();
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', () => {
@ -1158,186 +1133,3 @@ describe('updateDmGmDisplayName', () => {
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();
});
});

Some files were not shown because too many files have changed in this diff Show more