Compare commits
88 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f013531d65 | |||
| cb659b1b11 | |||
|
|
e986f4ef9c | ||
|
|
27b8c9b890 | ||
|
|
dce5874400 | ||
| 1d4a8d5b87 | |||
| 1adf27c6a3 | |||
| 6489096ec6 | |||
| 830eb54f09 | |||
|
|
3319aec473 | ||
|
|
82e5ff2ae8 | ||
|
|
5365109ca8 | ||
|
|
6000e69886 | ||
|
|
6f0d538dd7 | ||
|
|
3b3bea56c4 | ||
|
|
1902340017 | ||
|
|
9e30f87a98 | ||
|
|
4b625e3517 | ||
|
|
058b87ee4d | ||
|
|
b4798e78cc | ||
|
|
e977303275 | ||
|
|
c391484cc8 | ||
|
|
1a01c532a1 | ||
|
|
c313f7712f | ||
|
|
46d46790b5 | ||
|
|
7afdaddd2b | ||
|
|
1cea8c6f0c | ||
|
|
c3f83f9028 | ||
|
|
1757654882 | ||
|
|
0a3e701ba5 | ||
|
|
9e0735c00d | ||
|
|
cef108db68 | ||
|
|
d0cc38d549 | ||
|
|
cff12f7182 | ||
|
|
36439bd5f4 | ||
|
|
058d080e9a | ||
|
|
9793383832 | ||
|
|
3735aa6ff8 | ||
|
|
714c3dc769 | ||
|
|
1db5cd456c | ||
|
|
2bf0ffc95f | ||
|
|
dbcf2d9bd5 | ||
|
|
12c8d80f4a | ||
|
|
9699318043 | ||
|
|
f9001ec7cd | ||
|
|
616483edbf | ||
|
|
bf3f1d5241 | ||
|
|
23565b5135 | ||
|
|
61f10310dc | ||
|
|
95d50125e4 | ||
|
|
121980c7e5 | ||
|
|
04de7b9745 | ||
|
|
fe621047ce | ||
|
|
717ea47735 | ||
|
|
4611a350e0 | ||
|
|
882c2ec57b | ||
|
|
a9258543bb | ||
|
|
8732cb9430 | ||
|
|
83e5b6026e | ||
|
|
462d6d5052 | ||
|
|
6b0437a6e3 | ||
|
|
205b7a4aea | ||
|
|
fdc559d70d | ||
|
|
d014856a3a | ||
|
|
fbaf13c7aa | ||
|
|
ca117805be | ||
|
|
ab9d66c8b6 | ||
|
|
20a70ae64d | ||
|
|
98bf396714 | ||
|
|
71d67f27d0 | ||
|
|
a3dc1a1bdc | ||
|
|
6fb109faf1 | ||
|
|
cbf300ffd3 | ||
|
|
678f1b1184 | ||
|
|
bd1dcef010 | ||
|
|
3f284dec55 | ||
|
|
855a6b8e26 | ||
|
|
944a542110 | ||
|
|
e436e1992e | ||
|
|
4324846736 | ||
|
|
c0fc0c868f | ||
|
|
cb3fcc9651 | ||
|
|
5e4b81f77a | ||
|
|
7c8d63ef28 | ||
|
|
97db6d1cc7 | ||
|
|
90135d8580 | ||
|
|
a3248d2a41 | ||
|
|
e39b27be7e |
2
.github/workflows/build-ios-release.yml
vendored
|
|
@ -135,7 +135,7 @@ jobs:
|
|||
- name: ci/upload-ios-simulator-new-bucket
|
||||
env:
|
||||
TAG: "${{ github.ref_name }}"
|
||||
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_BETA_AWS_NEW_BUCKET_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
|
||||
|
|
|
|||
2
.github/workflows/e2e-android-template.yml
vendored
|
|
@ -127,7 +127,7 @@ jobs:
|
|||
device_os_version: ${{ env.SDK_VERSION }}
|
||||
|
||||
e2e-android:
|
||||
name: android-detox-e2e-${{ matrix.runId }}-${{ matrix.deviceName }}-${{ matrix.deviceOsVersion }}
|
||||
name: machine-${{ matrix.runId }}-api-${{ matrix.deviceOsVersion }}
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
continue-on-error: true
|
||||
timeout-minutes: 240
|
||||
|
|
|
|||
19
.github/workflows/e2e-detox-pr.yml
vendored
|
|
@ -1,12 +1,11 @@
|
|||
# Can be used to run Detox E2E tests on pull requests for the Mattermost mobile app with low bandwidth
|
||||
# by using 'E2E iOS tests for PR (LBW 1)' instead.
|
||||
name: Detox E2E Tests PR
|
||||
name: E2E
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- feature_schedule_posts
|
||||
types:
|
||||
- labeled
|
||||
|
||||
|
|
@ -49,7 +48,7 @@ jobs:
|
|||
|
||||
build-ios-simulator:
|
||||
if: contains(github.event.label.name, 'E2E iOS tests for PR')
|
||||
runs-on: macos-15
|
||||
runs-on: macos-26
|
||||
needs:
|
||||
- update-initial-status-ios
|
||||
steps:
|
||||
|
|
@ -62,20 +61,22 @@ jobs:
|
|||
intune-enabled: ${{ env.INTUNE_ENABLED }}
|
||||
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
|
||||
run: bundle exec fastlane ios simulator --env ios.simulator skip_upload_to_s3_bucket:true
|
||||
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-x86_64.app.zip
|
||||
path: Mattermost-simulator-*.app.zip
|
||||
|
||||
build-android-apk:
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
|
|
@ -122,7 +123,7 @@ jobs:
|
|||
|
||||
run-ios-tests-on-pr:
|
||||
if: contains(github.event.label.name, 'E2E iOS tests for PR')
|
||||
name: iOS Mobile Tests on PR
|
||||
name: iOS
|
||||
uses: ./.github/workflows/e2e-ios-template.yml
|
||||
needs:
|
||||
- build-ios-simulator
|
||||
|
|
@ -134,7 +135,7 @@ jobs:
|
|||
|
||||
run-android-tests-on-pr:
|
||||
if: contains(github.event.label.name, 'E2E Android tests for PR')
|
||||
name: Android Mobile Tests on PR
|
||||
name: Android
|
||||
uses: ./.github/workflows/e2e-android-template.yml
|
||||
needs:
|
||||
- build-android-apk
|
||||
|
|
|
|||
178
.github/workflows/e2e-ios-template.yml
vendored
|
|
@ -38,12 +38,12 @@ on:
|
|||
description: "iPhone simulator name"
|
||||
required: false
|
||||
type: string
|
||||
default: "iPhone 15 Pro"
|
||||
default: "iPhone 17 Pro"
|
||||
ios_device_os_name:
|
||||
description: "iPhone simulator OS version"
|
||||
required: false
|
||||
type: string
|
||||
default: "iOS 17.2"
|
||||
default: "iOS 26.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: ios-detox-e2e-${{ matrix.runId }}-${{ matrix.deviceName }}-${{ matrix.deviceOsVersion }}
|
||||
name: machine-${{ matrix.runId }}-os-${{ matrix.deviceOsVersion }}
|
||||
runs-on: macos-15
|
||||
continue-on-error: true
|
||||
timeout-minutes: ${{ inputs.low_bandwidth_mode && 240 || 180 }}
|
||||
|
|
@ -173,6 +173,82 @@ 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
|
||||
|
|
@ -182,35 +258,111 @@ jobs:
|
|||
|
||||
- name: Set .env with RUNNING_E2E=true
|
||||
run: |
|
||||
cat > .env <<EOF
|
||||
echo "RUNNING_E2E=true" >> .env
|
||||
echo "RUNNING_E2E=true" > .env
|
||||
|
||||
- name: Run Detox E2E Tests
|
||||
- name: Verify Simulator Health
|
||||
run: |
|
||||
echo "Verifying simulator health before running tests..."
|
||||
|
||||
# Check if simulator is still responsive
|
||||
if ! xcrun simctl bootstatus "$SIMULATOR_ID" -b 2>/dev/null; then
|
||||
echo "⚠️ Simulator not responsive, attempting recovery..."
|
||||
|
||||
# Try to shutdown and reboot
|
||||
xcrun simctl shutdown "$SIMULATOR_ID" || true
|
||||
sleep 3
|
||||
xcrun simctl boot "$SIMULATOR_ID"
|
||||
xcrun simctl bootstatus "$SIMULATOR_ID"
|
||||
fi
|
||||
|
||||
echo "✅ Simulator is healthy and ready"
|
||||
|
||||
- name: Start Metro Bundler and Run Detox E2E Tests
|
||||
continue-on-error: true # We want to run all the tests
|
||||
run: |
|
||||
# Start the server
|
||||
npm run start &
|
||||
sleep 120 # Wait for watchman to finish querying the files
|
||||
# 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
|
||||
|
||||
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;
|
||||
|
||||
|
|
|
|||
312
.gitignore
vendored
|
|
@ -1,135 +1,199 @@
|
|||
assets/override
|
||||
dist
|
||||
build-ios
|
||||
*.zip
|
||||
server.PID
|
||||
mattermost.keystore
|
||||
tmp/
|
||||
.env
|
||||
env.d.ts
|
||||
*.apk
|
||||
*.aab
|
||||
*.ipa
|
||||
.tsbuildinfo.precommit
|
||||
# Sorted combined gitignore from .gitignore and .gitignore_my
|
||||
|
||||
*/**/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
|
||||
*.hmap
|
||||
*.xcuserstate
|
||||
project.xcworkspace
|
||||
ios/Pods
|
||||
.podinstall
|
||||
ios/.xcode.env.local
|
||||
|
||||
# Android/IntelliJ
|
||||
#
|
||||
.idea
|
||||
.gradle
|
||||
local.properties
|
||||
*.iml
|
||||
*.hprof
|
||||
.cxx/
|
||||
*.keystore
|
||||
!build/notice-file
|
||||
!debug.keystore
|
||||
android/app/bin
|
||||
android/app/build
|
||||
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
|
||||
!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
|
||||
**/fastlane/.env
|
||||
|
||||
# Sentry
|
||||
android/sentry.properties
|
||||
ios/sentry.properties
|
||||
|
||||
# Testing
|
||||
.nyc_output
|
||||
coverage
|
||||
.tmp
|
||||
|
||||
# E2E testing
|
||||
mattermost-license.txt
|
||||
*.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
|
||||
*.zip
|
||||
*/**/compass-icons.ttf
|
||||
*~
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
.AppleDouble
|
||||
.DS_Store
|
||||
.DocumentRevisions-V100
|
||||
.LSOverride
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trash-*
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
._*
|
||||
.aider*
|
||||
.apdisk
|
||||
.buckconfig.local
|
||||
.buckd/
|
||||
.buckversion
|
||||
.bundle
|
||||
.cache
|
||||
.cache/
|
||||
.classpath
|
||||
.claude/settings.local.json
|
||||
.com.apple.timemachine.donotpresent
|
||||
.cxx/
|
||||
.directory
|
||||
.docusaurus
|
||||
.dynamodb/
|
||||
.env
|
||||
.env.development.local
|
||||
.env.local
|
||||
.env.production.local
|
||||
.env.test.local
|
||||
.eslintcache
|
||||
.expo
|
||||
.externalNativeBuild/
|
||||
.fakebuckversion
|
||||
.fseventsd
|
||||
.fuse_hidden*
|
||||
.fusebox/
|
||||
.gradle
|
||||
.gradle/
|
||||
.gradletasknamecache
|
||||
.grunt
|
||||
.idea
|
||||
.idea/
|
||||
.lock-wscript
|
||||
.metro-health-check*
|
||||
.netrwhist
|
||||
.next
|
||||
.nfs*
|
||||
.node_repl_history
|
||||
.npm
|
||||
.npminstall
|
||||
.nuxt
|
||||
.nyc_output
|
||||
.parcel-cache
|
||||
.pnp.*
|
||||
.pnpm-debug.log*
|
||||
.podinstall
|
||||
.project
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
.scannerwork
|
||||
.serverless/
|
||||
.settings
|
||||
.stylelintcache
|
||||
.temp
|
||||
.tern-port
|
||||
.tmp
|
||||
.tsbuildinfo.precommit
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/settings.json
|
||||
.vscode-test
|
||||
.vuepress/dist
|
||||
.yarn-integrity
|
||||
.yarn/build-state.yml
|
||||
.yarn/cache
|
||||
.yarn/install-state.gz
|
||||
.yarn/unplugged
|
||||
.yarninstall
|
||||
DerivedData
|
||||
Icon
|
||||
Network Trash Folder
|
||||
Session.vim
|
||||
Temporary Items
|
||||
[._]*.s[a-w][a-z]
|
||||
[._]s[a-w][a-z]
|
||||
__generated__
|
||||
android/app/bin
|
||||
android/app/build
|
||||
android/app/src/main/res/raw/*
|
||||
android/build
|
||||
android/sentry.properties
|
||||
assets/override
|
||||
bower_components
|
||||
buck-out/
|
||||
build-ios
|
||||
build/Release
|
||||
captures/
|
||||
coverage
|
||||
deploymentTargetDropDown.xml
|
||||
detox/artifacts
|
||||
detox/detox_pixel_*
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
# Android sounds
|
||||
android/app/src/main/res/raw/*
|
||||
.aider*
|
||||
|
||||
# Intune submodule (private repository - internal builds only)
|
||||
# Track submodule reference but ignore content
|
||||
libraries/**/**/build
|
||||
libraries/@mattermost/intune/*
|
||||
!libraries/@mattermost/intune/.gitkeep
|
||||
|
||||
# Prevent committing Intune module in node_modules
|
||||
local.properties
|
||||
logs
|
||||
mattermost-license.txt
|
||||
mattermost.keystore
|
||||
misc.xml
|
||||
node_modules/
|
||||
node_modules/@mattermost/intune
|
||||
# Claude Code
|
||||
.claude/settings.local.json
|
||||
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
|
||||
3
.kilocode/rules/rules.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# 필수 준수 사항
|
||||
- agent-ops/rules/global.md 파일을 읽고 이에 대해 꼭 준수해서 진행해야한다.
|
||||
- global.md 내용을 모르면 추정하지 말고 먼저 해당 파일을 확인한다.
|
||||
1
.kilocode/system-prompt-architect
Normal file
|
|
@ -0,0 +1 @@
|
|||
-
|
||||
1
.kilocode/system-prompt-ask
Normal file
|
|
@ -0,0 +1 @@
|
|||
-
|
||||
1
.kilocode/system-prompt-code
Normal file
|
|
@ -0,0 +1 @@
|
|||
-
|
||||
1
.kilocode/system-prompt-debug
Normal file
|
|
@ -0,0 +1 @@
|
|||
-
|
||||
1
.kilocode/system-prompt-orchestrator
Normal file
|
|
@ -0,0 +1 @@
|
|||
-
|
||||
1
.kilocode/system-prompt-review
Normal file
|
|
@ -0,0 +1 @@
|
|||
-
|
||||
31
.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Metro: Start",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"runtimeExecutable": "bash",
|
||||
"runtimeArgs": [
|
||||
"-c",
|
||||
"lsof -ti:8081 | xargs kill -9 2>/dev/null; npm start"
|
||||
],
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen"
|
||||
},
|
||||
{
|
||||
"name": "Android: Run App",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"runtimeExecutable": "bash",
|
||||
"runtimeArgs": [
|
||||
"-c",
|
||||
"adb devices | grep -q emulator || (emulator -avd Pixel_7_Pro &); adb -e wait-for-device && npm run android -- --deviceId emulator-5554"
|
||||
],
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen"
|
||||
}
|
||||
]
|
||||
}
|
||||
17
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"react-native.packager.port": 8081,
|
||||
"terminal.integrated.env.osx": {
|
||||
"NVM_DIR": "${env:HOME}/.nvm",
|
||||
"JAVA_HOME": "/opt/homebrew/opt/openjdk@17",
|
||||
"ANDROID_HOME": "${env:HOME}/Library/Android/sdk",
|
||||
"ANDROID_SDK_ROOT": "${env:HOME}/Library/Android/sdk"
|
||||
},
|
||||
"terminal.integrated.profiles.osx": {
|
||||
"zsh": {
|
||||
"path": "/bin/zsh",
|
||||
"args": ["-l"]
|
||||
}
|
||||
},
|
||||
"terminal.integrated.defaultProfile.osx": "zsh",
|
||||
"npm.scriptExplorerAction": "run"
|
||||
}
|
||||
3
AGENTS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# 필수 준수 사항
|
||||
- agent-ops/rules/global.md 파일을 읽고 이에 대해 꼭 준수해서 진행해야한다.
|
||||
- global.md 내용을 모르면 추정하지 말고 먼저 해당 파일을 확인한다.
|
||||
209
Agent-Ops-SKILL.md
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
---
|
||||
name: scaffold-agent-ops
|
||||
description: 프로젝트 상태를 판별하고 에이전트 진입 파일과 agent-ops 기본 스캐폴드를 생성하기 위한 초기 규칙
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# 목적
|
||||
|
||||
이 스킬은 프로젝트에 Agent-Ops 구조가 없거나 불완전할 때,
|
||||
현재 프로젝트 상태를 분석하여 다음을 세팅한다.
|
||||
|
||||
1. 에이전트 진입 파일
|
||||
2. agent-ops 기본 폴더 구조
|
||||
3. global rule
|
||||
4. domain rule 템플릿
|
||||
5. skill router
|
||||
6. skill 템플릿
|
||||
7. 초기 domain / skill 세팅
|
||||
|
||||
이 스킬은 과도한 초기 생성보다
|
||||
"현재 프로젝트에 맞는 최소 스캐폴드" 생성을 우선한다.
|
||||
|
||||
# 핵심 원칙
|
||||
|
||||
- 기존 구조를 우선한다.
|
||||
- 새 파일 생성은 꼭 필요한 최소 범위로 제한한다.
|
||||
- 에이전트별 md는 얇은 진입점으로 둔다.
|
||||
- 실제 규칙은 agent-ops 아래로 모은다.
|
||||
- 도메인은 발명하지 말고 현재 구조에서 발견한다.
|
||||
- 처음에는 핵심 도메인만 생성한다.
|
||||
- 반복되는 작업만 초기 skill로 만든다.
|
||||
- 불확실한 내용은 후보로 제시한다.
|
||||
|
||||
# 상태 판별
|
||||
|
||||
## 신규 프로젝트
|
||||
다음 조건이 많으면 신규 프로젝트로 본다.
|
||||
|
||||
- 코드/폴더 구조가 단순하다
|
||||
- 도메인 경계가 아직 약하다
|
||||
- Agent-Ops 관련 파일이 없다
|
||||
- 반복 작업 패턴이 아직 적다
|
||||
|
||||
## 운영중 프로젝트
|
||||
다음 조건이 많으면 운영중 프로젝트로 본다.
|
||||
|
||||
- 모듈/폴더/패키지 경계가 보인다
|
||||
- 반복 작업이 드러난다
|
||||
- 기존 규칙/문서/관례의 흔적이 있다
|
||||
- 핵심 책임 경계가 식별된다
|
||||
|
||||
# 생성 대상
|
||||
|
||||
이 스킬은 아래 구조를 기준으로 scaffold를 제안하거나 생성한다.
|
||||
|
||||
- CLAUDE.md
|
||||
- GEMINI.md
|
||||
- .cursorrules
|
||||
- agent-ops/rules/global.md
|
||||
- agent-ops/rules/_templates/domain-rule-template.md
|
||||
- agent-ops/rules/domain/<domain>/rules.md
|
||||
- agent-ops/skills/router.md
|
||||
- agent-ops/skills/_templates/skill-template.md
|
||||
|
||||
필요 시 아래 초기 skill도 함께 제안한다.
|
||||
|
||||
- agent-ops/skills/find-domain/SKILL.md
|
||||
- agent-ops/skills/code-change/SKILL.md
|
||||
|
||||
# 에이전트 진입 파일 원칙
|
||||
|
||||
CLAUDE.md, GEMINI.md, .cursorrules 는 공통적으로 얇게 유지한다.
|
||||
|
||||
각 파일에는 아래만 둔다.
|
||||
|
||||
- 응답 언어
|
||||
- 기존 구조 우선
|
||||
- 새 파일 생성보다 기존 수정 우선
|
||||
- 작업 전 agent-ops/skills/router.md 참조
|
||||
- 관련 rule / skill 만 최소 로드
|
||||
- 세부 규칙은 agent-ops 아래 파일을 따른다
|
||||
|
||||
각 에이전트 진입 파일에 프로젝트별 상세 규칙을 중복 작성하지 않는다.
|
||||
|
||||
# Rule 구조 원칙
|
||||
|
||||
## global.md
|
||||
전 프로젝트 공통 규칙만 둔다.
|
||||
|
||||
포함 항목:
|
||||
- 응답 언어
|
||||
- 기존 구조 우선
|
||||
- 새 파일 생성보다 기존 수정 우선
|
||||
- 코드 변경 전 관련 domain rule 확인
|
||||
- 요청 범위를 넘는 변경 금지
|
||||
- 불확실하면 단정하지 말고 후보 제시
|
||||
- 플랫폼 특화 규칙은 global에 두지 않음
|
||||
|
||||
## domain rule
|
||||
각 도메인 rules.md 는 아래만 둔다.
|
||||
|
||||
- 목적 / 책임
|
||||
- 포함 경로
|
||||
- 제외 경로
|
||||
- 주요 구성 요소
|
||||
- 유지할 패턴
|
||||
- 다른 도메인과의 경계
|
||||
- 금지 사항
|
||||
|
||||
# DDD 기준
|
||||
|
||||
도메인은 아래 기준으로 분류한다.
|
||||
|
||||
## Core Domain
|
||||
핵심 가치와 주요 유즈케이스를 담당하는 영역
|
||||
|
||||
## Supporting Domain
|
||||
핵심 도메인을 지원하는 영역
|
||||
|
||||
## Generic / Common
|
||||
여러 도메인이 공통으로 사용하는 범용 영역
|
||||
|
||||
도메인은 반드시 실제 폴더, 모듈, 패키지, 네임스페이스, 책임 경계와 연결되어야 한다.
|
||||
|
||||
# Skill 구조 원칙
|
||||
|
||||
## router.md
|
||||
router.md 는 작업 유형에 따라 필요한 skill 또는 domain rule만 로드하게 한다.
|
||||
|
||||
최소 라우팅 축:
|
||||
- 구조 분석 / 스캐폴드
|
||||
- 도메인 판별
|
||||
- 코드 변경
|
||||
- 흐름 추적
|
||||
- 문서/릴리즈 정리
|
||||
|
||||
처음부터 너무 많은 분기를 만들지 않는다.
|
||||
|
||||
## skill-template.md
|
||||
각 skill 은 아래 형식을 따른다.
|
||||
|
||||
- 목적
|
||||
- 언제 호출할지
|
||||
- 입력
|
||||
- 먼저 확인할 것
|
||||
- 실행 절차
|
||||
- 출력 형식
|
||||
- 금지 사항
|
||||
|
||||
# 신규 프로젝트 처리
|
||||
|
||||
신규 프로젝트일 때는 템플릿 중심으로 세팅한다.
|
||||
|
||||
1. 언어/구조를 확인한다.
|
||||
2. 에이전트 진입 파일 3개를 얇게 생성한다.
|
||||
3. agent-ops/rules/global.md 생성
|
||||
4. domain-rule-template.md 생성
|
||||
5. skills/router.md 생성
|
||||
6. skill-template.md 생성
|
||||
7. 핵심 domain placeholder 2~4개만 제안
|
||||
8. 초기 skill 은 최소 2개만 제안한다.
|
||||
|
||||
신규 프로젝트에서는 domain rules 를 과도하게 채우지 않는다.
|
||||
|
||||
# 운영중 프로젝트 처리
|
||||
|
||||
운영중 프로젝트일 때는 현재 구조 기반으로 자동 채움한다.
|
||||
|
||||
1. 구조를 분석한다.
|
||||
2. Core / Supporting / Generic 도메인 후보를 식별한다.
|
||||
3. 실제 경로를 반영한 domain rules 초안을 제안한다.
|
||||
4. 반복 작업 기반으로 필요한 초기 skill 을 제안한다.
|
||||
5. router.md 에 기본 라우팅을 채운다.
|
||||
6. 신규 프로젝트보다 한 단계 더 구체적으로 scaffold 한다.
|
||||
|
||||
운영중 프로젝트에서는 실제 코드 구조에 맞는 domain / skill 을 일부 채워 넣는다.
|
||||
|
||||
# 출력 형식
|
||||
|
||||
## 상태 판별
|
||||
- Agent-Ops 상태: 없음 / 부분 적용 / 운영중
|
||||
- 프로젝트 상태: 신규 / 운영중
|
||||
- 근거: 짧게 요약
|
||||
|
||||
## 스캐폴드 계획
|
||||
- 생성할 파일
|
||||
- 바로 채울 파일
|
||||
- placeholder 로 둘 파일
|
||||
|
||||
## 도메인 제안
|
||||
- Core Domain
|
||||
- Supporting Domain
|
||||
- Generic / Common
|
||||
|
||||
## 초기 skill 제안
|
||||
- skill 이름
|
||||
- 필요한 이유
|
||||
|
||||
## 주의사항
|
||||
- 지금 만들지 말아야 할 것
|
||||
- 아직 확정하면 안 되는 것
|
||||
|
||||
# 금지
|
||||
|
||||
- 플랫폼 전용 규칙을 기본 scaffold 에 넣지 않는다.
|
||||
- agent 별 md 에 상세 규칙을 중복 작성하지 않는다.
|
||||
- 실제 구조보다 앞선 추상 구조를 강요하지 않는다.
|
||||
- 처음부터 많은 domain / skill 을 만들지 않는다.
|
||||
- scaffold 단계에서 불필요한 세부 구현까지 생성하지 않는다.
|
||||
172
CLAUDE-IOS-SIMULATOR.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# iOS Simulator Setup Guide for AI Agents
|
||||
|
||||
This guide documents the process for setting up and running the Mattermost Mobile app on an iOS simulator.
|
||||
|
||||
## Prerequisites Check
|
||||
|
||||
Before starting, verify these are installed:
|
||||
|
||||
```bash
|
||||
# Check Xcode
|
||||
xcodebuild -version
|
||||
|
||||
# Check CocoaPods
|
||||
pod --version
|
||||
|
||||
# Check Node.js
|
||||
node --version
|
||||
|
||||
# Check npm dependencies
|
||||
test -d node_modules && echo "node_modules exists" || echo "Run: npm install"
|
||||
```
|
||||
|
||||
## Step 1: iOS Simulator Runtime
|
||||
|
||||
Check if iOS simulators are available:
|
||||
|
||||
```bash
|
||||
xcrun simctl list devices available
|
||||
```
|
||||
|
||||
If no devices are listed or you see "Install Started", you need to download the iOS platform:
|
||||
|
||||
```bash
|
||||
xcodebuild -downloadPlatform iOS
|
||||
```
|
||||
|
||||
**Note:** This downloads ~8GB and takes several minutes. The command runs in the foreground and shows progress.
|
||||
|
||||
## Step 2: CocoaPods Installation
|
||||
|
||||
### Critical: Set UTF-8 Encoding
|
||||
|
||||
CocoaPods requires UTF-8 encoding. Always set this before running pod commands:
|
||||
|
||||
```bash
|
||||
export LANG=en_US.UTF-8
|
||||
export LC_ALL=en_US.UTF-8
|
||||
```
|
||||
|
||||
### Install Pods (Apple Silicon Mac)
|
||||
|
||||
```bash
|
||||
cd ios
|
||||
RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install
|
||||
```
|
||||
|
||||
### Common CocoaPods Issues
|
||||
|
||||
#### Issue: "Unicode Normalization not appropriate for ASCII-8BIT"
|
||||
**Solution:** Set UTF-8 encoding as shown above.
|
||||
|
||||
#### Issue: Podfile.lock version conflicts
|
||||
**Solution:** Clean install:
|
||||
```bash
|
||||
cd ios
|
||||
rm -rf Pods Podfile.lock build
|
||||
export LANG=en_US.UTF-8 && export LC_ALL=en_US.UTF-8
|
||||
RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install
|
||||
```
|
||||
|
||||
#### Issue: GitHub SSH timeouts during pod install
|
||||
**Symptom:** Errors like "Connection to github.com port 22: Operation timed out"
|
||||
|
||||
**Solution:** Temporarily configure git to use HTTPS instead of SSH:
|
||||
```bash
|
||||
git config --global url."https://github.com/".insteadOf git@github.com:
|
||||
```
|
||||
|
||||
**IMPORTANT:** Revert this after pod install completes, or the user won't be able to push:
|
||||
```bash
|
||||
git config --global --unset url.https://github.com/.insteadof
|
||||
```
|
||||
|
||||
**Warning:** Never leave git URL rewrites in place without informing the user!
|
||||
|
||||
## Step 3: Start Metro Bundler (if not already running)
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
Wait for "Dev server ready" message before proceeding.
|
||||
|
||||
## Step 4: Run iOS App (if not already built)
|
||||
|
||||
### Using npm script
|
||||
```bash
|
||||
npm run ios -- --simulator="iPhone 17 Pro"
|
||||
```
|
||||
|
||||
### Or specify a different simulator
|
||||
List available simulators first:
|
||||
```bash
|
||||
xcrun simctl list devices available | grep -E "iPhone|iPad"
|
||||
```
|
||||
|
||||
Then run with your chosen device:
|
||||
```bash
|
||||
npm run ios -- --simulator="iPhone 16e"
|
||||
```
|
||||
|
||||
## Build Times
|
||||
|
||||
- **First build:** 10-30 minutes (compiles all native code)
|
||||
- **Subsequent builds:** Much faster (incremental)
|
||||
- **JS/TS changes:** ~3 seconds hot reload (no native rebuild needed)
|
||||
|
||||
## Troubleshooting Build Failures
|
||||
|
||||
### Getting Better Error Messages
|
||||
|
||||
If `npm run ios` fails, run xcodebuild directly for clearer errors:
|
||||
|
||||
```bash
|
||||
xcodebuild -workspace ios/Mattermost.xcworkspace \
|
||||
-configuration Debug \
|
||||
-scheme Mattermost \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
|
||||
2>&1 | grep -E "error:|fatal error"
|
||||
```
|
||||
|
||||
### Clean Build
|
||||
|
||||
If builds fail mysteriously, try a clean build:
|
||||
|
||||
```bash
|
||||
# Clean Xcode derived data
|
||||
rm -rf ~/Library/Developer/Xcode/DerivedData/Mattermost-*
|
||||
|
||||
# Clean and reinstall pods
|
||||
cd ios
|
||||
rm -rf Pods build
|
||||
export LANG=en_US.UTF-8 && export LC_ALL=en_US.UTF-8
|
||||
RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install
|
||||
```
|
||||
|
||||
### Opening in Xcode
|
||||
|
||||
For complex build issues, open in Xcode for better debugging:
|
||||
|
||||
```bash
|
||||
open ios/Mattermost.xcworkspace
|
||||
```
|
||||
|
||||
Then select your simulator target and press Cmd+B to build.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Full setup sequence (Apple Silicon)
|
||||
export LANG=en_US.UTF-8 && export LC_ALL=en_US.UTF-8
|
||||
cd ios && RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install && cd ..
|
||||
npm start &
|
||||
sleep 10
|
||||
npm run ios -- --simulator="iPhone 17 Pro"
|
||||
```
|
||||
|
||||
## Do NOT Modify
|
||||
|
||||
- **Git configuration:** Never permanently change git URL rewrites without reverting
|
||||
- **Podfile:** Don't add modular_headers to pods unless you understand the implications
|
||||
- **New Architecture:** Keep `RCT_NEW_ARCH_ENABLED=0` as the project uses the old architecture
|
||||
371
CLAUDE.md
|
|
@ -1,368 +1,3 @@
|
|||
# CLAUDE.md
|
||||
|
||||
## 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
|
||||
- **Adding new strings**: Define the message ID and defaultMessage in code using `defineMessages()`, then run `npm run i18n-extract` to automatically add them to `en.json`
|
||||
- Default messages in code must match JSON translations exactly, including newlines
|
||||
- Translation IDs should be descriptive enough for translators to understand context
|
||||
- Don't reuse translation IDs
|
||||
- Translate user-facing strings, not debug/error messages
|
||||
|
||||
### Markdown Component Usage
|
||||
**Required props:**
|
||||
- `baseTextStyle`, `value`, `theme`, `location`
|
||||
- Use `Screens.CHANNEL` or appropriate constant from `@constants` for the `location` prop
|
||||
- `textStyles`, `blockStyles`, `enableLatex`, and similar props are auto-generated via HOC
|
||||
|
||||
**Important Limitations:**
|
||||
- **Cannot embed custom React components inline**: The Markdown component renders CommonMark to native components. You cannot insert custom React components (like badges, icons) inline within the markdown text flow
|
||||
- **Link handling**: Links in markdown go through `openLink()` → deep link system → `tryOpenURL()`. Custom URL schemes (e.g., `citation:`, `action:`) will show error alerts unless you implement a full deep link handler in `app/utils/deep_link/`
|
||||
- **Post-processing not possible**: Unlike web, you cannot post-process the rendered markdown JSX tree to replace markers with React components
|
||||
|
||||
### Adding New Post Types
|
||||
When adding custom post types (e.g., for new products):
|
||||
1. Add the type string to `PostType` union in `types/api/posts.d.ts`
|
||||
2. Define constants in your product's constants file (e.g., `app/products/agents/constants.ts`)
|
||||
3. Use the globally-available `Post` type (defined in `types/api/posts.d.ts`) - no import needed
|
||||
|
||||
### Platform-Specific
|
||||
- iOS: Opt out of iOS 18+ features (liquid glass) until UI is properly addressed
|
||||
- Android: Test on multiple API levels (34, 35, 36) to verify behavior with edge-to-edge changes
|
||||
- Use `Set` instead of `Array` for exception lists (faster, ensures uniqueness)
|
||||
|
||||
### testID Convention
|
||||
Components use hierarchical testIDs: `component.subcomponent.element`
|
||||
- Example: `channel_list.category.CATEGORY.channel_item.ID.display_name`
|
||||
- Example: `channel.post_draft.send_action.send.button`
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
### JavaScript Compatibility
|
||||
- **Don't use `Object.hasOwn()`** - React Native doesn't support ES2022+ features
|
||||
- ❌ `Object.hasOwn(obj, 'key')`
|
||||
- ✅ `'key' in obj`
|
||||
|
||||
### Anti-Patterns
|
||||
- Conditionals in tests for TypeScript satisfaction
|
||||
- Creating new arrays/objects on every render when passed as props
|
||||
- Over-engineering - follow YAGNI principle
|
||||
- Impossible states with boolean + optional parameters that depend on each other
|
||||
- Redundant checks - if `array.length >= 3`, indices 0,1,2 are guaranteed to exist
|
||||
- Verbose error messages that reveal internal system details
|
||||
- Mass assignment risks - whitelist fields instead of sending `Partial<Model>` objects
|
||||
- Duplicate conditional branches that return identical results - consolidate into a single condition (e.g., `if (A && B)` and `if (A && !B)` both returning same thing should be just `if (A)`)
|
||||
|
||||
### Security
|
||||
- Question whether showing secrets with "eye" toggle is a security concern
|
||||
- Don't commit files that likely contain secrets (.env, credentials.json)
|
||||
- Consider pre-populating sensitive fields with `*********` instead of actual values
|
||||
|
||||
## Configuration
|
||||
|
||||
- **Patches**: Applied via `patch-package` on postinstall (`patches/` directory)
|
||||
- **Self-compiled apps**: Require your own Mattermost Push Notification Service
|
||||
- **Self-signed certificates**: Not supported
|
||||
|
||||
## Development Notes
|
||||
|
||||
### Hot Reload & Build Times
|
||||
- **Hot reload**: ~3 seconds for JS/TS changes; does NOT work for native code changes
|
||||
- **Native rebuilds**: 10-30 minutes (avoid unless necessary)
|
||||
- **Pre-commit hook**: Runs ESLint + incremental TypeScript checking
|
||||
|
||||
### Known Issues
|
||||
- Many components require `theme` prop - check for `useTheme()` hook in parent component
|
||||
# 필수 준수 사항
|
||||
- agent-ops/rules/global.md 파일을 읽고 이에 대해 꼭 준수해서 진행해야한다.
|
||||
- global.md 내용을 모르면 추정하지 말고 먼저 해당 파일을 확인한다.
|
||||
3
GEMINI.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# 필수 준수 사항
|
||||
- agent-ops/rules/global.md 파일을 읽고 이에 대해 꼭 준수해서 진행해야한다.
|
||||
- global.md 내용을 모르면 추정하지 말고 먼저 해당 파일을 확인한다.
|
||||
1
Gemfile
|
|
@ -1,3 +1,4 @@
|
|||
source "https://rubygems.org"
|
||||
|
||||
gem "cocoapods", "1.16.1"
|
||||
gem "rexml", ">= 3.4.2"
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ GEM
|
|||
netrc (0.11.0)
|
||||
nkf (0.2.0)
|
||||
public_suffix (4.0.7)
|
||||
rexml (3.3.9)
|
||||
rexml (3.4.4)
|
||||
ruby-macho (2.5.1)
|
||||
securerandom (0.4.0)
|
||||
typhoeus (1.4.1)
|
||||
|
|
@ -107,6 +107,7 @@ PLATFORMS
|
|||
|
||||
DEPENDENCIES
|
||||
cocoapods (= 1.16.1)
|
||||
rexml (>= 3.4.2)
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.11
|
||||
|
|
|
|||
15
NOTICE.txt
|
|
@ -20,6 +20,21 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Mattermost Mobile v2
|
||||
|
||||
- **Minimum Server versions:** Current ESR version (10.11.0+)
|
||||
- **Supported iOS versions:** 15.1+
|
||||
- **Supported iOS versions:** 16.0+
|
||||
- **Supported Android versions:** 7.0+
|
||||
|
||||
Mattermost is an open source Slack-alternative used by thousands of companies around the world in 21 languages. Learn more at [https://mattermost.com](https://mattermost.com).
|
||||
|
|
@ -47,7 +47,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.mattermost.rnbeta)
|
||||
- [Android](https://play.google.com/apps/testing/com.tokilabs.mattermost)
|
||||
- [iOS](https://testflight.apple.com/join/Q7Rx7K9P) - Open this link from your iOS device
|
||||
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 +59,7 @@ To help with testing app updates before they're released, you can:
|
|||
- Join the [Native Mobile Apps channel](https://community.mattermost.com/core/channels/native-mobile-apps) to see what's new and discuss feedback with other contributors and the core team
|
||||
|
||||
You can leave the Beta testing program at any time:
|
||||
- On Android, [click this link](https://play.google.com/apps/testing/com.mattermost.rnbeta) while logged in with your Google Play email address used to opt-in for the Beta program, then click **Leave the program**.
|
||||
- On Android, [click this link](https://play.google.com/apps/testing/com.tokilabs.mattermost) while logged in with your Google Play email address used to opt-in for the Beta program, then click **Leave the program**.
|
||||
- On iOS, access the `Mattermost Beta` app page in TestFlight and click **Stop Testing**.
|
||||
|
||||
### Contribute Code
|
||||
|
|
|
|||
32
agent-ops/rules/_templates/domain-rule-template.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
name: <domain-name>
|
||||
description: <도메인 한 줄 설명>
|
||||
type: core | supporting | generic
|
||||
---
|
||||
|
||||
# <Domain Name>
|
||||
|
||||
## 목적 / 책임
|
||||
<이 도메인이 담당하는 핵심 책임>
|
||||
|
||||
## 포함 경로
|
||||
- `app/<path>/`
|
||||
- `app/actions/local/<file>.ts`
|
||||
- `app/actions/remote/<file>.ts`
|
||||
|
||||
## 제외 경로
|
||||
- <이 도메인에 속하지 않는 경로>
|
||||
|
||||
## 주요 구성 요소
|
||||
- **<component>**: <역할>
|
||||
- **<component>**: <역할>
|
||||
|
||||
## 유지할 패턴
|
||||
- <이 도메인에서 따라야 할 패턴>
|
||||
|
||||
## 다른 도메인과의 경계
|
||||
- **database 도메인**: DB 조작은 database 도메인 패턴을 따른다
|
||||
- **network 도메인**: API 호출은 ClientMix 패턴을 따른다
|
||||
|
||||
## 금지 사항
|
||||
- <이 도메인에서 하면 안 되는 것>
|
||||
46
agent-ops/rules/domain/database/rules.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
name: database
|
||||
description: WatermelonDB 이중 DB 시스템 - 앱 DB와 서버별 DB
|
||||
type: supporting
|
||||
---
|
||||
|
||||
# Database Domain
|
||||
|
||||
## 목적 / 책임
|
||||
WatermelonDB 기반 이중 데이터베이스를 관리한다. DatabaseManager 싱글톤, operator, 스키마, 모델을 담당한다.
|
||||
|
||||
## 포함 경로
|
||||
- `app/database/` (전체)
|
||||
- `app/database/manager/` - DatabaseManager 싱글톤
|
||||
- `app/database/models/app/` - 앱 전역 모델 (서버 목록 등)
|
||||
- `app/database/models/server/` - 서버별 모델 (채널, 유저, 포스트 등)
|
||||
- `app/database/operator/` - 데이터 쓰기 operator
|
||||
- `app/database/schema/` - DB 스키마 정의
|
||||
- `docs/database/` - DB 스키마 문서
|
||||
|
||||
## 제외 경로
|
||||
- `app/queries/` (Query Layer는 별도 - 여기서 DB를 읽는다)
|
||||
|
||||
## 주요 구성 요소
|
||||
- **DatabaseManager**: 모든 DB 인스턴스 관리 싱글톤 (`app/database/manager/index.ts`)
|
||||
- **App Database**: 서버 목록 등 전역 앱 상태 (하나만 존재)
|
||||
- **Server Database**: 서버별 채널/유저/포스트 데이터 (서버당 하나)
|
||||
- **Operators**: transformer/handler/comparator 패턴으로 데이터 쓰기
|
||||
- **Schema**: 테이블 정의 및 마이그레이션
|
||||
|
||||
## 유지할 패턴
|
||||
- DB 경로: iOS는 App Group, Android는 `${documentDirectory}/databases/`
|
||||
- Operator를 통해 batch 작업으로 데이터 쓰기
|
||||
- Sync handler는 create/update/delete 전체 라이프사이클 처리 필수
|
||||
- 스키마 변경 시 반드시 버전 번호 증가 + 문서(`docs/database/`) 업데이트
|
||||
- Query Layer(`app/queries/`)는 `query*`, `observe*`, `get*`, `prepare*` 패턴
|
||||
|
||||
## 다른 도메인과의 경계
|
||||
- **모든 도메인**: DB 직접 접근보다 Query Layer(`app/queries/`) 사용 권장
|
||||
- **network 도메인**: remote action이 API 응답을 operator에 전달
|
||||
|
||||
## 금지 사항
|
||||
- 스키마 변경 후 `docs/database/` 문서 미업데이트
|
||||
- Sync handler에서 stale 레코드 삭제 누락 (`prepareDestroyPermanently()` 필수)
|
||||
- 테스트에서 `extraLokiOptions: {autosave: false}` 누락 (메모리 누수)
|
||||
- 테스트 후 `DatabaseManager.destroyServerDatabase()` cleanup 누락
|
||||
45
agent-ops/rules/domain/messaging/rules.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
name: messaging
|
||||
description: 채널, 포스트, 스레드, 드래프트 등 핵심 메시징 기능
|
||||
type: core
|
||||
---
|
||||
|
||||
# Messaging Domain
|
||||
|
||||
## 목적 / 책임
|
||||
Mattermost의 핵심 메시징 기능: 채널, 포스트, 스레드, 리액션, 드래프트, 예약 포스트를 담당한다.
|
||||
|
||||
## 포함 경로
|
||||
- `app/actions/local/channel.ts`, `post.ts`, `thread.ts`, `draft.ts`, `reactions.ts`, `scheduled_post.ts`
|
||||
- `app/actions/remote/channel.ts`, `post.ts`, `thread.ts`, `reactions.ts`, `search.ts`
|
||||
- `app/queries/servers/channel.ts`, `post.ts`, `thread.ts`, `drafts.ts`, `scheduled_post.ts`, `reaction.ts`
|
||||
- `app/database/models/server/channel*.ts`, `post.ts`, `thread*.ts`, `draft.ts`, `reaction.ts`, `scheduled_post.ts`
|
||||
- `app/screens/channel/`, `app/screens/thread/`, `app/screens/edit_post/`
|
||||
- `app/components/post_list/`, `app/components/post_draft/`
|
||||
|
||||
## 제외 경로
|
||||
- `app/products/` (products는 각 product 도메인)
|
||||
- `app/actions/remote/user.ts` (user 도메인)
|
||||
|
||||
## 주요 구성 요소
|
||||
- **Channel**: 채널 CRUD, 멤버십, 카테고리 관리
|
||||
- **Post**: 포스트 생성/수정/삭제, 파일 첨부, 리액션
|
||||
- **Thread**: 스레드 팔로우, 언급, 참여자 관리
|
||||
- **Draft**: 임시저장, 예약 포스트
|
||||
- **Search**: 채널/포스트 검색
|
||||
|
||||
## 유지할 패턴
|
||||
- Remote action: `fetchXxx()` → API 호출 → operator로 DB 저장 → `{error}` 반환
|
||||
- Local action: DB 조작만 수행
|
||||
- Sync handler는 create/update/delete 전체 라이프사이클을 처리한다
|
||||
- `fetchMissingProfilesByIds()` 사용 - 직접 `client.getProfilesByIds()` 호출 금지
|
||||
|
||||
## 다른 도메인과의 경계
|
||||
- **database 도메인**: WatermelonDB 스키마 변경 시 `docs/database/server/server.md` 업데이트 필요
|
||||
- **network 도메인**: API 호출은 `client/rest.ts`의 ClientMix 패턴 사용
|
||||
- **ui 도메인**: 화면 렌더링은 ui 도메인, 데이터 로직은 이 도메인
|
||||
|
||||
## 금지 사항
|
||||
- `console.error()` / `console.log()` 직접 사용 (→ `logError()`, `logDebug()` 사용)
|
||||
- `TouchableOpacity` 사용 (→ `Pressable` 사용)
|
||||
- DB sync handler에서 삭제 누락 (stale 레코드는 `prepareDestroyPermanently()` 처리)
|
||||
49
agent-ops/rules/domain/products-agents/rules.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
---
|
||||
name: products-agents
|
||||
description: AI 에이전트 기능 - WebSocket 스트리밍, AI 봇, 툴 호출
|
||||
type: core
|
||||
---
|
||||
|
||||
# Products/Agents Domain
|
||||
|
||||
## 목적 / 책임
|
||||
AI 에이전트 기능을 담당한다. WebSocket 스트리밍, AI 봇 관리, 툴 호출/승인, 어노테이션을 처리한다.
|
||||
|
||||
## 포함 경로
|
||||
- `app/products/agents/` (전체)
|
||||
- `app/products/agents/actions/`
|
||||
- `app/products/agents/client/rest.ts`
|
||||
- `app/products/agents/components/`
|
||||
- `app/products/agents/constants.ts`
|
||||
- `app/products/agents/hooks/`
|
||||
- `app/products/agents/screens/`
|
||||
- `app/products/agents/store/`
|
||||
- `app/products/agents/types/`
|
||||
- `app/products/agents/websocket/`
|
||||
|
||||
## 제외 경로
|
||||
- `app/actions/remote/` (messaging 도메인)
|
||||
- `app/products/calls/` (calls 도메인)
|
||||
|
||||
## 주요 구성 요소
|
||||
- **WebSocket Handler**: `custom_mattermost-ai_postupdate` 이벤트 처리 (start, message, reasoning, tool_call, annotation, end)
|
||||
- **Streaming Store**: 스트리밍 중 ephemeral 상태 관리
|
||||
- **AI Bot**: AI 봇 목록 조회 및 DB 동기화 (`handleAIBots`)
|
||||
- **Tool Call**: 툴 호출 승인/거절 처리
|
||||
- **Client (ClientMix)**: `app/products/agents/client/rest.ts`
|
||||
|
||||
## 유지할 패턴
|
||||
- **WebSocket 스트리밍**: `ENDED` 이벤트에서 로컬 스트리밍 상태를 반드시 초기화 (stale state 방지)
|
||||
- **ClientMix 패턴**: API 호출은 `client/rest.ts` 내에서만 수행, action에서 직접 `doFetch()` 금지
|
||||
- **PostType 추가 시**: `types/api/posts.d.ts`의 `PostType` union에 타입 추가
|
||||
- **Sync handler**: create/update/delete 모두 처리 (`prepareDestroyPermanently()` 필수)
|
||||
|
||||
## 다른 도메인과의 경계
|
||||
- **messaging 도메인**: 포스트 데이터는 `POST_EDITED` WebSocket 이벤트로 수신
|
||||
- **database 도메인**: Agents 전용 DB 모델 사용
|
||||
- **network 도메인**: ClientMix를 통해 NetworkManager와 연결
|
||||
|
||||
## 금지 사항
|
||||
- 툴 호출 상태 변경을 별도 WebSocket 이벤트로 처리 (→ `POST_EDITED`로 수신)
|
||||
- `ENDED` 이벤트 후 스트리밍 상태 미초기화
|
||||
- action 파일에서 직접 `client.doFetch()` 호출
|
||||
41
agent-ops/rules/domain/products-calls/rules.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
name: products-calls
|
||||
description: 음성/화상 통화 기능
|
||||
type: core
|
||||
---
|
||||
|
||||
# Products/Calls Domain
|
||||
|
||||
## 목적 / 책임
|
||||
음성/화상 통화 기능을 담당한다. 통화 연결, 상태 관리, UI를 처리한다.
|
||||
|
||||
## 포함 경로
|
||||
- `app/products/calls/` (전체)
|
||||
- `app/products/calls/actions/`
|
||||
- `app/products/calls/client/rest.ts`
|
||||
- `app/products/calls/components/`
|
||||
- `app/products/calls/constants.ts`
|
||||
- `app/products/calls/hooks/`
|
||||
- `app/products/calls/screens/`
|
||||
- `app/products/calls/store/`
|
||||
- `app/products/calls/types/`
|
||||
|
||||
## 제외 경로
|
||||
- `app/products/agents/` (agents 도메인)
|
||||
- `app/products/playbooks/` (playbooks 도메인)
|
||||
|
||||
## 주요 구성 요소
|
||||
- **Call Connection**: WebRTC 기반 통화 연결 관리
|
||||
- **Call State**: Ephemeral store로 통화 상태 관리
|
||||
- **Client (ClientMix)**: `app/products/calls/client/rest.ts`
|
||||
|
||||
## 유지할 패턴
|
||||
- **ClientMix 패턴**: API 호출은 `client/rest.ts` 내에서만 수행
|
||||
- **Ephemeral Store**: 통화 상태는 ephemeral store로 관리 (DB 아님)
|
||||
|
||||
## 다른 도메인과의 경계
|
||||
- **messaging 도메인**: 채널 정보는 messaging 도메인 쿼리 사용
|
||||
- **network 도메인**: ClientMix를 통해 NetworkManager와 연결
|
||||
|
||||
## 금지 사항
|
||||
- action 파일에서 직접 `client.doFetch()` 호출
|
||||
53
agent-ops/rules/domain/ui/rules.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
---
|
||||
name: ui
|
||||
description: 컴포넌트, 스크린, 네비게이션, 스타일링
|
||||
type: generic
|
||||
---
|
||||
|
||||
# UI Domain
|
||||
|
||||
## 목적 / 책임
|
||||
React Native UI 컴포넌트, 스크린, 네비게이션, 스타일링을 담당한다.
|
||||
|
||||
## 포함 경로
|
||||
- `app/components/` - 재사용 컴포넌트
|
||||
- `app/screens/` - 화면 단위 컴포넌트
|
||||
- `app/hooks/` - 공통 React hooks
|
||||
- `app/utils/` - UI 유틸리티
|
||||
- `share_extension/` - Android 공유 확장 (React Navigation 사용)
|
||||
|
||||
## 제외 경로
|
||||
- `app/products/*/components/` (각 product 도메인)
|
||||
- `app/actions/` (비즈니스 로직은 messaging/product 도메인)
|
||||
|
||||
## 주요 구성 요소
|
||||
- **Navigation**: `react-native-navigation` v7 (메인 앱), `React Navigation` (share extension)
|
||||
- **Components**: 60+ 재사용 컴포넌트
|
||||
- **Screens**: 63+ 화면 컴포넌트 (각각 독립 등록)
|
||||
- **Themes**: `PREFERENCES.THEMES` 상수 사용
|
||||
|
||||
## 유지할 패턴
|
||||
- `Pressable` 사용 (→ `TouchableOpacity` 금지), 항상 pressed 피드백 추가
|
||||
- `typography()` 사용 (→ raw `fontSize`/`fontWeight`/`fontFamily` 금지)
|
||||
- `makeStyleSheetFromTheme` 사용 시 `StyleSheet.create` 불필요
|
||||
- `getStyleSheet` 는 파일 상단(imports 이후)에 위치
|
||||
- 정적 객체(hitSlop 등)는 모듈 레벨 상수로 추출
|
||||
- `FormattedText` 사용 (→ `<Text>{intl.formatMessage(...)}</Text>` 지양)
|
||||
- `useServerUrl()` hook 사용 (→ `serverUrl` prop 전달 지양)
|
||||
- `usePreventDoubleTap` hook 사용 (버튼 중복 클릭 방지)
|
||||
- 리스트 렌더링: 인라인 화살표 함수 금지, 자식 컴포넌트에서 ID 받아 처리
|
||||
- `useDidMount` (→ `useEffect(cb, [])` 대신), `useInitialValue` (→ `useMemo(f, [])` 대신)
|
||||
|
||||
## 다른 도메인과의 경계
|
||||
- **messaging/product 도메인**: UI는 데이터 표시만, 비즈니스 로직은 해당 도메인
|
||||
- **database 도메인**: `observe*` 쿼리를 통해 반응형 데이터 구독
|
||||
|
||||
## 금지 사항
|
||||
- `TouchableOpacity` 사용
|
||||
- raw `fontSize`, `fontWeight`, `fontFamily` 스타일 직접 사용
|
||||
- `Object.hasOwn()` 사용 (ES2022+, RN 미지원 → `'key' in obj` 사용)
|
||||
- `Animated` API 사용 (→ `react-native-reanimated` 사용)
|
||||
- `new URL()` 사용 (→ `urlParse` 사용)
|
||||
- `Linking.openURL()` 직접 사용 (→ `tryOpenURL()` 사용)
|
||||
- `ActivityIndicator` 직접 사용 (→ `<Loading>` 컴포넌트 사용)
|
||||
- 커스텀 React 컴포넌트를 Markdown 컴포넌트 내부에 인라인 삽입
|
||||
23
agent-ops/rules/global.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
name: global
|
||||
description: 전 프로젝트 공통 규칙
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# Global Rules
|
||||
|
||||
## 응답 언어
|
||||
- 한국어로 응답한다.
|
||||
|
||||
## 기존 구조 우선
|
||||
- 새 파일 생성보다 기존 파일 수정을 우선한다.
|
||||
- 기존 패턴과 컨벤션을 따른다.
|
||||
|
||||
## 코드 변경 원칙
|
||||
- 코드 변경 전 관련 domain rule을 확인한다.
|
||||
- 요청 범위를 넘는 변경을 금지한다.
|
||||
- 불확실하면 단정하지 말고 후보로 제시한다.
|
||||
|
||||
## 작업 전 확인
|
||||
- `agent-ops/skills/router.md` 를 먼저 참조하여 필요한 skill 또는 domain rule만 로드한다.
|
||||
- 관련 rule / skill 만 최소로 로드한다.
|
||||
35
agent-ops/skills/_templates/skill-template.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
name: <skill-name>
|
||||
description: <skill 한 줄 설명>
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# <Skill Name>
|
||||
|
||||
## 목적
|
||||
<이 skill이 해결하는 문제>
|
||||
|
||||
## 언제 호출할지
|
||||
- <이 skill을 사용해야 하는 상황>
|
||||
- <이 skill을 사용해야 하는 상황>
|
||||
|
||||
## 입력
|
||||
- `<param>`: <설명> (필수/선택)
|
||||
|
||||
## 먼저 확인할 것
|
||||
- [ ] <사전 확인 항목>
|
||||
- [ ] <사전 확인 항목>
|
||||
|
||||
## 실행 절차
|
||||
1. <단계>
|
||||
2. <단계>
|
||||
3. <단계>
|
||||
|
||||
## 출력 형식
|
||||
```
|
||||
<출력 예시>
|
||||
```
|
||||
|
||||
## 금지 사항
|
||||
- <하면 안 되는 것>
|
||||
- <하면 안 되는 것>
|
||||
48
agent-ops/skills/code-change/SKILL.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
---
|
||||
name: code-change
|
||||
description: 코드 변경 절차 - 읽기 → 도메인 rule 확인 → 변경 → 검증
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# Code Change
|
||||
|
||||
## 목적
|
||||
코드 변경 시 도메인 규칙을 준수하고 기존 패턴을 따른다.
|
||||
|
||||
## 언제 호출할지
|
||||
- 새 기능을 추가할 때
|
||||
- 버그를 수정할 때
|
||||
- 기존 코드를 수정할 때
|
||||
|
||||
## 입력
|
||||
- `변경 대상`: 파일 경로 또는 기능 설명 (필수)
|
||||
- `변경 내용`: 무엇을 어떻게 변경할지 (필수)
|
||||
|
||||
## 먼저 확인할 것
|
||||
- [ ] `find-domain` skill로 대상 도메인 판별
|
||||
- [ ] 해당 domain rule 로드 (`agent-ops/rules/domain/<domain>/rules.md`)
|
||||
- [ ] 변경할 파일을 Read tool로 먼저 읽기
|
||||
- [ ] 기존 패턴과 컨벤션 파악
|
||||
|
||||
## 실행 절차
|
||||
1. 대상 파일 읽기 (Read tool 사용)
|
||||
2. 도메인 rule의 "유지할 패턴"과 "금지 사항" 확인
|
||||
3. 변경 범위를 요청 범위로 제한 (추가 리팩터링 금지)
|
||||
4. 변경 적용 (Edit tool 우선, 전면 재작성은 Write tool)
|
||||
5. TypeScript 타입 확인 (`npm run tsc`)
|
||||
6. 린팅 확인 (`npm run fix`)
|
||||
|
||||
## 출력 형식
|
||||
변경 후:
|
||||
```
|
||||
변경 파일: <path>
|
||||
변경 내용: <요약>
|
||||
확인 필요: <있으면 명시>
|
||||
```
|
||||
|
||||
## 금지 사항
|
||||
- 파일을 읽기 전에 변경하지 않는다
|
||||
- 요청 범위를 넘는 리팩터링, 주석 추가, 타입 어노테이션 추가
|
||||
- 도메인 rule의 금지 사항 위반
|
||||
- `console.log()` / `console.error()` 직접 사용
|
||||
- 다른 언어 파일 수정 (`en.json` 외 i18n 파일 수정 금지)
|
||||
96
agent-ops/skills/create-domain-rule/SKILL.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
---
|
||||
name: create-domain-rule
|
||||
description: 새로운 도메인 rules.md 파일을 생성하기 위한 범용 스킬
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# Create Domain Rule
|
||||
|
||||
## 목적
|
||||
|
||||
`agent-ops/rules/domain/<domain-name>/rules.md` 파일을 올바른 형식으로 생성한다.
|
||||
실제 프로젝트 폴더 구조를 분석하여 경로와 구성 요소를 자동으로 채운다.
|
||||
생성 후 router.md 의 코드 변경 라우팅 테이블에 항목을 추가한다.
|
||||
|
||||
## 언제 호출할지
|
||||
|
||||
- 새로운 도메인 영역의 코드를 처음 변경하기 전에
|
||||
- 기존 domain rule이 없는 경로에 대해 규칙이 필요할 때
|
||||
- 사용자가 특정 도메인의 rule 파일을 만들어 달라고 요청할 때
|
||||
- agent-ops 초기 scaffold 이후 도메인을 추가 확장할 때
|
||||
|
||||
## 입력
|
||||
|
||||
- `domain-name`: 생성할 도메인 이름, kebab-case (필수)
|
||||
- `domain-type`: `core` / `supporting` / `generic` 중 하나 (필수)
|
||||
- `path-hints`: 이 도메인에 해당하는 경로 힌트 목록 (선택, 없으면 자동 탐색)
|
||||
|
||||
## 먼저 확인할 것
|
||||
|
||||
- [ ] `agent-ops/rules/domain/<domain-name>/rules.md` 가 이미 존재하는지 확인
|
||||
- [ ] `agent-ops/rules/_templates/domain-rule-template.md` 를 읽어 최신 템플릿 형식 파악
|
||||
- [ ] `agent-ops/rules/domain/` 하위 기존 domain rule 목록을 확인하여 중복·유사 도메인 여부 판단
|
||||
|
||||
## 실행 절차
|
||||
|
||||
1. **중복 확인**
|
||||
- `agent-ops/rules/domain/<domain-name>/` 폴더 존재 여부 확인
|
||||
- 책임이 겹치는 기존 domain rule 이 있으면 사용자에게 알리고 중단한다
|
||||
|
||||
2. **경로 탐색**
|
||||
- `path-hints` 가 제공된 경우: 해당 경로를 기준으로 폴더 구조 확인
|
||||
- `path-hints` 가 없는 경우: `domain-name` 과 연관된 폴더명·파일명으로 프로젝트를 탐색
|
||||
- 실제로 존재하는 경로만 포함 경로에 기재한다
|
||||
- 존재하지 않는 경로는 기재하지 않는다
|
||||
|
||||
3. **도메인 분석**
|
||||
- 탐색된 경로의 주요 파일·모듈을 읽어 아래를 도출한다
|
||||
- 도메인의 목적 / 책임 한 줄 요약
|
||||
- 포함 경로 목록
|
||||
- 제외 경로 (인접 도메인과 겹칠 수 있는 경로)
|
||||
- 주요 구성 요소 (파일, 모듈, 클래스, 함수 등)
|
||||
- 유지할 패턴 (네이밍 규칙, 아키텍처 패턴 등)
|
||||
- 다른 도메인과의 경계
|
||||
- 금지 사항
|
||||
|
||||
4. **rules.md 생성**
|
||||
- 경로: `agent-ops/rules/domain/<domain-name>/rules.md`
|
||||
- `domain-rule-template.md` 형식을 따른다
|
||||
- 실제 코드에서 확인된 내용만 기재한다
|
||||
- 불확실한 항목은 `<!-- TODO: 확인 필요 -->` 주석으로 남긴다
|
||||
|
||||
5. **router.md 업데이트**
|
||||
- `agent-ops/skills/router.md` 를 읽는다
|
||||
- 코드 변경 섹션의 경로-도메인 라우팅 테이블에 아래 형식으로 추가한다
|
||||
```
|
||||
| `<path-pattern>` | `agent-ops/rules/domain/<domain-name>/rules.md` |
|
||||
```
|
||||
- 경로 패턴은 실제 포함 경로를 기준으로 작성한다
|
||||
- 기존 테이블 항목을 삭제하거나 재정렬하지 않는다
|
||||
|
||||
6. **결과 보고**
|
||||
- 생성한 파일 경로
|
||||
- router.md 에 추가한 항목
|
||||
- 불확실하여 TODO로 남긴 항목 (해당 시)
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## 생성 완료
|
||||
|
||||
- Domain Rule 경로: agent-ops/rules/domain/<domain-name>/rules.md
|
||||
- 도메인 유형: <core | supporting | generic>
|
||||
- router.md 추가 경로 패턴: <path-pattern>
|
||||
|
||||
## TODO 항목 (확인 필요)
|
||||
- <불확실하여 직접 확인이 필요한 항목> (해당 시)
|
||||
```
|
||||
|
||||
## 금지 사항
|
||||
|
||||
- 이미 존재하는 rules.md 를 덮어쓰지 않는다
|
||||
- 실제 존재하지 않는 경로를 포함 경로에 넣지 않는다
|
||||
- 추측으로 패턴이나 금지 사항을 채우지 않는다 — 확인된 내용만 기재한다
|
||||
- router.md 의 기존 항목을 삭제하거나 재정렬하지 않는다
|
||||
- 하나의 domain rule 에 여러 독립 도메인의 책임을 묶지 않는다
|
||||
- 코드 변경을 수행하지 않는다 — 이 skill 은 rule 파일 생성만 담당한다
|
||||
83
agent-ops/skills/create-skill/SKILL.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
---
|
||||
name: create-skill
|
||||
description: 새로운 SKILL.md 파일을 생성하기 위한 범용 스킬
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# Create Skill
|
||||
|
||||
## 목적
|
||||
|
||||
`agent-ops/skills/<skill-name>/SKILL.md` 파일을 올바른 형식으로 생성한다.
|
||||
기존 skill-template.md 를 기반으로, 요청 목적에 맞는 내용을 채워 넣는다.
|
||||
생성 후 router.md 에 라우팅 항목을 추가한다.
|
||||
|
||||
## 언제 호출할지
|
||||
|
||||
- 새로운 반복 작업 패턴이 생겨 skill로 정의해야 할 때
|
||||
- 기존 skill이 없는 작업 유형을 처음 수행하기 전에
|
||||
- 사용자가 특정 작업을 skill로 만들어 달라고 요청할 때
|
||||
|
||||
## 입력
|
||||
|
||||
- `skill-name`: 생성할 skill 이름, kebab-case (필수)
|
||||
- `purpose`: 이 skill이 해결하는 문제 한 줄 요약 (필수)
|
||||
- `trigger-cases`: 이 skill을 호출해야 하는 상황 목록 (선택)
|
||||
|
||||
## 먼저 확인할 것
|
||||
|
||||
- [ ] `agent-ops/skills/` 하위에 동일 이름의 디렉터리가 이미 존재하는지 확인
|
||||
- [ ] `agent-ops/skills/router.md` 에 이미 유사한 라우팅 항목이 있는지 확인
|
||||
- [ ] `agent-ops/skills/_templates/skill-template.md` 를 읽어 최신 템플릿 형식 파악
|
||||
|
||||
## 실행 절차
|
||||
|
||||
1. **중복 확인**
|
||||
- `agent-ops/skills/<skill-name>/` 폴더 존재 여부 확인
|
||||
- 기능이 겹치는 기존 skill이 있으면 사용자에게 알리고 중단한다
|
||||
|
||||
2. **목적 분석**
|
||||
- `purpose` 와 `trigger-cases` 를 바탕으로 아래 항목을 도출한다
|
||||
- 언제 호출할지 (2~4개)
|
||||
- 필요한 입력 파라미터
|
||||
- 사전 확인 항목
|
||||
- 실행 절차 (3~7단계)
|
||||
- 출력 형식
|
||||
- 금지 사항
|
||||
|
||||
3. **SKILL.md 생성**
|
||||
- 경로: `agent-ops/skills/<skill-name>/SKILL.md`
|
||||
- `skill-template.md` 형식을 따른다
|
||||
- 프로젝트 특화 내용보다 범용 절차를 우선한다
|
||||
- 절차는 구체적이되 지나치게 세부 구현을 기술하지 않는다
|
||||
|
||||
4. **router.md 업데이트**
|
||||
- `agent-ops/skills/router.md` 를 읽는다
|
||||
- 이 skill이 속할 라우팅 축(구조 분석/코드 변경/흐름 추적 등)을 판단한다
|
||||
- 해당 축에 `→ agent-ops/skills/<skill-name>/SKILL.md` 항목을 추가한다
|
||||
- 기존 라우팅 구조를 깨지 않는다
|
||||
|
||||
5. **결과 보고**
|
||||
- 생성한 파일 경로
|
||||
- router.md 에 추가한 항목
|
||||
- 이 skill이 다루지 않는 범위(필요 시)
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## 생성 완료
|
||||
|
||||
- SKILL 경로: agent-ops/skills/<skill-name>/SKILL.md
|
||||
- router.md 추가 항목: <라우팅 축> → <skill-name>
|
||||
|
||||
## 주의사항 (해당 시)
|
||||
- <이 skill이 다루지 않는 범위 또는 주의할 점>
|
||||
```
|
||||
|
||||
## 금지 사항
|
||||
|
||||
- 이미 존재하는 skill 을 덮어쓰지 않는다
|
||||
- 프로젝트 특화 경로(예: `app/screens/`)를 skill 본문에 하드코딩하지 않는다
|
||||
- skill 생성과 무관한 코드 파일을 수정하지 않는다
|
||||
- router.md 의 기존 항목을 삭제하거나 재정렬하지 않는다
|
||||
- 하나의 skill 에 여러 독립적인 책임을 묶지 않는다
|
||||
40
agent-ops/skills/find-domain/SKILL.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
name: find-domain
|
||||
description: 파일 경로 또는 기능으로 해당 도메인을 판별한다
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# Find Domain
|
||||
|
||||
## 목적
|
||||
변경하거나 분석할 코드가 어느 도메인에 속하는지 판별한다.
|
||||
|
||||
## 언제 호출할지
|
||||
- 어떤 파일을 수정해야 하는지 불분명할 때
|
||||
- 기능이 어느 도메인에 속하는지 모를 때
|
||||
- 여러 도메인에 걸친 변경이 필요한지 파악할 때
|
||||
|
||||
## 입력
|
||||
- `경로 또는 기능 설명`: 파일 경로, 컴포넌트 이름, 또는 기능 설명 (필수)
|
||||
|
||||
## 먼저 확인할 것
|
||||
- [ ] `agent-ops/skills/router.md`의 경로 패턴 테이블 확인
|
||||
- [ ] CLAUDE.md의 TypeScript Path Aliases 섹션 확인
|
||||
|
||||
## 실행 절차
|
||||
1. 입력이 파일 경로면 → router.md 테이블과 매칭
|
||||
2. 입력이 기능 설명이면 → 관련 파일을 Glob/Grep으로 탐색
|
||||
3. 도메인 후보를 식별하고 해당 domain rule 로드
|
||||
4. 여러 도메인에 걸친 경우 → 주 도메인 + 영향 도메인 명시
|
||||
|
||||
## 출력 형식
|
||||
```
|
||||
도메인: <domain-name>
|
||||
근거: <판별 근거>
|
||||
관련 rule: agent-ops/rules/domain/<domain>/rules.md
|
||||
영향 도메인: <있으면 명시>
|
||||
```
|
||||
|
||||
## 금지 사항
|
||||
- 도메인을 단정하지 않고 불확실하면 후보를 제시한다
|
||||
- 관련 없는 domain rule까지 로드하지 않는다
|
||||
48
agent-ops/skills/router.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
---
|
||||
name: router
|
||||
description: 작업 유형별 skill / domain rule 라우팅
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# Skill Router
|
||||
|
||||
작업 유형에 따라 필요한 skill 또는 domain rule만 로드한다.
|
||||
|
||||
## 라우팅 축
|
||||
|
||||
### 구조 분석 / 스캐폴드
|
||||
→ `agent-ops/skills/find-domain/SKILL.md`
|
||||
→ `agent-ops/rules/global.md`
|
||||
|
||||
### 새 Skill 생성
|
||||
→ `agent-ops/skills/create-skill/SKILL.md`
|
||||
|
||||
### 새 Domain Rule 생성
|
||||
→ `agent-ops/skills/create-domain-rule/SKILL.md`
|
||||
|
||||
### 도메인 판별
|
||||
→ `agent-ops/skills/find-domain/SKILL.md`
|
||||
|
||||
### 코드 변경
|
||||
→ `agent-ops/skills/code-change/SKILL.md`
|
||||
→ 변경 대상 경로에 해당하는 domain rule 로드
|
||||
|
||||
| 경로 패턴 | Domain Rule |
|
||||
|---|---|
|
||||
| `app/products/agents/` | `agent-ops/rules/domain/products-agents/rules.md` |
|
||||
| `app/products/calls/` | `agent-ops/rules/domain/products-calls/rules.md` |
|
||||
| `app/database/`, `app/queries/` | `agent-ops/rules/domain/database/rules.md` |
|
||||
| `app/components/`, `app/screens/`, `app/hooks/` | `agent-ops/rules/domain/ui/rules.md` |
|
||||
| `app/actions/`, 채널/포스트/스레드 관련 | `agent-ops/rules/domain/messaging/rules.md` |
|
||||
|
||||
### 흐름 추적
|
||||
→ 흐름에 관련된 domain rule 로드
|
||||
→ `agent-ops/skills/find-domain/SKILL.md`로 경로 판별 후 진행
|
||||
|
||||
### 문서 / 릴리즈 정리
|
||||
→ `agent-ops/rules/global.md`
|
||||
→ `docs/` 폴더 직접 확인
|
||||
|
||||
## 원칙
|
||||
- 필요한 rule만 로드한다. 전체를 로드하지 않는다.
|
||||
- 불확실한 도메인은 `find-domain` skill로 먼저 판별한다.
|
||||
|
|
@ -104,7 +104,7 @@ android {
|
|||
ndkVersion rootProject.ext.ndkVersion
|
||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||
namespace "com.mattermost.rnbeta"
|
||||
namespace "com.tokilabs.mattermost"
|
||||
|
||||
lintOptions {
|
||||
checkReleaseBuilds false
|
||||
|
|
@ -112,11 +112,11 @@ android {
|
|||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.mattermost.rnbeta"
|
||||
applicationId "com.tokilabs.mattermost"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 727
|
||||
versionName "2.37.2"
|
||||
versionCode 730
|
||||
versionName "2.38.0"
|
||||
testBuildType System.getProperty('testBuildType', 'debug')
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,80 +1,26 @@
|
|||
{
|
||||
"project_info": {
|
||||
"project_number": "184930218130",
|
||||
"firebase_url": "https://api-7231322553409637977-752355.firebaseio.com",
|
||||
"project_id": "api-7231322553409637977-752355",
|
||||
"storage_bucket": "api-7231322553409637977-752355.firebasestorage.app"
|
||||
"project_number": "1047648748539",
|
||||
"project_id": "mattermost-6ac08",
|
||||
"storage_bucket": "mattermost-6ac08.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:184930218130:android:2db4058a5b5d6506",
|
||||
"mobilesdk_app_id": "1:1047648748539:android:818bf70bfbb3f9d070415e",
|
||||
"android_client_info": {
|
||||
"package_name": "com.mattermost.rn"
|
||||
"package_name": "com.tokilabs.mattermost"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "184930218130-8nahrspll1opff0uogtkh2qsv8coqngo.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyASRwdI3DKpyjt_gMbL1MpViI3BTHB9rFg"
|
||||
"current_key": "AIzaSyAW6j_oPl9MVcm93qS3JgaEPD5ywp-TzZ0"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "109751306267633962395",
|
||||
"client_type": 3
|
||||
},
|
||||
{
|
||||
"client_id": "184930218130-1kq3fsu90eppbhh508f667og6qpqrpip.apps.googleusercontent.com",
|
||||
"client_type": 2,
|
||||
"ios_info": {
|
||||
"bundle_id": "com.mattermost.rn"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "AIzaSyASRwdI3DKpyjt_gMbL1MpViI3BTHB9rFg"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "109751306267633962395",
|
||||
"client_type": 3
|
||||
},
|
||||
{
|
||||
"client_id": "184930218130-1kq3fsu90eppbhh508f667og6qpqrpip.apps.googleusercontent.com",
|
||||
"client_type": 2,
|
||||
"ios_info": {
|
||||
"bundle_id": "com.mattermost.rn"
|
||||
}
|
||||
}
|
||||
]
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package com.mattermost.rnbeta;
|
||||
package com.tokilabs.mattermost;
|
||||
|
||||
import com.wix.detox.Detox;
|
||||
import com.wix.detox.config.DetoxConfig;
|
||||
|
|
@ -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="mmauthbeta" />
|
||||
<data android:scheme="mmauth_tokilabs" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service android:name=".NotificationDismissService"
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import androidx.core.app.Person;
|
|||
import androidx.core.app.RemoteInput;
|
||||
import androidx.core.graphics.drawable.IconCompat;
|
||||
|
||||
import com.mattermost.rnbeta.*;
|
||||
import com.tokilabs.mattermost.*;
|
||||
import com.mattermost.rnutils.helpers.NotificationHelper;
|
||||
import com.nozbe.watermelondb.WMDatabase;
|
||||
import com.mattermost.turbolog.TurboLog;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package com.mattermost.rnbeta
|
||||
package com.tokilabs.mattermost
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.mattermost.rnbeta
|
||||
package com.tokilabs.mattermost
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.mattermost.rnbeta
|
||||
package com.tokilabs.mattermost
|
||||
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.mattermost.rnbeta;
|
||||
package com.tokilabs.mattermost;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.mattermost.rnbeta;
|
||||
package com.tokilabs.mattermost;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationManager;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.mattermost.rnbeta;
|
||||
package com.tokilabs.mattermost;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
|
Before Width: | Height: | Size: 787 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 351 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 345 KiB |
|
Before Width: | Height: | Size: 730 KiB After Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 730 KiB After Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 730 KiB After Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 7.7 KiB |
|
|
@ -10,6 +10,7 @@ import {fetchConfigAndLicense} from '@actions/remote/systems';
|
|||
import {Events, Preferences, Screens} from '@constants';
|
||||
import 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';
|
||||
|
|
@ -26,6 +27,7 @@ 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');
|
||||
|
|
@ -182,4 +184,54 @@ 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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ 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';
|
||||
|
|
@ -53,7 +54,10 @@ export async function switchToServerAndLogin(serverUrl: string, theme: Theme, in
|
|||
return;
|
||||
}
|
||||
|
||||
const result = await doPing(server.url, true);
|
||||
// Retrieve pre-auth secret from keychain if it exists
|
||||
const preauthSecret = await getPreauthSecret(server.url);
|
||||
|
||||
const result = await doPing(server.url, true, 5000, preauthSecret);
|
||||
if (result.error) {
|
||||
alertServerError(intl, result.error);
|
||||
callback?.();
|
||||
|
|
|
|||
|
|
@ -5,13 +5,15 @@
|
|||
|
||||
import {DeviceEventEmitter} from 'react-native';
|
||||
|
||||
import {Navigation} from '@constants';
|
||||
import {ActionType, Events, 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,
|
||||
|
|
@ -27,6 +29,8 @@ import {
|
|||
updateChannelsDisplayName,
|
||||
showUnreadChannelsOnly,
|
||||
updateDmGmDisplayName,
|
||||
deletePostsForChannelsWithAutotranslation,
|
||||
deletePostsForChannel,
|
||||
} from './channel';
|
||||
|
||||
import type {ChannelModel, MyChannelModel, SystemModel} from '@database/models/server';
|
||||
|
|
@ -770,19 +774,19 @@ describe('updateMyChannelFromWebsocket', () => {
|
|||
const serverUrl = 'baseHandler.test.com';
|
||||
const channelId = 'id1';
|
||||
const teamId = 'tId1';
|
||||
const channel: Channel = {
|
||||
const channel: Channel = TestHelper.fakeChannel({
|
||||
id: channelId,
|
||||
team_id: teamId,
|
||||
total_msg_count: 0,
|
||||
delete_at: 0,
|
||||
} as Channel;
|
||||
const channelMember: ChannelMembership = {
|
||||
});
|
||||
const channelMember: ChannelMembership = TestHelper.fakeChannelMember({
|
||||
id: 'id',
|
||||
user_id: 'userid',
|
||||
channel_id: channelId,
|
||||
msg_count: 0,
|
||||
roles: '',
|
||||
} as ChannelMembership;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await DatabaseManager.init([serverUrl]);
|
||||
|
|
@ -808,6 +812,27 @@ 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', () => {
|
||||
|
|
@ -1133,3 +1158,186 @@ 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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import {DeviceEventEmitter} from 'react-native';
|
||||
|
||||
import {General, Navigation as NavigationConstants, Preferences, Screens} from '@constants';
|
||||
import {Events, General, Navigation as NavigationConstants, Preferences, Screens} from '@constants';
|
||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
|
||||
|
|
@ -13,7 +13,9 @@ import {
|
|||
prepareDeleteChannel, prepareMyChannelsForTeam, queryAllMyChannel,
|
||||
getMyChannel, getChannelById, queryUsersOnChannel, queryUserChannelsByTypes,
|
||||
prepareAllMyChannels,
|
||||
queryMyChannelsWithAutotranslation,
|
||||
} from '@queries/servers/channel';
|
||||
import {prepareDeletePost, queryPostsInChannel, queryPostsInThread} from '@queries/servers/post';
|
||||
import {queryDisplayNamePreferences} from '@queries/servers/preference';
|
||||
import {prepareCommonSystemValues, type PrepareCommonSystemValuesArgs, getCommonSystemValues, getCurrentTeamId, setCurrentChannelId, getCurrentUserId, getConfig, getLicense} from '@queries/servers/system';
|
||||
import {addChannelToTeamHistory, addTeamToTeamHistory, getTeamById, removeChannelFromTeamHistory} from '@queries/servers/team';
|
||||
|
|
@ -296,9 +298,15 @@ export async function updateMyChannelFromWebsocket(serverUrl: string, channelMem
|
|||
try {
|
||||
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
const member = await getMyChannel(database, channelMember.channel_id);
|
||||
|
||||
if (member && Boolean(member.autotranslationDisabled) !== Boolean(channelMember.autotranslation_disabled)) {
|
||||
await deletePostsForChannel(serverUrl, channelMember.channel_id);
|
||||
}
|
||||
|
||||
if (member) {
|
||||
member.prepareUpdate((m) => {
|
||||
m.roles = channelMember.roles;
|
||||
m.autotranslationDisabled = channelMember.autotranslation_disabled ?? false;
|
||||
});
|
||||
if (!prepareRecordsOnly) {
|
||||
operator.batchRecords([member], 'updateMyChannelFromWebsocket');
|
||||
|
|
@ -485,3 +493,86 @@ export const updateDmGmDisplayName = async (serverUrl: string) => {
|
|||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
export async function deletePostsForChannel(serverUrl: string, channelId: string, prepareRecordsOnly = false): Promise<{models: Model[]; error?: unknown}> {
|
||||
try {
|
||||
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
const channel = await getChannelById(database, channelId);
|
||||
|
||||
if (!channel) {
|
||||
return {models: []};
|
||||
}
|
||||
|
||||
const posts = await channel.posts.fetch();
|
||||
if (!posts.length) {
|
||||
return {models: []};
|
||||
}
|
||||
|
||||
const preparedPostsPromises = posts.map((post) => prepareDeletePost(post));
|
||||
const preparedPostsArrays = await Promise.all(preparedPostsPromises);
|
||||
const preparedModels: Model[] = preparedPostsArrays.flat();
|
||||
|
||||
const postsInChannel = await queryPostsInChannel(database, channelId);
|
||||
if (postsInChannel.length) {
|
||||
for (const postRange of postsInChannel) {
|
||||
const preparedPostRanges = postRange.prepareDestroyPermanently();
|
||||
preparedModels.push(preparedPostRanges);
|
||||
}
|
||||
}
|
||||
|
||||
const threadPromises = posts.filter((post) => post.rootId === '').map((post) => {
|
||||
return queryPostsInThread(database, post.id).fetch();
|
||||
});
|
||||
|
||||
const threadRanges = (await Promise.all(threadPromises)).flat();
|
||||
for (const threadRange of threadRanges) {
|
||||
const preparedThreadRange = threadRange.prepareDestroyPermanently();
|
||||
preparedModels.push(preparedThreadRange);
|
||||
}
|
||||
|
||||
const myChannel = await getMyChannel(database, channelId);
|
||||
if (myChannel) {
|
||||
myChannel.prepareUpdate((v) => {
|
||||
v.lastFetchedAt = 0;
|
||||
});
|
||||
preparedModels.push(myChannel);
|
||||
}
|
||||
|
||||
if (preparedModels.length && !prepareRecordsOnly) {
|
||||
await operator.batchRecords(preparedModels, 'deletePostsForChannel');
|
||||
DeviceEventEmitter.emit(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId, teamId: channel.teamId});
|
||||
}
|
||||
|
||||
return {error: false, models: preparedModels};
|
||||
} catch (error) {
|
||||
logError('Failed deletePostsForChannel', error);
|
||||
return {error, models: []};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePostsForChannelsWithAutotranslation(serverUrl: string, prepareRecordsOnly = false) {
|
||||
try {
|
||||
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
const myChannels = await queryMyChannelsWithAutotranslation(database).fetch();
|
||||
|
||||
const deleteResults = await Promise.all(
|
||||
myChannels.map((myChannel) => deletePostsForChannel(serverUrl, myChannel.id, prepareRecordsOnly)),
|
||||
);
|
||||
|
||||
const allModels: Model[] = [];
|
||||
for (const result of deleteResults) {
|
||||
if (result.models) {
|
||||
allModels.push(...result.models);
|
||||
}
|
||||
}
|
||||
|
||||
if (allModels.length && !prepareRecordsOnly) {
|
||||
await operator.batchRecords(allModels, 'deletePostsForChannelsWithAutotranslation');
|
||||
}
|
||||
|
||||
return {error: undefined, models: allModels};
|
||||
} catch (error) {
|
||||
logError('Failed deletePostsForChannelsWithAutotranslation', error);
|
||||
return {error, models: []};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {DeviceEventEmitter} from 'react-native';
|
|||
import {Navigation, Screens} from '@constants';
|
||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import {DRAFT_SCREEN_TAB_DRAFTS, DRAFT_SCREEN_TAB_SCHEDULED_POSTS} from '@constants/draft';
|
||||
import {PostTypes} from '@constants/post';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {goToScreen, popTo} from '@screens/navigation';
|
||||
import NavigationStore from '@store/navigation_store';
|
||||
|
|
@ -19,6 +20,7 @@ import {
|
|||
addFilesToDraft,
|
||||
removeDraft,
|
||||
updateDraftPriority,
|
||||
updateDraftBoRConfig,
|
||||
updateDraftMarkdownImageMetadata,
|
||||
} from './draft';
|
||||
|
||||
|
|
@ -405,3 +407,47 @@ describe('updateDraftMarkdownImageMetadata', () => {
|
|||
expect(result.draft.metadata?.images?.image1).toEqual(postImageData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDraftBoRConfig', () => {
|
||||
const postBoRConfig: PostBoRConfig = {
|
||||
enabled: true,
|
||||
borDurationSeconds: 300,
|
||||
borMaximumTimeToLiveSeconds: 3600,
|
||||
};
|
||||
|
||||
it('handle not found database', async () => {
|
||||
const result = await updateDraftBoRConfig('foo', channelId, '', postBoRConfig) as {draft: unknown; error: unknown};
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('handle no draft', async () => {
|
||||
const models = await updateDraftBoRConfig(serverUrl, channelId, '', postBoRConfig) as DraftModel[];
|
||||
expect(models).toBeDefined();
|
||||
expect(models.length).toBe(1);
|
||||
expect(models[0].metadata?.borConfig?.enabled).toBe(postBoRConfig.enabled);
|
||||
expect(models[0].metadata?.borConfig?.borDurationSeconds).toBe(postBoRConfig.borDurationSeconds);
|
||||
expect(models[0].type).toBe(PostTypes.BURN_ON_READ);
|
||||
});
|
||||
|
||||
it('update draft BoR config with enabled true', async () => {
|
||||
await operator.handleDraft({drafts: [draft], prepareRecordsOnly: false});
|
||||
|
||||
const result = await updateDraftBoRConfig(serverUrl, channelId, '', postBoRConfig) as {draft: DraftModel; error: unknown};
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.draft).toBeDefined();
|
||||
expect(result.draft.metadata?.borConfig?.enabled).toBe(postBoRConfig.enabled);
|
||||
expect(result.draft.metadata?.borConfig?.borDurationSeconds).toBe(postBoRConfig.borDurationSeconds);
|
||||
expect(result.draft.type).toBe('burn_on_read');
|
||||
});
|
||||
|
||||
it('update draft BoR config with enabled false', async () => {
|
||||
await operator.handleDraft({drafts: [draft], prepareRecordsOnly: false});
|
||||
|
||||
const disabledBoRConfig = {...postBoRConfig, enabled: false};
|
||||
const result = await updateDraftBoRConfig(serverUrl, channelId, '', disabledBoRConfig) as {draft: DraftModel; error: unknown};
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.draft).toBeDefined();
|
||||
expect(result.draft.metadata?.borConfig?.enabled).toBe(false);
|
||||
expect(result.draft.type).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {Image} from 'expo-image';
|
|||
import {DeviceEventEmitter} from 'react-native';
|
||||
|
||||
import {Navigation, Screens} from '@constants';
|
||||
import {PostTypes} from '@constants/post';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {getDraft} from '@queries/servers/drafts';
|
||||
import {getCurrentChannelId, getCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system';
|
||||
|
|
@ -265,6 +266,53 @@ export async function updateDraftPriority(serverUrl: string, channelId: string,
|
|||
}
|
||||
}
|
||||
|
||||
export async function updateDraftBoRConfig(serverUrl: string, channelId: string, rootId: string, postBoRConfig: PostBoRConfig, prepareRecordsOnly = false) {
|
||||
try {
|
||||
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
const draft = await getDraft(database, channelId, rootId);
|
||||
if (!draft) {
|
||||
const newDraft: Draft = {
|
||||
channel_id: channelId,
|
||||
root_id: rootId,
|
||||
update_at: Date.now(),
|
||||
metadata: {
|
||||
borConfig: postBoRConfig,
|
||||
},
|
||||
};
|
||||
|
||||
if (postBoRConfig.enabled) {
|
||||
newDraft.type = PostTypes.BURN_ON_READ;
|
||||
} else {
|
||||
newDraft.type = '';
|
||||
}
|
||||
|
||||
return operator.handleDraft({drafts: [newDraft], prepareRecordsOnly});
|
||||
}
|
||||
|
||||
draft?.prepareUpdate((d) => {
|
||||
d.metadata = {
|
||||
...d.metadata,
|
||||
borConfig: postBoRConfig,
|
||||
};
|
||||
|
||||
if (postBoRConfig.enabled) {
|
||||
d.type = PostTypes.BURN_ON_READ;
|
||||
} else {
|
||||
d.type = '';
|
||||
}
|
||||
});
|
||||
|
||||
if (!prepareRecordsOnly) {
|
||||
await operator.batchRecords([draft], 'updateDraftBoRConfig');
|
||||
}
|
||||
|
||||
return {draft};
|
||||
} catch (error) {
|
||||
logError('Failed updateDraftBoRConfig', error);
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDraftMarkdownImageMetadata({
|
||||
serverUrl,
|
||||
channelId,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import {ActionType, Post} from '@constants';
|
||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {getPostById} from '@queries/servers/post';
|
||||
import TestHelper from '@test/test_helper';
|
||||
import {COMBINED_USER_ACTIVITY} from '@utils/post_list';
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ import {
|
|||
removePostAcknowledgement,
|
||||
deletePosts,
|
||||
getUsersCountFromMentions,
|
||||
updatePostTranslation,
|
||||
} from './post';
|
||||
|
||||
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||
|
|
@ -352,3 +354,49 @@ describe('getUsersCountFromMentions', () => {
|
|||
expect(num).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePostTranslation', () => {
|
||||
const post = TestHelper.fakePost({id: 'postid4', channel_id: channelId});
|
||||
|
||||
it('post not found', async () => {
|
||||
const translation: PostTranslation = {object: {message: 'Hola'}, state: 'ready', source_lang: 'en'};
|
||||
const result = await updatePostTranslation(serverUrl, 'nonexistent', 'es', translation);
|
||||
expect(result.error).toBe('Post not found');
|
||||
});
|
||||
|
||||
it('updates post metadata with translation', async () => {
|
||||
await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||
order: [post.id],
|
||||
posts: [post],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
const translation: PostTranslation = {object: {message: 'Hola'}, state: 'ready', source_lang: 'en'};
|
||||
const result = await updatePostTranslation(serverUrl, post.id, 'es', translation);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const updatedPost = await getPostById(operator.database, post.id);
|
||||
expect(updatedPost).toBeDefined();
|
||||
expect(updatedPost?.metadata?.translations?.es).toEqual(translation);
|
||||
});
|
||||
|
||||
it('handle database write error', async () => {
|
||||
await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||
order: [post.id],
|
||||
posts: [post],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
const originalWrite = operator.database.write;
|
||||
operator.database.write = jest.fn().mockRejectedValue(new Error('Write failed'));
|
||||
|
||||
const translation: PostTranslation = {object: {message: 'Hola'}, state: 'ready', source_lang: 'en'};
|
||||
const result = await updatePostTranslation(serverUrl, post.id, 'es', translation);
|
||||
|
||||
operator.database.write = originalWrite;
|
||||
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {countUsersFromMentions, getPostById, prepareDeletePost, queryPostsById}
|
|||
import {getCurrentUserId} from '@queries/servers/system';
|
||||
import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread';
|
||||
import {generateId} from '@utils/general';
|
||||
import {safeParseJSON} from '@utils/helpers';
|
||||
import {logError} from '@utils/log';
|
||||
import {getLastFetchedAtFromPosts} from '@utils/post';
|
||||
import {getPostIdsForCombinedUserActivityPost} from '@utils/post_list';
|
||||
|
|
@ -390,3 +391,31 @@ export function getUsersCountFromMentions(serverUrl: string, mentions: string[])
|
|||
return Promise.resolve(0);
|
||||
}
|
||||
}
|
||||
|
||||
export const updatePostTranslation = async (serverUrl: string, postId: string, language: string, translation: PostTranslation) => {
|
||||
try {
|
||||
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
const post = await getPostById(database, postId);
|
||||
if (!post) {
|
||||
return {error: 'Post not found'};
|
||||
}
|
||||
await database.write(async () => {
|
||||
post.update((v) => {
|
||||
// At this point, the metadata is a string, so we need to parse it
|
||||
let metadata: PostMetadata = safeParseJSON(v.metadata as string) as PostMetadata;
|
||||
if (!metadata) {
|
||||
metadata = {};
|
||||
}
|
||||
if (!metadata.translations) {
|
||||
metadata.translations = {};
|
||||
}
|
||||
metadata.translations[language] = translation;
|
||||
v.metadata = JSON.stringify(metadata) as any;
|
||||
});
|
||||
});
|
||||
return {error: undefined};
|
||||
} catch (error) {
|
||||
logError('Failed updatePostTranslation', error);
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -342,5 +342,29 @@ describe('session actions', () => {
|
|||
|
||||
expect(resetMomentLocale).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should remove user credentials but preserve pre-auth secret on logout (removeServer=false)', async () => {
|
||||
await terminateSession(mockServerUrl, false);
|
||||
|
||||
// Verify user credentials are removed
|
||||
expect(removeServerCredentials).toHaveBeenCalledWith(mockServerUrl);
|
||||
|
||||
// Verify database is deleted (not destroyed)
|
||||
expect(DatabaseManager.deleteServerDatabase).toHaveBeenCalledWith(mockServerUrl);
|
||||
expect(DatabaseManager.destroyServerDatabase).not.toHaveBeenCalled();
|
||||
|
||||
// Note: removeServerCredentials now only removes the token, not the pre-auth secret
|
||||
// The pre-auth secret is only removed when destroyServerDatabase is called
|
||||
});
|
||||
|
||||
it('should call destroyServerDatabase which removes pre-auth secret on server removal (removeServer=true)', async () => {
|
||||
await terminateSession(mockServerUrl, true);
|
||||
|
||||
// Verify database is destroyed (which will remove pre-auth secret internally)
|
||||
expect(DatabaseManager.destroyServerDatabase).toHaveBeenCalledWith(mockServerUrl);
|
||||
expect(DatabaseManager.deleteServerDatabase).not.toHaveBeenCalled();
|
||||
|
||||
// Note: destroyServerDatabase internally calls removePreauthSecret
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import PostModel from '@typings/database/models/servers/post';
|
|||
import {isExpiredBoRPost} from '@utils/bor';
|
||||
import {logError} from '@utils/log';
|
||||
|
||||
import {deletePostsForChannelsWithAutotranslation} from './channel';
|
||||
import {deletePosts} from './post';
|
||||
|
||||
import type {DataRetentionPoliciesRequest} from '@actions/remote/systems';
|
||||
|
|
@ -71,6 +72,9 @@ export async function storeConfig(serverUrl: string, config: ClientConfig | unde
|
|||
const configsToUpdate: IdValue[] = [];
|
||||
const configsToDelete: IdValue[] = [];
|
||||
|
||||
// Check if EnableAutoTranslation changed from enabled to disabled
|
||||
const enableAutoTranslationChanged = (currentConfig?.EnableAutoTranslation === 'true') !== (config.EnableAutoTranslation === 'true');
|
||||
|
||||
let k: keyof ClientConfig;
|
||||
for (k in config) {
|
||||
if (currentConfig?.[k] !== config[k]) {
|
||||
|
|
@ -92,6 +96,12 @@ export async function storeConfig(serverUrl: string, config: ClientConfig | unde
|
|||
if (configsToDelete.length || configsToUpdate.length) {
|
||||
const results = await operator.handleConfigs({configs: configsToUpdate, configsToDelete, prepareRecordsOnly});
|
||||
DeviceEventEmitter.emit(Events.CONFIG_CHANGED, {serverUrl, config});
|
||||
|
||||
// If EnableAutoTranslation was disabled, delete posts and disable user autotranslation
|
||||
if (enableAutoTranslationChanged) {
|
||||
await deletePostsForChannelsWithAutotranslation(serverUrl, prepareRecordsOnly);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@
|
|||
/* eslint-disable max-lines */
|
||||
|
||||
import {createIntl} from 'react-intl';
|
||||
import {DeviceEventEmitter} from 'react-native';
|
||||
|
||||
import {DeepLink} from '@constants';
|
||||
import {ActionType, DeepLink, Events} from '@constants';
|
||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import NetworkManager from '@managers/network_manager';
|
||||
import TestHelper from '@test/test_helper';
|
||||
|
||||
import {
|
||||
removeMemberFromChannel,
|
||||
|
|
@ -55,6 +57,8 @@ import {
|
|||
handleKickFromChannel,
|
||||
fetchGroupMessageMembersCommonTeams,
|
||||
convertGroupMessageToPrivateChannel,
|
||||
setChannelAutotranslation,
|
||||
setMyChannelAutotranslation,
|
||||
} from './channel';
|
||||
|
||||
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||
|
|
@ -134,6 +138,8 @@ const mockClient = {
|
|||
posts: [],
|
||||
previousPostId: '',
|
||||
})),
|
||||
setChannelAutotranslation: jest.fn(),
|
||||
setMyChannelAutotranslation: jest.fn(),
|
||||
};
|
||||
|
||||
const teamId = 'teamid1';
|
||||
|
|
@ -145,7 +151,6 @@ const intl = createIntl({
|
|||
});
|
||||
|
||||
beforeAll(() => {
|
||||
// eslint-disable-next-line
|
||||
// @ts-ignore
|
||||
NetworkManager.getClient = () => mockClient;
|
||||
});
|
||||
|
|
@ -765,3 +770,59 @@ describe('direct and group', () => {
|
|||
expect(updatedChannel).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setChannelAutotranslation', () => {
|
||||
it('should update channel and call deletePostsForChannel when disabling autotranslation', async () => {
|
||||
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
|
||||
const channelWithAutotranslation = TestHelper.fakeChannel({id: channelId, team_id: teamId, autotranslation: true});
|
||||
await operator.handleChannel({channels: [channelWithAutotranslation], prepareRecordsOnly: false});
|
||||
await operator.handlePosts({actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, order: [post.id], posts: [post], prepareRecordsOnly: false});
|
||||
jest.mocked(mockClient.setChannelAutotranslation).mockResolvedValue({id: channelId, autotranslation: false} as Channel);
|
||||
|
||||
const result = await setChannelAutotranslation(serverUrl, channelId, false);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.channel).toBeDefined();
|
||||
expect(result.channel!.autotranslation).toBe(false);
|
||||
expect(mockClient.setChannelAutotranslation).toHaveBeenCalledWith(channelId, false);
|
||||
|
||||
expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId, teamId});
|
||||
});
|
||||
|
||||
it('should handle client error', async () => {
|
||||
jest.mocked(mockClient.setChannelAutotranslation).mockRejectedValueOnce(new Error('API error'));
|
||||
|
||||
const result = await setChannelAutotranslation(serverUrl, channelId, true);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(DeviceEventEmitter.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setMyChannelAutotranslation', () => {
|
||||
it('should update myChannel and call deletePostsForChannel when autotranslation changes', async () => {
|
||||
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
|
||||
const channel = TestHelper.fakeChannel({id: channelId, team_id: teamId});
|
||||
const myChannelMember = TestHelper.fakeChannelMember({channel_id: channelId, autotranslation_disabled: false});
|
||||
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
|
||||
await operator.handleMyChannel({channels: [channel], myChannels: [myChannelMember], prepareRecordsOnly: false});
|
||||
await operator.handlePosts({actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, order: [post.id], posts: [post], prepareRecordsOnly: false});
|
||||
jest.mocked(mockClient.setMyChannelAutotranslation).mockResolvedValue({} as ChannelMembership);
|
||||
|
||||
const result = await setMyChannelAutotranslation(serverUrl, channelId, false);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.data).toBe(true);
|
||||
expect(mockClient.setMyChannelAutotranslation).toHaveBeenCalledWith(channelId, false);
|
||||
expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId, teamId});
|
||||
});
|
||||
|
||||
it('should handle client error', async () => {
|
||||
jest.mocked(mockClient.setMyChannelAutotranslation).mockRejectedValueOnce(new Error('API error'));
|
||||
|
||||
const result = await setMyChannelAutotranslation(serverUrl, channelId, true);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(DeviceEventEmitter.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import {DeviceEventEmitter} from 'react-native';
|
||||
|
||||
import {addChannelToDefaultCategory, handleConvertedGMCategories, storeCategories} from '@actions/local/category';
|
||||
import {markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeAllMyChannels, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel';
|
||||
import {markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeAllMyChannels, storeMyChannelsForTeam, switchToChannel, deletePostsForChannel} from '@actions/local/channel';
|
||||
import {switchToGlobalDrafts} from '@actions/local/draft';
|
||||
import {switchToGlobalThreads} from '@actions/local/thread';
|
||||
import {loadCallForChannel} from '@calls/actions/calls';
|
||||
|
|
@ -272,18 +272,21 @@ export async function patchChannel(serverUrl: string, channelId: string, channel
|
|||
}
|
||||
|
||||
const channel = await getChannelById(database, channelData.id);
|
||||
if (channel && (channel.displayName !== channelData.display_name || channel.type !== channelData.type)) {
|
||||
if (channel && (channel.displayName !== channelData.display_name || channel.type !== channelData.type || channel.autotranslation !== channelData.autotranslation)) {
|
||||
channel.prepareUpdate((v) => {
|
||||
// DM and GM display names cannot be patched and are formatted client-side; do not overwrite
|
||||
if (channelData.type !== General.DM_CHANNEL && channelData.type !== General.GM_CHANNEL) {
|
||||
v.displayName = channelData.display_name;
|
||||
}
|
||||
v.type = channelData.type;
|
||||
if (channelData.autotranslation !== undefined) {
|
||||
v.autotranslation = channelData.autotranslation;
|
||||
}
|
||||
});
|
||||
models.push(channel);
|
||||
}
|
||||
if (models?.length) {
|
||||
await operator.batchRecords(models.flat(), 'patchChannel');
|
||||
await operator.batchRecords(models, 'patchChannel');
|
||||
}
|
||||
return {channel: channelData};
|
||||
} catch (error) {
|
||||
|
|
@ -1243,6 +1246,76 @@ export const updateChannelNotifyProps = async (serverUrl: string, channelId: str
|
|||
}
|
||||
};
|
||||
|
||||
export const setChannelAutotranslation = async (serverUrl: string, channelId: string, enabled: boolean) => {
|
||||
try {
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
|
||||
const channelData = await client.setChannelAutotranslation(channelId, enabled);
|
||||
const models = [];
|
||||
|
||||
const channel = await getChannelById(database, channelData.id);
|
||||
const autotranslationDisabled = channel && channel.autotranslation && !channelData.autotranslation;
|
||||
|
||||
if (channel && channel.autotranslation !== channelData.autotranslation) {
|
||||
channel.prepareUpdate((v) => {
|
||||
v.autotranslation = channelData.autotranslation ?? false;
|
||||
});
|
||||
models.push(channel);
|
||||
}
|
||||
|
||||
if (models?.length) {
|
||||
await operator.batchRecords(models, 'setChannelAutotranslation');
|
||||
}
|
||||
|
||||
// Delete posts when autotranslation setting changes
|
||||
if (autotranslationDisabled) {
|
||||
await deletePostsForChannel(serverUrl, channelData.id);
|
||||
}
|
||||
|
||||
return {channel: channelData};
|
||||
} catch (error) {
|
||||
logDebug('error on setChannelAutotranslation', getFullErrorMessage(error));
|
||||
forceLogoutIfNecessary(serverUrl, error);
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
export const setMyChannelAutotranslation = async (serverUrl: string, channelId: string, enabled: boolean) => {
|
||||
try {
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
|
||||
await client.setMyChannelAutotranslation(channelId, enabled);
|
||||
const models = [];
|
||||
|
||||
const myChannel = await getMyChannel(database, channelId);
|
||||
const autotranslationChanged = myChannel && myChannel.autotranslationDisabled !== !enabled;
|
||||
|
||||
if (myChannel && autotranslationChanged) {
|
||||
myChannel.prepareUpdate((v) => {
|
||||
v.autotranslationDisabled = !enabled;
|
||||
});
|
||||
models.push(myChannel);
|
||||
}
|
||||
|
||||
if (models?.length) {
|
||||
await operator.batchRecords(models, 'setMyChannelAutotranslation');
|
||||
}
|
||||
|
||||
// Delete posts when autotranslation setting changes
|
||||
if (autotranslationChanged) {
|
||||
await deletePostsForChannel(serverUrl, channelId);
|
||||
}
|
||||
|
||||
return {data: true};
|
||||
} catch (error) {
|
||||
logDebug('error on setMyChannelAutotranslation', getFullErrorMessage(error));
|
||||
forceLogoutIfNecessary(serverUrl, error);
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleMuteChannel = async (serverUrl: string, channelId: string, showSnackBar = false) => {
|
||||
try {
|
||||
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
|
|
@ -1395,6 +1468,42 @@ export const fetchGroupMessageMembersCommonTeams = async (serverUrl: string, cha
|
|||
}
|
||||
};
|
||||
|
||||
export async function switchToChannelByName(serverUrl: string, channelName: string, teamName?: string) {
|
||||
try {
|
||||
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
|
||||
let teamId = '';
|
||||
if (teamName) {
|
||||
const team = await getTeamByName(database, teamName);
|
||||
teamId = team?.id || '';
|
||||
}
|
||||
if (!teamId) {
|
||||
teamId = await getCurrentTeamId(database) || '';
|
||||
}
|
||||
|
||||
let channelId = '';
|
||||
const channel = await getChannelByName(database, teamId, channelName);
|
||||
if (channel) {
|
||||
channelId = channel.id;
|
||||
} else {
|
||||
const fetchResult = await fetchChannelByName(serverUrl, teamId, channelName, true);
|
||||
if (fetchResult.channel) {
|
||||
channelId = fetchResult.channel.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (channelId) {
|
||||
await switchToChannelById(serverUrl, channelId, teamId);
|
||||
}
|
||||
|
||||
return {};
|
||||
} catch (error) {
|
||||
logDebug('error on switchToChannelByName', getFullErrorMessage(error));
|
||||
forceLogoutIfNecessary(serverUrl, error);
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
|
||||
export const convertGroupMessageToPrivateChannel = async (serverUrl: string, channelId: string, targetTeamId: string, displayName: string) => {
|
||||
try {
|
||||
const name = generateChannelNameFromDisplayName(displayName);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {fetchAgents} from '@agents/actions/remote/agents';
|
||||
|
||||
import {setLastServerVersionCheck} from '@actions/local/systems';
|
||||
import {fetchConfigAndLicense} from '@actions/remote/systems';
|
||||
import DatabaseManager from '@database/manager';
|
||||
|
|
@ -35,6 +37,9 @@ export async function appEntry(serverUrl: string, since = 0) {
|
|||
|
||||
verifyPushProxy(serverUrl);
|
||||
|
||||
// Fetch agents to determine if AI features are available
|
||||
fetchAgents(serverUrl);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import {nativeApplicationVersion} from 'expo-application';
|
||||
import {RESULTS, checkNotifications} from 'react-native-permissions';
|
||||
|
||||
import {deletePostsForChannel, deletePostsForChannelsWithAutotranslation} from '@actions/local/channel';
|
||||
import {fetchChannelById, fetchMyChannelsForTeam, handleKickFromChannel, type MyChannelsRequest} from '@actions/remote/channel';
|
||||
import {type MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference';
|
||||
import {fetchConfigAndLicense, fetchDataRetentionPolicy} from '@actions/remote/systems';
|
||||
|
|
@ -18,11 +19,12 @@ import {selectDefaultTeam} from '@helpers/api/team';
|
|||
import {DEFAULT_LOCALE} from '@i18n';
|
||||
import NetworkManager from '@managers/network_manager';
|
||||
import {getDeviceToken} from '@queries/app/global';
|
||||
import {getChannelById} from '@queries/servers/channel';
|
||||
import {getChannelById, queryChannelsById, queryMyChannelsByChannelIds} from '@queries/servers/channel';
|
||||
import {prepareEntryModels, truncateCrtRelatedTables} from '@queries/servers/entry';
|
||||
import {getHasCRTChanged} from '@queries/servers/preference';
|
||||
import {getCurrentChannelId, getCurrentTeamId, getIsDataRetentionEnabled, getPushVerificationStatus, getLastFullSync, setCurrentTeamAndChannelId, getConfigValue} from '@queries/servers/system';
|
||||
import {getTeamChannelHistory} from '@queries/servers/team';
|
||||
import {getCurrentUser} from '@queries/servers/user';
|
||||
import NavigationStore from '@store/navigation_store';
|
||||
import {isDefaultChannel, isDMorGM, sortChannelsByDisplayName} from '@utils/channel';
|
||||
import {getFullErrorMessage} from '@utils/errors';
|
||||
|
|
@ -179,6 +181,8 @@ const entryRest = async (serverUrl: string, teamId?: string, channelId?: string,
|
|||
|
||||
const dt = Date.now();
|
||||
|
||||
await handleAutotranslationChanges(serverUrl, meData, chData);
|
||||
|
||||
const modelPromises = await prepareEntryModels({operator, teamData: initialTeamData, chData, prefData, meData, isCRTEnabled});
|
||||
const models = (await Promise.all(modelPromises)).flat();
|
||||
logDebug('Process models on entry', groupLabel, models.length, `${Date.now() - dt}ms`);
|
||||
|
|
@ -190,6 +194,65 @@ const entryRest = async (serverUrl: string, teamId?: string, channelId?: string,
|
|||
}
|
||||
};
|
||||
|
||||
export async function handleAutotranslationChanges(serverUrl: string, meData: MyUserRequest | undefined, chData: MyChannelsRequest | undefined) {
|
||||
try {
|
||||
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
|
||||
// Config changes already handled in storeConfigAndLicense
|
||||
const enableAutoTranslation = (await getConfigValue(operator.database, 'EnableAutoTranslation')) === 'true';
|
||||
if (!enableAutoTranslation) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If user locale changed, delete all the posts for all channels with autotranslation enabled
|
||||
// so the posts can be refetch with the new translation
|
||||
if (meData) {
|
||||
const currentUser = await getCurrentUser(database);
|
||||
if (currentUser && meData.user?.locale !== currentUser.locale) {
|
||||
await deletePostsForChannelsWithAutotranslation(serverUrl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (chData) {
|
||||
// If a channel stop being autotranslated by the admin, delete the posts for that channel
|
||||
// so the posts can be refetch without the translation
|
||||
const newChannels = chData.channels || [];
|
||||
const channels = await queryChannelsById(database, newChannels.map((c) => c.id)).fetch();
|
||||
const chMap = new Map(channels.map((c) => [c.id, c]));
|
||||
const promises = [];
|
||||
for (const ch of newChannels) {
|
||||
const channel = chMap.get(ch.id);
|
||||
if (channel && channel.autotranslation && !ch.autotranslation) { // Autotranslation disabled
|
||||
promises.push(deletePostsForChannel(serverUrl, ch.id, true));
|
||||
}
|
||||
}
|
||||
const chModels = (await Promise.all(promises)).map((m) => m.models).flat();
|
||||
if (chModels.length) {
|
||||
await operator.batchRecords(chModels, 'handleAutotranslationChanges');
|
||||
}
|
||||
|
||||
// If a channel starts or stops being autotranslated by the user, delete the posts for that channel
|
||||
// so the posts can be refetch with or without the translation
|
||||
const newMemberships = chData.memberships || [];
|
||||
const memberships = await queryMyChannelsByChannelIds(database, newMemberships.map((m) => m.channel_id)).fetch();
|
||||
const membershipMap = new Map(memberships.map((m) => [m.id, m]));
|
||||
for (const m of newMemberships) {
|
||||
const membership = membershipMap.get(m.channel_id);
|
||||
if (membership && membership.autotranslationDisabled !== Boolean(m.autotranslation_disabled)) { // Autotranslation modified
|
||||
promises.push(deletePostsForChannel(serverUrl, m.channel_id, true));
|
||||
}
|
||||
}
|
||||
const membershipModels = (await Promise.all(promises)).map((m) => m.models).flat();
|
||||
if (membershipModels.length) {
|
||||
await operator.batchRecords(membershipModels, 'handleAutotranslationChanges');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logError('handleAutotranslationChanges', getFullErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function entryInitialChannelId(database: Database, requestedChannelId = '', requestedTeamId = '', initialTeamId: string, locale: string, channels?: Channel[], memberships?: ChannelMember[]) {
|
||||
const membershipIds = new Set(memberships?.map((m) => m.channel_id));
|
||||
const requestedChannel = channels?.find((c) => (c.id === requestedChannelId) && membershipIds.has(c.id));
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {act} from '@testing-library/react-native';
|
|||
import {fetchMyChannelsForTeam, fetchMissingDirectChannelsInfo, type MyChannelsRequest} from '@actions/remote/channel';
|
||||
import {fetchGroupsForMember} from '@actions/remote/groups';
|
||||
import {fetchPostsForUnreadChannels} from '@actions/remote/post';
|
||||
import {fetchRoles} from '@actions/remote/role';
|
||||
import {fetchScheduledPosts} from '@actions/remote/scheduled_post';
|
||||
import {fetchTeamsThreads, updateCanJoinTeams, type MyTeamsRequest} from '@actions/remote/team';
|
||||
import {autoUpdateTimezone, updateAllUsersSince, type MyUserRequest} from '@actions/remote/user';
|
||||
|
|
@ -18,6 +19,7 @@ import {processEntryModels, processEntryModelsForDeletion} from '@queries/server
|
|||
import {deferredAppEntryActions, restDeferredAppEntryActions, testExports} from './deferred';
|
||||
|
||||
jest.mock('@actions/remote/channel');
|
||||
jest.mock('@actions/remote/role');
|
||||
jest.mock('@actions/remote/scheduled_post');
|
||||
jest.mock('@actions/remote/team');
|
||||
jest.mock('@actions/remote/user');
|
||||
|
|
@ -153,6 +155,7 @@ describe('actions/remote/entry/deferred', () => {
|
|||
expect(fetchPostsForUnreadChannels).toHaveBeenCalled();
|
||||
expect(fetchGroupsForMember).toHaveBeenCalledWith(serverUrl, currentUserId, false, undefined);
|
||||
expect(fetchScheduledPosts).toHaveBeenCalledWith(serverUrl, initialTeamId, true, undefined);
|
||||
expect(fetchRoles).toHaveBeenCalledWith(serverUrl, defaultTeamData.memberships, defaultChData.memberships, defaultMeData.user, false, true, undefined);
|
||||
});
|
||||
|
||||
it('should handle missing data gracefully', async () => {
|
||||
|
|
@ -335,7 +338,7 @@ describe('actions/remote/entry/deferred', () => {
|
|||
);
|
||||
});
|
||||
|
||||
expect(processEntryModels).toHaveBeenCalledWith(
|
||||
expect(processEntryModels).toHaveBeenCalledWith(serverUrl,
|
||||
expect.objectContaining({
|
||||
teamData: expect.objectContaining({
|
||||
teams: expect.arrayContaining([
|
||||
|
|
@ -345,7 +348,7 @@ describe('actions/remote/entry/deferred', () => {
|
|||
}),
|
||||
);
|
||||
|
||||
expect(processEntryModels).toHaveBeenCalledWith(
|
||||
expect(processEntryModels).toHaveBeenCalledWith(serverUrl,
|
||||
expect.objectContaining({
|
||||
teamData: expect.objectContaining({
|
||||
teams: expect.arrayContaining([
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export async function restDeferredAppEntryActions(
|
|||
};
|
||||
|
||||
/* eslint-disable-next-line no-await-in-loop */
|
||||
await processEntryModels({operator, teamData: currentTeamData, chData: data, isCRTEnabled});
|
||||
await processEntryModels(serverUrl, {operator, teamData: currentTeamData, chData: data, isCRTEnabled});
|
||||
}
|
||||
|
||||
const uniqueChannelsData: MyChannelsRequest = {
|
||||
|
|
@ -116,7 +116,7 @@ export async function restDeferredAppEntryActions(
|
|||
|
||||
const processFinalInitializationTasks = async (uniqueChannelsData: MyChannelsRequest) => {
|
||||
try {
|
||||
fetchRoles(serverUrl, teamData.memberships, chData?.memberships, meData?.user, false, false, groupLabel);
|
||||
fetchRoles(serverUrl, teamData.memberships, chData?.memberships, meData?.user, false, true, groupLabel);
|
||||
|
||||
if (initialTeamId) {
|
||||
const initialTeam = teamMap.get(initialTeamId);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import {defineMessage} from 'react-intl';
|
||||
|
||||
import * as ClientConstants from '@client/rest/constants';
|
||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import {PUSH_PROXY_RESPONSE_VERIFIED, PUSH_PROXY_STATUS_VERIFIED} from '@constants/push_proxy';
|
||||
import DatabaseManager from '@database/manager';
|
||||
|
|
@ -10,6 +11,7 @@ import NetworkManager from '@managers/network_manager';
|
|||
import {getDeviceToken} from '@queries/app/global';
|
||||
import {getExpandedLinks, getPushVerificationStatus} from '@queries/servers/system';
|
||||
import {getFullErrorMessage} from '@utils/errors';
|
||||
import {getResponseHeader} from '@utils/headers';
|
||||
import {logDebug} from '@utils/log';
|
||||
|
||||
import {forceLogoutIfNecessary} from './session';
|
||||
|
|
@ -75,7 +77,7 @@ export const doPing = async (serverUrl: string, verifyPushProxy: boolean, timeou
|
|||
if (!client) {
|
||||
NetworkManager.invalidateClient(serverUrl);
|
||||
}
|
||||
if (response.code === 403 && response.headers?.['x-reject-reason'] === 'pre-auth') {
|
||||
if (response.code === 403 && getResponseHeader(response.headers, ClientConstants.HEADER_X_REJECT_REASON) === 'pre-auth') {
|
||||
return {error: {intl: pingError}, isPreauthError: true};
|
||||
}
|
||||
return {error: {intl: pingError}};
|
||||
|
|
@ -84,7 +86,7 @@ export const doPing = async (serverUrl: string, verifyPushProxy: boolean, timeou
|
|||
// Check if this is a 403 with pre-auth header
|
||||
const errorObj = error as ClientError;
|
||||
if (errorObj.status_code === 403) {
|
||||
if (errorObj.headers?.['x-reject-reason'] === 'pre-auth') {
|
||||
if (getResponseHeader(errorObj.headers, ClientConstants.HEADER_X_REJECT_REASON) === 'pre-auth') {
|
||||
return {error: {intl: pingError}, isPreauthError: true};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import DatabaseManager from '@database/manager';
|
|||
import PostModel from '@database/models/server/post';
|
||||
import NetworkManager from '@managers/network_manager';
|
||||
import TestHelper from '@test/test_helper';
|
||||
import {getFullErrorMessage} from '@utils/errors';
|
||||
|
||||
import {
|
||||
createPost,
|
||||
|
|
@ -32,6 +33,7 @@ import {
|
|||
fetchPostById,
|
||||
fetchSavedPosts,
|
||||
fetchPinnedPosts,
|
||||
burnPostNow,
|
||||
} from './post';
|
||||
import * as PostAuxilaryFunctions from './post.auxiliary';
|
||||
|
||||
|
|
@ -94,6 +96,7 @@ const mockClient = {
|
|||
pinPost: jest.fn(),
|
||||
unpinPost: jest.fn(),
|
||||
deletePost: jest.fn(),
|
||||
burnPostNow: jest.fn(),
|
||||
getChannel: jest.fn((_channelId: string) => ({id: _channelId, name: 'channel1', creatorId: user1.id, total_msg_count: 100})),
|
||||
getChannelMember: jest.fn((_channelId: string, userId: string) => ({id: userId + '-' + _channelId, user_id: userId, channel_id: _channelId, roles: '', msg_count: 100, mention_count: 0})),
|
||||
getMyChannelMember: jest.fn((_channelId: string) => ({id: user1.id + '-' + _channelId, user_id: user1.id, channel_id: _channelId, roles: '', msg_count: 100, mention_count: 0})),
|
||||
|
|
@ -382,6 +385,51 @@ describe('create, update & delete posts', () => {
|
|||
expect(result.post).toBeDefined();
|
||||
});
|
||||
|
||||
it('burnPostNow - handle error', async () => {
|
||||
mockClient.burnPostNow.mockImplementationOnce(jest.fn(throwFunc));
|
||||
const result = await burnPostNow('foo', {} as PostModel);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('burnPostNow - base case', async () => {
|
||||
mockClient.burnPostNow.mockReset();
|
||||
const borPost = TestHelper.fakePost({channel_id: channelId, id: 'bor_postid1', user_id: user1.id, type: 'burn_on_read'});
|
||||
|
||||
const postModels = await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||
order: [borPost.id],
|
||||
posts: [borPost],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
// verify post exists in database before burn
|
||||
const {database} = operator;
|
||||
const existingPost = await database.get('Post').find(borPost.id);
|
||||
expect(existingPost).toBeDefined();
|
||||
|
||||
const result = await burnPostNow(serverUrl, postModels[0] as PostModel);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.post).toBeDefined();
|
||||
|
||||
// Verify that the post is deleted from the database
|
||||
try {
|
||||
await database.get('Post').find(borPost.id);
|
||||
expect(true).toBe(false); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeDefined();
|
||||
expect(getFullErrorMessage(error)).toBe('Record Post#bor_postid1 not found');
|
||||
}
|
||||
});
|
||||
|
||||
it('burnPostNow - returns error for non-BoR posts', async () => {
|
||||
const result = await burnPostNow('foo', {} as PostModel);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(result.error).toBe('Post is not a Burn-on-Read post');
|
||||
});
|
||||
|
||||
it('deletePost - system post', async () => {
|
||||
const postModels = await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers
|
|||
import {queryAllUsers} from '@queries/servers/user';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
import {setFetchingThreadState} from '@store/fetching_thread_store';
|
||||
import {isBoRPost} from '@utils/bor';
|
||||
import {getValidEmojis, matchEmoticons} from '@utils/emoji/helpers';
|
||||
import {getFullErrorMessage, isServerError} from '@utils/errors';
|
||||
import {hasArrayChanged} from '@utils/helpers';
|
||||
|
|
@ -63,7 +64,7 @@ type AuthorsRequest = {
|
|||
error?: unknown;
|
||||
}
|
||||
|
||||
export async function createPost(serverUrl: string, post: Partial<Post>, files: FileInfo[] = []): Promise<{data?: boolean; error?: unknown}> {
|
||||
export async function createPost(serverUrl: string, post: Partial<Post>, files: FileInfo[] = []): Promise<{data?: boolean; post?: Post; error?: unknown}> {
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
if (!operator) {
|
||||
return {error: `${serverUrl} database not found`};
|
||||
|
|
@ -204,7 +205,7 @@ export async function createPost(serverUrl: string, post: Partial<Post>, files:
|
|||
|
||||
newPost = created;
|
||||
|
||||
return {data: true};
|
||||
return {data: true, post: created};
|
||||
}
|
||||
|
||||
export const retryFailedPost = async (serverUrl: string, post: PostModel) => {
|
||||
|
|
@ -873,6 +874,24 @@ export const deletePost = async (serverUrl: string, postToDelete: PostModel | Po
|
|||
}
|
||||
};
|
||||
|
||||
export const burnPostNow = async (serverUrl: string, postToBurn: PostModel | Post) => {
|
||||
if (!isBoRPost(postToBurn)) {
|
||||
return {error: 'Post is not a Burn-on-Read post'};
|
||||
}
|
||||
|
||||
try {
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
await client.burnPostNow(postToBurn.id);
|
||||
|
||||
const post = await removePost(serverUrl, postToBurn);
|
||||
return {post};
|
||||
} catch (error) {
|
||||
logDebug('error on burnPostNow', getFullErrorMessage(error));
|
||||
forceLogoutIfNecessary(serverUrl, error);
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
export const markPostAsUnread = async (serverUrl: string, postId: string) => {
|
||||
try {
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/* eslint-disable max-lines */
|
||||
import {waitFor} from '@testing-library/react-native';
|
||||
|
||||
import {ActionType} from '@constants';
|
||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
|
|
@ -20,8 +20,10 @@ import {
|
|||
loadEarlierThreads,
|
||||
fetchAndSwitchToThread,
|
||||
syncThreadsIfNeeded,
|
||||
batchTeamThreadSync,
|
||||
} from './thread';
|
||||
|
||||
import type {Client} from '@client/rest';
|
||||
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||
|
||||
const serverUrl = 'baseHandler.test.com';
|
||||
|
|
@ -62,14 +64,15 @@ const throwFunc = () => {
|
|||
throw Error('error');
|
||||
};
|
||||
|
||||
const mockClient = {
|
||||
getThread: jest.fn((userId: string, _teamId: string, threadId: string) => ({...thread1, id: threadId})),
|
||||
const partialClient: Partial<Client> = {
|
||||
getThread: jest.fn((userId: string, _teamId: string, threadId: string) => Promise.resolve({...thread1, id: threadId})),
|
||||
updateTeamThreadsAsRead: jest.fn(),
|
||||
markThreadAsRead: jest.fn(),
|
||||
markThreadAsUnread: jest.fn(),
|
||||
updateThreadFollow: jest.fn(),
|
||||
getThreads: jest.fn(() => ({threads})),
|
||||
getThreads: jest.fn(() => Promise.resolve({threads, total: threads.length, total_unread_mentions: 0, total_unread_threads: 0})),
|
||||
};
|
||||
const mockClient = partialClient as Client;
|
||||
|
||||
let mockGetIsCRTEnabled: jest.Mock;
|
||||
jest.mock('@queries/servers/thread', () => {
|
||||
|
|
@ -82,7 +85,7 @@ jest.mock('@queries/servers/thread', () => {
|
|||
});
|
||||
|
||||
beforeAll(() => {
|
||||
// eslint-disable-next-line
|
||||
|
||||
// @ts-ignore
|
||||
NetworkManager.getClient = () => mockClient;
|
||||
});
|
||||
|
|
@ -98,7 +101,7 @@ afterEach(async () => {
|
|||
|
||||
describe('get threads', () => {
|
||||
it('fetchThread - handle error', async () => {
|
||||
mockClient.getThread.mockImplementationOnce(jest.fn(throwFunc));
|
||||
jest.mocked(mockClient.getThread).mockImplementationOnce(jest.fn(throwFunc));
|
||||
const result = await fetchThread(serverUrl, teamId, thread1.id);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.error).toBeDefined();
|
||||
|
|
@ -229,7 +232,7 @@ describe('get threads', () => {
|
|||
|
||||
describe('update threads', () => {
|
||||
it('updateTeamThreadsAsRead - handle error', async () => {
|
||||
mockClient.updateTeamThreadsAsRead.mockImplementationOnce(jest.fn(throwFunc));
|
||||
jest.mocked(mockClient.updateTeamThreadsAsRead).mockImplementationOnce(jest.fn(throwFunc));
|
||||
const result = await updateTeamThreadsAsRead(serverUrl, teamId);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.error).toBeDefined();
|
||||
|
|
@ -302,3 +305,109 @@ describe('update threads', () => {
|
|||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchTeamThreadSync', () => {
|
||||
// We don't use fake timers since they break the
|
||||
// database operations.
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(mockClient.getThreads).mockClear();
|
||||
});
|
||||
|
||||
const teamFilter = (id: string) => {
|
||||
return (call: unknown[]) => call[1] === id && call[6] === undefined;
|
||||
};
|
||||
|
||||
// fetchThreads calls twice the function, one for the unreads and one for
|
||||
// the latests. We expect that part to work correctly and if one is called
|
||||
// for unreads, the one for the latest is also called.
|
||||
const allCallsFilter = (call: unknown[]) => call[6] === undefined;
|
||||
|
||||
it('returns early when CRT is disabled', async () => {
|
||||
mockGetIsCRTEnabled.mockResolvedValueOnce(false);
|
||||
|
||||
await batchTeamThreadSync(serverUrl, teamId);
|
||||
|
||||
await waitFor(() => expect(mockClient.getThreads).not.toHaveBeenCalled(), {timeout: 600});
|
||||
});
|
||||
|
||||
it('schedules sync after timeout when CRT is enabled', async () => {
|
||||
mockGetIsCRTEnabled.mockResolvedValue(true);
|
||||
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
||||
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
|
||||
|
||||
await batchTeamThreadSync(serverUrl, teamId);
|
||||
expect(mockClient.getThreads).not.toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => expect(mockClient.getThreads).toHaveBeenCalled());
|
||||
const getThreadsCalls = jest.mocked(mockClient.getThreads).mock.calls;
|
||||
expect(getThreadsCalls.filter(teamFilter(teamId))).toHaveLength(1);
|
||||
expect(getThreadsCalls.filter(allCallsFilter)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('batches multiple teamIds and syncs each after timeout', async () => {
|
||||
mockGetIsCRTEnabled.mockResolvedValue(true);
|
||||
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
||||
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
|
||||
|
||||
await batchTeamThreadSync(serverUrl, teamId);
|
||||
await batchTeamThreadSync(serverUrl, 'teamid2');
|
||||
expect(mockClient.getThreads).not.toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => expect(mockClient.getThreads).toHaveBeenCalled());
|
||||
const getThreadsCalls = jest.mocked(mockClient.getThreads).mock.calls;
|
||||
expect(getThreadsCalls.filter(teamFilter(teamId))).toHaveLength(1);
|
||||
expect(getThreadsCalls.filter(teamFilter('teamid2'))).toHaveLength(1);
|
||||
expect(getThreadsCalls.filter(allCallsFilter)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('calling several times for the same team id does not create multiple calls', async () => {
|
||||
mockGetIsCRTEnabled.mockResolvedValue(true);
|
||||
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
||||
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
|
||||
|
||||
await batchTeamThreadSync(serverUrl, teamId);
|
||||
await batchTeamThreadSync(serverUrl, teamId);
|
||||
|
||||
await waitFor(() => expect(mockClient.getThreads).toHaveBeenCalled());
|
||||
const getThreadsCalls = jest.mocked(mockClient.getThreads).mock.calls;
|
||||
expect(getThreadsCalls.filter(teamFilter(teamId))).toHaveLength(1);
|
||||
expect(getThreadsCalls.filter(allCallsFilter)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('includes direct channels when empty teamId is in batch only once', async () => {
|
||||
mockGetIsCRTEnabled.mockResolvedValue(true);
|
||||
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
||||
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
|
||||
|
||||
await batchTeamThreadSync(serverUrl, '');
|
||||
await batchTeamThreadSync(serverUrl, teamId);
|
||||
await batchTeamThreadSync(serverUrl, 'teamid2');
|
||||
await batchTeamThreadSync(serverUrl, 'teamid3');
|
||||
|
||||
await waitFor(() => expect(mockClient.getThreads).toHaveBeenCalled());
|
||||
expect(mockClient.getThreads).toHaveBeenCalled();
|
||||
const getThreadsCalls = jest.mocked(mockClient.getThreads).mock.calls;
|
||||
expect(getThreadsCalls.filter(teamFilter(teamId)).length).toBe(1);
|
||||
expect(getThreadsCalls.filter(teamFilter('teamid2')).length).toBe(1);
|
||||
expect(getThreadsCalls.filter(teamFilter('teamid3')).length).toBe(1);
|
||||
expect(getThreadsCalls.filter(allCallsFilter)).toHaveLength(3);
|
||||
expect(getThreadsCalls.filter((call) => call[10] === false && call[6] === undefined).length).toBe(1);
|
||||
});
|
||||
|
||||
it('logs error when syncTeamThreads returns error', async () => {
|
||||
mockGetIsCRTEnabled.mockResolvedValue(true);
|
||||
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
||||
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
|
||||
const logErrorSpy = jest.spyOn(require('@utils/log'), 'logError').mockImplementation(() => {});
|
||||
jest.mocked(mockClient.getThreads).mockImplementationOnce(() => {
|
||||
throw new Error('sync failed');
|
||||
});
|
||||
|
||||
await batchTeamThreadSync(serverUrl, teamId);
|
||||
|
||||
await waitFor(() => expect(logErrorSpy).toHaveBeenCalled());
|
||||
expect(logErrorSpy).toHaveBeenCalledWith('batchTeamThreadSync: Error', expect.any(Error));
|
||||
logErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -530,3 +530,53 @@ export const loadEarlierThreads = async (serverUrl: string, teamId: string, last
|
|||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
const handlePostDeletedForChannelIfNeededBatch: Record<string, {
|
||||
timeout: NodeJS.Timeout | undefined;
|
||||
teamIds: Set<string>;
|
||||
}> = {};
|
||||
|
||||
const handlePostDeletedForChannelIfNeededBatchTimeout = 500;
|
||||
|
||||
export const batchTeamThreadSync = async (serverUrl: string, teamId: string) => {
|
||||
try {
|
||||
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
const isCRTEnabled = await getIsCRTEnabled(database);
|
||||
if (!isCRTEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let existingBatch = handlePostDeletedForChannelIfNeededBatch[serverUrl];
|
||||
if (!existingBatch) {
|
||||
existingBatch = {
|
||||
timeout: undefined,
|
||||
teamIds: new Set(),
|
||||
};
|
||||
handlePostDeletedForChannelIfNeededBatch[serverUrl] = existingBatch;
|
||||
}
|
||||
|
||||
clearTimeout(existingBatch.timeout);
|
||||
|
||||
existingBatch.teamIds.add(teamId);
|
||||
|
||||
existingBatch.timeout = setTimeout(async () => {
|
||||
const batch = handlePostDeletedForChannelIfNeededBatch[serverUrl];
|
||||
if (!batch) {
|
||||
return;
|
||||
}
|
||||
delete handlePostDeletedForChannelIfNeededBatch[serverUrl];
|
||||
|
||||
const includeDirect = batch.teamIds.has('');
|
||||
const teamIds = Array.from(batch.teamIds).filter((t) => t !== '');
|
||||
const promises = teamIds.map((t, idx) => syncTeamThreads(serverUrl, t, {excludeDirect: !includeDirect || idx !== 0, refresh: true, fetchOnly: false}));
|
||||
const results = await Promise.all(promises);
|
||||
for (const r of results) {
|
||||
if (r.error) {
|
||||
logError('batchTeamThreadSync: Error', r.error);
|
||||
}
|
||||
}
|
||||
}, handlePostDeletedForChannelIfNeededBatchTimeout);
|
||||
} catch (error) {
|
||||
logError('handlePostDeletedForChannelIfNeeded: Error', error);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import {chunk} from 'lodash';
|
||||
|
||||
import {updateChannelsDisplayName} from '@actions/local/channel';
|
||||
import {updateChannelsDisplayName, deletePostsForChannelsWithAutotranslation} from '@actions/local/channel';
|
||||
import {updateRecentCustomStatuses, updateLocalUser} from '@actions/local/user';
|
||||
import {fetchRolesIfNeeded} from '@actions/remote/role';
|
||||
import {General} from '@constants';
|
||||
|
|
@ -15,7 +15,7 @@ import NetworkManager from '@managers/network_manager';
|
|||
import {getMembersCountByChannelsId, queryChannelsByTypes} from '@queries/servers/channel';
|
||||
import {queryGroupsByNames} from '@queries/servers/group';
|
||||
import {getCurrentUserId, setCurrentUserId} from '@queries/servers/system';
|
||||
import {getCurrentUser, prepareUsers, queryAllUsers, queryUsersById, queryUsersByIdsOrUsernames, queryUsersByUsername} from '@queries/servers/user';
|
||||
import {getCurrentUser, getUserById, prepareUsers, queryAllUsers, queryUsersById, queryUsersByIdsOrUsernames, queryUsersByUsername} from '@queries/servers/user';
|
||||
import {getFullErrorMessage} from '@utils/errors';
|
||||
import {logDebug} from '@utils/log';
|
||||
import {getDeviceTimezone} from '@utils/timezone';
|
||||
|
|
@ -47,7 +47,7 @@ export type ProfilesInChannelRequest = {
|
|||
export const fetchMe = async (serverUrl: string, fetchOnly = false, groupLabel?: RequestGroupLabel): Promise<MyUserRequest> => {
|
||||
try {
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
|
||||
const resultSettled = await Promise.allSettled([client.getMe(groupLabel), client.getStatus('me', groupLabel)]);
|
||||
let user: UserProfile|undefined;
|
||||
|
|
@ -71,6 +71,14 @@ export const fetchMe = async (serverUrl: string, fetchOnly = false, groupLabel?:
|
|||
|
||||
if (!fetchOnly) {
|
||||
await operator.handleUsers({users: [user], prepareRecordsOnly: false});
|
||||
|
||||
const currentUser = await getUserById(database, user.id);
|
||||
if (currentUser) {
|
||||
const localeChanged = currentUser.locale !== user.locale;
|
||||
if (localeChanged) {
|
||||
await deletePostsForChannelsWithAutotranslation(serverUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {user};
|
||||
|
|
|
|||
|
|
@ -3,16 +3,21 @@
|
|||
|
||||
import {removePost} from '@actions/local/post';
|
||||
import {handleNewPostEvent, handlePostEdited} from '@actions/websocket/posts';
|
||||
import {ActionType} from '@constants';
|
||||
import {PostTypes} from '@constants/post';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {getPostById} from '@queries/servers/post';
|
||||
import {getCurrentUser} from '@queries/servers/user';
|
||||
import TestHelper from '@test/test_helper';
|
||||
|
||||
import {handleBoRPostRevealedEvent, handleBoRPostBurnedEvent} from './burn_on_read';
|
||||
import {handleBoRPostRevealedEvent, handleBoRPostBurnedEvent, handleBoRPostAllRevealed} from './burn_on_read';
|
||||
|
||||
import type {ServerDatabase} from '@typings/database/database';
|
||||
|
||||
jest.mock('@actions/websocket/posts');
|
||||
jest.mock('@queries/servers/post');
|
||||
jest.mock('@actions/local/post');
|
||||
jest.mock('@queries/servers/user');
|
||||
|
||||
const serverUrl = 'burnOnRead.test.com';
|
||||
|
||||
|
|
@ -23,6 +28,7 @@ describe('WebSocket Burn on Read Actions', () => {
|
|||
const mockedHandleNewPostEvent = jest.mocked(handleNewPostEvent);
|
||||
const mockedHandlePostEdited = jest.mocked(handlePostEdited);
|
||||
const mockedRemovePost = jest.mocked(removePost);
|
||||
const mockedGetCurrentUser = jest.mocked(getCurrentUser);
|
||||
|
||||
beforeEach(async () => {
|
||||
await DatabaseManager.init([serverUrl]);
|
||||
|
|
@ -57,10 +63,149 @@ describe('WebSocket Burn on Read Actions', () => {
|
|||
await handleBoRPostRevealedEvent(serverUrl, msg);
|
||||
|
||||
expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1');
|
||||
expect(mockedHandlePostEdited).toHaveBeenCalledWith(serverUrl, msg);
|
||||
expect(mockedHandleNewPostEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should merge recipients from message with existing post recipients', async () => {
|
||||
const existingPost = TestHelper.fakePostModel({
|
||||
id: 'post1',
|
||||
metadata: {recipients: ['user1', 'user2']},
|
||||
});
|
||||
mockedGetPostById.mockResolvedValue(existingPost);
|
||||
|
||||
const msgWithRecipients = {
|
||||
data: {
|
||||
post: JSON.stringify(post),
|
||||
recipients: ['user3', 'user4'],
|
||||
},
|
||||
} as WebSocketMessage;
|
||||
|
||||
await handleBoRPostRevealedEvent(serverUrl, msgWithRecipients);
|
||||
|
||||
expect(mockedHandlePostEdited).toHaveBeenCalled();
|
||||
const calledMsg = mockedHandlePostEdited.mock.calls[0][1];
|
||||
const updatedPost = JSON.parse(calledMsg.data.post);
|
||||
expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2', 'user3', 'user4']);
|
||||
});
|
||||
|
||||
it('should deduplicate recipients when merging', async () => {
|
||||
const existingPost = TestHelper.fakePostModel({
|
||||
id: 'post1',
|
||||
metadata: {recipients: ['user1', 'user2']},
|
||||
});
|
||||
mockedGetPostById.mockResolvedValue(existingPost);
|
||||
|
||||
const msgWithDuplicates = {
|
||||
data: {
|
||||
post: JSON.stringify(post),
|
||||
recipients: ['user2', 'user3'],
|
||||
},
|
||||
} as WebSocketMessage;
|
||||
|
||||
await handleBoRPostRevealedEvent(serverUrl, msgWithDuplicates);
|
||||
|
||||
expect(mockedHandlePostEdited).toHaveBeenCalled();
|
||||
const calledMsg = mockedHandlePostEdited.mock.calls[0][1];
|
||||
const updatedPost = JSON.parse(calledMsg.data.post);
|
||||
expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2', 'user3']);
|
||||
});
|
||||
|
||||
it('should handle existing post without recipients metadata', async () => {
|
||||
const existingPost = TestHelper.fakePostModel({
|
||||
id: 'post1',
|
||||
metadata: {},
|
||||
});
|
||||
mockedGetPostById.mockResolvedValue(existingPost);
|
||||
|
||||
const msgWithRecipients = {
|
||||
data: {
|
||||
post: JSON.stringify(post),
|
||||
recipients: ['user1', 'user2'],
|
||||
},
|
||||
} as WebSocketMessage;
|
||||
|
||||
await handleBoRPostRevealedEvent(serverUrl, msgWithRecipients);
|
||||
|
||||
expect(mockedHandlePostEdited).toHaveBeenCalled();
|
||||
const calledMsg = mockedHandlePostEdited.mock.calls[0][1];
|
||||
const updatedPost = JSON.parse(calledMsg.data.post);
|
||||
expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2']);
|
||||
});
|
||||
|
||||
it('should preserve existing post metadata when adding recipients', async () => {
|
||||
const postWithMetadata = TestHelper.fakePost({
|
||||
id: 'post1',
|
||||
channel_id: 'channel1',
|
||||
user_id: 'user1',
|
||||
create_at: 12345,
|
||||
message: 'hello',
|
||||
metadata: {embeds: [{type: 'permalink'} as PostEmbed]},
|
||||
});
|
||||
const existingPost = TestHelper.fakePostModel({
|
||||
id: 'post1',
|
||||
metadata: {recipients: ['user1']},
|
||||
});
|
||||
mockedGetPostById.mockResolvedValue(existingPost);
|
||||
|
||||
const msgWithMetadata = {
|
||||
data: {
|
||||
post: JSON.stringify(postWithMetadata),
|
||||
recipients: ['user2'],
|
||||
},
|
||||
} as WebSocketMessage;
|
||||
|
||||
await handleBoRPostRevealedEvent(serverUrl, msgWithMetadata);
|
||||
|
||||
expect(mockedHandlePostEdited).toHaveBeenCalled();
|
||||
const calledMsg = mockedHandlePostEdited.mock.calls[0][1];
|
||||
const updatedPost = JSON.parse(calledMsg.data.post);
|
||||
expect(updatedPost.metadata.embeds).toEqual([{type: 'permalink'}]);
|
||||
expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2']);
|
||||
});
|
||||
|
||||
it('should handle empty recipients array in websocket message', async () => {
|
||||
const existingPost = TestHelper.fakePostModel({
|
||||
id: 'post1',
|
||||
metadata: {recipients: ['user1', 'user2']},
|
||||
});
|
||||
mockedGetPostById.mockResolvedValue(existingPost);
|
||||
|
||||
const msgWithEmptyRecipients = {
|
||||
data: {
|
||||
post: JSON.stringify(post),
|
||||
recipients: [],
|
||||
},
|
||||
} as WebSocketMessage;
|
||||
|
||||
await handleBoRPostRevealedEvent(serverUrl, msgWithEmptyRecipients);
|
||||
|
||||
expect(mockedHandlePostEdited).toHaveBeenCalled();
|
||||
const calledMsg = mockedHandlePostEdited.mock.calls[0][1];
|
||||
const updatedPost = JSON.parse(calledMsg.data.post);
|
||||
expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2']);
|
||||
});
|
||||
|
||||
it('should handle undefined recipients in websocket message', async () => {
|
||||
const existingPost = TestHelper.fakePostModel({
|
||||
id: 'post1',
|
||||
metadata: {recipients: ['user1', 'user2']},
|
||||
});
|
||||
mockedGetPostById.mockResolvedValue(existingPost);
|
||||
|
||||
const msgWithoutRecipients = {
|
||||
data: {
|
||||
post: JSON.stringify(post),
|
||||
},
|
||||
} as WebSocketMessage;
|
||||
|
||||
await handleBoRPostRevealedEvent(serverUrl, msgWithoutRecipients);
|
||||
|
||||
expect(mockedHandlePostEdited).toHaveBeenCalled();
|
||||
const calledMsg = mockedHandlePostEdited.mock.calls[0][1];
|
||||
const updatedPost = JSON.parse(calledMsg.data.post);
|
||||
expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2']);
|
||||
});
|
||||
|
||||
it('should handle malformed post data gracefully', async () => {
|
||||
const malformedMsg = {
|
||||
data: {
|
||||
|
|
@ -186,4 +331,115 @@ describe('WebSocket Burn on Read Actions', () => {
|
|||
expect(mockedRemovePost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleBoRPostAllRevealed', () => {
|
||||
const currentUser = TestHelper.fakeUserModel({id: 'user1'});
|
||||
const burnOnReadPost = TestHelper.fakePostModel({
|
||||
id: 'post1',
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
userId: 'user1',
|
||||
});
|
||||
|
||||
const msg = {
|
||||
data: {
|
||||
post_id: 'post1',
|
||||
sender_expire_at: 67890,
|
||||
},
|
||||
} as WebSocketMessage;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockOperator = {
|
||||
database: {},
|
||||
handlePosts: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
DatabaseManager.serverDatabases[serverUrl] = {operator: mockOperator} as unknown as ServerDatabase;
|
||||
});
|
||||
|
||||
it('should update post with expire_at when user owns the burn-on-read post', async () => {
|
||||
const mockToApi = jest.fn().mockResolvedValue({
|
||||
id: 'post1',
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
user_id: 'user1',
|
||||
metadata: {},
|
||||
});
|
||||
burnOnReadPost.toApi = mockToApi;
|
||||
|
||||
mockedGetPostById.mockResolvedValue(burnOnReadPost);
|
||||
mockedGetCurrentUser.mockResolvedValue(currentUser);
|
||||
|
||||
const result = await handleBoRPostAllRevealed(serverUrl, msg);
|
||||
|
||||
expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1');
|
||||
expect(mockedGetCurrentUser).toHaveBeenCalledWith(expect.any(Object));
|
||||
expect(mockToApi).toHaveBeenCalled();
|
||||
expect(DatabaseManager.serverDatabases[serverUrl]?.operator?.handlePosts).toHaveBeenCalledWith({
|
||||
actionType: ActionType.POSTS.RECEIVED_NEW,
|
||||
order: ['post1'],
|
||||
posts: [{
|
||||
id: 'post1',
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
user_id: 'user1',
|
||||
metadata: {expire_at: 67890},
|
||||
}],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
expect(result).toHaveProperty('post');
|
||||
expect(result!.post!.metadata.expire_at).toBe(67890);
|
||||
});
|
||||
|
||||
it('should return null when post does not exist locally', async () => {
|
||||
mockedGetPostById.mockResolvedValue(undefined);
|
||||
|
||||
const result = await handleBoRPostAllRevealed(serverUrl, msg);
|
||||
|
||||
expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1');
|
||||
expect(mockedGetCurrentUser).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when user does not own the post', async () => {
|
||||
const otherUsersBoRPost = TestHelper.fakePostModel({
|
||||
id: 'post1',
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
userId: 'otheruser1',
|
||||
});
|
||||
mockedGetPostById.mockResolvedValue(otherUsersBoRPost);
|
||||
mockedGetCurrentUser.mockResolvedValue(currentUser);
|
||||
|
||||
const result = await handleBoRPostAllRevealed(serverUrl, msg);
|
||||
|
||||
expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1');
|
||||
expect(mockedGetCurrentUser).toHaveBeenCalledWith(expect.any(Object));
|
||||
expect(DatabaseManager.serverDatabases[serverUrl]?.operator?.handlePosts).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle missing server database gracefully', async () => {
|
||||
const result = await handleBoRPostAllRevealed('invalid-server-url', msg);
|
||||
|
||||
expect(mockedGetPostById).not.toHaveBeenCalled();
|
||||
expect(mockedGetCurrentUser).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle missing operator gracefully', async () => {
|
||||
DatabaseManager.serverDatabases[serverUrl] = {} as any;
|
||||
|
||||
const result = await handleBoRPostAllRevealed(serverUrl, msg);
|
||||
|
||||
expect(mockedGetPostById).not.toHaveBeenCalled();
|
||||
expect(mockedGetCurrentUser).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully and return error object', async () => {
|
||||
mockedGetPostById.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await handleBoRPostAllRevealed(serverUrl, msg);
|
||||
|
||||
expect(result).toHaveProperty('error');
|
||||
expect(result!.error).toBeInstanceOf(Error);
|
||||
expect(mockedGetCurrentUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||