Compare commits

..

45 Commits

Author SHA1 Message Date
Elias Nahum
b9b2991861 Fix android CustomPushNotificationHelper bad merge 2023-02-07 12:33:50 +02:00
Mattermost Build
7927d9ce78 Replace package and imports for Kotlin files (#7090) (#7091)
(cherry picked from commit f37a9fbabb)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2023-02-07 11:36:46 +02:00
Elias Nahum
f724e0abfa Bump app build number to 455 2023-02-07 10:47:32 +02:00
Elias Nahum
788be8478d Bump app version number to 2.0.1 2023-02-07 10:46:46 +02:00
Mattermost Build
bbc3d5ea82 Fix (#7082) (#7084)
(cherry picked from commit fab5665773)

Co-authored-by: Anurag Shivarathri <anurag6713@gmail.com>
2023-02-03 15:27:02 +05:30
Elias Nahum
2e87df6a8d Update RN and deps to fix ANR issues (#7078) 2023-02-02 14:44:31 +02:00
Elias Nahum
89be2be00e Fix the animation that occurs in login flow (#7054) 2023-02-02 12:16:27 +02:00
Elias Nahum
75c56a993f Support for Android Tablets & Foldable (#7025)
* Add Support for Android tablets & foldables

* add tablet and book posture

* Regenerate disposed observable on WindowInfoTracker
2023-02-02 12:15:43 +02:00
Elias Nahum
8d267f320d iPad: enable rotation in all directions (#7007)
* iPad: enable rotation in all directions

* feedback review
2023-02-02 12:14:27 +02:00
Elias Nahum
48b5b6099c Update Dependencies and bug fixes (#7000)
* update dependencies

* update dependencies

* feedback review

* update @mattermost/react-native-turbo-mailer
2023-02-02 12:13:09 +02:00
Elias Nahum
9b4c31bacf New UI for Emoji picker (#6933)
* BottomSheet migration to react-native-bottom-sheet

* Refactor Emoji picker to use bottom sheet

* Add skin selector

* Add Emoji Skin Tone tutorial

* add selected indicator to tone selector

* feedback review

* show tooltip after 750ms

* ux feedback review

* ux feedback review #2

* Hide emoji picker scroll indicator
2023-02-02 10:58:42 +02:00
Mattermost Build
e6bb2b826f Use timeout defaults for iOS Share Extension and Notification Service (#7051) (#7073)
* Use timeout defaults for iOS Share Extension and Notification Service

* more logs

* Add more logs, handle errors and safe parse the filename

(cherry picked from commit 5aaff10664)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2023-02-01 21:28:39 +02:00
Elias Nahum
2cec2f02f0 Fix lint after cherry-pick 2023-01-31 22:17:19 +02:00
Elias Nahum
cf2a29e219 Only fetchMissingDirectChannelsInfo when no display name is set (#7060) 2023-01-31 22:10:46 +02:00
Elias Nahum
26801c2516 Request permissions for Android push notifications and refactor code to use network client (#7059) 2023-01-31 21:46:16 +02:00
Daniel Espino García
e876942892 Fix add to default category code for dms and gms (#7057) (#7066) 2023-01-31 17:01:39 +01:00
Elias Nahum
210642f287 Fix the animation that occurs in login flow (#7055) 2023-01-30 11:58:26 +02:00
Avinash Lingaloo
e635d04505 Bump app build number to 454 (#7042)
Co-authored-by: Mattermost Build <build@mattermost.com>
2023-01-27 22:24:40 +02:00
Mattermost Build
245f89815e Fixes crashes and errors in iOS Share Extension and Notification Service (#7032) (#7047)
* Fix erros & crashes in iOS share extension

* Fix erros & crashes in iOS notification service

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
(cherry picked from commit ca14631487)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2023-01-27 22:20:19 +02:00
Elias Nahum
ec99c8bc0d disable top domain level verification (#7045) 2023-01-27 22:07:57 +02:00
Daniel Espino García
0a1c1a8bf7 Fix pdf upload and pdf download from search results (#6984) 2023-01-27 20:19:48 +02:00
Mattermost Build
ec2aeca0d0 catch exceptions in Android Database helper (#7027) (#7040)
(cherry picked from commit 7ed2e73a91)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2023-01-27 18:58:29 +02:00
Mattermost Build
cfb09ce7d7 Do not access record children directly to avoid crashes if the child is not present in the db (#7028) (#7036)
(cherry picked from commit 50b845452e)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2023-01-27 18:10:45 +02:00
Mattermost Build
ee7b4f05d5 Fix crash when dismissing notification on android (#7029) (#7035)
* Fix crash when dismissing notification on android

* ensure notification channels are created

(cherry picked from commit 9bae53b4ad)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2023-01-27 18:10:20 +02:00
Mattermost Build
069eaa9f52 Fix matchDeepLink when server is on a subpath (#7010) (#7018)
* Fixes matchDeepLink when server is in a subpath

* Fix matchDeepLink when serverUrl is in a subpath

(cherry picked from commit 983d0aab66)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2023-01-26 20:32:52 +02:00
Mattermost Build
4764a76c9f Remove mock locations from jailbrake detection (#7005) (#7017)
(cherry picked from commit 4aaf08b12a)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2023-01-26 20:32:36 +02:00
Mattermost Build
70119fc026 Bump app build number to 452 (#6974) (#6975)
(cherry picked from commit d417b95643)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2023-01-13 21:16:22 +02:00
Mattermost Build
0d9c6e0ad3 Fix the caret position when using the search phrase modifier (#6972) (#6973)
(cherry picked from commit 49fc180982)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2023-01-13 21:15:11 +02:00
Mattermost Build
f7d8ed9e1f Bump app build number to 451 (#6967) (#6968)
(cherry picked from commit 9411dbd669)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2023-01-12 13:07:36 +02:00
Mattermost Build
b8cc13d7fa Fix (#6960) (#6966)
(cherry picked from commit c8ded6ef3c)

Co-authored-by: Anurag Shivarathri <anurag6713@gmail.com>
2023-01-12 13:00:51 +02:00
Mattermost Build
317568b4c8 Fix some issues found by Sentry (#6962) (#6965)
(cherry picked from commit bb351c7376)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2023-01-12 11:11:14 +02:00
Mattermost Build
55f919dd27 Add String description in log arguments (#6961) (#6964)
(cherry picked from commit 247d8371d9)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2023-01-12 11:08:12 +02:00
Mattermost Build
da1b3dc71d Add back wrongly removed Ephemeral.theme assignment (#6959) (#6963)
(cherry picked from commit cab863d62f)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2023-01-12 11:03:57 +02:00
Elias Nahum
57a9ff31bf fix schema test 2023-01-11 22:23:03 +02:00
Elias Nahum
4f86a87bdc fix ci 2023-01-11 22:14:01 +02:00
Mattermost Build
9ab21b2f62 Bump build number to 450 (#6950) (#6955)
* Fix upgrade path

* Introduce Upgrade helper

* Reset server database schema version to 1

* Enable release builds on the CI

* Bump build number to 450

(cherry picked from commit 4199b13843)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2023-01-11 21:45:47 +02:00
Mattermost Build
1934945d72 Fix code syntax highlight on full screen view (#6944) (#6954)
(cherry picked from commit 8edf128d59)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2023-01-11 21:45:14 +02:00
Mattermost Build
5162e6b6e7 Fix connection banner showing when not needed (#6948) (#6953)
* Fix connection banner showing when not needed

* Fix some issues and some refactoring

(cherry picked from commit 6082a6a790)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2023-01-11 21:44:31 +02:00
Guillermo Vayá
9139a26967 Merge pull request #6951 from mattermost/weblate-6947
Weblate 6947
2023-01-11 13:14:49 +01:00
Kaya Zeren
56fbb3d842 Translated using Weblate (Turkish)
Currently translated at 93.6% (879 of 939 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/tr/
2023-01-11 12:53:17 +01:00
정성근
56349f865f Translated using Weblate (Korean)
Currently translated at 59.2% (556 of 939 strings)

Translation: mattermost-languages-shipped/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/ko/
2023-01-11 12:53:01 +01:00
Elias Nahum
fdf593bcec Fix navigation theming (#6946) 2023-01-11 09:54:07 +02:00
Elias Nahum
8e2e016a6c check for analytics enabled 2023-01-09 11:14:15 +02:00
Elias Nahum
4d9bc1fbed Bump app build number to 449 (#6940) 2023-01-07 18:44:27 +02:00
Elias Nahum
7351c7ccac fix Sentry import 2023-01-07 18:39:26 +02:00
1356 changed files with 61887 additions and 78160 deletions

616
.circleci/config.yml Normal file
View File

@@ -0,0 +1,616 @@
version: 2.1
orbs:
owasp: entur/owasp@0.0.10
node: circleci/node@5.0.3
executors:
android:
parameters:
resource_class:
default: xlarge
type: string
environment:
NODE_OPTIONS: --max_old_space_size=12000
NODE_ENV: production
BABEL_ENV: production
docker:
- image: cimg/android:2022.09.2-node
working_directory: ~/mattermost-mobile
resource_class: <<parameters.resource_class>>
ios:
parameters:
resource_class:
default: medium
type: string
environment:
NODE_OPTIONS: --max_old_space_size=12000
NODE_ENV: production
BABEL_ENV: production
macos:
xcode: "14.0.0"
working_directory: ~/mattermost-mobile
shell: /bin/bash --login -o pipefail
resource_class: <<parameters.resource_class>>
commands:
checkout-private:
description: "Checkout the private repo with build env vars"
steps:
- add_ssh_keys:
fingerprints:
- "03:1c:a7:07:35:bc:57:e4:1d:6c:e1:2c:4b:be:09:6d"
- run:
name: Clone the mobile private repo
command: git clone git@github.com:mattermost/mattermost-mobile-private.git ~/mattermost-mobile-private
fastlane-dependencies:
description: "Get Fastlane dependencies"
parameters:
for:
type: string
steps:
- restore_cache:
name: Restore Fastlane cache
key: v1-gems-<< parameters.for >>-{{ checksum "fastlane/Gemfile.lock" }}-{{ arch }}
- run:
working_directory: fastlane
name: Download Fastlane dependencies
command: bundle install --path vendor/bundle
- save_cache:
name: Save Fastlane cache
key: v1-gems-<< parameters.for >>-{{ checksum "fastlane/Gemfile.lock" }}-{{ arch }}
paths:
- fastlane/vendor/bundle
gradle-dependencies:
description: "Get Gradle dependencies"
steps:
- restore_cache:
name: Restore Gradle cache
key: v1-gradle-{{ checksum "android/build.gradle" }}-{{ checksum "android/app/build.gradle" }}
- run:
working_directory: android
name: Download Gradle dependencies
command: ./gradlew dependencies
- save_cache:
name: Save Gradle cache
paths:
- ~/.gradle
key: v1-gradle-{{ checksum "android/build.gradle" }}-{{ checksum "android/app/build.gradle" }}
assets:
description: "Generate app assets"
steps:
- restore_cache:
name: Restore assets cache
key: v1-assets-{{ checksum "assets/base/config.json" }}-{{ arch }}
- run:
name: Generate assets
command: node ./scripts/generate-assets.js
- run:
name: Compass Icons
environment:
COMPASS_ICONS: "node_modules/@mattermost/compass-icons/font/compass-icons.ttf"
command: |
cp "$COMPASS_ICONS" "assets/fonts/"
cp "$COMPASS_ICONS" "android/app/src/main/assets/fonts"
- save_cache:
name: Save assets cache
key: v1-assets-{{ checksum "assets/base/config.json" }}-{{ arch }}
paths:
- dist
npm-dependencies:
description: "Get JavaScript dependencies"
steps:
- node/install:
node-version: '18.7.0'
- restore_cache:
name: Restore npm cache
key: v2-npm-{{ checksum "package.json" }}-{{ arch }}
- run:
name: Getting JavaScript dependencies
command: |
NODE_ENV=development npm ci --ignore-scripts
node node_modules/\@sentry/cli/scripts/install.js
- save_cache:
name: Save npm cache
key: v2-npm-{{ checksum "package.json" }}-{{ arch }}
paths:
- node_modules
- run:
name: "Patch dependencies"
command: npx patch-package
pods-dependencies:
description: "Get cocoapods dependencies"
steps:
- restore_cache:
name: Restore cocoapods specs and pods
key: v1-cocoapods-{{ checksum "ios/Podfile.lock" }}-{{ arch }}
- run:
name: iOS gems
command: npm run ios-gems
- run:
name: Getting cocoapods dependencies
command: npm run pod-install
- save_cache:
name: Save cocoapods specs and pods cache
key: v1-cocoapods-{{ checksum "ios/Podfile.lock" }}-{{ arch }}
paths:
- ios/Pods
- ~/.cocoapods
build-android:
description: "Build the android app"
steps:
- checkout:
path: ~/mattermost-mobile
- checkout-private
- npm-dependencies
- assets
- fastlane-dependencies:
for: android
- gradle-dependencies
- run:
name: Append Keystore to build Android
command: |
cp ~/mattermost-mobile-private/android/${STORE_FILE} android/app/${STORE_FILE}
echo "" | tee -a android/gradle.properties > /dev/null
echo MATTERMOST_RELEASE_STORE_FILE=${STORE_FILE} | tee -a android/gradle.properties > /dev/null
echo ${STORE_ALIAS} | tee -a android/gradle.properties > /dev/null
echo ${STORE_PASSWORD} | tee -a android/gradle.properties > /dev/null
- run:
name: Jetify android libraries
command: ./node_modules/.bin/jetify
- run:
working_directory: fastlane
name: Run fastlane to build android
no_output_timeout: 30m
command: export TERM=xterm && bundle exec fastlane android build
build-ios:
description: "Build the iOS app"
steps:
- checkout:
path: ~/mattermost-mobile
- npm-dependencies
- pods-dependencies
- assets
- fastlane-dependencies:
for: ios
- run:
working_directory: fastlane
name: Run fastlane to build iOS
no_output_timeout: 30m
command: |
HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
export TERM=xterm && bundle exec fastlane ios build
deploy-to-store:
description: "Deploy build to store"
parameters:
task:
type: string
target:
type: string
file:
type: string
env:
type: string
steps:
- attach_workspace:
at: ~/
- run:
name: <<parameters.task>>
working_directory: fastlane
command: <<parameters.env>> bundle exec fastlane <<parameters.target>> deploy file:$HOME/mattermost-mobile/<<parameters.file>>
persist:
description: "Persist mattermost-mobile directory"
steps:
- persist_to_workspace:
root: ~/
paths:
- mattermost-mobile*
save:
description: "Save binaries artifacts"
parameters:
filename:
type: string
steps:
- run:
name: Copying artifacts
command: |
mkdir /tmp/artifacts;
cp ~/mattermost-mobile/<<parameters.filename>> /tmp/artifacts;
- store_artifacts:
path: /tmp/artifacts
jobs:
test:
working_directory: ~/mattermost-mobile
docker:
- image: cimg/node:16.14.2
steps:
- checkout:
path: ~/mattermost-mobile
- npm-dependencies
- assets
- run:
name: Check styles
command: npm run check
- run:
name: Running Tests
command: npm test
- run:
name: Check i18n
command: ./scripts/precommit/i18n.sh
check-deps:
parameters:
cve_data_directory:
type: string
default: "~/.owasp/dependency-check-data"
working_directory: ~/mattermost-mobile
executor: owasp/default
environment:
version_url: "https://jeremylong.github.io/DependencyCheck/current.txt"
executable_url: "https://dl.bintray.com/jeremy-long/owasp/dependency-check-VERSION-release.zip"
steps:
- checkout
- restore_cache:
name: Restore npm cache
key: v2-npm-{{ checksum "package.json" }}-{{ arch }}
- run:
name: Checkout config
command: cd .. && git clone https://github.com/mattermost/security-automation-config
- run:
name: Install Go
command: sudo apt-get update && sudo apt-get install golang
- owasp/with_commandline:
steps:
# Taken from https://github.com/entur/owasp-orb/blob/master/src/%40orb.yml#L349-L361
- owasp/generate_cache_keys:
cache_key: commmandline-default-cache-key-v7
- owasp/restore_owasp_cache
- run:
name: Update OWASP Dependency-Check Database
command: |
if ! ~/.owasp/dependency-check/bin/dependency-check.sh --data << parameters.cve_data_directory >> --updateonly; then
# Update failed, probably due to a bad DB version; delete cached DB and try again
rm -rv ~/.owasp/dependency-check-data/*.db
~/.owasp/dependency-check/bin/dependency-check.sh --data << parameters.cve_data_directory >> --updateonly
fi
- owasp/store_owasp_cache:
cve_data_directory: <<parameters.cve_data_directory>>
- run:
name: Run OWASP Dependency-Check Analyzer
command: |
~/.owasp/dependency-check/bin/dependency-check.sh \
--data << parameters.cve_data_directory >> --format ALL --noupdate --enableExperimental \
--propertyfile ../security-automation-config/dependency-check/dependencycheck.properties \
--suppression ../security-automation-config/dependency-check/suppression.xml \
--suppression ../security-automation-config/dependency-check/suppression.$CIRCLE_PROJECT_REPONAME.xml \
--scan './**/*' || true
- owasp/collect_reports:
persist_to_workspace: false
- run:
name: Post results to Mattermost
command: go run ../security-automation-config/dependency-check/post_results.go
build-android-beta:
executor: android
steps:
- build-android
- persist
- save:
filename: "*.apk"
build-android-release:
executor: android
steps:
- build-android
- persist
- save:
filename: "*.apk"
build-android-pr:
executor: android
environment:
BRANCH_TO_BUILD: ${CIRCLE_BRANCH}
steps:
- build-android
- save:
filename: "*.apk"
build-android-unsigned:
executor: android
steps:
- checkout:
path: ~/mattermost-mobile
- npm-dependencies
- assets
- fastlane-dependencies:
for: android
- gradle-dependencies
- run:
name: Jetify Android libraries
command: ./node_modules/.bin/jetify
- run:
working_directory: fastlane
name: Run fastlane to build unsigned android
no_output_timeout: 30m
command: bundle exec fastlane android unsigned
- persist
- save:
filename: "*.apk"
build-ios-beta:
executor:
name: ios
resource_class: large
steps:
- build-ios
- persist
- save:
filename: "*.ipa"
build-ios-release:
executor: ios
steps:
- build-ios
- persist
- save:
filename: "*.ipa"
build-ios-pr:
executor: ios
environment:
BRANCH_TO_BUILD: ${CIRCLE_BRANCH}
steps:
- build-ios
- save:
filename: "*.ipa"
build-ios-unsigned:
executor: ios
steps:
- checkout:
path: ~/mattermost-mobile
- npm-dependencies
- pods-dependencies
- assets
- fastlane-dependencies:
for: ios
- run:
working_directory: fastlane
name: Run fastlane to build unsigned iOS
no_output_timeout: 30m
command: |
HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
bundle exec fastlane ios unsigned
- persist_to_workspace:
root: ~/
paths:
- mattermost-mobile/*.ipa
- save:
filename: "*.ipa"
build-ios-simulator:
executor: ios
steps:
- checkout:
path: ~/mattermost-mobile
- npm-dependencies
- pods-dependencies
- assets
- fastlane-dependencies:
for: ios
- run:
working_directory: fastlane
name: Run fastlane to build unsigned x86_64 iOS app for iPhone simulator
no_output_timeout: 30m
command: |
HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
bundle exec fastlane ios simulator
- persist_to_workspace:
root: ~/
paths:
- mattermost-mobile/Mattermost-simulator-x86_64.app.zip
- save:
filename: "Mattermost-simulator-x86_64.app.zip"
deploy-android-release:
executor:
name: android
resource_class: medium
steps:
- deploy-to-store:
task: "Deploy to Google Play"
target: android
file: "*.apk"
env: "SUPPLY_TRACK=beta"
deploy-android-beta:
executor:
name: android
resource_class: medium
steps:
- deploy-to-store:
task: "Deploy to Google Play"
target: android
file: "*.apk"
env: "SUPPLY_TRACK=alpha"
deploy-ios-release:
executor: ios
steps:
- deploy-to-store:
task: "Deploy to TestFlight"
target: ios
file: "*.ipa"
env: ""
deploy-ios-beta:
executor: ios
steps:
- deploy-to-store:
task: "Deploy to TestFlight"
target: ios
file: "*.ipa"
env: ""
github-release:
executor:
name: android
resource_class: medium
steps:
- attach_workspace:
at: ~/
- run:
name: Create GitHub release
working_directory: fastlane
command: bundle exec fastlane github
workflows:
version: 2
build:
jobs:
- test
# - check-deps:
# context: sast-webhook
# requires:
# - test
- build-android-release:
context: mattermost-mobile-android-release
requires:
- test
filters:
branches:
only:
- /^build-release-\d+$/
- /^build-android-release-\d+$/
- deploy-android-release:
context: mattermost-mobile-android-release
requires:
- build-android-release
filters:
branches:
only:
- /^build-release-\d+$/
- /^build-android-release-\d+$/
- build-android-beta:
context: mattermost-mobile-android-beta
filters:
branches:
only:
- /^build-\d+$/
- /^build-android-\d+$/
- /^build-android-beta-\d+$/
- deploy-android-beta:
context: mattermost-mobile-android-beta
requires:
- build-android-beta
filters:
branches:
only:
- /^build-\d+$/
- /^build-android-\d+$/
- /^build-android-beta-\d+$/
- build-ios-release:
context: mattermost-mobile-ios-release
requires:
- test
filters:
branches:
only:
- /^build-release-\d+$/
- /^build-ios-release-\d+$/
- deploy-ios-release:
context: mattermost-mobile-ios-release
requires:
- build-ios-release
filters:
branches:
only:
- /^build-release-\d+$/
- /^build-ios-release-\d+$/
- build-ios-beta:
context: mattermost-mobile-ios-beta
filters:
branches:
only:
- /^build-\d+$/
- /^build-ios-\d+$/
- /^build-ios-beta-\d+$/
- deploy-ios-beta:
context: mattermost-mobile-ios-beta
requires:
- build-ios-beta
filters:
branches:
only:
- /^build-\d+$/
- /^build-ios-\d+$/
- /^build-ios-beta-\d+$/
- build-android-pr:
context: mattermost-mobile-android-pr
requires:
- test
filters:
branches:
only: /^(build|android)-pr-.*/
- build-ios-pr:
context: mattermost-mobile-ios-pr
requires:
- test
filters:
branches:
only: /^(build|ios)-pr-.*/
- build-android-unsigned:
context: mattermost-mobile-unsigned
requires:
- test
filters:
tags:
only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
branches:
only: unsigned
- build-ios-unsigned:
context: mattermost-mobile-unsigned
requires:
- test
filters:
tags:
only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
branches:
only: unsigned
- build-ios-simulator:
context: mattermost-mobile-unsigned
requires:
- test
filters:
branches:
only:
- /^build-\d+$/
- /^build-ios-sim-\d+$/
- github-release:
context: mattermost-mobile-unsigned
requires:
- build-android-unsigned
- build-ios-unsigned
filters:
tags:
only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
branches:
only: unsigned

View File

@@ -61,7 +61,6 @@
"afterColon": true
}}],
"@typescript-eslint/member-delimiter-style": 2,
"@typescript-eslint/no-unsafe-declaration-merging": "off",
"import/order": [
2,
{

View File

@@ -1,49 +0,0 @@
name: prepare-android-build
description: Action to prepare environment for android build
inputs:
sign:
description: Flag to enable android package signing
default: "true"
runs:
using: composite
steps:
- name: ci/prepare-mobile-build
uses: ./.github/actions/prepare-mobile-build
# Disable this since we are not caching anything for now
# - name: ci/install-gradle-dependencies
# shell: bash
# working-directory: android
# run: |
# echo "::group::install-gradle-dependencies"
# ./gradlew dependencies
# echo "::endgroup::"
- name: ci/jetify-android-libraries
shell: bash
run: |
echo "::group::jetify-android-libraries"
./node_modules/.bin/jetify
echo "::endgroup::"
- name: ci/checkout-private-repo
if: ${{ inputs.sign == 'true' }}
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
with:
repository: mattermost/mattermost-mobile-private
token: ${{ env.MATTERMOST_BUILD_GH_TOKEN }}
path: ${{ github.workspace }}/mattermost-mobile-private
- name: ci/append-keystore-to-android-build-for-signing
if: ${{ inputs.sign == 'true' }}
shell: bash
run: |
echo "::group::append-keystore-to-android-build-for-signing"
cp ${{ github.workspace }}/mattermost-mobile-private/android/${STORE_FILE} android/app/${STORE_FILE}
echo "" | tee -a android/gradle.properties > /dev/null
echo MATTERMOST_RELEASE_STORE_FILE=${STORE_FILE} | tee -a android/gradle.properties > /dev/null
echo ${STORE_ALIAS} | tee -a android/gradle.properties > /dev/null
echo ${STORE_PASSWORD} | tee -a android/gradle.properties > /dev/null
echo "::endgroup::"

View File

@@ -1,16 +0,0 @@
name: prepare-ios-build
description: Action to prepare environment for ios build
runs:
using: composite
steps:
- name: ci/prepare-mobile-build
uses: ./.github/actions/prepare-mobile-build
- name: ci/install-pods-dependencies
shell: bash
run: |
echo "::group::install-pods-dependencies"
npm run ios-gems
npm run pod-install
echo "::endgroup::"

View File

@@ -1,19 +0,0 @@
name: prepare-mobile-build
description: Action to prepare environment for mobile build
runs:
using: composite
steps:
# The required ruby version is mentioned in '.ruby-version'
- uses: ruby/setup-ruby@22fdc77bf4148f810455b226c90fb81b5cbc00a7 # v 1.171.0
- name: ci/setup-fastlane-dependencies
shell: bash
run: |
echo "::group::setup-fastlane-dependencies"
bundle install
echo "::endgroup::"
working-directory: ./fastlane
- name: ci/prepare-node-deps
uses: ./.github/actions/prepare-node-deps

View File

@@ -1,46 +0,0 @@
name: deps
description: Common deps for mobile repo
runs:
using: composite
steps:
- name: ci/setup-node
uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0
with:
node-version-file: ".nvmrc"
cache: "npm"
cache-dependency-path: package-lock.json
- name: ci/install-npm-dependencies
shell: bash
env:
NODE_ENV: development
run: |
echo "::group::install-npm-dependencies"
npm ci --ignore-scripts
node node_modules/\@sentry/cli/scripts/install.js
echo "::endgroup::"
- name: ci/patch-npm-dependencies
shell: bash
run: |
echo "::group::patch-npm-dependencies"
npx patch-package
echo "::endgroup::"
- name: ci/generate-assets
shell: bash
run: |
echo "::group::generate-assets"
node ./scripts/generate-assets.js
echo "::endgroup::"
- name: ci/import-compass-icon
shell: bash
env:
COMPASS_ICONS: "node_modules/@mattermost/compass-icons/font/compass-icons.ttf"
run: |
echo "::group::import-compass-icon"
cp "$COMPASS_ICONS" "assets/fonts/"
cp "$COMPASS_ICONS" "android/app/src/main/assets/fonts"
echo "::endgroup::"

View File

@@ -1,27 +0,0 @@
name: test
description: Common tests for mobile repo
runs:
using: composite
steps:
- name: ci/prepare-node-deps
uses: ./.github/actions/prepare-node-deps
- name: ci/check-styles
shell: bash
run: |
echo "::group::check-styles"
npm run check
echo "::endgroup::"
- name: ci/run-tests
shell: bash
run: |
echo "::group::run-tests"
npm test
echo "::endgroup::"
- name: ci/check-i18n
shell: bash
run: |
echo "::group::check-i18n"
./scripts/precommit/i18n.sh
echo "::endgroup::"

View File

@@ -1,60 +0,0 @@
---
name: build-android-beta
on:
push:
branches:
- build-beta-[0-9]+
- build-beta-android-[0-9]+
env:
NODE_VERSION: 18.7.0
TERM: xterm
jobs:
test:
runs-on: ubuntu-22.04
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/test
uses: ./.github/actions/test
build-and-deploy-android-beta:
runs-on: ubuntu-22.04
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/prepare-android-build
uses: ./.github/actions/prepare-android-build
env:
STORE_FILE: "${{ secrets.MM_MOBILE_STORE_FILE }}"
STORE_ALIAS: "${{ secrets.MM_MOBILE_STORE_ALIAS }}"
STORE_PASSWORD: "${{ secrets.MM_MOBILE_STORE_PASSWORD }}"
MATTERMOST_BUILD_GH_TOKEN: "${{ secrets.MATTERMOST_BUILD_GH_TOKEN }}"
- name: ci/build-and-deploy-android-beta
env:
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_BETA_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_BETA_AWS_SECRET_ACCESS_KEY }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_BETA_MATTERMOST_WEBHOOK_URL }}"
SENTRY_AUTH_TOKEN: "${{ secrets.MM_MOBILE_SENTRY_AUTH_TOKEN }}"
SENTRY_DSN_ANDROID: ${{ secrets.MM_MOBILE_BETA_SENTRY_DSN_ANDROID }}
SUPPLY_JSON_KEY: ${{ github.workspace }}/mattermost-mobile-private/android/mattermost-credentials.json
run: |
echo "::group::Build"
bundle exec fastlane android build --env android.beta
echo "::endgroup::"
echo "::group::Deploy to Play Store"
bundle exec fastlane android deploy file:"${{ github.workspace }}/*.apk" --env android.beta
echo "::endgroup::"
working-directory: ./fastlane
- name: ci/upload-android-beta-build
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
name: android-build-beta-${{ github.run_id }}
path: "*.apk"

View File

@@ -1,60 +0,0 @@
---
name: build-android-release
on:
push:
branches:
- build-release-[0-9]+
- build-release-android-[0-9]+
env:
NODE_VERSION: 18.7.0
TERM: xterm
jobs:
test:
runs-on: ubuntu-22.04
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/test
uses: ./.github/actions/test
build-and-deploy-android-release:
runs-on: ubuntu-22.04
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/prepare-android-build
uses: ./.github/actions/prepare-android-build
env:
STORE_FILE: "${{ secrets.MM_MOBILE_STORE_FILE }}"
STORE_ALIAS: "${{ secrets.MM_MOBILE_STORE_ALIAS }}"
STORE_PASSWORD: "${{ secrets.MM_MOBILE_STORE_PASSWORD }}"
MATTERMOST_BUILD_GH_TOKEN: "${{ secrets.MATTERMOST_BUILD_GH_TOKEN }}"
- name: ci/build-and-deploy-android-release
env:
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_RELEASE_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_RELEASE_AWS_SECRET_ACCESS_KEY }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_RELEASE_MATTERMOST_WEBHOOK_URL }}"
SENTRY_AUTH_TOKEN: "${{ secrets.MM_MOBILE_SENTRY_AUTH_TOKEN }}"
SENTRY_DSN_ANDROID: ${{ secrets.MM_MOBILE_RELEASE_SENTRY_DSN_ANDROID }}
SUPPLY_JSON_KEY: ${{ github.workspace }}/mattermost-mobile-private/android/mattermost-credentials.json
run: |
echo "::group::Build"
bundle exec fastlane android build --env android.release
echo "::endgroup::"
echo "::group::Deploy to Play Store"
bundle exec fastlane android deploy file:"${{ github.workspace }}/*.apk" --env android.release
echo "::endgroup::"
working-directory: ./fastlane
- name: ci/upload-android-release-build
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
name: android-build-release-${{ github.run_id }}
path: "*.apk"

View File

@@ -1,99 +0,0 @@
---
name: build-ios-beta
on:
push:
branches:
- build-beta-[0-9]+
- build-beta-ios-[0-9]+
- build-beta-sim-[0-9]+
env:
NODE_VERSION: 18.7.0
TERM: xterm
jobs:
test:
runs-on: ubuntu-22.04
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/test
uses: ./.github/actions/test
build-ios-simulator:
runs-on: macos-14-large
if: ${{ !contains(github.ref_name, 'beta-ios') }}
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
- name: ci/build-ios-simulator
env:
TAG: "${{ github.ref_name }}"
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_BETA_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_BETA_AWS_SECRET_ACCESS_KEY }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_BETA_MATTERMOST_WEBHOOK_URL }}"
GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
run: bundle exec fastlane ios simulator --env ios.simulator
working-directory: ./fastlane
- name: ci/upload-ios-pr-simulator
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
name: ios-build-simulator-${{ github.run_id }}
path: Mattermost-simulator-x86_64.app.zip
build-and-deploy-ios-beta:
runs-on: macos-14-large
if: ${{ !contains(github.ref_name, 'beta-sim') }}
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/output-ssh-private-key
shell: bash
run: |
SSH_KEY_PATH=~/.ssh/id_ed25519
mkdir -p ~/.ssh
echo -e '${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }}' > ${SSH_KEY_PATH}
chmod 0600 ${SSH_KEY_PATH}
ssh-keygen -y -f ${SSH_KEY_PATH} > ${SSH_KEY_PATH}.pub
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
- name: ci/build-and-deploy-ios-beta
env:
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_BETA_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_BETA_AWS_SECRET_ACCESS_KEY }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_BETA_MATTERMOST_WEBHOOK_URL }}"
FASTLANE_TEAM_ID: "${{ secrets.MM_MOBILE_FASTLANE_TEAM_ID }}"
IOS_API_ISSUER_ID: "${{ secrets.MM_MOBILE_IOS_API_ISSUER_ID }}"
IOS_API_KEY: "${{ secrets.MM_MOBILE_IOS_API_KEY }}"
IOS_API_KEY_ID: "${{ secrets.MM_MOBILE_IOS_API_KEY_ID }}"
MATCH_GIT_URL: "${{ secrets.MM_MOBILE_MATCH_GIT_URL }}"
MATCH_PASSWORD: "${{ secrets.MM_MOBILE_MATCH_PASSWORD }}"
SENTRY_AUTH_TOKEN: "${{ secrets.MM_MOBILE_SENTRY_AUTH_TOKEN }}"
SENTRY_DSN_IOS: "${{ secrets.MM_MOBILE_BETA_SENTRY_DSN_IOS }}"
run: |
echo "::group::Build"
bundle exec fastlane ios build --env ios.beta
echo "::endgroup::"
echo "::group::Deploy to TestFlight"
bundle exec fastlane ios deploy file:"${{ github.workspace }}/*.ipa" --env ios.beta
echo "::endgroup::"
working-directory: ./fastlane
- name: ci/upload-ios-beta-build
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
name: ios-build-beta-${{ github.run_id }}
path: "*.ipa"

View File

@@ -1,99 +0,0 @@
---
name: build-ios-release
on:
push:
branches:
- build-release-[0-9]+
- build-release-ios-[0-9]+
- build-release-sim-[0-9]+
env:
NODE_VERSION: 18.7.0
TERM: xterm
jobs:
test:
runs-on: ubuntu-22.04
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/test
uses: ./.github/actions/test
build-and-deploy-ios-release:
runs-on: macos-14-large
if: ${{ !contains(github.ref_name, 'release-sim') }}
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
- name: ci/output-ssh-private-key
shell: bash
run: |
SSH_KEY_PATH=~/.ssh/id_ed25519
mkdir -p ~/.ssh
echo -e '${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }}' > ${SSH_KEY_PATH}
chmod 0600 ${SSH_KEY_PATH}
ssh-keygen -y -f ${SSH_KEY_PATH} > ${SSH_KEY_PATH}.pub
- name: ci/build-and-deploy-ios-release
env:
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_RELEASE_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_RELEASE_AWS_SECRET_ACCESS_KEY }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_RELEASE_MATTERMOST_WEBHOOK_URL }}"
FASTLANE_TEAM_ID: "${{ secrets.MM_MOBILE_FASTLANE_TEAM_ID }}"
IOS_API_ISSUER_ID: "${{ secrets.MM_MOBILE_IOS_API_ISSUER_ID }}"
IOS_API_KEY: "${{ secrets.MM_MOBILE_IOS_API_KEY }}"
IOS_API_KEY_ID: "${{ secrets.MM_MOBILE_IOS_API_KEY_ID }}"
MATCH_GIT_URL: "${{ secrets.MM_MOBILE_MATCH_GIT_URL }}"
MATCH_PASSWORD: "${{ secrets.MM_MOBILE_MATCH_PASSWORD }}"
SENTRY_AUTH_TOKEN: "${{ secrets.MM_MOBILE_SENTRY_AUTH_TOKEN }}"
SENTRY_DSN_IOS: ${{ secrets.MM_MOBILE_RELEASE_SENTRY_DSN_IOS }}
run: |
echo "::group::Build"
bundle exec fastlane ios build --env ios.release
echo "::endgroup::"
echo "::group::Deploy to TestFlight"
bundle exec fastlane ios deploy file:"${{ github.workspace }}/*.ipa" --env ios.release
echo "::endgroup::"
working-directory: ./fastlane
- name: ci/upload-ios-release-build
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
name: ios-build-release-${{ github.run_id }}
path: "*.ipa"
build-ios-simulator:
runs-on: macos-14-large
if: ${{ !contains(github.ref_name , 'release-ios') }}
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
- name: ci/build-ios-simulator
env:
TAG: "${{ github.ref_name }}"
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_BETA_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_BETA_AWS_SECRET_ACCESS_KEY }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_BETA_MATTERMOST_WEBHOOK_URL }}"
GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
run: bundle exec fastlane ios simulator --env ios.simulator
working-directory: ./fastlane
- name: ci/upload-ios-pr-simulator
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
name: ios-build-simulator-${{ github.run_id }}
path: Mattermost-simulator-x86_64.app.zip

View File

@@ -1,100 +0,0 @@
---
name: build-pr
on:
pull_request:
types:
- labeled
env:
NODE_VERSION: 18.7.0
TERM: xterm
jobs:
test:
runs-on: ubuntu-22.04
if: ${{ github.event.label.name == 'Build Apps for PR' || github.event.label.name == 'Build App for iOS' || github.event.label.name == 'Build App for Android' }}
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: ci/test
uses: ./.github/actions/test
build-ios-pr:
runs-on: macos-14-large
if: ${{ github.event.label.name == 'Build Apps for PR' || github.event.label.name == 'Build App for iOS' }}
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
- name: ci/output-ssh-private-key
shell: bash
run: |
SSH_KEY_PATH=~/.ssh/id_ed25519
mkdir -p ~/.ssh
echo -e '${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }}' > ${SSH_KEY_PATH}
chmod 0600 ${SSH_KEY_PATH}
ssh-keygen -y -f ${SSH_KEY_PATH} > ${SSH_KEY_PATH}.pub
- name: ci/build-ios-pr
env:
BRANCH_TO_BUILD: "${{ github.event.pull_request.head.ref }}"
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_PR_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_PR_AWS_SECRET_ACCESS_KEY }}"
FASTLANE_TEAM_ID: "${{ secrets.MM_MOBILE_FASTLANE_TEAM_ID }}"
IOS_API_ISSUER_ID: "${{ secrets.MM_MOBILE_IOS_API_ISSUER_ID }}"
IOS_API_KEY: "${{ secrets.MM_MOBILE_IOS_API_KEY }}"
IOS_API_KEY_ID: "${{ secrets.MM_MOBILE_IOS_API_KEY_ID }}"
MATCH_GIT_URL: "${{ secrets.MM_MOBILE_MATCH_GIT_URL }}"
MATCH_PASSWORD: "${{ secrets.MM_MOBILE_MATCH_PASSWORD }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_PR_MATTERMOST_WEBHOOK_URL }}"
run: bundle exec fastlane ios build --env ios.pr
working-directory: ./fastlane
- name: ci/upload-ios-pr-build
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
name: ios-build-pr-${{ github.run_id }}
path: "*.ipa"
build-android-pr:
runs-on: ubuntu-22.04
if: ${{ github.event.label.name == 'Build Apps for PR' || github.event.label.name == 'Build App for Android' }}
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: ci/prepare-android-build
uses: ./.github/actions/prepare-android-build
env:
STORE_FILE: "${{ secrets.MM_MOBILE_STORE_FILE }}"
STORE_ALIAS: "${{ secrets.MM_MOBILE_STORE_ALIAS }}"
STORE_PASSWORD: "${{ secrets.MM_MOBILE_STORE_PASSWORD }}"
MATTERMOST_BUILD_GH_TOKEN: "${{ secrets.MATTERMOST_BUILD_GH_TOKEN }}"
- name: ci/build-android-pr
env:
BRANCH_TO_BUILD: "${{ github.event.pull_request.head.ref }}"
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_PR_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_PR_AWS_SECRET_ACCESS_KEY }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_PR_MATTERMOST_WEBHOOK_URL }}"
run: bundle exec fastlane android build --env android.pr
working-directory: ./fastlane
- name: ci/upload-android-pr-build
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
name: android-build-pr-${{ github.run_id }}
path: "*.apk"

View File

@@ -1,21 +0,0 @@
---
name: ci
on:
push:
branches:
- main
- 'release*'
pull_request:
env:
NODE_VERSION: 18.7.0
TERM: xterm
jobs:
test:
runs-on: ubuntu-22.04
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/test
uses: ./.github/actions/test

View File

@@ -1,98 +0,0 @@
---
name: github-release
on:
push:
tags:
- v[0-9]+.[0-9]+.[0-9]+*
jobs:
test:
runs-on: ubuntu-22.04
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/test
uses: ./.github/actions/test
build-ios-unsigned:
runs-on: macos-14-large
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
- name: ci/output-ssh-private-key
shell: bash
run: |
SSH_KEY_PATH=~/.ssh/id_ed25519
mkdir -p ~/.ssh
echo -e '${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }}' > ${SSH_KEY_PATH}
chmod 0600 ${SSH_KEY_PATH}
ssh-keygen -y -f ${SSH_KEY_PATH} > ${SSH_KEY_PATH}.pub
- name: ci/build-ios-unsigned
env:
TAG: "${{ github.ref_name }}"
GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
run: bundle exec fastlane ios unsigned
working-directory: ./fastlane
- name: ci/upload-ios-unsigned
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
path: Mattermost-unsigned.ipa
name: Mattermost-unsigned.ipa
build-android-unsigned:
runs-on: ubuntu-22.04
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/prepare-android-build
uses: ./.github/actions/prepare-android-build
with:
sign: false
- name: ci/build-android-beta
env:
TAG: "${{ github.ref_name }}"
GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
run: bundle exec fastlane android unsigned
working-directory: ./fastlane
- name: ci/upload-android-unsigned-build
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
path: Mattermost-unsigned.apk
name: Mattermost-unsigned.apk
release:
runs-on: ubuntu-22.04
needs:
- build-ios-unsigned
- build-android-unsigned
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- uses: ruby/setup-ruby@22fdc77bf4148f810455b226c90fb81b5cbc00a7 # v1.171.0
- name: release/setup-fastlane-dependencies
run: bundle install
working-directory: ./fastlane
- name: ci/download-artifacts
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
- name: release/create-github-release
env:
GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
run: bundle exec fastlane github
working-directory: ./fastlane

6
.gitignore vendored
View File

@@ -7,9 +7,6 @@ mattermost.keystore
tmp/
.env
env.d.ts
*.apk
*.aab
*.ipa
*/**/compass-icons.ttf
@@ -33,6 +30,8 @@ xcuserdata
*.moved-aside
DerivedData
*.hmap
*.ipa
*.apk
*.xcuserstate
project.xcworkspace
ios/Pods
@@ -103,7 +102,6 @@ detox/detox_pixel_*
# Bundle artifact
*.jsbundle
.bundle
#editor-settings
.vscode

View File

@@ -1 +0,0 @@
18.17

1
.npmrc
View File

@@ -1,2 +1 @@
save-exact=true
engine-strict=true

1
.nvmrc
View File

@@ -1 +0,0 @@
18.17

View File

@@ -1 +0,0 @@
3.0.6

View File

@@ -4,11 +4,29 @@
"output" : "moderate"
},
"requirements": {
"Node": [
{
"rule": "cli",
"binary": "node",
"semver": ">=16.0.0",
"error": "install node using nvm https://github.com/nvm-sh/nvm#installing-and-updating"
},
{
"rule": "cli",
"binary": "npm",
"semver": ">=8.5.5 <9.0.0",
"error": "install npm 8.5.5 `npm i -g npm@8.5.5"
}
],
"Android": [
{
"rule": "cli",
"binary": "emulator"
},
{
"rule": "cli",
"binary": "android"
},
{
"rule": "env",
"variable": "ANDROID_HOME",
@@ -32,14 +50,14 @@
{
"rule": "cli",
"binary": "ruby",
"semver": ">=3.0.0",
"semver": ">=2.7.1 <3.0.0",
"error": "visit rvm install https://rvm.io/rvm/install",
"platform": "darwin"
},
{
"rule": "cli",
"binary": "pod",
"semver": "1.14.3",
"semver": "1.11.3",
"platform": "darwin"
}
],

View File

@@ -1,4 +1,4 @@
Submit feature requests to https://mattermost.com/suggestions/. File non-security related bugs here in the following format:
Submit feature requests to http://www.mattermost.org/feature-requests/. File non-security related bugs here in the following format:
#### Summary
Issue in one concise sentence.

View File

@@ -34,18 +34,6 @@ A spec-compliant polyfill/ponyfill for Intl.getCanonicalLocales tested by the of
* LICENSE: MIT
---
## @formatjs/intl-listformat
This product contains '@formatjs/intl-listformat' by FormatJS.
This repository is the home of FormatJS and related libraries.
* HOMEPAGE: https://github.com/formatjs/formatjs
* LICENSE MIT
---
## @formatjs/intl-locale
@@ -103,52 +91,6 @@ A spec-compliant polyfill for Intl.RelativeTimeFormat fully tested by the offici
---
## @gorhom/bottom-sheet
This product contains '@gorhom/bottom-sheet' by Mo Gorhom.
A performant interactive bottom sheet with fully configurable options
* HOMEPAGE:
* https://github.com/gorhom/react-native-bottom-sheet
* LICENSE: MIT License
Copyright (c) 2020 Mo Gorhom
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## @mattermost/calls
This product contains '@mattermost/calls' by Mattermost.
Calls enables voice calling and screen sharing functionality in Mattermost channels.
* HOMEPAGE:
* https://github.com/mattermost/calls-common
* LICENSE: Apache License
---
## @mattermost/compass-icons
This product contains '@mattermost/compass-icons' by Mattermost.
@@ -258,6 +200,41 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## @mattermost/react-native-turbo-mailer
This product contains '@mattermost/react-native-turbo-mailer' by Avinash Lingaloo.
An adaptation of react-native-mail that supports Turbo Module
* HOMEPAGE:
* https://github.com/mattermost/react-native-turbo-mailer#readme
* LICENSE: MIT
MIT License
Copyright (c) 2022 Mattermost
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## @msgpack/msgpack
@@ -314,6 +291,41 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## @nozbe/with-observables
This product contains '@nozbe/with-observables' by Nozbe.
A higher-order component for connecting RxJS Observables to React components.
* HOMEPAGE:
* https://github.com/Nozbe/withObservables
* LICENSE: MIT
MIT License
Copyright (c) Nozbe
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## @react-native-camera-roll/camera-roll
@@ -531,6 +543,42 @@ Stack navigator component for iOS and Android with animated transitions and gest
---
## @rudderstack/rudder-sdk-react-native
This product contains '@rudderstack/rudder-sdk-react-native' by RudderStack.
Rudder React Native SDK
* HOMEPAGE:
* https://github.com/rudderlabs/rudder-sdk-reactnative#readme
* LICENSE: Apache-2.0
MIT License
Copyright (c) 2021 RudderStack
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## @sentry/react-native
@@ -602,51 +650,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## @tsconfig/react-native
This product contains a modified version of '@tsconfig/react-native' by TSC Base.
Hosts TSConfigs for you to extend in your apps, tuned to a particular runtime environment.
* HOMEPAGE:
* https://github.com/tsconfig/bases
* LICENSE: MIT
---
## @voximplant/react-native-foreground-service
This product contains a modified version of '@voximplant/react-native-foreground-service' by Voximplant.
A foreground service performs some operation that is noticeable to the user.
* HOMEPAGE:
* https://github.com/voximplant/react-native-foreground-service
* LICENSE: MIT License
Copyright (c) 2019 Zingaya, Inc
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
@@ -1333,41 +1336,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---
## path-to-regexp
This product contains 'path-to-regexp' by lastuniverse.
Turn a path string such as /user/:id or /user/:id(\d+) into a regular expression
* HOMEPAGE:
* https://github.com/lastuniverse/path-to-regex
* LICENSE: MIT
The MIT License (MIT)
Copyright (c) 2018 Roman Surmanidze (kapa6a3er@gmail.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---
## react
@@ -1532,6 +1500,42 @@ Open Android settings from your React Native app
* LICENSE: ISC
---
## react-native-animated-numbers
This product contains 'react-native-animated-numbers' by Lake (Yeongsu Han).
Library showing animation of number changes in react-native
* HOMEPAGE:
* https://github.com/heyman333/react-native-animated-numbers
* LICENSE: MIT
MIT License
Copyright (c) 2020 Yeongsu Han
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## react-native-background-timer
@@ -2787,38 +2791,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## react-native-walkthrough-tooltip
This product contains 'react-native-walkthrough-tooltip' by Jason Gaare.
React Native Walkthrough Tooltip is a fullscreen modal that highlights whichever element it wraps.
* HOMEPAGE:
* https://github.com/jasongaare/react-native-walkthrough-tooltip
* LICENSE: MIT License
Copyright (c) 2018 Jason Gaare
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---

View File

@@ -1,12 +1,12 @@
# Mattermost Mobile v2
- **Minimum Server versions:** Current ESR version (8.1.0+)
- **Supported iOS versions:** 12.4+
- **Minimum Server versions:** Current ESR version (7.1.0+)
- **Supported iOS versions:** 12.1+
- **Supported Android versions:** 7.0+
Mattermost is an open source Slack-alternative used by thousands of companies around the world in 21 languages. Learn more at [https://mattermost.com](https://mattermost.com).
Mattermost is an open source Slack-alternative used by thousands of companies around the world in 21 languages. Learn more at [https://about.mattermost.com](https://about.mattermost.com).
You can download our apps from the [App Store](https://mattermost.com/mattermost-ios-app/) or [Google Play Store](https://mattermost.com/mattermost-android-app/), or [build them yourself](https://developers.mattermost.com/contribute/mobile/build-your-own/).
You can download our apps from the [App Store](https://about.mattermost.com/mattermost-ios-app/) or [Google Play Store](https://about.mattermost.com/mattermost-android-app/), or [build them yourself](https://developers.mattermost.com/contribute/mobile/build-your-own/).
We plan on releasing monthly updates with new features - check the [changelog](https://github.com/mattermost/mattermost-mobile/blob/master/CHANGELOG.md) for what features are currently supported!
@@ -27,8 +27,8 @@ To help with testing app updates before they're released, you can:
- Repro steps
- Observed behavior (including screenshot / video when possible)
- Expected behavior
4. (Optional) [Sign up for our team site](https://community.mattermost.com/signup_user_complete/?id=codoy5s743rq5mk18i7u5ksz7e&md=link&sbr=su)
- 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
4. (Optional) [Sign up for our team site](https://pre-release.mattermost.com/signup_user_complete/?id=f1924a8db44ff3bb41c96424cdc20676)
- Join the [Native Mobile Apps channel](https://pre-release.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**.
@@ -39,7 +39,7 @@ You can leave the Beta testing program at any time:
1. Look in [GitHub issues](https://mattermost.com/pl/help-wanted-mattermost-mobile) for issues marked as [Help Wanted]
2. Comment to let people know youre working on it
3. Follow [these instructions](https://developers.mattermost.com/contribute/mobile/developer-setup/) to set up your developer environment
4. Join the [Native Mobile Apps channel](https://community.mattermost.com/core/channels/native-mobile-apps) on our team site to ask questions
4. Join the [Native Mobile Apps channel](https://pre-release.mattermost.com/core/channels/native-mobile-apps) on our team site to ask questions
@@ -57,7 +57,7 @@ You can still access it! We have moved the code from master to the [v1 branch](h
### I keep getting a message "Cannot connect to the server. Please check your server URL and internet connection."
This sometimes appears when there is an issue with the SSL certificate configuration.
This sometimes appears when there is an issue with the SSL certitificate configuration.
To check that your SSL certificate is set up correctly, test the SSL certificate by visiting a site such as https://www.ssllabs.com/ssltest/index.html. If theres an error about the missing chain or certificate path, there is likely an intermediate certificate missing that needs to be included.

View File

@@ -1,6 +1,8 @@
apply plugin: "com.android.application"
apply plugin: "com.facebook.react"
apply plugin: 'kotlin-android'
import com.android.build.OutputFile
/**
* This is the configuration block to customize your React Native Android app.
@@ -13,8 +15,8 @@ apply plugin: 'kotlin-android'
// root = file("../")
// The folder where the react-native NPM package is. Default is ../node_modules/react-native
// reactNativeDir = file("../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../node_modules/@react-native/codegen
// codegenDir = file("../node_modules/@react-native/codegen")
// The folder where the react-native Codegen package is. Default is ../node_modules/react-native-codegen
// codegenDir = file("../node_modules/react-native-codegen")
// The cli.js file which is the React Native CLI entrypoint. Default is ../node_modules/react-native/cli.js
// cliFile = file("../node_modules/react-native/cli.js")
/* Variants */
@@ -98,7 +100,6 @@ def reactNativeArchitectures() {
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdkVersion rootProject.ext.compileSdkVersion
namespace "com.mattermost.rnbeta"
@@ -111,8 +112,8 @@ android {
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 512
versionName "2.15.0"
versionCode 455
versionName "2.0.1"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
@@ -146,7 +147,6 @@ android {
release {
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
proguardFile "${rootProject.projectDir}/../node_modules/detox/android/detox/proguard-rules-app.pro"
if (useReleaseKey) {
signingConfig signingConfigs.release
} else {
@@ -172,10 +172,10 @@ android {
// For each separate APK per architecture, set a unique version code as described here:
// http://tools.android.com/tech-docs/new-build-system/user-guide/apk-splits
def versionCodes = ["armeabi-v7a":1, "x86":2, "arm64-v8a": 3, "x86_64": 4]
def abi = output.filters[0]
def abi = output.getFilter(OutputFile.ABI)
if (abi != null) { // null for the universal-debug, universal-release variants
output.versionCodeOverride =
versionCodes.get(abi.identifier) * 2000000 + defaultConfig.versionCode
versionCodes.get(abi) * 2000000 + defaultConfig.versionCode
}
}
}
@@ -191,6 +191,8 @@ dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0")
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}")
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
exclude group:'com.squareup.okhttp3', module:'okhttp'
@@ -203,15 +205,14 @@ dependencies {
implementation jscFlavor
}
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
implementation 'io.reactivex.rxjava3:rxjava:3.1.6'
implementation 'io.reactivex.rxjava3:rxandroid:3.0.2'
implementation 'androidx.window:window-core:1.1.0'
implementation 'androidx.window:window-rxjava3:1.0.0'
implementation 'androidx.window:window:1.0.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.8.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation 'com.google.android.material:material:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation "com.google.firebase:firebase-messaging:$firebaseVersion"
androidTestImplementation('com.wix:detox:+')
@@ -223,22 +224,25 @@ configurations.all {
resolutionStrategy {
eachDependency { DependencyResolveDetails details ->
if (details.requested.name == 'play-services-base') {
details.useTarget group: details.requested.group, name: details.requested.name, version: '18.2.0'
details.useTarget group: details.requested.group, name: details.requested.name, version: '15.0.1'
}
if (details.requested.name == 'play-services-tasks') {
details.useTarget group: details.requested.group, name: details.requested.name, version: '18.0.2'
details.useTarget group: details.requested.group, name: details.requested.name, version: '15.0.1'
}
if (details.requested.name == 'play-services-stats') {
details.useTarget group: details.requested.group, name: details.requested.name, version: '15.0.1'
}
if (details.requested.name == 'play-services-basement') {
details.useTarget group: details.requested.group, name: details.requested.name, version: '18.2.0'
details.useTarget group: details.requested.group, name: details.requested.name, version: '15.0.1'
}
if (details.requested.name == 'okhttp') {
details.useTarget group: details.requested.group, name: details.requested.name, version: '4.12.0'
details.useTarget group: details.requested.group, name: details.requested.name, version: '4.10.0'
}
if (details.requested.name == 'okhttp-tls') {
details.useTarget group: details.requested.group, name: details.requested.name, version: '4.12.0'
details.useTarget group: details.requested.group, name: details.requested.name, version: '4.10.0'
}
if (details.requested.name == 'okhttp-urlconnection') {
details.useTarget group: details.requested.group, name: details.requested.name, version: '4.12.0'
details.useTarget group: details.requested.group, name: details.requested.name, version: '4.10.0'
}
}
}

Binary file not shown.

View File

@@ -7,5 +7,10 @@
<application
android:usesCleartextTraffic="true"
tools:targetApi="28"
tools:ignore="GoogleAppIndexingWarning"/>
tools:ignore="GoogleAppIndexingWarning">
<activity
android:name="com.facebook.react.devsupport.DevSettingsActivity"
android:exported="false"
/>
</application>
</manifest>

View File

@@ -22,7 +22,7 @@ import com.facebook.react.ReactInstanceEventListener;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.modules.network.NetworkingModule;
import com.mattermost.networkclient.RCTOkHttpClientFactory;
import okhttp3.OkHttpClient;
/**
* Class responsible of loading Flipper inside your React Native application. This is the debug
@@ -37,9 +37,13 @@ public class ReactNativeFlipper {
client.addPlugin(new SharedPreferencesFlipperPlugin(context));
client.addPlugin(CrashReporterPlugin.getInstance());
NetworkFlipperPlugin networkFlipperPlugin = new NetworkFlipperPlugin();
RCTOkHttpClientFactory.Companion.setFlipperPlugin(networkFlipperPlugin);
NetworkingModule.setCustomClientBuilder(
builder -> builder.addNetworkInterceptor(new FlipperOkhttpInterceptor(networkFlipperPlugin)));
new NetworkingModule.CustomClientBuilder() {
@Override
public void apply(OkHttpClient.Builder builder) {
builder.addNetworkInterceptor(new FlipperOkhttpInterceptor(networkFlipperPlugin));
}
});
client.addPlugin(networkFlipperPlugin);
client.start();
// Fresco Plugin needs to ensure that ImagePipelineFactory is initialized
@@ -52,7 +56,12 @@ public class ReactNativeFlipper {
public void onReactContextInitialized(ReactContext reactContext) {
reactInstanceManager.removeReactInstanceEventListener(this);
reactContext.runOnNativeModulesQueueThread(
() -> client.addPlugin(new FrescoFlipperPlugin()));
new Runnable() {
@Override
public void run() {
client.addPlugin(new FrescoFlipperPlugin());
}
});
}
});
} else {

View File

@@ -12,8 +12,6 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<!-- Request legacy Bluetooth permissions on older devices. -->
<uses-permission android:name="android.permission.BLUETOOTH"
@@ -103,8 +101,5 @@
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<!-- For Calls microphone to work in the background -->
<service android:name="com.voximplant.foregroundservice.VIForegroundService"/>
</application>
</application>
</manifest>

View File

@@ -5,7 +5,6 @@ import android.util.LruCache
class BitmapCache {
private var memoryCache: LruCache<String, Bitmap>
private var keysCache: LruCache<String, String>
init {
val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
@@ -15,35 +14,15 @@ class BitmapCache {
return bitmap.byteCount / 1024
}
}
keysCache = LruCache<String, String>(50)
}
fun bitmap(userId: String, updatedAt: Double, serverUrl: String): Bitmap? {
val key = "$serverUrl-$userId-$updatedAt"
fun getBitmapFromMemCache(key: String): Bitmap? {
return memoryCache.get(key)
}
fun insertBitmap(bitmap: Bitmap?, userId: String, updatedAt: Double, serverUrl: String) {
if (bitmap == null) {
removeBitmap(userId, serverUrl)
fun addBitmapToMemoryCache(key: String, bitmap: Bitmap) {
if (getBitmapFromMemCache(key) == null) {
memoryCache.put(key, bitmap)
}
val key = "$serverUrl-$userId-$updatedAt"
val cachedKey = "$serverUrl-$userId"
keysCache.put(cachedKey, key)
memoryCache.put(key, bitmap)
}
fun removeBitmap(userId: String, serverUrl: String) {
val cachedKey = "$serverUrl-$userId"
val key = keysCache.get(cachedKey)
if (key != null) {
memoryCache.remove(key)
keysCache.remove(cachedKey)
}
}
fun removeAllBitmaps() {
memoryCache.evictAll()
keysCache.evictAll()
}
}

View File

@@ -34,7 +34,7 @@ public class Credentials {
String service = map.getString("service");
assert service != null;
if (service.isEmpty()) {
String[] credentials = token[0].split(", *");
String[] credentials = token[0].split(",[ ]*");
if (credentials.length == 2) {
token[0] = credentials[0];
}

View File

@@ -21,7 +21,6 @@ import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.app.Person;
@@ -29,7 +28,6 @@ import androidx.core.app.RemoteInput;
import androidx.core.graphics.drawable.IconCompat;
import com.mattermost.rnbeta.*;
import com.nozbe.watermelondb.WMDatabase;
import java.io.IOException;
import java.util.Date;
@@ -39,9 +37,6 @@ import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import static com.mattermost.helpers.database_extension.GeneralKt.getDatabaseForServer;
import static com.mattermost.helpers.database_extension.UserKt.getLastPictureUpdate;
public class CustomPushNotificationHelper {
public static final String CHANNEL_HIGH_IMPORTANCE_ID = "channel_01";
public static final String CHANNEL_MIN_IMPORTANCE_ID = "channel_02";
@@ -52,7 +47,6 @@ public class CustomPushNotificationHelper {
public static final String PUSH_TYPE_MESSAGE = "message";
public static final String PUSH_TYPE_CLEAR = "clear";
public static final String PUSH_TYPE_SESSION = "session";
public static final String CATEGORY_CAN_REPLY = "CAN_REPLY";
private static NotificationChannel mHighImportanceChannel;
private static NotificationChannel mMinImportanceChannel;
@@ -61,7 +55,7 @@ public class CustomPushNotificationHelper {
private static final BitmapCache bitmapCache = new BitmapCache();
private static void addMessagingStyleMessages(Context context, NotificationCompat.MessagingStyle messagingStyle, String conversationTitle, Bundle bundle) {
private static void addMessagingStyleMessages(NotificationCompat.MessagingStyle messagingStyle, String conversationTitle, Bundle bundle) {
String message = bundle.getString("message", bundle.getString("body"));
String senderId = bundle.getString("sender_id");
String serverUrl = bundle.getString("server_url");
@@ -81,9 +75,9 @@ public class CustomPushNotificationHelper {
.setKey(senderId)
.setName(senderName);
if (serverUrl != null && type != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) {
if (serverUrl != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) {
try {
Bitmap avatar = userAvatar(context, serverUrl, senderId, urlOverride);
Bitmap avatar = userAvatar(serverUrl, senderId, urlOverride);
if (avatar != null) {
sender.setIcon(IconCompat.createWithBitmap(avatar));
}
@@ -129,13 +123,11 @@ public class CustomPushNotificationHelper {
notification.addExtras(userInfoBundle);
}
@SuppressLint("UnspecifiedImmutableFlag")
private static void addNotificationReplyAction(Context context, NotificationCompat.Builder notification, Bundle bundle, int notificationId) {
String postId = bundle.getString("post_id");
String serverUrl = bundle.getString("server_url");
boolean canReply = bundle.containsKey("category") && Objects.equals(bundle.getString("category"), CATEGORY_CAN_REPLY);
if (android.text.TextUtils.isEmpty(postId) || serverUrl == null || !canReply) {
if (android.text.TextUtils.isEmpty(postId) || serverUrl == null) {
return;
}
@@ -183,12 +175,12 @@ public class CustomPushNotificationHelper {
String rootId = bundle.getString("root_id");
int notificationId = postId != null ? postId.hashCode() : MESSAGE_NOTIFICATION_ID;
boolean is_crt_enabled = bundle.containsKey("is_crt_enabled") && Objects.equals(bundle.getString("is_crt_enabled"), "true");
boolean is_crt_enabled = bundle.containsKey("is_crt_enabled") && bundle.getString("is_crt_enabled").equals("true");
String groupId = is_crt_enabled && !android.text.TextUtils.isEmpty(rootId) ? rootId : channelId;
addNotificationExtras(notification, bundle);
setNotificationIcons(context, notification, bundle);
setNotificationMessagingStyle(context, notification, bundle);
setNotificationIcons(notification, bundle);
setNotificationMessagingStyle(notification, bundle);
setNotificationGroup(notification, groupId, createSummary);
setNotificationBadgeType(notification);
@@ -264,7 +256,7 @@ public class CustomPushNotificationHelper {
return title;
}
private static NotificationCompat.MessagingStyle getMessagingStyle(Context context, Bundle bundle) {
private static NotificationCompat.MessagingStyle getMessagingStyle(Bundle bundle) {
NotificationCompat.MessagingStyle messagingStyle;
final String senderId = "me";
final String serverUrl = bundle.getString("server_url");
@@ -275,9 +267,9 @@ public class CustomPushNotificationHelper {
.setKey(senderId)
.setName("Me");
if (serverUrl != null && type != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) {
if (serverUrl != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) {
try {
Bitmap avatar = userAvatar(context, serverUrl, "me", urlOverride);
Bitmap avatar = userAvatar(serverUrl, "me", urlOverride);
if (avatar != null) {
sender.setIcon(IconCompat.createWithBitmap(avatar));
}
@@ -290,7 +282,7 @@ public class CustomPushNotificationHelper {
String conversationTitle = getConversationTitle(bundle);
setMessagingStyleConversationTitle(messagingStyle, conversationTitle, bundle);
addMessagingStyleMessages(context, messagingStyle, conversationTitle, bundle);
addMessagingStyleMessages(messagingStyle, conversationTitle, bundle);
return messagingStyle;
}
@@ -372,8 +364,8 @@ public class CustomPushNotificationHelper {
notification.setDeleteIntent(deleteIntent);
}
private static void setNotificationMessagingStyle(Context context, NotificationCompat.Builder notification, Bundle bundle) {
NotificationCompat.MessagingStyle messagingStyle = getMessagingStyle(context, bundle);
private static void setNotificationMessagingStyle(NotificationCompat.Builder notification, Bundle bundle) {
NotificationCompat.MessagingStyle messagingStyle = getMessagingStyle(bundle);
notification.setStyle(messagingStyle);
}
@@ -386,7 +378,7 @@ public class CustomPushNotificationHelper {
}
}
private static void setNotificationIcons(Context context, NotificationCompat.Builder notification, Bundle bundle) {
private static void setNotificationIcons(NotificationCompat.Builder notification, Bundle bundle) {
String channelName = getConversationTitle(bundle);
String senderName = bundle.getString("sender_name");
String serverUrl = bundle.getString("server_url");
@@ -397,7 +389,7 @@ public class CustomPushNotificationHelper {
if (serverUrl != null && channelName.equals(senderName)) {
try {
String senderId = bundle.getString("sender_id");
Bitmap avatar = userAvatar(context, serverUrl, senderId, urlOverride);
Bitmap avatar = userAvatar(serverUrl, senderId, urlOverride);
if (avatar != null) {
notification.setLargeIcon(avatar);
}
@@ -407,33 +399,19 @@ public class CustomPushNotificationHelper {
}
}
private static Bitmap userAvatar(final Context context, @NonNull final String serverUrl, final String userId, final String urlOverride) throws IOException {
private static Bitmap userAvatar(final String serverUrl, final String userId, final String urlOverride) throws IOException {
try {
Response response;
Double lastUpdateAt = 0.0;
if (!TextUtils.isEmpty(urlOverride)) {
Request request = new Request.Builder().url(urlOverride).build();
Log.i("ReactNative", String.format("Fetch override profile image %s", urlOverride));
response = client.newCall(request).execute();
} else {
DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance();
if (dbHelper != null) {
WMDatabase db = getDatabaseForServer(dbHelper, context, serverUrl);
if (db != null) {
lastUpdateAt = getLastPictureUpdate(db, userId);
if (lastUpdateAt == null) {
lastUpdateAt = 0.0;
}
db.close();
}
}
Bitmap cached = bitmapCache.bitmap(userId, lastUpdateAt, serverUrl);
Bitmap cached = bitmapCache.getBitmapFromMemCache(userId);
if (cached != null) {
Bitmap bitmap = cached.copy(cached.getConfig(), false);
return getCircleBitmap(bitmap);
}
bitmapCache.removeBitmap(userId, serverUrl);
String url = String.format("api/v4/users/%s/image", userId);
Log.i("ReactNative", String.format("Fetch profile image %s", url));
response = Network.getSync(serverUrl, url, null);
@@ -444,7 +422,7 @@ public class CustomPushNotificationHelper {
byte[] bytes = Objects.requireNonNull(response.body()).bytes();
Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
if (TextUtils.isEmpty(urlOverride) && !TextUtils.isEmpty(userId)) {
bitmapCache.insertBitmap(bitmap.copy(bitmap.getConfig(), false), userId, lastUpdateAt, serverUrl);
bitmapCache.addBitmapToMemoryCache(userId, bitmap.copy(bitmap.getConfig(), false));
}
return getCircleBitmap(bitmap);
}

View File

@@ -1,31 +1,33 @@
package com.mattermost.helpers
import android.content.Context
import android.database.Cursor
import android.net.Uri
import com.facebook.react.bridge.WritableMap
import com.nozbe.watermelondb.WMDatabase
import java.lang.Exception
import android.text.TextUtils
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.NoSuchKeyException
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.nozbe.watermelondb.Database
import com.nozbe.watermelondb.mapCursor
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
typealias QueryArgs = Array<Any?>
import java.lang.Exception
import java.util.*
class DatabaseHelper {
var defaultDatabase: WMDatabase? = null
private var defaultDatabase: Database? = null
val onlyServerUrl: String?
get() {
try {
val query = "SELECT url FROM Servers WHERE last_active_at != 0 AND identifier != ''"
defaultDatabase!!.rawQuery(query).use { cursor ->
if (cursor.count == 1) {
cursor.moveToFirst()
return cursor.getString(0)
}
val cursor = defaultDatabase!!.rawQuery(query)
if (cursor.count == 1) {
cursor.moveToFirst()
val url = cursor.getString(0)
cursor.close()
return url
}
} catch (e: Exception) {
e.printStackTrace()
@@ -40,13 +42,640 @@ class DatabaseHelper {
}
}
fun getServerUrlForIdentifier(identifier: String): String? {
try {
val args: Array<Any?> = arrayOf(identifier)
val query = "SELECT url FROM Servers WHERE identifier=?"
val cursor = defaultDatabase!!.rawQuery(query, args)
if (cursor.count == 1) {
cursor.moveToFirst()
val url = cursor.getString(0)
cursor.close()
return url
}
} catch (e: Exception) {
e.printStackTrace()
// let it fall to return null
}
return null
}
fun find(db: Database, tableName: String, id: String?): ReadableMap? {
val args: Array<Any?> = arrayOf(id)
try {
db.rawQuery("select * from $tableName where id == ? limit 1", args).use { cursor ->
if (cursor.count <= 0) {
return null
}
val resultMap = Arguments.createMap()
cursor.moveToFirst()
resultMap.mapCursor(cursor)
return resultMap
}
} catch (e: Exception) {
e.printStackTrace()
return null
}
}
fun getDatabaseForServer(context: Context?, serverUrl: String): Database? {
try {
val args: Array<Any?> = arrayOf(serverUrl)
val query = "SELECT db_path FROM Servers WHERE url=?"
val cursor = defaultDatabase!!.rawQuery(query, args)
if (cursor.count == 1) {
cursor.moveToFirst()
val databasePath = cursor.getString(0)
cursor.close()
return Database(databasePath, context!!)
}
} catch (e: Exception) {
e.printStackTrace()
// let it fall to return null
}
return null
}
fun queryIds(db: Database, tableName: String, ids: Array<String>): List<String> {
val list: MutableList<String> = ArrayList()
val args = TextUtils.join(",", Arrays.stream(ids).map { "?" }.toArray())
try {
db.rawQuery("select distinct id from $tableName where id IN ($args)", ids as Array<Any?>).use { cursor ->
if (cursor.count > 0) {
while (cursor.moveToNext()) {
val index = cursor.getColumnIndex("id")
if (index >= 0) {
list.add(cursor.getString(index))
}
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return list
}
fun queryByColumn(db: Database, tableName: String, columnName: String, values: Array<Any?>): List<String> {
val list: MutableList<String> = ArrayList()
val args = TextUtils.join(",", Arrays.stream(values).map { "?" }.toArray())
try {
db.rawQuery("select distinct $columnName from $tableName where $columnName IN ($args)", values).use { cursor ->
if (cursor.count > 0) {
while (cursor.moveToNext()) {
val index = cursor.getColumnIndex(columnName)
if (index >= 0) {
list.add(cursor.getString(index))
}
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return list
}
fun queryCurrentUserId(db: Database): String? {
val result = find(db, "System", "currentUserId")!!
return result.getString("value")
}
private fun queryLastPostCreateAt(db: Database?, channelId: String): Double? {
if (db != null) {
val postsInChannelQuery = "SELECT earliest, latest FROM PostsInChannel WHERE channel_id=? ORDER BY latest DESC LIMIT 1"
val cursor1 = db.rawQuery(postsInChannelQuery, arrayOf(channelId))
if (cursor1.count == 1) {
cursor1.moveToFirst()
val earliest = cursor1.getDouble(0)
val latest = cursor1.getDouble(1)
cursor1.close()
val postQuery = "SELECT create_at FROM POST WHERE channel_id= ? AND delete_at=0 AND create_at BETWEEN ? AND ? ORDER BY create_at DESC"
val cursor2 = db.rawQuery(postQuery, arrayOf(channelId, earliest, latest))
if (cursor2.count >= 60) {
cursor2.moveToFirst()
val createAt = cursor2.getDouble(0)
cursor2.close()
return createAt
}
}
}
return null
}
fun queryPostSinceForChannel(db: Database?, channelId: String): Double? {
try {
if (db != null) {
val postsInChannelQuery = "SELECT last_fetched_at FROM MyChannel WHERE id=? LIMIT 1"
val cursor1 = db.rawQuery(postsInChannelQuery, arrayOf(channelId))
if (cursor1.count == 1) {
cursor1.moveToFirst()
val lastFetchedAt = cursor1.getDouble(0)
cursor1.close()
if (lastFetchedAt == 0.0) {
return queryLastPostCreateAt(db, channelId)
}
return lastFetchedAt
}
}
} catch (e: Exception) {
e.printStackTrace()
// let it fall to return null
}
return null
}
fun handlePosts(db: Database, postsData: ReadableMap?, channelId: String, receivingThreads: Boolean) {
// Posts, PostInChannel, PostInThread, Reactions, Files, CustomEmojis, Users
if (postsData != null) {
val ordered = postsData.getArray("order")?.toArrayList()
val posts = ReadableMapUtils.toJSONObject(postsData.getMap("posts")).toMap()
val previousPostId = postsData.getString("prev_post_id")
val postsInThread = hashMapOf<String, List<JSONObject>>()
val postList = posts.toList()
var earliest = 0.0
var latest = 0.0
var lastFetchedAt = 0.0
if (ordered != null && posts.isNotEmpty()) {
val firstId = ordered.first()
val lastId = ordered.last()
lastFetchedAt = postList.fold(0.0) { acc, next ->
val post = next.second as Map<*, *>
val createAt = post["create_at"] as Double
val updateAt = post["update_at"] as Double
val deleteAt = post["delete_at"] as Double
val value = maxOf(createAt, updateAt, deleteAt)
maxOf(value, acc)
}
var prevPostId = ""
val sortedPosts = postList.sortedBy { (_, value) ->
((value as Map<*, *>)["create_at"] as Double)
}
sortedPosts.forEachIndexed { index, it ->
val key = it.first
if (it.second != null) {
val post = it.second as MutableMap<String, Any?>
if (index == 0) {
post.putIfAbsent("prev_post_id", previousPostId)
} else if (prevPostId.isNotEmpty()) {
post.putIfAbsent("prev_post_id", prevPostId)
}
if (lastId == key) {
earliest = post["create_at"] as Double
}
if (firstId == key) {
latest = post["create_at"] as Double
}
val jsonPost = JSONObject(post)
val rootId = post["root_id"] as? String
if (!rootId.isNullOrEmpty()) {
var thread = postsInThread[rootId]?.toMutableList()
if (thread == null) {
thread = mutableListOf()
}
thread.add(jsonPost)
postsInThread[rootId] = thread.toList()
}
if (find(db, "Post", key) == null) {
insertPost(db, jsonPost)
} else {
updatePost(db, jsonPost)
}
if (ordered.contains(key)) {
prevPostId = key
}
}
}
}
if (!receivingThreads) {
handlePostsInChannel(db, channelId, earliest, latest)
updateMyChannelLastFetchedAt(db, channelId, lastFetchedAt)
}
handlePostsInThread(db, postsInThread)
}
}
fun handleThreads(db: Database, threads: ReadableArray) {
for (i in 0 until threads.size()) {
val thread = threads.getMap(i)
val threadId = thread.getString("id")
// Insert/Update the thread
val existingRecord = find(db, "Thread", threadId)
if (existingRecord == null) {
insertThread(db, thread)
} else {
updateThread(db, thread, existingRecord)
}
// Delete existing and insert thread participants
val participants = thread.getArray("participants")
if (participants != null) {
db.execute("delete from ThreadParticipant where thread_id = ?", arrayOf(threadId))
if (participants.size() > 0) {
insertThreadParticipants(db, threadId!!, participants)
}
}
}
}
fun handleUsers(db: Database, users: ReadableArray) {
for (i in 0 until users.size()) {
val user = users.getMap(i)
val roles = user.getString("roles") ?: ""
val isBot = try {
user.getBoolean("is_bot")
} catch (e: NoSuchKeyException) {
false
}
val lastPictureUpdate = try { user.getDouble("last_picture_update") } catch (e: NoSuchKeyException) { 0 }
db.execute(
"insert into User (id, auth_service, update_at, delete_at, email, first_name, is_bot, is_guest, " +
"last_name, last_picture_update, locale, nickname, position, roles, status, username, notify_props, " +
"props, timezone, _status) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'created')",
arrayOf(
user.getString("id"),
user.getString("auth_service"),
user.getDouble("update_at"),
user.getDouble("delete_at"),
user.getString("email"),
user.getString("first_name"),
isBot,
roles.contains("system_guest"),
user.getString("last_name"),
lastPictureUpdate,
user.getString("locale"),
user.getString("nickname"),
user.getString("position"),
roles,
"",
user.getString("username"),
"{}",
ReadableMapUtils.toJSONObject(user.getMap("props") ?: Arguments.createMap()).toString(),
ReadableMapUtils.toJSONObject(user.getMap("timezone") ?: Arguments.createMap()).toString(),
)
)
}
}
private fun setDefaultDatabase(context: Context) {
val databaseName = "app.db"
val databasePath = Uri.fromFile(context.filesDir).toString() + "/" + databaseName
defaultDatabase = WMDatabase.getInstance(databasePath, context)
defaultDatabase = Database(databasePath, context)
}
internal fun JSONObject.toMap(): Map<String, Any?> = keys().asSequence().associateWith { it ->
private fun insertPost(db: Database, post: JSONObject) {
var metadata: JSONObject?
var reactions: JSONArray? = null
var customEmojis: JSONArray? = null
var files: JSONArray? = null
try {
metadata = post.getJSONObject("metadata")
reactions = metadata.remove("reactions") as JSONArray?
customEmojis = metadata.remove("emojis") as JSONArray?
files = metadata.remove("files") as JSONArray?
} catch (e: Exception) {
// no metadata found
metadata = JSONObject()
}
db.execute(
"insert into Post " +
"(id, channel_id, create_at, delete_at, update_at, edit_at, is_pinned, message, metadata, original_id, pending_post_id, " +
"previous_post_id, root_id, type, user_id, props, _status)" +
" values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'created')",
arrayOf(
post.getString("id"),
post.getString("channel_id"),
post.getDouble("create_at"),
post.getDouble("delete_at"),
post.getDouble("update_at"),
post.getDouble("edit_at"),
post.getBoolean("is_pinned"),
post.getString("message"),
metadata.toString(),
post.getString("original_id"),
post.getString("pending_post_id"),
post.getString("prev_post_id"),
post.getString("root_id"),
post.getString("type"),
post.getString("user_id"),
post.getJSONObject("props").toString()
)
)
if (reactions != null && reactions.length() > 0) {
insertReactions(db, reactions)
}
if (customEmojis != null && customEmojis.length() > 0) {
insertCustomEmojis(db, customEmojis)
}
if (files != null && files.length() > 0) {
insertFiles(db, files)
}
}
private fun updatePost(db: Database, post: JSONObject) {
var metadata: JSONObject?
var reactions: JSONArray? = null
var customEmojis: JSONArray? = null
try {
metadata = post.getJSONObject("metadata")
reactions = metadata.remove("reactions") as JSONArray?
customEmojis = metadata.remove("emojis") as JSONArray?
metadata.remove("files")
} catch (e: Exception) {
// no metadata found
metadata = JSONObject()
}
db.execute(
"update Post SET channel_id = ?, create_at = ?, delete_at = ?, update_at =?, edit_at =?, " +
"is_pinned = ?, message = ?, metadata = ?, original_id = ?, pending_post_id = ?, previous_post_id = ?, " +
"root_id = ?, type = ?, user_id = ?, props = ?, _status = 'updated' " +
"where id = ?",
arrayOf(
post.getString("channel_id"),
post.getDouble("create_at"),
post.getDouble("delete_at"),
post.getDouble("update_at"),
post.getDouble("edit_at"),
post.getBoolean("is_pinned"),
post.getString("message"),
metadata.toString(),
post.getString("original_id"),
post.getString("pending_post_id"),
post.getString("prev_post_id"),
post.getString("root_id"),
post.getString("type"),
post.getString("user_id"),
post.getJSONObject("props").toString(),
post.getString("id"),
)
)
if (reactions != null && reactions.length() > 0) {
db.execute("delete from Reaction where post_id = ?", arrayOf(post.getString("id")))
insertReactions(db, reactions)
}
if (customEmojis != null && customEmojis.length() > 0) {
insertCustomEmojis(db, customEmojis)
}
}
private fun insertThread(db: Database, thread: ReadableMap) {
// These fields are not present when we extract threads from posts
val isFollowing = try { thread.getBoolean("is_following") } catch (e: NoSuchKeyException) { false }
val lastViewedAt = try { thread.getDouble("last_viewed_at") } catch (e: NoSuchKeyException) { 0 }
val unreadReplies = try { thread.getInt("unread_replies") } catch (e: NoSuchKeyException) { 0 }
val unreadMentions = try { thread.getInt("unread_mentions") } catch (e: NoSuchKeyException) { 0 }
val lastReplyAt = try { thread.getDouble("last_reply_at") } catch (e: NoSuchKeyException) { 0 }
val replyCount = try { thread.getInt("reply_count") } catch (e: NoSuchKeyException) { 0 }
db.execute(
"insert into Thread " +
"(id, last_reply_at, last_fetched_at, last_viewed_at, reply_count, is_following, unread_replies, unread_mentions, _status)" +
" values (?, ?, 0, ?, ?, ?, ?, ?, 'created')",
arrayOf(
thread.getString("id"),
lastReplyAt,
lastViewedAt,
replyCount,
isFollowing,
unreadReplies,
unreadMentions
)
)
}
private fun updateThread(db: Database, thread: ReadableMap, existingRecord: ReadableMap) {
// These fields are not present when we extract threads from posts
val isFollowing = try { thread.getBoolean("is_following") } catch (e: NoSuchKeyException) { existingRecord.getInt("is_following") == 1 }
val lastViewedAt = try { thread.getDouble("last_viewed_at") } catch (e: NoSuchKeyException) { existingRecord.getDouble("last_viewed_at") }
val unreadReplies = try { thread.getInt("unread_replies") } catch (e: NoSuchKeyException) { existingRecord.getInt("unread_replies") }
val unreadMentions = try { thread.getInt("unread_mentions") } catch (e: NoSuchKeyException) { existingRecord.getInt("unread_mentions") }
val lastReplyAt = try { thread.getDouble("last_reply_at") } catch (e: NoSuchKeyException) { 0 }
val replyCount = try { thread.getInt("reply_count") } catch (e: NoSuchKeyException) { 0 }
db.execute(
"update Thread SET last_reply_at = ?, last_viewed_at = ?, reply_count = ?, is_following = ?, unread_replies = ?, unread_mentions = ?, _status = 'updated' where id = ?",
arrayOf(
lastReplyAt,
lastViewedAt,
replyCount,
isFollowing,
unreadReplies,
unreadMentions,
thread.getString("id")
)
)
}
private fun insertThreadParticipants(db: Database, threadId: String, participants: ReadableArray) {
for (i in 0 until participants.size()) {
val participant = participants.getMap(i)
val id = RandomId.generate()
db.execute(
"insert into ThreadParticipant " +
"(id, thread_id, user_id, _status)" +
" values (?, ?, ?, 'created')",
arrayOf(
id,
threadId,
participant.getString("id")
)
)
}
}
private fun insertCustomEmojis(db: Database, customEmojis: JSONArray) {
for (i in 0 until customEmojis.length()) {
val emoji = customEmojis.getJSONObject(i)
if(find(db, "CustomEmoji", emoji.getString("id")) == null) {
db.execute(
"insert into CustomEmoji (id, name, _status) values (?, ?, 'created')",
arrayOf(
emoji.getString("id"),
emoji.getString("name"),
)
)
}
}
}
private fun insertFiles(db: Database, files: JSONArray) {
for (i in 0 until files.length()) {
val file = files.getJSONObject(i)
val miniPreview = try { file.getString("mini_preview") } catch (e: JSONException) { "" }
val height = try { file.getInt("height") } catch (e: JSONException) { 0 }
val width = try { file.getInt("width") } catch (e: JSONException) { 0 }
db.execute(
"insert into File (id, extension, height, image_thumbnail, local_path, mime_type, name, post_id, size, width, _status) " +
"values (?, ?, ?, ?, '', ?, ?, ?, ?, ?, 'created')",
arrayOf(
file.getString("id"),
file.getString("extension"),
height,
miniPreview,
file.getString("mime_type"),
file.getString("name"),
file.getString("post_id"),
file.getDouble("size"),
width
)
)
}
}
private fun insertReactions(db: Database, reactions: JSONArray) {
for (i in 0 until reactions.length()) {
val reaction = reactions.getJSONObject(i)
val id = RandomId.generate()
db.execute(
"insert into Reaction (id, create_at, emoji_name, post_id, user_id, _status) " +
"values (?, ?, ?, ?, ?, 'created')",
arrayOf(
id,
reaction.getDouble("create_at"),
reaction.getString("emoji_name"),
reaction.getString("post_id"),
reaction.getString("user_id")
)
)
}
}
private fun handlePostsInChannel(db: Database, channelId: String, earliest: Double, latest: Double) {
db.rawQuery("select id, channel_id, earliest, latest from PostsInChannel where channel_id = ?", arrayOf(channelId)).use { cursor ->
if (cursor.count == 0) {
// create new post in channel
insertPostInChannel(db, channelId, earliest, latest)
return
}
val resultArray = Arguments.createArray()
while (cursor.moveToNext()) {
val cursorMap = Arguments.createMap()
cursorMap.mapCursor(cursor)
resultArray.pushMap(cursorMap)
}
val chunk = findPostInChannel(resultArray, earliest, latest)
if (chunk != null) {
db.execute(
"update PostsInChannel set earliest = ?, latest = ?, _status = 'updated' where id = ?",
arrayOf(
minOf(earliest, chunk.getDouble("earliest")),
maxOf(latest, chunk.getDouble("latest")),
chunk.getString("id")
)
)
return
}
val newChunk = insertPostInChannel(db, channelId, earliest, latest)
mergePostsInChannel(db, resultArray, newChunk)
}
}
private fun updateMyChannelLastFetchedAt(db: Database, channelId: String, lastFetchedAt: Double) {
db.execute(
"UPDATE MyChannel SET last_fetched_at = ?, _status = 'updated' WHERE id = ?",
arrayOf(
lastFetchedAt,
channelId
)
)
}
private fun findPostInChannel(chunks: ReadableArray, earliest: Double, latest: Double): ReadableMap? {
for (i in 0 until chunks.size()) {
val chunk = chunks.getMap(i)
if (earliest >= chunk.getDouble("earliest") || latest <= chunk.getDouble("latest")) {
return chunk
}
}
return null
}
private fun insertPostInChannel(db: Database, channelId: String, earliest: Double, latest: Double): ReadableMap {
val id = RandomId.generate()
db.execute("insert into PostsInChannel (id, channel_id, earliest, latest, _status) values (?, ?, ?, ?, 'created')",
arrayOf(id, channelId, earliest, latest))
val map = Arguments.createMap()
map.putString("id", id)
map.putString("channel_id", channelId)
map.putDouble("earliest", earliest)
map.putDouble("latest", latest)
return map
}
private fun mergePostsInChannel(db: Database, existingChunks: ReadableArray, newChunk: ReadableMap) {
for (i in 0 until existingChunks.size()) {
val chunk = existingChunks.getMap(i)
if (newChunk.getDouble("earliest") <= chunk.getDouble("earliest") &&
newChunk.getDouble("latest") >= chunk.getDouble("latest")) {
db.execute("delete from PostsInChannel where id = ?", arrayOf(chunk.getString("id")))
break
}
}
}
private fun handlePostsInThread(db: Database, postsInThread: Map<String, List<JSONObject>>) {
postsInThread.forEach { (key, list) ->
val sorted = list.sortedBy { it.getDouble("create_at") }
val earliest = sorted.first().getDouble("create_at")
val latest = sorted.last().getDouble("create_at")
db.rawQuery("select * from PostsInThread where root_id = ? order by latest desc", arrayOf(key)).use { cursor ->
if (cursor.count > 0) {
cursor.moveToFirst()
val cursorMap = Arguments.createMap()
cursorMap.mapCursor(cursor)
db.execute(
"update PostsInThread set earliest = ?, latest = ?, _status = 'updated' where id = ?",
arrayOf(
minOf(earliest, cursorMap.getDouble("earliest")),
maxOf(latest, cursorMap.getDouble("latest")),
key
)
)
return
}
val id = RandomId.generate()
db.execute(
"insert into PostsInThread (id, root_id, earliest, latest, _status) " +
"values (?, ?, ?, ?, 'created')",
arrayOf(id, key, earliest, latest)
)
}
}
}
private fun JSONObject.toMap(): Map<String, *> = keys().asSequence().associateWith { it ->
when (val value = this[it])
{
is JSONArray ->
@@ -54,15 +683,9 @@ class DatabaseHelper {
val map = (0 until value.length()).associate { Pair(it.toString(), value[it]) }
JSONObject(map).toMap().values.toList()
}
is JSONObject -> {
value.toMap()
}
JSONObject.NULL -> {
null
}
else -> {
value
}
is JSONObject -> value.toMap()
JSONObject.NULL -> null
else -> value
}
}
@@ -77,15 +700,3 @@ class DatabaseHelper {
private set
}
}
fun WritableMap.mapCursor(cursor: Cursor) {
for (i in 0 until cursor.columnCount) {
when (cursor.getType(i)) {
Cursor.FIELD_TYPE_NULL -> putNull(cursor.getColumnName(i))
Cursor.FIELD_TYPE_INTEGER -> putDouble(cursor.getColumnName(i), cursor.getDouble(i))
Cursor.FIELD_TYPE_FLOAT -> putDouble(cursor.getColumnName(i), cursor.getDouble(i))
Cursor.FIELD_TYPE_STRING -> putString(cursor.getColumnName(i), cursor.getString(i))
else -> putString(cursor.getColumnName(i), "")
}
}
}

View File

@@ -21,7 +21,7 @@ public class Network {
private static final Promise emptyPromise = new ResolvePromise();
public static void init(Context context) {
final ReactApplicationContext reactContext = (APIClientModule.context == null) ? new ReactApplicationContext(context) : APIClientModule.context;
final ReactApplicationContext reactContext = new ReactApplicationContext(context);
clientModule = new APIClientModule(reactContext);
createClientOptions();
}

View File

@@ -4,151 +4,292 @@ import android.content.Context
import android.os.Bundle
import android.util.Log
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.database_extension.*
import com.mattermost.helpers.push_notification.*
import com.facebook.react.bridge.WritableNativeArray
import com.nozbe.watermelondb.Database
import java.io.IOException
import java.util.concurrent.Executors
import kotlin.coroutines.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class PushNotificationDataHelper(private val context: Context) {
private var coroutineScope = CoroutineScope(Dispatchers.Default)
fun fetchAndStoreDataForPushNotification(initialData: Bundle, isReactInit: Boolean): Bundle? {
var result: Bundle? = null
val job = coroutineScope.launch(Dispatchers.Default) {
result = PushNotificationDataRunnable.start(context, initialData, isReactInit)
}
runBlocking {
job.join()
}
return result
private var scope = Executors.newSingleThreadExecutor()
fun fetchAndStoreDataForPushNotification(initialData: Bundle) {
scope.execute(Runnable {
runBlocking {
PushNotificationDataRunnable.start(context, initialData)
}
})
}
}
class PushNotificationDataRunnable {
companion object {
internal val specialMentions = listOf("all", "here", "channel")
private val dbHelper = DatabaseHelper.instance!!
private val mutex = Mutex()
private val specialMentions = listOf("all", "here", "channel")
suspend fun start(context: Context, initialData: Bundle, isReactInit: Boolean): Bundle? {
// for more info see: https://blog.danlew.net/2020/01/28/coroutines-and-java-synchronization-dont-mix/
mutex.withLock {
val serverUrl: String = initialData.getString("server_url") ?: return null
val db = dbHelper.getDatabaseForServer(context, serverUrl)
var result: Bundle? = null
@Synchronized
suspend fun start(context: Context, initialData: Bundle) {
try {
val serverUrl: String = initialData.getString("server_url") ?: return
val channelId = initialData.getString("channel_id")
val rootId = initialData.getString("root_id")
val isCRTEnabled = initialData.getString("is_crt_enabled") == "true"
val db = DatabaseHelper.instance!!.getDatabaseForServer(context, serverUrl)
Log.i("ReactNative", "Start fetching notification data in server="+serverUrl+" for channel="+channelId)
try {
if (db != null) {
val teamId = initialData.getString("team_id")
val channelId = initialData.getString("channel_id")
val postId = initialData.getString("post_id")
val rootId = initialData.getString("root_id")
val isCRTEnabled = initialData.getString("is_crt_enabled") == "true"
if (db != null) {
var postData: ReadableMap?
var posts: ReadableMap? = null
var userIdsToLoad: ReadableArray? = null
var usernamesToLoad: ReadableArray? = null
Log.i("ReactNative", "Start fetching notification data in server=$serverUrl for channel=$channelId")
var threads: ReadableArray? = null
var usersFromThreads: ReadableArray? = null
val receivingThreads = isCRTEnabled && !rootId.isNullOrEmpty()
val receivingThreads = isCRTEnabled && !rootId.isNullOrEmpty()
val notificationData = Arguments.createMap()
coroutineScope {
if (channelId != null) {
postData = fetchPosts(db, serverUrl, channelId, isCRTEnabled, rootId)
if (!teamId.isNullOrEmpty()) {
val res = fetchTeamIfNeeded(db, serverUrl, teamId)
res.first?.let { notificationData.putMap("team", it) }
res.second?.let { notificationData.putMap("myTeam", it) }
}
posts = postData?.getMap("posts")
userIdsToLoad = postData?.getArray("userIdsToLoad")
usernamesToLoad = postData?.getArray("usernamesToLoad")
threads = postData?.getArray("threads")
usersFromThreads = postData?.getArray("usersFromThreads")
if (channelId != null && postId != null) {
val channelRes = fetchMyChannel(db, serverUrl, channelId, isCRTEnabled)
channelRes.first?.let { notificationData.putMap("channel", it) }
channelRes.second?.let { notificationData.putMap("myChannel", it) }
val loadedProfiles = channelRes.third
// Fetch categories if needed
if (!teamId.isNullOrEmpty() && notificationData.getMap("myTeam") != null) {
// should load all categories
val res = fetchMyTeamCategories(db, serverUrl, teamId)
res?.let { notificationData.putMap("categories", it) }
} else if (notificationData.getMap("channel") != null) {
// check if the channel is in the category for the team
val res = addToDefaultCategoryIfNeeded(db, notificationData.getMap("channel")!!)
res?.let { notificationData.putArray("categoryChannels", it) }
if (userIdsToLoad != null && userIdsToLoad!!.size() > 0) {
val users = fetchUsersById(serverUrl, userIdsToLoad!!)
userIdsToLoad = users?.getArray("data")
}
val postData = fetchPosts(db, serverUrl, channelId, isCRTEnabled, rootId, loadedProfiles)
postData?.getMap("posts")?.let { notificationData.putMap("posts", it) }
var notificationThread: ReadableMap? = null
if (isCRTEnabled && !rootId.isNullOrEmpty()) {
notificationThread = fetchThread(db, serverUrl, rootId, teamId)
if (usernamesToLoad != null && usernamesToLoad!!.size() > 0) {
val users = fetchUsersByUsernames(serverUrl, usernamesToLoad!!)
usernamesToLoad = users?.getArray("data")
}
getThreadList(notificationThread, postData?.getArray("threads"))?.let {
val threadsArray = Arguments.createArray()
for(item in it) {
threadsArray.pushMap(item)
}
notificationData.putArray("threads", threadsArray)
}
val userList = fetchNeededUsers(serverUrl, loadedProfiles, postData)
notificationData.putArray("users", ReadableArrayUtils.toWritableArray(userList.toArray()))
}
result = Arguments.toBundle(notificationData)
if (!isReactInit) {
dbHelper.saveToDatabase(db, notificationData, teamId, channelId, receivingThreads)
}
Log.i("ReactNative", "Done processing push notification=$serverUrl for channel=$channelId")
}
} catch (e: Exception) {
e.printStackTrace()
} finally {
db?.close()
Log.i("ReactNative", "DONE fetching notification data")
}
return result
db.transaction {
if (posts != null && channelId != null) {
DatabaseHelper.instance!!.handlePosts(db, posts!!.getMap("data"), channelId, receivingThreads)
}
if (threads != null) {
DatabaseHelper.instance!!.handleThreads(db, threads!!)
}
if (userIdsToLoad != null && userIdsToLoad!!.size() > 0) {
DatabaseHelper.instance!!.handleUsers(db, userIdsToLoad!!)
}
if (usernamesToLoad != null && usernamesToLoad!!.size() > 0) {
DatabaseHelper.instance!!.handleUsers(db, usernamesToLoad!!)
}
if (usersFromThreads != null) {
DatabaseHelper.instance!!.handleUsers(db, usersFromThreads!!)
}
}
db.close()
Log.i("ReactNative", "Done processing push notification="+serverUrl+" for channel="+channelId)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun getThreadList(notificationThread: ReadableMap?, threads: ReadableArray?): ArrayList<ReadableMap>? {
threads?.let {
val threadsArray = ArrayList<ReadableMap>()
val threadIds = ArrayList<String>()
notificationThread?.let { thread ->
thread.getString("id")?.let { it1 -> threadIds.add(it1) }
threadsArray.add(thread)
}
for(i in 0 until it.size()) {
val thread = it.getMap(i)
val threadId = thread.getString("id")
if (threadId != null) {
if (threadIds.contains(threadId)) {
// replace the values for participants and is_following
val index = threadsArray.indexOfFirst { el -> el.getString("id") == threadId }
val prev = threadsArray[index]
val merge = Arguments.createMap()
merge.merge(prev)
merge.putBoolean("is_following", thread.getBoolean("is_following"))
merge.putArray("participants", thread.getArray("participants"))
threadsArray[index] = merge
} else {
threadsArray.add(thread)
threadIds.add(threadId)
private suspend fun fetchPosts(db: Database, serverUrl: String, channelId: String, isCRTEnabled: Boolean, rootId: String?): ReadableMap? {
val regex = Regex("""\B@(([a-z0-9-._]*[a-z0-9_])[.-]*)""", setOf(RegexOption.IGNORE_CASE))
val since = DatabaseHelper.instance!!.queryPostSinceForChannel(db, channelId)
val currentUserId = DatabaseHelper.instance!!.queryCurrentUserId(db)?.removeSurrounding("\"")
val currentUser = DatabaseHelper.instance!!.find(db, "User", currentUserId)
val currentUsername = currentUser?.getString("username")
var additionalParams = ""
if (isCRTEnabled) {
additionalParams = "&collapsedThreads=true&collapsedThreadsExtended=true"
}
val receivingThreads = isCRTEnabled && !rootId.isNullOrEmpty()
val endpoint = if (receivingThreads) {
val queryParams = "?skipFetchThreads=false&perPage=60&fromCreatedAt=0&direction=up"
"/api/v4/posts/$rootId/thread$queryParams$additionalParams"
} else {
val queryParams = if (since == null) "?page=0&per_page=60" else "?since=${since.toLong()}"
"/api/v4/channels/$channelId/posts$queryParams$additionalParams"
}
val postsResponse = fetch(serverUrl, endpoint)
val results = Arguments.createMap()
if (postsResponse != null) {
val data = ReadableMapUtils.toMap(postsResponse)
results.putMap("posts", postsResponse)
val postsData = data["data"] as? Map<*, *>
if (postsData != null) {
val postsMap = postsData["posts"]
if (postsMap != null) {
val posts = ReadableMapUtils.toWritableMap(postsMap as? Map<String, Any>)
val iterator = posts.keySetIterator()
val userIds = mutableListOf<String>()
val usernames = mutableListOf<String>()
val threads = WritableNativeArray()
val threadParticipantUserIds = mutableListOf<String>() // Used to exclude the "userIds" present in the thread participants
val threadParticipantUsernames = mutableListOf<String>() // Used to exclude the "usernames" present in the thread participants
val threadParticipantUsers = HashMap<String, ReadableMap>() // All unique users from thread participants are stored here
while(iterator.hasNextKey()) {
val key = iterator.nextKey()
val post = posts.getMap(key)
val userId = post?.getString("user_id")
if (userId != null && userId != currentUserId && !userIds.contains(userId)) {
userIds.add(userId)
}
val message = post?.getString("message")
if (message != null) {
val matchResults = regex.findAll(message)
matchResults.iterator().forEach {
val username = it.value.removePrefix("@")
if (!usernames.contains(username) && currentUsername != username && !specialMentions.contains(username)) {
usernames.add(username)
}
}
}
if (isCRTEnabled) {
// Add root post as a thread
val threadId = post?.getString("root_id")
if (threadId.isNullOrEmpty()) {
threads.pushMap(post!!)
}
// Add participant userIds and usernames to exclude them from getting fetched again
val participants = post.getArray("participants")
if (participants != null) {
for (i in 0 until participants.size()) {
val participant = participants.getMap(i)
val participantId = participant.getString("id")
if (participantId != currentUserId && participantId != null) {
if (!threadParticipantUserIds.contains(participantId)) {
threadParticipantUserIds.add(participantId)
}
if (!threadParticipantUsers.containsKey(participantId)) {
threadParticipantUsers[participantId] = participant
}
}
val username = participant.getString("username")
if (username != null && username != currentUsername && !threadParticipantUsernames.contains(username)) {
threadParticipantUsernames.add(username)
}
}
}
}
}
val existingUserIds = DatabaseHelper.instance!!.queryIds(db, "User", userIds.toTypedArray())
val existingUsernames = DatabaseHelper.instance!!.queryByColumn(db, "User", "username", usernames.toTypedArray())
userIds.removeAll { it in existingUserIds }
usernames.removeAll { it in existingUsernames }
if (threadParticipantUserIds.size > 0) {
// Do not fetch users found in thread participants as we get the user's data in the posts response already
userIds.removeAll { it in threadParticipantUserIds }
usernames.removeAll { it in threadParticipantUsernames }
// Get users from thread participants
val existingThreadParticipantUserIds = DatabaseHelper.instance!!.queryIds(db, "User", threadParticipantUserIds.toTypedArray())
// Exclude the thread participants already present in the DB from getting inserted again
val usersFromThreads = WritableNativeArray()
threadParticipantUsers.forEach{ (userId, user) ->
if (!existingThreadParticipantUserIds.contains(userId)) {
usersFromThreads.pushMap(user)
}
}
if (usersFromThreads.size() > 0) {
results.putArray("usersFromThreads", usersFromThreads)
}
}
if (userIds.size > 0) {
results.putArray("userIdsToLoad", ReadableArrayUtils.toWritableArray(userIds.toTypedArray()))
}
if (usernames.size > 0) {
results.putArray("usernamesToLoad", ReadableArrayUtils.toWritableArray(usernames.toTypedArray()))
}
if (threads.size() > 0) {
results.putArray("threads", threads)
}
}
}
return threadsArray
}
return results
}
return null
private suspend fun fetchUsersById(serverUrl: String, userIds: ReadableArray): ReadableMap? {
val endpoint = "api/v4/users/ids"
val options = Arguments.createMap()
options.putArray("body", ReadableArrayUtils.toWritableArray(ReadableArrayUtils.toArray(userIds)))
return fetchWithPost(serverUrl, endpoint, options)
}
private suspend fun fetchUsersByUsernames(serverUrl: String, usernames: ReadableArray): ReadableMap? {
val endpoint = "api/v4/users/usernames"
val options = Arguments.createMap()
options.putArray("body", ReadableArrayUtils.toWritableArray(ReadableArrayUtils.toArray(usernames)))
return fetchWithPost(serverUrl, endpoint, options)
}
private suspend fun fetch(serverUrl: String, endpoint: String): ReadableMap? {
return suspendCoroutine { cont ->
Network.get(serverUrl, endpoint, null, object : ResolvePromise() {
override fun resolve(value: Any?) {
val response = value as ReadableMap?
if (response != null && !response.getBoolean("ok")) {
val error = response.getMap("data")
cont.resumeWith(Result.failure((IOException("Unexpected code ${error?.getInt("status_code")} ${error?.getString("message")}"))))
} else {
cont.resumeWith(Result.success(response))
}
}
override fun reject(code: String, message: String) {
cont.resumeWith(Result.failure(IOException("Unexpected code $code $message")))
}
override fun reject(reason: Throwable?) {
cont.resumeWith(Result.failure(IOException("Unexpected code $reason")))
}
})
}
}
private suspend fun fetchWithPost(serverUrl: String, endpoint: String, options: ReadableMap?) : ReadableMap? {
return suspendCoroutine { cont ->
Network.post(serverUrl, endpoint, options, object : ResolvePromise() {
override fun resolve(value: Any?) {
val response = value as ReadableMap?
cont.resumeWith(Result.success(response))
}
override fun reject(code: String, message: String) {
cont.resumeWith(Result.failure(IOException("Unexpected code $code $message")))
}
override fun reject(reason: Throwable?) {
cont.resumeWith(Result.failure(IOException("Unexpected code $reason")))
}
})
}
}
}
}

View File

@@ -2,7 +2,6 @@ package com.mattermost.helpers;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.ReadableType;
import com.facebook.react.bridge.WritableArray;
@@ -110,9 +109,7 @@ public class ReadableArrayUtils {
writableArray.pushString((String) value);
} else if (value instanceof Map) {
writableArray.pushMap(ReadableMapUtils.toWritableMap((Map<String, Object>) value));
} else if (value instanceof ReadableMap) {
writableArray.pushMap((ReadableMap) value);
}else if (value.getClass().isArray()) {
} else if (value.getClass().isArray()) {
writableArray.pushArray(ReadableArrayUtils.toWritableArray((Object[]) value));
}
}

View File

@@ -122,37 +122,24 @@ public class RealPathUtil {
File cacheDir = new File(context.getCacheDir(), CACHE_DIR_NAME);
boolean cacheDirExists = cacheDir.exists();
if (!cacheDirExists) {
cacheDirExists = cacheDir.mkdirs();
if (!cacheDir.exists()) {
cacheDir.mkdirs();
}
if (cacheDirExists) {
tmpFile = new File(cacheDir, fileName);
boolean fileCreated = tmpFile.createNewFile();
tmpFile = new File(cacheDir, fileName);
tmpFile.createNewFile();
if (fileCreated) {
ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(uri, "r");
ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(uri, "r");
try (FileInputStream inputSrc = new FileInputStream(pfd.getFileDescriptor())) {
FileChannel src = inputSrc.getChannel();
try (FileOutputStream outputDst = new FileOutputStream(tmpFile)) {
FileChannel dst = outputDst.getChannel();
dst.transferFrom(src, 0, src.size());
src.close();
dst.close();
}
}
pfd.close();
}
return tmpFile.getAbsolutePath();
}
FileChannel src = new FileInputStream(pfd.getFileDescriptor()).getChannel();
FileChannel dst = new FileOutputStream(tmpFile).getChannel();
dst.transferFrom(src, 0, src.size());
src.close();
dst.close();
} catch (IOException ex) {
ex.printStackTrace();
return null;
}
return null;
return tmpFile.getAbsolutePath();
}
public static String getDataColumn(Context context, Uri uri, String selection,
@@ -258,9 +245,7 @@ public class RealPathUtil {
}
}
if (!fileOrDirectory.delete()) {
Log.i("ReactNative", "Couldn't delete file " + fileOrDirectory.getName());
}
fileOrDirectory.delete();
}
private static String sanitizeFilename(String filename) {
@@ -271,4 +256,22 @@ public class RealPathUtil {
File f = new File(filename);
return f.getName();
}
public static File createDirIfNotExists(String path) {
File dir = new File(path);
if (dir.exists()) {
return dir;
}
try {
dir.mkdirs();
// Add .nomedia to hide the thumbnail directory from gallery
File noMedia = new File(path, ".nomedia");
noMedia.createNewFile();
} catch (IOException e) {
e.printStackTrace();
}
return dir;
}
}

View File

@@ -1,87 +0,0 @@
package com.mattermost.helpers.database_extension
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.nozbe.watermelondb.WMDatabase
fun insertCategory(db: WMDatabase, category: ReadableMap) {
try {
val id = category.getString("id") ?: return
val collapsed = false
val displayName = category.getString("display_name")
val muted = category.getBoolean("muted")
val sortOrder = category.getInt("sort_order")
val sorting = category.getString("sorting") ?: "recent"
val teamId = category.getString("team_id")
val type = category.getString("type")
db.execute(
"""
INSERT INTO Category
(id, collapsed, display_name, muted, sort_order, sorting, team_id, type, _changed, _status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(
id, collapsed, displayName, muted,
sortOrder / 10, sorting, teamId, type
)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
fun insertCategoryChannels(db: WMDatabase, categoryId: String, teamId: String, channelIds: ReadableArray) {
try {
for (i in 0 until channelIds.size()) {
val channelId = channelIds.getString(i)
val id = "${teamId}_$channelId"
db.execute(
"""
INSERT INTO CategoryChannel
(id, category_id, channel_id, sort_order, _changed, _status)
VALUES (?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(id, categoryId, channelId, i)
)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
fun insertCategoriesWithChannels(db: WMDatabase, orderCategories: ReadableMap) {
val categories = orderCategories.getArray("categories") ?: return
for (i in 0 until categories.size()) {
val category = categories.getMap(i)
val id = category.getString("id")
val teamId = category.getString("team_id")
val channelIds = category.getArray("channel_ids")
insertCategory(db, category)
if (id != null && teamId != null) {
channelIds?.let { insertCategoryChannels(db, id, teamId, it) }
}
}
}
fun insertChannelToDefaultCategory(db: WMDatabase, categoryChannels: ReadableArray) {
try {
for (i in 0 until categoryChannels.size()) {
val cc = categoryChannels.getMap(i)
val id = cc.getString("id")
val categoryId = cc.getString("category_id")
val channelId = cc.getString("channel_id")
val count = countByColumn(db, "CategoryChannel", "category_id", categoryId)
db.execute(
"""
INSERT INTO CategoryChannel
(id, category_id, channel_id, sort_order, _changed, _status)
VALUES (?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(id, categoryId, channelId, if (count > 0) count + 1 else count)
)
}
} catch (e: Exception) {
e.printStackTrace()
}
}

View File

@@ -1,220 +0,0 @@
package com.mattermost.helpers.database_extension
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.DatabaseHelper
import com.mattermost.helpers.ReadableMapUtils
import com.nozbe.watermelondb.WMDatabase
import org.json.JSONException
import org.json.JSONObject
fun findChannel(db: WMDatabase?, channelId: String): Boolean {
if (db != null) {
val team = find(db, "Channel", channelId)
return team != null
}
return false
}
fun findMyChannel(db: WMDatabase?, channelId: String): Boolean {
if (db != null) {
val team = find(db, "MyChannel", channelId)
return team != null
}
return false
}
internal fun handleChannel(db: WMDatabase, channel: ReadableMap) {
try {
val exists = channel.getString("id")?.let { findChannel(db, it) } ?: false
if (!exists) {
val json = ReadableMapUtils.toJSONObject(channel)
if (insertChannel(db, json)) {
insertChannelInfo(db, json)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
internal fun DatabaseHelper.handleMyChannel(db: WMDatabase, myChannel: ReadableMap, postsData: ReadableMap?, receivingThreads: Boolean) {
try {
val json = ReadableMapUtils.toJSONObject(myChannel)
val exists = myChannel.getString("id")?.let { findMyChannel(db, it) } ?: false
if (postsData != null && !receivingThreads) {
val posts = ReadableMapUtils.toJSONObject(postsData.getMap("posts")).toMap()
val postList = posts.toList()
val lastFetchedAt = postList.fold(0.0) { acc, next ->
val post = next.second as Map<*, *>
val createAt = post["create_at"] as Double
val updateAt = post["update_at"] as Double
val deleteAt = post["delete_at"] as Double
val value = maxOf(createAt, updateAt, deleteAt)
maxOf(value, acc)
}
json.put("last_fetched_at", lastFetchedAt)
}
if (exists) {
updateMyChannel(db, json)
return
}
if (insertMyChannel(db, json)) {
insertMyChannelSettings(db, json)
insertChannelMember(db, json)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
fun insertChannel(db: WMDatabase, channel: JSONObject): Boolean {
val id = try { channel.getString("id") } catch (e: JSONException) { return false }
val createAt = try { channel.getDouble("create_at") } catch (e: JSONException) { 0 }
val deleteAt = try { channel.getDouble("delete_at") } catch (e: JSONException) { 0 }
val updateAt = try { channel.getDouble("update_at") } catch (e: JSONException) { 0 }
val creatorId = try { channel.getString("creator_id") } catch (e: JSONException) { "" }
val displayName = try { channel.getString("display_name") } catch (e: JSONException) { "" }
val name = try { channel.getString("name") } catch (e: JSONException) { "" }
val teamId = try { channel.getString("team_id") } catch (e: JSONException) { "" }
val type = try { channel.getString("type") } catch (e: JSONException) { "O" }
val isGroupConstrained = try { channel.getBoolean("group_constrained") } catch (e: JSONException) { false }
val shared = try { channel.getBoolean("shared") } catch (e: JSONException) { false }
return try {
db.execute(
"""
INSERT INTO Channel
(id, create_at, delete_at, update_at, creator_id, display_name, name, team_id, type, is_group_constrained, shared, _changed, _status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(
id, createAt, deleteAt, updateAt,
creatorId, displayName, name, teamId, type,
isGroupConstrained, shared
)
)
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
fun insertChannelInfo(db: WMDatabase, channel: JSONObject) {
val id = try { channel.getString("id") } catch (e: JSONException) { return }
val header = try { channel.getString("header") } catch (e: JSONException) { "" }
val purpose = try { channel.getString("purpose") } catch (e: JSONException) { "" }
try {
db.execute(
"""
INSERT INTO ChannelInfo
(id, header, purpose, guest_count, member_count, pinned_post_count, _changed, _status)
VALUES (?, ?, ?, 0, 0, 0, '', 'created')
""".trimIndent(),
arrayOf(id, header, purpose)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
fun insertMyChannel(db: WMDatabase, myChanel: JSONObject): Boolean {
return try {
val id = try { myChanel.getString("id") } catch (e: JSONException) { return false }
val roles = try { myChanel.getString("roles") } catch (e: JSONException) { "" }
val msgCount = try { myChanel.getInt("message_count") } catch (e: JSONException) { 0 }
val mentionsCount = try { myChanel.getInt("mentions_count") } catch (e: JSONException) { 0 }
val isUnread = try { myChanel.getBoolean("is_unread") } catch (e: JSONException) { false }
val lastPostAt = try { myChanel.getDouble("last_post_at") } catch (e: JSONException) { 0 }
val lastViewedAt = try { myChanel.getDouble("last_viewed_at") } catch (e: JSONException) { 0 }
val viewedAt = 0
val lastFetchedAt = try { myChanel.getDouble("last_fetched_at") } catch (e: JSONException) { 0 }
val manuallyUnread = false
db.execute(
"""
INSERT INTO MyChannel
(id, roles, message_count, mentions_count, is_unread, manually_unread,
last_post_at, last_viewed_at, viewed_at, last_fetched_at, _changed, _status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'created')
""",
arrayOf(
id, roles, msgCount, mentionsCount, isUnread, manuallyUnread,
lastPostAt, lastViewedAt, viewedAt, lastFetchedAt
)
)
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
fun insertMyChannelSettings(db: WMDatabase, myChanel: JSONObject) {
try {
val id = try { myChanel.getString("id") } catch (e: JSONException) { return }
val notifyProps = try { myChanel.getString("notify_props") } catch (e: JSONException) { return }
db.execute(
"""
INSERT INTO MyChannelSettings (id, notify_props, _changed, _status)
VALUES (?, ?, '', 'created')
""",
arrayOf(id, notifyProps)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
fun insertChannelMember(db: WMDatabase, myChanel: JSONObject) {
try {
val userId = queryCurrentUserId(db) ?: return
val channelId = try { myChanel.getString("id") } catch (e: JSONException) { return }
val schemeAdmin = try { myChanel.getBoolean("scheme_admin") } catch (e: JSONException) { false }
val id = "$channelId-$userId"
db.execute(
"""
INSERT INTO ChannelMembership
(id, channel_id, user_id, scheme_admin, _changed, _status)
VALUES (?, ?, ?, ?, '', 'created')
""",
arrayOf(id, channelId, userId, schemeAdmin)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
fun updateMyChannel(db: WMDatabase, myChanel: JSONObject) {
try {
val id = try { myChanel.getString("id") } catch (e: JSONException) { return }
val msgCount = try { myChanel.getInt("message_count") } catch (e: JSONException) { 0 }
val mentionsCount = try { myChanel.getInt("mentions_count") } catch (e: JSONException) { 0 }
val isUnread = try { myChanel.getBoolean("is_unread") } catch (e: JSONException) { false }
val lastPostAt = try { myChanel.getDouble("last_post_at") } catch (e: JSONException) { 0 }
val lastViewedAt = try { myChanel.getDouble("last_viewed_at") } catch (e: JSONException) { 0 }
val lastFetchedAt = try { myChanel.getDouble("last_fetched_at") } catch (e: JSONException) { 0 }
db.execute(
"""
UPDATE MyChannel SET message_count=?, mentions_count=?, is_unread=?,
last_post_at=?, last_viewed_at=?, last_fetched_at=?, _status = 'updated'
WHERE id=?
""",
arrayOf(
msgCount, mentionsCount, isUnread,
lastPostAt, lastViewedAt, lastFetchedAt, id
)
)
} catch (e: Exception) {
e.printStackTrace()
}
}

View File

@@ -1,23 +0,0 @@
package com.mattermost.helpers.database_extension
import com.nozbe.watermelondb.WMDatabase
import org.json.JSONArray
internal fun insertCustomEmojis(db: WMDatabase, customEmojis: JSONArray) {
for (i in 0 until customEmojis.length()) {
try {
val emoji = customEmojis.getJSONObject(i)
if (find(db, "CustomEmoji", emoji.getString("id")) == null) {
db.execute(
"INSERT INTO CustomEmoji (id, name, _changed, _status) VALUES (?, ?, '', 'created')",
arrayOf(
emoji.getString("id"),
emoji.getString("name"),
)
)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}

View File

@@ -1,35 +0,0 @@
package com.mattermost.helpers.database_extension
import com.nozbe.watermelondb.WMDatabase
import org.json.JSONArray
import org.json.JSONException
internal fun insertFiles(db: WMDatabase, files: JSONArray) {
try {
for (i in 0 until files.length()) {
val file = files.getJSONObject(i)
val id = file.getString("id")
val extension = file.getString("extension")
val miniPreview = try { file.getString("mini_preview") } catch (e: JSONException) { "" }
val height = try { file.getInt("height") } catch (e: JSONException) { 0 }
val mime = file.getString("mime_type")
val name = file.getString("name")
val postId = file.getString("post_id")
val size = try { file.getDouble("size") } catch (e: JSONException) { 0 }
val width = try { file.getInt("width") } catch (e: JSONException) { 0 }
db.execute(
"""
INSERT INTO File
(id, extension, height, image_thumbnail, local_path, mime_type, name, post_id, size, width, _changed, _status)
VALUES (?, ?, ?, ?, '', ?, ?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(
id, extension, height, miniPreview,
mime, name, postId, size, width
)
)
}
} catch (e: Exception) {
e.printStackTrace()
}
}

View File

@@ -1,168 +0,0 @@
package com.mattermost.helpers.database_extension
import android.content.Context
import android.text.TextUtils
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.DatabaseHelper
import com.mattermost.helpers.QueryArgs
import com.mattermost.helpers.mapCursor
import com.nozbe.watermelondb.WMDatabase
import java.util.*
import kotlin.Exception
internal fun DatabaseHelper.saveToDatabase(db: WMDatabase, data: ReadableMap, teamId: String?, channelId: String?, receivingThreads: Boolean) {
db.transaction {
val posts = data.getMap("posts")
data.getMap("team")?.let { insertTeam(db, it) }
data.getMap("myTeam")?.let { insertMyTeam(db, it) }
data.getMap("channel")?.let { handleChannel(db, it) }
data.getMap("myChannel")?.let { handleMyChannel(db, it, posts, receivingThreads) }
data.getMap("categories")?.let { insertCategoriesWithChannels(db, it) }
data.getArray("categoryChannels")?.let { insertChannelToDefaultCategory(db, it) }
if (channelId != null) {
handlePosts(db, posts, channelId, receivingThreads)
}
data.getArray("threads")?.let {
val threadsArray = ArrayList<ReadableMap>()
for (i in 0 until it.size()) {
threadsArray.add(it.getMap(i))
}
handleThreads(db, threadsArray, teamId)
}
data.getArray("users")?.let { handleUsers(db, it) }
}
}
fun DatabaseHelper.getServerUrlForIdentifier(identifier: String): String? {
try {
val query = "SELECT url FROM Servers WHERE identifier=?"
defaultDatabase!!.rawQuery(query, arrayOf(identifier)).use { cursor ->
if (cursor.count == 1) {
cursor.moveToFirst()
return cursor.getString(0)
}
}
} catch (e: Exception) {
e.printStackTrace()
// let it fall to return null
}
return null
}
fun DatabaseHelper.getDatabaseForServer(context: Context?, serverUrl: String): WMDatabase? {
try {
val query = "SELECT db_path FROM Servers WHERE url=?"
defaultDatabase!!.rawQuery(query, arrayOf(serverUrl)).use { cursor ->
if (cursor.count == 1) {
cursor.moveToFirst()
val databasePath = cursor.getString(0)
return WMDatabase.getInstance(databasePath, context!!)
}
}
} catch (e: Exception) {
e.printStackTrace()
// let it fall to return null
}
return null
}
fun find(db: WMDatabase, tableName: String, id: String?): ReadableMap? {
try {
db.rawQuery(
"SELECT * FROM $tableName WHERE id == ? LIMIT 1",
arrayOf(id)
).use { cursor ->
if (cursor.count <= 0) {
return null
}
val resultMap = Arguments.createMap()
cursor.moveToFirst()
resultMap.mapCursor(cursor)
return resultMap
}
} catch (e: Exception) {
e.printStackTrace()
return null
}
}
fun findByColumns(db: WMDatabase, tableName: String, columnNames: Array<String>, values: QueryArgs): ReadableMap? {
try {
val whereString = columnNames.joinToString(" AND ") { "$it = ?" }
db.rawQuery(
"SELECT * FROM $tableName WHERE $whereString LIMIT 1",
values
).use { cursor ->
if (cursor.count <= 0) {
return null
}
val resultMap = Arguments.createMap()
cursor.moveToFirst()
resultMap.mapCursor(cursor)
return resultMap
}
} catch (e: Exception) {
e.printStackTrace()
return null
}
}
fun queryIds(db: WMDatabase, tableName: String, ids: Array<String>): List<String> {
val list: MutableList<String> = ArrayList()
val args = TextUtils.join(",", Arrays.stream(ids).map { "?" }.toArray())
try {
@Suppress("UNCHECKED_CAST")
db.rawQuery("SELECT DISTINCT id FROM $tableName WHERE id IN ($args)", ids as Array<Any?>).use { cursor ->
if (cursor.count > 0) {
while (cursor.moveToNext()) {
val index = cursor.getColumnIndex("id")
if (index >= 0) {
list.add(cursor.getString(index))
}
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return list
}
fun queryByColumn(db: WMDatabase, tableName: String, columnName: String, values: Array<Any?>): List<String> {
val list: MutableList<String> = ArrayList()
val args = TextUtils.join(",", Arrays.stream(values).map { "?" }.toArray())
try {
db.rawQuery("SELECT DISTINCT $columnName FROM $tableName WHERE $columnName IN ($args)", values).use { cursor ->
if (cursor.count > 0) {
while (cursor.moveToNext()) {
val index = cursor.getColumnIndex(columnName)
if (index >= 0) {
list.add(cursor.getString(index))
}
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return list
}
fun countByColumn(db: WMDatabase, tableName: String, columnName: String, value: Any?): Int {
try {
db.rawQuery(
"SELECT COUNT(*) FROM $tableName WHERE $columnName == ? LIMIT 1",
arrayOf(value)
).use { cursor ->
if (cursor.count <= 0) {
return 0
}
cursor.moveToFirst()
return cursor.getInt(0)
}
} catch (e: Exception) {
e.printStackTrace()
return 0
}
}

View File

@@ -1,258 +0,0 @@
package com.mattermost.helpers.database_extension
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.DatabaseHelper
import com.mattermost.helpers.ReadableMapUtils
import com.nozbe.watermelondb.WMDatabase
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import kotlin.Exception
internal fun queryLastPostCreateAt(db: WMDatabase?, channelId: String): Double? {
try {
if (db != null) {
val postsInChannelQuery = "SELECT earliest, latest FROM PostsInChannel WHERE channel_id=? ORDER BY latest DESC LIMIT 1"
db.rawQuery(postsInChannelQuery, arrayOf(channelId)).use { cursor1 ->
if (cursor1.count == 1) {
cursor1.moveToFirst()
val earliest = cursor1.getDouble(0)
val latest = cursor1.getDouble(1)
val postQuery = "SELECT create_at FROM POST WHERE channel_id= ? AND delete_at=0 AND create_at BETWEEN ? AND ? ORDER BY create_at DESC"
db.rawQuery(postQuery, arrayOf(channelId, earliest, latest)).use { cursor2 ->
if (cursor2.count >= 60) {
cursor2.moveToFirst()
return cursor2.getDouble(0)
}
}
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return null
}
fun queryPostSinceForChannel(db: WMDatabase?, channelId: String): Double? {
try {
if (db != null) {
val postsInChannelQuery = "SELECT last_fetched_at FROM MyChannel WHERE id=? LIMIT 1"
db.rawQuery(postsInChannelQuery, arrayOf(channelId)).use { cursor ->
if (cursor.count == 1) {
cursor.moveToFirst()
val lastFetchedAt = cursor.getDouble(0)
if (lastFetchedAt == 0.0) {
return queryLastPostCreateAt(db, channelId)
}
return lastFetchedAt
}
}
}
} catch (e: Exception) {
e.printStackTrace()
// let it fall to return null
}
return null
}
fun queryLastPostInThread(db: WMDatabase?, rootId: String): Double? {
try {
if (db != null) {
val query = "SELECT create_at FROM Post WHERE root_id=? AND delete_at=0 ORDER BY create_at DESC LIMIT 1"
db.rawQuery(query, arrayOf(rootId)).use { cursor ->
if (cursor.count == 1) {
cursor.moveToFirst()
return cursor.getDouble(0)
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return null
}
internal fun insertPost(db: WMDatabase, post: JSONObject) {
try {
val id = try { post.getString("id") } catch (e: JSONException) { return }
val channelId = try { post.getString("channel_id") } catch (e: JSONException) { return }
val userId = try { post.getString("user_id") } catch (e: JSONException) { return }
val createAt = try { post.getDouble("create_at") } catch (e: JSONException) { return }
val deleteAt = try { post.getDouble("delete_at") } catch (e: JSONException) { 0 }
val updateAt = try { post.getDouble("update_at") } catch (e: JSONException) { 0 }
val editAt = try { post.getDouble("edit_at") } catch (e: JSONException) { 0 }
val isPinned = try { post.getBoolean("is_pinned") } catch (e: JSONException) { false }
val message = try { post.getString("message") } catch (e: JSONException) { "" }
val messageSource = try { post.getString("message_source") } catch (e: JSONException) { "" }
val metadata = try { post.getJSONObject("metadata") } catch (e: JSONException) { JSONObject() }
val originalId = try { post.getString("original_id") } catch (e: JSONException) { "" }
val pendingId = try { post.getString("pending_post_id") } catch (e: JSONException) { "" }
val prevId = try { post.getString("prev_post_id") } catch (e: JSONException) { "" }
val rootId = try { post.getString("root_id") } catch (e: JSONException) { "" }
val type = try { post.getString("type") } catch (e: JSONException) { "" }
val props = try { post.getJSONObject("props").toString() } catch (e: JSONException) { "" }
val reactions = metadata.remove("reactions") as JSONArray?
val customEmojis = metadata.remove("emojis") as JSONArray?
val files = metadata.remove("files") as JSONArray?
db.execute(
"""
INSERT INTO Post
(id, channel_id, create_at, delete_at, update_at, edit_at, is_pinned, message, message_source, metadata, original_id, pending_post_id,
previous_post_id, root_id, type, user_id, props, _changed, _status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(
id, channelId, createAt, deleteAt, updateAt, editAt,
isPinned, message, messageSource, metadata.toString(),
originalId, pendingId, prevId, rootId,
type, userId, props
)
)
if (reactions != null && reactions.length() > 0) {
insertReactions(db, reactions)
}
if (customEmojis != null && customEmojis.length() > 0) {
insertCustomEmojis(db, customEmojis)
}
if (files != null && files.length() > 0) {
insertFiles(db, files)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
internal fun updatePost(db: WMDatabase, post: JSONObject) {
try {
val id = try { post.getString("id") } catch (e: JSONException) { return }
val channelId = try { post.getString("channel_id") } catch (e: JSONException) { return }
val userId = try { post.getString("user_id") } catch (e: JSONException) { return }
val createAt = try { post.getDouble("create_at") } catch (e: JSONException) { return }
val deleteAt = try { post.getDouble("delete_at") } catch (e: JSONException) { 0 }
val updateAt = try { post.getDouble("update_at") } catch (e: JSONException) { 0 }
val editAt = try { post.getDouble("edit_at") } catch (e: JSONException) { 0 }
val isPinned = try { post.getBoolean("is_pinned") } catch (e: JSONException) { false }
val message = try { post.getString("message") } catch (e: JSONException) { "" }
val messageSource = try { post.getString("message_source") } catch (e: JSONException) { "" }
val metadata = try { post.getJSONObject("metadata") } catch (e: JSONException) { JSONObject() }
val originalId = try { post.getString("original_id") } catch (e: JSONException) { "" }
val pendingId = try { post.getString("pending_post_id") } catch (e: JSONException) { "" }
val prevId = try { post.getString("prev_post_id") } catch (e: JSONException) { "" }
val rootId = try { post.getString("root_id") } catch (e: JSONException) { "" }
val type = try { post.getString("type") } catch (e: JSONException) { "" }
val props = try { post.getJSONObject("props").toString() } catch (e: JSONException) { "" }
val reactions = metadata.remove("reactions") as JSONArray?
val customEmojis = metadata.remove("emojis") as JSONArray?
metadata.remove("files")
db.execute(
"""
UPDATE Post SET channel_id = ?, create_at = ?, delete_at = ?, update_at =?, edit_at =?,
is_pinned = ?, message = ?, message_source = ?, metadata = ?, original_id = ?, pending_post_id = ?, previous_post_id = ?,
root_id = ?, type = ?, user_id = ?, props = ?, _status = 'updated'
WHERE id = ?
""".trimIndent(),
arrayOf(
channelId, createAt, deleteAt, updateAt, editAt,
isPinned, message, messageSource, metadata.toString(),
originalId, pendingId, prevId, rootId,
type, userId, props,
id,
)
)
if (reactions != null && reactions.length() > 0) {
db.execute("DELETE FROM Reaction WHERE post_id = ?", arrayOf(post.getString("id")))
insertReactions(db, reactions)
}
if (customEmojis != null && customEmojis.length() > 0) {
insertCustomEmojis(db, customEmojis)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
fun DatabaseHelper.handlePosts(db: WMDatabase, postsData: ReadableMap?, channelId: String, receivingThreads: Boolean) {
// Posts, PostInChannel, PostInThread, Reactions, Files, CustomEmojis, Users
try {
if (postsData != null) {
val ordered = postsData.getArray("order")?.toArrayList()
val posts = ReadableMapUtils.toJSONObject(postsData.getMap("posts")).toMap()
val previousPostId = postsData.getString("prev_post_id")
val postsInThread = hashMapOf<String, List<JSONObject>>()
val postList = posts.toList()
var earliest = 0.0
var latest = 0.0
if (ordered != null && posts.isNotEmpty()) {
val firstId = ordered.first()
val lastId = ordered.last()
var prevPostId = ""
val sortedPosts = postList.sortedBy { (_, value) ->
((value as Map<*, *>)["create_at"] as Double)
}
sortedPosts.forEachIndexed { index, it ->
val key = it.first
if (it.second != null) {
@Suppress("UNCHECKED_CAST", "UNCHECKED_CAST")
val post: MutableMap<String, Any?> = it.second as MutableMap<String, Any?>
if (index == 0) {
post.putIfAbsent("prev_post_id", previousPostId)
} else if (prevPostId.isNotEmpty()) {
post.putIfAbsent("prev_post_id", prevPostId)
}
if (lastId == key) {
earliest = post["create_at"] as Double
}
if (firstId == key) {
latest = post["create_at"] as Double
}
val jsonPost = JSONObject(post)
val postId = post["id"] as? String ?: ""
val rootId = post["root_id"] as? String ?: ""
val postInThread = rootId.ifEmpty { postId }
var thread = postsInThread[postInThread]?.toMutableList()
if (thread == null) {
thread = mutableListOf()
}
thread.add(jsonPost)
postsInThread[postInThread] = thread.toList()
if (find(db, "Post", key) == null) {
insertPost(db, jsonPost)
} else {
updatePost(db, jsonPost)
}
if (ordered.contains(key)) {
prevPostId = key
}
}
}
}
if (!receivingThreads) {
handlePostsInChannel(db, channelId, earliest, latest)
}
handlePostsInThread(db, postsInThread)
}
} catch (e: Exception) {
e.printStackTrace()
}
}

View File

@@ -1,97 +0,0 @@
package com.mattermost.helpers.database_extension
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.RandomId
import com.mattermost.helpers.mapCursor
import com.nozbe.watermelondb.WMDatabase
internal fun findPostInChannel(chunks: ReadableArray, earliest: Double, latest: Double): ReadableMap? {
for (i in 0 until chunks.size()) {
val chunk = chunks.getMap(i)
if (earliest >= chunk.getDouble("earliest") || latest <= chunk.getDouble("latest")) {
return chunk
}
}
return null
}
internal fun insertPostInChannel(db: WMDatabase, channelId: String, earliest: Double, latest: Double): ReadableMap? {
return try {
val id = RandomId.generate()
db.execute(
"""
INSERT INTO PostsInChannel
(id, channel_id, earliest, latest, _changed, _status)
VALUES (?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(id, channelId, earliest, latest))
val map = Arguments.createMap()
map.putString("id", id)
map.putString("channel_id", channelId)
map.putDouble("earliest", earliest)
map.putDouble("latest", latest)
map
} catch (e: Exception) {
e.printStackTrace()
null
}
}
internal fun mergePostsInChannel(db: WMDatabase, existingChunks: ReadableArray, newChunk: ReadableMap) {
for (i in 0 until existingChunks.size()) {
try {
val chunk = existingChunks.getMap(i)
if (newChunk.getDouble("earliest") <= chunk.getDouble("earliest") &&
newChunk.getDouble("latest") >= chunk.getDouble("latest")) {
db.execute("DELETE FROM PostsInChannel WHERE id = ?", arrayOf(chunk.getString("id")))
break
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
internal fun handlePostsInChannel(db: WMDatabase, channelId: String, earliest: Double, latest: Double) {
try {
db.rawQuery(
"SELECT id, channel_id, earliest, latest FROM PostsInChannel WHERE channel_id = ?",
arrayOf(channelId)
).use { cursor ->
if (cursor.count == 0) {
// create new post in channel
insertPostInChannel(db, channelId, earliest, latest)
return
}
val resultArray = Arguments.createArray()
while (cursor.moveToNext()) {
val cursorMap = Arguments.createMap()
cursorMap.mapCursor(cursor)
resultArray.pushMap(cursorMap)
}
val chunk = findPostInChannel(resultArray, earliest, latest)
if (chunk != null) {
db.execute(
"UPDATE PostsInChannel SET earliest = ?, latest = ?, _status = 'updated' WHERE id = ?",
arrayOf(
minOf(earliest, chunk.getDouble("earliest")),
maxOf(latest, chunk.getDouble("latest")),
chunk.getString("id")
)
)
return
}
val newChunk = insertPostInChannel(db, channelId, earliest, latest)
newChunk?.let { mergePostsInChannel(db, resultArray, it) }
}
} catch (e: Exception) {
e.printStackTrace()
}
}

View File

@@ -1,29 +0,0 @@
package com.mattermost.helpers.database_extension
import com.facebook.react.bridge.Arguments
import com.mattermost.helpers.mapCursor
import com.nozbe.watermelondb.WMDatabase
fun getTeammateDisplayNameSetting(db: WMDatabase): String {
val configSetting = queryConfigDisplayNameSetting(db)
if (configSetting != null) {
return configSetting
}
try {
db.rawQuery(
"SELECT value FROM Preference where category = ? AND name = ? limit 1",
arrayOf("display_settings", "name_format")
).use { cursor ->
if (cursor.count <= 0) {
return "username"
}
val resultMap = Arguments.createMap()
cursor.moveToFirst()
resultMap.mapCursor(cursor)
return resultMap?.getString("value") ?: "username"
}
} catch (e: Exception) {
return "username"
}
}

View File

@@ -1,28 +0,0 @@
package com.mattermost.helpers.database_extension
import com.mattermost.helpers.RandomId
import com.nozbe.watermelondb.WMDatabase
import org.json.JSONArray
internal fun insertReactions(db: WMDatabase, reactions: JSONArray) {
for (i in 0 until reactions.length()) {
try {
val reaction = reactions.getJSONObject(i)
val id = RandomId.generate()
db.execute(
"""
INSERT INTO Reaction
(id, create_at, emoji_name, post_id, user_id, _changed, _status)
VALUES (?, ?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(
id,
reaction.getDouble("create_at"), reaction.getString("emoji_name"),
reaction.getString("post_id"), reaction.getString("user_id")
)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
}

View File

@@ -1,32 +0,0 @@
package com.mattermost.helpers.database_extension
import com.nozbe.watermelondb.WMDatabase
import org.json.JSONObject
fun queryCurrentUserId(db: WMDatabase): String? {
val result = find(db, "System", "currentUserId")
return result?.getString("value")?.removeSurrounding("\"")
}
fun queryCurrentTeamId(db: WMDatabase): String? {
val result = find(db, "System", "currentTeamId")
return result?.getString("value")?.removeSurrounding("\"")
}
fun queryConfigDisplayNameSetting(db: WMDatabase): String? {
val license = find(db, "System", "license")
val lockDisplayName = find(db, "Config", "LockTeammateNameDisplay")
val displayName = find(db, "Config", "TeammateNameDisplay")
val licenseValue = license?.getString("value") ?: ""
val lockDisplayNameValue = lockDisplayName?.getString("value") ?: "false"
val displayNameValue = displayName?.getString("value") ?: "full_name"
val licenseJson = JSONObject(licenseValue)
val licenseLock = try { licenseJson.getString("LockTeammateNameDisplay") } catch (e: Exception) { "false"}
if (licenseLock == "true" && lockDisplayNameValue == "true") {
return displayNameValue
}
return null
}

View File

@@ -1,106 +0,0 @@
package com.mattermost.helpers.database_extension
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.NoSuchKeyException
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.mapCursor
import com.nozbe.watermelondb.WMDatabase
fun findTeam(db: WMDatabase?, teamId: String): Boolean {
if (db != null) {
val team = find(db, "Team", teamId)
return team != null
}
return false
}
fun findMyTeam(db: WMDatabase?, teamId: String): Boolean {
if (db != null) {
val team = find(db, "MyTeam", teamId)
return team != null
}
return false
}
fun queryMyTeams(db: WMDatabase?): ArrayList<ReadableMap>? {
db?.rawQuery("SELECT * FROM MyTeam")?.use { cursor ->
val results = ArrayList<ReadableMap>()
if (cursor.count > 0) {
while(cursor.moveToNext()) {
val map = Arguments.createMap()
map.mapCursor(cursor)
results.add(map)
}
}
return results
}
return null
}
fun insertTeam(db: WMDatabase, team: ReadableMap): Boolean {
val id = try { team.getString("id") } catch (e: Exception) { return false }
val deleteAt = try {team.getDouble("delete_at") } catch (e: Exception) { 0 }
if (deleteAt.toInt() > 0) {
return false
}
val isAllowOpenInvite = try { team.getBoolean("allow_open_invite") } catch (e: NoSuchKeyException) { false }
val description = try { team.getString("description") } catch (e: NoSuchKeyException) { "" }
val displayName = try { team.getString("display_name") } catch (e: NoSuchKeyException) { "" }
val name = try { team.getString("name") } catch (e: NoSuchKeyException) { "" }
val updateAt = try { team.getDouble("update_at") } catch (e: NoSuchKeyException) { 0 }
val type = try { team.getString("type") } catch (e: NoSuchKeyException) { "O" }
val allowedDomains = try { team.getString("allowed_domains") } catch (e: NoSuchKeyException) { "" }
val isGroupConstrained = try { team.getBoolean("group_constrained") } catch (e: NoSuchKeyException) { false }
val lastTeamIconUpdatedAt = try { team.getDouble("last_team_icon_update") } catch (e: NoSuchKeyException) { 0 }
val inviteId = try { team.getString("invite_id") } catch (e: NoSuchKeyException) { "" }
val status = "created"
return try {
db.execute(
"""
INSERT INTO Team (
id, allow_open_invite, description, display_name, name, update_at, type, allowed_domains,
group_constrained, last_team_icon_update, invite_id, _changed, _status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)
""".trimIndent(),
arrayOf(
id, isAllowOpenInvite, description, displayName, name, updateAt,
type, allowedDomains, isGroupConstrained, lastTeamIconUpdatedAt, inviteId, status
)
)
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
fun insertMyTeam(db: WMDatabase, myTeam: ReadableMap): Boolean {
val currentUserId = queryCurrentUserId(db) ?: return false
val id = try { myTeam.getString("id") } catch (e: NoSuchKeyException) { return false }
val roles = try { myTeam.getString("roles") } catch (e: NoSuchKeyException) { "" }
val schemeAdmin = try { myTeam.getBoolean("scheme_admin") } catch (e: NoSuchKeyException) { false }
val status = "created"
val membershipId = "$id-$currentUserId"
return try {
db.execute(
"INSERT INTO MyTeam (id, roles, _changed, _status) VALUES (?, ?, '', ?)",
arrayOf(id, roles, status)
)
db.execute(
"""
INSERT INTO TeamMembership (id, team_id, user_id, scheme_admin, _changed, _status)
VALUES (?, ?, ?, ?, '', ?)
""".trimIndent(),
arrayOf(membershipId, id, currentUserId, schemeAdmin, status)
)
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}

View File

@@ -1,247 +0,0 @@
package com.mattermost.helpers.database_extension
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.NoSuchKeyException
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.RandomId
import com.mattermost.helpers.mapCursor
import com.nozbe.watermelondb.WMDatabase
import org.json.JSONObject
internal fun insertThread(db: WMDatabase, thread: ReadableMap) {
// These fields are not present when we extract threads from posts
try {
val id = try { thread.getString("id") } catch (e: NoSuchKeyException) { return }
val isFollowing = try { thread.getBoolean("is_following") } catch (e: NoSuchKeyException) { false }
val lastViewedAt = try { thread.getDouble("last_viewed_at") } catch (e: NoSuchKeyException) { 0 }
val unreadReplies = try { thread.getInt("unread_replies") } catch (e: NoSuchKeyException) { 0 }
val unreadMentions = try { thread.getInt("unread_mentions") } catch (e: NoSuchKeyException) { 0 }
val lastReplyAt = try { thread.getDouble("last_reply_at") } catch (e: NoSuchKeyException) { 0 }
val replyCount = try { thread.getInt("reply_count") } catch (e: NoSuchKeyException) { 0 }
db.execute(
"""
INSERT INTO Thread
(id, last_reply_at, last_fetched_at, last_viewed_at, reply_count, is_following, unread_replies, unread_mentions, viewed_at, _changed, _status)
VALUES (?, ?, 0, ?, ?, ?, ?, ?, 0, '', 'created')
""".trimIndent(),
arrayOf(
id, lastReplyAt, lastViewedAt,
replyCount, isFollowing, unreadReplies, unreadMentions
)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
internal fun updateThread(db: WMDatabase, thread: ReadableMap, existingRecord: ReadableMap) {
try {
// These fields are not present when we extract threads from posts
val id = try { thread.getString("id") } catch (e: NoSuchKeyException) { return }
val isFollowing = try { thread.getBoolean("is_following") } catch (e: NoSuchKeyException) { existingRecord.getInt("is_following") == 1 }
val lastViewedAt = try { thread.getDouble("last_viewed_at") } catch (e: NoSuchKeyException) { existingRecord.getDouble("last_viewed_at") }
val unreadReplies = try { thread.getInt("unread_replies") } catch (e: NoSuchKeyException) { existingRecord.getInt("unread_replies") }
val unreadMentions = try { thread.getInt("unread_mentions") } catch (e: NoSuchKeyException) { existingRecord.getInt("unread_mentions") }
val lastReplyAt = try { thread.getDouble("last_reply_at") } catch (e: NoSuchKeyException) { 0 }
val replyCount = try { thread.getInt("reply_count") } catch (e: NoSuchKeyException) { 0 }
db.execute(
"""
UPDATE Thread SET
last_reply_at = ?, last_viewed_at = ?, reply_count = ?, is_following = ?, unread_replies = ?,
unread_mentions = ?, _status = 'updated' where id = ?
""".trimIndent(),
arrayOf(
lastReplyAt, lastViewedAt, replyCount,
isFollowing, unreadReplies, unreadMentions, id
)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
internal fun insertThreadParticipants(db: WMDatabase, threadId: String, participants: ReadableArray) {
for (i in 0 until participants.size()) {
try {
val participant = participants.getMap(i)
val id = RandomId.generate()
db.execute(
"""
INSERT INTO ThreadParticipant
(id, thread_id, user_id, _changed, _status)
VALUES (?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(id, threadId, participant.getString("id"))
)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
fun insertTeamThreadsSync(db: WMDatabase, teamId: String, earliest: Double, latest: Double) {
try {
val query = """
INSERT INTO TeamThreadsSync (id, _changed, _status, earliest, latest)
VALUES (?, '', 'created', ?, ?)
"""
db.execute(query, arrayOf(teamId, earliest, latest))
} catch (e: Exception) {
e.printStackTrace()
}
}
fun updateTeamThreadsSync(db: WMDatabase, teamId: String, earliest: Double, latest: Double, existingRecord: ReadableMap) {
try {
val storeEarliest = minOf(earliest, existingRecord.getDouble("earliest"))
val storeLatest = maxOf(latest, existingRecord.getDouble("latest"))
val query = "UPDATE TeamThreadsSync SET earliest=?, latest=? WHERE id=?"
db.execute(query, arrayOf(storeEarliest, storeLatest, teamId))
} catch (e: Exception) {
e.printStackTrace()
}
}
fun syncParticipants(db: WMDatabase, thread: ReadableMap) {
try {
val threadId = thread.getString("id")
val participants = thread.getArray("participants")
if (participants != null) {
db.execute("DELETE FROM ThreadParticipant WHERE thread_id = ?", arrayOf(threadId))
if (participants.size() > 0) {
insertThreadParticipants(db, threadId!!, participants)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
internal fun handlePostsInThread(db: WMDatabase, postsInThread: Map<String, List<JSONObject>>) {
postsInThread.forEach { (key, list) ->
try {
val sorted = list.sortedBy { it.getDouble("create_at") }
val earliest = sorted.first().getDouble("create_at")
val latest = sorted.last().getDouble("create_at")
db.rawQuery("SELECT * FROM PostsInThread WHERE root_id = ? ORDER BY latest DESC", arrayOf(key)).use { cursor ->
if (cursor.count > 0) {
cursor.moveToFirst()
val cursorMap = Arguments.createMap()
cursorMap.mapCursor(cursor)
val storeEarliest = minOf(earliest, cursorMap.getDouble("earliest"))
val storeLatest = maxOf(latest, cursorMap.getDouble("latest"))
db.execute(
"UPDATE PostsInThread SET earliest = ?, latest = ?, _status = 'updated' WHERE root_id = ?",
arrayOf(
storeEarliest,
storeLatest,
key
)
)
return
}
val id = RandomId.generate()
db.execute(
"""
INSERT INTO PostsInThread
(id, root_id, earliest, latest, _changed, _status)
VALUES (?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(id, key, earliest, latest)
)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
fun handleThreads(db: WMDatabase, threads: ArrayList<ReadableMap>, teamId: String?) {
val teamIds = ArrayList<String>()
if (teamId.isNullOrEmpty()) {
val myTeams = queryMyTeams(db)
if (myTeams != null) {
for (myTeam in myTeams) {
myTeam.getString("id")?.let { teamIds.add(it) }
}
}
} else {
teamIds.add(teamId)
}
for (i in 0 until threads.size) {
try {
val thread = threads[i]
handleThread(db, thread, teamIds)
} catch (e: Exception) {
e.printStackTrace()
}
}
handleTeamThreadsSync(db, threads, teamIds)
}
fun handleThread(db: WMDatabase, thread: ReadableMap, teamIds: ArrayList<String>) {
// Insert/Update the thread
val threadId = thread.getString("id")
val isFollowing = thread.getBoolean("is_following")
val existingRecord = find(db, "Thread", threadId)
if (existingRecord == null) {
insertThread(db, thread)
} else {
updateThread(db, thread, existingRecord)
}
syncParticipants(db, thread)
// this is per team
if (isFollowing) {
for (teamId in teamIds) {
handleThreadInTeam(db, thread, teamId)
}
}
}
fun handleThreadInTeam(db: WMDatabase, thread: ReadableMap, teamId: String) {
val threadId = thread.getString("id") ?: return
val existingRecord = findByColumns(
db,
"ThreadsInTeam",
arrayOf("thread_id", "team_id"),
arrayOf(threadId, teamId)
)
if (existingRecord == null) {
try {
val id = RandomId.generate()
val query = """
INSERT INTO ThreadsInTeam (id, team_id, thread_id, _changed, _status)
VALUES (?, ?, ?, '', 'created')
"""
db.execute(query, arrayOf(id, teamId, threadId))
} catch (e: Exception) {
e.printStackTrace()
}
}
}
fun handleTeamThreadsSync(db: WMDatabase, threadList: ArrayList<ReadableMap>, teamIds: ArrayList<String>) {
val sortedList = threadList.filter{ it.getBoolean("is_following") }
.sortedBy { it.getDouble("last_reply_at") }
.map { it.getDouble("last_reply_at") }
val earliest = sortedList.first()
val latest = sortedList.last()
for (teamId in teamIds) {
val existingTeamThreadsSync = find(db, "TeamThreadsSync", teamId)
if (existingTeamThreadsSync == null) {
insertTeamThreadsSync(db, teamId, earliest, latest)
} else {
updateTeamThreadsSync(db, teamId, earliest, latest, existingTeamThreadsSync)
}
}
}

View File

@@ -1,85 +0,0 @@
package com.mattermost.helpers.database_extension
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.NoSuchKeyException
import com.facebook.react.bridge.ReadableArray
import com.mattermost.helpers.ReadableMapUtils
import com.nozbe.watermelondb.WMDatabase
fun getLastPictureUpdate(db: WMDatabase?, userId: String): Double? {
try {
if (db != null) {
var id = userId
if (userId == "me") {
(queryCurrentUserId(db) ?: userId).also { id = it }
}
val userQuery = "SELECT last_picture_update FROM User WHERE id=?"
db.rawQuery(userQuery, arrayOf(id)).use { cursor ->
if (cursor.count == 1) {
cursor.moveToFirst()
return cursor.getDouble(0)
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return null
}
fun getCurrentUserLocale(db: WMDatabase): String {
try {
val currentUserId = queryCurrentUserId(db) ?: return "en"
val userQuery = "SELECT locale FROM User WHERE id=?"
db.rawQuery(userQuery, arrayOf(currentUserId)).use { cursor ->
if (cursor.count == 1) {
cursor.moveToFirst()
return cursor.getString(0)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return "en"
}
fun handleUsers(db: WMDatabase, users: ReadableArray) {
for (i in 0 until users.size()) {
val user = users.getMap(i)
val roles = user.getString("roles") ?: ""
val isBot = try {
user.getBoolean("is_bot")
} catch (e: NoSuchKeyException) {
false
}
val lastPictureUpdate = try { user.getDouble("last_picture_update") } catch (e: NoSuchKeyException) { 0 }
try {
db.execute(
"""
INSERT INTO User (id, auth_service, update_at, delete_at, email, first_name, is_bot, is_guest,
last_name, last_picture_update, locale, nickname, position, roles, status, username, notify_props,
props, timezone, _changed, _status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(
user.getString("id"),
user.getString("auth_service"), user.getDouble("update_at"), user.getDouble("delete_at"),
user.getString("email"), user.getString("first_name"), isBot,
roles.contains("system_guest"), user.getString("last_name"), lastPictureUpdate,
user.getString("locale"), user.getString("nickname"), user.getString("position"),
roles, "", user.getString("username"), "{}",
ReadableMapUtils.toJSONObject(user.getMap("props")
?: Arguments.createMap()).toString(),
ReadableMapUtils.toJSONObject(user.getMap("timezone")
?: Arguments.createMap()).toString(),
)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
}

View File

@@ -1,70 +0,0 @@
package com.mattermost.helpers.push_notification
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.PushNotificationDataRunnable
import com.mattermost.helpers.database_extension.findByColumns
import com.mattermost.helpers.database_extension.queryCurrentUserId
import com.mattermost.helpers.database_extension.queryMyTeams
import com.nozbe.watermelondb.WMDatabase
suspend fun PushNotificationDataRunnable.Companion.fetchMyTeamCategories(db: WMDatabase, serverUrl: String, teamId: String): ReadableMap? {
return try {
val userId = queryCurrentUserId(db)
val categories = fetch(serverUrl, "/api/v4/users/$userId/teams/$teamId/channels/categories")
categories?.getMap("data")
} catch (e: Exception) {
e.printStackTrace()
null
}
}
fun PushNotificationDataRunnable.Companion.addToDefaultCategoryIfNeeded(db: WMDatabase, channel: ReadableMap): ReadableArray? {
val channelId = channel.getString("id") ?: return null
val channelType = channel.getString("type")
val categoryChannels = Arguments.createArray()
if (channelType == "D" || channelType == "G") {
val myTeams = queryMyTeams(db)
myTeams?.let {
for (myTeam in it) {
val map = categoryChannelForTeam(db, channelId, myTeam.getString("id"), "direct_messages")
if (map != null) {
categoryChannels.pushMap(map)
}
}
}
} else {
val map = categoryChannelForTeam(db, channelId, channel.getString("team_id"), "channels")
if (map != null) {
categoryChannels.pushMap(map)
}
}
return categoryChannels
}
private fun categoryChannelForTeam(db: WMDatabase, channelId: String, teamId: String?, type: String): ReadableMap? {
teamId?.let { id ->
val category = findByColumns(db, "Category", arrayOf("type", "team_id"), arrayOf(type, id))
val categoryId = category?.getString("id")
categoryId?.let { cId ->
val cc = findByColumns(
db,
"CategoryChannel",
arrayOf("category_id", "channel_id"),
arrayOf(cId, channelId)
)
if (cc == null) {
val map = Arguments.createMap()
map.putString("channel_id", channelId)
map.putString("category_id", cId)
map.putString("id", "${id}_$channelId")
return map
}
}
}
return null
}

View File

@@ -1,161 +0,0 @@
package com.mattermost.helpers.push_notification
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.PushNotificationDataRunnable
import com.mattermost.helpers.database_extension.findChannel
import com.mattermost.helpers.database_extension.getCurrentUserLocale
import com.mattermost.helpers.database_extension.getTeammateDisplayNameSetting
import com.mattermost.helpers.database_extension.queryCurrentUserId
import com.nozbe.watermelondb.WMDatabase
import java.text.Collator
import java.util.Locale
suspend fun PushNotificationDataRunnable.Companion.fetchMyChannel(db: WMDatabase, serverUrl: String, channelId: String, isCRTEnabled: Boolean): Triple<ReadableMap?, ReadableMap?, ReadableArray?> {
val channel = fetch(serverUrl, "/api/v4/channels/$channelId")
var channelData = channel?.getMap("data")
val myChannelData = channelData?.let { fetchMyChannelData(serverUrl, channelId, isCRTEnabled, it) }
val channelType = channelData?.getString("type")
var profilesArray: ReadableArray? = null
if (channelData != null && channelType != null && !findChannel(db, channelId)) {
val displayNameSetting = getTeammateDisplayNameSetting(db)
when (channelType) {
"D" -> {
profilesArray = fetchProfileInChannel(db, serverUrl, channelId)
if ((profilesArray?.size() ?: 0) > 0) {
val displayName = displayUsername(profilesArray!!.getMap(0), displayNameSetting)
val data = Arguments.createMap()
data.merge(channelData)
data.putString("display_name", displayName)
channelData = data
}
}
"G" -> {
profilesArray = fetchProfileInChannel(db, serverUrl, channelId)
if ((profilesArray?.size() ?: 0) > 0) {
val localeString = getCurrentUserLocale(db)
val localeArray = localeString.split("-")
val locale = if (localeArray.size == 1) {
Locale(localeString)
} else {
Locale(localeArray[0], localeArray[1])
}
val displayName = displayGroupMessageName(profilesArray!!, locale, displayNameSetting)
val data = Arguments.createMap()
data.merge(channelData)
data.putString("display_name", displayName)
channelData = data
}
}
else -> {}
}
}
return Triple(channelData, myChannelData, profilesArray)
}
private suspend fun PushNotificationDataRunnable.Companion.fetchMyChannelData(serverUrl: String, channelId: String, isCRTEnabled: Boolean, channelData: ReadableMap): ReadableMap? {
try {
val myChannel = fetch(serverUrl, "/api/v4/channels/$channelId/members/me")
val myChannelData = myChannel?.getMap("data")
if (myChannelData != null) {
val data = Arguments.createMap()
data.merge(myChannelData)
data.putString("id", channelId)
val totalMsg = if (isCRTEnabled) {
channelData.getInt("total_msg_count_root")
} else {
channelData.getInt("total_msg_count")
}
val myMsgCount = if (isCRTEnabled) {
myChannelData.getInt("msg_count_root")
} else {
myChannelData.getInt("msg_count")
}
val mentionCount = if (isCRTEnabled) {
myChannelData.getInt("mention_count_root")
} else {
myChannelData.getInt("mention_count")
}
val lastPostAt = if (isCRTEnabled) {
try {
channelData.getDouble("last_root_post_at")
} catch (e: Exception) {
channelData.getDouble("last_post_at")
}
} else {
channelData.getDouble("last_post_at")
}
val messageCount = 0.coerceAtLeast(totalMsg - myMsgCount)
data.putInt("message_count", messageCount)
data.putInt("mentions_count", mentionCount)
data.putBoolean("is_unread", messageCount > 0)
data.putDouble("last_post_at", lastPostAt)
return data
}
} catch (e: Exception) {
e.printStackTrace()
}
return null
}
private suspend fun PushNotificationDataRunnable.Companion.fetchProfileInChannel(db: WMDatabase, serverUrl: String, channelId: String): ReadableArray? {
return try {
val currentUserId = queryCurrentUserId(db)
val profilesInChannel = fetch(serverUrl, "/api/v4/users?in_channel=${channelId}&page=0&per_page=8&sort=")
val profilesArray = profilesInChannel?.getArray("data")
val result = Arguments.createArray()
if (profilesArray != null) {
for (i in 0 until profilesArray.size()) {
val profile = profilesArray.getMap(i)
if (profile.getString("id") != currentUserId) {
result.pushMap(profile)
}
}
}
result
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private fun PushNotificationDataRunnable.Companion.displayUsername(user: ReadableMap, displayNameSetting: String): String {
val name = user.getString("username") ?: ""
val nickname = user.getString("nickname")
val firstName = user.getString("first_name") ?: ""
val lastName = user.getString("last_name") ?: ""
return when (displayNameSetting) {
"nickname_full_name" -> {
(nickname ?: "$firstName $lastName").trim()
}
"full_name" -> {
"$firstName $lastName".trim()
}
else -> {
name.trim()
}
}
}
private fun PushNotificationDataRunnable.Companion.displayGroupMessageName(profilesArray: ReadableArray, locale: Locale, displayNameSetting: String): String {
val names = ArrayList<String>()
for (i in 0 until profilesArray.size()) {
val profile = profilesArray.getMap(i)
names.add(displayUsername(profile, displayNameSetting))
}
return names.sortedWith { s1, s2 ->
Collator.getInstance(locale).compare(s1, s2)
}.joinToString(", ").trim()
}

View File

@@ -1,53 +0,0 @@
package com.mattermost.helpers.push_notification
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.Network
import com.mattermost.helpers.PushNotificationDataRunnable
import com.mattermost.helpers.ResolvePromise
import java.io.IOException
import kotlin.coroutines.suspendCoroutine
internal suspend fun PushNotificationDataRunnable.Companion.fetch(serverUrl: String, endpoint: String): ReadableMap? {
return suspendCoroutine { cont ->
Network.get(serverUrl, endpoint, null, object : ResolvePromise() {
override fun resolve(value: Any?) {
val response = value as ReadableMap?
if (response != null && !response.getBoolean("ok")) {
val error = response.getMap("data")
cont.resumeWith(Result.failure((IOException("Unexpected code ${error?.getInt("status_code")} ${error?.getString("message")}"))))
} else {
cont.resumeWith(Result.success(response))
}
}
override fun reject(code: String, message: String) {
cont.resumeWith(Result.failure(IOException("Unexpected code $code $message")))
}
override fun reject(reason: Throwable?) {
cont.resumeWith(Result.failure(IOException("Unexpected code $reason")))
}
})
}
}
internal suspend fun PushNotificationDataRunnable.Companion.fetchWithPost(serverUrl: String, endpoint: String, options: ReadableMap?) : ReadableMap? {
return suspendCoroutine { cont ->
Network.post(serverUrl, endpoint, options, object : ResolvePromise() {
override fun resolve(value: Any?) {
val response = value as ReadableMap?
cont.resumeWith(Result.success(response))
}
override fun reject(code: String, message: String) {
cont.resumeWith(Result.failure(IOException("Unexpected code $code $message")))
}
override fun reject(reason: Throwable?) {
cont.resumeWith(Result.failure(IOException("Unexpected code $reason")))
}
})
}
}

View File

@@ -1,201 +0,0 @@
package com.mattermost.helpers.push_notification
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.NoSuchKeyException
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.WritableNativeArray
import com.mattermost.helpers.PushNotificationDataRunnable
import com.mattermost.helpers.ReadableArrayUtils
import com.mattermost.helpers.ReadableMapUtils
import com.mattermost.helpers.database_extension.*
import com.nozbe.watermelondb.WMDatabase
internal suspend fun PushNotificationDataRunnable.Companion.fetchPosts(
db: WMDatabase, serverUrl: String, channelId: String, isCRTEnabled: Boolean,
rootId: String?, loadedProfiles: ReadableArray?
): ReadableMap? {
return try {
val regex = Regex("""\B@(([a-z\d-._]*[a-z\d_])[.-]*)""", setOf(RegexOption.IGNORE_CASE))
val currentUserId = queryCurrentUserId(db)
val currentUser = find(db, "User", currentUserId)
val currentUsername = currentUser?.getString("username")
var additionalParams = ""
if (isCRTEnabled) {
additionalParams = "&collapsedThreads=true&collapsedThreadsExtended=true"
}
val receivingThreads = isCRTEnabled && !rootId.isNullOrEmpty()
val endpoint = if (receivingThreads) {
val since = rootId?.let { queryLastPostInThread(db, it) }
val queryParams = if (since == null) "?perPage=60&fromCreatedAt=0&direction=up" else
"?fromCreateAt=${since.toLong()}&direction=down"
"/api/v4/posts/$rootId/thread$queryParams$additionalParams"
} else {
val since = queryPostSinceForChannel(db, channelId)
val queryParams = if (since == null) "?page=0&per_page=60" else "?since=${since.toLong()}"
"/api/v4/channels/$channelId/posts$queryParams$additionalParams"
}
val postsResponse = fetch(serverUrl, endpoint)
val postData = postsResponse?.getMap("data")
val results = Arguments.createMap()
if (postData != null) {
val data = ReadableMapUtils.toMap(postData)
results.putMap("posts", postData)
if (data != null) {
val postsMap = data["posts"]
if (postsMap != null) {
@Suppress("UNCHECKED_CAST")
val posts = ReadableMapUtils.toWritableMap(postsMap as? Map<String, Any>)
val iterator = posts.keySetIterator()
val userIds = mutableListOf<String>()
val usernames = mutableListOf<String>()
val threads = WritableNativeArray()
val threadParticipantUserIds = mutableListOf<String>() // Used to exclude the "userIds" present in the thread participants
val threadParticipantUsernames = mutableListOf<String>() // Used to exclude the "usernames" present in the thread participants
val threadParticipantUsers = HashMap<String, ReadableMap>() // All unique users from thread participants are stored here
val userIdsAlreadyLoaded = mutableListOf<String>()
if (loadedProfiles != null) {
for (i in 0 until loadedProfiles.size()) {
loadedProfiles.getMap(i).getString("id")?.let { userIdsAlreadyLoaded.add(it) }
}
}
fun findNeededUsernames(text: String?) {
if (text == null) {
return
}
val matchResults = regex.findAll(text)
matchResults.iterator().forEach {
val username = it.value.removePrefix("@")
if (!usernames.contains(username) && currentUsername != username && !specialMentions.contains(username)) {
usernames.add(username)
}
}
}
while (iterator.hasNextKey()) {
val key = iterator.nextKey()
val post = posts.getMap(key)
val userId = post?.getString("user_id")
if (userId != null && userId != currentUserId && !userIdsAlreadyLoaded.contains(userId) && !userIds.contains(userId)) {
userIds.add(userId)
}
val message = post?.getString("message")
findNeededUsernames(message)
val props = post?.getMap("props")
val attachments = props?.getArray("attachments")
if (attachments != null) {
for (i in 0 until attachments.size()) {
val attachment = attachments.getMap(i)
val pretext = attachment.getString("pretext")
val text = attachment.getString("text")
findNeededUsernames(pretext)
findNeededUsernames(text)
}
}
if (isCRTEnabled) {
// Add root post as a thread
val threadId = post?.getString("root_id")
if (threadId.isNullOrEmpty()) {
post?.let {
val thread = Arguments.createMap()
thread.putString("id", it.getString("id"))
thread.putInt("reply_count", it.getInt("reply_count"))
thread.putDouble("last_reply_at", 0.0)
thread.putDouble("last_viewed_at", 0.0)
thread.putArray("participants", it.getArray("participants"))
thread.putMap("post", it)
thread.putBoolean("is_following", try {
it.getBoolean("is_following")
} catch (e: NoSuchKeyException) {
false
})
thread.putInt("unread_replies", 0)
thread.putInt("unread_mentions", 0)
thread.putDouble("delete_at", it.getDouble("delete_at"))
threads.pushMap(thread)
}
}
// Add participant userIds and usernames to exclude them from getting fetched again
val participants = post?.getArray("participants")
participants?.let {
for (i in 0 until it.size()) {
val participant = it.getMap(i)
val participantId = participant.getString("id")
if (participantId != currentUserId && participantId != null) {
if (!threadParticipantUserIds.contains(participantId) && !userIdsAlreadyLoaded.contains(participantId)) {
threadParticipantUserIds.add(participantId)
}
if (!threadParticipantUsers.containsKey(participantId)) {
threadParticipantUsers[participantId] = participant
}
}
val username = participant.getString("username")
if (username != null && username != currentUsername && !threadParticipantUsernames.contains(username)) {
threadParticipantUsernames.add(username)
}
}
}
}
}
val existingUserIds = queryIds(db, "User", userIds.toTypedArray())
val existingUsernames = queryByColumn(db, "User", "username", usernames.toTypedArray())
userIds.removeAll { it in existingUserIds }
usernames.removeAll { it in existingUsernames }
if (threadParticipantUserIds.size > 0) {
// Do not fetch users found in thread participants as we get the user's data in the posts response already
userIds.removeAll { it in threadParticipantUserIds }
usernames.removeAll { it in threadParticipantUsernames }
// Get users from thread participants
val existingThreadParticipantUserIds = queryIds(db, "User", threadParticipantUserIds.toTypedArray())
// Exclude the thread participants already present in the DB from getting inserted again
val usersFromThreads = WritableNativeArray()
threadParticipantUsers.forEach { (userId, user) ->
if (!existingThreadParticipantUserIds.contains(userId)) {
usersFromThreads.pushMap(user)
}
}
if (usersFromThreads.size() > 0) {
results.putArray("usersFromThreads", usersFromThreads)
}
}
if (userIds.size > 0) {
results.putArray("userIdsToLoad", ReadableArrayUtils.toWritableArray(userIds.toTypedArray()))
}
if (usernames.size > 0) {
results.putArray("usernamesToLoad", ReadableArrayUtils.toWritableArray(usernames.toTypedArray()))
}
if (threads.size() > 0) {
results.putArray("threads", threads)
}
}
}
}
results
} catch (e: Exception) {
e.printStackTrace()
null
}
}

View File

@@ -1,28 +0,0 @@
package com.mattermost.helpers.push_notification
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.PushNotificationDataRunnable
import com.mattermost.helpers.database_extension.findMyTeam
import com.mattermost.helpers.database_extension.findTeam
import com.nozbe.watermelondb.WMDatabase
suspend fun PushNotificationDataRunnable.Companion.fetchTeamIfNeeded(db: WMDatabase, serverUrl: String, teamId: String): Pair<ReadableMap?, ReadableMap?> {
return try {
var team: ReadableMap? = null
var myTeam: ReadableMap? = null
val teamExists = findTeam(db, teamId)
val myTeamExists = findMyTeam(db, teamId)
if (!teamExists) {
team = fetch(serverUrl, "/api/v4/teams/$teamId")
}
if (!myTeamExists) {
myTeam = fetch(serverUrl, "/api/v4/teams/$teamId/members/me")
}
Pair(team, myTeam)
} catch (e: Exception) {
e.printStackTrace()
Pair(null, null)
}
}

View File

@@ -1,19 +0,0 @@
package com.mattermost.helpers.push_notification
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.PushNotificationDataRunnable
import com.mattermost.helpers.database_extension.*
import com.nozbe.watermelondb.WMDatabase
internal suspend fun PushNotificationDataRunnable.Companion.fetchThread(db: WMDatabase, serverUrl: String, threadId: String, teamId: String?): ReadableMap? {
val currentUserId = queryCurrentUserId(db) ?: return null
val threadTeamId = (if (teamId.isNullOrEmpty()) queryCurrentTeamId(db) else teamId) ?: return null
return try {
val thread = fetch(serverUrl, "/api/v4/users/$currentUserId/teams/${threadTeamId}/threads/$threadId")
thread?.getMap("data")
} catch (e: Exception) {
e.printStackTrace()
null
}
}

View File

@@ -1,61 +0,0 @@
package com.mattermost.helpers.push_notification
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.PushNotificationDataRunnable
import com.mattermost.helpers.ReadableArrayUtils
internal suspend fun PushNotificationDataRunnable.Companion.fetchUsersById(serverUrl: String, userIds: ReadableArray): ReadableArray? {
return try {
val endpoint = "api/v4/users/ids"
val options = Arguments.createMap()
options.putArray("body", ReadableArrayUtils.toWritableArray(ReadableArrayUtils.toArray(userIds)))
val result = fetchWithPost(serverUrl, endpoint, options)
result?.getArray("data")
} catch (e: Exception) {
e.printStackTrace()
null
}
}
internal suspend fun PushNotificationDataRunnable.Companion.fetchUsersByUsernames(serverUrl: String, usernames: ReadableArray): ReadableArray? {
return try {
val endpoint = "api/v4/users/usernames"
val options = Arguments.createMap()
options.putArray("body", ReadableArrayUtils.toWritableArray(ReadableArrayUtils.toArray(usernames)))
val result = fetchWithPost(serverUrl, endpoint, options)
result?.getArray("data")
} catch (e: Exception) {
e.printStackTrace()
null
}
}
internal suspend fun PushNotificationDataRunnable.Companion.fetchNeededUsers(serverUrl: String, loadedUsers: ReadableArray?, data: ReadableMap?): ArrayList<Any> {
val userList = ArrayList<Any>()
loadedUsers?.let { PushNotificationDataRunnable.addUsersToList(it, userList) }
data?.getArray("userIdsToLoad")?.let { ids ->
if (ids.size() > 0) {
val result = fetchUsersById(serverUrl, ids)
result?.let { PushNotificationDataRunnable.addUsersToList(it, userList) }
}
}
data?.getArray("usernamesToLoad")?.let { ids ->
if (ids.size() > 0) {
val result = fetchUsersByUsernames(serverUrl, ids)
result?.let { PushNotificationDataRunnable.addUsersToList(it, userList) }
}
}
data?.getArray("usersFromThreads")?.let { PushNotificationDataRunnable.addUsersToList(it, userList) }
return userList
}
internal fun PushNotificationDataRunnable.Companion.addUsersToList(users: ReadableArray, list: ArrayList<Any>) {
for (i in 0 until users.size()) {
list.add(users.getMap(i))
}
}

View File

@@ -7,29 +7,23 @@ import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import java.util.Objects;
import com.facebook.react.bridge.ReadableMap;
import com.mattermost.helpers.CustomPushNotificationHelper;
import com.mattermost.helpers.DatabaseHelper;
import com.mattermost.helpers.Network;
import com.mattermost.helpers.NotificationHelper;
import com.mattermost.helpers.PushNotificationDataHelper;
import com.mattermost.helpers.ReadableMapUtils;
import com.mattermost.share.ShareModule;
import com.wix.reactnativenotifications.core.NotificationIntentAdapter;
import com.wix.reactnativenotifications.core.notification.PushNotification;
import com.wix.reactnativenotifications.core.AppLaunchHelper;
import com.wix.reactnativenotifications.core.AppLifecycleFacade;
import com.wix.reactnativenotifications.core.JsIOHelper;
import static com.mattermost.helpers.database_extension.GeneralKt.*;
import static com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_EVENT_NAME;
public class CustomPushNotification extends PushNotification {
private final PushNotificationDataHelper dataHelper;
@@ -57,6 +51,7 @@ public class CustomPushNotification extends PushNotification {
int notificationId = NotificationHelper.getNotificationId(initialData);
String serverUrl = addServerUrlToBundle(initialData);
boolean isReactInit = mAppLifecycleFacade.isReactInitialized();
if (ackId != null && serverUrl != null) {
Bundle response = ReceiptDelivery.send(ackId, serverUrl, postId, type, isIdLoaded);
@@ -70,7 +65,7 @@ public class CustomPushNotification extends PushNotification {
}
}
finishProcessingNotification(serverUrl, type, channelId, notificationId);
finishProcessingNotification(serverUrl, type, channelId, notificationId, isReactInit);
}
@Override
@@ -83,9 +78,7 @@ public class CustomPushNotification extends PushNotification {
}
}
private void finishProcessingNotification(final String serverUrl, @NonNull final String type, final String channelId, final int notificationId) {
final boolean isReactInit = mAppLifecycleFacade.isReactInitialized();
private void finishProcessingNotification(String serverUrl, String type, String channelId, int notificationId, Boolean isReactInit) {
switch (type) {
case CustomPushNotificationHelper.PUSH_TYPE_MESSAGE:
case CustomPushNotificationHelper.PUSH_TYPE_SESSION:
@@ -97,17 +90,13 @@ public class CustomPushNotification extends PushNotification {
if (type.equals(CustomPushNotificationHelper.PUSH_TYPE_MESSAGE)) {
if (channelId != null) {
Bundle notificationBundle = mNotificationProps.asBundle();
if (serverUrl != null) {
if (serverUrl != null && !isReactInit) {
// We will only fetch the data related to the notification on the native side
// as updating the data directly to the db removes the wal & shm files needed
// by watermelonDB, if the DB is updated while WDB is running it causes WDB to
// detect the database as malformed, thus the app stop working and a restart is required.
// Data will be fetch from within the JS context instead.
Bundle notificationResult = dataHelper.fetchAndStoreDataForPushNotification(notificationBundle, isReactInit);
if (notificationResult != null) {
notificationBundle.putBundle("data", notificationResult);
mNotificationProps = createProps(notificationBundle);
}
dataHelper.fetchAndStoreDataForPushNotification(notificationBundle);
}
createSummary = NotificationHelper.addNotificationToPreferences(
mContext,
@@ -156,20 +145,17 @@ public class CustomPushNotification extends PushNotification {
}
private String addServerUrlToBundle(Bundle bundle) {
DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance();
String serverId = bundle.getString("server_id");
String serverUrl = null;
if (dbHelper != null) {
if (serverId == null) {
serverUrl = dbHelper.getOnlyServerUrl();
} else {
serverUrl = getServerUrlForIdentifier(dbHelper, serverId);
}
String serverUrl;
if (serverId == null) {
serverUrl = Objects.requireNonNull(DatabaseHelper.Companion.getInstance()).getOnlyServerUrl();
} else {
serverUrl = Objects.requireNonNull(DatabaseHelper.Companion.getInstance()).getServerUrlForIdentifier(serverId);
}
if (!TextUtils.isEmpty(serverUrl)) {
bundle.putString("server_url", serverUrl);
mNotificationProps = createProps(bundle);
}
if (!TextUtils.isEmpty(serverUrl)) {
bundle.putString("server_url", serverUrl);
mNotificationProps = createProps(bundle);
}
return serverUrl;

View File

@@ -1,13 +1,9 @@
package com.mattermost.rnbeta
import android.app.Activity
import androidx.window.core.layout.WindowHeightSizeClass
import androidx.window.core.layout.WindowSizeClass
import androidx.window.core.layout.WindowWidthSizeClass
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
import androidx.window.layout.WindowLayoutInfo
import androidx.window.layout.WindowMetricsCalculator
import androidx.window.rxjava3.layout.windowLayoutInfoObservable
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
@@ -16,70 +12,40 @@ import io.reactivex.rxjava3.disposables.Disposable
class FoldableObserver(private val activity: Activity) {
private var disposable: Disposable? = null
private lateinit var observable: Observable<WindowLayoutInfo>
public var isDeviceFolded: Boolean = false
companion object {
private var instance: FoldableObserver? = null
fun getInstance(activity: Activity): FoldableObserver {
if (instance == null) {
instance = FoldableObserver(activity)
}
return instance!!
}
fun getInstance(): FoldableObserver? {
return instance
}
}
fun onCreate() {
public fun onCreate() {
observable = WindowInfoTracker.getOrCreate(activity)
.windowLayoutInfoObservable(activity)
}
fun onStart() {
public fun onStart() {
if (disposable?.isDisposed == true) {
onCreate()
}
disposable = observable.observeOn(AndroidSchedulers.mainThread())
.subscribe { layoutInfo ->
val splitViewModule = SplitViewModule.getInstance()
setIsDeviceFolded(layoutInfo)
splitViewModule?.setDeviceFolded()
val foldingFeature = layoutInfo.displayFeatures
.filterIsInstance<FoldingFeature>()
.firstOrNull()
when {
foldingFeature?.state === FoldingFeature.State.FLAT ->
splitViewModule?.setDeviceFolded(false)
isTableTopPosture(foldingFeature) ->
splitViewModule?.setDeviceFolded(false)
isBookPosture(foldingFeature) ->
splitViewModule?.setDeviceFolded(false)
else -> {
splitViewModule?.setDeviceFolded(true)
}
}
}
}
fun onStop() {
public fun onStop() {
disposable?.dispose()
}
private fun setIsDeviceFolded(layoutInfo: WindowLayoutInfo) {
val foldingFeature = layoutInfo.displayFeatures
.filterIsInstance<FoldingFeature>()
.firstOrNull()
isDeviceFolded = when {
foldingFeature === null -> isCompactView()
foldingFeature.state === FoldingFeature.State.FLAT -> false
isTableTopPosture(foldingFeature) -> false
isBookPosture(foldingFeature) -> false
else -> true
}
}
fun isCompactView(): Boolean {
val metrics = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(activity)
val width = metrics.bounds.width()
val height = metrics.bounds.height()
val density = activity.resources.displayMetrics.density
val windowSizeClass = WindowSizeClass.compute(width/density, height/density)
val widthWindowSizeClass = windowSizeClass.windowWidthSizeClass
val heightWindowSizeClass = windowSizeClass.windowHeightSizeClass
return widthWindowSizeClass === WindowWidthSizeClass.COMPACT || heightWindowSizeClass === WindowHeightSizeClass.COMPACT
}
private fun isTableTopPosture(foldFeature : FoldingFeature?) : Boolean {
return foldFeature?.state == FoldingFeature.State.HALF_OPENED &&
foldFeature.orientation == FoldingFeature.Orientation.HORIZONTAL

View File

@@ -1,8 +1,6 @@
package com.mattermost.rnbeta;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.view.KeyEvent;
@@ -14,11 +12,9 @@ import com.github.emilioicai.hwkeyboardevent.HWKeyboardEventModule;
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
import com.facebook.react.defaults.DefaultReactActivityDelegate;
import java.util.Objects;
public class MainActivity extends NavigationActivity {
private boolean HWKeyboardConnected = false;
private final FoldableObserver foldableObserver = FoldableObserver.Companion.getInstance(this);
private FoldableObserver foldableObserver = new FoldableObserver(this);
@Override
protected String getMainComponentName() {
@@ -34,9 +30,12 @@ public class MainActivity extends NavigationActivity {
protected ReactActivityDelegate createReactActivityDelegate() {
return new DefaultReactActivityDelegate(
this,
Objects.requireNonNull(getMainComponentName()),
getMainComponentName(),
// If you opted-in for the New Architecture, we enable the Fabric Renderer.
DefaultNewArchitectureEntryPoint.getFabricEnabled());
DefaultNewArchitectureEntryPoint.getFabricEnabled(), // fabricEnabled
// If you opted-in for the New Architecture, we enable Concurrent React (i.e. React 18).
DefaultNewArchitectureEntryPoint.getConcurrentReactEnabled() // concurrentRootEnabled
);
}
@Override
@@ -60,7 +59,7 @@ public class MainActivity extends NavigationActivity {
}
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
if (newConfig.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO) {
@@ -98,7 +97,7 @@ public class MainActivity extends NavigationActivity {
}
}
return super.dispatchKeyEvent(event);
}
};
private void setHWKeyboardConnected() {
HWKeyboardConnected = getResources().getConfiguration().keyboard == Configuration.KEYBOARD_QWERTY;

View File

@@ -32,10 +32,10 @@ import com.mattermost.helpers.RealPathUtil;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.net.URL;
import java.nio.channels.FileChannel;
import java.util.Objects;
public class MattermostManagedModule extends ReactContextBaseJavaModule {
private static final String SAVE_EVENT = "MattermostManagedSaveFile";
@@ -46,6 +46,8 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule {
private Promise mPickerPromise;
private String fileContent;
private static final String TAG = MattermostManagedModule.class.getSimpleName();
private MattermostManagedModule(ReactApplicationContext reactContext) {
super(reactContext);
this.reactContext = reactContext;
@@ -147,7 +149,7 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule {
}
try {
final String packageName = currentActivity.getPackageName();
final String authority = packageName + ".provider";
final String authority = new StringBuilder(packageName).append(".provider").toString();
contentUri = FileProvider.getUriForFile(currentActivity, authority, newFile);
}
catch(IllegalArgumentException e) {
@@ -174,7 +176,7 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule {
intent.setType(mimeType);
intent.putExtra(Intent.EXTRA_TITLE, filename);
PackageManager pm = Objects.requireNonNull(getCurrentActivity()).getPackageManager();
PackageManager pm = getCurrentActivity().getPackageManager();
if (intent.resolveActivity(pm) != null) {
try {
getCurrentActivity().startActivityForResult(intent, SAVE_REQUEST);
@@ -209,7 +211,7 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule {
if (!TextUtils.isEmpty(token)) {
WritableMap headers = Arguments.createMap();
if (optionsMap.hasKey("headers")) {
headers.merge(Objects.requireNonNull(optionsMap.getMap("headers")));
headers.merge(optionsMap.getMap("headers"));
}
headers.putString("Authorization", "Bearer " + token);
optionsMap.putMap("headers", headers);
@@ -235,21 +237,34 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule {
@Override
protected Object doInBackgroundGuarded() {
FileChannel source = null;
FileChannel dest = null;
try {
ParcelFileDescriptor pfd = weakContext.get().getContentResolver().openFileDescriptor(toFile, "w");
File input = new File(this.fromFile);
try (FileInputStream fileInputStream = new FileInputStream(input)) {
try (FileOutputStream fileOutputStream = new FileOutputStream(pfd.getFileDescriptor())) {
FileChannel source = fileInputStream.getChannel();
FileChannel dest = fileOutputStream.getChannel();
dest.transferFrom(source, 0, source.size());
source.close();
dest.close();
}
}
pfd.close();
FileInputStream fileInputStream = new FileInputStream(input);
ParcelFileDescriptor pfd = weakContext.get().getContentResolver().openFileDescriptor(toFile, "w");
FileOutputStream fileOutputStream = new FileOutputStream(pfd.getFileDescriptor());
source = fileInputStream.getChannel();
dest = fileOutputStream.getChannel();
dest.transferFrom(source, 0, source.size());
} catch (Exception e) {
e.printStackTrace();
} finally {
if (source != null) {
try {
source.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (dest != null) {
try {
dest.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return null;

View File

@@ -1,6 +1,7 @@
package com.mattermost.rnbeta;
import android.app.Notification;
import android.app.NotificationManager;
import android.content.Context;
import android.os.Bundle;
import android.service.notification.StatusBarNotification;

View File

@@ -5,6 +5,7 @@ import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEm
import com.learnium.RNDeviceInfo.resolver.DeviceTypeResolver
class SplitViewModule(private var reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
private var isDeviceFolded: Boolean = false
private var listenerCount = 0
companion object {
@@ -28,21 +29,18 @@ class SplitViewModule(private var reactContext: ReactApplicationContext) : React
override fun getName() = "SplitView"
private fun sendEvent(params: WritableMap?) {
fun sendEvent(eventName: String,
params: WritableMap?) {
reactContext
.getJSModule(RCTDeviceEventEmitter::class.java)
.emit("SplitViewChanged", params)
.emit(eventName, params)
}
private fun getSplitViewResults(folded: Boolean) : WritableMap? {
if (currentActivity != null) {
val deviceResolver = DeviceTypeResolver(this.reactContext)
val map = Arguments.createMap()
var isSplitView = folded;
if (currentActivity?.isInMultiWindowMode == true) {
isSplitView = FoldableObserver.getInstance()?.isCompactView() == true
}
map.putBoolean("isSplitView", isSplitView)
map.putBoolean("isSplitView", currentActivity!!.isInMultiWindowMode || folded)
map.putBoolean("isTablet", deviceResolver.isTablet)
return map
}
@@ -50,16 +48,17 @@ class SplitViewModule(private var reactContext: ReactApplicationContext) : React
return null
}
fun setDeviceFolded() {
val map = getSplitViewResults(FoldableObserver.getInstance()?.isDeviceFolded == true)
if (listenerCount > 0) {
sendEvent(map)
fun setDeviceFolded(folded: Boolean) {
val map = getSplitViewResults(folded)
if (listenerCount > 0 && isDeviceFolded != folded) {
sendEvent("SplitViewChanged", map)
}
isDeviceFolded = folded
}
@ReactMethod(isBlockingSynchronousMethod = true)
fun isRunningInSplitView(): WritableMap? {
return getSplitViewResults(FoldableObserver.getInstance()?.isDeviceFolded == true)
@ReactMethod
fun isRunningInSplitView(promise: Promise) {
promise.resolve(getSplitViewResults(isDeviceFolded))
}
@ReactMethod

View File

@@ -29,7 +29,6 @@ import org.json.JSONException;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
@@ -76,8 +75,8 @@ public class ShareModule extends ReactContextBaseJavaModule {
public String getCurrentActivityName() {
Activity currentActivity = getCurrentActivity();
if (currentActivity != null) {
String activityName = currentActivity.getComponentName().getClassName();
String[] components = activityName.split("\\.");
String actvName = currentActivity.getComponentName().getClassName();
String[] components = actvName.split("\\.");
return components[components.length - 1];
}
@@ -116,7 +115,7 @@ public class ShareModule extends ReactContextBaseJavaModule {
if (data != null && data.hasKey("serverUrl")) {
ReadableArray files = data.getArray("files");
String serverUrl = data.getString("serverUrl");
final String token = Credentials.getCredentialsForServerSync(mReactContext, serverUrl);
final String token = Credentials.getCredentialsForServerSync(this.getReactApplicationContext(), serverUrl);
JSONObject postData = buildPostObject(data);
if (files != null && files.size() > 0) {
@@ -237,7 +236,7 @@ public class ShareModule extends ReactContextBaseJavaModule {
try (Response response = client.newCall(request).execute()) {
if (response.isSuccessful()) {
String responseData = Objects.requireNonNull(response.body()).string();
String responseData = response.body().string();
JSONObject responseJson = new JSONObject(responseData);
JSONArray fileInfoArray = responseJson.getJSONArray("file_infos");
JSONArray file_ids = new JSONArray();

View File

@@ -16,7 +16,7 @@
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material">
<selector>
<!--
<!--
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'

View File

@@ -1,19 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* <p>This source code is licensed under the MIT license found in the LICENSE file in the root
* directory of this source tree.
*/
package com.mattermost.flipper;
import android.content.Context;
import com.facebook.react.ReactInstanceManager;
/**
* Class responsible of loading Flipper inside your React Native application. This is the release
* flavor of it so it's empty as we don't want to load Flipper.
*/
public class ReactNativeFlipper {
public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
// Do nothing as we don't want to initialize Flipper on Release.
}
}

View File

@@ -7,10 +7,10 @@ buildscript {
compileSdkVersion = 33
targetSdkVersion = 33
supportLibVersion = "33.0.0"
kotlinVersion = "1.8.21"
kotlin_version = kotlinVersion
kotlinVersion = "1.5.30"
kotlin_version = "1.5.30"
firebaseVersion = "21.0.0"
RNNKotlinVersion = kotlinVersion
firebaseVersion = "23.3.1"
// We use NDK 23 which has both M1 support and is the side-by-side NDK version from AGP.
ndkVersion = "23.1.7779620"
@@ -21,9 +21,9 @@ buildscript {
google()
}
dependencies {
classpath("com.android.tools.build:gradle")
classpath("com.android.tools.build:gradle:7.3.1")
classpath("com.facebook.react:react-native-gradle-plugin")
classpath('com.google.gms:google-services:4.4.0')
classpath('com.google.gms:google-services:4.3.14')
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
// NOTE: Do not place your application dependencies here; they belong

View File

@@ -29,7 +29,7 @@ android.useAndroidX=true
android.enableJetifier=true
# Version of flipper SDK to use with React Native
FLIPPER_VERSION=0.182.0
FLIPPER_VERSION=0.125.0
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using

Binary file not shown.

View File

@@ -1,6 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.1-all.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-all.zip

20
android/gradlew vendored
View File

@@ -55,7 +55,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -80,11 +80,11 @@ do
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
@@ -143,16 +143,12 @@ fi
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@@ -209,12 +205,6 @@ set -- \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
@@ -241,4 +231,4 @@ eval "set -- $(
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
exec "$JAVACMD" "$@"

17
android/gradlew.bat vendored
View File

@@ -14,7 +14,7 @@
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@@ -25,8 +25,7 @@
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@@ -41,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
@@ -76,17 +75,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
:omega

View File

@@ -7,4 +7,4 @@ include ':react-native-video'
project(':react-native-video').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-video/android-exoplayer')
include ':watermelondb-jsi'
project(':watermelondb-jsi').projectDir = new File(rootProject.projectDir, '../node_modules/@nozbe/watermelondb/native/android-jsi')
includeBuild('../node_modules/@react-native/gradle-plugin')
includeBuild('../node_modules/react-native-gradle-plugin')

View File

@@ -4,7 +4,6 @@
import {Tutorial} from '@constants';
import {GLOBAL_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getActiveServerUrl} from '@init/credentials';
import {logError} from '@utils/log';
export const storeGlobal = async (id: string, value: unknown, prepareRecordsOnly = false) => {
@@ -51,37 +50,3 @@ export const storeLastAskForReview = async (prepareRecordsOnly = false) => {
export const storeFirstLaunch = async (prepareRecordsOnly = false) => {
return storeGlobal(GLOBAL_IDENTIFIERS.FIRST_LAUNCH, Date.now(), prepareRecordsOnly);
};
export const storeLastViewedChannelIdAndServer = async (channelId: string) => {
const currentServerUrl = await getActiveServerUrl();
return storeGlobal(GLOBAL_IDENTIFIERS.LAST_VIEWED_CHANNEL, {
server_url: currentServerUrl,
channel_id: channelId,
}, false);
};
export const storeLastViewedThreadIdAndServer = async (threadId: string) => {
const currentServerUrl = await getActiveServerUrl();
return storeGlobal(GLOBAL_IDENTIFIERS.LAST_VIEWED_THREAD, {
server_url: currentServerUrl,
thread_id: threadId,
}, false);
};
export const removeLastViewedChannelIdAndServer = async () => {
return storeGlobal(GLOBAL_IDENTIFIERS.LAST_VIEWED_CHANNEL, null, false);
};
export const removeLastViewedThreadIdAndServer = async () => {
return storeGlobal(GLOBAL_IDENTIFIERS.LAST_VIEWED_THREAD, null, false);
};
export const storePushDisabledInServerAcknowledged = async (serverUrl: string) => {
return storeGlobal(`${GLOBAL_IDENTIFIERS.PUSH_DISABLED_ACK}${serverUrl}`, 'true', false);
};
export const removePushDisabledInServerAcknowledged = async (serverUrl: string) => {
return storeGlobal(`${GLOBAL_IDENTIFIERS.PUSH_DISABLED_ACK}${serverUrl}`, null, false);
};

View File

@@ -1,69 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import {handleConvertedGMCategories} from './category';
import type ServerDataOperator from '@database/operator/server_data_operator';
describe('handleConvertedGMCategories', () => {
const serverUrl = 'baseHandler.test.com';
const channelId = 'channel_id_1';
const teamId1 = 'team_id_1';
const teamId2 = 'team_id_2';
const team: Team = {
id: teamId1,
} as Team;
let operator: ServerDataOperator;
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
});
it('base case', async () => {
await operator.handleTeam({teams: [team], prepareRecordsOnly: false});
const defaultCategory: Category = {
id: 'default_category_id',
team_id: teamId1,
type: 'channels',
} as Category;
const customCategory: Category = {
id: 'custom_category_id',
team_id: teamId2,
type: 'custom',
} as Category;
const dmCategory: Category = {
id: 'dm_category_id',
team_id: teamId1,
type: 'direct_messages',
} as Category;
await operator.handleCategories({categories: [defaultCategory, customCategory, dmCategory], prepareRecordsOnly: false});
const dmCategoryChannel: CategoryChannel = {
id: 'dm_category_channel_id',
category_id: 'dm_category_id',
channel_id: channelId,
sort_order: 1,
};
const customCategoryChannel: CategoryChannel = {
id: 'custom_category_channel_id',
category_id: 'dm_category_id',
channel_id: channelId,
sort_order: 1,
};
await operator.handleCategoryChannels({categoryChannels: [dmCategoryChannel, customCategoryChannel], prepareRecordsOnly: false});
const {models, error} = await handleConvertedGMCategories(serverUrl, channelId, teamId1, true);
expect(error).toBeUndefined();
expect(models).toBeDefined();
expect(models!.length).toBe(3); // two for removing channel for a custom and a DM category, and one for adding it to default channels category
});
});

View File

@@ -3,13 +3,12 @@
import {CHANNELS_CATEGORY, DMS_CATEGORY} from '@constants/categories';
import DatabaseManager from '@database/manager';
import {prepareCategoryChannels, queryCategoriesByTeamIds, getCategoryById, prepareCategoriesAndCategoriesChannels, queryCategoryChannelsByChannelId} from '@queries/servers/categories';
import {prepareCategoryChannels, queryCategoriesByTeamIds, getCategoryById, prepareCategoriesAndCategoriesChannels} from '@queries/servers/categories';
import {getCurrentUserId} from '@queries/servers/system';
import {queryMyTeams} from '@queries/servers/team';
import {isDMorGM} from '@utils/channel';
import {logDebug, logError} from '@utils/log';
import {logError} from '@utils/log';
import type {Database, Model} from '@nozbe/watermelondb';
import type ChannelModel from '@typings/database/models/servers/channel';
export const deleteCategory = async (serverUrl: string, categoryId: string) => {
@@ -40,7 +39,7 @@ export async function storeCategories(serverUrl: string, categories: CategoryWit
}
if (models.length > 0) {
await operator.batchRecords(models, 'storeCategories');
await operator.batchRecords(models);
}
return {models};
@@ -92,8 +91,11 @@ export async function addChannelToDefaultCategory(serverUrl: string, channel: Ch
categoriesWithChannels.push(cwc);
}
} else {
const cwc = await prepareAddNonGMDMChannelToDefaultCategory(database, teamId, channel.id);
if (cwc) {
const categories = await queryCategoriesByTeamIds(database, [teamId]).fetch();
const channelCategory = categories.find((c) => c.type === CHANNELS_CATEGORY);
if (channelCategory) {
const cwc = await channelCategory.toCategoryWithChannels();
cwc.channel_ids.unshift(channel.id);
categoriesWithChannels.push(cwc);
}
}
@@ -101,67 +103,11 @@ export async function addChannelToDefaultCategory(serverUrl: string, channel: Ch
const models = await prepareCategoryChannels(operator, categoriesWithChannels);
if (models.length && !prepareRecordsOnly) {
await operator.batchRecords(models, 'addChannelToDefaultCategory');
await operator.batchRecords(models);
}
return {models};
} catch (error) {
logError('Failed to add channel to default category', error);
return {error};
}
}
async function prepareAddNonGMDMChannelToDefaultCategory(database: Database, teamId: string, channelId: string): Promise<CategoryWithChannels | undefined> {
const categories = await queryCategoriesByTeamIds(database, [teamId]).fetch();
const channelCategory = categories.find((category) => category.type === CHANNELS_CATEGORY);
if (channelCategory) {
const cwc = await channelCategory.toCategoryWithChannels();
if (cwc.channel_ids.indexOf(channelId) < 0) {
cwc.channel_ids.unshift(channelId);
return cwc;
}
}
return undefined;
}
export async function handleConvertedGMCategories(serverUrl: string, channelId: string, targetTeamID: string, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const categoryChannels = await queryCategoryChannelsByChannelId(database, channelId).fetch();
const categories = await queryCategoriesByTeamIds(database, [targetTeamID]).fetch();
const channelCategory = categories.find((category) => category.type === CHANNELS_CATEGORY);
if (!channelCategory) {
const error = 'Failed to find default category when handling category of converted GM';
logError(error);
return {error};
}
const models: Model[] = [];
categoryChannels.forEach((categoryChannel) => {
if (categoryChannel.categoryId !== channelCategory.id) {
models.push(categoryChannel.prepareDestroyPermanently());
}
});
const cwc = await prepareAddNonGMDMChannelToDefaultCategory(database, targetTeamID, channelId);
if (cwc) {
const model = await prepareCategoryChannels(operator, [cwc]);
models.push(...model);
} else {
logDebug('handleConvertedGMCategories: could not find channel category of target team');
}
if (models.length > 0 && !prepareRecordsOnly) {
await operator.batchRecords(models, 'putGMInCorrectCategory');
}
return {models};
} catch (error) {
logError('Failed to handle category update for GM converted to channel', error);
return {error};
}
}

View File

@@ -13,8 +13,8 @@ import {
prepareDeleteChannel, prepareMyChannelsForTeam, queryAllMyChannel,
getMyChannel, getChannelById, queryUsersOnChannel, queryUserChannelsByTypes,
} from '@queries/servers/channel';
import {queryDisplayNamePreferences} from '@queries/servers/preference';
import {prepareCommonSystemValues, type PrepareCommonSystemValuesArgs, getCommonSystemValues, getCurrentTeamId, setCurrentChannelId, getCurrentUserId, getConfig, getLicense} from '@queries/servers/system';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {prepareCommonSystemValues, PrepareCommonSystemValuesArgs, getCommonSystemValues, getCurrentTeamId, setCurrentChannelId, getCurrentUserId, getConfig, getLicense} from '@queries/servers/system';
import {addChannelToTeamHistory, addTeamToTeamHistory, getTeamById, removeChannelFromTeamHistory} from '@queries/servers/team';
import {getCurrentUser, queryUsersById} from '@queries/servers/user';
import {dismissAllModalsAndPopToRoot, dismissAllModalsAndPopToScreen} from '@screens/navigation';
@@ -32,7 +32,7 @@ export async function switchToChannel(serverUrl: string, channelId: string, team
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
let models: Model[] = [];
const dt = Date.now();
const isTabletDevice = isTablet();
const isTabletDevice = await isTablet();
const system = await getCommonSystemValues(database);
const member = await getMyChannel(database, channelId);
@@ -82,7 +82,7 @@ export async function switchToChannel(serverUrl: string, channelId: string, team
}
if (models.length && !prepareRecordsOnly) {
await operator.batchRecords(models, 'switchToChannel');
await operator.batchRecords(models);
}
if (isTabletDevice) {
@@ -124,7 +124,7 @@ export async function removeCurrentUserFromChannel(serverUrl: string, channelId:
await removeChannelFromTeamHistory(operator, teamId, channel.id, false);
if (models.length && !prepareRecordsOnly) {
await operator.batchRecords(models, 'removeCurrentUserFromChannel');
await operator.batchRecords(models);
}
}
return {models};
@@ -145,7 +145,7 @@ export async function setChannelDeleteAt(serverUrl: string, channelId: string, d
const model = channel.prepareUpdate((c) => {
c.deleteAt = deleteAt;
});
await operator.batchRecords([model], 'setChannelDeleteAt');
await operator.batchRecords([model]);
} catch (error) {
logError('FAILED TO BATCH CHANGES FOR CHANNEL DELETE AT', error);
}
@@ -179,7 +179,7 @@ export async function markChannelAsViewed(serverUrl: string, channelId: string,
});
PushNotifications.removeChannelNotifications(serverUrl, channelId);
if (!prepareRecordsOnly) {
await operator.batchRecords([member], 'markChannelAsViewed');
await operator.batchRecords([member]);
}
return {member};
@@ -206,7 +206,7 @@ export async function markChannelAsUnread(serverUrl: string, channelId: string,
m.isUnread = true;
});
if (!prepareRecordsOnly) {
await operator.batchRecords([member], 'markChannelAsUnread');
await operator.batchRecords([member]);
}
return {member};
@@ -226,7 +226,7 @@ export async function resetMessageCount(serverUrl: string, channelId: string) {
member.prepareUpdate((m) => {
m.messageCount = 0;
});
await operator.batchRecords([member], 'resetMessageCount');
await operator.batchRecords([member]);
return member;
} catch (error) {
@@ -254,7 +254,7 @@ export async function storeMyChannelsForTeam(serverUrl: string, teamId: string,
}
if (flattenedModels.length) {
await operator.batchRecords(flattenedModels, 'storeMyChannelsForTeam');
await operator.batchRecords(flattenedModels);
}
return {models: flattenedModels};
@@ -273,7 +273,7 @@ export async function updateMyChannelFromWebsocket(serverUrl: string, channelMem
m.roles = channelMember.roles;
});
if (!prepareRecordsOnly) {
operator.batchRecords([member], 'updateMyChannelFromWebsocket');
operator.batchRecords([member]);
}
}
return {model: member};
@@ -293,7 +293,7 @@ export async function updateChannelInfoFromChannel(serverUrl: string, channel: C
}],
prepareRecordsOnly: true});
if (!prepareRecordsOnly) {
operator.batchRecords(newInfo, 'updateChannelInfoFromChannel');
operator.batchRecords(newInfo);
}
return {model: newInfo};
} catch (error) {
@@ -317,7 +317,7 @@ export async function updateLastPostAt(serverUrl: string, channelId: string, las
});
if (!prepareRecordsOnly) {
await operator.batchRecords([myChannel], 'updateLastPostAt');
await operator.batchRecords([myChannel]);
}
return {member: myChannel};
@@ -345,7 +345,7 @@ export async function updateMyChannelLastFetchedAt(serverUrl: string, channelId:
});
if (!prepareRecordsOnly) {
await operator.batchRecords([myChannel], 'updateMyChannelLastFetchedAt');
await operator.batchRecords([myChannel]);
}
return {member: myChannel};
@@ -369,7 +369,7 @@ export async function updateChannelsDisplayName(serverUrl: string, channels: Cha
const license = await getLicense(database);
const config = await getConfig(database);
const preferences = await queryDisplayNamePreferences(database, Preferences.NAME_NAME_FORMAT).fetch();
const preferences = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT).fetch();
const displaySettings = getTeammateNameDisplaySetting(preferences, config.LockTeammateNameDisplay, config.TeammateNameDisplay, license);
const models: Model[] = [];
for await (const channel of channels) {
@@ -403,7 +403,7 @@ export async function updateChannelsDisplayName(serverUrl: string, channels: Cha
}
if (models.length && !prepareRecordsOnly) {
await operator.batchRecords(models, 'updateChannelsDisplayName');
await operator.batchRecords(models);
}
return {models};

View File

@@ -26,7 +26,7 @@ export async function updateDraftFile(serverUrl: string, channelId: string, root
});
if (!prepareRecordsOnly) {
await operator.batchRecords([draft], 'updateDraftFile');
await operator.batchRecords([draft]);
}
return {draft};
@@ -58,7 +58,7 @@ export async function removeDraftFile(serverUrl: string, channelId: string, root
}
if (!prepareRecordsOnly) {
await operator.batchRecords([draft], 'removeDraftFile');
await operator.batchRecords([draft]);
}
return {draft};
@@ -99,7 +99,7 @@ export async function updateDraftMessage(serverUrl: string, channelId: string, r
}
if (!prepareRecordsOnly) {
await operator.batchRecords([draft], 'updateDraftMessage');
await operator.batchRecords([draft]);
}
return {draft};
@@ -129,7 +129,7 @@ export async function addFilesToDraft(serverUrl: string, channelId: string, root
});
if (!prepareRecordsOnly) {
await operator.batchRecords([draft], 'addFilesToDraft');
await operator.batchRecords([draft]);
}
return {draft};
@@ -155,37 +155,3 @@ export const removeDraft = async (serverUrl: string, channelId: string, rootId =
return {error};
}
};
export async function updateDraftPriority(serverUrl: string, channelId: string, rootId: string, postPriority: PostPriority, 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,
metadata: {
priority: postPriority,
},
};
return operator.handleDraft({drafts: [newDraft], prepareRecordsOnly});
}
draft.prepareUpdate((d) => {
d.metadata = {
...d.metadata,
priority: postPriority,
};
});
if (!prepareRecordsOnly) {
await operator.batchRecords([draft], 'updateDraftPriority');
}
return {draft};
} catch (error) {
logError('Failed updateDraftPriority', error);
return {error};
}
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import {getPostById, queryPostsInChannel, queryPostsInThread} from '@queries/servers/post';
import {logError} from '@utils/log';
export const updatePostSinceCache = async (serverUrl: string, notification: NotificationWithData) => {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
if (notification.payload?.channel_id) {
const chunks = await queryPostsInChannel(database, notification.payload.channel_id).fetch();
if (chunks.length) {
const recent = chunks[0];
const lastPost = await getPostById(database, notification.payload.post_id);
if (lastPost) {
await operator.database.write(async () => {
await recent.update(() => {
recent.latest = lastPost.createAt;
});
});
}
}
}
return {};
} catch (error) {
logError('Failed updatePostSinceCache', error);
return {error};
}
};
export const updatePostsInThreadsSinceCache = async (serverUrl: string, notification: NotificationWithData) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
if (notification.payload?.root_id) {
const {database} = operator;
const chunks = await queryPostsInThread(database, notification.payload.root_id).fetch();
if (chunks.length) {
const recent = chunks[0];
const lastPost = await getPostById(database, notification.payload.post_id);
if (lastPost) {
await operator.database.write(async () => {
await recent.update(() => {
recent.latest = lastPost.createAt;
});
});
}
}
}
return {};
} catch (error) {
return {error};
}
};

View File

@@ -3,9 +3,8 @@
import {fetchPostAuthors} from '@actions/remote/post';
import {ActionType, Post} from '@constants';
import {MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
import {countUsersFromMentions, getPostById, prepareDeletePost, queryPostsById} from '@queries/servers/post';
import {getPostById, prepareDeletePost, queryPostsById} from '@queries/servers/post';
import {getCurrentUserId} from '@queries/servers/system';
import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread';
import {generateId} from '@utils/general';
@@ -15,13 +14,10 @@ import {getPostIdsForCombinedUserActivityPost} from '@utils/post_list';
import {updateLastPostAt, updateMyChannelLastFetchedAt} from './channel';
import type {Q} from '@nozbe/watermelondb';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
const {SERVER: {DRAFT, FILE, POST, POSTS_IN_THREAD, REACTION, THREAD, THREAD_PARTICIPANT, THREADS_IN_TEAM}} = MM_TABLES;
export const sendAddToChannelEphemeralPost = async (serverUrl: string, user: UserModel, addedUsernames: string[], messages: string[], channeId: string, postRootId = '') => {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
@@ -128,14 +124,14 @@ export async function removePost(serverUrl: string, post: PostModel | Post) {
}
if (removeModels.length) {
await operator.batchRecords(removeModels, 'removePost (combined user activity)');
await operator.batchRecords(removeModels);
}
} else {
const postModel = await getPostById(database, post.id);
if (postModel) {
const preparedPost = await prepareDeletePost(postModel);
if (preparedPost.length) {
await operator.batchRecords(preparedPost, 'removePost');
await operator.batchRecords(preparedPost);
}
}
}
@@ -158,13 +154,12 @@ export async function markPostAsDeleted(serverUrl: string, post: Post, prepareRe
const model = dbPost.prepareUpdate((p) => {
p.deleteAt = Date.now();
p.message = '';
p.messageSource = '';
p.metadata = null;
p.props = undefined;
});
if (!prepareRecordsOnly) {
await operator.batchRecords([dbPost], 'markPostAsDeleted');
await operator.batchRecords([dbPost]);
}
return {model};
} catch (error) {
@@ -231,7 +226,7 @@ export async function storePostsForChannel(
}
if (models.length && !prepareRecordsOnly) {
await operator.batchRecords(models, 'storePostsForChannel');
await operator.batchRecords(models);
}
return {models};
@@ -241,114 +236,11 @@ export async function storePostsForChannel(
}
}
export async function getPosts(serverUrl: string, ids: string[], sort?: Q.SortOrder) {
export async function getPosts(serverUrl: string, ids: string[]) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
return queryPostsById(database, ids, sort).fetch();
return queryPostsById(database, ids).fetch();
} catch (error) {
return [];
}
}
export async function addPostAcknowledgement(serverUrl: string, postId: string, userId: string, acknowledgedAt: number, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const post = await getPostById(database, postId);
if (!post) {
throw new Error('Post not found');
}
// Check if the post has already been acknowledged by the user
const isAckd = post.metadata?.acknowledgements?.find((a) => a.user_id === userId);
if (isAckd) {
return {error: false};
}
const acknowledgements = [...(post.metadata?.acknowledgements || []), {
user_id: userId,
acknowledged_at: acknowledgedAt,
post_id: postId,
}];
const model = post.prepareUpdate((p) => {
p.metadata = {
...p.metadata,
acknowledgements,
};
});
if (!prepareRecordsOnly) {
await operator.batchRecords([model], 'addPostAcknowledgement');
}
return {model};
} catch (error) {
logError('Failed addPostAcknowledgement', error);
return {error};
}
}
export async function removePostAcknowledgement(serverUrl: string, postId: string, userId: string, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const post = await getPostById(database, postId);
if (!post) {
throw new Error('Post not found');
}
const model = post.prepareUpdate((record) => {
record.metadata = {
...post.metadata,
acknowledgements: post.metadata?.acknowledgements?.filter(
(a) => a.user_id !== userId,
) || [],
};
});
if (!prepareRecordsOnly) {
await operator.batchRecords([model], 'removePostAcknowledgement');
}
return {model};
} catch (error) {
logError('Failed removePostAcknowledgement', error);
return {error};
}
}
export async function deletePosts(serverUrl: string, postIds: string[]) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const postsFormatted = `'${postIds.join("','")}'`;
await database.write(() => {
return database.adapter.unsafeExecute({
sqls: [
[`DELETE FROM ${POST} where id IN (${postsFormatted})`, []],
[`DELETE FROM ${REACTION} where post_id IN (${postsFormatted})`, []],
[`DELETE FROM ${FILE} where post_id IN (${postsFormatted})`, []],
[`DELETE FROM ${DRAFT} where root_id IN (${postsFormatted})`, []],
[`DELETE FROM ${POSTS_IN_THREAD} where root_id IN (${postsFormatted})`, []],
[`DELETE FROM ${THREAD} where id IN (${postsFormatted})`, []],
[`DELETE FROM ${THREAD_PARTICIPANT} where thread_id IN (${postsFormatted})`, []],
[`DELETE FROM ${THREADS_IN_TEAM} where thread_id IN (${postsFormatted})`, []],
],
});
});
return {error: false};
} catch (error) {
return {error};
}
}
export function getUsersCountFromMentions(serverUrl: string, mentions: string[]): Promise<number> {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
return countUsersFromMentions(database, mentions);
} catch (error) {
return Promise.resolve(0);
}
}

View File

@@ -1,23 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import deepEqual from 'deep-equal';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getServerCredentials} from '@init/credentials';
import {queryAllChannelsForTeam} from '@queries/servers/channel';
import {getConfig, getLicense, getGlobalDataRetentionPolicy, getGranularDataRetentionPolicies, getLastGlobalDataRetentionRun, getIsDataRetentionEnabled} from '@queries/servers/system';
import PostModel from '@typings/database/models/servers/post';
import {getConfig, getLicense} from '@queries/servers/system';
import {logError} from '@utils/log';
import {deletePosts} from './post';
import type {DataRetentionPoliciesRequest} from '@actions/remote/systems';
const {SERVER: {POST}} = MM_TABLES;
export async function storeConfigAndLicense(serverUrl: string, config: ClientConfig, license: ClientLicense) {
try {
// If we have credentials for this server then update the values in the database
@@ -83,187 +74,6 @@ export async function storeConfig(serverUrl: string, config: ClientConfig | unde
return [];
}
export async function storeDataRetentionPolicies(serverUrl: string, data: DataRetentionPoliciesRequest, prepareRecordsOnly = false) {
try {
const {globalPolicy, teamPolicies, channelPolicies} = data;
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const systems: IdValue[] = [{
id: SYSTEM_IDENTIFIERS.DATA_RETENTION_POLICIES,
value: globalPolicy || {},
}, {
id: SYSTEM_IDENTIFIERS.GRANULAR_DATA_RETENTION_POLICIES,
value: {
team: teamPolicies || [],
channel: channelPolicies || [],
},
}];
return operator.handleSystem({
systems,
prepareRecordsOnly,
});
} catch {
return [];
}
}
export async function updateLastDataRetentionRun(serverUrl: string, value?: number, prepareRecordsOnly = false) {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const systems: IdValue[] = [{
id: SYSTEM_IDENTIFIERS.LAST_DATA_RETENTION_RUN,
value: value || Date.now(),
}];
return operator.handleSystem({systems, prepareRecordsOnly});
} catch (error) {
logError('Failed updateLastDataRetentionRun', error);
return {error};
}
}
export async function dataRetentionCleanup(serverUrl: string) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const lastRunAt = await getLastGlobalDataRetentionRun(database);
const lastCleanedToday = new Date(lastRunAt).toDateString() === new Date().toDateString();
// Do not run if clean up is already done today
if (lastRunAt && lastCleanedToday) {
return {error: undefined};
}
const isDataRetentionEnabled = await getIsDataRetentionEnabled(database);
const result = await (isDataRetentionEnabled ? dataRetentionPolicyCleanup(serverUrl) : dataRetentionWithoutPolicyCleanup(serverUrl));
if (!result.error) {
await updateLastDataRetentionRun(serverUrl);
}
await database.unsafeVacuum();
return result;
} catch (error) {
logError('An error occurred while performing data retention cleanup', error);
return {error};
}
}
async function dataRetentionPolicyCleanup(serverUrl: string) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const globalPolicy = await getGlobalDataRetentionPolicy(database);
const granularPoliciesData = await getGranularDataRetentionPolicies(database);
// Get global data retention cutoff
let globalRetentionCutoff = 0;
if (globalPolicy?.message_deletion_enabled) {
globalRetentionCutoff = globalPolicy.message_retention_cutoff;
}
// Get Granular data retention policies
let teamPolicies: TeamDataRetentionPolicy[] = [];
let channelPolicies: ChannelDataRetentionPolicy[] = [];
if (granularPoliciesData) {
teamPolicies = granularPoliciesData.team;
channelPolicies = granularPoliciesData.channel;
}
const channelsCutoffs: {[key: string]: number} = {};
// Get channel level cutoff from team policies
for await (const teamPolicy of teamPolicies) {
const {team_id, post_duration} = teamPolicy;
const channelIds = await queryAllChannelsForTeam(database, team_id).fetchIds();
if (channelIds.length) {
const cutoff = getDataRetentionPolicyCutoff(post_duration);
channelIds.forEach((channelId) => {
channelsCutoffs[channelId] = cutoff;
});
}
}
// Get channel level cutoff from channel policies
channelPolicies.forEach(({channel_id, post_duration}) => {
channelsCutoffs[channel_id] = getDataRetentionPolicyCutoff(post_duration);
});
const conditions = [];
const channelIds = Object.keys(channelsCutoffs);
if (channelIds.length) {
// Fetch posts by channel level cutoff
for (const channelId of channelIds) {
const cutoff = channelsCutoffs[channelId];
conditions.push(`(channel_id='${channelId}' AND create_at < ${cutoff})`);
}
// Fetch posts by global cutoff which are not already fetched by channel level cutoff
conditions.push(`(channel_id NOT IN ('${channelIds.join("','")}') AND create_at < ${globalRetentionCutoff})`);
} else {
conditions.push(`create_at < ${globalRetentionCutoff}`);
}
const postIds = await database.get<PostModel>(POST).query(
Q.unsafeSqlQuery(`SELECT * FROM ${POST} where ${conditions.join(' OR ')}`),
).fetchIds();
return dataRetentionCleanPosts(serverUrl, postIds);
} catch (error) {
logError('An error occurred while performing data retention policy cleanup', error);
return {error};
}
}
async function dataRetentionWithoutPolicyCleanup(serverUrl: string) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const cutoff = getDataRetentionPolicyCutoff(14); // 14 days
const postIds = await database.get<PostModel>(POST).query(
Q.where('create_at', Q.lt(cutoff)),
).fetchIds();
return dataRetentionCleanPosts(serverUrl, postIds);
} catch (error) {
logError('An error occurred while performing data retention without policy cleanup', error);
return {error};
}
}
async function dataRetentionCleanPosts(serverUrl: string, postIds: string[]) {
if (postIds.length) {
const batchSize = 1000;
const deletePromises = [];
for (let i = 0; i < postIds.length; i += batchSize) {
const batch = postIds.slice(i, batchSize);
deletePromises.push(
deletePosts(serverUrl, batch),
);
}
const deleteResult = await Promise.all(deletePromises);
for (const {error} of deleteResult) {
if (error) {
return {error};
}
}
}
return {error: undefined};
}
// Returns cutoff time based on the policy's post_duration
function getDataRetentionPolicyCutoff(postDuration: number) {
const periodDate = new Date();
periodDate.setDate(periodDate.getDate() - postDuration);
periodDate.setHours(0);
periodDate.setMinutes(0);
periodDate.setSeconds(0);
return periodDate.getTime();
}
export async function setLastServerVersionCheck(serverUrl: string, reset = false) {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);

View File

@@ -22,7 +22,7 @@ export async function removeUserFromTeam(serverUrl: string, teamId: string) {
models.push(...system);
}
if (models.length) {
await operator.batchRecords(models, 'removeUserFromTeam');
await operator.batchRecords(models);
}
}
@@ -61,7 +61,7 @@ export async function addSearchToTeamSearchHistory(serverUrl: string, teamId: st
}
}
await operator.batchRecords(models, 'addSearchToTeamHistory');
await operator.batchRecords(models);
return {searchModel};
} catch (error) {
logError('Failed addSearchToTeamSearchHistory', error);

View File

@@ -8,11 +8,11 @@ import DatabaseManager from '@database/manager';
import {getTranslations, t} from '@i18n';
import {getChannelById} from '@queries/servers/channel';
import {getPostById} from '@queries/servers/post';
import {getCurrentTeamId, getCurrentUserId, prepareCommonSystemValues, type PrepareCommonSystemValuesArgs, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {getCurrentTeamId, getCurrentUserId, prepareCommonSystemValues, PrepareCommonSystemValuesArgs, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {addChannelToTeamHistory, addTeamToTeamHistory} from '@queries/servers/team';
import {getThreadById, prepareThreadsFromReceivedPosts, queryThreadsInTeam} from '@queries/servers/thread';
import {getIsCRTEnabled, getThreadById, prepareThreadsFromReceivedPosts, queryThreadsInTeam} from '@queries/servers/thread';
import {getCurrentUser} from '@queries/servers/user';
import {dismissAllModals, dismissAllModalsAndPopToRoot, dismissAllOverlays, goToScreen} from '@screens/navigation';
import {dismissAllModalsAndPopToRoot, goToScreen} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import NavigationStore from '@store/navigation_store';
import {isTablet} from '@utils/helpers';
@@ -40,10 +40,10 @@ export const switchToGlobalThreads = async (serverUrl: string, teamId?: string,
models.push(...history);
if (!prepareRecordsOnly) {
await operator.batchRecords(models, 'switchToGlobalThreads');
await operator.batchRecords(models);
}
const isTabletDevice = isTablet();
const isTabletDevice = await isTablet();
if (isTabletDevice) {
DeviceEventEmitter.emit(Navigation.NAVIGATION_HOME, Screens.GLOBAL_THREADS);
} else {
@@ -75,27 +75,20 @@ export const switchToThread = async (serverUrl: string, rootId: string, isFromNo
}
const currentTeamId = await getCurrentTeamId(database);
const isTabletDevice = isTablet();
const isTabletDevice = await isTablet();
const teamId = channel.teamId || currentTeamId;
const currentThreadId = EphemeralStore.getCurrentThreadId();
EphemeralStore.setCurrentThreadId(rootId);
if (isFromNotification) {
if (currentThreadId && currentThreadId === rootId && NavigationStore.getScreensInStack().includes(Screens.THREAD)) {
await dismissAllModals();
await dismissAllOverlays();
return {};
let switchingTeams = false;
if (currentTeamId === teamId) {
const models = await prepareCommonSystemValues(operator, {
currentChannelId: channel.id,
});
if (models.length) {
await operator.batchRecords(models);
}
await dismissAllModalsAndPopToRoot();
await NavigationStore.waitUntilScreenIsTop(Screens.HOME);
if (currentTeamId !== teamId && isTabletDevice) {
DeviceEventEmitter.emit(Navigation.NAVIGATION_HOME, Screens.GLOBAL_THREADS);
}
}
if (currentTeamId !== teamId) {
} else {
const modelPromises: Array<Promise<Model[]>> = [];
switchingTeams = true;
modelPromises.push(addTeamToTeamHistory(operator, teamId, true));
const commonValues: PrepareCommonSystemValuesArgs = {
currentChannelId: channel.id,
@@ -104,10 +97,29 @@ export const switchToThread = async (serverUrl: string, rootId: string, isFromNo
modelPromises.push(prepareCommonSystemValues(operator, commonValues));
const models = (await Promise.all(modelPromises)).flat();
if (models.length) {
await operator.batchRecords(models, 'switchToThread');
await operator.batchRecords(models);
}
}
// Modal right buttons
const rightButtons = [];
const isCRTEnabled = await getIsCRTEnabled(database);
if (isCRTEnabled) {
// CRT: Add follow/following button
rightButtons.push({
id: 'thread-follow-button',
component: {
id: post.id,
name: Screens.THREAD_FOLLOW_BUTTON,
passProps: {
teamId: channel.teamId,
threadId: post.id,
},
},
});
}
// Get translation by user locale
const translations = getTranslations(user.locale);
@@ -123,6 +135,15 @@ export const switchToThread = async (serverUrl: string, rootId: string, isFromNo
subtitle = subtitle.replace('{channelName}', channel.displayName);
}
EphemeralStore.setCurrentThreadId(rootId);
if (isFromNotification) {
await dismissAllModalsAndPopToRoot();
await NavigationStore.waitUntilScreenIsTop(Screens.HOME);
if (switchingTeams && isTabletDevice) {
DeviceEventEmitter.emit(Navigation.NAVIGATION_HOME, Screens.GLOBAL_THREADS);
}
}
goToScreen(Screens.THREAD, '', {rootId}, {
topBar: {
title: {
@@ -135,15 +156,14 @@ export const switchToThread = async (serverUrl: string, rootId: string, isFromNo
noBorder: true,
scrollEdgeAppearance: {
noBorder: true,
active: true,
},
rightButtons,
},
});
return {};
} catch (error) {
logError('Failed switchToThread', error);
EphemeralStore.setCurrentThreadId('');
return {error};
}
};
@@ -181,7 +201,7 @@ export async function createThreadFromNewPost(serverUrl: string, post: Post, pre
}
if (!prepareRecordsOnly) {
await operator.batchRecords(models, 'createThreadFromNewPost');
await operator.batchRecords(models);
}
return {models};
@@ -237,7 +257,7 @@ export async function processReceivedThreads(serverUrl: string, threads: Thread[
}
if (!prepareRecordsOnly) {
await operator.batchRecords(models, 'processReceivedThreads');
await operator.batchRecords(models);
}
return {models};
} catch (error) {
@@ -257,7 +277,7 @@ export async function markTeamThreadsAsRead(serverUrl: string, teamId: string, p
record.viewedAt = Date.now();
}));
if (!prepareRecordsOnly) {
await operator.batchRecords(models, 'markTeamThreadsAsRead');
await operator.batchRecords(models);
}
return {models};
} catch (error) {
@@ -280,7 +300,7 @@ export async function markThreadAsViewed(serverUrl: string, threadId: string, pr
});
if (!prepareRecordsOnly) {
await operator.batchRecords([thread], 'markThreadAsViewed');
await operator.batchRecords([thread]);
}
return {model: thread};
@@ -307,7 +327,7 @@ export async function updateThread(serverUrl: string, threadId: string, updatedT
record.unreadReplies = updatedThread.unread_replies ?? record.unreadReplies;
});
if (!prepareRecordsOnly) {
await operator.batchRecords([model], 'updateThread');
await operator.batchRecords([model]);
}
return {model};
} catch (error) {
@@ -321,7 +341,7 @@ export async function updateTeamThreadsSync(serverUrl: string, data: TeamThreads
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const models = await operator.handleTeamThreadsSync({data: [data], prepareRecordsOnly});
if (!prepareRecordsOnly) {
await operator.batchRecords(models, 'updateTeamThreadsSync');
await operator.batchRecords(models);
}
return {models};
} catch (error) {

View File

@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import General from '@constants/general';
import DatabaseManager from '@database/manager';
import {getRecentCustomStatuses} from '@queries/servers/system';
import {getCurrentUser, getUserById} from '@queries/servers/user';
@@ -12,7 +13,7 @@ import {addRecentReaction} from './reactions';
import type Model from '@nozbe/watermelondb/Model';
import type UserModel from '@typings/database/models/servers/user';
export async function setCurrentUserStatus(serverUrl: string, status: string) {
export async function setCurrentUserStatusOffline(serverUrl: string) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const user = await getCurrentUser(database);
@@ -20,14 +21,11 @@ export async function setCurrentUserStatus(serverUrl: string, status: string) {
throw new Error(`No current user for ${serverUrl}`);
}
if (user.status !== status) {
user.prepareStatus(status);
await operator.batchRecords([user], 'setCurrentUserStatus');
}
user.prepareStatus(General.OFFLINE);
await operator.batchRecords([user]);
return null;
} catch (error) {
logError('Failed setCurrentUserStatus', error);
logError('Failed setCurrentUserStatusOffline', error);
return {error};
}
}
@@ -56,7 +54,7 @@ export async function updateLocalCustomStatus(serverUrl: string, user: UserModel
}
}
await operator.batchRecords(models, 'updateLocalCustomStatus');
await operator.batchRecords(models);
return {};
} catch (error) {
@@ -99,37 +97,27 @@ export const updateRecentCustomStatuses = async (serverUrl: string, customStatus
export const updateLocalUser = async (
serverUrl: string,
userDetails: Partial<UserProfile> & { status?: string},
userId?: string,
) => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
let user: UserModel | undefined;
if (userId) {
user = await getUserById(database, userId);
} else {
user = await getCurrentUser(database);
}
const user = await getCurrentUser(database);
if (user) {
const u = user;
await database.write(async () => {
await u.update((userRecord: UserModel) => {
userRecord.authService = userDetails.auth_service ?? u.authService;
userRecord.email = userDetails.email ?? u.email;
userRecord.firstName = userDetails.first_name ?? u.firstName;
userRecord.lastName = userDetails.last_name ?? u.lastName;
userRecord.lastPictureUpdate = userDetails.last_picture_update ?? u.lastPictureUpdate;
userRecord.locale = userDetails.locale ?? u.locale;
userRecord.nickname = userDetails.nickname ?? u.nickname;
userRecord.notifyProps = userDetails.notify_props ?? u.notifyProps;
userRecord.position = userDetails?.position ?? u.position;
userRecord.props = userDetails.props ?? u.props;
userRecord.roles = userDetails.roles ?? u.roles;
userRecord.status = userDetails?.status ?? u.status;
userRecord.timezone = userDetails.timezone ?? u.timezone;
userRecord.username = userDetails.username ?? u.username;
await user.update((userRecord: UserModel) => {
userRecord.authService = userDetails.auth_service ?? user.authService;
userRecord.email = userDetails.email ?? user.email;
userRecord.firstName = userDetails.first_name ?? user.firstName;
userRecord.lastName = userDetails.last_name ?? user.lastName;
userRecord.lastPictureUpdate = userDetails.last_picture_update ?? user.lastPictureUpdate;
userRecord.locale = userDetails.locale ?? user.locale;
userRecord.nickname = userDetails.nickname ?? user.nickname;
userRecord.notifyProps = userDetails.notify_props ?? user.notifyProps;
userRecord.position = userDetails?.position ?? user.position;
userRecord.props = userDetails.props ?? user.props;
userRecord.roles = userDetails.roles ?? user.roles;
userRecord.status = userDetails?.status ?? user.status;
userRecord.timezone = userDetails.timezone ?? user.timezone;
userRecord.username = userDetails.username ?? user.username;
});
});
}

View File

@@ -5,10 +5,9 @@ import {sendEphemeralPost} from '@actions/local/post';
import {AppCallResponseTypes} from '@constants/apps';
import NetworkManager from '@managers/network_manager';
import {cleanForm, createCallRequest, makeCallErrorResponse} from '@utils/apps';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
import type {Client} from '@client/rest';
import type ClientError from '@client/rest/error';
import type PostModel from '@typings/database/models/servers/post';
import type {IntlShape} from 'react-intl';
@@ -59,9 +58,15 @@ export async function handleBindingClick<Res=unknown>(serverUrl: string, binding
return doAppSubmit<Res>(serverUrl, callRequest, intl);
}
export async function doAppSubmit<Res=unknown>(serverUrl: string, inCall: AppCallRequest, intl: IntlShape): Promise<{data: AppCallResponse<Res>} | {error: AppCallResponse<Res>}> {
export async function doAppSubmit<Res=unknown>(serverUrl: string, inCall: AppCallRequest, intl: IntlShape) {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error: makeCallErrorResponse((error as ClientError).message)};
}
try {
const client = NetworkManager.getClient(serverUrl);
const call: AppCallRequest = {
...inCall,
context: {
@@ -111,18 +116,23 @@ export async function doAppSubmit<Res=unknown>(serverUrl: string, inCall: AppCal
}
}
} catch (error) {
const errMsg = getFullErrorMessage(error) || intl.formatMessage({
const errMsg = (error as ClientError).message || intl.formatMessage({
id: 'apps.error.responses.unexpected_error',
defaultMessage: 'Received an unexpected error.',
});
logDebug('error on doAppSubmit', getFullErrorMessage(error));
return {error: makeCallErrorResponse(errMsg)};
}
}
export async function doAppFetchForm<Res=unknown>(serverUrl: string, call: AppCallRequest, intl: IntlShape) {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error: makeCallErrorResponse((error as ClientError).message)};
}
try {
const client = NetworkManager.getClient(serverUrl);
const res = await client.executeAppCall<Res>(call, false);
const responseType = res.type || AppCallResponseTypes.OK;
@@ -147,12 +157,11 @@ export async function doAppFetchForm<Res=unknown>(serverUrl: string, call: AppCa
return {error: makeCallErrorResponse(errMsg)};
}
}
} catch (error) {
const errMsg = getFullErrorMessage(error) || intl.formatMessage({
} catch (error: any) {
const errMsg = error.message || intl.formatMessage({
id: 'apps.error.responses.unexpected_error',
defaultMessage: 'Received an unexpected error.',
});
logDebug('error on doAppFetchForm', getFullErrorMessage(error));
return {error: makeCallErrorResponse(errMsg)};
}
}
@@ -161,6 +170,11 @@ export async function doAppLookup<Res=unknown>(serverUrl: string, call: AppCallR
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error: makeCallErrorResponse((error as ClientError).message)};
}
try {
const res = await client.executeAppCall<Res>(call, false);
const responseType = res.type || AppCallResponseTypes.OK;
@@ -179,11 +193,10 @@ export async function doAppLookup<Res=unknown>(serverUrl: string, call: AppCallR
}
}
} catch (error: any) {
const errMsg = getFullErrorMessage(error) || intl.formatMessage({
const errMsg = error.message || intl.formatMessage({
id: 'apps.error.responses.unexpected_error',
defaultMessage: 'Received an unexpected error.',
});
logDebug('error on doAppLookup', getFullErrorMessage(error));
return {error: makeCallErrorResponse(errMsg)};
}
}

View File

@@ -9,20 +9,26 @@ import NetworkManager from '@managers/network_manager';
import {getChannelCategory, queryCategoriesByTeamIds} from '@queries/servers/categories';
import {getChannelById} from '@queries/servers/channel';
import {getCurrentTeamId} from '@queries/servers/system';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
import {showFavoriteChannelSnackbar} from '@utils/snack_bar';
import {forceLogoutIfNecessary} from './session';
import type {Client} from '@client/rest';
export type CategoriesRequest = {
categories?: CategoryWithChannels[];
error?: unknown;
}
export const fetchCategories = async (serverUrl: string, teamId: string, prune = false, fetchOnly = false): Promise<CategoriesRequest> => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const client = NetworkManager.getClient(serverUrl);
const {categories} = await client.getCategories('me', teamId);
if (!fetchOnly) {
@@ -31,17 +37,26 @@ export const fetchCategories = async (serverUrl: string, teamId: string, prune =
return {categories};
} catch (error) {
logDebug('error on fetchCategories', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const toggleFavoriteChannel = async (serverUrl: string, channelId: string, showSnackBar = false) => {
try {
const client = NetworkManager.getClient(serverUrl);
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const {database} = operator;
const channel = await getChannelById(database, channelId);
if (!channel) {
return {error: 'channel not found'};
@@ -94,8 +109,7 @@ export const toggleFavoriteChannel = async (serverUrl: string, channelId: string
return {data: true};
} catch (error) {
logDebug('error on toggleFavoriteChannel', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -14,29 +14,26 @@ import {getChannelById} from '@queries/servers/channel';
import {getConfig, getCurrentTeamId} from '@queries/servers/system';
import {showAppForm} from '@screens/navigation';
import {handleDeepLink, matchDeepLink} from '@utils/deep_link';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
import {tryOpenURL} from '@utils/url';
import type {Client} from '@client/rest';
import type {IntlShape} from 'react-intl';
export const executeCommand = async (serverUrl: string, intl: IntlShape, message: string, channelId: string, rootId?: string): Promise<{data?: CommandResponse; error?: unknown}> => {
export const executeCommand = async (serverUrl: string, intl: IntlShape, message: string, channelId: string, rootId?: string): Promise<{data?: CommandResponse; error?: string | {message: string}}> => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const {database} = operator;
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
return {error: error as ClientErrorProps};
}
const channel = await getChannelById(database, channelId);
const teamId = channel?.teamId || (await getCurrentTeamId(database));
const channel = await getChannelById(operator.database, channelId);
const teamId = channel?.teamId || (await getCurrentTeamId(operator.database));
const args: CommandArgs = {
channel_id: channelId,
@@ -71,8 +68,7 @@ export const executeCommand = async (serverUrl: string, intl: IntlShape, message
try {
data = await client.executeCommand(msg, args);
} catch (error) {
logDebug('error on executeCommand', getFullErrorMessage(error));
return {error};
return {error: error as ClientErrorProps};
}
if (data?.trigger_id) { //eslint-disable-line camelcase
@@ -93,14 +89,14 @@ const executeAppCommand = async (serverUrl: string, intl: IntlShape, parser: App
}
const res = await doAppSubmit(serverUrl, creq, intl);
if ('error' in res) {
const errorResponse = res.error;
if (res.error) {
const errorResponse = res.error as AppCallResponse;
return createErrorMessage(errorResponse.text || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error.',
}));
}
const callResp = res.data;
const callResp = res.data as AppCallResponse;
switch (callResp.type) {
case AppCallResponseTypes.OK:
@@ -143,7 +139,7 @@ export const handleGotoLocation = async (serverUrl: string, intl: IntlShape, loc
const match = matchDeepLink(location, serverUrl, config?.SiteURL);
if (match) {
handleDeepLink(match.url, intl, location);
handleDeepLink(match, intl, location);
} else {
const {formatMessage} = intl;
const onError = () => Alert.alert(
@@ -162,22 +158,31 @@ export const handleGotoLocation = async (serverUrl: string, intl: IntlShape, loc
return {data: true};
};
export const fetchCommands = async (serverUrl: string, teamId: string): Promise<{commands: Command[]} | {error: unknown}> => {
export const fetchCommands = async (serverUrl: string, teamId: string) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error: error as ClientErrorProps};
}
try {
const client = NetworkManager.getClient(serverUrl);
return {commands: await client.getCommandsList(teamId)};
} catch (error) {
logDebug('error on fetchCommands', getFullErrorMessage(error));
return {error};
return {error: error as ClientErrorProps};
}
};
export const fetchSuggestions = async (serverUrl: string, term: string, teamId: string, channelId: string, rootId?: string): Promise<{suggestions: AutocompleteSuggestion[]} | {error: unknown}> => {
export const fetchSuggestions = async (serverUrl: string, term: string, teamId: string, channelId: string, rootId?: string) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error: error as ClientErrorProps};
}
try {
const client = NetworkManager.getClient(serverUrl);
return {suggestions: await client.getCommandAutocompleteSuggestionsList(term, teamId, channelId, rootId)};
} catch (error) {
logDebug('error on fetchSuggestions', getFullErrorMessage(error));
return {error};
return {error: error as ClientErrorProps};
}
};

View File

@@ -7,14 +7,23 @@ import DatabaseManager from '@database/manager';
import {debounce} from '@helpers/api/general';
import NetworkManager from '@managers/network_manager';
import {queryCustomEmojisByName} from '@queries/servers/custom_emoji';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
import type {Client} from '@client/rest';
export const fetchCustomEmojis = async (serverUrl: string, page = 0, perPage = General.PAGE_SIZE_DEFAULT, sort = Emoji.SORT_BY_NAME) => {
let client: Client;
try {
const client = NetworkManager.getClient(serverUrl);
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const data = await client.getCustomEmojis(page, perPage, sort);
await operator.handleCustomEmojis({
emojis: data,
@@ -23,20 +32,29 @@ export const fetchCustomEmojis = async (serverUrl: string, page = 0, perPage = G
return {data};
} catch (error) {
logDebug('error on fetchCustomEmojis', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const searchCustomEmojis = async (serverUrl: string, term: string) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const client = NetworkManager.getClient(serverUrl);
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const data = await client.searchCustomEmoji(term);
if (data.length) {
const names = data.map((c) => c.name);
const exist = await queryCustomEmojisByName(database, names).fetch();
const exist = await queryCustomEmojisByName(operator.database, names).fetch();
const existingNames = new Set(exist.map((e) => e.name));
const emojis = data.filter((d) => !existingNames.has(d.name));
await operator.handleCustomEmojis({
@@ -46,22 +64,31 @@ export const searchCustomEmojis = async (serverUrl: string, term: string) => {
}
return {data};
} catch (error) {
logDebug('error on searchCustomEmojis', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
const names = new Set<string>();
const debouncedFetchEmojiByNames = debounce(async (serverUrl: string) => {
let client: Client;
try {
const client = NetworkManager.getClient(serverUrl);
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
const promises: Array<Promise<CustomEmoji>> = [];
for (const name of names) {
promises.push(client.getCustomEmojiByName(name));
}
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const promises: Array<Promise<CustomEmoji>> = [];
for (const name of names) {
promises.push(client.getCustomEmojiByName(name));
}
try {
const emojisResult = await Promise.allSettled(promises);
const emojis = emojisResult.reduce<CustomEmoji[]>((result, e) => {
if (e.status === 'fulfilled') {
@@ -72,9 +99,8 @@ const debouncedFetchEmojiByNames = debounce(async (serverUrl: string) => {
if (emojis.length) {
await operator.handleCustomEmojis({emojis, prepareRecordsOnly: false});
}
return {};
return {error: undefined};
} catch (error) {
logDebug('error on debouncedFetchEmojiByNames', getFullErrorMessage(error));
return {error};
}
}, 200, false, () => {

View File

@@ -4,19 +4,24 @@
import {setLastServerVersionCheck} from '@actions/local/systems';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import DatabaseManager from '@database/manager';
import WebsocketManager from '@managers/websocket_manager';
import {prepareCommonSystemValues} from '@queries/servers/system';
import {prepareCommonSystemValues, getCurrentTeamId, getWebSocketLastDisconnected, getCurrentChannelId, getConfig, getLicense} from '@queries/servers/system';
import {getCurrentUser} from '@queries/servers/user';
import {setTeamLoading} from '@store/team_load_store';
import {deleteV1Data} from '@utils/file';
import {isTablet} from '@utils/helpers';
import {logInfo} from '@utils/log';
import {verifyPushProxy} from './common';
import {handleEntryAfterLoadNavigation, registerDeviceToken, syncOtherServers, verifyPushProxy} from './common';
import {deferredAppEntryActions, entry} from './gql_common';
export async function appEntry(serverUrl: string, since = 0) {
export async function appEntry(serverUrl: string, since = 0, isUpgrade = false) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
if (!since) {
registerDeviceToken(serverUrl);
if (Object.keys(DatabaseManager.serverDatabases).length === 1) {
await setLastServerVersionCheck(serverUrl, true);
}
@@ -25,14 +30,55 @@ export async function appEntry(serverUrl: string, since = 0) {
// clear lastUnreadChannelId
const removeLastUnreadChannelId = await prepareCommonSystemValues(operator, {lastUnreadChannelId: ''});
if (removeLastUnreadChannelId) {
await operator.batchRecords(removeLastUnreadChannelId, 'appEntry - removeLastUnreadChannelId');
await operator.batchRecords(removeLastUnreadChannelId);
}
WebsocketManager.openAll();
const {database} = operator;
const currentTeamId = await getCurrentTeamId(database);
const currentChannelId = await getCurrentChannelId(database);
const lastDisconnectedAt = (await getWebSocketLastDisconnected(database)) || since;
setTeamLoading(serverUrl, true);
const entryData = await entry(serverUrl, currentTeamId, currentChannelId, since);
if ('error' in entryData) {
setTeamLoading(serverUrl, false);
return {error: entryData.error};
}
const {models, initialTeamId, initialChannelId, prefData, teamData, chData, meData} = entryData;
if (isUpgrade && meData?.user) {
const isTabletDevice = await isTablet();
const me = await prepareCommonSystemValues(operator, {
currentUserId: meData.user.id,
currentTeamId: initialTeamId,
currentChannelId: isTabletDevice ? initialChannelId : undefined,
});
if (me?.length) {
await operator.batchRecords(me);
}
}
await handleEntryAfterLoadNavigation(serverUrl, teamData.memberships || [], chData?.memberships || [], currentTeamId, currentChannelId, initialTeamId, initialChannelId);
const dt = Date.now();
await operator.batchRecords(models);
logInfo('ENTRY MODELS BATCHING TOOK', `${Date.now() - dt}ms`);
setTeamLoading(serverUrl, false);
const {id: currentUserId, locale: currentUserLocale} = meData?.user || (await getCurrentUser(database))!;
const config = await getConfig(database);
const license = await getLicense(database);
await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId);
if (!since) {
// Load data from other servers
syncOtherServers(serverUrl);
}
verifyPushProxy(serverUrl);
return {};
return {userId: currentUserId};
}
export async function upgradeEntry(serverUrl: string) {
@@ -40,7 +86,7 @@ export async function upgradeEntry(serverUrl: string) {
try {
const configAndLicense = await fetchConfigAndLicense(serverUrl, false);
const entryData = await appEntry(serverUrl, 0);
const entryData = await appEntry(serverUrl, 0, true);
const error = configAndLicense.error || entryData.error;
if (!error) {

View File

@@ -1,15 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fetchMissingDirectChannelsInfo, fetchMyChannelsForTeam, handleKickFromChannel, type MyChannelsRequest} from '@actions/remote/channel';
import {fetchMissingDirectChannelsInfo, fetchMyChannelsForTeam, handleKickFromChannel, MyChannelsRequest} from '@actions/remote/channel';
import {fetchGroupsForMember} from '@actions/remote/groups';
import {fetchPostsForUnreadChannels} from '@actions/remote/post';
import {type MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference';
import {MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference';
import {fetchRoles} from '@actions/remote/role';
import {fetchConfigAndLicense, fetchDataRetentionPolicy} from '@actions/remote/systems';
import {fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, handleKickFromTeam, type MyTeamsRequest, updateCanJoinTeams} from '@actions/remote/team';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import {fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, handleKickFromTeam, MyTeamsRequest, updateCanJoinTeams} from '@actions/remote/team';
import {syncTeamThreads} from '@actions/remote/thread';
import {fetchMe, type MyUserRequest, updateAllUsersSince, autoUpdateTimezone} from '@actions/remote/user';
import {autoUpdateTimezone, fetchMe, MyUserRequest, updateAllUsersSince} from '@actions/remote/user';
import {gqlAllChannels} from '@client/graphQL/entry';
import {General, Preferences, Screens} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import {PUSH_PROXY_RESPONSE_NOT_AVAILABLE, PUSH_PROXY_RESPONSE_UNKNOWN, PUSH_PROXY_STATUS_NOT_AVAILABLE, PUSH_PROXY_STATUS_UNKNOWN, PUSH_PROXY_STATUS_VERIFIED} from '@constants/push_proxy';
@@ -19,18 +20,21 @@ 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, queryAllChannelsForTeam, queryChannelsById} from '@queries/servers/channel';
import {getAllServers} from '@queries/app/servers';
import {prepareMyChannelsForTeam, queryAllChannelsForTeam, queryChannelsById} from '@queries/servers/channel';
import {prepareModels, truncateCrtRelatedTables} from '@queries/servers/entry';
import {getHasCRTChanged} from '@queries/servers/preference';
import {getConfig, getCurrentChannelId, getCurrentTeamId, getIsDataRetentionEnabled, getPushVerificationStatus, getWebSocketLastDisconnected, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {getConfig, getCurrentChannelId, getCurrentTeamId, getCurrentUserId, getPushVerificationStatus, getWebSocketLastDisconnected, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {deleteMyTeams, getAvailableTeamIds, getTeamChannelHistory, queryMyTeams, queryMyTeamsByIds, queryTeamsById} from '@queries/servers/team';
import {getIsCRTEnabled} from '@queries/servers/thread';
import NavigationStore from '@store/navigation_store';
import {isDMorGM, sortChannelsByDisplayName} from '@utils/channel';
import {getFullErrorMessage, isErrorWithStatusCode} from '@utils/errors';
import {getMemberChannelsFromGQLQuery, gqlToClientChannelMembership} from '@utils/graphql';
import {isTablet} from '@utils/helpers';
import {logDebug} from '@utils/log';
import {processIsCRTEnabled} from '@utils/thread';
import type ClientError from '@client/rest/error';
import type {Database, Model} from '@nozbe/watermelondb';
export type AppEntryData = {
@@ -42,12 +46,10 @@ export type AppEntryData = {
removeTeamIds?: string[];
removeChannelIds?: string[];
isCRTEnabled: boolean;
initialChannelId?: string;
gmConverted: boolean;
}
export type AppEntryError = {
error: unknown;
error: Error | ClientError | string;
}
export type EntryResponse = {
@@ -58,7 +60,6 @@ export type EntryResponse = {
teamData: MyTeamsRequest;
chData?: MyChannelsRequest;
meData?: MyUserRequest;
gmConverted: boolean;
} | {
error: unknown;
}
@@ -66,43 +67,19 @@ export type EntryResponse = {
const FETCH_MISSING_DM_TIMEOUT = 2500;
export const FETCH_UNREADS_TIMEOUT = 2500;
export const entry = async (serverUrl: string, teamId?: string, channelId?: string, since = 0): Promise<EntryResponse> => {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const result = entryRest(serverUrl, teamId, channelId, since);
// Fetch data retention policies
const isDataRetentionEnabled = await getIsDataRetentionEnabled(database);
if (isDataRetentionEnabled) {
fetchDataRetentionPolicy(serverUrl);
}
return result;
};
export async function deferredAppEntryActions(
serverUrl: string, since: number, currentUserId: string, currentUserLocale: string, preferences: PreferenceType[] | undefined,
config: ClientConfig, license: ClientLicense | undefined, teamData: MyTeamsRequest, chData: MyChannelsRequest | undefined,
initialTeamId?: string, initialChannelId?: string) {
const result = restDeferredAppEntryActions(serverUrl, since, currentUserId, currentUserLocale, preferences, config, license, teamData, chData, initialTeamId, initialChannelId);
autoUpdateTimezone(serverUrl);
return result;
}
const getRemoveTeamIds = async (database: Database, teamData: MyTeamsRequest) => {
export const getRemoveTeamIds = async (database: Database, teamData: MyTeamsRequest) => {
const myTeams = await queryMyTeams(database).fetch();
const joinedTeams = new Set(teamData.memberships?.filter((m) => m.delete_at === 0).map((m) => m.team_id));
return myTeams.filter((m) => !joinedTeams.has(m.id)).map((m) => m.id);
};
const teamsToRemove = async (serverUrl: string, removeTeamIds?: string[]) => {
export const teamsToRemove = async (serverUrl: string, removeTeamIds?: string[]) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return [];
}
const {database} = operator;
const {database} = operator;
if (removeTeamIds?.length) {
// Immediately delete myTeams so that the UI renders only teams the user is a member of.
const removeMyTeams = await queryMyTeamsByIds(database, removeTeamIds).fetch();
@@ -117,27 +94,22 @@ const teamsToRemove = async (serverUrl: string, removeTeamIds?: string[]) => {
return [];
};
const entryRest = async (serverUrl: string, teamId?: string, channelId?: string, since = 0): Promise<EntryResponse> => {
export const entryRest = async (serverUrl: string, teamId?: string, channelId?: string, since = 0): Promise<EntryResponse> => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const {database} = operator;
const lastDisconnectedAt = since || await getWebSocketLastDisconnected(database);
const fetchedData = await fetchAppEntryData(serverUrl, lastDisconnectedAt, teamId, channelId);
const fetchedData = await fetchAppEntryData(serverUrl, lastDisconnectedAt, teamId);
if ('error' in fetchedData) {
return {error: fetchedData.error};
}
const {initialTeamId, initialChannelId: fetchedChannelId, teamData, chData, prefData, meData, removeTeamIds, removeChannelIds, isCRTEnabled, gmConverted} = fetchedData;
const chError = chData?.error;
if (isErrorWithStatusCode(chError) && chError.status_code === 403) {
// if the user does not have appropriate permissions, which means the user those not belong to the team,
// we set it as there is no errors, so that the teams and others can be properly handled
chData!.error = undefined;
}
const {initialTeamId, teamData, chData, prefData, meData, removeTeamIds, removeChannelIds, isCRTEnabled} = fetchedData;
const error = teamData.error || chData?.error || prefData.error || meData.error;
if (error) {
return {error};
@@ -145,7 +117,7 @@ const entryRest = async (serverUrl: string, teamId?: string, channelId?: string,
const rolesData = await fetchRoles(serverUrl, teamData.memberships, chData?.memberships, meData.user, true);
const initialChannelId = await entryInitialChannelId(database, fetchedChannelId, teamId, initialTeamId, meData?.user?.locale || '', chData?.channels, chData?.memberships);
const initialChannelId = await entryInitialChannelId(database, channelId, teamId, initialTeamId, meData?.user?.locale || '', chData?.channels, chData?.memberships);
const removeTeams = await teamsToRemove(serverUrl, removeTeamIds);
@@ -161,10 +133,10 @@ const entryRest = async (serverUrl: string, teamId?: string, channelId?: string,
const models = await Promise.all(modelPromises);
return {models: models.flat(), initialChannelId, initialTeamId, prefData, teamData, chData, meData, gmConverted};
return {models: models.flat(), initialChannelId, initialTeamId, prefData, teamData, chData, meData};
};
const fetchAppEntryData = async (serverUrl: string, sinceArg: number, onLoadTeamId = '', channelId?: string): Promise<AppEntryData | AppEntryError> => {
export const fetchAppEntryData = async (serverUrl: string, sinceArg: number, initialTeamId = ''): Promise<AppEntryData | AppEntryError> => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
@@ -191,50 +163,21 @@ const fetchAppEntryData = async (serverUrl: string, sinceArg: number, onLoadTeam
}
}
// Fetch in parallel teams / team membership / user preferences / user
const promises: [Promise<MyTeamsRequest>, Promise<MyUserRequest>] = [
// Fetch in parallel teams / team membership / channels for current team / user preferences / user
const promises: [Promise<MyTeamsRequest>, Promise<MyChannelsRequest | undefined>, Promise<MyUserRequest>] = [
fetchMyTeams(serverUrl, fetchOnly),
initialTeamId ? fetchMyChannelsForTeam(serverUrl, initialTeamId, includeDeletedChannels, since, fetchOnly, false, isCRTEnabled) : Promise.resolve(undefined),
fetchMe(serverUrl, fetchOnly),
];
const resolution = await Promise.all(promises);
const [teamData, meData] = resolution;
let chData;
let initialTeamId = onLoadTeamId;
let initialChannelId = channelId;
let gmConverted = false;
if (channelId) {
const existingChannel = await getChannelById(database, channelId);
if (existingChannel && existingChannel.type === General.GM_CHANNEL) {
// Okay, so now we know the channel existsin in mobile app's database as a GM.
// We now need to also check if channel on server is actually a private channel,
// and if so, which team does it belong to now. That team will become the
// active team on mobile app after this point.
const client = NetworkManager.getClient(serverUrl);
const serverChannel = await client.getChannel(channelId);
// Although yon can convert GM only to a pirvate channel, a private channel can furthur be converted to a public channel.
// So between the mobile app being on the GM and reconnecting,
// it may have become either a public or a private channel. So we need to check for both.
if (serverChannel.type === General.PRIVATE_CHANNEL || serverChannel.type === General.OPEN_CHANNEL) {
initialTeamId = serverChannel.team_id;
initialChannelId = channelId;
gmConverted = true;
}
}
}
if (initialTeamId) {
chData = await fetchMyChannelsForTeam(serverUrl, initialTeamId, includeDeletedChannels, since, fetchOnly, false, isCRTEnabled);
}
const [teamData, , meData] = resolution;
let [, chData] = resolution;
if (!initialTeamId && teamData.teams?.length && teamData.memberships?.length) {
// If no initial team was set in the database but got teams in the response
const config = await getConfig(database);
const teamOrderPreference = getPreferenceValue<string>(prefData.preferences || [], Preferences.CATEGORIES.TEAMS_ORDER, '', '');
const teamOrderPreference = getPreferenceValue(prefData.preferences || [], Preferences.TEAMS_ORDER, '', '') as string;
const teamMembers = new Set(teamData.memberships.filter((m) => m.delete_at === 0).map((m) => m.team_id));
const myTeams = teamData.teams!.filter((t) => teamMembers.has(t.id));
const defaultTeam = selectDefaultTeam(myTeams, meData.user?.locale || DEFAULT_LOCALE, teamOrderPreference, config?.ExperimentalPrimaryTeam);
@@ -253,8 +196,6 @@ const fetchAppEntryData = async (serverUrl: string, sinceArg: number, onLoadTeam
meData,
removeTeamIds,
isCRTEnabled,
initialChannelId,
gmConverted,
};
if (teamData.teams?.length === 0 && !teamData.error) {
@@ -266,8 +207,8 @@ const fetchAppEntryData = async (serverUrl: string, sinceArg: number, onLoadTeam
}
const inTeam = teamData.teams?.find((t) => t.id === initialTeamId);
const chError = chData?.error;
if ((!inTeam && !teamData.error) || (isErrorWithStatusCode(chError) && chError.status_code === 403)) {
const chError = chData?.error as ClientError | undefined;
if ((!inTeam && !teamData.error) || chError?.status_code === 403) {
// User is no longer a member of the current team
if (!removeTeamIds.includes(initialTeamId)) {
removeTeamIds.push(initialTeamId);
@@ -302,7 +243,7 @@ const fetchAppEntryData = async (serverUrl: string, sinceArg: number, onLoadTeam
return data;
};
const fetchAlternateTeamData = async (
export const fetchAlternateTeamData = async (
serverUrl: string, availableTeamIds: string[], removeTeamIds: string[],
includeDeleted = true, since = 0, fetchOnly = false, isCRTEnabled?: boolean) => {
let initialTeamId = '';
@@ -311,8 +252,8 @@ const fetchAlternateTeamData = async (
for (const teamId of availableTeamIds) {
// eslint-disable-next-line no-await-in-loop
chData = await fetchMyChannelsForTeam(serverUrl, teamId, includeDeleted, since, fetchOnly, false, isCRTEnabled);
const chError = chData.error;
if (isErrorWithStatusCode(chError) && chError.status_code === 403) {
const chError = chData.error as ClientError | undefined;
if (chError?.status_code === 403) {
removeTeamIds.push(teamId);
} else {
initialTeamId = teamId;
@@ -327,7 +268,7 @@ const fetchAlternateTeamData = async (
return {initialTeamId, removeTeamIds};
};
async function entryInitialChannelId(database: Database, requestedChannelId = '', requestedTeamId = '', initialTeamId: string, locale: string, channels?: Channel[], memberships?: ChannelMember[]) {
export async function entryInitialChannelId(database: Database, requestedChannelId = '', requestedTeamId = '', initialTeamId: string, locale: string, channels?: Channel[], memberships?: ChannelMember[]) {
const membershipIds = new Set(memberships?.map((m) => m.channel_id));
const requestedChannel = channels?.find((c) => (c.id === requestedChannelId) && membershipIds.has(c.id));
@@ -365,12 +306,17 @@ async function entryInitialChannelId(database: Database, requestedChannelId = ''
return myFirstTeamChannel?.id || '';
}
async function restDeferredAppEntryActions(
export async function restDeferredAppEntryActions(
serverUrl: string, since: number, currentUserId: string, currentUserLocale: string, preferences: PreferenceType[] | undefined,
config: ClientConfig, license: ClientLicense | undefined, teamData: MyTeamsRequest, chData: MyChannelsRequest | undefined,
initialTeamId?: string, initialChannelId?: string) {
// defer sidebar DM & GM profiles
let channelsToFetchProfiles: Set<Channel>|undefined;
setTimeout(async () => {
if (chData?.channels?.length && chData.memberships?.length) {
const directChannels = chData.channels.filter(isDMorGM);
channelsToFetchProfiles = new Set<Channel>(directChannels);
// defer fetching posts for unread channels on initial team
fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId);
}
@@ -402,11 +348,8 @@ async function restDeferredAppEntryActions(
// Fetch groups for current user
fetchGroupsForMember(serverUrl, currentUserId);
// defer sidebar DM & GM profiles
setTimeout(async () => {
const directChannels = chData?.channels?.filter(isDMorGM);
const channelsToFetchProfiles = new Set<Channel>(directChannels);
if (channelsToFetchProfiles.size) {
if (channelsToFetchProfiles?.size) {
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config.LockTeammateNameDisplay, config.TeammateNameDisplay, license);
fetchMissingDirectChannelsInfo(serverUrl, Array.from(channelsToFetchProfiles), currentUserLocale, teammateDisplayNameSetting, currentUserId);
}
@@ -414,38 +357,149 @@ async function restDeferredAppEntryActions(
}
export const registerDeviceToken = async (serverUrl: string) => {
let client;
try {
const client = NetworkManager.getClient(serverUrl);
const deviceToken = await getDeviceToken();
if (deviceToken) {
client.attachDevice(deviceToken);
}
return {};
client = NetworkManager.getClient(serverUrl);
} catch (error) {
logDebug('error on registerDeviceToken', getFullErrorMessage(error));
return {error};
}
const deviceToken = await getDeviceToken();
if (deviceToken) {
client.attachDevice(deviceToken);
}
return {error: undefined};
};
export const syncOtherServers = async (serverUrl: string) => {
const servers = await getAllServers();
for (const server of servers) {
if (server.url !== serverUrl && server.lastActiveAt > 0) {
registerDeviceToken(server.url);
syncAllChannelMembersAndThreads(server.url);
autoUpdateTimezone(server.url);
}
}
};
const syncAllChannelMembersAndThreads = async (serverUrl: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return;
}
const config = await getConfig(database);
if (config?.FeatureFlagGraphQL === 'true') {
const error = await graphQLSyncAllChannelMembers(serverUrl);
if (error) {
logDebug('failed graphQL, falling back to rest', error);
restSyncAllChannelMembers(serverUrl);
}
} else {
restSyncAllChannelMembers(serverUrl);
}
};
const graphQLSyncAllChannelMembers = async (serverUrl: string) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return 'Server database not found';
}
const {database} = operator;
const response = await gqlAllChannels(serverUrl);
if ('error' in response) {
return response.error;
}
if (response.errors) {
return response.errors[0].message;
}
const userId = await getCurrentUserId(database);
const channels = getMemberChannelsFromGQLQuery(response.data);
const memberships = response.data.channelMembers?.map((m) => gqlToClientChannelMembership(m, userId));
if (channels && memberships) {
const modelPromises = await prepareMyChannelsForTeam(operator, '', channels, memberships, undefined, true);
const models = (await Promise.all(modelPromises)).flat();
if (models.length) {
await operator.batchRecords(models);
}
}
const isCRTEnabled = await getIsCRTEnabled(database);
if (isCRTEnabled) {
const myTeams = await queryMyTeams(operator.database).fetch();
for await (const myTeam of myTeams) {
// need to await here since GM/DM threads in different teams overlap
await syncTeamThreads(serverUrl, myTeam.id);
}
}
return '';
};
const restSyncAllChannelMembers = async (serverUrl: string) => {
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch {
return;
}
try {
const myTeams = await client.getMyTeams();
const preferences = await client.getMyPreferences();
const config = await client.getClientConfigOld();
let excludeDirect = false;
for await (const myTeam of myTeams) {
fetchMyChannelsForTeam(serverUrl, myTeam.id, false, 0, false, excludeDirect);
excludeDirect = true;
if (preferences && processIsCRTEnabled(preferences, config.CollapsedThreads, config.FeatureFlagCollapsedThreads, config.Version)) {
// need to await here since GM/DM threads in different teams overlap
await syncTeamThreads(serverUrl, myTeam.id);
}
}
} catch {
// Do nothing
}
};
export async function verifyPushProxy(serverUrl: string) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return;
}
const {database} = operator;
const ppVerification = await getPushVerificationStatus(database);
if (
ppVerification !== PUSH_PROXY_STATUS_UNKNOWN &&
ppVerification !== ''
) {
return;
}
const deviceId = await getDeviceToken();
if (!deviceId) {
return;
}
let client;
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
client = NetworkManager.getClient(serverUrl);
} catch (err) {
return;
}
const ppVerification = await getPushVerificationStatus(database);
if (
ppVerification !== PUSH_PROXY_STATUS_UNKNOWN &&
ppVerification !== ''
) {
return;
}
const client = NetworkManager.getClient(serverUrl);
try {
const response = await client.ping(deviceId);
const canReceiveNotifications = response?.data?.CanReceiveNotifications;
switch (canReceiveNotifications) {
@@ -457,9 +511,7 @@ export async function verifyPushProxy(serverUrl: string) {
default:
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.PUSH_VERIFICATION_STATUS, value: PUSH_PROXY_STATUS_VERIFIED}], prepareRecordsOnly: false});
}
} catch (error) {
logDebug('error on verifyPushProxy', getFullErrorMessage(error));
} catch (err) {
// Do nothing
}
}
@@ -472,7 +524,6 @@ export async function handleEntryAfterLoadNavigation(
currentChannelId: string,
initialTeamId: string,
initialChannelId: string,
gmConverted: boolean,
) {
try {
const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
@@ -482,26 +533,15 @@ export async function handleEntryAfterLoadNavigation(
const mountedScreens = NavigationStore.getScreensInStack();
const isChannelScreenMounted = mountedScreens.includes(Screens.CHANNEL);
const isThreadsMounted = mountedScreens.includes(Screens.THREAD);
const tabletDevice = isTablet();
const tabletDevice = await isTablet();
if (!currentTeamIdAfterLoad) {
// First load or no team
if (tabletDevice) {
await setCurrentTeamAndChannelId(operator, initialTeamId, initialChannelId);
} else {
await setCurrentTeamAndChannelId(operator, initialTeamId, '');
}
} else if (currentTeamIdAfterLoad !== currentTeamId) {
if (currentTeamIdAfterLoad !== currentTeamId) {
// Switched teams while loading
if (!teamMembers.find((t) => t.team_id === currentTeamIdAfterLoad && t.delete_at === 0)) {
await handleKickFromTeam(serverUrl, currentTeamIdAfterLoad);
}
} else if (currentTeamIdAfterLoad !== initialTeamId) {
if (gmConverted) {
await setCurrentTeamAndChannelId(operator, initialTeamId, currentChannelId);
} else {
await handleKickFromTeam(serverUrl, currentTeamIdAfterLoad);
}
await handleKickFromTeam(serverUrl, currentTeamIdAfterLoad);
} else if (currentChannelIdAfterLoad !== currentChannelId) {
// Switched channels while loading
if (!channelMembers.find((m) => m.channel_id === currentChannelIdAfterLoad)) {

View File

@@ -0,0 +1,288 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {storeConfigAndLicense} from '@actions/local/systems';
import {fetchGroupsForMember} from '@actions/remote/groups';
import {fetchPostsForUnreadChannels} from '@actions/remote/post';
import {MyTeamsRequest, updateCanJoinTeams} from '@actions/remote/team';
import {syncTeamThreads} from '@actions/remote/thread';
import {autoUpdateTimezone, updateAllUsersSince} from '@actions/remote/user';
import {gqlEntry, gqlEntryChannels, gqlOtherChannels} from '@client/graphQL/entry';
import {Preferences} from '@constants';
import DatabaseManager from '@database/manager';
import {getPreferenceValue} from '@helpers/api/preference';
import {selectDefaultTeam} from '@helpers/api/team';
import {queryAllChannels, queryAllChannelsForTeam} from '@queries/servers/channel';
import {prepareModels, truncateCrtRelatedTables} from '@queries/servers/entry';
import {getHasCRTChanged} from '@queries/servers/preference';
import {getConfig} from '@queries/servers/system';
import {filterAndTransformRoles, getMemberChannelsFromGQLQuery, getMemberTeamsFromGQLQuery, gqlToClientChannelMembership, gqlToClientPreference, gqlToClientSidebarCategory, gqlToClientTeamMembership, gqlToClientUser} from '@utils/graphql';
import {logDebug} from '@utils/log';
import {processIsCRTEnabled} from '@utils/thread';
import {teamsToRemove, FETCH_UNREADS_TIMEOUT, entryRest, EntryResponse, entryInitialChannelId, restDeferredAppEntryActions, getRemoveTeamIds} from './common';
import type {MyChannelsRequest} from '@actions/remote/channel';
import type ClientError from '@client/rest/error';
import type {Database} from '@nozbe/watermelondb';
import type ChannelModel from '@typings/database/models/servers/channel';
export async function deferredAppEntryGraphQLActions(
serverUrl: string,
since: number,
currentUserId: string,
teamData: MyTeamsRequest,
chData: MyChannelsRequest | undefined,
preferences: PreferenceType[] | undefined,
config: ClientConfig,
initialTeamId?: string,
initialChannelId?: string,
) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const {database} = operator;
setTimeout(() => {
if (chData?.channels?.length && chData.memberships?.length) {
// defer fetching posts for unread channels on initial team
fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId);
}
}, FETCH_UNREADS_TIMEOUT);
if (preferences && processIsCRTEnabled(preferences, config.CollapsedThreads, config.FeatureFlagCollapsedThreads, config.Version)) {
if (initialTeamId) {
await syncTeamThreads(serverUrl, initialTeamId);
}
if (teamData.teams?.length) {
for await (const team of teamData.teams) {
if (team.id !== initialTeamId) {
// need to await here since GM/DM threads in different teams overlap
await syncTeamThreads(serverUrl, team.id);
}
}
}
}
if (initialTeamId) {
const result = await getChannelData(serverUrl, initialTeamId, currentUserId, true);
if ('error' in result) {
return result;
}
const removeChannels = await getRemoveChannels(database, result.chData, initialTeamId, false);
const modelPromises = await prepareModels({operator, removeChannels, chData: result.chData}, true);
const roles = filterAndTransformRoles(result.roles);
if (roles.length) {
modelPromises.push(operator.handleRole({roles, prepareRecordsOnly: true}));
}
const models = (await Promise.all(modelPromises)).flat();
operator.batchRecords(models);
setTimeout(() => {
if (result.chData?.channels?.length && result.chData.memberships?.length) {
// defer fetching posts for unread channels on other teams
fetchPostsForUnreadChannels(serverUrl, result.chData.channels, result.chData.memberships, initialChannelId);
}
}, FETCH_UNREADS_TIMEOUT);
}
// Fetch groups for current user
fetchGroupsForMember(serverUrl, currentUserId);
updateCanJoinTeams(serverUrl);
updateAllUsersSince(serverUrl, since);
return {error: undefined};
}
const getRemoveChannels = async (database: Database, chData: MyChannelsRequest | undefined, initialTeamId: string, singleTeam: boolean) => {
const removeChannels: ChannelModel[] = [];
if (chData?.channels) {
const fetchedChannelIds = chData.channels?.map((channel) => channel.id);
const query = singleTeam ? queryAllChannelsForTeam(database, initialTeamId) : queryAllChannels(database);
const channels = await query.fetch();
for (const channel of channels) {
const excludeCondition = singleTeam ? true : channel.teamId !== initialTeamId && channel.teamId !== '';
if (excludeCondition && !fetchedChannelIds?.includes(channel.id)) {
removeChannels.push(channel);
}
}
}
return removeChannels;
};
const getChannelData = async (serverUrl: string, initialTeamId: string, userId: string, exclude: boolean): Promise<{chData: MyChannelsRequest; roles: Array<Partial<GQLRole>|undefined>} | {error: unknown}> => {
let response;
try {
const request = exclude ? gqlOtherChannels : gqlEntryChannels;
response = await request(serverUrl, initialTeamId);
} catch (error) {
return {error: (error as ClientError).message};
}
if ('error' in response) {
return {error: response.error};
}
if ('errors' in response && response.errors?.length) {
return {error: response.errors[0].message};
}
const channelsFetchedData = response.data;
const chData = {
channels: getMemberChannelsFromGQLQuery(channelsFetchedData),
memberships: channelsFetchedData.channelMembers?.map((m) => gqlToClientChannelMembership(m, userId)),
categories: channelsFetchedData.sidebarCategories?.map((c) => gqlToClientSidebarCategory(c, '')),
};
const roles = channelsFetchedData.channelMembers?.map((m) => m.roles).flat() || [];
return {chData, roles};
};
export const entryGQL = async (serverUrl: string, currentTeamId?: string, currentChannelId?: string): Promise<EntryResponse> => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const {database} = operator;
let response;
try {
response = await gqlEntry(serverUrl);
} catch (error) {
return {error: (error as ClientError).message};
}
if ('error' in response) {
return {error: response.error};
}
if ('errors' in response && response.errors?.length) {
return {error: response.errors[0].message};
}
const fetchedData = response.data;
const config = fetchedData.config || {} as ClientConfig;
const license = fetchedData.license || {} as ClientLicense;
await storeConfigAndLicense(serverUrl, config, license);
const meData = {
user: gqlToClientUser(fetchedData.user!),
};
const allTeams = getMemberTeamsFromGQLQuery(fetchedData);
const allTeamMemberships = fetchedData.teamMembers.map((m) => gqlToClientTeamMembership(m, meData.user.id));
const [nonArchivedTeams, archivedTeamIds] = allTeams.reduce((acc, t) => {
if (t.delete_at) {
acc[1].add(t.id);
return acc;
}
return [[...acc[0], t], acc[1]];
}, [[], new Set<string>()]);
const nonArchivedTeamMemberships = allTeamMemberships.filter((m) => !archivedTeamIds.has(m.team_id));
const teamData = {
teams: nonArchivedTeams,
memberships: nonArchivedTeamMemberships,
};
const prefData = {
preferences: fetchedData.user?.preferences?.map(gqlToClientPreference),
};
if (prefData.preferences) {
const crtToggled = await getHasCRTChanged(database, prefData.preferences);
if (crtToggled) {
const {error} = await truncateCrtRelatedTables(serverUrl);
if (error) {
return {error: `Resetting CRT on ${serverUrl} failed`};
}
}
}
let initialTeamId = currentTeamId;
if (!teamData.teams.length) {
initialTeamId = '';
} else if (!initialTeamId || !teamData.teams.find((t) => t.id === currentTeamId && t.delete_at === 0)) {
const teamOrderPreference = getPreferenceValue(prefData.preferences || [], Preferences.TEAMS_ORDER, '', '') as string;
initialTeamId = selectDefaultTeam(teamData.teams, meData.user.locale, teamOrderPreference, config.ExperimentalPrimaryTeam)?.id || '';
}
const gqlRoles = [
...fetchedData.user?.roles || [],
...fetchedData.teamMembers?.map((m) => m.roles).flat() || [],
];
let chData;
if (initialTeamId) {
const result = await getChannelData(serverUrl, initialTeamId, meData.user.id, false);
if ('error' in result) {
return result;
}
chData = result.chData;
gqlRoles.push(...result.roles);
}
const roles = filterAndTransformRoles(gqlRoles);
const initialChannelId = await entryInitialChannelId(database, currentChannelId, currentTeamId, initialTeamId, meData.user.id, chData?.channels, chData?.memberships);
const removeChannels = await getRemoveChannels(database, chData, initialTeamId, true);
const removeTeamIds = await getRemoveTeamIds(database, teamData);
const removeTeams = await teamsToRemove(serverUrl, removeTeamIds);
const modelPromises = await prepareModels({operator, initialTeamId, removeTeams, removeChannels, teamData, chData, prefData, meData}, true);
if (roles.length) {
modelPromises.push(operator.handleRole({roles, prepareRecordsOnly: true}));
}
const models = (await Promise.all(modelPromises)).flat();
return {models, initialTeamId, initialChannelId, prefData, teamData, chData, meData};
};
export const entry = async (serverUrl: string, teamId?: string, channelId?: string, since = 0): Promise<EntryResponse> => {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const config = await getConfig(database);
let result;
if (config?.FeatureFlagGraphQL === 'true') {
result = await entryGQL(serverUrl, teamId, channelId);
if ('error' in result) {
logDebug('Error using GraphQL, trying REST', result.error);
result = entryRest(serverUrl, teamId, channelId, since);
}
} else {
result = entryRest(serverUrl, teamId, channelId, since);
}
return result;
};
export async function deferredAppEntryActions(
serverUrl: string, since: number, currentUserId: string, currentUserLocale: string, preferences: PreferenceType[] | undefined,
config: ClientConfig, license: ClientLicense | undefined, teamData: MyTeamsRequest, chData: MyChannelsRequest | undefined,
initialTeamId?: string, initialChannelId?: string) {
let result;
if (config?.FeatureFlagGraphQL === 'true') {
result = await deferredAppEntryGraphQLActions(serverUrl, since, currentUserId, teamData, chData, preferences, config, initialTeamId, initialChannelId);
if (result.error) {
logDebug('Error using GraphQL, trying REST', result.error);
result = restDeferredAppEntryActions(serverUrl, since, currentUserId, currentUserLocale, preferences, config, license, teamData, chData, initialTeamId, initialChannelId);
}
} else {
result = restDeferredAppEntryActions(serverUrl, since, currentUserId, currentUserLocale, preferences, config, license, teamData, chData, initialTeamId, initialChannelId);
}
autoUpdateTimezone(serverUrl);
return result;
}

View File

@@ -1,34 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {switchToChannelById} from '@actions/remote/channel';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import DatabaseManager from '@database/manager';
import {getServerCredentials} from '@init/credentials';
import WebsocketManager from '@managers/websocket_manager';
import NetworkManager from '@managers/network_manager';
import {setCurrentTeamAndChannelId} from '@queries/servers/system';
import {setTeamLoading} from '@store/team_load_store';
import {isTablet} from '@utils/helpers';
import {deferredAppEntryActions, entry} from './gql_common';
import type {Client} from '@client/rest';
type AfterLoginArgs = {
serverUrl: string;
user: UserProfile;
deviceToken?: string;
}
export async function loginEntry({serverUrl}: AfterLoginArgs): Promise<{error?: unknown}> {
export async function loginEntry({serverUrl, user, deviceToken}: AfterLoginArgs): Promise<{error?: any; hasTeams?: boolean; time?: number}> {
const dt = Date.now();
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
if (deviceToken) {
try {
client.attachDevice(deviceToken);
} catch {
// do nothing, the token could've failed to attach to the session but is not a blocker
}
}
try {
const clData = await fetchConfigAndLicense(serverUrl, false);
if (clData.error) {
return {error: clData.error};
}
const credentials = await getServerCredentials(serverUrl);
if (credentials?.token) {
WebsocketManager.createClient(serverUrl, credentials.token);
await WebsocketManager.initializeClient(serverUrl);
setTeamLoading(serverUrl, true);
const entryData = await entry(serverUrl, '', '');
if ('error' in entryData) {
setTeamLoading(serverUrl, false);
return {error: entryData.error};
}
return {};
const {models, initialTeamId, initialChannelId, prefData, teamData, chData} = entryData;
const isTabletDevice = await isTablet();
let switchToChannel = false;
if (initialChannelId && isTabletDevice) {
switchToChannel = true;
switchToChannelById(serverUrl, initialChannelId, initialTeamId);
} else {
setCurrentTeamAndChannelId(operator, initialTeamId, '');
}
await operator.batchRecords(models);
setTeamLoading(serverUrl, false);
const config = clData.config || {} as ClientConfig;
const license = clData.license || {} as ClientLicense;
deferredAppEntryActions(serverUrl, 0, user.id, user.locale, prefData.preferences, config, license, teamData, chData, initialTeamId, switchToChannel ? initialChannelId : undefined);
return {time: Date.now() - dt, hasTeams: Boolean(teamData.teams?.length)};
} catch (error) {
return {error};
}

View File

@@ -1,45 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fetchMyChannel, switchToChannelById} from '@actions/remote/channel';
import {fetchPostById} from '@actions/remote/post';
import {fetchMyTeam} from '@actions/remote/team';
import {switchToChannelById} from '@actions/remote/channel';
import {fetchAndSwitchToThread} from '@actions/remote/thread';
import {Preferences, Screens} from '@constants';
import {getDefaultThemeByAppearance} from '@context/theme';
import DatabaseManager from '@database/manager';
import WebsocketManager from '@managers/websocket_manager';
import {getMyChannel} from '@queries/servers/channel';
import {getPostById} from '@queries/servers/post';
import {queryThemePreferences} from '@queries/servers/preference';
import {getCurrentTeamId} from '@queries/servers/system';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {getConfig, getCurrentTeamId, getLicense, getWebSocketLastDisconnected, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {getMyTeamById} from '@queries/servers/team';
import {getIsCRTEnabled} from '@queries/servers/thread';
import {getCurrentUser} from '@queries/servers/user';
import EphemeralStore from '@store/ephemeral_store';
import {isErrorWithStatusCode} from '@utils/errors';
import NavigationStore from '@store/navigation_store';
import {setTeamLoading} from '@store/team_load_store';
import {isTablet} from '@utils/helpers';
import {emitNotificationError} from '@utils/notification';
import {setThemeDefaults, updateThemeIfNeeded} from '@utils/theme';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
import type MyTeamModel from '@typings/database/models/servers/my_team';
import type PostModel from '@typings/database/models/servers/post';
export async function pushNotificationEntry(serverUrl: string, notification: NotificationData) {
// We only reach this point if we have a channel Id in the notification payload
const channelId = notification.channel_id!;
const rootId = notification.root_id!;
import {syncOtherServers} from './common';
import {deferredAppEntryActions, entry} from './gql_common';
export async function pushNotificationEntry(serverUrl: string, notification: NotificationWithData) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const {database} = operator;
// We only reach this point if we have a channel Id in the notification payload
const channelId = notification.payload!.channel_id!;
const rootId = notification.payload!.root_id!;
const {database} = operator;
const currentTeamId = await getCurrentTeamId(database);
const currentServerUrl = await DatabaseManager.getActiveServerUrl();
const lastDisconnectedAt = await getWebSocketLastDisconnected(database);
let teamId = notification.team_id;
let isDirectChannel = false;
let teamId = notification.payload?.team_id;
if (!teamId) {
// If the notification payload does not have a teamId we assume is a DM/GM
isDirectChannel = true;
teamId = currentTeamId;
}
@@ -51,7 +53,7 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not
// When opening the app from a push notification the theme may not be set in the EphemeralStore
// causing the goToScreen to use the Appearance theme instead and that causes the screen background color to potentially
// not match the theme
const themes = await queryThemePreferences(database, teamId).fetch();
const themes = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_THEME, teamId).fetch();
let theme = getDefaultThemeByAppearance();
if (themes.length) {
theme = setThemeDefaults(JSON.parse(themes[0].value) as Theme);
@@ -59,62 +61,91 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not
updateThemeIfNeeded(theme, true);
}
await NavigationStore.waitUntilScreenHasLoaded(Screens.HOME);
// To make the switch faster we determine if we already have the team & channel
let myChannel: MyChannelModel | ChannelMembership | undefined = await getMyChannel(database, channelId);
let myTeam: MyTeamModel | TeamMembership | undefined = await getMyTeamById(database, teamId);
if (!myTeam) {
const resp = await fetchMyTeam(serverUrl, teamId);
if (resp.error) {
if (isErrorWithStatusCode(resp.error) && resp.error.status_code === 403) {
emitNotificationError('Team');
} else {
emitNotificationError('Connection');
}
} else {
myTeam = resp.memberships?.[0];
}
}
if (!myChannel) {
const resp = await fetchMyChannel(serverUrl, teamId, channelId);
if (resp.error) {
if (isErrorWithStatusCode(resp.error) && resp.error.status_code === 403) {
emitNotificationError('Channel');
} else {
emitNotificationError('Connection');
}
} else {
myChannel = resp.memberships?.[0];
}
}
const myChannel = await getMyChannel(database, channelId);
const myTeam = await getMyTeamById(database, teamId);
const isCRTEnabled = await getIsCRTEnabled(database);
const isThreadNotification = isCRTEnabled && Boolean(rootId);
let switchedToScreen = false;
let switchedToChannel = false;
if (myChannel && myTeam) {
if (isThreadNotification) {
let post: PostModel | Post | undefined = await getPostById(database, rootId);
if (!post) {
const resp = await fetchPostById(serverUrl, rootId);
post = resp.post;
}
const actualRootId = post && ('root_id' in post ? post.root_id : post.rootId);
if (actualRootId) {
await fetchAndSwitchToThread(serverUrl, actualRootId, true);
} else if (post) {
await fetchAndSwitchToThread(serverUrl, rootId, true);
} else {
emitNotificationError('Post');
}
await fetchAndSwitchToThread(serverUrl, rootId, true);
} else {
switchedToChannel = true;
await switchToChannelById(serverUrl, channelId, teamId);
}
switchedToScreen = true;
}
setTeamLoading(serverUrl, true);
const entryData = await entry(serverUrl, teamId, channelId);
if ('error' in entryData) {
setTeamLoading(serverUrl, false);
return {error: entryData.error};
}
const {models, initialTeamId, initialChannelId, prefData, teamData, chData} = entryData;
// There is a chance that after the above request returns
// the user is no longer part of the team or channel
// that triggered the notification (rare but possible)
let selectedTeamId = teamId;
let selectedChannelId = channelId;
if (initialTeamId !== teamId) {
// We are no longer a part of the team that the notification belongs to
// Immediately set the new team as the current team in the database so that the UI
// renders the correct team.
selectedTeamId = initialTeamId;
if (!isDirectChannel) {
selectedChannelId = initialChannelId;
}
}
WebsocketManager.openAll();
if (!switchedToScreen) {
const isTabletDevice = await isTablet();
if (isTabletDevice || (channelId === selectedChannelId)) {
// Make switch again to get the missing data and make sure the team is the correct one
switchedToScreen = true;
if (isThreadNotification) {
await fetchAndSwitchToThread(serverUrl, rootId, true);
} else {
switchedToChannel = true;
await switchToChannelById(serverUrl, channelId, teamId);
}
} else if (teamId !== selectedTeamId || channelId !== selectedChannelId) {
// If in the end the selected team or channel is different than the one from the notification
// we switch again
await setCurrentTeamAndChannelId(operator, selectedTeamId, selectedChannelId);
}
}
return {};
if (teamId !== selectedTeamId) {
emitNotificationError('Team');
} else if (channelId !== selectedChannelId) {
emitNotificationError('Channel');
}
// Waiting for the screen to display fixes a race condition when fetching and storing data
if (switchedToChannel) {
await NavigationStore.waitUntilScreenHasLoaded(Screens.CHANNEL);
} else if (switchedToScreen && isThreadNotification) {
await NavigationStore.waitUntilScreenHasLoaded(Screens.THREAD);
}
await operator.batchRecords(models);
setTeamLoading(serverUrl, false);
const {id: currentUserId, locale: currentUserLocale} = (await getCurrentUser(operator.database))!;
const config = await getConfig(database);
const license = await getLicense(database);
await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, selectedTeamId, selectedChannelId);
syncOtherServers(serverUrl);
return {userId: currentUserId};
}

View File

@@ -3,12 +3,11 @@
import {DOWNLOAD_TIMEOUT} from '@constants/network';
import NetworkManager from '@managers/network_manager';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
import {forceLogoutIfNecessary} from './session';
import type {Client} from '@client/rest';
import type ClientError from '@client/rest/error';
import type {ClientResponse, ClientResponseError} from '@mattermost/react-native-network-client';
export const downloadFile = (serverUrl: string, fileId: string, desitnation: string) => { // Let it throw and handle it accordingly
@@ -30,23 +29,28 @@ export const uploadFile = (
onError: (response: ClientResponseError) => void = () => {/*Do Nothing*/},
skipBytes = 0,
) => {
let client: Client;
try {
const client = NetworkManager.getClient(serverUrl);
return {cancel: client.uploadPostAttachment(file, channelId, onProgress, onComplete, onError, skipBytes)};
client = NetworkManager.getClient(serverUrl);
} catch (error) {
logDebug('error on uploadFile', getFullErrorMessage(error));
return {error};
return {error: error as ClientError};
}
return {cancel: client.uploadPostAttachment(file, channelId, onProgress, onComplete, onError, skipBytes)};
};
export const fetchPublicLink = async (serverUrl: string, fileId: string) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error: error as ClientError};
}
try {
const client = NetworkManager.getClient(serverUrl);
const publicLink = await client!.getFilePublicLink(fileId);
return publicLink;
} catch (error) {
logDebug('error on fetchPublicLink', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};

View File

@@ -8,8 +8,6 @@ import {t} from '@i18n';
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 {logDebug} from '@utils/log';
import {forceLogoutIfNecessary} from './session';
@@ -65,12 +63,10 @@ export const doPing = async (serverUrl: string, verifyPushProxy: boolean, timeou
}
if (!response.ok) {
logDebug('Server ping returned not ok response', response);
NetworkManager.invalidateClient(serverUrl);
return {error: {intl: pingError}};
}
} catch (error) {
logDebug('Server ping threw an exception', getFullErrorMessage(error));
NetworkManager.invalidateClient(serverUrl);
return {error: {intl: pingError}};
}
@@ -83,19 +79,29 @@ export const doPing = async (serverUrl: string, verifyPushProxy: boolean, timeou
canReceiveNotifications = PUSH_PROXY_RESPONSE_VERIFIED;
}
return {canReceiveNotifications};
return {canReceiveNotifications, error: undefined};
}
return {};
return {error: undefined};
};
export const getRedirectLocation = async (serverUrl: string, link: string) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const client = NetworkManager.getClient(serverUrl);
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const expandedLink = await client.getRedirectLocation(link);
if (expandedLink?.location) {
const storedLinks = await getExpandedLinks(database);
const storedLinks = await getExpandedLinks(operator.database);
storedLinks[link] = expandedLink.location;
const expanded: IdValue = {
id: SYSTEM_IDENTIFIERS.EXPANDED_LINKS,
@@ -109,8 +115,7 @@ export const getRedirectLocation = async (serverUrl: string, link: string) => {
return {expandedLink};
} catch (error) {
logDebug('error on getRedirectLocation', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};

View File

@@ -4,23 +4,30 @@
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {getChannelById} from '@queries/servers/channel';
import {getLicense} from '@queries/servers/system';
import {getTeamById} from '@queries/servers/team';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
import {forceLogoutIfNecessary} from './session';
import type {Client} from '@client/rest';
export const fetchGroup = async (serverUrl: string, id: string, fetchOnly = false) => {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const client: Client = NetworkManager.getClient(serverUrl);
const group = await client.getGroup(id);
// Save locally
return operator.handleGroups({groups: [group], prepareRecordsOnly: fetchOnly});
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const fetchGroupsForAutocomplete = async (serverUrl: string, query: string, fetchOnly = false) => {
try {
const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const license = await getLicense(database);
if (!license || license.IsLicensed !== 'true') {
return [];
}
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const client: Client = NetworkManager.getClient(serverUrl);
const response = await client.getGroups({query, includeMemberCount: true});
@@ -30,19 +37,14 @@ export const fetchGroupsForAutocomplete = async (serverUrl: string, query: strin
return operator.handleGroups({groups: response, prepareRecordsOnly: fetchOnly});
} catch (error) {
logDebug('error on fetchGroupsForAutocomplete', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const fetchGroupsByNames = async (serverUrl: string, names: string[], fetchOnly = false) => {
try {
const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const license = await getLicense(database);
if (!license || license.IsLicensed !== 'true') {
return [];
}
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const client: Client = NetworkManager.getClient(serverUrl);
const promises: Array <Promise<Group[]>> = [];
@@ -60,20 +62,14 @@ export const fetchGroupsByNames = async (serverUrl: string, names: string[], fet
return operator.handleGroups({groups, prepareRecordsOnly: fetchOnly});
} catch (error) {
logDebug('error on fetchGroupsByNames', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const fetchGroupsForChannel = async (serverUrl: string, channelId: string, fetchOnly = false) => {
try {
const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const license = await getLicense(database);
if (!license || license.IsLicensed !== 'true') {
return {groups: [], groupChannels: []};
}
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const client = NetworkManager.getClient(serverUrl);
const response = await client.getAllGroupsAssociatedToChannel(channelId);
@@ -87,24 +83,19 @@ export const fetchGroupsForChannel = async (serverUrl: string, channelId: string
]);
if (!fetchOnly) {
await operator.batchRecords([...groups, ...groupChannels], 'fetchGroupsForChannel');
await operator.batchRecords([...groups, ...groupChannels]);
}
return {groups, groupChannels};
} catch (error) {
logDebug('error on fetchGroupsForChannel', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const fetchGroupsForTeam = async (serverUrl: string, teamId: string, fetchOnly = false) => {
try {
const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const license = await getLicense(database);
if (!license || license.IsLicensed !== 'true') {
return {groups: [], groupTeams: []};
}
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const client: Client = NetworkManager.getClient(serverUrl);
const response = await client.getAllGroupsAssociatedToTeam(teamId);
@@ -119,23 +110,18 @@ export const fetchGroupsForTeam = async (serverUrl: string, teamId: string, fetc
]);
if (!fetchOnly) {
await operator.batchRecords([...groups, ...groupTeams], 'fetchGroupsForTeam');
await operator.batchRecords([...groups, ...groupTeams]);
}
return {groups, groupTeams};
} catch (error) {
logDebug('error on fetchGroupsForTeam', getFullErrorMessage(error));
return {error};
}
};
export const fetchGroupsForMember = async (serverUrl: string, userId: string, fetchOnly = false) => {
try {
const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const license = await getLicense(database);
if (!license || license.IsLicensed !== 'true') {
return {groups: [], groupMemberships: []};
}
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const client: Client = NetworkManager.getClient(serverUrl);
const response = await client.getAllGroupsAssociatedToMembership(userId);
@@ -150,30 +136,41 @@ export const fetchGroupsForMember = async (serverUrl: string, userId: string, fe
]);
if (!fetchOnly) {
await operator.batchRecords([...groups, ...groupMemberships], 'fetchGroupsForMember');
await operator.batchRecords([...groups, ...groupMemberships]);
}
return {groups, groupMemberships};
} catch (error) {
logDebug('error on fetchGroupsForMember', getFullErrorMessage(error));
return {error};
}
};
export const fetchFilteredTeamGroups = async (serverUrl: string, searchTerm: string, teamId: string) => {
const res = await fetchGroupsForTeam(serverUrl, teamId);
if ('error' in res) {
return {error: res.error};
try {
const groups = await fetchGroupsForTeam(serverUrl, teamId);
if (groups && Array.isArray(groups)) {
return groups.filter((g) => g.name.toLowerCase().includes(searchTerm.toLowerCase()));
}
throw groups.error;
} catch (error) {
return {error};
}
return res.groups.filter((g) => g.name.toLowerCase().includes(searchTerm.toLowerCase()));
};
export const fetchFilteredChannelGroups = async (serverUrl: string, searchTerm: string, channelId: string) => {
const res = await fetchGroupsForChannel(serverUrl, channelId);
if ('error' in res) {
return {error: res.error};
try {
const groups = await fetchGroupsForChannel(serverUrl, channelId);
if (groups && Array.isArray(groups)) {
return groups.filter((g) => g.name.toLowerCase().includes(searchTerm.toLowerCase()));
}
throw groups.error;
} catch (error) {
return {error};
}
return res.groups.filter((g) => g.name.toLowerCase().includes(searchTerm.toLowerCase()));
};
export const fetchGroupsForTeamIfConstrained = async (serverUrl: string, teamId: string, fetchOnly = false) => {

View File

@@ -6,31 +6,45 @@ import DatabaseManager from '@database/manager';
import IntegrationsMananger from '@managers/integrations_manager';
import NetworkManager from '@managers/network_manager';
import {getCurrentChannelId, getCurrentTeamId} from '@queries/servers/system';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
import {forceLogoutIfNecessary} from './session';
export const submitInteractiveDialog = async (serverUrl: string, submission: DialogSubmission) => {
try {
const client = NetworkManager.getClient(serverUrl);
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
import type {Client} from '@client/rest';
submission.channel_id = await getCurrentChannelId(database);
submission.team_id = await getCurrentTeamId(database);
export const submitInteractiveDialog = async (serverUrl: string, submission: DialogSubmission) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
submission.channel_id = await getCurrentChannelId(database);
submission.team_id = await getCurrentTeamId(database);
try {
const data = await client.submitInteractiveDialog(submission);
return {data};
} catch (error) {
logDebug('error on submitInteractiveDialog', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const postActionWithCookie = async (serverUrl: string, postId: string, actionId: string, actionCookie: string, selectedOption = '') => {
let client: Client;
try {
const client = NetworkManager.getClient(serverUrl);
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const data = await client.doPostActionWithCookie(postId, actionId, actionCookie, selectedOption);
if (data?.trigger_id) {
IntegrationsMananger.getManager(serverUrl)?.setTriggerId(data.trigger_id);
@@ -38,8 +52,7 @@ export const postActionWithCookie = async (serverUrl: string, postId: string, ac
return {data};
} catch (error) {
logDebug('error on postActionWithCookie', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};

View File

@@ -3,37 +3,34 @@
import {Platform} from 'react-native';
import {addChannelToDefaultCategory, storeCategories} from '@actions/local/category';
import {storeMyChannelsForTeam} from '@actions/local/channel';
import {storePostsForChannel} from '@actions/local/post';
// import {updatePostSinceCache, updatePostsInThreadsSinceCache} from '@actions/local/notification';
import {fetchDirectChannelsInfo, fetchMyChannel, switchToChannelById} from '@actions/remote/channel';
import {fetchPostsForChannel, fetchPostThread} from '@actions/remote/post';
import {forceLogoutIfNecessary} from '@actions/remote/session';
import {fetchMyTeam} from '@actions/remote/team';
import {fetchAndSwitchToThread} from '@actions/remote/thread';
import {ActionType} from '@constants';
import DatabaseManager from '@database/manager';
import {getMyChannel, getChannelById} from '@queries/servers/channel';
import {getCurrentTeamId} from '@queries/servers/system';
import {getMyTeamById, prepareMyTeams} from '@queries/servers/team';
import {getCurrentTeamId, getWebSocketLastDisconnected} from '@queries/servers/system';
import {getMyTeamById} from '@queries/servers/team';
import {getIsCRTEnabled} from '@queries/servers/thread';
import EphemeralStore from '@store/ephemeral_store';
import {logWarning} from '@utils/log';
import {emitNotificationError} from '@utils/notification';
import {processPostsFetched} from '@utils/post';
import type {Model} from '@nozbe/watermelondb';
const fetchNotificationData = async (serverUrl: string, notification: NotificationWithData, skipEvents = false) => {
const channelId = notification.payload?.channel_id;
if (!channelId) {
return {error: 'No chanel Id was specified'};
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const channelId = notification.payload?.channel_id;
if (!channelId) {
return {error: 'No chanel Id was specified'};
}
const {database} = operator;
const currentTeamId = await getCurrentTeamId(database);
let teamId = notification.payload?.team_id;
let isDirectChannel = false;
@@ -92,107 +89,44 @@ const fetchNotificationData = async (serverUrl: string, notification: Notificati
}
return {};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error);
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const backgroundNotification = async (serverUrl: string, notification: NotificationWithData) => {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const channelId = notification.payload?.channel_id;
let teamId = notification.payload?.team_id;
if (!channelId) {
throw new Error('No chanel Id was specified');
}
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return;
}
if (!teamId) {
// If the notification payload does not have a teamId we assume is a DM/GM
const currentTeamId = await getCurrentTeamId(database);
teamId = currentTeamId;
}
if (notification.payload?.data) {
const {data, isCRTEnabled} = notification.payload;
const {channel, myChannel, team, myTeam, posts, users, threads} = data;
const models: Model[] = [];
const lastDisconnectedAt = await getWebSocketLastDisconnected(database);
if (lastDisconnectedAt) {
// if (Platform.OS === 'ios') {
// const isCRTEnabled = await getIsCRTEnabled(database);
// const isThreadNotification = isCRTEnabled && Boolean(notification.payload?.root_id);
// if (isThreadNotification) {
// updatePostsInThreadsSinceCache(serverUrl, notification);
// } else {
// updatePostSinceCache(serverUrl, notification);
// }
// }
if (posts) {
const postsData = processPostsFetched(posts);
const isThreadNotification = isCRTEnabled && Boolean(notification.payload.root_id);
const actionType = isThreadNotification ? ActionType.POSTS.RECEIVED_IN_THREAD : ActionType.POSTS.RECEIVED_IN_CHANNEL;
if (team || myTeam) {
const teamPromises = prepareMyTeams(operator, team ? [team] : [], myTeam ? [myTeam] : []);
if (teamPromises.length) {
const teamModels = await Promise.all(teamPromises);
models.push(...teamModels.flat());
}
}
await storeMyChannelsForTeam(
serverUrl, teamId,
channel ? [channel] : [],
myChannel ? [myChannel] : [],
false, isCRTEnabled,
);
if (data.categoryChannels?.length && channel) {
const {models: categoryModels} = await addChannelToDefaultCategory(serverUrl, channel, true);
if (categoryModels?.length) {
models.push(...categoryModels);
}
} else if (data.categories?.categories) {
const {models: categoryModels} = await storeCategories(serverUrl, data.categories.categories, false, true);
if (categoryModels?.length) {
models.push(...categoryModels);
}
}
await storePostsForChannel(
serverUrl, channelId,
postsData.posts, postsData.order, postsData.previousPostId ?? '',
actionType, users || [],
);
if (isThreadNotification && threads?.length) {
const threadModels = await operator.handleThreads({
threads: threads.map((t) => ({
...t,
lastFetchedAt: Math.max(t.post.create_at, t.post.update_at, t.post.delete_at),
})),
teamId,
prepareRecordsOnly: true,
});
if (threadModels.length) {
models.push(...threadModels);
}
}
}
if (models.length) {
await operator.batchRecords(models, 'backgroundNotification');
}
}
} catch (error) {
logWarning('backgroundNotification', error);
await fetchNotificationData(serverUrl, notification, true);
}
};
export const openNotification = async (serverUrl: string, notification: NotificationWithData) => {
// Wait for initial launch to kick in if needed
await new Promise((r) => setTimeout(r, 500));
if (EphemeralStore.getProcessingNotification() === notification.identifier) {
return {};
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
EphemeralStore.setNotificationTapped(true);
const channelId = notification.payload!.channel_id!;
const rootId = notification.payload!.root_id!;
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
EphemeralStore.setNotificationTapped(true);
const {database} = operator;
const channelId = notification.payload!.channel_id!;
const rootId = notification.payload!.root_id!;
const isCRTEnabled = await getIsCRTEnabled(database);
const isThreadNotification = isCRTEnabled && Boolean(rootId);
@@ -231,7 +165,7 @@ export const openNotification = async (serverUrl: string, notification: Notifica
}
return switchToChannelById(serverUrl, channelId, teamId);
} catch (error) {
forceLogoutIfNecessary(serverUrl, error);
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};

View File

@@ -3,8 +3,6 @@
import {General} from '@constants';
import NetworkManager from '@managers/network_manager';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
export const isNPSEnabled = async (serverUrl: string) => {
try {
@@ -17,7 +15,6 @@ export const isNPSEnabled = async (serverUrl: string) => {
}
return false;
} catch (error) {
logDebug('error on isNPSEnabled', getFullErrorMessage(error));
return false;
}
};
@@ -28,7 +25,6 @@ export const giveFeedbackAction = async (serverUrl: string) => {
const post = await client.npsGiveFeedbackAction();
return {post};
} catch (error) {
logDebug('error on giveFeedbackAction', getFullErrorMessage(error));
return {error};
}
};

View File

@@ -9,9 +9,12 @@ import {displayPermalink} from '@utils/permalink';
import type TeamModel from '@typings/database/models/servers/team';
export const showPermalink = async (serverUrl: string, teamName: string, postId: string, openAsPermalink = true) => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
try {
let name = teamName;
let team: TeamModel | undefined;
if (!name || name === DeepLink.Redirect) {
@@ -23,7 +26,7 @@ export const showPermalink = async (serverUrl: string, teamName: string, postId:
await displayPermalink(name, postId, openAsPermalink);
return {};
return {error: undefined};
} catch (error) {
return {error};
}

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