Compare commits

..

5 Commits

Author SHA1 Message Date
Harrison Healey
4a5da8d625 Switch to Commonmark fork 2022-05-06 11:14:57 -04:00
Elias Nahum
70d5cb068c Render markdown checkbox 2022-05-06 10:46:28 -04:00
Harrison Healey
c6af59b388 Add initial task list support 2022-05-06 10:44:48 -04:00
Harrison Healey
97cdac1d40 Remove fonts from highlighted text so that it can be bolded 2022-05-06 10:42:17 -04:00
Harrison Healey
b9376ad429 Initial attempt at search highlighting for Markdown 2022-05-06 10:42:15 -04:00
1678 changed files with 51702 additions and 119012 deletions

View File

@@ -1,20 +1,20 @@
version: 2.1
orbs:
owasp: entur/owasp@0.0.10
node: circleci/node@5.0.3
node: circleci/node@5.0.2
executors:
android:
parameters:
resource_class:
default: xlarge
default: large
type: string
environment:
NODE_OPTIONS: --max_old_space_size=12000
NODE_ENV: production
BABEL_ENV: production
docker:
- image: cimg/android:2022.09.2-node
- image: cimg/android:2022.03-node
working_directory: ~/mattermost-mobile
resource_class: <<parameters.resource_class>>
@@ -28,7 +28,7 @@ executors:
NODE_ENV: production
BABEL_ENV: production
macos:
xcode: "14.0.0"
xcode: "13.3.0"
working_directory: ~/mattermost-mobile
shell: /bin/bash --login -o pipefail
resource_class: <<parameters.resource_class>>
@@ -39,7 +39,7 @@ commands:
steps:
- add_ssh_keys:
fingerprints:
- "03:1c:a7:07:35:bc:57:e4:1d:6c:e1:2c:4b:be:09:6d"
- "59:4d:99:5e:1c:6d:30:36:6d:60:76:88:ff:a7:ab:63"
- run:
name: Clone the mobile private repo
command: git clone git@github.com:mattermost/mattermost-mobile-private.git ~/mattermost-mobile-private
@@ -105,16 +105,13 @@ commands:
description: "Get JavaScript dependencies"
steps:
- node/install:
node-version: '18.7.0'
node-version: '16.14.2'
- 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
node node_modules/react-native-webrtc/tools/downloadWebRTC.js
command: NODE_ENV=development npm ci --ignore-scripts
- save_cache:
name: Save npm cache
key: v2-npm-{{ checksum "package.json" }}-{{ arch }}
@@ -310,13 +307,13 @@ jobs:
- save:
filename: "*.apk"
build-android-release:
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
@@ -327,27 +324,27 @@ jobs:
- 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-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:
@@ -359,13 +356,13 @@ jobs:
- save:
filename: "*.ipa"
build-ios-release:
executor: ios
steps:
- build-ios
- persist
- save:
filename: "*.ipa"
# build-ios-release:
# executor: ios
# steps:
# - build-ios
# - persist
# - save:
# filename: "*.ipa"
build-ios-pr:
executor: ios
@@ -376,64 +373,63 @@ jobs:
- 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-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"
# 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-release:
# executor:
# name: android
# resource_class: medium
# steps:
# - deploy-to-store:
# task: "Deploy to Google Play"
# target: android
# file: "*.apk"
deploy-android-beta:
executor:
@@ -446,14 +442,13 @@ jobs:
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-release:
# executor: ios
# steps:
# - deploy-to-store:
# task: "Deploy to TestFlight"
# target: ios
# file: "*.ipa"
deploy-ios-beta:
executor: ios
@@ -464,17 +459,17 @@ jobs:
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
# 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
@@ -486,24 +481,26 @@ workflows:
# 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-release:
# context: mattermost-mobile-android-release
# requires:
# - test
# filters:
# branches:
# only:
# - /^build-\d+$/
# - /^build-android-\d+$/
# - /^build-android-release-\d+$/
# - deploy-android-release:
# context: mattermost-mobile-android-release
# requires:
# - build-android-release
# filters:
# branches:
# only:
# - /^build-\d+$/
# - /^build-android-\d+$/
# - /^build-android-release-\d+$/
- build-android-beta:
context: mattermost-mobile-android-beta
@@ -524,24 +521,26 @@ workflows:
- /^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-release:
# context: mattermost-mobile-ios-release
# requires:
# - test
# filters:
# branches:
# only:
# - /^build-\d+$/
# - /^build-ios-\d+$/
# - /^build-ios-release-\d+$/
# - deploy-ios-release:
# context: mattermost-mobile-ios-release
# requires:
# - build-ios-release
# filters:
# branches:
# only:
# - /^build-\d+$/
# - /^build-ios-\d+$/
# - /^build-ios-release-\d+$/
- build-ios-beta:
context: mattermost-mobile-ios-beta
@@ -577,41 +576,43 @@ workflows:
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
# - 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-\d+$/
# - /^build-ios-beta-\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

@@ -1,7 +1,6 @@
{
"extends": [
"./eslint/eslint-mattermost",
"./eslint/eslint-react",
"plugin:mattermost/react",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended"
@@ -9,6 +8,7 @@
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint",
"mattermost",
"import"
],
"settings": {
@@ -68,7 +68,7 @@
"newlines-between": "always",
"pathGroups": [
{
"pattern": "{@(@actions|@app|@assets|@calls|@client|@components|@constants|@context|@database|@helpers|@hooks|@init|@managers|@queries|@screens|@selectors|@share|@store|@telemetry|@typings|@test|@utils)/**,@(@constants|@i18n|@notifications|@store|@websocket)}",
"pattern": "{@(@actions|@app|@assets|@client|@components|@constants|@context|@database|@helpers|@hooks|@init|@managers|@queries|@screens|@selectors|@share|@store|@telemetry|@typings|@test|@utils)/**,@(@constants|@i18n|@notifications|@store|@websocket)}",
"group": "external",
"position": "after"
},

View File

@@ -56,6 +56,7 @@ deprecated-type=warn
unsafe-getters-setters=warn
inexact-spread=warn
unnecessary-invariant=warn
signature-verification-failure=warn
[strict]
deprecated-type
@@ -67,4 +68,4 @@ untyped-import
untyped-type-import
[version]
^0.182.0
^0.170.0

View File

@@ -1,22 +0,0 @@
---
name: 'fastlane-dependencies'
description: 'Install fastlane dependencies'
inputs:
caching_key:
description: Name of the OS to generate dependencies for, to compartimentalize caching
type: string
required: true
runs:
using: 'composite'
steps:
- name: ci/cache-fastlane-dependencies
uses: actions/cache@627f0f41f6904a5b1efbaed9f96d9eb58e92e920 # v3.2.4
with:
key: gems-v1-${{ inputs.os }}-${{ hashFiles('fastlane/Gemfile.lock') }}-${{ runner.arch }}
path: fastlane/vendor/bundle
- name: ci/get-fastlane-dependencies
working-directory: fastlane
shell: bash
run: bundle install --path vendor/bundle

View File

@@ -1,21 +0,0 @@
---
name: 'generate-assets'
description: 'Generate app assets'
runs:
using: 'composite'
steps:
- name: ci/cache-assets
uses: actions/cache@627f0f41f6904a5b1efbaed9f96d9eb58e92e920 # v3.2.4
with:
key: assets-v1-${{ hashFiles('assets/base/config.json') }}-${{ runner.arch }}
path: dist
- name: ci/genereate-assets
shell: bash
run: node ./scripts/generate-assets.js
- name: ci/import-compass-icon
shell: bash
run: |
COMPASS_ICONS="node_modules/@mattermost/compass-icons/font/compass-icons.ttf"
cp "$COMPASS_ICONS" "assets/fonts/"
cp "$COMPASS_ICONS" "android/app/src/main/assets/fonts"

View File

@@ -1,21 +0,0 @@
---
name: 'node-prepare'
description: 'Install node packages'
runs:
using: 'composite'
steps:
- name: ci/cache-npm-dependencies
uses: actions/cache@627f0f41f6904a5b1efbaed9f96d9eb58e92e920 # v3.2.4
with:
key: npm-v1-${{ hashFiles('package-lock.json') }}
path: node_modules
- name: ci/install-npm-dependencies
shell: bash
run: |
NODE_ENV=development npm ci --ignore-scripts
node node_modules/\@sentry/cli/scripts/install.js
node node_modules/react-native-webrtc/tools/downloadWebRTC.js
- name: ci/patch-npm-dependencies
shell: bash
run: npx patch-package

View File

@@ -1,19 +0,0 @@
---
name: 'pods-dependencies'
description: 'Get cocoapods dependencies'
runs:
using: 'composite'
steps:
- name: ci/cache-pods-dependencies
uses: actions/cache@627f0f41f6904a5b1efbaed9f96d9eb58e92e920 # v3.2.4
with:
key: cocoapods-v1-${{ inputs.os }}-${{ hashFiles('ios/Podfile.lock') }}-${{ runner.arch }}
path: |
ios/Pods
~/.cocoapods
- name: ci/get-pods-dependencies
shell: bash
run: |
npm run ios-gems
npm run pod-install

View File

@@ -1,13 +0,0 @@
name: "CodeQL config"
query-filters:
- exclude:
problem.severity:
- warning
- recommendation
- exclude:
id: js/insecure-randomness
paths-ignore:
- test
- '**/*.test.*'

View File

@@ -1,245 +0,0 @@
---
name: build-ios
on:
workflow_call:
# If any of these is set to an empty value, its corresponding env var will not be set
inputs:
app_name:
required: false
type: string
app_scheme:
required: false
type: string
aws_bucket_name:
required: false
type: string
aws_folder_name:
required: false
type: string
aws_region:
required: false
type: string
beta_build:
required: false
type: string
build_for_release:
required: false
type: string
build_pr:
required: false
type: string
extension_app_identifier:
required: false
type: string
branch_to_build:
required: false
type: string
build_artifact_name:
required: true
type: string
build_type:
# Valid values: signed, unsigned, simulator
required: true
type: string
ios_app_group:
required: false
type: string
ios_build_export_method:
required: false
type: string
ios_icloud_container:
required: false
type: string
main_app_identifier:
required: false
type: string
match_app_identifier:
required: false
type: string
match_readonly:
required: false
type: string
match_shallow_clone:
required: false
type: string
match_skip_docs:
required: false
type: string
match_type:
required: false
type: string
babel_env:
required: false
type: string
default: "production"
node_env:
required: false
type: string
default: "production"
node_options:
required: false
type: string
default: "--max_old_space_size=12000"
notification_service_identifier:
required: false
type: string
pilot_skip_waiting_for_build_processing:
required: false
type: string
replace_assets:
required: false
type: string
sentry_enabled:
required: false
type: string
show_onboarding:
required: false
type: string
sync_provisioning_profiles:
required: false
type: string
tag:
required: false
type: string
secrets:
aws_access_key_id:
required: false
aws_secret_access_key:
required: false
fastlane_team_id:
required: false
gh_token:
required: false
ios_api_issuer_id:
required: false
ios_api_key:
required: false
ios_api_key_id:
required: false
match_git_url:
required: false
match_password:
required: false
mattermost_webhook_url:
required: false
private_deploy_key:
required: false
sentry_auth_token:
required: false
sentry_dsn_ios:
required: false
sentry_org:
required: false
sentry_project_ios:
required: false
jobs:
build-ios:
runs-on: macos-11
steps:
- name: ci/generate-env
run: |
# Using this method instead of the workflow-level `env`, to avoid setting variables with empty values
# Note that multiline strings require a different notation: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings
[ -z "${{ inputs.branch_to_build }}" ] || echo 'BRANCH_TO_BUILD=${{ inputs.branch_to_build }}' >> $GITHUB_ENV
[ -z "${{ inputs.app_name }}" ] || echo 'APP_NAME=${{ inputs.app_name }}' >> $GITHUB_ENV
[ -z "${{ inputs.app_scheme }}" ] || echo 'APP_SCHEME=${{ inputs.app_scheme }}' >> $GITHUB_ENV
[ -z "${{ secrets.aws_access_key_id }}" ] || echo 'AWS_ACCESS_KEY_ID=${{ secrets.aws_access_key_id }}' >> $GITHUB_ENV
[ -z "${{ secrets.aws_secret_access_key }}" ] || echo 'AWS_SECRET_ACCESS_KEY=${{ secrets.aws_secret_access_key }}' >> $GITHUB_ENV
[ -z "${{ inputs.aws_bucket_name }}" ] || echo 'AWS_BUCKET_NAME=${{ inputs.aws_bucket_name }}' >> $GITHUB_ENV
[ -z "${{ inputs.aws_folder_name }}" ] || echo 'AWS_FOLDER_NAME=${{ inputs.aws_folder_name }}' >> $GITHUB_ENV
[ -z "${{ inputs.aws_region }}" ] || echo 'AWS_REGION=${{ inputs.aws_region }}' >> $GITHUB_ENV
[ -z "${{ inputs.beta_build }}" ] || echo 'BETA_BUILD=${{ inputs.beta_build }}' >> $GITHUB_ENV
[ -z "${{ inputs.build_for_release }}" ] || echo 'BUILD_FOR_RELEASE=${{ inputs.build_for_release }}' >> $GITHUB_ENV
[ -z "${{ inputs.build_pr }}" ] || echo 'BUILD_PR=${{ inputs.build_pr }}' >> $GITHUB_ENV
[ -z "${{ inputs.extension_app_identifier }}" ] || echo 'EXTENSION_APP_IDENTIFIER=${{ inputs.extension_app_identifier }}' >> $GITHUB_ENV
[ -z "${{ secrets.fastlane_team_id }}" ] || echo 'FASTLANE_TEAM_ID=${{ secrets.fastlane_team_id }}' >> $GITHUB_ENV
[ -z "${{ secrets.gh_token }}" ] || echo 'GITHUB_TOKEN=${{ secrets.gh_token }}' >> $GITHUB_ENV
[ -z "${{ secrets.ios_api_issuer_id }}" ] || echo 'IOS_API_ISSUER_ID=${{ secrets.ios_api_issuer_id }}' >> $GITHUB_ENV
if [ -n "${{ secrets.ios_api_key }}" ]; then
cat <<EOF >>$GITHUB_ENV
IOS_API_KEY<<INNEREOF
${{ secrets.ios_api_key }}
INNEREOF
EOF
fi
[ -z "${{ secrets.ios_api_key_id }}" ] || echo 'IOS_API_KEY_ID=${{ secrets.ios_api_key_id }}' >> $GITHUB_ENV
[ -z "${{ inputs.ios_app_group }}" ] || echo 'IOS_APP_GROUP=${{ inputs.ios_app_group }}' >> $GITHUB_ENV
[ -z "${{ inputs.ios_build_export_method }}" ] || echo 'IOS_BUILD_EXPORT_METHOD=${{ inputs.ios_build_export_method }}' >> $GITHUB_ENV
[ -z "${{ inputs.ios_icloud_container }}" ] || echo 'IOS_ICLOUD_CONTAINER=${{ inputs.ios_icloud_container }}' >> $GITHUB_ENV
[ -z "${{ inputs.main_app_identifier }}" ] || echo 'MAIN_APP_IDENTIFIER=${{ inputs.main_app_identifier }}' >> $GITHUB_ENV
[ -z "${{ inputs.match_app_identifier }}" ] || echo 'MATCH_APP_IDENTIFIER=${{ inputs.match_app_identifier }}' >> $GITHUB_ENV
[ -z "${{ inputs.match_readonly }}" ] || echo 'MATCH_READONLY=${{ inputs.match_readonly }}' >> $GITHUB_ENV
[ -z "${{ inputs.match_shallow_clone }}" ] || echo 'MATCH_SHALLOW_CLONE=${{ inputs.match_shallow_clone }}' >> $GITHUB_ENV
[ -z "${{ inputs.match_skip_docs }}" ] || echo 'MATCH_SKIP_DOCS=${{ inputs.match_skip_docs }}' >> $GITHUB_ENV
[ -z "${{ secrets.match_git_url }}" ] || echo 'MATCH_GIT_URL=${{ secrets.match_git_url }}' >> $GITHUB_ENV
[ -z "${{ secrets.match_password }}" ] || echo 'MATCH_PASSWORD=${{ secrets.match_password }}' >> $GITHUB_ENV
[ -z "${{ inputs.match_type }}" ] || echo 'MATCH_TYPE=${{ inputs.match_type }}' >> $GITHUB_ENV
[ -z "${{ secrets.mattermost_webhook_url }}" ] || echo 'MATTERMOST_WEBHOOK_URL=${{ secrets.mattermost_webhook_url }}' >> $GITHUB_ENV
[ -z "${{ inputs.notification_service_identifier }}" ] || echo 'NOTIFICATION_SERVICE_IDENTIFIER=${{ inputs.notification_service_identifier }}' >> $GITHUB_ENV
[ -z "${{ inputs.node_env }}" ] || echo 'NODE_ENV=${{ inputs.node_env }}' >> $GITHUB_ENV
[ -z "${{ inputs.node_options }}" ] || echo 'NODE_OPTIONS=${{ inputs.node_options }}' >> $GITHUB_ENV
[ -z "${{ inputs.babel_env }}" ] || echo 'BABEL_ENV=${{ inputs.babel_env }}' >> $GITHUB_ENV
[ -z "${{ inputs.pilot_skip_waiting_for_build_processing }}" ] || echo 'PILOT_SKIP_WAITING_FOR_BUILD_PROCESSING=${{ inputs.pilot_skip_waiting_for_build_processing }}' >> $GITHUB_ENV
[ -z "${{ inputs.replace_assets }}" ] || echo 'REPLACE_ASSETS=${{ inputs.replace_assets }}' >> $GITHUB_ENV
[ -z "${{ secrets.sentry_auth_token }}" ] || echo 'SENTRY_AUTH_TOKEN=${{ secrets.sentry_auth_token }}' >> $GITHUB_ENV
[ -z "${{ secrets.sentry_dsn_ios }}" ] || echo 'SENTRY_DSN_IOS=${{ secrets.sentry_dsn_ios }}' >> $GITHUB_ENV
[ -z "${{ inputs.sentry_enabled }}" ] || echo 'SENTRY_ENABLED=${{ inputs.sentry_enabled }}' >> $GITHUB_ENV
[ -z "${{ secrets.sentry_org }}" ] || echo 'SENTRY_ORG=${{ secrets.sentry_org }}' >> $GITHUB_ENV
[ -z "${{ secrets.sentry_project_ios }}" ] || echo 'SENTRY_PROJECT_IOS=${{ secrets.sentry_project_ios }}' >> $GITHUB_ENV
[ -z "${{ inputs.show_onboarding }}" ] || echo 'SHOW_ONBOARDING=${{ inputs.show_onboarding }}' >> $GITHUB_ENV
[ -z "${{ inputs.sync_provisioning_profiles }}" ] || echo 'SYNC_PROVISIONING_PROFILES=${{ inputs.sync_provisioning_profiles }}' >> $GITHUB_ENV
[ -z "${{ inputs.tag }}" ] || echo 'TAG=${{ inputs.tag }}' >> $GITHUB_ENV
- name: ci/output-ssh-private-key
run: |
SSH_KEY_PATH=~/.ssh/id_ed25519
if [ -n "${{ secrets.private_deploy_key }}" ]; then
mkdir -p ~/.ssh
echo -e '${{ secrets.private_deploy_key }}' > ${SSH_KEY_PATH}
chmod 0600 ${SSH_KEY_PATH}
ssh-keygen -y -f ${SSH_KEY_PATH} > ${SSH_KEY_PATH}.pub
fi
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/node-prepare
uses: ./.github/actions/node-prepare
- name: ci/pods-dependencies
uses: ./.github/actions/pods-dependencies
- name: ci/generate-assets
uses: ./.github/actions/generate-assets
- name: ci/fastlane-dependencies
uses: ./.github/actions/fastlane-dependencies
with:
caching_key: ios
# Build iOS app, method depends on the value of `build_type`
- name: ci/build-ios-signed
working-directory: ./fastlane
run: |
HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
export TERM=xterm && bundle exec fastlane ios build
if: "${{ inputs.build_type == 'signed' }}"
- name: ci/build-ios-unsigned
working-directory: ./fastlane
run: |
HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
bundle exec fastlane ios unsigned
if: "${{ inputs.build_type == 'unsigned' }}"
- name: ci/build-ios-simulator
working-directory: ./fastlane
run: |
HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
bundle exec fastlane ios simulator
if: "${{ inputs.build_type == 'simulator' }}"
# Upload the build artifact, its path depends on the value of `build_type`
- name: ci/upload-ios-buld
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
name: "${{ inputs.build_artifact_name }}"
path: "*.ipa"
if: "${{ inputs.build_type == 'signed' || inputs.build_type == 'unsigned' }}"
- name: ci/upload-ios-buld-simulator
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
name: "${{ inputs.build_artifact_name }}"
path: "Mattermost-simulator-x86_64.app.zip"
if: "${{ inputs.build_type == 'simulator' }}"

View File

@@ -1,27 +0,0 @@
---
name: 'deploy-to-store'
on:
workflow_call:
inputs:
build_artifact_name:
required: true
type: string
os:
# Valid values: `ios`, `android`
required: true
type: string
env:
required: false
type: string
jobs:
deploy-to-store:
runs-on: ubuntu-latest
steps:
- name: ci/download-artifact
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
with:
name: "${{ inputs.build_artifact_name }}"
- name: ci/deploy-to-store
working-directory: fastlane
run: ${{ inputs.env }} bundle exec fastlane ${{ inputs.os }} deploy file:$(pwd)/../${{ ( inputs.os == 'ios' && '*.ipa' ) || '*.apk' }}

View File

@@ -1,23 +0,0 @@
---
name: test
on:
workflow_call:
jobs:
test:
runs-on: ubuntu-latest
container:
image: node:16.14.2-bullseye
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/node-prepare
uses: ./.github/actions/node-prepare
- name: ci/generate-assets
uses: ./.github/actions/generate-assets
- name: ci/check-styles
run: npm run check
- name: ci/run-tests
run: npm test
- name: ci/check-i18n
run: ./scripts/precommit/i18n.sh

View File

@@ -1,60 +0,0 @@
---
name: "build-ios-beta"
on:
push:
branches:
# TODO: remove the `gha-` to match the current workflow again
- build-gha-\d+$
- build-gha-ios-\d+$
- build-gha-ios-beta-\d+$
jobs:
build-ios-pr:
uses: ./.github/workflows/.build-ios.yml
with:
build_artifact_name: "ios-build-beta-${{ github.run_id }}"
build_type: "signed"
app_name: "Mattermost Beta"
app_scheme: "mattermost"
aws_bucket_name: "releases.mattermost.com"
aws_folder_name: "mattermost-mobile-beta"
aws_region: "us-east-1"
beta_build: "true"
build_for_release: "true"
extension_app_identifier: "com.mattermost.rnbeta.MattermostShare"
ios_app_group: "group.com.mattermost.rnbeta"
ios_build_export_method: "app-store"
ios_icloud_container: "iCloud.com.mattermost.rnbeta"
main_app_identifier: "com.mattermost.rnbeta"
match_app_identifier: "com.mattermost.rnbeta.NotificationService,com.mattermost.rnbeta.MattermostShare,com.mattermost.rnbeta"
match_readonly: "true"
match_shallow_clone: "true"
match_skip_docs: "true"
match_type: "appstore"
notification_service_identifier: "com.mattermost.rnbeta.NotificationService"
pilot_skip_waiting_for_build_processing: "true"
replace_assets: "false"
sentry_enabled: "true"
show_onboarding: "true"
sync_provisioning_profiles: "true"
secrets:
aws_access_key_id: "${{ secrets.MM_MOBILE_BETA_AWS_ACCESS_KEY_ID }}"
aws_secret_access_key: "${{ secrets.MM_MOBILE_BETA_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_BETA_MATTERMOST_WEBHOOK_URL }}"
private_deploy_key: "${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }}"
sentry_auth_token: "${{ secrets.MM_MOBILE_SENTRY_AUTH_TOKEN }}"
sentry_dsn_ios: "${{ secrets.MM_MOBILE_SENTRY_DSN_IOS }}"
sentry_org: "${{ secrets.MM_MOBILE_SENTRY_ORG }}"
sentry_project_ios: "${{ secrets.MM_MOBILE_SENTRY_PROJECT_IOS }}"
#deploy-to-store:
# uses: ./.github/workflows/.deploy-to-store.yml
# with:
# build_artifact_name: "ios-build-beta-${{ github.run_id }}"
# os: 'ios'
# needs: [build-ios-pr]

View File

@@ -1,49 +0,0 @@
---
name: "build-ios-pr"
on:
push:
branches:
# TODO: remove the `gha-` to match the current workflow again
- 'build-gha-pr-*'
- 'ios-gha-pr-*'
jobs:
build-ios-pr:
uses: ./.github/workflows/.build-ios.yml
with:
branch_to_build: "${{ github.ref_name }}"
build_artifact_name: "ios-build-pr-${{ github.run_id }}"
build_type: "signed"
app_name: "Mattermost Beta"
app_scheme: "mattermost"
aws_bucket_name: "pr-builds.mattermost.com"
aws_folder_name: "mattermost-mobile"
aws_region: "us-east-1"
beta_build: "true"
build_for_release: "true"
build_pr: "true"
extension_app_identifier: "com.mattermost.rnbeta.MattermostShare"
ios_app_group: "group.com.mattermost.rnbeta"
ios_build_export_method: "ad-hoc"
ios_icloud_container: "iCloud.com.mattermost.rnbeta"
main_app_identifier: "com.mattermost.rnbeta"
match_app_identifier: "com.mattermost.rnbeta.NotificationService,com.mattermost.rnbeta.MattermostShare,com.mattermost.rnbeta"
match_readonly: "true"
match_shallow_clone: "true"
match_skip_docs: "true"
match_type: "adhoc"
notification_service_identifier: "com.mattermost.rnbeta.NotificationService"
replace_assets: "false"
show_onboarding: "true"
sync_provisioning_profiles: "true"
secrets:
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 }}"
private_deploy_key: "${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }}"

View File

@@ -1,19 +0,0 @@
---
name: "build-ios-simulator"
on:
push:
branches:
# TODO: remove the `gha-` to match the current workflow again
- 'build-gha-*'
- 'build-ios-sim-gha-*'
jobs:
build-ios-unsigned:
uses: ./.github/workflows/.build-ios.yml
with:
build_artifact_name: "ios-build-simulator-${{ github.run_id }}"
build_type: simulator
node_options: "--max_old_space_size=12000"
tag: "${{ github.ref_name }}"
secrets:
gh_token: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"

View File

@@ -9,13 +9,8 @@ on:
schedule:
- cron: '0 0 * * 0'
permissions:
contents: read
jobs:
analyze:
permissions:
security-events: write
name: Analyze
runs-on: ubuntu-latest
@@ -23,20 +18,26 @@ jobs:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
config-file: ./.github/codeql/codeql-config.yml
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
uses: github/codeql-action/autobuild@v1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v1

View File

@@ -1,36 +0,0 @@
---
name: "github-release-draft"
on:
push:
tags:
# TODO: remove the `gha-` to match the current workflow again
- 'gha-v[0-9]+.[0-9]+.[0-9]+*'
jobs:
build-ios-unsigned:
uses: ./.github/workflows/.build-ios.yml
with:
build_artifact_name: "ios-build-unsigned-${{ github.run_id }}"
build_type: unsigned
node_options: "--max_old_space_size=12000"
tag: "${{ github.ref_name }}"
secrets:
gh_token: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
# TODO add android job here. Also modify github-release to download its artifact, and to depend on it
github-release:
runs-on: ubuntu-20.04 # To use Ruby 2.7, instead of Ruby 3
steps:
- name: ci/fastlane-dependencies
uses: ./.github/actions/fastlane-dependencies
with:
caching_key: github-release
- name: ci/download-android-artifact
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
with:
name: "ios-build-unsigned-${{ github.run_id }}"
- run:
name: Create GitHub release
working_directory: fastlane
command: bundle exec fastlane github
# TODO depend on android job as well
needs: [build-ios-unsigned]

View File

@@ -1,9 +0,0 @@
---
name: test
on:
pull_request:
types: [opened, synchronize]
jobs:
test:
uses: ./.github/workflows/.test.yml

35
.gitignore vendored
View File

@@ -16,7 +16,7 @@ env.d.ts
# Xcode
#
ios/build/*
build/
*.pbxuser
!default.pbxuser
*.mode1v3
@@ -36,7 +36,6 @@ DerivedData
project.xcworkspace
ios/Pods
.podinstall
ios/.xcode.env.local
# Android/IntelliJ
#
@@ -44,13 +43,7 @@ ios/.xcode.env.local
.gradle
local.properties
*.iml
*.hprof
.cxx/
*.keystore
!debug.keystore
android/app/bin
android/app/build
android/build
.settings
.project
.classpath
@@ -63,6 +56,12 @@ npm-debug.log
yarn-error.log
.yarninstall
# BUCK
buck-out/
\.buckd/
android/app/libs
*.keystore
# Vim
[._]*.s[a-w][a-z]
[._]s[a-w][a-z]
@@ -79,11 +78,11 @@ tags
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/
**/fastlane/report.xml
**/fastlane/Preview.html
**/fastlane/screenshots
**/fastlane/test_output
**/fastlane/.env
*/fastlane/report.xml
*/fastlane/Preview.html
*/fastlane/screenshots
fastlane/.env
fastlane/report.xml
# Sentry
android/sentry.properties
@@ -98,19 +97,11 @@ coverage
mattermost-license.txt
*.mattermost-license
detox/artifacts
detox/detox_pixel_*
detox/detox_pixel_4_xl_api_30
# Bundle artifact
*.jsbundle
.bundle
#editor-settings
.vscode
.scannerwork
launch.json
# Notice.txt generation
!build/notice-file
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*

View File

@@ -1,72 +0,0 @@
{
"$schema": "http://json.schemastore.org/solidaritySchema",
"config" : {
"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",
"error": "The ANDROID_HOME environment variable must be set to your local SDK. Refer to getting started docs for help."
}
],
"iOS": [
{
"rule": "cli",
"binary": "watchman",
"error": "install watchman `brew install watchman`",
"platform": "darwin"
},
{
"rule": "cli",
"binary": "xcodebuild",
"semver": ">=13.0",
"error": "install xcode",
"platform": "darwin"
},
{
"rule": "cli",
"binary": "ruby",
"semver": ">=2.7.1 <3.0.0",
"error": "visit rvm install https://rvm.io/rvm/install",
"platform": "darwin"
},
{
"rule": "cli",
"binary": "pod",
"semver": "1.11.3",
"platform": "darwin"
}
],
"Git email": [
{
"rule": "shell",
"command": "git config user.email",
"match": ".+@.+"
}
]
}
}

6
.storybook/main.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
"stories": [
"../app/components/**/*.stories.mdx",
"../app/components/**/*.stories.@(js|jsx|ts|tsx)"
],
}

5
.storybook/preview.js Normal file
View File

@@ -0,0 +1,5 @@
export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
}

View File

@@ -1,4 +1,4 @@
Submit feature requests to https://portal.productboard.com/mattermost/33-what-matters-to-you. 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.

1865
NOTICE.txt

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,12 @@
# Mattermost Mobile v2
- **Minimum Server versions:** Current ESR version (7.1.0+)
- **Supported iOS versions:** 12.1+
This is a work in progress branch for the next major version of the Mattermost mobile app. Once the work is completed and ready to share, this brach will be set as the default branch in this repository.
- **Minimum Server versions:** Current ESR version (5.25)
- **Supported iOS versions:** 11+
- **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://about.mattermost.com](https://about.mattermost.com).
Mattermost is an open source Slack-alternative used by thousands of companies around the world in 14 languages. Learn more at [https://about.mattermost.com](https://about.mattermost.com).
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/).
@@ -49,9 +51,15 @@ You can leave the Beta testing program at any time:
App data is wiped from the device when a user logs out of the app. If the user is logged in when the account is deactivated, then within one minute the system logs the user out, and as a result all app data is wiped from the device.
### I need the code for the v1 version
### Can I connect to multiple Mattermost servers using the mobile apps?
You can still access it! We have moved the code from master to the [v1 branch](https://github.com/mattermost/mattermost-mobile/tree/v1). Be aware that we will not be providing any more v1 versions or updates in the public stores.
At the moment, we only support connecting to one server at a time. If you need to connect to multiple servers, please [upvote the feature request](https://mattermost.uservoice.com/forums/306457/suggestions/10975938) so we can track demand for it.
As a work around, you can install both the released "Mattermost" app and sign up to be a [tester](#testing) for the "Mattermost Beta" app so you can connect to two servers at once.
### Will there be second generation apps available for tablets?
We plan to add support for tablets in the future, but the timeline depends on how many people have a need for it. If you're looking for a tablet version, please help us out by [upvoting the feature request](https://mattermost.uservoice.com/forums/306457/suggestions/20082079)!
# Troubleshooting

65
android/app/BUCK Normal file
View File

@@ -0,0 +1,65 @@
# To learn about Buck see [Docs](https://buckbuild.com/).
# To run your application with Buck:
# - install Buck
# - `npm start` - to start the packager
# - `cd android`
# - `keytool -genkey -v -keystore keystores/debug.keystore -storepass android -alias androiddebugkey -keypass android -dname "CN=Android Debug,O=Android,C=US"`
# - `./gradlew :app:copyDownloadableDepsToLibs` - make all Gradle compile dependencies available to Buck
# - `buck install -r android/app` - compile, install and run application
#
lib_deps = []
for jarfile in glob(['libs/*.jar']):
name = 'jars__' + jarfile[jarfile.rindex('/') + 1: jarfile.rindex('.jar')]
lib_deps.append(':' + name)
prebuilt_jar(
name = name,
binary_jar = jarfile,
)
for aarfile in glob(['libs/*.aar']):
name = 'aars__' + aarfile[aarfile.rindex('/') + 1: aarfile.rindex('.aar')]
lib_deps.append(':' + name)
android_prebuilt_aar(
name = name,
aar = aarfile,
)
android_library(
name = "all-libs",
exported_deps = lib_deps,
)
android_library(
name = "app-code",
srcs = glob([
"src/main/java/**/*.java",
]),
deps = [
":all-libs",
":build_config",
":res",
],
)
android_build_config(
name = "build_config",
package = "com.mattermost.rnbeta",
)
android_resource(
name = "res",
package = "com.mattermost.rnbeta",
res = "src/main/res",
)
android_binary(
name = "app",
keystore = "//android/keystores:debug",
manifest = "src/main/AndroidManifest.xml",
package_type = "debug",
deps = [
":app-code",
],
)

View File

@@ -1,56 +1,87 @@
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.
* By default you don't need to apply any configuration, just uncomment the lines you need.
* The react.gradle file registers a task for each build variant (e.g. bundleDebugJsAndAssets
* and bundleReleaseJsAndAssets).
* These basically call `react-native bundle` with the correct arguments during the Android build
* cycle. By default, bundleDebugJsAndAssets is skipped, as in debug/dev mode we prefer to load the
* bundle directly from the development server. Below you can see all the possible configurations
* and their defaults. If you decide to add a configuration block, make sure to add it before the
* `apply from: "../../node_modules/react-native/react.gradle"` line.
*
* project.ext.react = [
* // the name of the generated asset file containing your JS bundle
* bundleAssetName: "index.android.bundle",
*
* // the entry file for bundle generation. If none specified and
* // "index.android.js" exists, it will be used. Otherwise "index.js" is
* // default. Can be overridden with ENTRY_FILE environment variable.
* entryFile: "index.android.js",
*
* // whether to bundle JS and assets in debug mode
* bundleInDebug: false,
*
* // whether to bundle JS and assets in release mode
* bundleInRelease: true,
*
* // whether to bundle JS and assets in another build variant (if configured).
* // See http://tools.android.com/tech-docs/new-build-system/user-guide#TOC-Build-Variants
* // The configuration property can be in the following formats
* // 'bundleIn${productFlavor}${buildType}'
* // 'bundleIn${buildType}'
* // bundleInFreeDebug: true,
* // bundleInPaidRelease: true,
* // bundleInBeta: true,
*
* // whether to disable dev mode in custom build variants (by default only disabled in release)
* // for example: to disable dev mode in the staging build type (if configured)
* devDisabledInStaging: true,
* // The configuration property can be in the following formats
* // 'devDisabledIn${productFlavor}${buildType}'
* // 'devDisabledIn${buildType}'
*
* // the root of your project, i.e. where "package.json" lives
* root: "../../",
*
* // where to put the JS bundle asset in debug mode
* jsBundleDirDebug: "$buildDir/intermediates/assets/debug",
*
* // where to put the JS bundle asset in release mode
* jsBundleDirRelease: "$buildDir/intermediates/assets/release",
*
* // where to put drawable resources / React Native assets, e.g. the ones you use via
* // require('./image.png')), in debug mode
* resourcesDirDebug: "$buildDir/intermediates/res/merged/debug",
*
* // where to put drawable resources / React Native assets, e.g. the ones you use via
* // require('./image.png')), in release mode
* resourcesDirRelease: "$buildDir/intermediates/res/merged/release",
*
* // by default the gradle tasks are skipped if none of the JS files or assets change; this means
* // that we don't look at files in android/ or ios/ to determine whether the tasks are up to
* // date; if you have any other folders that you want to ignore for performance reasons (gradle
* // indexes the entire tree), add them here. Alternatively, if you have JS files in android/
* // for example, you might want to remove it from here.
* inputExcludes: ["android/**", "ios/**"],
*
* // override which node gets called and with what additional arguments
* nodeExecutableAndArgs: ["node"],
*
* // supply additional arguments to the packager
* extraPackagerArgs: []
* ]
*/
react {
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '..'
// 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 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 */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The command to run when bundling. By default is 'bundle'
// bundleCommand = "ram-bundle"
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
entryFile = file("../../index.ts")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
}
project.ext.react = [
entryFile: "index.ts",
bundleConfig: "metro.config.js",
bundleCommand: "bundle",
enableHermes: true,
]
apply from: "../../node_modules/react-native/react.gradle"
apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"
if (System.getenv("SENTRY_ENABLED") == "true") {
@@ -63,35 +94,33 @@ if (System.getenv("SENTRY_ENABLED") == "true") {
}
/**
* Set this to true to create four separate APKs instead of one,
* one for each native architecture. This is useful if you don't
* use App Bundles (https://developer.android.com/guide/app-bundle/)
* and want to have separate APKs to upload to the Play Store
* Set this to true to create two separate APKs instead of one:
* - An APK that only works on ARM devices
* - An APK that only works on x86 devices
* The advantage is the size of the APK is reduced by about 4MB.
* Upload all the APKs to the Play Store and people will download
* the correct one based on the CPU architecture of their device.
*/
def enableSeparateBuildPerCPUArchitecture = project.hasProperty('separateApk') ? project.property('separateApk').toBoolean() : false
/**
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
* Run Proguard to shrink the Java bytecode in release builds.
*/
def enableProguardInReleaseBuilds = false
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'org.webkit:android-jsc-intl:+'
/**
* Private function to get the list of Native Architectures you want to build.
* This reads the value from reactNativeArchitectures in your gradle.properties
* file and works together with the --active-arch-only flag of react-native run-android.
* Whether to enable the Hermes VM.
*
* This should be set on project.ext.react and that value will be read here. If it is not set
* on project.ext.react, JavaScript will not be compiled to Hermes Bytecode
* and the benefits of using Hermes will therefore be sharply reduced.
*/
def enableHermes = project.ext.react.get("enableHermes", false);
/**
* Architectures to build native code for.
*/
def reactNativeArchitectures() {
def value = project.getProperties().get("reactNativeArchitectures")
@@ -101,21 +130,86 @@ def reactNativeArchitectures() {
android {
ndkVersion rootProject.ext.ndkVersion
compileSdkVersion rootProject.ext.compileSdkVersion
namespace "com.mattermost.rnbeta"
lintOptions {
checkReleaseBuilds false
abortOnError false
}
packagingOptions {
pickFirst '**/libc++_shared.so'
}
defaultConfig {
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 454
versionCode 393
versionName "2.0.0"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
if (isNewArchitectureEnabled()) {
// We configure the NDK build only if you decide to opt-in for the New Architecture.
externalNativeBuild {
ndkBuild {
arguments "APP_PLATFORM=android-21",
"APP_STL=c++_shared",
"NDK_TOOLCHAIN_VERSION=clang",
"GENERATED_SRC_DIR=$buildDir/generated/source",
"PROJECT_BUILD_DIR=$buildDir",
"REACT_ANDROID_DIR=$rootDir/../node_modules/react-native/ReactAndroid",
"REACT_ANDROID_BUILD_DIR=$rootDir/../node_modules/react-native/ReactAndroid/build"
cFlags "-Wall", "-Werror", "-fexceptions", "-frtti", "-DWITH_INSPECTOR=1"
cppFlags "-std=c++17"
// Make sure this target name is the same you specify inside the
// src/main/jni/Android.mk file for the `LOCAL_MODULE` variable.
targets "rndiffapp_appmodules"
}
}
}
}
if (isNewArchitectureEnabled()) {
// We configure the NDK build only if you decide to opt-in for the New Architecture.
externalNativeBuild {
ndkBuild {
path "$projectDir/src/main/jni/Android.mk"
}
}
def reactAndroidProjectDir = project(':ReactAndroid').projectDir
def packageReactNdkDebugLibs = tasks.register("packageReactNdkDebugLibs", Copy) {
dependsOn(":ReactAndroid:packageReactNdkDebugLibsForBuck")
from("$reactAndroidProjectDir/src/main/jni/prebuilt/lib")
into("$buildDir/react-ndk/exported")
}
def packageReactNdkReleaseLibs = tasks.register("packageReactNdkReleaseLibs", Copy) {
dependsOn(":ReactAndroid:packageReactNdkReleaseLibsForBuck")
from("$reactAndroidProjectDir/src/main/jni/prebuilt/lib")
into("$buildDir/react-ndk/exported")
}
afterEvaluate {
// If you wish to add a custom TurboModule or component locally,
// you should uncomment this line.
// preBuild.dependsOn("generateCodegenArtifactsFromSchema")
preDebugBuild.dependsOn(packageReactNdkDebugLibs)
preReleaseBuild.dependsOn(packageReactNdkReleaseLibs)
// Due to a bug inside AGP, we have to explicitly set a dependency
// between configureNdkBuild* tasks and the preBuild tasks.
// This can be removed once this is solved: https://issuetracker.google.com/issues/207403732
configureNdkBuildRelease.dependsOn(preReleaseBuild)
configureNdkBuildDebug.dependsOn(preDebugBuild)
reactNativeArchitectures().each { architecture ->
tasks.findByName("configureNdkBuildDebug[${architecture}]")?.configure {
dependsOn("preDebugBuild")
}
tasks.findByName("configureNdkBuildRelease[${architecture}]")?.configure {
dependsOn("preReleaseBuild")
}
}
}
}
signingConfigs {
@@ -188,40 +282,67 @@ repositories {
}
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
implementation fileTree(dir: "libs", include: ["*.jar"])
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0")
//noinspection GradleDynamicVersio
implementation "com.facebook.react:react-native:+" // From node_modules
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}")
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
implementation project(':lottie-react-native')
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") {
exclude group:'com.facebook.fbjni'
}
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
exclude group:'com.squareup.okhttp3', module:'okhttp'
exclude group:'com.facebook.flipper'
}
debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}") {
exclude group:'com.facebook.flipper'
}
debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}")
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
if (enableHermes) {
def hermesPath = "../../node_modules/hermes-engine/android/";
debugImplementation files(hermesPath + "hermes-debug.aar")
releaseImplementation files(hermesPath + "hermes-release.aar")
unsignedImplementation files(hermesPath + "hermes-release.aar")
} else {
implementation jscFlavor
}
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-rxjava3:1.0.0'
implementation 'androidx.window:window:1.0.0'
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation 'androidx.appcompat:appcompat:1.0.0'
implementation 'com.google.android.material:material:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation project(':reactnativenotifications')
implementation "com.google.firebase:firebase-messaging:$firebaseVersion"
// For animated GIF support
implementation 'com.facebook.fresco:fresco:2.0.0'
implementation 'com.facebook.fresco:animated-gif:2.0.0'
// For WebP support, including animated WebP
implementation 'com.facebook.fresco:animated-webp:2.0.0'
implementation 'com.facebook.fresco:webpsupport:2.0.0'
androidTestImplementation('com.wix:detox:+')
implementation project(':reactnativenotifications')
implementation project(':watermelondb-jsi')
}
configurations.all {
if (isNewArchitectureEnabled()) {
// If new architecture is enabled, we let you build RN from source
// Otherwise we fallback to a prebuilt .aar bundled in the NPM package.
// This will be applied to all the imported transtitive dependency.
resolutionStrategy.dependencySubstitution {
substitute(module("com.facebook.react:react-native"))
.using(project(":ReactAndroid")).because("On New Architecture we're building React Native from source")
}
}
resolutionStrategy {
force "com.facebook.soloader:soloader:0.10.1"
eachDependency { DependencyResolveDetails details ->
if (details.requested.name == 'play-services-base') {
details.useTarget group: details.requested.group, name: details.requested.name, version: '15.0.1'
@@ -236,13 +357,13 @@ configurations.all {
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.10.0'
details.useTarget group: details.requested.group, name: details.requested.name, version: '4.9.2'
}
if (details.requested.name == 'okhttp-tls') {
details.useTarget group: details.requested.group, name: details.requested.name, version: '4.10.0'
details.useTarget group: details.requested.group, name: details.requested.name, version: '4.9.2'
}
if (details.requested.name == 'okhttp-urlconnection') {
details.useTarget group: details.requested.group, name: details.requested.name, version: '4.10.0'
details.useTarget group: details.requested.group, name: details.requested.name, version: '4.9.2'
}
}
}
@@ -257,3 +378,11 @@ task copyDownloadableDepsToLibs(type: Copy) {
apply plugin: 'com.google.gms.google-services'
apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
def isNewArchitectureEnabled() {
// To opt-in for the New Architecture, you can either:
// - Set `newArchEnabled` to true inside the `gradle.properties` file
// - Invoke gradle with `-newArchEnabled=true`
// - Set an environment variable `ORG_GRADLE_PROJECT_newArchEnabled=true`
return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true"
}

Binary file not shown.

View File

@@ -1,7 +1,6 @@
package com.mattermost.rnbeta;
import com.wix.detox.Detox;
import com.wix.detox.config.DetoxConfig;
import org.junit.Rule;
import org.junit.Test;
@@ -20,11 +19,10 @@ public class DetoxTest {
@Test
public void runDetoxTests() {
DetoxConfig detoxConfig = new DetoxConfig();
detoxConfig.idlePolicyConfig.masterTimeoutSec = 90;
detoxConfig.idlePolicyConfig.idleResourceTimeoutSec = 60;
detoxConfig.rnContextLoadTimeoutSec = (BuildConfig.DEBUG ? 180 : 60);
Detox.DetoxIdlePolicyConfig idlePolicyConfig = new Detox.DetoxIdlePolicyConfig();
idlePolicyConfig.masterTimeoutSec = 60;
idlePolicyConfig.idleResourceTimeoutSec = 30;
Detox.runTests(mActivityRule, detoxConfig);
Detox.runTests(mActivityRule, idlePolicyConfig);
}
}

View File

@@ -4,7 +4,7 @@
* <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;
package com.rn;
import android.content.Context;
import com.facebook.flipper.android.AndroidFlipperClient;
@@ -17,22 +17,19 @@ import com.facebook.flipper.plugins.inspector.DescriptorMapping;
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin;
import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor;
import com.facebook.flipper.plugins.network.NetworkFlipperPlugin;
import com.facebook.flipper.plugins.react.ReactFlipperPlugin;
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin;
import com.facebook.react.ReactInstanceEventListener;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.modules.network.NetworkingModule;
import okhttp3.OkHttpClient;
/**
* Class responsible of loading Flipper inside your React Native application. This is the debug
* flavor of it. Here you can add your own plugins and customize the Flipper setup.
*/
public class ReactNativeFlipper {
public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
if (FlipperUtils.shouldEnableFlipper(context)) {
final FlipperClient client = AndroidFlipperClient.getInstance(context);
client.addPlugin(new InspectorFlipperPlugin(context, DescriptorMapping.withDefaults()));
client.addPlugin(new ReactFlipperPlugin());
client.addPlugin(new DatabasesFlipperPlugin(context));
client.addPlugin(new SharedPreferencesFlipperPlugin(context));
client.addPlugin(CrashReporterPlugin.getInstance());

View File

@@ -1,25 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.mattermost.rnbeta">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission-sdk-23 android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<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" />
<!-- Request legacy Bluetooth permissions on older devices. -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<queries>
<intent>
@@ -83,23 +71,5 @@
android:resizeableActivity="true"
android:exported="true"
/>
<activity
android:name="com.mattermost.share.ShareActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:windowSoftInputMode="adjustResize"
android:label="@string/app_name"
android:screenOrientation="portrait"
android:theme="@style/AppTheme"
android:taskAffinity="com.mattermost.share"
android:exported="true"
>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<!-- for sharing-->
<data android:mimeType="*/*" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -46,9 +46,6 @@ public class CustomPushNotificationHelper {
public static final int MESSAGE_NOTIFICATION_ID = 435345;
public static final String NOTIFICATION_ID = "notificationId";
public static final String NOTIFICATION = "notification";
public static final String PUSH_TYPE_MESSAGE = "message";
public static final String PUSH_TYPE_CLEAR = "clear";
public static final String PUSH_TYPE_SESSION = "session";
private static NotificationChannel mHighImportanceChannel;
private static NotificationChannel mMinImportanceChannel;
@@ -57,34 +54,40 @@ public class CustomPushNotificationHelper {
String message = bundle.getString("message", bundle.getString("body"));
String senderId = bundle.getString("sender_id");
String serverUrl = bundle.getString("server_url");
String type = bundle.getString("type");
String urlOverride = bundle.getString("override_icon_url");
if (senderId == null) {
senderId = "sender_id";
}
Bundle userInfoBundle = bundle.getBundle("userInfo");
String senderName = getSenderName(bundle);
if (userInfoBundle != null) {
boolean localPushNotificationTest = userInfoBundle.getBoolean("test");
if (localPushNotificationTest) {
senderName = "Test";
}
}
if (conversationTitle == null || !android.text.TextUtils.isEmpty(senderName.trim())) {
message = removeSenderNameFromMessage(message, senderName);
}
long timestamp = new Date().getTime();
Person.Builder sender = new Person.Builder()
.setKey(senderId)
.setName(senderName);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
messagingStyle.addMessage(message, timestamp, senderName);
} else {
Person.Builder sender = new Person.Builder()
.setKey(senderId)
.setName(senderName);
if (serverUrl != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) {
try {
Bitmap avatar = userAvatar(context, serverUrl, senderId, urlOverride);
if (avatar != null) {
sender.setIcon(IconCompat.createWithBitmap(avatar));
if (serverUrl != null) {
try {
sender.setIcon(IconCompat.createWithBitmap(Objects.requireNonNull(userAvatar(context, serverUrl, senderId))));
} catch (IOException e) {
e.printStackTrace();
}
} catch (IOException e) {
e.printStackTrace();
}
}
messagingStyle.addMessage(message, timestamp, sender.build());
messagingStyle.addMessage(message, timestamp, sender.build());
}
}
private static void addNotificationExtras(NotificationCompat.Builder notification, Bundle bundle) {
@@ -98,26 +101,6 @@ public class CustomPushNotificationHelper {
userInfoBundle.putString("channel_id", channelId);
}
String postId = bundle.getString("post_id");
if (postId != null) {
userInfoBundle.putString("post_id", postId);
}
String rootId = bundle.getString("root_id");
if (rootId != null) {
userInfoBundle.putString("root_id", rootId);
}
String crtEnabled = bundle.getString("is_crt_enabled");
if (crtEnabled != null) {
userInfoBundle.putString("is_crt_enabled", crtEnabled);
}
String serverUrl = bundle.getString("server_url");
if (serverUrl != null) {
userInfoBundle.putString("server_url", serverUrl);
}
notification.addExtras(userInfoBundle);
}
@@ -134,20 +117,12 @@ public class CustomPushNotificationHelper {
replyIntent.putExtra(NOTIFICATION_ID, notificationId);
replyIntent.putExtra(NOTIFICATION, bundle);
PendingIntent replyPendingIntent;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
replyPendingIntent = PendingIntent.getBroadcast(
context,
notificationId,
replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
} else {
replyPendingIntent = PendingIntent.getBroadcast(
context,
notificationId,
replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT);
}
@SuppressLint("UnspecifiedImmutableFlag")
PendingIntent replyPendingIntent = PendingIntent.getBroadcast(
context,
notificationId,
replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT);
RemoteInput remoteInput = new RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel("Reply")
@@ -170,19 +145,15 @@ public class CustomPushNotificationHelper {
String channelId = bundle.getString("channel_id");
String postId = bundle.getString("post_id");
String rootId = bundle.getString("root_id");
int notificationId = postId != null ? postId.hashCode() : MESSAGE_NOTIFICATION_ID;
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);
setNotificationGroup(notification, groupId, createSummary);
setNotificationGroup(notification, channelId, createSummary);
setNotificationBadgeType(notification);
setNotificationChannel(context, notification, bundle);
setNotificationChannel(notification, bundle);
setNotificationDeleteIntent(context, notification, bundle, notificationId);
addNotificationReplyAction(context, notification, bundle, notificationId);
@@ -247,10 +218,6 @@ public class CustomPushNotificationHelper {
title = bundle.getString("sender_name");
}
if (android.text.TextUtils.isEmpty(title)) {
title = bundle.getString("title", "");
}
return title;
}
@@ -264,27 +231,26 @@ public class CustomPushNotificationHelper {
private static NotificationCompat.MessagingStyle getMessagingStyle(Context context, Bundle bundle) {
NotificationCompat.MessagingStyle messagingStyle;
final String senderId = "me";
String senderId = "me";
final String serverUrl = bundle.getString("server_url");
final String type = bundle.getString("type");
String urlOverride = bundle.getString("override_icon_url");
Person.Builder sender = new Person.Builder()
.setKey(senderId)
.setName("Me");
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
messagingStyle = new NotificationCompat.MessagingStyle("Me");
} else {
Person.Builder sender = new Person.Builder()
.setKey(senderId)
.setName("Me");
if (serverUrl != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) {
try {
Bitmap avatar = userAvatar(context, serverUrl, "me", urlOverride);
if (avatar != null) {
sender.setIcon(IconCompat.createWithBitmap(avatar));
if (serverUrl != null) {
try {
sender.setIcon(IconCompat.createWithBitmap(Objects.requireNonNull(userAvatar(context, serverUrl, "me"))));
} catch (IOException e) {
e.printStackTrace();
}
} catch (IOException e) {
e.printStackTrace();
}
}
messagingStyle = new NotificationCompat.MessagingStyle(sender.build());
messagingStyle = new NotificationCompat.MessagingStyle(sender.build());
}
String conversationTitle = getConversationTitle(bundle);
setMessagingStyleConversationTitle(messagingStyle, conversationTitle, bundle);
@@ -365,16 +331,26 @@ public class CustomPushNotificationHelper {
}
}
private static void setNotificationChannel(Context context, NotificationCompat.Builder notification, Bundle bundle) {
private static void setNotificationChannel(NotificationCompat.Builder notification, Bundle bundle) {
// If Android Oreo or above we need to register a channel
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
if (mHighImportanceChannel == null) {
createNotificationChannels(context);
}
NotificationChannel notificationChannel = mHighImportanceChannel;
boolean testNotification = false;
boolean localNotification = false;
Bundle userInfoBundle = bundle.getBundle("userInfo");
if (userInfoBundle != null) {
testNotification = userInfoBundle.getBoolean("test");
localNotification = userInfoBundle.getBoolean("local");
}
if (testNotification || localNotification) {
notificationChannel = mMinImportanceChannel;
}
notification.setChannelId(notificationChannel.getId());
}
@@ -385,7 +361,7 @@ public class CustomPushNotificationHelper {
delIntent.putExtra(NOTIFICATION_ID, notificationId);
delIntent.putExtra(PUSH_NOTIFICATION_EXTRA_NAME, bundle);
@SuppressLint("UnspecifiedImmutableFlag")
PendingIntent deleteIntent = PendingIntent.getService(context, (int) System.currentTimeMillis(), delIntent, PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE);
PendingIntent deleteIntent = PendingIntent.getService(context, (int) System.currentTimeMillis(), delIntent, PendingIntent.FLAG_ONE_SHOT);
notification.setDeleteIntent(deleteIntent);
}
@@ -408,7 +384,6 @@ public class CustomPushNotificationHelper {
String channelName = getConversationTitle(bundle);
String senderName = bundle.getString("sender_name");
String serverUrl = bundle.getString("server_url");
String urlOverride = bundle.getString("override_icon_url");
int smallIconResId = getSmallIconResourceId(context, smallIcon);
notification.setSmallIcon(smallIconResId);
@@ -416,46 +391,33 @@ public class CustomPushNotificationHelper {
if (serverUrl != null && channelName.equals(senderName)) {
try {
String senderId = bundle.getString("sender_id");
Bitmap avatar = userAvatar(context, serverUrl, senderId, urlOverride);
if (avatar != null) {
notification.setLargeIcon(avatar);
}
notification.setLargeIcon(userAvatar(context, serverUrl, senderId));
} catch (IOException e) {
e.printStackTrace();
}
}
}
private static Bitmap userAvatar(Context context, final String serverUrl, final String userId, final String urlOverride) throws IOException {
try {
final OkHttpClient client = new OkHttpClient();
Request request;
String url;
if (!TextUtils.isEmpty(urlOverride)) {
request = new Request.Builder().url(urlOverride).build();
Log.i("ReactNative", String.format("Fetch override profile image %s", urlOverride));
} else {
final ReactApplicationContext reactApplicationContext = new ReactApplicationContext(context);
final String token = Credentials.getCredentialsForServerSync(reactApplicationContext, serverUrl);
url = String.format("%s/api/v4/users/%s/image", serverUrl, userId);
Log.i("ReactNative", String.format("Fetch profile image %s", url));
request = new Request.Builder()
.header("Authorization", String.format("Bearer %s", token))
.url(url)
.build();
}
Response response = client.newCall(request).execute();
if (response.code() == 200) {
assert response.body() != null;
byte[] bytes = Objects.requireNonNull(response.body()).bytes();
Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
return getCircleBitmap(bitmap);
}
private static Bitmap userAvatar(Context context, final String serverUrl, final String userId) throws IOException {
final ReactApplicationContext reactApplicationContext = new ReactApplicationContext(context);
final String token = Credentials.getCredentialsForServerSync(reactApplicationContext, serverUrl);
return null;
} catch (Exception e) {
e.printStackTrace();
return null;
final OkHttpClient client = new OkHttpClient();
final String url = String.format("%s/api/v4/users/%s/image", serverUrl, userId);
Request request = new Request.Builder()
.header("Authorization", String.format("Bearer %s", token))
.url(url)
.build();
Response response = client.newCall(request).execute();
if (response.code() == 200) {
assert response.body() != null;
byte[] bytes = Objects.requireNonNull(response.body()).bytes();
Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
Log.i("ReactNative", String.format("Fetch profile %s", url));
return getCircleBitmap(bitmap);
}
return null;
}
}

View File

@@ -16,22 +16,18 @@ import java.lang.Exception
import java.util.*
class DatabaseHelper {
private var defaultDatabase: Database? = null
var defaultDatabase: Database? = null
private set
val onlyServerUrl: String?
get() {
try {
val query = "SELECT url FROM Servers WHERE last_active_at != 0 AND identifier != ''"
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()
// let it fall to return null
val query = "SELECT url FROM Servers WHERE last_active_at != 0 AND identifier != ''"
val cursor = defaultDatabase!!.rawQuery(query)
if (cursor.count == 1) {
cursor.moveToFirst()
val url = cursor.getString(0)
cursor.close()
return url
}
return null
}
@@ -43,19 +39,14 @@ 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
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
}
return null
}
@@ -73,40 +64,31 @@ class DatabaseHelper {
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
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!!)
}
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())
val args = TextUtils.join(",", Arrays.stream(ids).map { value: String? -> "?" }.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))
}
list.add(cursor.getString(cursor.getColumnIndex("id")))
}
}
}
@@ -118,15 +100,12 @@ class DatabaseHelper {
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())
val args = TextUtils.join(",", Arrays.stream(values).map { value: Any? -> "?" }.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))
}
list.add(cursor.getString(cursor.getColumnIndex(columnName)))
}
}
}
@@ -141,7 +120,7 @@ class DatabaseHelper {
return result.getString("value")
}
private fun queryLastPostCreateAt(db: Database?, channelId: String): Double? {
fun queryPostSinceForChannel(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))
@@ -163,87 +142,54 @@ class DatabaseHelper {
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) {
fun handlePosts(db: Database, postsData: ReadableMap?, channelId: String) {
// 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)
val sortedPosts = posts.toList().sortedBy { (_, value) ->
((value as Map<*, *>).get("create_at") as Double)
}
sortedPosts.forEachIndexed { index, it ->
val key = it.first
if (it.second != null) {
val post = it.second as MutableMap<String, Any?>
val post = (it.second as MutableMap<String, Any?>)
if (index == 0) {
post.putIfAbsent("prev_post_id", previousPostId)
} else if (prevPostId.isNotEmpty()) {
} else if (!prevPostId.isNullOrEmpty()) {
post.putIfAbsent("prev_post_id", prevPostId)
}
if (lastId == key) {
earliest = post["create_at"] as Double
earliest = post.get("create_at") as Double
}
if (firstId == key) {
latest = post["create_at"] as Double
latest = post.get("create_at") as Double
}
val jsonPost = JSONObject(post)
val rootId = post["root_id"] as? String
val rootId = post.get("root_id") as? String
if (!rootId.isNullOrEmpty()) {
var thread = postsInThread[rootId]?.toMutableList()
var thread = postsInThread.get(rootId)?.toMutableList()
if (thread == null) {
thread = mutableListOf()
}
thread.add(jsonPost)
postsInThread[rootId] = thread.toList()
postsInThread.put(rootId, thread.toList())
}
if (find(db, "Post", key) == null) {
@@ -259,39 +205,11 @@ class DatabaseHelper {
}
}
if (!receivingThreads) {
handlePostsInChannel(db, channelId, earliest, latest)
updateMyChannelLastFetchedAt(db, channelId, lastFetchedAt)
}
handlePostsInChannel(db, channelId, earliest, latest)
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)
@@ -302,8 +220,6 @@ class DatabaseHelper {
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, " +
@@ -319,7 +235,7 @@ class DatabaseHelper {
isBot,
roles.contains("system_guest"),
user.getString("last_name"),
lastPictureUpdate,
user.getDouble("last_picture_update"),
user.getString("locale"),
user.getString("nickname"),
user.getString("position"),
@@ -341,7 +257,7 @@ class DatabaseHelper {
}
private fun insertPost(db: Database, post: JSONObject) {
var metadata: JSONObject?
var metadata: JSONObject? = null
var reactions: JSONArray? = null
var customEmojis: JSONArray? = null
var files: JSONArray? = null
@@ -395,7 +311,7 @@ class DatabaseHelper {
}
private fun updatePost(db: Database, post: JSONObject) {
var metadata: JSONObject?
var metadata: JSONObject? = null
var reactions: JSONArray? = null
var customEmojis: JSONArray? = null
@@ -444,71 +360,6 @@ class DatabaseHelper {
}
}
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)
@@ -527,9 +378,9 @@ class DatabaseHelper {
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 }
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')",
@@ -599,25 +450,15 @@ class DatabaseHelper {
}
}
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 chunk;
}
}
return null
return null;
}
private fun insertPostInChannel(db: Database, channelId: String, earliest: Double, latest: Double): ReadableMap {
@@ -675,7 +516,7 @@ class DatabaseHelper {
}
}
private fun JSONObject.toMap(): Map<String, *> = keys().asSequence().associateWith { it ->
private fun JSONObject.toMap(): Map<String, *> = keys().asSequence().associateWith {
when (val value = this[it])
{
is JSONArray ->

View File

@@ -0,0 +1,68 @@
package com.mattermost.helpers;
import com.facebook.react.bridge.Dynamic;
import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.ReadableType;
import java.util.ArrayList;
/**
* KeysReadableArray: Helper class that abstracts boilerplate
*/
public class KeysReadableArray implements ReadableArray {
@Override
public int size() {
return 0;
}
@Override
public boolean isNull(int index) {
return false;
}
@Override
public boolean getBoolean(int index) {
return false;
}
@Override
public double getDouble(int index) {
return 0;
}
@Override
public int getInt(int index) {
return 0;
}
@Override
public String getString(int index) {
return null;
}
@Override
public ReadableArray getArray(int index) {
return null;
}
@Override
public ReadableMap getMap(int index) {
return null;
}
@Override
public Dynamic getDynamic(int index) {
return null;
}
@Override
public ReadableType getType(int index) {
return null;
}
@Override
public ArrayList<Object> toArrayList() {
return null;
}
}

View File

@@ -1,312 +0,0 @@
package com.mattermost.helpers;
import android.app.Notification;
import android.app.NotificationManager;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.pm.PackageInfo;
import android.os.Bundle;
import android.service.notification.StatusBarNotification;
import androidx.core.app.NotificationManagerCompat;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Objects;
public class NotificationHelper {
public static final String PUSH_NOTIFICATIONS = "PUSH_NOTIFICATIONS";
public static final String NOTIFICATIONS_IN_GROUP = "notificationsInGroup";
private static final String VERSION_PREFERENCE = "VERSION_PREFERENCE";
public static void cleanNotificationPreferencesIfNeeded(Context context) {
try {
PackageInfo pInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 0);
String version = String.valueOf(pInfo.versionCode);
String storedVersion = null;
SharedPreferences pSharedPref = context.getSharedPreferences(VERSION_PREFERENCE, Context.MODE_PRIVATE);
if (pSharedPref != null) {
storedVersion = pSharedPref.getString("Version", "");
}
if (!version.equals(storedVersion)) {
if (pSharedPref != null) {
SharedPreferences.Editor editor = pSharedPref.edit();
editor.putString("Version", version);
editor.apply();
}
Map<String, JSONObject> inputMap = new HashMap<>();
saveMap(context, inputMap);
}
} catch (Exception e) {
e.printStackTrace();
}
}
public static int getNotificationId(Bundle notification) {
final String postId = notification.getString("post_id");
final String channelId = notification.getString("channel_id");
int notificationId = CustomPushNotificationHelper.MESSAGE_NOTIFICATION_ID;
if (postId != null) {
notificationId = postId.hashCode();
} else if (channelId != null) {
notificationId = channelId.hashCode();
}
return notificationId;
}
public static StatusBarNotification[] getDeliveredNotifications(Context context) {
final NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
return notificationManager.getActiveNotifications();
}
public static boolean addNotificationToPreferences(Context context, int notificationId, Bundle notification) {
try {
boolean createSummary = true;
final String serverUrl = notification.getString("server_url");
final String channelId = notification.getString("channel_id");
final String rootId = notification.getString("root_id");
final boolean isCRTEnabled = notification.containsKey("is_crt_enabled") && notification.getString("is_crt_enabled").equals("true");
final boolean isThreadNotification = isCRTEnabled && !android.text.TextUtils.isEmpty(rootId);
final String groupId = isThreadNotification ? rootId : channelId;
Map<String, JSONObject> notificationsPerServer = loadMap(context);
JSONObject notificationsInServer = notificationsPerServer.get(serverUrl);
if (notificationsInServer == null) {
notificationsInServer = new JSONObject();
}
JSONObject notificationsInGroup = notificationsInServer.optJSONObject(groupId);
if (notificationsInGroup == null) {
notificationsInGroup = new JSONObject();
}
if (notificationsInGroup.length() > 0) {
createSummary = false;
}
notificationsInGroup.put(String.valueOf(notificationId), false);
if (createSummary) {
// Add the summary notification id as well
notificationsInGroup.put(String.valueOf(notificationId + 1), true);
}
notificationsInServer.put(groupId, notificationsInGroup);
notificationsPerServer.put(serverUrl, notificationsInServer);
saveMap(context, notificationsPerServer);
return createSummary;
} catch(Exception e) {
e.printStackTrace();
return false;
}
}
public static void dismissNotification(Context context, Bundle notification) {
final boolean isCRTEnabled = notification.containsKey("is_crt_enabled") && notification.getString("is_crt_enabled").equals("true");
final String serverUrl = notification.getString("server_url");
final String channelId = notification.getString("channel_id");
final String rootId = notification.getString("root_id");
int notificationId = getNotificationId(notification);
if (!android.text.TextUtils.isEmpty(serverUrl) && !android.text.TextUtils.isEmpty(channelId)) {
boolean isThreadNotification = isCRTEnabled && !android.text.TextUtils.isEmpty(rootId);
String notificationIdStr = String.valueOf(notificationId);
String groupId = isThreadNotification ? rootId : channelId;
Map<String, JSONObject> notificationsPerServer = loadMap(context);
JSONObject notificationsInServer = notificationsPerServer.get(serverUrl);
if (notificationsInServer == null) {
return;
}
JSONObject notificationsInGroup = notificationsInServer.optJSONObject(groupId);
if (notificationsInGroup == null) {
return;
}
boolean isSummary = notificationsInGroup.optBoolean(notificationIdStr);
notificationsInGroup.remove(notificationIdStr);
NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
notificationManager.cancel(notificationId);
StatusBarNotification[] statusNotifications = getDeliveredNotifications(context);
boolean hasMore = false;
for (final StatusBarNotification status : statusNotifications) {
Bundle bundle = status.getNotification().extras;
if (isThreadNotification) {
hasMore = bundle.containsKey("root_id") && bundle.getString("root_id").equals(rootId);
} else {
hasMore = bundle.containsKey("channel_id") && bundle.getString("channel_id").equals(channelId);
}
if (hasMore) break;
}
if (!hasMore || isSummary) {
notificationsInServer.remove(groupId);
} else {
try {
notificationsInServer.put(groupId, notificationsInGroup);
} catch (JSONException e) {
e.printStackTrace();
}
}
notificationsPerServer.put(serverUrl, notificationsInServer);
saveMap(context, notificationsPerServer);
}
}
public static void removeChannelNotifications(Context context, String serverUrl, String channelId) {
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
Map<String, JSONObject> notificationsPerServer = loadMap(context);
JSONObject notificationsInServer = notificationsPerServer.get(serverUrl);
if (notificationsInServer != null) {
notificationsInServer.remove(channelId);
notificationsPerServer.put(serverUrl, notificationsInServer);
saveMap(context, notificationsPerServer);
}
StatusBarNotification[] notifications = getDeliveredNotifications(context);
for (StatusBarNotification sbn:notifications) {
Notification n = sbn.getNotification();
Bundle bundle = n.extras;
String cId = bundle.getString("channel_id");
String rootId = bundle.getString("root_id");
boolean isCRTEnabled = bundle.containsKey("is_crt_enabled") && bundle.getString("is_crt_enabled").equals("true");
boolean skipThreadNotification = isCRTEnabled && !android.text.TextUtils.isEmpty(rootId);
if (Objects.equals(cId, channelId) && !skipThreadNotification) {
notificationManager.cancel(sbn.getId());
}
}
}
public static void removeThreadNotifications(Context context, String serverUrl, String threadId) {
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
Map<String, JSONObject> notificationsPerServer = loadMap(context);
JSONObject notificationsInServer = notificationsPerServer.get(serverUrl);
StatusBarNotification[] notifications = getDeliveredNotifications(context);
for (StatusBarNotification sbn:notifications) {
Notification n = sbn.getNotification();
Bundle bundle = n.extras;
String rootId = bundle.getString("root_id");
String postId = bundle.getString("post_id");
if (Objects.equals(rootId, threadId)) {
notificationManager.cancel(sbn.getId());
}
if (Objects.equals(postId, threadId)) {
String channelId = bundle.getString("channel_id");
int id = sbn.getId();
if (notificationsInServer != null && channelId != null) {
JSONObject notificationsInChannel = notificationsInServer.optJSONObject(channelId);
if (notificationsInChannel != null) {
notificationsInChannel.remove(String.valueOf(id));
try {
notificationsInServer.put(channelId, notificationsInChannel);
} catch (JSONException e) {
e.printStackTrace();
}
}
}
notificationManager.cancel(id);
}
}
if (notificationsInServer != null) {
notificationsInServer.remove(threadId);
notificationsPerServer.put(serverUrl, notificationsInServer);
saveMap(context, notificationsPerServer);
}
}
public static void removeServerNotifications(Context context, String serverUrl) {
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
Map<String, JSONObject> notificationsPerServer = loadMap(context);
notificationsPerServer.remove(serverUrl);
saveMap(context, notificationsPerServer);
StatusBarNotification[] notifications = getDeliveredNotifications(context);
for (StatusBarNotification sbn:notifications) {
Notification n = sbn.getNotification();
Bundle bundle = n.extras;
String url = bundle.getString("server_url");
if (Objects.equals(url, serverUrl)) {
notificationManager.cancel(sbn.getId());
}
}
}
public static void clearChannelOrThreadNotifications(Context context, Bundle notification) {
final String serverUrl = notification.getString("server_url");
final String channelId = notification.getString("channel_id");
final String rootId = notification.getString("root_id");
if (channelId != null) {
final boolean isCRTEnabled = notification.containsKey("is_crt_enabled") && notification.getString("is_crt_enabled").equals("true");
// rootId is available only when CRT is enabled & clearing the thread
final boolean isClearThread = isCRTEnabled && !android.text.TextUtils.isEmpty(rootId);
if (isClearThread) {
removeThreadNotifications(context, serverUrl, rootId);
} else {
removeChannelNotifications(context, serverUrl, channelId);
}
}
}
/**
* Map Structure
*
* { serverUrl: { groupId: { notification1: true, notification2: false } } }
* summary notification has a value of true
*
*/
private static void saveMap(Context context, Map<String, JSONObject> inputMap) {
SharedPreferences pSharedPref = context.getSharedPreferences(PUSH_NOTIFICATIONS, Context.MODE_PRIVATE);
if (pSharedPref != null) {
JSONObject json = new JSONObject(inputMap);
String jsonString = json.toString();
SharedPreferences.Editor editor = pSharedPref.edit();
editor.remove(NOTIFICATIONS_IN_GROUP).apply();
editor.putString(NOTIFICATIONS_IN_GROUP, jsonString);
editor.apply();
}
}
private static Map<String, JSONObject> loadMap(Context context) {
Map<String, JSONObject> outputMap = new HashMap<>();
if (context != null) {
SharedPreferences pSharedPref = context.getSharedPreferences(PUSH_NOTIFICATIONS, Context.MODE_PRIVATE);
try {
if (pSharedPref != null) {
String jsonString = pSharedPref.getString(NOTIFICATIONS_IN_GROUP, (new JSONObject()).toString());
JSONObject json = new JSONObject(jsonString);
Iterator<String> servers = json.keys();
while (servers.hasNext()) {
String serverUrl = servers.next();
JSONObject notificationGroup = json.getJSONObject(serverUrl);
outputMap.put(serverUrl, notificationGroup);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
return outputMap;
}
}

View File

@@ -2,11 +2,9 @@ package com.mattermost.helpers
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.facebook.react.bridge.WritableNativeArray
import com.nozbe.watermelondb.Database
import java.io.IOException
import java.util.concurrent.Executors
@@ -26,17 +24,13 @@ class PushNotificationDataHelper(private val context: Context) {
class PushNotificationDataRunnable {
companion object {
private val specialMentions = listOf("all", "here", "channel")
private val specialMentions = listOf<String>("all", "here", "channel")
@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)
if (db != null) {
var postData: ReadableMap?
@@ -44,19 +38,12 @@ class PushNotificationDataRunnable {
var userIdsToLoad: ReadableArray? = null
var usernamesToLoad: ReadableArray? = null
var threads: ReadableArray? = null
var usersFromThreads: ReadableArray? = null
val receivingThreads = isCRTEnabled && !rootId.isNullOrEmpty()
coroutineScope {
if (channelId != null) {
postData = fetchPosts(db, serverUrl, channelId, isCRTEnabled, rootId)
postData = fetchPosts(db, serverUrl, channelId)
posts = postData?.getMap("posts")
userIdsToLoad = postData?.getArray("userIdsToLoad")
usernamesToLoad = postData?.getArray("usernamesToLoad")
threads = postData?.getArray("threads")
usersFromThreads = postData?.getArray("usersFromThreads")
if (userIdsToLoad != null && userIdsToLoad!!.size() > 0) {
val users = fetchUsersById(serverUrl, userIdsToLoad!!)
@@ -72,11 +59,7 @@ class PushNotificationDataRunnable {
db.transaction {
if (posts != null && channelId != null) {
DatabaseHelper.instance!!.handlePosts(db, posts!!.getMap("data"), channelId, receivingThreads)
}
if (threads != null) {
DatabaseHelper.instance!!.handleThreads(db, threads!!)
DatabaseHelper.instance!!.handlePosts(db, posts!!.getMap("data"), channelId)
}
if (userIdsToLoad != null && userIdsToLoad!!.size() > 0) {
@@ -86,61 +69,37 @@ class PushNotificationDataRunnable {
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 suspend fun fetchPosts(db: Database, serverUrl: String, channelId: String, isCRTEnabled: Boolean, rootId: String?): ReadableMap? {
private suspend fun fetchPosts(db: Database, serverUrl: String, channelId: 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 queryParams = if (since == null) "?page=0&per_page=60" else "?since=${since.toLong()}"
val endpoint = "/api/v4/channels/$channelId/posts$queryParams"
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<*, *>
val postsData = data.get("data") as? Map<*, *>
if (postsData != null) {
val postsMap = postsData["posts"]
val postsMap = postsData.get("posts")
if (postsMap != null) {
val posts = ReadableMapUtils.toWritableMap(postsMap as? Map<String, Any>)
val posts = ReadableMapUtils.toWritableMap(postsMap as? Map<String, Object>)
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)
@@ -158,38 +117,6 @@ class PushNotificationDataRunnable {
}
}
}
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())
@@ -197,27 +124,6 @@ class PushNotificationDataRunnable {
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()))
}
@@ -225,13 +131,11 @@ class PushNotificationDataRunnable {
if (usernames.size > 0) {
results.putArray("usernamesToLoad", ReadableArrayUtils.toWritableArray(usernames.toTypedArray()))
}
if (threads.size() > 0) {
results.putArray("threads", threads)
}
}
}
}
return results
}
@@ -239,14 +143,14 @@ class PushNotificationDataRunnable {
val endpoint = "api/v4/users/ids"
val options = Arguments.createMap()
options.putArray("body", ReadableArrayUtils.toWritableArray(ReadableArrayUtils.toArray(userIds)))
return fetchWithPost(serverUrl, endpoint, options)
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)
return fetchWithPost(serverUrl, endpoint, options);
}
private suspend fun fetch(serverUrl: String, endpoint: String): ReadableMap? {

View File

@@ -5,15 +5,15 @@ import kotlin.math.floor
class RandomId {
companion object {
private const val alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
private const val alphabetLength = alphabet.length
private const val idLength = 16
private const val alphabetLenght = alphabet.length
private const val idLenght = 16
fun generate(): String {
var id = ""
for (i in 1.rangeTo((idLength / 2))) {
val random = floor(Math.random() * alphabetLength * alphabetLength)
id += alphabet[floor(random / alphabetLength).toInt()]
id += alphabet[(random % alphabetLength).toInt()]
for (i in 1.rangeTo((idLenght / 2))) {
val random = floor(Math.random() * alphabetLenght * alphabetLenght)
id += alphabet[floor(random / alphabetLenght).toInt()]
id += alphabet[(random % alphabetLenght).toInt()]
}
return id

View File

@@ -99,17 +99,23 @@ public class ReadableArrayUtils {
for (Object value : array) {
if (value == null) {
writableArray.pushNull();
} else if (value instanceof Boolean) {
}
if (value instanceof Boolean) {
writableArray.pushBoolean((Boolean) value);
} else if (value instanceof Double) {
}
if (value instanceof Double) {
writableArray.pushDouble((Double) value);
} else if (value instanceof Integer) {
}
if (value instanceof Integer) {
writableArray.pushInt((Integer) value);
} else if (value instanceof String) {
}
if (value instanceof String) {
writableArray.pushString((String) value);
} else if (value instanceof Map) {
}
if (value instanceof Map) {
writableArray.pushMap(ReadableMapUtils.toWritableMap((Map<String, Object>) value));
} else if (value.getClass().isArray()) {
}
if (value.getClass().isArray()) {
writableArray.pushArray(ReadableArrayUtils.toWritableArray((Object[]) value));
}
}

View File

@@ -1,7 +1,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.ReadableMapKeySetIterator;
import com.facebook.react.bridge.ReadableType;
@@ -39,16 +38,10 @@ public class ReadableMapUtils {
jsonObject.put(key, readableMap.getString(key));
break;
case Map:
ReadableMap map = readableMap.getMap(key);
if (map != null) {
jsonObject.put(key, ReadableMapUtils.toJSONObject(map));
}
jsonObject.put(key, ReadableMapUtils.toJSONObject(readableMap.getMap(key)));
break;
case Array:
ReadableArray array = readableMap.getArray(key);
if (array != null) {
jsonObject.put(key, ReadableArrayUtils.toJSONArray(array));
}
jsonObject.put(key, ReadableArrayUtils.toJSONArray(readableMap.getArray(key)));
break;
}
}
@@ -99,16 +92,10 @@ public class ReadableMapUtils {
map.put(key, readableMap.getString(key));
break;
case Map:
ReadableMap obj = readableMap.getMap(key);
if (obj != null) {
map.put(key, ReadableMapUtils.toMap(obj));
}
map.put(key, ReadableMapUtils.toMap(readableMap.getMap(key)));
break;
case Array:
ReadableArray array = readableMap.getArray(key);
if (array != null) {
map.put(key, ReadableArrayUtils.toArray(array));
}
map.put(key, ReadableArrayUtils.toArray(readableMap.getArray(key)));
break;
}
}
@@ -118,26 +105,26 @@ public class ReadableMapUtils {
public static WritableMap toWritableMap(Map<String, Object> map) {
WritableMap writableMap = Arguments.createMap();
Iterator<Map.Entry<String, Object>> iterator = map.entrySet().iterator();
Iterator iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Object> pair = iterator.next();
Map.Entry pair = (Map.Entry)iterator.next();
Object value = pair.getValue();
if (value == null) {
writableMap.putNull(pair.getKey());
writableMap.putNull((String) pair.getKey());
} else if (value instanceof Boolean) {
writableMap.putBoolean(pair.getKey(), (Boolean) value);
writableMap.putBoolean((String) pair.getKey(), (Boolean) value);
} else if (value instanceof Double) {
writableMap.putDouble(pair.getKey(), (Double) value);
writableMap.putDouble((String) pair.getKey(), (Double) value);
} else if (value instanceof Integer) {
writableMap.putInt(pair.getKey(), (Integer) value);
writableMap.putInt((String) pair.getKey(), (Integer) value);
} else if (value instanceof String) {
writableMap.putString(pair.getKey(), (String) value);
} else if (value instanceof Map)
writableMap.putMap(pair.getKey(), ReadableMapUtils.toWritableMap((Map<String, Object>) value));
else if (value.getClass().isArray()) {
writableMap.putArray(pair.getKey(), ReadableArrayUtils.toWritableArray((Object[]) value));
writableMap.putString((String) pair.getKey(), (String) value);
} else if (value instanceof Map) {
writableMap.putMap((String) pair.getKey(), ReadableMapUtils.toWritableMap((Map<String, Object>) value));
} else if (value.getClass() != null && value.getClass().isArray()) {
writableMap.putArray((String) pair.getKey(), ReadableArrayUtils.toWritableArray((Object[]) value));
}
iterator.remove();

View File

@@ -3,6 +3,7 @@ package com.mattermost.helpers;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.provider.DocumentsContract;
import android.provider.MediaStore;
import android.provider.OpenableColumns;
@@ -17,14 +18,16 @@ import android.os.ParcelFileDescriptor;
import java.io.*;
import java.nio.channels.FileChannel;
// Class based on DocumentHelper https://gist.github.com/steveevers/a5af24c226f44bb8fdc3
// Class based on the steveevers DocumentHelper https://gist.github.com/steveevers/a5af24c226f44bb8fdc3
public class RealPathUtil {
public static final String CACHE_DIR_NAME = "mmShare";
public static String getRealPathFromURI(final Context context, final Uri uri) {
final boolean isKitKatOrNewer = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
// DocumentProvider
if (DocumentsContract.isDocumentUri(context, uri)) {
if (isKitKatOrNewer && DocumentsContract.isDocumentUri(context, uri)) {
// ExternalStorageProvider
if (isExternalStorageDocument(uri)) {
final String docId = DocumentsContract.getDocumentId(uri);
@@ -45,7 +48,7 @@ public class RealPathUtil {
try {
return getPathFromSavingTempFile(context, uri);
} catch (NumberFormatException e) {
Log.e("ReactNative", "DownloadsProvider unexpected uri " + uri);
Log.e("ReactNative", "DownloadsProvider unexpected uri " + uri.toString());
return null;
}
}
@@ -70,12 +73,7 @@ public class RealPathUtil {
split[1]
};
String name = getDataColumn(context, contentUri, selection, selectionArgs);
if (!TextUtils.isEmpty(name)) {
return name;
}
return getPathFromSavingTempFile(context, uri);
return getDataColumn(context, contentUri, selection, selectionArgs);
}
}
@@ -97,7 +95,7 @@ public class RealPathUtil {
public static String getPathFromSavingTempFile(Context context, final Uri uri) {
File tmpFile;
String fileName = "";
String fileName = null;
if (uri == null || uri.isRelative()) {
return null;
@@ -110,14 +108,13 @@ public class RealPathUtil {
int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
returnCursor.moveToFirst();
fileName = sanitizeFilename(returnCursor.getString(nameIndex));
returnCursor.close();
} catch (Exception e) {
// just continue to get the filename with the last segment of the path
}
try {
if (TextUtils.isEmpty(fileName)) {
fileName = sanitizeFilename(uri.getLastPathSegment().trim());
fileName = sanitizeFilename(uri.getLastPathSegment().toString().trim());
}
@@ -126,6 +123,7 @@ public class RealPathUtil {
cacheDir.mkdirs();
}
String mimeType = getMimeType(uri.getPath());
tmpFile = new File(cacheDir, fileName);
tmpFile.createNewFile();
@@ -183,14 +181,8 @@ public class RealPathUtil {
}
public static String getExtension(String uri) {
String extension = "";
if (uri == null) {
return extension;
}
extension = MimeTypeMap.getFileExtensionFromUrl(uri);
if (!extension.equals("")) {
return extension;
return null;
}
int dot = uri.lastIndexOf(".");
@@ -217,7 +209,7 @@ public class RealPathUtil {
return getMimeType(file);
}
public static String getMimeTypeFromUri(final Context context, final Uri uri) {
public static String getMimeTypeFromUri(final Context context, final Uri uri) {
try {
ContentResolver cR = context.getContentResolver();
return cR.getType(uri);
@@ -237,13 +229,9 @@ public class RealPathUtil {
}
private static void deleteRecursive(File fileOrDirectory) {
if (fileOrDirectory.isDirectory()) {
File[] files = fileOrDirectory.listFiles();
if (files != null) {
for (File child : files)
deleteRecursive(child);
}
}
if (fileOrDirectory.isDirectory())
for (File child : fileOrDirectory.listFiles())
deleteRecursive(child);
fileOrDirectory.delete();
}
@@ -257,21 +245,4 @@ public class RealPathUtil {
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,7 +1,5 @@
package com.mattermost.helpers;
import androidx.annotation.NonNull;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.WritableMap;
@@ -20,7 +18,7 @@ public class ResolvePromise implements Promise {
}
@Override
public void reject(String code, @NonNull WritableMap map) {
public void reject(String code, WritableMap map) {
}
@@ -50,7 +48,7 @@ public class ResolvePromise implements Promise {
}
@Override
public void reject(String code, String message, @NonNull WritableMap map) {
public void reject(String code, String message, WritableMap map) {
}

View File

@@ -0,0 +1,116 @@
package com.mattermost.newarchitecture;
import android.app.Application;
import androidx.annotation.NonNull;
import com.facebook.react.PackageList;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.ReactNativeHost;
import com.facebook.react.ReactPackage;
import com.facebook.react.ReactPackageTurboModuleManagerDelegate;
import com.facebook.react.bridge.JSIModulePackage;
import com.facebook.react.bridge.JSIModuleProvider;
import com.facebook.react.bridge.JSIModuleSpec;
import com.facebook.react.bridge.JSIModuleType;
import com.facebook.react.bridge.JavaScriptContextHolder;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.UIManager;
import com.facebook.react.fabric.ComponentFactory;
import com.facebook.react.fabric.CoreComponentsRegistry;
import com.facebook.react.fabric.EmptyReactNativeConfig;
import com.facebook.react.fabric.FabricJSIModuleProvider;
import com.facebook.react.uimanager.ViewManagerRegistry;
import com.mattermost.rnbeta.BuildConfig;
import com.mattermost.newarchitecture.components.MainComponentsRegistry;
import com.mattermost.newarchitecture.modules.MainApplicationTurboModuleManagerDelegate;
import java.util.ArrayList;
import java.util.List;
/**
* A {@link ReactNativeHost} that helps you load everything needed for the New Architecture, both
* TurboModule delegates and the Fabric Renderer.
*
* <p>Please note that this class is used ONLY if you opt-in for the New Architecture (see the
* `newArchEnabled` property). Is ignored otherwise.
*/
public class MainApplicationReactNativeHost extends ReactNativeHost {
public MainApplicationReactNativeHost(Application application) {
super(application);
}
@Override
public boolean getUseDeveloperSupport() {
return BuildConfig.DEBUG;
}
@Override
protected List<ReactPackage> getPackages() {
List<ReactPackage> packages = new PackageList(this).getPackages();
// Packages that cannot be autolinked yet can be added manually here, for example:
// packages.add(new MyReactNativePackage());
// TurboModules must also be loaded here providing a valid TurboReactPackage implementation:
// packages.add(new TurboReactPackage() { ... });
// If you have custom Fabric Components, their ViewManagers should also be loaded here
// inside a ReactPackage.
return packages;
}
@Override
protected String getJSMainModuleName() {
return "index";
}
@NonNull
@Override
protected ReactPackageTurboModuleManagerDelegate.Builder
getReactPackageTurboModuleManagerDelegateBuilder() {
// Here we provide the ReactPackageTurboModuleManagerDelegate Builder. This is necessary
// for the new architecture and to use TurboModules correctly.
return new MainApplicationTurboModuleManagerDelegate.Builder();
}
@Override
protected JSIModulePackage getJSIModulePackage() {
return new JSIModulePackage() {
@Override
public List<JSIModuleSpec> getJSIModules(
final ReactApplicationContext reactApplicationContext,
final JavaScriptContextHolder jsContext) {
final List<JSIModuleSpec> specs = new ArrayList<>();
// Here we provide a new JSIModuleSpec that will be responsible of providing the
// custom Fabric Components.
specs.add(
new JSIModuleSpec() {
@Override
public JSIModuleType getJSIModuleType() {
return JSIModuleType.UIManager;
}
@Override
public JSIModuleProvider<UIManager> getJSIModuleProvider() {
final ComponentFactory componentFactory = new ComponentFactory();
CoreComponentsRegistry.register(componentFactory);
// Here we register a Components Registry.
// The one that is generated with the template contains no components
// and just provides you the one from React Native core.
MainComponentsRegistry.register(componentFactory);
final ReactInstanceManager reactInstanceManager = getReactInstanceManager();
ViewManagerRegistry viewManagerRegistry =
new ViewManagerRegistry(
reactInstanceManager.getOrCreateViewManagers(reactApplicationContext));
return new FabricJSIModuleProvider(
reactApplicationContext,
componentFactory,
new EmptyReactNativeConfig(),
viewManagerRegistry);
}
});
return specs;
}
};
}
}

View File

@@ -0,0 +1,36 @@
package com.mattermost.newarchitecture.components;
import com.facebook.jni.HybridData;
import com.facebook.proguard.annotations.DoNotStrip;
import com.facebook.react.fabric.ComponentFactory;
import com.facebook.soloader.SoLoader;
/**
* Class responsible to load the custom Fabric Components. This class has native methods and needs a
* corresponding C++ implementation/header file to work correctly (already placed inside the jni/
* folder for you).
*
* <p>Please note that this class is used ONLY if you opt-in for the New Architecture (see the
* `newArchEnabled` property). Is ignored otherwise.
*/
@DoNotStrip
public class MainComponentsRegistry {
static {
SoLoader.loadLibrary("fabricjni");
}
@DoNotStrip private final HybridData mHybridData;
@DoNotStrip
private native HybridData initHybrid(ComponentFactory componentFactory);
@DoNotStrip
private MainComponentsRegistry(ComponentFactory componentFactory) {
mHybridData = initHybrid(componentFactory);
}
@DoNotStrip
public static MainComponentsRegistry register(ComponentFactory componentFactory) {
return new MainComponentsRegistry(componentFactory);
}
}

View File

@@ -0,0 +1,48 @@
package com.mattermost.newarchitecture.modules;
import com.facebook.jni.HybridData;
import com.facebook.react.ReactPackage;
import com.facebook.react.ReactPackageTurboModuleManagerDelegate;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.soloader.SoLoader;
import java.util.List;
/**
* Class responsible to load the TurboModules. This class has native methods and needs a
* corresponding C++ implementation/header file to work correctly (already placed inside the jni/
* folder for you).
*
* <p>Please note that this class is used ONLY if you opt-in for the New Architecture (see the
* `newArchEnabled` property). Is ignored otherwise.
*/
public class MainApplicationTurboModuleManagerDelegate
extends ReactPackageTurboModuleManagerDelegate {
private static volatile boolean sIsSoLibraryLoaded;
protected MainApplicationTurboModuleManagerDelegate(
ReactApplicationContext reactApplicationContext, List<ReactPackage> packages) {
super(reactApplicationContext, packages);
}
protected native HybridData initHybrid();
native boolean canCreateTurboModule(String moduleName);
public static class Builder extends ReactPackageTurboModuleManagerDelegate.Builder {
protected MainApplicationTurboModuleManagerDelegate build(
ReactApplicationContext context, List<ReactPackage> packages) {
return new MainApplicationTurboModuleManagerDelegate(context, packages);
}
}
@Override
protected synchronized void maybeLoadOtherSoLibraries() {
if (!sIsSoLibraryLoaded) {
// If you change the name of your application .so file in the Android.mk file,
// make sure you update the name here as well.
SoLoader.loadLibrary("rndiffapp_appmodules");
sIsSoLibraryLoaded = true;
}
}
}

View File

@@ -1,24 +1,34 @@
package com.mattermost.rnbeta;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.service.notification.StatusBarNotification;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
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.ResolvePromise;
import com.mattermost.share.ShareModule;
import com.wix.reactnativenotifications.core.NotificationIntentAdapter;
import com.wix.reactnativenotifications.core.notification.PushNotification;
import com.wix.reactnativenotifications.core.AppLaunchHelper;
@@ -26,22 +36,105 @@ import com.wix.reactnativenotifications.core.AppLifecycleFacade;
import com.wix.reactnativenotifications.core.JsIOHelper;
import static com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_EVENT_NAME;
import org.json.JSONArray;
import org.json.JSONObject;
public class CustomPushNotification extends PushNotification {
private static final String PUSH_NOTIFICATIONS = "PUSH_NOTIFICATIONS";
private static final String VERSION_PREFERENCE = "VERSION_PREFERENCE";
private static final String PUSH_TYPE_MESSAGE = "message";
private static final String PUSH_TYPE_CLEAR = "clear";
private static final String PUSH_TYPE_SESSION = "session";
private static final String NOTIFICATIONS_IN_CHANNEL = "notificationsInChannel";
private final PushNotificationDataHelper dataHelper;
public CustomPushNotification(Context context, Bundle bundle, AppLifecycleFacade appLifecycleFacade, AppLaunchHelper appLaunchHelper, JsIOHelper jsIoHelper) {
super(context, bundle, appLifecycleFacade, appLaunchHelper, jsIoHelper);
CustomPushNotificationHelper.createNotificationChannels(context);
dataHelper = new PushNotificationDataHelper(context);
try {
Objects.requireNonNull(DatabaseHelper.Companion.getInstance()).init(context);
Network.init(context);
NotificationHelper.cleanNotificationPreferencesIfNeeded(context);
} catch (Exception e) {
PackageInfo pInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 0);
String version = String.valueOf(pInfo.versionCode);
String storedVersion = null;
SharedPreferences pSharedPref = context.getSharedPreferences(VERSION_PREFERENCE, Context.MODE_PRIVATE);
if (pSharedPref != null) {
storedVersion = pSharedPref.getString("Version", "");
}
if (!version.equals(storedVersion)) {
if (pSharedPref != null) {
SharedPreferences.Editor editor = pSharedPref.edit();
editor.putString("Version", version);
editor.apply();
}
Map<String, List<Integer>> inputMap = new HashMap<>();
saveNotificationsMap(context, inputMap);
}
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
}
public static void cancelNotification(Context context, String channelId, Integer notificationId) {
if (!android.text.TextUtils.isEmpty(channelId)) {
Map<String, List<Integer>> notificationsInChannel = loadNotificationsMap(context);
List<Integer> notifications = notificationsInChannel.get(channelId);
if (notifications == null) {
return;
}
final NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
notificationManager.cancel(notificationId);
notifications.remove(notificationId);
final StatusBarNotification[] statusNotifications = notificationManager.getActiveNotifications();
boolean hasMore = false;
for (final StatusBarNotification status : statusNotifications) {
if (status.getNotification().extras.getString("channel_id").equals(channelId)) {
hasMore = true;
break;
}
}
if (!hasMore) {
notificationsInChannel.remove(channelId);
}
saveNotificationsMap(context, notificationsInChannel);
}
}
public static void clearChannelNotifications(Context context, String channelId) {
if (!android.text.TextUtils.isEmpty(channelId)) {
Map<String, List<Integer>> notificationsInChannel = loadNotificationsMap(context);
List<Integer> notifications = notificationsInChannel.get(channelId);
if (notifications == null) {
return;
}
notificationsInChannel.remove(channelId);
saveNotificationsMap(context, notificationsInChannel);
for (final Integer notificationId : notifications) {
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
notificationManager.cancel(notificationId);
}
}
}
public static void clearAllNotifications(Context context) {
if (context != null) {
Map<String, List<Integer>> notificationsInChannel = loadNotificationsMap(context);
notificationsInChannel.clear();
saveNotificationsMap(context, notificationsInChannel);
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
notificationManager.cancelAll();
}
}
@Override
public void onReceived() {
final Bundle initialData = mNotificationProps.asBundle();
@@ -50,7 +143,12 @@ public class CustomPushNotification extends PushNotification {
final String postId = initialData.getString("post_id");
final String channelId = initialData.getString("channel_id");
final boolean isIdLoaded = initialData.getString("id_loaded") != null && initialData.getString("id_loaded").equals("true");
int notificationId = NotificationHelper.getNotificationId(initialData);
int notificationId = CustomPushNotificationHelper.MESSAGE_NOTIFICATION_ID;
if (postId != null) {
notificationId = postId.hashCode();
} else if (channelId != null) {
notificationId = channelId.hashCode();
}
String serverUrl = addServerUrlToBundle(initialData);
boolean isReactInit = mAppLifecycleFacade.isReactInitialized();
@@ -63,9 +161,7 @@ public class CustomPushNotification extends PushNotification {
Bundle response = (Bundle) value;
if (value != null) {
response.putString("server_url", serverUrl);
Bundle current = mNotificationProps.asBundle();
current.putAll(response);
mNotificationProps = createProps(current);
mNotificationProps = createProps(response);
}
}
}
@@ -78,37 +174,47 @@ public class CustomPushNotification extends PushNotification {
}
switch (type) {
case CustomPushNotificationHelper.PUSH_TYPE_MESSAGE:
case CustomPushNotificationHelper.PUSH_TYPE_SESSION:
ShareModule shareModule = ShareModule.getInstance();
String currentActivityName = shareModule != null ? shareModule.getCurrentActivityName() : "";
Log.i("ReactNative", currentActivityName);
if (!mAppLifecycleFacade.isAppVisible() || !currentActivityName.equals("MainActivity")) {
boolean createSummary = type.equals(CustomPushNotificationHelper.PUSH_TYPE_MESSAGE);
if (type.equals(CustomPushNotificationHelper.PUSH_TYPE_MESSAGE)) {
case PUSH_TYPE_MESSAGE:
case PUSH_TYPE_SESSION:
boolean createSummary = type.equals(PUSH_TYPE_MESSAGE);
if (!mAppLifecycleFacade.isAppVisible()) {
if (type.equals(PUSH_TYPE_MESSAGE)) {
if (channelId != null) {
Bundle notificationBundle = mNotificationProps.asBundle();
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.
dataHelper.fetchAndStoreDataForPushNotification(notificationBundle);
dataHelper.fetchAndStoreDataForPushNotification(mNotificationProps.asBundle());
}
createSummary = NotificationHelper.addNotificationToPreferences(
mContext,
notificationId,
notificationBundle
);
Map<String, List<Integer>> notificationsInChannel = loadNotificationsMap(mContext);
List<Integer> list = notificationsInChannel.get(channelId);
if (list == null) {
list = Collections.synchronizedList(new ArrayList<>(0));
}
list.add(0, notificationId);
if (list.size() > 1) {
createSummary = false;
}
if (createSummary) {
// Add the summary notification id as well
list.add(0, notificationId + 1);
}
notificationsInChannel.put(channelId, list);
saveNotificationsMap(mContext, notificationsInChannel);
}
}
buildNotification(notificationId, createSummary);
}
break;
case CustomPushNotificationHelper.PUSH_TYPE_CLEAR:
NotificationHelper.clearChannelOrThreadNotifications(mContext, mNotificationProps.asBundle());
case PUSH_TYPE_CLEAR:
clearChannelNotifications(mContext, channelId);
break;
}
@@ -119,11 +225,25 @@ public class CustomPushNotification extends PushNotification {
@Override
public void onOpened() {
if (mNotificationProps != null) {
digestNotification();
digestNotification();
Bundle data = mNotificationProps.asBundle();
NotificationHelper.clearChannelOrThreadNotifications(mContext, data);
Bundle data = mNotificationProps.asBundle();
final String channelId = data.getString("channel_id");
final String postId = data.getString("post_id");
Integer notificationId = CustomPushNotificationHelper.MESSAGE_NOTIFICATION_ID;
if (postId != null) {
notificationId = postId.hashCode();
}
if (channelId != null) {
Map<String, List<Integer>> notificationsInChannel = loadNotificationsMap(mContext);
List<Integer> notifications = notificationsInChannel.get(channelId);
if (notifications != null) {
notifications.remove(notificationId);
}
saveNotificationsMap(mContext, notificationsInChannel);
clearChannelNotifications(mContext, channelId);
}
}
@@ -172,4 +292,43 @@ public class CustomPushNotification extends PushNotification {
return serverUrl;
}
private static void saveNotificationsMap(Context context, Map<String, List<Integer>> inputMap) {
SharedPreferences pSharedPref = context.getSharedPreferences(PUSH_NOTIFICATIONS, Context.MODE_PRIVATE);
if (pSharedPref != null) {
JSONObject json = new JSONObject(inputMap);
String jsonString = json.toString();
SharedPreferences.Editor editor = pSharedPref.edit();
editor.remove(NOTIFICATIONS_IN_CHANNEL).apply();
editor.putString(NOTIFICATIONS_IN_CHANNEL, jsonString);
editor.apply();
}
}
private static Map<String, List<Integer>> loadNotificationsMap(Context context) {
Map<String, List<Integer>> outputMap = new HashMap<>();
if (context != null) {
SharedPreferences pSharedPref = context.getSharedPreferences(PUSH_NOTIFICATIONS, Context.MODE_PRIVATE);
try {
if (pSharedPref != null) {
String jsonString = pSharedPref.getString(NOTIFICATIONS_IN_CHANNEL, (new JSONObject()).toString());
JSONObject json = new JSONObject(jsonString);
Iterator<String> keysItr = json.keys();
while (keysItr.hasNext()) {
String key = keysItr.next();
JSONArray array = json.getJSONArray(key);
List<Integer> values = new ArrayList<>();
for (int i = 0; i < array.length(); ++i) {
values.add(array.getInt(i));
}
outputMap.put(key, values);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
return outputMap;
}
}

View File

@@ -0,0 +1,35 @@
package com.mattermost.rnbeta;
import android.content.Context;
import com.wix.reactnativenotifications.core.AppLaunchHelper;
import com.wix.reactnativenotifications.core.notificationdrawer.PushNotificationsDrawer;
public class CustomPushNotificationDrawer extends PushNotificationsDrawer {
final protected Context mContext;
final protected AppLaunchHelper mAppLaunchHelper;
protected CustomPushNotificationDrawer(Context context, AppLaunchHelper appLaunchHelper) {
super(context, appLaunchHelper);
mContext = context;
mAppLaunchHelper = appLaunchHelper;
}
@Override
public void onAppInit() {
}
@Override
public void onAppVisible() {
}
@Override
public void onNotificationOpened() {
}
@Override
public void onCancelAllLocalNotifications() {
CustomPushNotification.clearAllNotifications(mContext);
cancelAllScheduledNotifications();
}
}

View File

@@ -1,58 +0,0 @@
package com.mattermost.rnbeta
import android.app.Activity
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
import androidx.window.layout.WindowLayoutInfo
import androidx.window.rxjava3.layout.windowLayoutInfoObservable
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
class FoldableObserver(private val activity: Activity) {
private var disposable: Disposable? = null
private lateinit var observable: Observable<WindowLayoutInfo>
public fun onCreate() {
observable = WindowInfoTracker.getOrCreate(activity)
.windowLayoutInfoObservable(activity)
}
public fun onStart() {
if (disposable?.isDisposed == true) {
onCreate()
}
disposable = observable.observeOn(AndroidSchedulers.mainThread())
.subscribe { layoutInfo ->
val splitViewModule = SplitViewModule.getInstance()
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)
}
}
}
}
public fun onStop() {
disposable?.dispose()
}
private fun isTableTopPosture(foldFeature : FoldingFeature?) : Boolean {
return foldFeature?.state == FoldingFeature.State.HALF_OPENED &&
foldFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
}
private fun isBookPosture(foldFeature : FoldingFeature?) : Boolean {
return foldFeature?.state == FoldingFeature.State.HALF_OPENED &&
foldFeature.orientation == FoldingFeature.Orientation.VERTICAL
}
}

View File

@@ -6,56 +6,23 @@ import androidx.annotation.Nullable;
import android.view.KeyEvent;
import android.content.res.Configuration;
import com.facebook.react.ReactActivityDelegate;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.WritableMap;
// import com.facebook.react.ReactActivityDelegate;
// import com.facebook.react.ReactRootView;
import com.reactnativenavigation.NavigationActivity;
import com.github.emilioicai.hwkeyboardevent.HWKeyboardEventModule;
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
import com.facebook.react.defaults.DefaultReactActivityDelegate;
public class MainActivity extends NavigationActivity {
private boolean HWKeyboardConnected = false;
private FoldableObserver foldableObserver = new FoldableObserver(this);
@Override
protected String getMainComponentName() {
return "Mattermost";
}
/**
* Returns the instance of the {@link ReactActivityDelegate}. Here we use a util class {@link
* DefaultReactActivityDelegate} which allows you to easily enable Fabric and Concurrent React
* (aka React 18) with two boolean flags.
*/
@Override
protected ReactActivityDelegate createReactActivityDelegate() {
return new DefaultReactActivityDelegate(
this,
getMainComponentName(),
// If you opted-in for the New Architecture, we enable the Fabric Renderer.
DefaultNewArchitectureEntryPoint.getFabricEnabled(), // fabricEnabled
// If you opted-in for the New Architecture, we enable Concurrent React (i.e. React 18).
DefaultNewArchitectureEntryPoint.getConcurrentReactEnabled() // concurrentRootEnabled
);
}
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(null);
super.onCreate(savedInstanceState);
setContentView(R.layout.launch_screen);
setHWKeyboardConnected();
foldableObserver.onCreate();
}
@Override
protected void onStart() {
super.onStart();
foldableObserver.onStart();
}
@Override
protected void onStop() {
super.onStop();
foldableObserver.onStop();
}
@Override
@@ -69,12 +36,6 @@ public class MainActivity extends NavigationActivity {
}
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
getReactGateway().onWindowFocusChanged(hasFocus);
}
/*
https://mattermost.atlassian.net/browse/MM-10601
Required by react-native-hw-keyboard-event
@@ -82,19 +43,10 @@ public class MainActivity extends NavigationActivity {
*/
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
if (HWKeyboardConnected) {
int keyCode = event.getKeyCode();
int keyAction = event.getAction();
if (keyAction == KeyEvent.ACTION_UP) {
if (keyCode == KeyEvent.KEYCODE_ENTER) {
String keyPressed = event.isShiftPressed() ? "shift-enter" : "enter";
HWKeyboardEventModule.getInstance().keyPressed(keyPressed);
return true;
} else if (keyCode == KeyEvent.KEYCODE_K && event.isCtrlPressed()) {
HWKeyboardEventModule.getInstance().keyPressed("find-channels");
return true;
}
}
if (HWKeyboardConnected && event.getKeyCode() == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_UP) {
String keyPressed = event.isShiftPressed() ? "shift-enter" : "enter";
HWKeyboardEventModule.getInstance().keyPressed(keyPressed);
return true;
}
return super.dispatchKeyEvent(event);
};
@@ -102,4 +54,25 @@ public class MainActivity extends NavigationActivity {
private void setHWKeyboardConnected() {
HWKeyboardConnected = getResources().getConfiguration().keyboard == Configuration.KEYBOARD_QWERTY;
}
/**
* Returns the instance of the {@link ReactActivityDelegate}. There the RootView is created and
* you can specify the rendered you wish to use (Fabric or the older renderer).
*/
// @Override
// protected ReactActivityDelegate createReactActivityDelegate() {
// return new MainActivityDelegate(this, getMainComponentName());
// }
// public static class MainActivityDelegate extends ReactActivityDelegate {
// public MainActivityDelegate(ReactActivity activity, String mainComponentName) {
// super(activity, mainComponentName);
// }
// @Override
// protected ReactRootView createRootView() {
// ReactRootView reactRootView = new ReactRootView(getContext());
// // If you opted-in for the New Architecture, we enable the Fabric Renderer.
// reactRootView.setIsFabric(BuildConfig.IS_NEW_ARCHITECTURE_ENABLED);
// return reactRootView;
// }
// }
}

View File

@@ -1,33 +1,36 @@
package com.mattermost.rnbeta;
import com.facebook.react.bridge.JSIModuleSpec;
import com.facebook.react.bridge.JavaScriptContextHolder;
import android.content.Context;
import android.os.Bundle;
import android.util.Log;
import java.io.File;
import java.util.Collections;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.airbnb.android.react.lottie.LottiePackage;
import com.mattermost.helpers.RealPathUtil;
import com.mattermost.share.ShareModule;
import com.wix.reactnativenotifications.RNNotificationsPackage;
import com.reactnativenavigation.NavigationApplication;
import com.wix.reactnativenotifications.core.notification.INotificationsApplication;
import com.wix.reactnativenotifications.core.notification.IPushNotification;
import com.wix.reactnativenotifications.core.notificationdrawer.IPushNotificationsDrawer;
import com.wix.reactnativenotifications.core.notificationdrawer.INotificationsDrawerApplication;
import com.wix.reactnativenotifications.core.AppLaunchHelper;
import com.wix.reactnativenotifications.core.AppLifecycleFacade;
import com.wix.reactnativenotifications.core.JsIOHelper;
import com.facebook.react.PackageList;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.ReactPackage;
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
import com.facebook.react.defaults.DefaultReactNativeHost;
import com.facebook.react.config.ReactFeatureFlags;
import com.facebook.react.ReactNativeHost;
import com.facebook.react.TurboReactPackage;
import com.facebook.react.bridge.JSIModuleSpec;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.JSIModulePackage;
@@ -36,16 +39,18 @@ import com.facebook.react.module.model.ReactModuleInfoProvider;
import com.facebook.react.modules.network.OkHttpClientProvider;
import com.facebook.soloader.SoLoader;
import com.mattermost.flipper.ReactNativeFlipper;
import com.mattermost.networkclient.RCTOkHttpClientFactory;
import com.mattermost.newarchitecture.MainApplicationReactNativeHost;
import com.swmansion.reanimated.ReanimatedJSIModulePackage;
import com.nozbe.watermelondb.jsi.WatermelonDBJSIPackage;
public class MainApplication extends NavigationApplication implements INotificationsApplication {
public class MainApplication extends NavigationApplication implements INotificationsApplication, INotificationsDrawerApplication {
public static MainApplication instance;
public Boolean sharedExtensionIsOpened = false;
private final ReactNativeHost mReactNativeHost =
new DefaultReactNativeHost(this) {
new ReactNativeHost(this) {
@Override
public boolean getUseDeveloperSupport() {
return BuildConfig.DEBUG;
@@ -57,6 +62,7 @@ public class MainApplication extends NavigationApplication implements INotificat
// Packages that cannot be autolinked yet can be added manually here, for example:
// packages.add(new MyReactNativePackage());
packages.add(new RNNotificationsPackage(MainApplication.this));
packages.add(new LottiePackage());
packages.add(
@@ -66,14 +72,10 @@ public class MainApplication extends NavigationApplication implements INotificat
switch (name) {
case "MattermostManaged":
return MattermostManagedModule.getInstance(reactContext);
case "MattermostShare":
return ShareModule.getInstance(reactContext);
case "Notifications":
return NotificationsModule.getInstance(instance, reactContext);
case "SplitView":
return SplitViewModule.Companion.getInstance(reactContext);
default:
throw new IllegalArgumentException("Could not find module " + name);
case "NotificationPreferences":
return NotificationPreferencesModule.getInstance(instance, reactContext);
default:
throw new IllegalArgumentException("Could not find module " + name);
}
}
@@ -82,9 +84,7 @@ public class MainApplication extends NavigationApplication implements INotificat
return () -> {
Map<String, ReactModuleInfo> map = new HashMap<>();
map.put("MattermostManaged", new ReactModuleInfo("MattermostManaged", "com.mattermost.rnbeta.MattermostManagedModule", false, false, false, false, false));
map.put("MattermostShare", new ReactModuleInfo("MattermostShare", "com.mattermost.share.ShareModule", false, false, true, false, false));
map.put("Notifications", new ReactModuleInfo("Notifications", "com.mattermost.rnbeta.NotificationsModule", false, false, false, false, false));
map.put("SplitView", new ReactModuleInfo("SplitView", "com.mattermost.rnbeta.SplitViewModule", false, false, false, false, false));
map.put("NotificationPreferences", new ReactModuleInfo("NotificationPreferences", "com.mattermost.rnbeta.NotificationPreferencesModule", false, false, false, false, false));
return map;
};
}
@@ -96,11 +96,18 @@ public class MainApplication extends NavigationApplication implements INotificat
@Override
protected JSIModulePackage getJSIModulePackage() {
return (reactApplicationContext, jsContext) -> {
List<JSIModuleSpec> modules = Collections.emptyList();
modules.addAll(new WatermelonDBJSIPackage().getJSIModules(reactApplicationContext, jsContext));
return new JSIModulePackage() {
@Override
public List<JSIModuleSpec> getJSIModules(
final ReactApplicationContext reactApplicationContext,
final JavaScriptContextHolder jsContext
) {
List<JSIModuleSpec> modules = Arrays.asList();
modules.addAll(new WatermelonDBJSIPackage().getJSIModules(reactApplicationContext, jsContext));
modules.addAll(new ReanimatedJSIModulePackage().getJSIModules(reactApplicationContext, jsContext));
return modules;
return modules;
}
};
}
@@ -108,26 +115,30 @@ public class MainApplication extends NavigationApplication implements INotificat
protected String getJSMainModuleName() {
return "index";
}
@Override
protected boolean isNewArchEnabled() {
return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED;
}
@Override
protected Boolean isHermesEnabled() {
return BuildConfig.IS_HERMES_ENABLED;
}
};
private final ReactNativeHost mNewArchitectureNativeHost =
new MainApplicationReactNativeHost(this);
@Override
public ReactNativeHost getReactNativeHost() {
return mReactNativeHost;
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
return mNewArchitectureNativeHost;
} else {
return mReactNativeHost;
}
}
@Override
public void onCreate() {
super.onCreate();
instance = this;
// If you opted-in for the New Architecture, we enable the TurboModule system
ReactFeatureFlags.useTurboModules = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED;
SoLoader.init(this, /* native exopackage */ false);
initializeFlipper(this, getReactNativeHost().getReactInstanceManager());
Context context = getApplicationContext();
// Delete any previous temp files created by the app
@@ -139,13 +150,6 @@ public class MainApplication extends NavigationApplication implements INotificat
// with a cookie jar defined in APIClientModule and an interceptor to intercept all
// requests that originate from React Native's OKHttpClient
OkHttpClientProvider.setOkHttpClientFactory(new RCTOkHttpClientFactory());
SoLoader.init(this, /* native exopackage */ false);
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
DefaultNewArchitectureEntryPoint.load();
}
ReactNativeFlipper.initializeFlipper(this, getReactNativeHost().getReactInstanceManager());
}
@Override
@@ -158,4 +162,31 @@ public class MainApplication extends NavigationApplication implements INotificat
new JsIOHelper()
);
}
@Override
public IPushNotificationsDrawer getPushNotificationsDrawer(Context context, AppLaunchHelper defaultAppLaunchHelper) {
return new CustomPushNotificationDrawer(context, defaultAppLaunchHelper);
}
/**
* Loads Flipper in React Native templates. Call this in the onCreate method with something like
* initializeFlipper(this, getReactNativeHost().getReactInstanceManager());
*/
private static void initializeFlipper(
Context context, ReactInstanceManager reactInstanceManager) {
if (BuildConfig.DEBUG) {
try {
/*
We use reflection here to pick up the class that initializes Flipper,
since Flipper library is not available in release mode
*/
Class<?> aClass = Class.forName("com.rn.ReactNativeFlipper");
aClass
.getMethod("initializeFlipper", Context.class, ReactInstanceManager.class)
.invoke(null, context, reactInstanceManager);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}

View File

@@ -6,7 +6,6 @@ import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.text.TextUtils;
import android.webkit.MimeTypeMap;
import androidx.annotation.NonNull;
@@ -21,12 +20,8 @@ import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import com.mattermost.helpers.Credentials;
import com.reactlibrary.createthumbnail.CreateThumbnailModule;
import com.mattermost.helpers.RealPathUtil;
import java.io.File;
@@ -34,7 +29,6 @@ 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;
public class MattermostManagedModule extends ReactContextBaseJavaModule {
@@ -117,20 +111,16 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule {
}
@ReactMethod
public void getFilePath(String filePath, Promise promise) {
Activity currentActivity = getCurrentActivity();
WritableMap map = Arguments.createMap();
if (currentActivity != null) {
Uri uri = Uri.parse(filePath);
String path = RealPathUtil.getRealPathFromURI(currentActivity, uri);
if (path != null) {
String text = "file://" + path;
map.putString("filePath", text);
}
public void isRunningInSplitView(final Promise promise) {
WritableMap result = Arguments.createMap();
Activity current = getCurrentActivity();
if (current != null) {
result.putBoolean("isSplitView", current.isInMultiWindowMode());
} else {
result.putBoolean("isSplitView", false);
}
promise.resolve(map);
promise.resolve(result);
}
@ReactMethod
@@ -199,30 +189,6 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule {
}
}
@ReactMethod
public void createThumbnail(ReadableMap options, Promise promise) {
try {
WritableMap optionsMap = Arguments.createMap();
optionsMap.merge(options);
String url = options.hasKey("url") ? options.getString("url") : "";
URL videoUrl = new URL(url);
String serverUrl = videoUrl.getProtocol() + "://" + videoUrl.getHost() + ":" + videoUrl.getPort();
String token = Credentials.getCredentialsForServerSync(this.reactContext, serverUrl);
if (!TextUtils.isEmpty(token)) {
WritableMap headers = Arguments.createMap();
if (optionsMap.hasKey("headers")) {
headers.merge(optionsMap.getMap("headers"));
}
headers.putString("Authorization", "Bearer " + token);
optionsMap.putMap("headers", headers);
}
CreateThumbnailModule thumb = new CreateThumbnailModule(this.reactContext);
thumb.create(optionsMap.copy(), promise);
} catch (Exception e) {
promise.reject("CreateThumbnail_ERROR", e);
}
}
private static class SaveDataTask extends GuardedResultAsyncTask<Object> {
private final WeakReference<Context> weakContext;
private final String fromFile;

View File

@@ -6,7 +6,7 @@ import android.app.IntentService;
import android.os.Bundle;
import android.util.Log;
import com.mattermost.helpers.NotificationHelper;
import com.mattermost.helpers.CustomPushNotificationHelper;
import com.wix.reactnativenotifications.core.NotificationIntentAdapter;
public class NotificationDismissService extends IntentService {
@@ -18,8 +18,16 @@ public class NotificationDismissService extends IntentService {
protected void onHandleIntent(Intent intent) {
final Context context = getApplicationContext();
final Bundle bundle = NotificationIntentAdapter.extractPendingNotificationDataFromIntent(intent);
final String channelId = bundle.getString("channel_id");
final String postId = bundle.getString("post_id");
int notificationId = CustomPushNotificationHelper.MESSAGE_NOTIFICATION_ID;
if (postId != null) {
notificationId = postId.hashCode();
} else if (channelId != null) {
notificationId = channelId.hashCode();
}
NotificationHelper.dismissNotification(context, bundle);
CustomPushNotification.cancelNotification(context, channelId, notificationId);
Log.i("ReactNative", "Dismiss notification");
}
}

View File

@@ -0,0 +1,66 @@
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;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
public class NotificationPreferencesModule extends ReactContextBaseJavaModule {
private static NotificationPreferencesModule instance;
private final MainApplication mApplication;
private NotificationPreferencesModule(MainApplication application, ReactApplicationContext reactContext) {
super(reactContext);
mApplication = application;
Context context = mApplication.getApplicationContext();
}
public static NotificationPreferencesModule getInstance(MainApplication application, ReactApplicationContext reactContext) {
if (instance == null) {
instance = new NotificationPreferencesModule(application, reactContext);
}
return instance;
}
public static NotificationPreferencesModule getInstance() {
return instance;
}
@Override
public String getName() {
return "NotificationPreferences";
}
@ReactMethod
public void getDeliveredNotifications(final Promise promise) {
final Context context = mApplication.getApplicationContext();
final NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
StatusBarNotification[] statusBarNotifications = notificationManager.getActiveNotifications();
WritableArray result = Arguments.createArray();
for (StatusBarNotification sbn:statusBarNotifications) {
WritableMap map = Arguments.createMap();
Notification n = sbn.getNotification();
Bundle bundle = n.extras;
String channelId = bundle.getString("channel_id");
map.putString("channel_id", channelId);
result.pushMap(map);
}
promise.resolve(result);
}
@ReactMethod
public void removeDeliveredNotifications(String channelId) {
Context context = mApplication.getApplicationContext();
CustomPushNotification.clearChannelNotifications(context, channelId);
}
}

View File

@@ -1,80 +0,0 @@
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;
import androidx.annotation.NonNull;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import com.mattermost.helpers.NotificationHelper;
import java.util.Set;
public class NotificationsModule extends ReactContextBaseJavaModule {
private static NotificationsModule instance;
private final MainApplication mApplication;
private NotificationsModule(MainApplication application, ReactApplicationContext reactContext) {
super(reactContext);
mApplication = application;
}
public static NotificationsModule getInstance(MainApplication application, ReactApplicationContext reactContext) {
if (instance == null) {
instance = new NotificationsModule(application, reactContext);
}
return instance;
}
@NonNull
@Override
public String getName() {
return "Notifications";
}
@ReactMethod
public void getDeliveredNotifications(final Promise promise) {
Context context = mApplication.getApplicationContext();
StatusBarNotification[] notifications = NotificationHelper.getDeliveredNotifications(context);
WritableArray result = Arguments.createArray();
for (StatusBarNotification sbn:notifications) {
WritableMap map = Arguments.createMap();
Notification n = sbn.getNotification();
Bundle bundle = n.extras;
Set<String> keys = bundle.keySet();
for (String key: keys) {
map.putString(key, bundle.getString(key));
}
result.pushMap(map);
}
promise.resolve(result);
}
@ReactMethod
public void removeChannelNotifications(String serverUrl, String channelId) {
Context context = mApplication.getApplicationContext();
NotificationHelper.removeChannelNotifications(context, serverUrl, channelId);
}
@ReactMethod
public void removeThreadNotifications(String serverUrl, String threadId) {
Context context = mApplication.getApplicationContext();
NotificationHelper.removeThreadNotifications(context, serverUrl, threadId);
}
@ReactMethod
public void removeServerNotifications(String serverUrl) {
Context context = mApplication.getApplicationContext();
NotificationHelper.removeServerNotifications(context, serverUrl);
}
}

View File

@@ -1,73 +0,0 @@
package com.mattermost.rnbeta
import com.facebook.react.bridge.*
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
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 {
private var instance: SplitViewModule? = null
fun getInstance(reactContext: ReactApplicationContext): SplitViewModule {
if (instance == null) {
instance = SplitViewModule(reactContext)
} else {
instance!!.reactContext = reactContext
}
return instance!!
}
fun getInstance(): SplitViewModule? {
return instance
}
}
override fun getName() = "SplitView"
fun sendEvent(eventName: String,
params: WritableMap?) {
reactContext
.getJSModule(RCTDeviceEventEmitter::class.java)
.emit(eventName, params)
}
private fun getSplitViewResults(folded: Boolean) : WritableMap? {
if (currentActivity != null) {
val deviceResolver = DeviceTypeResolver(this.reactContext)
val map = Arguments.createMap()
map.putBoolean("isSplitView", currentActivity!!.isInMultiWindowMode || folded)
map.putBoolean("isTablet", deviceResolver.isTablet)
return map
}
return null
}
fun setDeviceFolded(folded: Boolean) {
val map = getSplitViewResults(folded)
if (listenerCount > 0 && isDeviceFolded != folded) {
sendEvent("SplitViewChanged", map)
}
isDeviceFolded = folded
}
@ReactMethod
fun isRunningInSplitView(promise: Promise) {
promise.resolve(getSplitViewResults(isDeviceFolded))
}
@ReactMethod
fun addListener(eventName: String) {
listenerCount += 1
}
@ReactMethod
fun removeListeners(count: Int) {
listenerCount -= count
}
}

View File

@@ -1,20 +0,0 @@
package com.mattermost.share;
import android.os.Bundle;
import com.facebook.react.ReactActivity;
import com.mattermost.rnbeta.MainApplication;
public class ShareActivity extends ReactActivity {
@Override
protected String getMainComponentName() {
return "MattermostShare";
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
MainApplication app = (MainApplication) this.getApplication();
app.sharedExtensionIsOpened = true;
}
}

View File

@@ -1,258 +0,0 @@
package com.mattermost.share;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.Arguments;
import com.mattermost.helpers.Credentials;
import com.mattermost.rnbeta.MainApplication;
import com.mattermost.helpers.RealPathUtil;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.io.File;
import java.util.ArrayList;
import org.json.JSONArray;
import org.json.JSONObject;
import org.json.JSONException;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
import okhttp3.Response;
public class ShareModule extends ReactContextBaseJavaModule {
private final OkHttpClient client = new OkHttpClient();
public static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
private static ShareModule instance;
private final MainApplication mApplication;
private ReactApplicationContext mReactContext;
private File tempFolder;
private ShareModule(ReactApplicationContext reactContext) {
super(reactContext);
mReactContext = reactContext;
mApplication = (MainApplication)reactContext.getApplicationContext();
}
public static ShareModule getInstance(ReactApplicationContext reactContext) {
if (instance == null) {
instance = new ShareModule(reactContext);
} else {
instance.mReactContext = reactContext;
}
return instance;
}
public static ShareModule getInstance() {
return instance;
}
@NonNull
@Override
public String getName() {
return "MattermostShare";
}
@ReactMethod(isBlockingSynchronousMethod = true)
public String getCurrentActivityName() {
Activity currentActivity = getCurrentActivity();
if (currentActivity != null) {
String actvName = currentActivity.getComponentName().getClassName();
String[] components = actvName.split("\\.");
return components[components.length - 1];
}
return "";
}
@ReactMethod
public void clear() {
Activity currentActivity = getCurrentActivity();
if (currentActivity != null && this.getCurrentActivityName().equals("ShareActivity")) {
Intent intent = currentActivity.getIntent();
intent.setAction("");
intent.removeExtra(Intent.EXTRA_TEXT);
intent.removeExtra(Intent.EXTRA_STREAM);
}
}
@Nullable
@Override
public Map<String, Object> getConstants() {
HashMap<String, Object> constants = new HashMap<>(1);
constants.put("cacheDirName", RealPathUtil.CACHE_DIR_NAME);
constants.put("isOpened", mApplication.sharedExtensionIsOpened);
return constants;
}
@ReactMethod
public void close(ReadableMap data) {
this.clear();
Activity currentActivity = getCurrentActivity();
if (currentActivity == null || !this.getCurrentActivityName().equals("ShareActivity")) {
return;
}
currentActivity.finishAndRemoveTask();
if (data != null && data.hasKey("serverUrl")) {
ReadableArray files = data.getArray("files");
String serverUrl = data.getString("serverUrl");
final String token = Credentials.getCredentialsForServerSync(this.getReactApplicationContext(), serverUrl);
JSONObject postData = buildPostObject(data);
if (files != null && files.size() > 0) {
uploadFiles(serverUrl, token, files, postData);
} else {
try {
post(serverUrl, token, postData);
} catch (IOException e) {
e.printStackTrace();
}
}
}
mApplication.sharedExtensionIsOpened = false;
RealPathUtil.deleteTempFiles(this.tempFolder);
}
@ReactMethod
public void getSharedData(Promise promise) {
promise.resolve(processIntent());
}
public WritableArray processIntent() {
String type, action, extra;
WritableArray items = Arguments.createArray();
Activity currentActivity = getCurrentActivity();
if (currentActivity != null) {
this.tempFolder = new File(currentActivity.getCacheDir(), RealPathUtil.CACHE_DIR_NAME);
Intent intent = currentActivity.getIntent();
action = intent.getAction();
type = intent.getType();
extra = intent.getStringExtra(Intent.EXTRA_TEXT);
if (Intent.ACTION_SEND.equals(action) && "text/plain".equals(type) && extra != null) {
items.pushMap(ShareUtils.getTextItem(extra));
} else if (Intent.ACTION_SEND.equals(action)) {
if (extra != null) {
items.pushMap(ShareUtils.getTextItem(extra));
}
Uri uri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
if (uri != null) {
ReadableMap fileInfo = ShareUtils.getFileItem(currentActivity, uri);
if (fileInfo != null) {
items.pushMap(fileInfo);
}
}
} else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
if (extra != null) {
items.pushMap(ShareUtils.getTextItem(extra));
}
ArrayList<Uri> uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
for (Uri uri : uris) {
ReadableMap fileInfo = ShareUtils.getFileItem(currentActivity, uri);
if (fileInfo != null) {
items.pushMap(fileInfo);
}
}
}
}
return items;
}
private JSONObject buildPostObject(ReadableMap data) {
JSONObject json = new JSONObject();
try {
json.put("user_id", data.getString("userId"));
if (data.hasKey("channelId")) {
json.put("channel_id", data.getString("channelId"));
}
if (data.hasKey("message")) {
json.put("message", data.getString("message"));
}
} catch (JSONException e) {
e.printStackTrace();
}
return json;
}
private void post(String serverUrl, String token, JSONObject postData) throws IOException {
RequestBody body = RequestBody.create(postData.toString(), JSON);
Request request = new Request.Builder()
.header("Authorization", "BEARER " + token)
.url(serverUrl + "/api/v4/posts")
.post(body)
.build();
client.newCall(request).execute();
}
private void uploadFiles(String serverUrl, String token, ReadableArray files, JSONObject postData) {
try {
MultipartBody.Builder builder = new MultipartBody.Builder()
.setType(MultipartBody.FORM);
for(int i = 0 ; i < files.size() ; i++) {
ReadableMap file = files.getMap(i);
String mime = file.getString("type");
String fullPath = file.getString("value");
if (fullPath != null) {
String filePath = fullPath.replaceFirst("file://", "");
File fileInfo = new File(filePath);
if (fileInfo.exists() && mime != null) {
final MediaType MEDIA_TYPE = MediaType.parse(mime);
builder.addFormDataPart("files", file.getString("filename"), RequestBody.create(fileInfo, MEDIA_TYPE));
}
}
}
builder.addFormDataPart("channel_id", postData.getString("channel_id"));
RequestBody body = builder.build();
Request request = new Request.Builder()
.header("Authorization", "BEARER " + token)
.url(serverUrl + "/api/v4/files")
.post(body)
.build();
try (Response response = client.newCall(request).execute()) {
if (response.isSuccessful()) {
String responseData = response.body().string();
JSONObject responseJson = new JSONObject(responseData);
JSONArray fileInfoArray = responseJson.getJSONArray("file_infos");
JSONArray file_ids = new JSONArray();
for(int i = 0 ; i < fileInfoArray.length() ; i++) {
JSONObject fileInfo = fileInfoArray.getJSONObject(i);
file_ids.put(fileInfo.getString("id"));
}
postData.put("file_ids", file_ids);
post(serverUrl, token, postData);
}
} catch (IOException e) {
e.printStackTrace();
}
} catch (JSONException e) {
e.printStackTrace();
}
}
}

View File

@@ -1,115 +0,0 @@
package com.mattermost.share;
import android.app.Activity;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.media.MediaMetadataRetriever;
import android.net.Uri;
import android.webkit.URLUtil;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.WritableMap;
import com.mattermost.helpers.RealPathUtil;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.UUID;
public class ShareUtils {
public static ReadableMap getTextItem(String text) {
WritableMap map = Arguments.createMap();
map.putString("value", text);
map.putString("type", "");
map.putBoolean("isString", true);
return map;
}
public static ReadableMap getFileItem(Activity activity, Uri uri) {
WritableMap map = Arguments.createMap();
String filePath = RealPathUtil.getRealPathFromURI(activity, uri);
if (filePath == null) {
return null;
}
File file = new File(filePath);
String type = RealPathUtil.getMimeTypeFromUri(activity, uri);
if (type != null) {
if (type.startsWith("image/")) {
BitmapFactory.Options bitMapOption = ShareUtils.getImageDimensions(filePath);
map.putInt("height", bitMapOption.outHeight);
map.putInt("width", bitMapOption.outWidth);
} else if (type.startsWith("video/")) {
File cacheDir = new File(activity.getCacheDir(), RealPathUtil.CACHE_DIR_NAME);
addVideoThumbnailToMap(cacheDir, activity.getApplicationContext(), map, "file://" + filePath);
}
} else {
type = "application/octet-stream";
}
map.putString("value", "file://" + filePath);
map.putDouble("size", (double) file.length());
map.putString("filename", file.getName());
map.putString("type", type);
map.putString("extension", RealPathUtil.getExtension(filePath).replaceFirst(".", ""));
map.putBoolean("isString", false);
return map;
}
public static BitmapFactory.Options getImageDimensions(String filePath) {
BitmapFactory.Options bitMapOption = new BitmapFactory.Options();
bitMapOption.inJustDecodeBounds=true;
BitmapFactory.decodeFile(filePath, bitMapOption);
return bitMapOption;
}
private static void addVideoThumbnailToMap(File cacheDir, Context context, WritableMap map, String filePath) {
String fileName = ("thumb-" + UUID.randomUUID().toString()) + ".png";
OutputStream fOut = null;
try {
File file = new File(cacheDir, fileName);
Bitmap image = getBitmapAtTime(context, filePath, 1);
if (file.createNewFile()) {
fOut = new FileOutputStream(file);
image.compress(Bitmap.CompressFormat.PNG, 100, fOut);
fOut.flush();
fOut.close();
map.putString("videoThumb", "file://" + file.getAbsolutePath());
map.putInt("width", image.getWidth());
map.putInt("height", image.getHeight());
}
} catch (Exception ignored) {
}
}
private static Bitmap getBitmapAtTime(Context context, String filePath, int time) {
try {
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
if (URLUtil.isFileUrl(filePath)) {
String decodedPath;
try {
decodedPath = URLDecoder.decode(filePath, "UTF-8");
} catch (UnsupportedEncodingException e) {
decodedPath = filePath;
}
retriever.setDataSource(decodedPath.replace("file://", ""));
} else if (filePath.contains("content://")) {
retriever.setDataSource(context, Uri.parse(filePath));
}
Bitmap image = retriever.getFrameAtTime(time * 1000, MediaMetadataRetriever.OPTION_CLOSEST_SYNC);
retriever.release();
return image;
} catch (Exception e) {
throw new IllegalStateException("File doesn't exist or not supported");
}
}
}

View File

@@ -0,0 +1,49 @@
THIS_DIR := $(call my-dir)
include $(REACT_ANDROID_DIR)/Android-prebuilt.mk
# If you wish to add a custom TurboModule or Fabric component in your app you
# will have to include the following autogenerated makefile.
# include $(GENERATED_SRC_DIR)/codegen/jni/Android.mk
include $(CLEAR_VARS)
LOCAL_PATH := $(THIS_DIR)
# You can customize the name of your application .so file here.
LOCAL_MODULE := mattermost_appmodules
LOCAL_C_INCLUDES := $(LOCAL_PATH)
LOCAL_SRC_FILES := $(wildcard $(LOCAL_PATH)/*.cpp)
LOCAL_EXPORT_C_INCLUDES := $(LOCAL_PATH)
# If you wish to add a custom TurboModule or Fabric component in your app you
# will have to uncomment those lines to include the generated source
# files from the codegen (placed in $(GENERATED_SRC_DIR)/codegen/jni)
#
# LOCAL_C_INCLUDES += $(GENERATED_SRC_DIR)/codegen/jni
# LOCAL_SRC_FILES += $(wildcard $(GENERATED_SRC_DIR)/codegen/jni/*.cpp)
# LOCAL_EXPORT_C_INCLUDES += $(GENERATED_SRC_DIR)/codegen/jni
# Here you should add any native library you wish to depend on.
LOCAL_SHARED_LIBRARIES := \
libfabricjni \
libfbjni \
libfolly_futures \
libfolly_json \
libglog \
libjsi \
libreact_codegen_rncore \
libreact_debug \
libreact_nativemodule_core \
libreact_render_componentregistry \
libreact_render_core \
libreact_render_debug \
libreact_render_graphics \
librrc_view \
libruntimeexecutor \
libturbomodulejsijni \
libyoga
LOCAL_CFLAGS := -DLOG_TAG=\"ReactNative\" -fexceptions -frtti -std=c++17 -Wall
include $(BUILD_SHARED_LIBRARY)

View File

@@ -0,0 +1,24 @@
#include "MainApplicationModuleProvider.h"
#include <rncore.h>
namespace facebook {
namespace react {
std::shared_ptr<TurboModule> MainApplicationModuleProvider(
const std::string moduleName,
const JavaTurboModule::InitParams &params) {
// Here you can provide your own module provider for TurboModules coming from
// either your application or from external libraries. The approach to follow
// is similar to the following (for a library called `samplelibrary`:
//
// auto module = samplelibrary_ModuleProvider(moduleName, params);
// if (module != nullptr) {
// return module;
// }
// return rncore_ModuleProvider(moduleName, params);
return rncore_ModuleProvider(moduleName, params);
}
} // namespace react
} // namespace facebook

View File

@@ -0,0 +1,16 @@
#pragma once
#include <memory>
#include <string>
#include <ReactCommon/JavaTurboModule.h>
namespace facebook {
namespace react {
std::shared_ptr<TurboModule> MainApplicationModuleProvider(
const std::string moduleName,
const JavaTurboModule::InitParams &params);
} // namespace react
} // namespace facebook

View File

@@ -0,0 +1,45 @@
#include "MainApplicationTurboModuleManagerDelegate.h"
#include "MainApplicationModuleProvider.h"
namespace facebook {
namespace react {
jni::local_ref<MainApplicationTurboModuleManagerDelegate::jhybriddata>
MainApplicationTurboModuleManagerDelegate::initHybrid(
jni::alias_ref<jhybridobject>) {
return makeCxxInstance();
}
void MainApplicationTurboModuleManagerDelegate::registerNatives() {
registerHybrid({
makeNativeMethod(
"initHybrid", MainApplicationTurboModuleManagerDelegate::initHybrid),
makeNativeMethod(
"canCreateTurboModule",
MainApplicationTurboModuleManagerDelegate::canCreateTurboModule),
});
}
std::shared_ptr<TurboModule>
MainApplicationTurboModuleManagerDelegate::getTurboModule(
const std::string name,
const std::shared_ptr<CallInvoker> jsInvoker) {
// Not implemented yet: provide pure-C++ NativeModules here.
return nullptr;
}
std::shared_ptr<TurboModule>
MainApplicationTurboModuleManagerDelegate::getTurboModule(
const std::string name,
const JavaTurboModule::InitParams &params) {
return MainApplicationModuleProvider(name, params);
}
bool MainApplicationTurboModuleManagerDelegate::canCreateTurboModule(
std::string name) {
return getTurboModule(name, nullptr) != nullptr ||
getTurboModule(name, {.moduleName = name}) != nullptr;
}
} // namespace react
} // namespace facebook

View File

@@ -0,0 +1,38 @@
#include <memory>
#include <string>
#include <ReactCommon/TurboModuleManagerDelegate.h>
#include <fbjni/fbjni.h>
namespace facebook {
namespace react {
class MainApplicationTurboModuleManagerDelegate
: public jni::HybridClass<
MainApplicationTurboModuleManagerDelegate,
TurboModuleManagerDelegate> {
public:
// Adapt it to the package you used for your Java class.
static constexpr auto kJavaDescriptor =
"Lcom/mattermost/newarchitecture/modules/MainApplicationTurboModuleManagerDelegate;";
static jni::local_ref<jhybriddata> initHybrid(jni::alias_ref<jhybridobject>);
static void registerNatives();
std::shared_ptr<TurboModule> getTurboModule(
const std::string name,
const std::shared_ptr<CallInvoker> jsInvoker) override;
std::shared_ptr<TurboModule> getTurboModule(
const std::string name,
const JavaTurboModule::InitParams &params) override;
/**
* Test-only method. Allows user to verify whether a TurboModule can be
* created by instances of this class.
*/
bool canCreateTurboModule(std::string name);
};
} // namespace react
} // namespace facebook

View File

@@ -0,0 +1,61 @@
#include "MainComponentsRegistry.h"
#include <CoreComponentsRegistry.h>
#include <fbjni/fbjni.h>
#include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h>
#include <react/renderer/components/rncore/ComponentDescriptors.h>
namespace facebook {
namespace react {
MainComponentsRegistry::MainComponentsRegistry(ComponentFactory *delegate) {}
std::shared_ptr<ComponentDescriptorProviderRegistry const>
MainComponentsRegistry::sharedProviderRegistry() {
auto providerRegistry = CoreComponentsRegistry::sharedProviderRegistry();
// Custom Fabric Components go here. You can register custom
// components coming from your App or from 3rd party libraries here.
//
// providerRegistry->add(concreteComponentDescriptorProvider<
// AocViewerComponentDescriptor>());
return providerRegistry;
}
jni::local_ref<MainComponentsRegistry::jhybriddata>
MainComponentsRegistry::initHybrid(
jni::alias_ref<jclass>,
ComponentFactory *delegate) {
auto instance = makeCxxInstance(delegate);
auto buildRegistryFunction =
[](EventDispatcher::Weak const &eventDispatcher,
ContextContainer::Shared const &contextContainer)
-> ComponentDescriptorRegistry::Shared {
auto registry = MainComponentsRegistry::sharedProviderRegistry()
->createComponentDescriptorRegistry(
{eventDispatcher, contextContainer});
auto mutableRegistry =
std::const_pointer_cast<ComponentDescriptorRegistry>(registry);
mutableRegistry->setFallbackComponentDescriptor(
std::make_shared<UnimplementedNativeViewComponentDescriptor>(
ComponentDescriptorParameters{
eventDispatcher, contextContainer, nullptr}));
return registry;
};
delegate->buildRegistryFunction = buildRegistryFunction;
return instance;
}
void MainComponentsRegistry::registerNatives() {
registerHybrid({
makeNativeMethod("initHybrid", MainComponentsRegistry::initHybrid),
});
}
} // namespace react
} // namespace facebook

View File

@@ -0,0 +1,32 @@
#pragma once
#include <ComponentFactory.h>
#include <fbjni/fbjni.h>
#include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h>
#include <react/renderer/componentregistry/ComponentDescriptorRegistry.h>
namespace facebook {
namespace react {
class MainComponentsRegistry
: public facebook::jni::HybridClass<MainComponentsRegistry> {
public:
// Adapt it to the package you used for your Java class.
constexpr static auto kJavaDescriptor =
"Lcom/mattermost/newarchitecture/components/MainComponentsRegistry;";
static void registerNatives();
MainComponentsRegistry(ComponentFactory *delegate);
private:
static std::shared_ptr<ComponentDescriptorProviderRegistry const>
sharedProviderRegistry();
static jni::local_ref<jhybriddata> initHybrid(
jni::alias_ref<jclass>,
ComponentFactory *delegate);
};
} // namespace react
} // namespace facebook

View File

@@ -0,0 +1,11 @@
#include <fbjni/fbjni.h>
#include "MainApplicationTurboModuleManagerDelegate.h"
#include "MainComponentsRegistry.h"
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
return facebook::jni::initialize(vm, [] {
facebook::react::MainApplicationTurboModuleManagerDelegate::
registerNatives();
facebook::react::MainComponentsRegistry::registerNatives();
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 730 KiB

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 730 KiB

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 730 KiB

After

Width:  |  Height:  |  Size: 3.0 MiB

View File

@@ -2,5 +2,5 @@
<resources>
<color name="white">#FFFFFF</color>
<color name="transparent">#00000000</color>
<color name="splashscreen_bg">#090A0B</color>
<color name="splashscreen_bg">#1E325C</color>
</resources>

View File

@@ -1,8 +0,0 @@
<?xml version='1.0' encoding='utf-8'?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="files" path="." />
<external-files-path name="external_files" path="." />
<external-path name="external" path="." />
<cache-path name="cache" path="." />
<root-path name="root" path="." />
</paths>

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

@@ -2,18 +2,16 @@
buildscript {
ext {
buildToolsVersion = "33.0.0"
buildToolsVersion = "31.0.0"
minSdkVersion = 24
compileSdkVersion = 33
targetSdkVersion = 33
supportLibVersion = "33.0.0"
compileSdkVersion = 30
targetSdkVersion = 30
ndkVersion = "21.4.7075529"
supportLibVersion = "30.0.0"
kotlinVersion = "1.5.30"
kotlin_version = "1.5.30"
firebaseVersion = "21.0.0"
RNNKotlinVersion = kotlinVersion
// We use NDK 23 which has both M1 support and is the side-by-side NDK version from AGP.
ndkVersion = "23.1.7779620"
}
repositories {
mavenCentral()
@@ -21,9 +19,10 @@ buildscript {
google()
}
dependencies {
classpath("com.android.tools.build:gradle:7.3.1")
classpath("com.android.tools.build:gradle:7.0.4")
classpath("com.facebook.react:react-native-gradle-plugin")
classpath('com.google.gms:google-services:4.3.14')
classpath("de.undercouch:gradle-download-task:4.1.2")
classpath('com.google.gms:google-services:4.3.10')
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
// NOTE: Do not place your application dependencies here; they belong
@@ -33,25 +32,34 @@ buildscript {
allprojects {
repositories {
google()
mavenCentral()
mavenLocal()
maven {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
url("$rootDir/../node_modules/react-native/android")
// Replace AAR from original RN with AAR from react-native-v8
// url("$rootDir/../node_modules/react-native-v8/dist")
}
maven {
// Local Maven repo containing AARs with JSC library built for Android
url("$rootDir/../node_modules/jsc-android/dist")
// prebuilt libv8android.so
// url("$rootDir/../node_modules/v8-android/dist")
}
maven {
url "https://www.jitpack.io"
}
maven {
url "$rootDir/../node_modules/detox/Detox-android"
}
}
}
subprojects {
afterEvaluate { subproject ->
if(subproject['name'] == 'react-native-create-thumbnail'){
def myAttribute = Attribute.of("com.android.build.api.attributes.BuildTypeAttr", String)
dependencies.attributesSchema {
attribute(myAttribute)
}
configurations {
implementation {
attributes {
attribute(myAttribute, "release")
}
}
mavenCentral {
// We don't want to fetch react-native from Maven Central as there are
// older versions over there.
content {
excludeGroup "com.facebook.react"
}
}
}

View File

@@ -40,8 +40,4 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=false
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
newArchEnabled=false

Binary file not shown.

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-all.zip

View File

@@ -1,5 +1,13 @@
rootProject.name = 'Mattermost'
include ':app'
includeBuild('../node_modules/react-native-gradle-plugin')
if (settings.hasProperty("newArchEnabled") && settings.newArchEnabled == "true") {
include(":ReactAndroid")
project(":ReactAndroid").projectDir = file('../node_modules/react-native/ReactAndroid')
}
include ':lottie-react-native'
project(':lottie-react-native').projectDir = new File(rootProject.projectDir, '../node_modules/lottie-react-native/src/android')
include ':reactnativenotifications'
project(':reactnativenotifications').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-notifications/lib/android/app')
apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
@@ -7,4 +15,3 @@ 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')

View File

@@ -1,52 +1,31 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Tutorial} from '@constants';
import {GLOBAL_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {logError} from '@utils/log';
export const storeGlobal = async (id: string, value: unknown, prepareRecordsOnly = false) => {
try {
const {operator} = DatabaseManager.getAppDatabaseAndOperator();
return operator.handleGlobal({
globals: [{id, value}],
prepareRecordsOnly,
});
} catch (error) {
logError('storeGlobal', error);
return {error};
}
};
export const storeDeviceToken = async (token: string, prepareRecordsOnly = false) => {
return storeGlobal(GLOBAL_IDENTIFIERS.DEVICE_TOKEN, token, prepareRecordsOnly);
};
const operator = DatabaseManager.appDatabase?.operator;
export const storeOnboardingViewedValue = async (value = true) => {
return storeGlobal(GLOBAL_IDENTIFIERS.ONBOARDING, value, false);
if (!operator) {
return {error: 'No App database found'};
}
return operator.handleGlobal({
globals: [{id: GLOBAL_IDENTIFIERS.DEVICE_TOKEN, value: token}],
prepareRecordsOnly,
});
};
export const storeMultiServerTutorial = async (prepareRecordsOnly = false) => {
return storeGlobal(Tutorial.MULTI_SERVER, 'true', prepareRecordsOnly);
};
const operator = DatabaseManager.appDatabase?.operator;
export const storeProfileLongPressTutorial = async (prepareRecordsOnly = false) => {
return storeGlobal(Tutorial.PROFILE_LONG_PRESS, 'true', prepareRecordsOnly);
};
if (!operator) {
return {error: 'No App database found'};
}
export const storeSkinEmojiSelectorTutorial = async (prepareRecordsOnly = false) => {
return storeGlobal(Tutorial.EMOJI_SKIN_SELECTOR, 'true', prepareRecordsOnly);
};
export const storeDontAskForReview = async (prepareRecordsOnly = false) => {
return storeGlobal(GLOBAL_IDENTIFIERS.DONT_ASK_FOR_REVIEW, 'true', prepareRecordsOnly);
};
export const storeLastAskForReview = async (prepareRecordsOnly = false) => {
return storeGlobal(GLOBAL_IDENTIFIERS.LAST_ASK_FOR_REVIEW, Date.now(), prepareRecordsOnly);
};
export const storeFirstLaunch = async (prepareRecordsOnly = false) => {
return storeGlobal(GLOBAL_IDENTIFIERS.FIRST_LAUNCH, Date.now(), prepareRecordsOnly);
return operator.handleGlobal({
globals: [{id: GLOBAL_IDENTIFIERS.MULTI_SERVER_TUTORIAL, value: 'true'}],
prepareRecordsOnly,
});
};

View File

@@ -1,20 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {CHANNELS_CATEGORY, DMS_CATEGORY} from '@constants/categories';
import DatabaseManager from '@database/manager';
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 {logError} from '@utils/log';
import {Model} from '@nozbe/watermelondb';
import type {Model} from '@nozbe/watermelondb';
import type ChannelModel from '@typings/database/models/servers/channel';
import DatabaseManager from '@database/manager';
import {prepareCategories, prepareCategoryChannels, queryCategoriesByTeamIds, getCategoryById} from '@queries/servers/categories';
import {pluckUnique} from '@utils/helpers';
export const deleteCategory = async (serverUrl: string, categoryId: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const category = await getCategoryById(database, categoryId);
if (category) {
@@ -25,34 +24,74 @@ export const deleteCategory = async (serverUrl: string, categoryId: string) => {
return {category};
} catch (error) {
logError('FAILED TO DELETE CATEGORY', categoryId);
// eslint-disable-next-line no-console
console.log('FAILED TO DELETE CATEGORY', categoryId);
return {error};
}
};
export async function storeCategories(serverUrl: string, categories: CategoryWithChannels[], prune = false, prepareRecordsOnly = false) {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const models = await prepareCategoriesAndCategoriesChannels(operator, categories, prune);
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (prepareRecordsOnly) {
return {models};
}
if (models.length > 0) {
await operator.batchRecords(models);
}
return {models};
} catch (error) {
logError('FAILED TO STORE CATEGORIES', error);
return {error};
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const modelPromises: Array<Promise<Model[]>> = [];
const preparedCategories = prepareCategories(operator, categories);
if (preparedCategories) {
modelPromises.push(preparedCategories);
}
const preparedCategoryChannels = prepareCategoryChannels(operator, categories);
if (preparedCategoryChannels) {
modelPromises.push(preparedCategoryChannels);
}
const models = await Promise.all(modelPromises);
const flattenedModels = models.flat();
if (prune && categories.length) {
const {database} = operator;
const remoteCategoryIds = new Set(categories.map((cat) => cat.id));
// If the passed categories have more than one team, we want to update across teams
const teamIds = pluckUnique('team_id')(categories) as string[];
const localCategories = await queryCategoriesByTeamIds(database, teamIds).fetch();
localCategories.
filter((category) => category.type === 'custom').
forEach((localCategory) => {
if (!remoteCategoryIds.has(localCategory.id)) {
localCategory.prepareDestroyPermanently();
flattenedModels.push(localCategory);
}
});
}
if (prepareRecordsOnly) {
return {models: flattenedModels};
}
if (flattenedModels?.length > 0) {
try {
await operator.batchRecords(flattenedModels);
} catch (error) {
// eslint-disable-next-line no-console
console.log('FAILED TO BATCH CATEGORIES', error);
return {error};
}
}
return {models: flattenedModels};
}
export const toggleCollapseCategory = async (serverUrl: string, categoryId: string) => {
const database = DatabaseManager.serverDatabases[serverUrl].database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const category = await getCategoryById(database, categoryId);
if (category) {
@@ -65,52 +104,8 @@ export const toggleCollapseCategory = async (serverUrl: string, categoryId: stri
return {category};
} catch (error) {
logError('FAILED TO COLLAPSE CATEGORY', categoryId, error);
// eslint-disable-next-line no-console
console.log('FAILED TO COLLAPSE CATEGORY', categoryId, error);
return {error};
}
};
export async function addChannelToDefaultCategory(serverUrl: string, channel: Channel | ChannelModel, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const teamId = 'teamId' in channel ? channel.teamId : channel.team_id;
const userId = await getCurrentUserId(database);
if (!userId) {
return {error: 'no current user id'};
}
const models: Model[] = [];
const categoriesWithChannels: CategoryWithChannels[] = [];
if (isDMorGM(channel)) {
const allTeamIds = await queryMyTeams(database).fetchIds();
const categories = await queryCategoriesByTeamIds(database, allTeamIds).fetch();
const channelCategories = categories.filter((c) => c.type === DMS_CATEGORY);
for await (const cc of channelCategories) {
const cwc = await cc.toCategoryWithChannels();
cwc.channel_ids.unshift(channel.id);
categoriesWithChannels.push(cwc);
}
} else {
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);
}
const ccModels = await prepareCategoryChannels(operator, categoriesWithChannels);
models.push(...ccModels);
}
if (models.length && !prepareRecordsOnly) {
await operator.batchRecords(models);
}
return {models};
} catch (error) {
return {error};
}
}

View File

@@ -1,11 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database} from '@nozbe/watermelondb';
import {DeviceEventEmitter} from 'react-native';
import {Navigation} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import ServerDataOperator from '@database/operator/server_data_operator';
import {getMyChannel} from '@queries/servers/channel';
import {getCommonSystemValues, getTeamHistory} from '@queries/servers/system';
import {getTeamChannelHistory} from '@queries/servers/team';
@@ -13,9 +15,6 @@ import {dismissAllModalsAndPopToRoot, dismissAllModalsAndPopToScreen} from '@scr
import {switchToChannel} from './channel';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type {Database} from '@nozbe/watermelondb';
let mockIsTablet: jest.Mock;
const now = new Date('2020-01-01').getTime();
@@ -65,7 +64,7 @@ describe('switchToChannel', () => {
} as ChannelMembership;
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
operator = DatabaseManager.serverDatabases[serverUrl].operator;
spyNow = jest.spyOn(Date, 'now').mockImplementation(() => now);
});

View File

@@ -1,43 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Model} from '@nozbe/watermelondb';
import {DeviceEventEmitter} from 'react-native';
import {General, Navigation as NavigationConstants, Preferences, Screens} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import {CHANNELS_CATEGORY, DMS_CATEGORY} from '@constants/categories';
import DatabaseManager from '@database/manager';
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
import {extractChannelDisplayName} from '@helpers/database';
import PushNotifications from '@init/push_notifications';
import {
prepareDeleteChannel, prepareMyChannelsForTeam, queryAllMyChannel,
getMyChannel, getChannelById, queryUsersOnChannel, queryUserChannelsByTypes,
} from '@queries/servers/channel';
import {prepareDeleteChannel, prepareMyChannelsForTeam, queryAllMyChannel, getMyChannel, getChannelById, queryUsersOnChannel} from '@queries/servers/channel';
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 {prepareCommonSystemValues, PrepareCommonSystemValuesArgs, getCommonSystemValues, getCurrentTeamId, setCurrentChannelId, getCurrentUserId} from '@queries/servers/system';
import {addChannelToTeamHistory, addTeamToTeamHistory, getTeamById, queryMyTeams, removeChannelFromTeamHistory} from '@queries/servers/team';
import {getCurrentUser} from '@queries/servers/user';
import {dismissAllModalsAndPopToRoot, dismissAllModalsAndPopToScreen} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {makeCategoryChannelId, makeCategoryId} from '@utils/categories';
import {isDMorGM} from '@utils/channel';
import {isTablet} from '@utils/helpers';
import {logError, logInfo} from '@utils/log';
import {setThemeDefaults, updateThemeIfNeeded} from '@utils/theme';
import {displayGroupMessageName, displayUsername, getUserIdFromChannelName} from '@utils/user';
import type {Model} from '@nozbe/watermelondb';
import type ChannelModel from '@typings/database/models/servers/channel';
import type UserModel from '@typings/database/models/servers/user';
export async function switchToChannel(serverUrl: string, channelId: string, teamId?: string, skipLastUnread = false, prepareRecordsOnly = false) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const {database} = operator;
const models: Model[] = [];
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
let models: Model[] = [];
const dt = Date.now();
const isTabletDevice = await isTablet();
const system = await getCommonSystemValues(database);
const member = await getMyChannel(database, channelId);
EphemeralStore.addSwitchingToChannel(channelId);
if (member) {
const channel = await member.channel.fetch();
if (channel) {
@@ -55,9 +57,9 @@ export async function switchToChannel(serverUrl: string, channelId: string, team
await setCurrentChannelId(operator, '');
}
const modelPromises: Array<Promise<Model[]>> = [];
if (system.currentTeamId !== toTeamId) {
modelPromises.push(addTeamToTeamHistory(operator, toTeamId, true));
const history = await addTeamToTeamHistory(operator, toTeamId, true);
models.push(...history);
}
const commonValues: PrepareCommonSystemValuesArgs = {
@@ -69,14 +71,17 @@ export async function switchToChannel(serverUrl: string, channelId: string, team
commonValues.currentTeamId = system.currentTeamId === toTeamId ? undefined : toTeamId;
}
modelPromises.push(prepareCommonSystemValues(operator, commonValues));
if (system.currentChannelId !== channelId || system.currentTeamId !== toTeamId) {
modelPromises.push(addChannelToTeamHistory(operator, toTeamId, channelId, true));
const common = await prepareCommonSystemValues(operator, commonValues);
if (common) {
models.push(...common);
}
models = (await Promise.all(modelPromises)).flat();
const {member: viewedAt} = await markChannelAsViewed(serverUrl, channelId, false, true);
if (system.currentChannelId !== channelId || system.currentTeamId !== toTeamId) {
const history = await addChannelToTeamHistory(operator, toTeamId, channelId, true);
models.push(...history);
}
const {member: viewedAt} = await markChannelAsViewed(serverUrl, channelId, true);
if (viewedAt) {
models.push(viewedAt);
}
@@ -85,6 +90,20 @@ export async function switchToChannel(serverUrl: string, channelId: string, team
await operator.batchRecords(models);
}
PushNotifications.cancelChannelNotifications(channelId);
if (!EphemeralStore.theme) {
// 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 queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_THEME, toTeamId).fetch();
let theme = Preferences.THEMES.denim;
if (themes.length) {
theme = setThemeDefaults(JSON.parse(themes[0].value) as Theme);
}
updateThemeIfNeeded(theme, true);
}
if (isTabletDevice) {
dismissAllModalsAndPopToRoot();
DeviceEventEmitter.emit(NavigationConstants.NAVIGATION_HOME, Screens.CHANNEL);
@@ -92,137 +111,156 @@ export async function switchToChannel(serverUrl: string, channelId: string, team
dismissAllModalsAndPopToScreen(Screens.CHANNEL, '', undefined, {topBar: {visible: false}});
}
logInfo('channel switch to', channel?.displayName, channelId, (Date.now() - dt), 'ms');
console.log('channel switch to', channel?.displayName, channelId, (Date.now() - dt), 'ms'); //eslint-disable-line
}
}
return {models};
} catch (error) {
logError('Failed to switch to channelId', channelId, 'teamId', teamId, 'error', error);
return {error};
}
return {models};
}
export async function removeCurrentUserFromChannel(serverUrl: string, channelId: string, prepareRecordsOnly = false) {
try {
const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const serverDatabase = DatabaseManager.serverDatabases[serverUrl];
if (!serverDatabase) {
return {error: `${serverUrl} database not found`};
}
const models: Model[] = [];
const myChannel = await getMyChannel(database, channelId);
if (myChannel) {
const channel = await myChannel.channel.fetch();
if (!channel) {
throw new Error('myChannel present but no channel on the database');
}
models.push(...await prepareDeleteChannel(channel));
let teamId = channel.teamId;
if (teamId) {
teamId = await getCurrentTeamId(database);
}
const {operator, database} = serverDatabase;
// We update the history ASAP to avoid clashes with channel switch.
await removeChannelFromTeamHistory(operator, teamId, channel.id, false);
if (models.length && !prepareRecordsOnly) {
const models: Model[] = [];
const myChannel = await getMyChannel(database, channelId);
if (myChannel) {
const channel = await myChannel.channel.fetch() as ChannelModel;
models.push(...await prepareDeleteChannel(channel));
let teamId = channel.teamId;
if (teamId) {
teamId = await getCurrentTeamId(database);
}
const system = await removeChannelFromTeamHistory(operator, teamId, channel.id, true);
if (system) {
models.push(...system);
}
if (models.length && !prepareRecordsOnly) {
try {
await operator.batchRecords(models);
} catch {
// eslint-disable-next-line no-console
console.log('FAILED TO BATCH CHANGES FOR REMOVE USER FROM CHANNEL');
}
}
return {models};
} catch (error) {
logError('failed to removeCurrentUserFromChannel', error);
return {error};
}
return {models};
}
export async function setChannelDeleteAt(serverUrl: string, channelId: string, deleteAt: number) {
try {
const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const channel = await getChannelById(database, channelId);
if (!channel) {
return;
}
const serverDatabase = DatabaseManager.serverDatabases[serverUrl];
if (!serverDatabase) {
return;
}
const model = channel.prepareUpdate((c) => {
c.deleteAt = deleteAt;
});
const {operator, database} = serverDatabase;
const channel = await getChannelById(database, channelId);
if (!channel) {
return;
}
const model = channel.prepareUpdate((c) => {
c.deleteAt = deleteAt;
});
try {
await operator.batchRecords([model]);
} catch (error) {
logError('FAILED TO BATCH CHANGES FOR CHANNEL DELETE AT', error);
} catch {
// eslint-disable-next-line no-console
console.log('FAILED TO BATCH CHANGES FOR CHANNEL DELETE AT');
}
}
export async function selectAllMyChannelIds(serverUrl: string) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
return queryAllMyChannel(database).fetchIds();
} catch {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return [];
}
return queryAllMyChannel(database).fetchIds();
}
export async function markChannelAsViewed(serverUrl: string, channelId: string, onlyCounts = false, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const member = await getMyChannel(database, channelId);
if (!member) {
return {error: 'not a member'};
}
export async function markChannelAsViewed(serverUrl: string, channelId: string, prepareRecordsOnly = false) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
member.prepareUpdate((m) => {
m.isUnread = false;
m.mentionsCount = 0;
m.manuallyUnread = false;
if (!onlyCounts) {
m.viewedAt = member.lastViewedAt;
m.lastViewedAt = Date.now();
}
});
PushNotifications.removeChannelNotifications(serverUrl, channelId);
const member = await getMyChannel(operator.database, channelId);
if (!member) {
return {error: 'not a member'};
}
member.prepareUpdate((m) => {
m.isUnread = false;
m.mentionsCount = 0;
m.manuallyUnread = false;
m.viewedAt = member.lastViewedAt;
m.lastViewedAt = Date.now();
});
try {
if (!prepareRecordsOnly) {
await operator.batchRecords([member]);
}
return {member};
} catch (error) {
logError('Failed markChannelAsViewed', error);
return {error};
}
}
export async function markChannelAsUnread(serverUrl: string, channelId: string, messageCount: number, mentionsCount: number, lastViewed: number, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const member = await getMyChannel(database, channelId);
if (!member) {
return {error: 'not a member'};
}
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
member.prepareUpdate((m) => {
m.viewedAt = lastViewed - 1;
m.lastViewedAt = lastViewed - 1;
m.messageCount = messageCount;
m.mentionsCount = mentionsCount;
m.manuallyUnread = true;
m.isUnread = true;
});
const member = await getMyChannel(operator.database, channelId);
if (!member) {
return {error: 'not a member'};
}
member.prepareUpdate((m) => {
m.viewedAt = lastViewed - 1;
m.lastViewedAt = lastViewed;
m.messageCount = messageCount;
m.mentionsCount = mentionsCount;
m.manuallyUnread = true;
m.isUnread = true;
});
try {
if (!prepareRecordsOnly) {
await operator.batchRecords([member]);
}
return {member};
} catch (error) {
logError('Failed markChannelAsUnread', error);
return {error};
}
}
export async function resetMessageCount(serverUrl: string, channelId: string) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const member = await getMyChannel(operator.database, channelId);
if (!member) {
return {error: 'not a member'};
}
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const member = await getMyChannel(database, channelId);
if (!member) {
return {error: 'not a member'};
}
member.prepareUpdate((m) => {
m.messageCount = 0;
});
@@ -230,230 +268,202 @@ export async function resetMessageCount(serverUrl: string, channelId: string) {
return member;
} catch (error) {
logError('Failed resetMessageCount', error);
return {error};
}
}
export async function storeMyChannelsForTeam(serverUrl: string, teamId: string, channels: Channel[], memberships: ChannelMembership[], prepareRecordsOnly = false, isCRTEnabled?: boolean) {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const modelPromises: Array<Promise<Model[]>> = [
...await prepareMyChannelsForTeam(operator, teamId, channels, memberships, isCRTEnabled),
];
const models = await Promise.all(modelPromises);
if (!models.length) {
return {models: []};
}
const flattenedModels = models.flat();
if (prepareRecordsOnly) {
return {models: flattenedModels};
}
if (flattenedModels.length) {
await operator.batchRecords(flattenedModels);
}
return {models: flattenedModels};
} catch (error) {
logError('Failed storeMyChannelsForTeam', error);
return {error};
export async function storeMyChannelsForTeam(serverUrl: string, teamId: string, channels: Channel[], memberships: ChannelMembership[], prepareRecordsOnly = false) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const modelPromises: Array<Promise<Model[]>> = [
...await prepareMyChannelsForTeam(operator, teamId, channels, memberships),
];
const models = await Promise.all(modelPromises);
if (!models.length) {
return {models: []};
}
const flattenedModels = models.flat() as Model[];
if (prepareRecordsOnly) {
return {models: flattenedModels};
}
if (flattenedModels.length) {
try {
await operator.batchRecords(flattenedModels);
} catch (error) {
// eslint-disable-next-line no-console
console.log('FAILED TO BATCH CHANNELS');
return {error};
}
}
return {models: flattenedModels};
}
export async function updateMyChannelFromWebsocket(serverUrl: string, channelMember: ChannelMembership, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const member = await getMyChannel(database, channelMember.channel_id);
if (member) {
member.prepareUpdate((m) => {
m.roles = channelMember.roles;
});
if (!prepareRecordsOnly) {
operator.batchRecords([member]);
}
}
return {model: member};
} catch (error) {
logError('Failed updateMyChannelFromWebsocket', error);
return {error};
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const member = await getMyChannel(operator.database, channelMember.channel_id);
if (member) {
member.prepareUpdate((m) => {
m.roles = channelMember.roles;
});
if (!prepareRecordsOnly) {
operator.batchRecords([member]);
}
}
return {model: member};
}
export async function updateChannelInfoFromChannel(serverUrl: string, channel: Channel, prepareRecordsOnly = false) {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const newInfo = await operator.handleChannelInfo({channelInfos: [{
header: channel.header,
purpose: channel.purpose,
id: channel.id,
}],
prepareRecordsOnly: true});
if (!prepareRecordsOnly) {
operator.batchRecords(newInfo);
}
return {model: newInfo};
} catch (error) {
logError('Failed updateChannelInfoFromChannel', error);
return {error};
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const newInfo = await operator.handleChannelInfo({channelInfos: [{
header: channel.header,
purpose: channel.purpose,
id: channel.id,
}],
prepareRecordsOnly: true});
if (!prepareRecordsOnly) {
operator.batchRecords(newInfo);
}
return {model: newInfo};
}
export async function updateLastPostAt(serverUrl: string, channelId: string, lastPostAt: number, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const myChannel = await getMyChannel(database, channelId);
if (!myChannel) {
return {error: 'not a member'};
}
if (lastPostAt > myChannel.lastPostAt) {
myChannel.resetPreparedState();
myChannel.prepareUpdate((m) => {
m.lastPostAt = lastPostAt;
});
if (!prepareRecordsOnly) {
await operator.batchRecords([myChannel]);
}
return {member: myChannel};
}
return {member: undefined};
} catch (error) {
logError('Failed updateLastPostAt', error);
return {error};
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const member = await getMyChannel(operator.database, channelId);
if (!member) {
return {error: 'not a member'};
}
if (lastPostAt > member.lastPostAt) {
member.prepareUpdate((m) => {
m.lastPostAt = lastPostAt;
});
try {
if (!prepareRecordsOnly) {
await operator.batchRecords([member]);
}
} catch (error) {
return {error};
}
return {member};
}
return {member: undefined};
}
export async function updateMyChannelLastFetchedAt(serverUrl: string, channelId: string, lastFetchedAt: number, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const myChannel = await getMyChannel(database, channelId);
if (!myChannel) {
return {error: 'not a member'};
}
if (lastFetchedAt > myChannel.lastFetchedAt) {
myChannel.resetPreparedState();
myChannel.prepareUpdate((m) => {
m.lastFetchedAt = lastFetchedAt;
});
if (!prepareRecordsOnly) {
await operator.batchRecords([myChannel]);
}
return {member: myChannel};
}
return {member: undefined};
} catch (error) {
logError('Failed updateLastFetchedAt', error);
return {error};
export async function updateChannelsDisplayName(serverUrl: string, channels: ChannelModel[], users: UserProfile[], prepareRecordsOnly = false) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {};
}
const {database} = operator;
const currentUser = await getCurrentUser(database);
if (!currentUser) {
return {};
}
const {config, license} = await getCommonSystemValues(database);
const preferences = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT).fetch();
const displaySettings = getTeammateNameDisplaySetting(preferences, config, license);
const models: Model[] = [];
for await (const channel of channels) {
let newDisplayName = '';
if (channel.type === General.DM_CHANNEL) {
const otherUserId = getUserIdFromChannelName(currentUser.id, channel.name);
const user = users.find((u) => u.id === otherUserId);
newDisplayName = displayUsername(user, currentUser.locale, displaySettings, false);
} else {
const dbProfiles = await queryUsersOnChannel(database, channel.id).fetch();
const profileIds = new Set(dbProfiles.map((p) => p.id));
const gmUsers = users.filter((u) => profileIds.has(u.id));
if (gmUsers.length) {
const uIds = new Set(gmUsers.map((u) => u.id));
const newProfiles: Array<UserModel|UserProfile> = dbProfiles.filter((u) => !uIds.has(u.id));
newProfiles.push(...gmUsers);
newDisplayName = displayGroupMessageName(newProfiles, currentUser.locale, displaySettings, currentUser.id);
}
}
if (channel.displayName !== newDisplayName) {
channel.prepareUpdate((c) => {
c.displayName = extractChannelDisplayName({type: c.type, display_name: newDisplayName}, c);
});
models.push(channel);
}
}
if (models.length && !prepareRecordsOnly) {
await operator.batchRecords(models);
}
return {models};
}
type User = UserProfile | UserModel;
export async function updateChannelsDisplayName(serverUrl: string, channels: ChannelModel[], users: User[], prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const currentUser = await getCurrentUser(database);
if (!currentUser) {
return {};
}
export async function addChannelToDefaultCategory(serverUrl: string, channel: Channel | ChannelModel, prepareRecordsOnly = false) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const license = await getLicense(database);
const config = await getConfig(database);
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) {
let newDisplayName = '';
if (channel.type === General.DM_CHANNEL) {
const otherUserId = getUserIdFromChannelName(currentUser.id, channel.name);
const user = users.find((u) => u.id === otherUserId);
newDisplayName = displayUsername(user, currentUser.locale, displaySettings, false);
} else {
const dbProfiles = await queryUsersOnChannel(database, channel.id).fetch();
const profileIds = new Set(dbProfiles.map((p) => p.id));
const gmUsers = users.filter((u) => profileIds.has(u.id));
if (gmUsers.length) {
const uIds = new Set(gmUsers.map((u) => u.id));
const newProfiles: Array<UserModel|UserProfile> = dbProfiles.filter((u) => !uIds.has(u.id));
newProfiles.push(...gmUsers);
newDisplayName = displayGroupMessageName(newProfiles, currentUser.locale, displaySettings, currentUser.id);
}
}
const {database} = operator;
if (newDisplayName && channel.displayName !== newDisplayName) {
channel.prepareUpdate((c) => {
c.displayName = extractChannelDisplayName({
type: c.type,
display_name: newDisplayName,
fake: true,
}, c);
});
models.push(channel);
}
}
const teamId = 'teamId' in channel ? channel.teamId : channel.team_id;
const userId = await getCurrentUserId(database);
if (!userId) {
return {error: 'no current user id'};
}
if (models.length && !prepareRecordsOnly) {
await operator.batchRecords(models);
}
if (!isDMorGM(channel)) {
const models = await operator.handleCategoryChannels({categoryChannels: [{
category_id: makeCategoryId(CHANNELS_CATEGORY, userId, teamId),
channel_id: channel.id,
sort_order: 0,
id: makeCategoryChannelId(teamId, channel.id),
}],
prepareRecordsOnly});
return {models};
} catch (error) {
logError('Failed updateChannelsDisplayName', error);
return {error};
}
const allTeams = await queryMyTeams(database).fetch();
const models = (
await Promise.all(
allTeams.map(
(t) => operator.handleCategoryChannels({categoryChannels: [{
category_id: makeCategoryId(DMS_CATEGORY, userId, t.id),
channel_id: channel.id,
sort_order: 0,
id: makeCategoryChannelId(t.id, channel.id),
}],
prepareRecordsOnly: true}),
),
)
).flat();
if (models.length && !prepareRecordsOnly) {
operator.batchRecords(models);
}
return {models};
}
export async function showUnreadChannelsOnly(serverUrl: string, onlyUnreads: boolean) {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
return operator.handleSystem({
systems: [{
id: SYSTEM_IDENTIFIERS.ONLY_UNREADS,
value: JSON.stringify(onlyUnreads),
}],
prepareRecordsOnly: false,
});
} catch (error) {
logError('Failed showUnreadChannelsOnly', error);
return {error};
}
}
export const updateDmGmDisplayName = async (serverUrl: string) => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const currentUserId = await getCurrentUserId(database);
if (!currentUserId) {
return {error: 'The current user id could not be retrieved from the database'};
}
const channels = await queryUserChannelsByTypes(database, currentUserId, ['G', 'D']).fetch();
const userIds = channels.reduce((acc: string[], ch) => {
if (ch.type !== General.DM_CHANNEL) {
return acc;
}
const uid = getUserIdFromChannelName(currentUserId, ch.name);
acc.push(uid);
return acc;
}, []);
const users = await queryUsersById(database, userIds).fetch();
await updateChannelsDisplayName(serverUrl, channels, users, false);
return {channels};
} catch (error) {
logError('Failed updateDmGmDisplayName', error);
return {error};
}
};

View File

@@ -3,155 +3,165 @@
import DatabaseManager from '@database/manager';
import {getDraft} from '@queries/servers/drafts';
import {logError} from '@utils/log';
export async function updateDraftFile(serverUrl: string, channelId: string, rootId: string, file: FileInfo, prepareRecordsOnly = false) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const draft = await getDraft(operator.database, channelId, rootId);
if (!draft) {
return {error: 'no draft'};
}
const i = draft.files.findIndex((v) => v.clientId === file.clientId);
if (i === -1) {
return {error: 'file not found'};
}
// We create a new list to make sure we re-render the draft input.
const newFiles = [...draft.files];
newFiles[i] = file;
draft.prepareUpdate((d) => {
d.files = newFiles;
});
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const draft = await getDraft(database, channelId, rootId);
if (!draft) {
return {error: 'no draft'};
}
const i = draft.files.findIndex((v) => v.clientId === file.clientId);
if (i === -1) {
return {error: 'file not found'};
}
// We create a new list to make sure we re-render the draft input.
const newFiles = [...draft.files];
newFiles[i] = file;
draft.prepareUpdate((d) => {
d.files = newFiles;
});
if (!prepareRecordsOnly) {
await operator.batchRecords([draft]);
}
return {draft};
} catch (error) {
logError('Failed updateDraftFile', error);
return {error};
}
}
export async function removeDraftFile(serverUrl: string, channelId: string, rootId: string, clientId: string, prepareRecordsOnly = false) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const draft = await getDraft(operator.database, channelId, rootId);
if (!draft) {
return {error: 'no draft'};
}
const i = draft.files.findIndex((v) => v.clientId === clientId);
if (i === -1) {
return {error: 'file not found'};
}
if (draft.files.length === 1 && !draft.message) {
draft.prepareDestroyPermanently();
} else {
draft.prepareUpdate((d) => {
d.files = draft.files.filter((v, index) => index !== i);
});
}
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const draft = await getDraft(database, channelId, rootId);
if (!draft) {
return {error: 'no draft'};
}
const i = draft.files.findIndex((v) => v.clientId === clientId);
if (i === -1) {
return {error: 'file not found'};
}
if (draft.files.length === 1 && !draft.message) {
draft.prepareDestroyPermanently();
} else {
draft.prepareUpdate((d) => {
d.files = draft.files.filter((v, index) => index !== i);
});
}
if (!prepareRecordsOnly) {
await operator.batchRecords([draft]);
}
return {draft};
} catch (error) {
logError('Failed removeDraftFile', error);
return {error};
}
}
export async function updateDraftMessage(serverUrl: string, channelId: string, rootId: string, message: string, prepareRecordsOnly = false) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const draft = await getDraft(operator.database, channelId, rootId);
if (!draft) {
if (!message) {
return {};
}
const newDraft: Draft = {
channel_id: channelId,
root_id: rootId,
message,
};
return operator.handleDraft({drafts: [newDraft], prepareRecordsOnly});
}
if (draft.message === message) {
return {draft};
}
if (draft.files.length === 0 && !message) {
draft.prepareDestroyPermanently();
} else {
draft.prepareUpdate((d) => {
d.message = message;
});
}
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const draft = await getDraft(database, channelId, rootId);
if (!draft) {
if (!message) {
return {};
}
const newDraft: Draft = {
channel_id: channelId,
root_id: rootId,
message,
};
return operator.handleDraft({drafts: [newDraft], prepareRecordsOnly});
}
if (draft.message === message) {
return {draft};
}
if (draft.files.length === 0 && !message) {
draft.prepareDestroyPermanently();
} else {
draft.prepareUpdate((d) => {
d.message = message;
});
}
if (!prepareRecordsOnly) {
await operator.batchRecords([draft]);
}
return {draft};
} catch (error) {
logError('Failed updateDraftMessage', error);
return {error};
}
}
export async function addFilesToDraft(serverUrl: string, channelId: string, rootId: string, files: FileInfo[], prepareRecordsOnly = false) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const draft = await getDraft(operator.database, channelId, rootId);
if (!draft) {
const newDraft: Draft = {
channel_id: channelId,
root_id: rootId,
files,
message: '',
};
return operator.handleDraft({drafts: [newDraft], prepareRecordsOnly});
}
draft.prepareUpdate((d) => {
d.files = [...draft.files, ...files];
});
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,
files,
message: '',
};
return operator.handleDraft({drafts: [newDraft], prepareRecordsOnly});
}
draft.prepareUpdate((d) => {
d.files = [...draft.files, ...files];
});
if (!prepareRecordsOnly) {
await operator.batchRecords([draft]);
}
return {draft};
} catch (error) {
logError('Failed addFilesToDraft', error);
return {error};
}
}
export const removeDraft = async (serverUrl: string, channelId: string, rootId = '') => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const draft = await getDraft(database, channelId, rootId);
if (draft) {
await database.write(async () => {
await draft.destroyPermanently();
});
}
return {draft};
} catch (error) {
logError('Failed removeDraft', error);
return {error};
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
const draft = await getDraft(database, channelId, rootId);
if (draft) {
await database.write(async () => {
await draft.destroyPermanently();
});
}
return {draft};
};

View File

@@ -3,21 +3,23 @@
import DatabaseManager from '@database/manager';
import {getFileById} from '@queries/servers/file';
import {logError} from '@utils/log';
export const updateLocalFile = async (serverUrl: string, file: FileInfo) => {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
return operator.handleFiles({files: [file], prepareRecordsOnly: false});
} catch (error) {
logError('Failed updateLocalFile', error);
return {error};
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
return operator.handleFiles({files: [file], prepareRecordsOnly: false});
};
export const updateLocalFilePath = async (serverUrl: string, fileId: string, localPath: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const file = await getFileById(database, fileId);
if (file) {
await database.write(async () => {
@@ -29,8 +31,6 @@ export const updateLocalFilePath = async (serverUrl: string, fileId: string, loc
return {error: undefined};
} catch (error) {
logError('Failed updateLocalFilePath', error);
return {error};
}
};

View File

@@ -1,79 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fetchFilteredChannelGroups, fetchFilteredTeamGroups, fetchGroupsForAutocomplete} from '@actions/remote/groups';
import DatabaseManager from '@database/manager';
import {queryGroupsByName, queryGroupsByNameInChannel, queryGroupsByNameInTeam} from '@queries/servers/group';
import {logError} from '@utils/log';
import type GroupModel from '@typings/database/models/servers/group';
export const searchGroupsByName = async (serverUrl: string, name: string): Promise<GroupModel[]> => {
let database;
try {
database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database;
} catch (e) {
logError('searchGroupsByName - DB Error', e);
return [];
}
try {
const groups = await fetchGroupsForAutocomplete(serverUrl, name);
if (groups && Array.isArray(groups)) {
return groups;
}
throw groups.error;
} catch (e) {
logError('searchGroupsByName - ERROR', e);
return queryGroupsByName(database, name).fetch();
}
};
export const searchGroupsByNameInTeam = async (serverUrl: string, name: string, teamId: string): Promise<GroupModel[]> => {
let database;
try {
database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database;
} catch (e) {
logError('searchGroupsByNameInTeam - DB Error', e);
return [];
}
try {
const groups = await fetchFilteredTeamGroups(serverUrl, name, teamId);
if (groups && Array.isArray(groups)) {
return groups;
}
throw groups.error;
} catch (e) {
logError('searchGroupsByNameInTeam - ERROR', e);
return queryGroupsByNameInTeam(database, name, teamId).fetch();
}
};
export const searchGroupsByNameInChannel = async (serverUrl: string, name: string, channelId: string): Promise<GroupModel[]> => {
let database;
try {
database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database;
} catch (e) {
logError('searchGroupsByNameInChannel - DB Error', e);
return [];
}
try {
const groups = await fetchFilteredChannelGroups(serverUrl, name, channelId);
if (groups && Array.isArray(groups)) {
return groups;
}
throw groups.error;
} catch (e) {
logError('searchGroupsByNameInChannel - ERROR', e);
return queryGroupsByNameInChannel(database, name, channelId).fetch();
}
};

View File

@@ -2,13 +2,17 @@
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import {getPostById, queryPostsInChannel, queryPostsInThread} from '@queries/servers/post';
import {logError} from '@utils/log';
import {getPostById, queryPostsInChannel} from '@queries/servers/post';
export const updatePostSinceCache = async (serverUrl: string, notification: NotificationWithData) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
if (notification.payload?.channel_id) {
const {database} = operator;
const chunks = await queryPostsInChannel(database, notification.payload.channel_id).fetch();
if (chunks.length) {
const recent = chunks[0];
@@ -24,35 +28,7 @@ export const updatePostSinceCache = async (serverUrl: string, notification: Noti
}
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

@@ -1,88 +1,31 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fetchPostAuthors} from '@actions/remote/post';
import {ActionType, Post} from '@constants';
import {MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getPostById, prepareDeletePost, queryPostsById} from '@queries/servers/post';
import {getPostById, prepareDeletePost} from '@queries/servers/post';
import {getCurrentUserId} from '@queries/servers/system';
import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread';
import {generateId} from '@utils/general';
import {logError} from '@utils/log';
import {getLastFetchedAtFromPosts} from '@utils/post';
import {getPostIdsForCombinedUserActivityPost} from '@utils/post_list';
import {updateLastPostAt, updateMyChannelLastFetchedAt} from './channel';
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);
const timestamp = Date.now();
const posts = addedUsernames.map((addedUsername, index) => {
const message = messages[index];
return {
id: generateId(),
user_id: user.id,
channel_id: channeId,
message,
type: Post.POST_TYPES.EPHEMERAL_ADD_TO_CHANNEL as PostType,
create_at: timestamp,
edit_at: 0,
update_at: timestamp,
delete_at: 0,
is_pinned: false,
original_id: '',
hashtags: '',
pending_post_id: '',
reply_count: 0,
metadata: {},
root_id: postRootId,
props: {
username: user.username,
addedUsername,
},
} as Post;
});
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: posts.map((p) => p.id),
posts,
});
return {posts};
} catch (error) {
logError('Failed sendAddToChannelEphemeralPost', error);
return {error};
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
};
export const sendEphemeralPost = async (serverUrl: string, message: string, channeId: string, rootId = '', userId?: string) => {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
if (!channeId) {
throw new Error('channel Id not defined');
}
let authorId = userId;
if (!authorId) {
authorId = await getCurrentUserId(database);
}
const timestamp = Date.now();
const post = {
const timestamp = Date.now();
const posts = addedUsernames.map((addedUsername, index) => {
const message = messages[index];
return {
id: generateId(),
user_id: authorId,
user_id: user.id,
channel_id: channeId,
message,
type: Post.POST_TYPES.EPHEMERAL as PostType,
type: Post.POST_TYPES.EPHEMERAL_ADD_TO_CHANNEL as PostType,
create_at: timestamp,
edit_at: 0,
update_at: timestamp,
@@ -93,185 +36,122 @@ export const sendEphemeralPost = async (serverUrl: string, message: string, chan
pending_post_id: '',
reply_count: 0,
metadata: {},
participants: null,
root_id: rootId,
props: {},
root_id: postRootId,
props: {
username: user.username,
addedUsername,
},
} as Post;
});
await fetchPostAuthors(serverUrl, [post], false);
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: [post.id],
posts: [post],
});
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: posts.map((p) => p.id),
posts,
});
return {post};
} catch (error) {
logError('Failed sendEphemeralPost', error);
return {error};
return {posts};
};
export const sendEphemeralPost = async (serverUrl: string, message: string, channeId: string, rootId = '', userId?: string) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
if (!channeId) {
return {error: 'channel Id not defined'};
}
let authorId = userId;
if (!authorId) {
authorId = await getCurrentUserId(operator.database);
}
const timestamp = Date.now();
const post = {
id: generateId(),
user_id: authorId,
channel_id: channeId,
message,
type: Post.POST_TYPES.EPHEMERAL_ADD_TO_CHANNEL as PostType,
create_at: timestamp,
edit_at: 0,
update_at: timestamp,
delete_at: 0,
is_pinned: false,
original_id: '',
hashtags: '',
pending_post_id: '',
reply_count: 0,
metadata: {},
participants: null,
root_id: rootId,
props: {},
} as Post;
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: [post.id],
posts: [post],
});
return {post};
};
export async function removePost(serverUrl: string, post: PostModel | Post) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
if (post.type === Post.POST_TYPES.COMBINED_USER_ACTIVITY && post.props?.system_post_ids) {
const systemPostIds = getPostIdsForCombinedUserActivityPost(post.id);
const removeModels = [];
for await (const id of systemPostIds) {
const postModel = await getPostById(database, id);
if (postModel) {
const preparedPost = await prepareDeletePost(postModel);
removeModels.push(...preparedPost);
}
}
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
if (removeModels.length) {
await operator.batchRecords(removeModels);
}
} else {
const postModel = await getPostById(database, post.id);
if (post.type === Post.POST_TYPES.COMBINED_USER_ACTIVITY && post.props?.system_post_ids) {
const systemPostIds = getPostIdsForCombinedUserActivityPost(post.id);
const removeModels = [];
for await (const id of systemPostIds) {
const postModel = await getPostById(operator.database, id);
if (postModel) {
const preparedPost = await prepareDeletePost(postModel);
if (preparedPost.length) {
await operator.batchRecords(preparedPost);
}
removeModels.push(...preparedPost);
}
}
return {post};
} catch (error) {
logError('Failed removePost', error);
return {error};
if (removeModels.length) {
await operator.batchRecords(removeModels);
}
} else {
const postModel = await getPostById(operator.database, post.id);
if (postModel) {
const preparedPost = await prepareDeletePost(postModel);
if (preparedPost.length) {
await operator.batchRecords(preparedPost);
}
}
}
return {post};
}
export async function markPostAsDeleted(serverUrl: string, post: Post, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const dbPost = await getPostById(database, post.id);
if (!dbPost) {
throw new Error('Post not found');
}
const model = dbPost.prepareUpdate((p) => {
p.deleteAt = Date.now();
p.message = '';
p.metadata = null;
p.props = undefined;
});
if (!prepareRecordsOnly) {
await operator.batchRecords([dbPost]);
}
return {model};
} catch (error) {
logError('Failed markPostAsDeleted', error);
return {error};
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
}
export async function storePostsForChannel(
serverUrl: string, channelId: string, posts: Post[], order: string[], previousPostId: string,
actionType: string, authors: UserProfile[], prepareRecordsOnly = false,
) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const isCRTEnabled = await getIsCRTEnabled(database);
const models = [];
const postModels = await operator.handlePosts({
actionType,
order,
posts,
previousPostId,
prepareRecordsOnly: true,
});
models.push(...postModels);
if (authors.length) {
const userModels = await operator.handleUsers({users: authors, prepareRecordsOnly: true});
models.push(...userModels);
}
const lastFetchedAt = getLastFetchedAtFromPosts(posts);
let myChannelModel: MyChannelModel | undefined;
if (lastFetchedAt) {
const {member} = await updateMyChannelLastFetchedAt(serverUrl, channelId, lastFetchedAt, true);
myChannelModel = member;
}
let lastPostAt = 0;
for (const post of posts) {
const isCrtReply = isCRTEnabled && post.root_id !== '';
if (!isCrtReply) {
lastPostAt = post.create_at > lastPostAt ? post.create_at : lastPostAt;
}
}
if (lastPostAt) {
const {member} = await updateLastPostAt(serverUrl, channelId, lastPostAt, true);
if (member) {
myChannelModel = member;
}
}
if (myChannelModel) {
models.push(myChannelModel);
}
if (isCRTEnabled) {
const threadModels = await prepareThreadsFromReceivedPosts(operator, posts, false);
if (threadModels?.length) {
models.push(...threadModels);
}
}
if (models.length && !prepareRecordsOnly) {
await operator.batchRecords(models);
}
return {models};
} catch (error) {
logError('storePostsForChannel', error);
return {error};
const dbPost = await getPostById(operator.database, post.id);
if (!dbPost) {
return {error: 'Post not found'};
}
}
export async function getPosts(serverUrl: string, ids: string[]) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
return queryPostsById(database, ids).fetch();
} catch (error) {
return [];
}
}
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};
const model = dbPost.prepareUpdate((p) => {
p.deleteAt = Date.now();
p.message = '';
p.metadata = null;
p.props = undefined;
});
if (!prepareRecordsOnly) {
await operator.batchRecords([dbPost]);
}
return {model};
}

View File

@@ -5,18 +5,23 @@ import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getRecentReactions} from '@queries/servers/system';
import {getEmojiFirstAlias} from '@utils/emoji/helpers';
import {logError} from '@utils/log';
const MAXIMUM_RECENT_EMOJI = 27;
export const addRecentReaction = async (serverUrl: string, emojiNames: string[], prepareRecordsOnly = false) => {
try {
if (!emojiNames.length) {
return [];
}
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const {database} = operator;
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
let recent = await getRecentReactions(database);
if (!emojiNames.length) {
return [];
}
let recent = await getRecentReactions(database);
try {
const recentEmojis = new Set(recent);
const aliases = emojiNames.map((e) => getEmojiFirstAlias(e));
for (const alias of aliases) {
@@ -38,7 +43,6 @@ export const addRecentReaction = async (serverUrl: string, emojiNames: string[],
prepareRecordsOnly,
});
} catch (error) {
logError('Failed addRecentReaction', error);
return {error};
}
};

View File

@@ -1,272 +0,0 @@
// 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 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 {logError} from '@utils/log';
import {deletePosts} from './post';
import type {DataRetentionPoliciesRequest} from '@actions/remote/systems';
import type PostModel from '@typings/database/models/servers/post';
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
const credentials = await getServerCredentials(serverUrl);
if (credentials) {
const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const currentLicense = await getLicense(database);
const systems: IdValue[] = [];
if (!deepEqual(license, currentLicense)) {
systems.push({
id: SYSTEM_IDENTIFIERS.LICENSE,
value: JSON.stringify(license),
});
}
if (systems.length) {
await operator.handleSystem({systems, prepareRecordsOnly: false});
}
await storeConfig(serverUrl, config);
}
} catch (error) {
logError('An error occurred while saving config & license', error);
}
}
export async function storeConfig(serverUrl: string, config: ClientConfig | undefined, prepareRecordsOnly = false) {
if (!config) {
return [];
}
try {
const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const currentConfig = await getConfig(database);
const configsToUpdate: IdValue[] = [];
const configsToDelete: IdValue[] = [];
let k: keyof ClientConfig;
for (k in config) {
if (currentConfig?.[k] !== config[k]) {
configsToUpdate.push({
id: k,
value: config[k],
});
}
}
for (k in currentConfig) {
if (config[k] === undefined) {
configsToDelete.push({
id: k,
value: currentConfig[k],
});
}
}
if (configsToDelete.length || configsToUpdate.length) {
return operator.handleConfigs({configs: configsToUpdate, configsToDelete, prepareRecordsOnly});
}
} catch (error) {
logError('storeConfig', error);
}
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 isDataRetentionEnabled = await getIsDataRetentionEnabled(database);
if (!isDataRetentionEnabled) {
return {error: undefined};
}
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 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();
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};
}
}
}
await updateLastDataRetentionRun(serverUrl);
return {error: undefined};
} catch (error) {
logError('An error occurred while performing data retention cleanup', error);
return {error};
}
}
// 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);
await operator.handleSystem({
systems: [{
id: SYSTEM_IDENTIFIERS.LAST_SERVER_VERSION_CHECK,
value: reset ? 0 : Date.now(),
}],
prepareRecordsOnly: false,
});
} catch (error) {
logError('setLastServerVersionCheck', error);
}
}
export async function setGlobalThreadsTab(serverUrl: string, globalThreadsTab: GlobalThreadsTab) {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
await operator.handleSystem({
systems: [{
id: SYSTEM_IDENTIFIERS.GLOBAL_THREADS_TAB,
value: globalThreadsTab,
}],
prepareRecordsOnly: false,
});
} catch (error) {
logError('setGlobalThreadsTab', error);
}
}
export async function dismissAnnouncement(serverUrl: string, announcementText: string) {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LAST_DISMISSED_BANNER, value: announcementText}], prepareRecordsOnly: false});
} catch (error) {
logError('An error occurred while dismissing an announcement', error);
}
}

View File

@@ -2,86 +2,33 @@
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import {prepareDeleteTeam, getMyTeamById, queryTeamSearchHistoryByTeamId, removeTeamFromTeamHistory, getTeamSearchHistoryById, getTeamById} from '@queries/servers/team';
import {logError} from '@utils/log';
import {prepareDeleteTeam, getMyTeamById, removeTeamFromTeamHistory} from '@queries/servers/team';
import type Model from '@nozbe/watermelondb/Model';
import type TeamModel from '@typings/database/models/servers/team';
export async function removeUserFromTeam(serverUrl: string, teamId: string) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const myTeam = await getMyTeamById(database, teamId);
if (myTeam) {
const team = await getTeamById(database, myTeam.id);
if (!team) {
throw new Error('Team not found');
}
const models = await prepareDeleteTeam(team);
const system = await removeTeamFromTeamHistory(operator, team.id, true);
if (system) {
models.push(...system);
}
if (models.length) {
const serverDatabase = DatabaseManager.serverDatabases[serverUrl];
if (!serverDatabase) {
return;
}
const {operator, database} = serverDatabase;
const myTeam = await getMyTeamById(database, teamId);
if (myTeam) {
const team = await myTeam.team.fetch() as TeamModel;
const models = await prepareDeleteTeam(team);
const system = await removeTeamFromTeamHistory(operator, team.id, true);
if (system) {
models.push(...system);
}
if (models.length) {
try {
await operator.batchRecords(models);
} catch {
// eslint-disable-next-line no-console
console.log('FAILED TO BATCH CHANGES FOR REMOVE USER FROM TEAM');
}
}
return {error: undefined};
} catch (error) {
logError('Failed removeUserFromTeam', error);
return {error};
}
}
export async function addSearchToTeamSearchHistory(serverUrl: string, teamId: string, terms: string) {
const MAX_TEAM_SEARCHES = 15;
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const newSearch: TeamSearchHistory = {
created_at: Date.now(),
display_term: terms,
term: terms,
team_id: teamId,
};
const models: Model[] = [];
const searchModels = await operator.handleTeamSearchHistory({teamSearchHistories: [newSearch], prepareRecordsOnly: true});
const searchModel = searchModels[0];
models.push(searchModel);
// determine if need to delete the oldest entry
if (searchModel._raw._changed !== 'created_at') {
const teamSearchHistory = await queryTeamSearchHistoryByTeamId(database, teamId).fetch();
if (teamSearchHistory.length > MAX_TEAM_SEARCHES) {
const lastSearches = teamSearchHistory.slice(MAX_TEAM_SEARCHES);
for (const lastSearch of lastSearches) {
models.push(lastSearch.prepareDestroyPermanently());
}
}
}
await operator.batchRecords(models);
return {searchModel};
} catch (error) {
logError('Failed addSearchToTeamSearchHistory', error);
return {error};
}
}
export async function removeSearchFromTeamSearchHistory(serverUrl: string, id: string) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const teamSearch = await getTeamSearchHistoryById(database, id);
if (teamSearch) {
await database.write(async () => {
await teamSearch.destroyPermanently();
});
}
return {teamSearch};
} catch (error) {
logError('Failed removeSearchFromTeamSearchHistory', error);
return {error};
}
}

View File

@@ -8,97 +8,70 @@ 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, PrepareCommonSystemValuesArgs, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {addChannelToTeamHistory, addTeamToTeamHistory} from '@queries/servers/team';
import {getCurrentTeamId, getCurrentUserId, setCurrentChannelId} from '@queries/servers/system';
import {addChannelToTeamHistory} from '@queries/servers/team';
import {getIsCRTEnabled, getThreadById, prepareThreadsFromReceivedPosts, queryThreadsInTeam} from '@queries/servers/thread';
import {getCurrentUser} from '@queries/servers/user';
import {dismissAllModalsAndPopToRoot, goToScreen} from '@screens/navigation';
import {goToScreen} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import NavigationStore from '@store/navigation_store';
import {isTablet} from '@utils/helpers';
import {logError} from '@utils/log';
import {changeOpacity} from '@utils/theme';
import type Model from '@nozbe/watermelondb/Model';
export const switchToGlobalThreads = async (serverUrl: string, teamId?: string, prepareRecordsOnly = false) => {
export const switchToGlobalThreads = async (serverUrl: string, prepareRecordsOnly = false) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const {database} = operator;
const models: Model[] = [];
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const models: Model[] = [];
let teamIdToUse = teamId;
if (!teamId) {
teamIdToUse = await getCurrentTeamId(database);
}
if (!teamIdToUse) {
throw new Error('no team to switch to');
}
await setCurrentTeamAndChannelId(operator, teamIdToUse, '');
const history = await addChannelToTeamHistory(operator, teamIdToUse, Screens.GLOBAL_THREADS, true);
await setCurrentChannelId(operator, '');
const currentTeamId = await getCurrentTeamId(database);
const history = await addChannelToTeamHistory(operator, currentTeamId, Screens.GLOBAL_THREADS, true);
models.push(...history);
if (!prepareRecordsOnly) {
await operator.batchRecords(models);
}
const isTabletDevice = await isTablet();
if (isTabletDevice) {
DeviceEventEmitter.emit(Navigation.NAVIGATION_HOME, Screens.GLOBAL_THREADS);
} else {
goToScreen(Screens.GLOBAL_THREADS, '', {}, {topBar: {visible: false}});
}
return {models};
if (!prepareRecordsOnly) {
await operator.batchRecords(models);
}
} catch (error) {
logError('Failed switchToGlobalThreads', error);
return {error};
}
return {models};
};
export const switchToThread = async (serverUrl: string, rootId: string, isFromNotification = false) => {
export const switchToThread = async (serverUrl: string, rootId: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const user = await getCurrentUser(database);
if (!user) {
throw new Error('User not found');
return {error: 'User not found'};
}
const post = await getPostById(database, rootId);
if (!post) {
throw new Error('Post not found');
return {error: 'Post not found'};
}
const channel = await getChannelById(database, post.channelId);
if (!channel) {
throw new Error('Channel not found');
return {error: 'Channel not found'};
}
const currentTeamId = await getCurrentTeamId(database);
const isTabletDevice = await isTablet();
const teamId = channel.teamId || currentTeamId;
let switchingTeams = false;
if (currentTeamId === teamId) {
const models = await prepareCommonSystemValues(operator, {
currentChannelId: channel.id,
});
if (models.length) {
await operator.batchRecords(models);
}
} else {
const modelPromises: Array<Promise<Model[]>> = [];
switchingTeams = true;
modelPromises.push(addTeamToTeamHistory(operator, teamId, true));
const commonValues: PrepareCommonSystemValuesArgs = {
currentChannelId: channel.id,
currentTeamId: teamId,
};
modelPromises.push(prepareCommonSystemValues(operator, commonValues));
const models = (await Promise.all(modelPromises)).flat();
if (models.length) {
await operator.batchRecords(models);
}
const theme = EphemeralStore.theme;
if (!theme) {
return {error: 'Theme not found'};
}
// Modal right buttons
@@ -124,46 +97,32 @@ export const switchToThread = async (serverUrl: string, rootId: string, isFromNo
const translations = getTranslations(user.locale);
// Get title translation or default title message
const title = translations[t('thread.header.thread')] || 'Thread';
let title = translations[t('thread.header.thread')] || 'Thread';
if (channel.type === General.DM_CHANNEL) {
title = translations[t('thread.header.thread_dm')] || 'Direct Message Thread';
}
let subtitle = '';
if (channel?.type === General.DM_CHANNEL) {
subtitle = channel.displayName;
} else {
if (channel?.type !== General.DM_CHANNEL) {
// Get translation or default message
subtitle = translations[t('thread.header.thread_in')] || 'in {channelName}';
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: {
text: title,
},
subtitle: {
color: changeOpacity(EphemeralStore.theme!.sidebarHeaderTextColor, 0.72),
color: changeOpacity(theme.sidebarHeaderTextColor, 0.72),
text: subtitle,
},
noBorder: true,
scrollEdgeAppearance: {
noBorder: true,
},
rightButtons,
},
});
return {};
} catch (error) {
logError('Failed switchToThread', error);
return {error};
}
};
@@ -172,180 +131,145 @@ export const switchToThread = async (serverUrl: string, rootId: string, isFromNo
// 1. If a reply, then update the reply_count, add user as the participant
// 2. Else add the post as a thread
export async function createThreadFromNewPost(serverUrl: string, post: Post, prepareRecordsOnly = false) {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const models: Model[] = [];
if (post.root_id) {
// Update the thread data: `reply_count`
const {model: threadModel} = await updateThread(serverUrl, post.root_id, {reply_count: post.reply_count}, true);
if (threadModel) {
models.push(threadModel);
}
// Add user as a participant to the thread
const threadParticipantModels = await operator.handleThreadParticipants({
threadsParticipants: [{
thread_id: post.root_id,
participants: [{
thread_id: post.root_id,
id: post.user_id,
}],
}],
prepareRecordsOnly: true,
skipSync: true,
});
models.push(...threadParticipantModels);
} else { // If the post is a root post, then we need to add it to the thread table
const threadModels = await prepareThreadsFromReceivedPosts(operator, [post], false);
models.push(...threadModels);
}
if (!prepareRecordsOnly) {
await operator.batchRecords(models);
}
return {models};
} catch (error) {
logError('Failed createThreadFromNewPost', error);
return {error};
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const models: Model[] = [];
if (post.root_id) {
// Update the thread data: `reply_count`
const {model: threadModel} = await updateThread(serverUrl, post.root_id, {reply_count: post.reply_count}, true);
if (threadModel) {
models.push(threadModel);
}
// Add user as a participant to the thread
const threadParticipantModels = await operator.handleThreadParticipants({
threadsParticipants: [{
thread_id: post.root_id,
participants: [{
thread_id: post.root_id,
id: post.user_id,
}],
}],
prepareRecordsOnly: true,
skipSync: true,
});
models.push(...threadParticipantModels);
} else { // If the post is a root post, then we need to add it to the thread table
const threadModels = await prepareThreadsFromReceivedPosts(operator, [post]);
models.push(...threadModels);
}
if (!prepareRecordsOnly) {
await operator.batchRecords(models);
}
return {models};
}
// On receiving threads, Along with the "threads" & "thread participants", extract and save "posts" & "users"
export async function processReceivedThreads(serverUrl: string, threads: Thread[], teamId: string, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const currentUserId = await getCurrentUserId(database);
const posts: Post[] = [];
const users: UserProfile[] = [];
const threadsToHandle: ThreadWithLastFetchedAt[] = [];
// Extract posts & users from the received threads
for (let i = 0; i < threads.length; i++) {
const {participants, post} = threads[i];
posts.push(post);
participants.forEach((participant) => {
if (currentUserId !== participant.id) {
users.push(participant);
}
});
threadsToHandle.push({...threads[i], lastFetchedAt: post.create_at});
}
const postModels = await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
order: [],
posts,
prepareRecordsOnly: true,
});
const threadModels = await operator.handleThreads({
threads: threadsToHandle,
teamId,
prepareRecordsOnly: true,
});
const models = [...postModels, ...threadModels];
if (users.length) {
const userModels = await operator.handleUsers({
users,
prepareRecordsOnly: true,
});
models.push(...userModels);
}
if (!prepareRecordsOnly) {
await operator.batchRecords(models);
}
return {models};
} catch (error) {
logError('Failed processReceivedThreads', error);
return {error};
export async function processReceivedThreads(serverUrl: string, threads: Thread[], teamId: string, loadedInGlobalThreads = false, prepareRecordsOnly = false) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const {database} = operator;
const currentUserId = await getCurrentUserId(database);
const posts: Post[] = [];
const users: UserProfile[] = [];
// Extract posts & users from the received threads
for (let i = 0; i < threads.length; i++) {
const {participants, post} = threads[i];
posts.push(post);
participants.forEach((participant) => {
if (currentUserId !== participant.id) {
users.push(participant);
}
});
}
const postModels = await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
order: [],
posts,
prepareRecordsOnly: true,
});
const threadModels = await operator.handleThreads({
threads,
teamId,
prepareRecordsOnly: true,
loadedInGlobalThreads,
});
const models = [...postModels, ...threadModels];
if (users.length) {
const userModels = await operator.handleUsers({
users,
prepareRecordsOnly: true,
});
models.push(...userModels);
}
if (!prepareRecordsOnly) {
await operator.batchRecords(models);
}
return {models};
}
export async function markTeamThreadsAsRead(serverUrl: string, teamId: string, prepareRecordsOnly = false) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const {database} = operator;
const threads = await queryThreadsInTeam(database, teamId, true, true, true).fetch();
const models = threads.map((thread) => thread.prepareUpdate((record) => {
record.unreadMentions = 0;
record.unreadReplies = 0;
record.lastViewedAt = Date.now();
record.viewedAt = Date.now();
}));
if (!prepareRecordsOnly) {
await operator.batchRecords(models);
}
return {models};
} catch (error) {
logError('Failed markTeamThreadsAsRead', error);
return {error};
}
}
export async function markThreadAsViewed(serverUrl: string, threadId: string, prepareRecordsOnly = false) {
export async function updateThread(serverUrl: string, threadId: string, updatedThread: Partial<Thread>, prepareRecordsOnly = false) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const {database} = operator;
const thread = await getThreadById(database, threadId);
if (!thread) {
return {error: 'Thread not found'};
if (thread) {
const model = thread.prepareUpdate((record) => {
record.isFollowing = updatedThread.is_following ?? record.isFollowing;
record.replyCount = updatedThread.reply_count ?? record.replyCount;
record.lastViewedAt = updatedThread.last_viewed_at ?? record.lastViewedAt;
record.unreadMentions = updatedThread.unread_mentions ?? record.unreadMentions;
record.unreadReplies = updatedThread.unread_replies ?? record.unreadReplies;
});
if (!prepareRecordsOnly) {
await operator.batchRecords([model]);
}
return {model};
}
thread.prepareUpdate((th) => {
th.viewedAt = thread.lastViewedAt;
th.lastViewedAt = Date.now();
});
if (!prepareRecordsOnly) {
await operator.batchRecords([thread]);
}
return {model: thread};
return {error: 'Thread not found'};
} catch (error) {
logError('Failed markThreadAsViewed', error);
return {error};
}
}
export async function updateThread(serverUrl: string, threadId: string, updatedThread: Partial<ThreadWithViewedAt>, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const thread = await getThreadById(database, threadId);
if (!thread) {
throw new Error('Thread not found');
}
const model = thread.prepareUpdate((record) => {
record.isFollowing = updatedThread.is_following ?? record.isFollowing;
record.replyCount = updatedThread.reply_count ?? record.replyCount;
record.lastViewedAt = updatedThread.last_viewed_at ?? record.lastViewedAt;
record.viewedAt = updatedThread.viewed_at ?? record.viewedAt;
record.unreadMentions = updatedThread.unread_mentions ?? record.unreadMentions;
record.unreadReplies = updatedThread.unread_replies ?? record.unreadReplies;
});
if (!prepareRecordsOnly) {
await operator.batchRecords([model]);
}
return {model};
} catch (error) {
logError('Failed updateThread', error);
return {error};
}
}
export async function updateTeamThreadsSync(serverUrl: string, data: TeamThreadsSync, prepareRecordsOnly = false) {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const models = await operator.handleTeamThreadsSync({data: [data], prepareRecordsOnly});
if (!prepareRecordsOnly) {
await operator.batchRecords(models);
}
return {models};
} catch (error) {
logError('Failed updateTeamThreadsSync', error);
return {error};
}
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getTimeZone} from 'react-native-localize';
import type UserModel from '@typings/database/models/servers/user';
export const isTimezoneEnabled = (config: Partial<ClientConfig>) => {
return config?.ExperimentalTimezone === 'true';
};
export function getDeviceTimezone() {
return getTimeZone();
}
export const getUserTimezone = (currentUser: UserModel) => {
if (currentUser?.timezone) {
return {
...currentUser?.timezone,
useAutomaticTimezone: currentUser?.timezone?.useAutomaticTimezone === 'true',
};
}
return {
useAutomaticTimezone: true,
automaticTimezone: '',
manualTimezone: '',
};
};

View File

@@ -5,8 +5,7 @@ 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';
import {logError} from '@utils/log';
import {getCurrentUser} from '@queries/servers/user';
import {addRecentReaction} from './reactions';
@@ -14,92 +13,107 @@ import type Model from '@nozbe/watermelondb/Model';
import type UserModel from '@typings/database/models/servers/user';
export async function setCurrentUserStatusOffline(serverUrl: string) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const user = await getCurrentUser(database);
if (!user) {
throw new Error(`No current user for ${serverUrl}`);
}
user.prepareStatus(General.OFFLINE);
await operator.batchRecords([user]);
return null;
} catch (error) {
logError('Failed setCurrentUserStatusOffline', error);
return {error};
const serverDatabase = DatabaseManager.serverDatabases[serverUrl];
if (!serverDatabase) {
return {error: `No database present for ${serverUrl}`};
}
const {database, operator} = serverDatabase;
const user = await getCurrentUser(database);
if (!user) {
return {error: `No current user for ${serverUrl}`};
}
user.prepareStatus(General.OFFLINE);
try {
await operator.batchRecords([user]);
} catch {
// eslint-disable-next-line no-console
console.log('FAILED TO BATCH CHANGES FOR SET CURRENT USER STATUS OFFLINE');
}
return null;
}
export async function updateLocalCustomStatus(serverUrl: string, user: UserModel, customStatus?: UserCustomStatus) {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const models: Model[] = [];
const currentProps = {...user.props, customStatus: customStatus || {}};
const userModel = user.prepareUpdate((u: UserModel) => {
u.props = currentProps;
});
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
models.push(userModel);
if (customStatus) {
const recent = await updateRecentCustomStatuses(serverUrl, customStatus, true);
if (Array.isArray(recent)) {
models.push(...recent);
}
const models: Model[] = [];
const currentProps = {...user.props, customStatus: customStatus || {}};
const userModel = user.prepareUpdate((u: UserModel) => {
u.props = currentProps;
});
if (customStatus.emoji) {
const recentEmojis = await addRecentReaction(serverUrl, [customStatus.emoji], true);
if (Array.isArray(recentEmojis)) {
models.push(...recentEmojis);
}
}
models.push(userModel);
if (customStatus) {
const recent = await updateRecentCustomStatuses(serverUrl, customStatus, true);
if (Array.isArray(recent)) {
models.push(...recent);
}
await operator.batchRecords(models);
return {};
} catch (error) {
logError('Failed updateLocalCustomStatus', error);
return {error};
if (customStatus.emoji) {
const recentEmojis = await addRecentReaction(serverUrl, [customStatus.emoji], true);
if (Array.isArray(recentEmojis)) {
models.push(...recentEmojis);
}
}
}
try {
await operator.batchRecords(models);
} catch {
// eslint-disable-next-line no-console
console.log('FAILED TO BATCH CHANGES FOR UPDATING CUSTOM STATUS');
}
return {};
}
export const updateRecentCustomStatuses = async (serverUrl: string, customStatus: UserCustomStatus, prepareRecordsOnly = false, remove = false) => {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const recentStatuses = await getRecentCustomStatuses(database);
const index = recentStatuses.findIndex((cs) => (
cs.emoji === customStatus.emoji &&
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const recentStatuses = await getRecentCustomStatuses(operator.database);
const index = recentStatuses.findIndex((cs) => (
cs.emoji === customStatus.emoji &&
cs.text === customStatus.text &&
cs.duration === customStatus.duration
));
));
if (index !== -1) {
recentStatuses.splice(index, 1);
}
if (!remove) {
recentStatuses.unshift(customStatus);
}
return operator.handleSystem({
systems: [{
id: SYSTEM_IDENTIFIERS.RECENT_CUSTOM_STATUS,
value: JSON.stringify(recentStatuses),
}],
prepareRecordsOnly,
});
} catch (error) {
logError('Failed updateRecentCustomStatuses', error);
return {error};
if (index !== -1) {
recentStatuses.splice(index, 1);
}
if (!remove) {
recentStatuses.unshift(customStatus);
}
return operator.handleSystem({
systems: [{
id: SYSTEM_IDENTIFIERS.RECENT_CUSTOM_STATUS,
value: JSON.stringify(recentStatuses),
}],
prepareRecordsOnly,
});
};
export const updateLocalUser = async (
serverUrl: string,
userDetails: Partial<UserProfile> & { status?: string},
) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const user = await getCurrentUser(database);
if (user) {
await database.write(async () => {
@@ -121,29 +135,9 @@ export const updateLocalUser = async (
});
});
}
return {user};
} catch (error) {
logError('Failed updateLocalUser', error);
return {error};
}
};
export const storeProfile = async (serverUrl: string, profile: UserProfile) => {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const user = await getUserById(database, profile.id);
if (user) {
return {user};
}
const records = await operator.handleUsers({
users: [profile],
prepareRecordsOnly: false,
});
return {user: records[0]};
} catch (error) {
logError('Failed storeProfile', error);
return {error};
}
return {};
};

View File

@@ -1,15 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IntlShape} from 'react-intl';
import {sendEphemeralPost} from '@actions/local/post';
import ClientError from '@client/rest/error';
import {AppCallResponseTypes} from '@constants/apps';
import NetworkManager from '@managers/network_manager';
import {cleanForm, createCallRequest, makeCallErrorResponse} from '@utils/apps';
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';
export async function handleBindingClick<Res=unknown>(serverUrl: string, binding: AppBinding, context: AppContext, intl: IntlShape): Promise<{data?: AppCallResponse<Res>; error?: AppCallResponse<Res>}> {
// Fetch form
@@ -74,7 +75,7 @@ export async function doAppSubmit<Res=unknown>(serverUrl: string, inCall: AppCal
track_as_submit: true,
},
};
const res = await client.executeAppCall<Res>(call, true);
const res = await client.executeAppCall(call, true) as AppCallResponse<Res>;
const responseType = res.type || AppCallResponseTypes.OK;
switch (responseType) {
@@ -133,7 +134,7 @@ export async function doAppFetchForm<Res=unknown>(serverUrl: string, call: AppCa
}
try {
const res = await client.executeAppCall<Res>(call, false);
const res = await client.executeAppCall(call, false) as AppCallResponse<Res>;
const responseType = res.type || AppCallResponseTypes.OK;
switch (responseType) {
@@ -175,7 +176,7 @@ export async function doAppLookup<Res=unknown>(serverUrl: string, call: AppCallR
}
try {
const res = await client.executeAppCall<Res>(call, false);
const res = await client.executeAppCall(call, false) as AppCallResponse<Res>;
const responseType = res.type || AppCallResponseTypes.OK;
switch (responseType) {
@@ -206,7 +207,7 @@ export function postEphemeralCallResponseForPost(serverUrl: string, response: Ap
serverUrl,
message,
post.channelId,
post.rootId || post.id,
post.rootId,
response.app_metadata?.bot_user_id,
);
}

View File

@@ -2,14 +2,7 @@
// See LICENSE.txt for license information.
import {storeCategories} from '@actions/local/category';
import {General} from '@constants';
import {CHANNELS_CATEGORY, DMS_CATEGORY, FAVORITES_CATEGORY} from '@constants/categories';
import DatabaseManager from '@database/manager';
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 {showFavoriteChannelSnackbar} from '@utils/snack_bar';
import {forceLogoutIfNecessary} from './session';
@@ -41,75 +34,3 @@ export const fetchCategories = async (serverUrl: string, teamId: string, prune =
return {error};
}
};
export const toggleFavoriteChannel = async (serverUrl: string, channelId: string, showSnackBar = false) => {
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'};
}
const currentTeamId = await getCurrentTeamId(database);
const teamId = channel?.teamId || currentTeamId;
const currentCategory = await getChannelCategory(database, teamId, channelId);
if (!currentCategory) {
return {error: 'channel does not belong to a category'};
}
const categories = await queryCategoriesByTeamIds(database, [teamId]).fetch();
const isFavorited = currentCategory.type === FAVORITES_CATEGORY;
let targetWithChannels: CategoryWithChannels;
let favoriteWithChannels: CategoryWithChannels;
if (isFavorited) {
const categoryType = (channel.type === General.DM_CHANNEL || channel.type === General.GM_CHANNEL) ? DMS_CATEGORY : CHANNELS_CATEGORY;
const targetCategory = categories.find((c) => c.type === categoryType);
if (!targetCategory) {
return {error: 'target category not found'};
}
targetWithChannels = await targetCategory.toCategoryWithChannels();
targetWithChannels.channel_ids.unshift(channelId);
favoriteWithChannels = await currentCategory.toCategoryWithChannels();
const channelIndex = favoriteWithChannels.channel_ids.indexOf(channelId);
favoriteWithChannels.channel_ids.splice(channelIndex, 1);
} else {
const favoritesCategory = categories.find((c) => c.type === FAVORITES_CATEGORY);
if (!favoritesCategory) {
return {error: 'No favorites category'};
}
favoriteWithChannels = await favoritesCategory.toCategoryWithChannels();
favoriteWithChannels.channel_ids.unshift(channelId);
targetWithChannels = await currentCategory.toCategoryWithChannels();
const channelIndex = targetWithChannels.channel_ids.indexOf(channelId);
targetWithChannels.channel_ids.splice(channelIndex, 1);
}
await client.updateChannelCategories('me', teamId, [targetWithChannels, favoriteWithChannels]);
if (showSnackBar) {
const onUndo = () => toggleFavoriteChannel(serverUrl, channelId, false);
showFavoriteChannelSnackbar(!isFavorited, onUndo);
}
return {data: true};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};

View File

@@ -2,45 +2,38 @@
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import {DeviceEventEmitter} from 'react-native';
import {Model} from '@nozbe/watermelondb';
import {IntlShape} from 'react-intl';
import {addChannelToDefaultCategory, storeCategories} from '@actions/local/category';
import {removeCurrentUserFromChannel, setChannelDeleteAt, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel';
import {storeCategories} from '@actions/local/category';
import {addChannelToDefaultCategory, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel';
import {switchToGlobalThreads} from '@actions/local/thread';
import {loadCallForChannel} from '@calls/actions/calls';
import {DeepLink, Events, General, Preferences, Screens} from '@constants';
import {General, Preferences, Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {privateChannelJoinPrompt} from '@helpers/api/channel';
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
import AppsManager from '@managers/apps_manager';
import NetworkManager from '@managers/network_manager';
import {getActiveServer} from '@queries/app/servers';
import {prepareMyChannelsForTeam, getChannelById, getChannelByName, getMyChannel, getChannelInfo, queryMyChannelSettingsByIds, getMembersCountByChannelsId} from '@queries/servers/channel';
import {prepareMyChannelsForTeam, getChannelById, getChannelByName, getMyChannel, getChannelInfo} from '@queries/servers/channel';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {getCommonSystemValues, getConfig, getCurrentChannelId, getCurrentTeamId, getCurrentUserId, getLicense, setCurrentChannelId, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {getNthLastChannelFromTeam, getMyTeamById, getTeamByName, queryMyTeams, removeChannelFromTeamHistory} from '@queries/servers/team';
import {getCommonSystemValues, getCurrentTeamId, getCurrentUserId} from '@queries/servers/system';
import {prepareMyTeams, getNthLastChannelFromTeam, getMyTeamById, getTeamById, getTeamByName, queryMyTeams} from '@queries/servers/team';
import {getCurrentUser} from '@queries/servers/user';
import {dismissAllModals, popToRoot} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {setTeamLoading} from '@store/team_load_store';
import {generateChannelNameFromDisplayName, getDirectChannelName, isDMorGM} from '@utils/channel';
import {isTablet} from '@utils/helpers';
import {logDebug, logError, logInfo} from '@utils/log';
import {showMuteChannelSnackbar} from '@utils/snack_bar';
import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url';
import {displayGroupMessageName, displayUsername} from '@utils/user';
import {fetchGroupsForChannelIfConstrained} from './groups';
import {fetchPostsForChannel} from './post';
import {setDirectChannelVisible} from './preference';
import {fetchRolesIfNeeded} from './role';
import {forceLogoutIfNecessary} from './session';
import {addCurrentUserToTeam, fetchTeamByName, removeCurrentUserFromTeam} from './team';
import {fetchProfilesInGroupChannels, fetchProfilesPerChannels, fetchUsersByIds, updateUsersNoLongerVisible} from './user';
import {addUserToTeam, fetchTeamByName, removeUserFromTeam} from './team';
import {fetchProfilesPerChannels, fetchUsersByIds} from './user';
import type {Client} from '@client/rest';
import type {Model} from '@nozbe/watermelondb';
import type ChannelModel from '@typings/database/models/servers/channel';
import type {IntlShape} from 'react-intl';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
import type MyTeamModel from '@typings/database/models/servers/my_team';
import type TeamModel from '@typings/database/models/servers/team';
export type MyChannelsRequest = {
categories?: CategoryWithChannels[];
@@ -163,7 +156,6 @@ export async function createChannel(serverUrl: string, displayName: string, purp
return {channel: channelData};
} catch (error) {
EphemeralStore.creatingChannel = false;
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
}
@@ -205,65 +197,10 @@ export async function patchChannel(serverUrl: string, channelPatch: Partial<Chan
}
return {channel: channelData};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
}
export async function leaveChannel(serverUrl: string, channelId: 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 {database} = operator;
const isTabletDevice = await isTablet();
const user = await getCurrentUser(database);
const models: Model[] = [];
if (!user) {
return {error: 'current user not found'};
}
EphemeralStore.addLeavingChannel(channelId);
await client.removeFromChannel(user.id, channelId);
if (user.isGuest) {
const {models: updateVisibleModels} = await updateUsersNoLongerVisible(serverUrl, true);
if (updateVisibleModels) {
models.push(...updateVisibleModels);
}
}
const {models: removeUserModels} = await removeCurrentUserFromChannel(serverUrl, channelId, true);
if (removeUserModels) {
models.push(...removeUserModels);
}
await operator.batchRecords(models);
if (isTabletDevice) {
switchToLastChannel(serverUrl);
} else {
setCurrentChannelId(operator, '');
}
return {error: undefined};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
} finally {
EphemeralStore.removeLeavingChannel(channelId);
}
}
export async function fetchChannelCreator(serverUrl: string, channelId: string, fetchOnly = false) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
@@ -346,7 +283,7 @@ export async function fetchChannelStats(serverUrl: string, channelId: string, fe
}
}
export async function fetchMyChannelsForTeam(serverUrl: string, teamId: string, includeDeleted = true, since = 0, fetchOnly = false, excludeDirect = false, isCRTEnabled?: boolean): Promise<MyChannelsRequest> {
export async function fetchMyChannelsForTeam(serverUrl: string, teamId: string, includeDeleted = true, since = 0, fetchOnly = false, excludeDirect = false): Promise<MyChannelsRequest> {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
@@ -360,9 +297,6 @@ export async function fetchMyChannelsForTeam(serverUrl: string, teamId: string,
}
try {
if (!fetchOnly) {
setTeamLoading(serverUrl, true);
}
const [allChannels, channelMemberships, categoriesWithOrder] = await Promise.all([
client.getMyChannels(teamId, includeDeleted, since),
client.getMyChannelMembers(teamId),
@@ -385,20 +319,16 @@ export async function fetchMyChannelsForTeam(serverUrl: string, teamId: string,
}, []);
if (!fetchOnly) {
const {models: chModels} = await storeMyChannelsForTeam(serverUrl, teamId, channels, memberships, true, isCRTEnabled);
const {models: chModels} = await storeMyChannelsForTeam(serverUrl, teamId, channels, memberships, true);
const {models: catModels} = await storeCategories(serverUrl, categories, true, true); // Re-sync
const models = (chModels || []).concat(catModels || []);
if (models.length) {
await operator.batchRecords(models);
}
setTeamLoading(serverUrl, false);
}
return {channels, memberships, categories};
} catch (error) {
if (!fetchOnly) {
setTeamLoading(serverUrl, false);
}
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
@@ -437,114 +367,68 @@ export async function fetchMyChannel(serverUrl: string, teamId: string, channelI
}
}
export async function fetchMissingDirectChannelsInfo(serverUrl: string, directChannels: Channel[], locale?: string, teammateDisplayNameSetting?: string, currentUserId?: string, fetchOnly = false) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
export async function fetchMissingSidebarInfo(serverUrl: string, directChannels: Channel[], locale?: string, teammateDisplayNameSetting?: string, currentUserId?: string, fetchOnly = false) {
const channelIds = directChannels.sort((a, b) => b.last_post_at - a.last_post_at).map((dc) => dc.id);
const result = await fetchProfilesPerChannels(serverUrl, channelIds, currentUserId, false);
if (result.error) {
return {error: result.error};
}
const {database} = operator;
const displayNameByChannel: Record<string, string> = {};
const users: UserProfile[] = [];
const updatedChannels = new Set<Channel>();
const dms: Channel[] = [];
const dmIds: string[] = [];
const dmWithoutDisplayName = new Set<string>();
const gms: Channel[] = [];
for (const c of directChannels) {
if (c.type === General.DM_CHANNEL) {
dms.push(c);
dmIds.push(c.id);
if (!c.display_name) {
dmWithoutDisplayName.add(c.id);
if (result.data) {
result.data.forEach((data) => {
if (data.users?.length) {
users.push(...data.users);
if (data.users.length > 1) {
displayNameByChannel[data.channelId] = displayGroupMessageName(data.users, locale, teammateDisplayNameSetting, currentUserId);
} else {
displayNameByChannel[data.channelId] = displayUsername(data.users[0], locale, teammateDisplayNameSetting, false);
}
}
continue;
}
gms.push(c);
});
}
try {
const currentUser = await getCurrentUser(database);
// let's filter those channels that we already have the users
const membersCount = await getMembersCountByChannelsId(database, dmIds);
const profileChannelsToFetch = dmIds.filter((id) => membersCount[id] <= 1 || dmWithoutDisplayName.has(id));
const results = await Promise.all([
profileChannelsToFetch.length ? fetchProfilesPerChannels(serverUrl, profileChannelsToFetch, currentUserId, false) : Promise.resolve({data: undefined}),
fetchProfilesInGroupChannels(serverUrl, gms.map((c) => c.id), false),
]);
const profileRequests = results.flat();
for (const result of profileRequests) {
result.data?.forEach((data) => {
if (data.users?.length) {
users.push(...data.users);
if (data.users.length > 1) {
displayNameByChannel[data.channelId] = displayGroupMessageName(data.users, locale, teammateDisplayNameSetting, currentUserId);
} else {
displayNameByChannel[data.channelId] = displayUsername(data.users[0], locale, teammateDisplayNameSetting, false);
}
}
});
directChannels.forEach((c) => {
const displayName = displayNameByChannel[c.id];
if (displayName) {
c.display_name = displayName;
c.fake = true;
updatedChannels.add(c);
}
});
if (currentUserId) {
const ownDirectChannel = dms.find((dm) => dm.name === getDirectChannelName(currentUserId, currentUserId));
if (ownDirectChannel) {
ownDirectChannel.display_name = displayUsername(currentUser, locale, teammateDisplayNameSetting, false);
ownDirectChannel.fake = true;
updatedChannels.add(ownDirectChannel);
}
}
directChannels.forEach((c) => {
const displayName = displayNameByChannel[c.id];
if (displayName) {
c.display_name = displayName;
c.fake = true;
}
});
const updatedChannelsArray = Array.from(updatedChannels);
if (!fetchOnly) {
if (currentUserId) {
const ownDirectChannel = directChannels.find((dm) => dm.name === getDirectChannelName(currentUserId, currentUserId));
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (ownDirectChannel && database) {
const currentUser = await getCurrentUser(database);
ownDirectChannel.display_name = displayUsername(currentUser, locale, teammateDisplayNameSetting, false);
}
}
if (!fetchOnly) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (operator) {
const modelPromises: Array<Promise<Model[]>> = [];
if (updatedChannelsArray.length) {
modelPromises.push(operator.handleChannel({channels: updatedChannelsArray, prepareRecordsOnly: true}));
if (users.length) {
modelPromises.push(operator.handleUsers({users, prepareRecordsOnly: true}));
modelPromises.push(operator.handleChannel({channels: directChannels, prepareRecordsOnly: true}));
}
const models = await Promise.all(modelPromises);
await operator.batchRecords(models.flat());
}
return {directChannels: updatedChannelsArray, users};
} catch (error) {
return {error};
}
}
export async function fetchDirectChannelsInfo(serverUrl: string, directChannels: ChannelModel[]) {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
const preferences = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS).fetch();
const config = await getConfig(database);
const license = await getLicense(database);
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences, config?.LockTeammateNameDisplay, config?.TeammateNameDisplay, license);
const currentUser = await getCurrentUser(database);
const channels = directChannels.map((d) => d.toApi());
return fetchMissingDirectChannelsInfo(serverUrl, channels, currentUser?.locale, teammateDisplayNameSetting, currentUser?.id);
return {directChannels, users};
}
export async function joinChannel(serverUrl: string, teamId: string, channelId?: string, channelName?: string, fetchOnly = false) {
export async function joinChannel(serverUrl: string, userId: string, teamId: string, channelId?: string, channelName?: string, fetchOnly = false) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const database = operator.database;
let client: Client;
try {
@@ -553,8 +437,6 @@ export async function joinChannel(serverUrl: string, teamId: string, channelId?:
return {error};
}
const userId = await getCurrentUserId(database);
let member: ChannelMembership | undefined;
let channel: Channel | undefined;
try {
@@ -573,7 +455,7 @@ export async function joinChannel(serverUrl: string, teamId: string, channelId?:
}
} catch (error) {
if (channelId || channel?.id) {
EphemeralStore.removeJoiningChannel(channelId || channel!.id);
EphemeralStore.removeJoiningChanel(channelId || channel!.id);
}
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
@@ -595,21 +477,21 @@ export async function joinChannel(serverUrl: string, teamId: string, channelId?:
try {
await operator.batchRecords(flattenedModels);
} catch {
logError('FAILED TO BATCH CHANNELS');
// eslint-disable-next-line no-console
console.log('FAILED TO BATCH CHANNELS');
}
}
}
}
} catch (error) {
if (channelId || channel?.id) {
EphemeralStore.removeJoiningChannel(channelId || channel!.id);
EphemeralStore.removeJoiningChanel(channelId || channel!.id);
}
return {error};
}
if (channelId || channel?.id) {
loadCallForChannel(serverUrl, channelId || channel!.id);
EphemeralStore.removeJoiningChannel(channelId || channel!.id);
EphemeralStore.removeJoiningChanel(channelId || channel!.id);
}
return {channel, member};
}
@@ -626,7 +508,8 @@ export async function joinChannelIfNeeded(serverUrl: string, channelId: string)
return {error: undefined};
}
return joinChannel(serverUrl, '', channelId);
const userId = await getCurrentUserId(database);
return joinChannel(serverUrl, userId, '', channelId);
} catch (error) {
return {error};
}
@@ -644,105 +527,159 @@ export async function markChannelAsRead(serverUrl: string, channelId: string) {
}
export async function switchToChannelByName(serverUrl: string, channelName: string, teamName: string, errorHandler: (intl: IntlShape) => void, intl: IntlShape) {
let database;
try {
const result = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
database = result.database;
} catch (e) {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
const onError = (joinedTeam: boolean, teamId?: string) => {
errorHandler(intl);
if (joinedTeam && teamId) {
removeCurrentUserFromTeam(serverUrl, teamId, false);
}
};
let joinedTeam = false;
let teamId = '';
try {
if (teamName === DeepLink.Redirect) {
teamId = await getCurrentTeamId(database);
} else {
const team = await getTeamByName(database, teamName);
const isTeamMember = team ? await getMyTeamById(database, team.id) : false;
teamId = team?.id || '';
let myChannel: MyChannelModel | ChannelMembership | undefined;
let team: TeamModel | Team | undefined;
let myTeam: MyTeamModel | TeamMembership | undefined;
let name = teamName;
const roles: string [] = [];
const system = await getCommonSystemValues(database);
const currentTeam = await getTeamById(database, system.currentTeamId);
if (!isTeamMember) {
const fetchRequest = await fetchTeamByName(serverUrl, teamName);
if (!fetchRequest.team) {
onError(joinedTeam);
return {error: fetchRequest.error || 'no team received'};
}
const {error} = await addCurrentUserToTeam(serverUrl, fetchRequest.team.id);
if (error) {
onError(joinedTeam);
return {error};
}
teamId = fetchRequest.team.id;
joinedTeam = true;
}
if (name === PERMALINK_GENERIC_TEAM_NAME_REDIRECT) {
name = currentTeam!.name;
} else {
team = await getTeamByName(database, teamName);
}
const channel = await getChannelByName(database, teamId, channelName);
const isChannelMember = channel ? await getMyChannel(database, channel.id) : false;
let channelId = channel?.id || '';
if (!isChannelMember) {
const fetchRequest = await fetchChannelByName(serverUrl, teamId, channelName, true);
if (!fetchRequest.channel) {
onError(joinedTeam, teamId);
return {error: fetchRequest.error || 'cannot fetch channel'};
if (!team) {
const fetchTeam = await fetchTeamByName(serverUrl, name, true);
if (fetchTeam.error) {
errorHandler(intl);
return {error: fetchTeam.error};
}
if (fetchRequest.channel.type === General.PRIVATE_CHANNEL) {
const {join} = await privateChannelJoinPrompt(fetchRequest.channel.display_name, intl);
team = fetchTeam.team!;
}
let joinedNewTeam = false;
myTeam = await getMyTeamById(database, team.id);
if (!myTeam) {
const added = await addUserToTeam(serverUrl, team.id, system.currentUserId, true);
if (added.error) {
errorHandler(intl);
return {error: added.error};
}
myTeam = added.member!;
roles.push(...myTeam.roles.split(' '));
joinedNewTeam = true;
}
if (!myTeam) {
errorHandler(intl);
return {error: 'Could not fetch team member'};
}
let isArchived = false;
const chReq = await fetchChannelByName(serverUrl, team.id, channelName);
if (chReq.error) {
errorHandler(intl);
return {error: chReq.error};
}
const channel = chReq.channel;
if (!channel) {
errorHandler(intl);
return {error: 'Could not fetch channel'};
}
isArchived = channel.delete_at > 0;
if (isArchived && system.config.ExperimentalViewArchivedChannels !== 'true') {
errorHandler(intl);
return {error: 'Channel is archived'};
}
myChannel = await getMyChannel(database, channel.id);
if (!myChannel) {
if (channel.type === General.PRIVATE_CHANNEL) {
const displayName = channel.display_name;
const {join} = await privateChannelJoinPrompt(displayName, intl);
if (!join) {
onError(joinedTeam, teamId);
if (joinedNewTeam) {
await removeUserFromTeam(serverUrl, team.id, system.currentUserId, true);
}
errorHandler(intl);
return {error: 'Refused to join Private channel'};
}
}
console.log('joining channel', displayName, channel.id); //eslint-disable-line
const result = await joinChannel(serverUrl, system.currentUserId, team.id, channel.id, undefined, true);
if (result.error || !result.channel) {
if (joinedNewTeam) {
await removeUserFromTeam(serverUrl, team.id, system.currentUserId, true);
}
logInfo('joining channel', fetchRequest.channel.display_name, fetchRequest.channel.id);
const joinRequest = await joinChannel(serverUrl, teamId, undefined, channelName, false);
if (!joinRequest.channel) {
onError(joinedTeam, teamId);
return {error: joinRequest.error || 'no channel returned from join'};
}
errorHandler(intl);
return {error: result.error};
}
channelId = fetchRequest.channel.id;
myChannel = result.member!;
roles.push(...myChannel.roles.split(' '));
}
}
if (!myChannel) {
errorHandler(intl);
return {error: 'could not fetch channel member'};
}
const modelPromises: Array<Promise<Model[]>> = [];
const {operator} = DatabaseManager.serverDatabases[serverUrl];
if (!(team instanceof Model)) {
modelPromises.push(...prepareMyTeams(operator, [team], [(myTeam as TeamMembership)]));
} else if (!(myTeam instanceof Model)) {
const mt: MyTeam[] = [{
id: myTeam.team_id,
roles: myTeam.roles,
}];
modelPromises.push(
operator.handleMyTeam({myTeams: mt, prepareRecordsOnly: true}),
operator.handleTeamMemberships({teamMemberships: [myTeam], prepareRecordsOnly: true}),
);
}
if (!(myChannel instanceof Model)) {
modelPromises.push(...await prepareMyChannelsForTeam(operator, team.id, [channel], [myChannel]));
}
let teamId;
if (team.id !== system.currentTeamId) {
teamId = team.id;
}
let channelId;
if (channel.id !== system.currentChannelId) {
channelId = channel.id;
}
if (modelPromises.length) {
const models = await Promise.all(modelPromises);
await operator.batchRecords(models.flat());
}
if (teamId) {
fetchMyChannelsForTeam(serverUrl, teamId, true, 0, false, true);
}
if (teamId && channelId) {
await switchToChannelById(serverUrl, channelId, teamId);
}
if (roles.length) {
fetchRolesIfNeeded(serverUrl, roles);
}
switchToChannelById(serverUrl, channelId, teamId);
return {error: undefined};
} catch (error) {
onError(joinedTeam, teamId);
errorHandler(intl);
return {error};
}
}
export async function goToNPSChannel(serverUrl: string) {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const user = await client.getUserByUsername(General.NPS_PLUGIN_BOT_USERNAME);
const {data, error} = await createDirectChannel(serverUrl, user.id);
if (error || !data) {
throw error || new Error('channel not found');
}
await switchToChannelById(serverUrl, data.id, data.team_id);
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
return {};
}
export async function createDirectChannel(serverUrl: string, userId: string, displayName = '') {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
@@ -762,13 +699,6 @@ export async function createDirectChannel(serverUrl: string, userId: string, dis
if (!currentUser) {
return {error: 'Cannot get the current user'};
}
const channelName = getDirectChannelName(currentUser.id, userId);
const channel = await getChannelByName(database, '', channelName);
if (channel) {
return {data: channel.toApi()};
}
EphemeralStore.creatingDMorGMTeammates = [userId];
const created = await client.createDirectChannel([userId, currentUser.id]);
const profiles: UserProfile[] = [];
@@ -777,10 +707,9 @@ export async function createDirectChannel(serverUrl: string, userId: string, dis
created.display_name = displayName;
} else {
const preferences = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT).fetch();
const license = await getLicense(database);
const config = await getConfig(database);
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config.LockTeammateNameDisplay, config.TeammateNameDisplay, license);
const {directChannels, users} = await fetchMissingDirectChannelsInfo(serverUrl, [created], currentUser.locale, teammateDisplayNameSetting, currentUser.id, true);
const system = await getCommonSystemValues(database);
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], system.config, system.license);
const {directChannels, users} = await fetchMissingSidebarInfo(serverUrl, [created], currentUser.locale, teammateDisplayNameSetting, currentUser.id, true);
created.display_name = directChannels?.[0].display_name || created.display_name;
if (users?.length) {
profiles.push(...users);
@@ -857,7 +786,7 @@ export async function makeDirectChannel(serverUrl: string, userId: string, displ
try {
const currentUserId = await getCurrentUserId(operator.database);
const channelName = getDirectChannelName(userId, currentUserId);
let channel: Channel|ChannelModel|undefined = await getChannelByName(operator.database, '', channelName);
let channel: Channel|ChannelModel|undefined = await getChannelByName(operator.database, channelName);
let result: {data?: Channel|ChannelModel; error?: any};
if (channel) {
result = {data: channel};
@@ -875,7 +804,6 @@ export async function makeDirectChannel(serverUrl: string, userId: string, displ
return {error};
}
}
export async function fetchArchivedChannels(serverUrl: string, teamId: string, page = 0, perPage: number = General.CHANNELS_CHUNK_SIZE) {
let client: Client;
try {
@@ -895,16 +823,19 @@ export async function fetchArchivedChannels(serverUrl: string, teamId: string, p
}
export async function createGroupChannel(serverUrl: string, userIds: 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 {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const currentUser = await getCurrentUser(database);
const currentUser = await getCurrentUser(operator.database);
if (!currentUser) {
return {error: 'Cannot get the current user'};
}
@@ -918,11 +849,10 @@ export async function createGroupChannel(serverUrl: string, userIds: string[]) {
return {data: created};
}
const preferences = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT).fetch();
const license = await getLicense(database);
const config = await getConfig(database);
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config.LockTeammateNameDisplay, config.TeammateNameDisplay, license);
const {directChannels, users} = await fetchMissingDirectChannelsInfo(serverUrl, [created], currentUser.locale, teammateDisplayNameSetting, currentUser.id, true);
const preferences = await queryPreferencesByCategoryAndName(operator.database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT).fetch();
const system = await getCommonSystemValues(operator.database);
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], system.config, system.license);
const {directChannels, users} = await fetchMissingSidebarInfo(serverUrl, [created], currentUser.locale, teammateDisplayNameSetting, currentUser.id, true);
const member = {
channel_id: created.id,
@@ -965,7 +895,6 @@ export async function createGroupChannel(serverUrl: string, userIds: string[]) {
return {error};
}
}
export async function fetchSharedChannels(serverUrl: string, teamId: string, page = 0, perPage: number = General.CHANNELS_CHUNK_SIZE) {
let client: Client;
try {
@@ -1003,7 +932,6 @@ export async function makeGroupChannel(serverUrl: string, userIds: string[], sho
return {error};
}
}
export async function getChannelMemberCountsByGroup(serverUrl: string, channelId: string, includeTimezones: boolean) {
let client: Client;
try {
@@ -1037,64 +965,38 @@ export async function getChannelTimezones(serverUrl: string, channelId: string)
}
export async function switchToChannelById(serverUrl: string, channelId: string, teamId?: string, skipLastUnread = false) {
if (channelId === Screens.GLOBAL_THREADS) {
return switchToGlobalThreads(serverUrl, teamId);
}
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
DeviceEventEmitter.emit(Events.CHANNEL_SWITCH, true);
fetchPostsForChannel(serverUrl, channelId);
await switchToChannel(serverUrl, channelId, teamId, skipLastUnread);
setDirectChannelVisible(serverUrl, channelId);
markChannelAsRead(serverUrl, channelId);
fetchChannelStats(serverUrl, channelId);
fetchGroupsForChannelIfConstrained(serverUrl, channelId);
DeviceEventEmitter.emit(Events.CHANNEL_SWITCH, false);
if (await AppsManager.isAppsEnabled(serverUrl)) {
AppsManager.fetchBindings(serverUrl, channelId);
}
return {};
}
export async function switchToPenultimateChannel(serverUrl: string, teamId?: string) {
export async function switchToPenultimateChannel(serverUrl: string) {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
try {
const teamIdToUse = teamId || await getCurrentTeamId(database);
const channelId = await getNthLastChannelFromTeam(database, teamIdToUse, 1);
const currentTeam = await getCurrentTeamId(database);
const channelId = await getNthLastChannelFromTeam(database, currentTeam, 1);
if (channelId === Screens.GLOBAL_THREADS) {
return switchToGlobalThreads(serverUrl);
}
return switchToChannelById(serverUrl, channelId);
} catch (error) {
return {error};
}
}
export async function switchToLastChannel(serverUrl: string, teamId?: string) {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
try {
const teamIdToUse = teamId || await getCurrentTeamId(database);
const channelId = await getNthLastChannelFromTeam(database, teamIdToUse);
return switchToChannelById(serverUrl, channelId);
} catch (error) {
return {error};
}
}
export async function searchChannels(serverUrl: string, term: string, teamId: string, isSearch = false) {
export async function searchChannels(serverUrl: string, term: string) {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
@@ -1108,8 +1010,8 @@ export async function searchChannels(serverUrl: string, term: string, teamId: st
}
try {
const autoCompleteFunc = isSearch ? client.autocompleteChannelsForSearch : client.autocompleteChannels;
const channels = await autoCompleteFunc(teamId, term);
const currentTeamId = await getCurrentTeamId(database);
const channels = await client.autocompleteChannels(currentTeamId, term);
return {channels};
} catch (error) {
return {error};
@@ -1153,200 +1055,3 @@ export async function searchAllChannels(serverUrl: string, term: string, archive
return {error};
}
}
export const updateChannelNotifyProps = async (serverUrl: string, channelId: string, props: Partial<ChannelNotifyProps>) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
try {
const userId = await getCurrentUserId(database);
const notifyProps = {...props, channel_id: channelId, user_id: userId} as ChannelNotifyProps & {channel_id: string; user_id: string};
await client.updateChannelNotifyProps(notifyProps);
return {
notifyProps,
};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const toggleMuteChannel = async (serverUrl: string, channelId: string, showSnackBar = false) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
try {
const channelSettings = await queryMyChannelSettingsByIds(database, [channelId]).fetch();
const myChannelSetting = channelSettings?.[0];
const mark_unread = myChannelSetting.notifyProps?.mark_unread === 'mention' ? 'all' : 'mention';
const notifyProps: Partial<ChannelNotifyProps> = {...myChannelSetting.notifyProps, mark_unread};
await updateChannelNotifyProps(serverUrl, channelId, notifyProps);
await database.write(async () => {
await myChannelSetting.update((c) => {
c.notifyProps = notifyProps;
});
});
if (showSnackBar) {
const onUndo = () => toggleMuteChannel(serverUrl, channelId, false);
showMuteChannelSnackbar(mark_unread === 'mention', onUndo);
}
return {
notifyProps,
};
} catch (error) {
return {error};
}
};
export const archiveChannel = async (serverUrl: string, channelId: 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 {database} = operator;
const config = await getConfig(database);
EphemeralStore.addArchivingChannel(channelId);
await client.deleteChannel(channelId);
if (config?.ExperimentalViewArchivedChannels === 'true') {
await setChannelDeleteAt(serverUrl, channelId, Date.now());
} else {
removeCurrentUserFromChannel(serverUrl, channelId);
}
return {error: undefined};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
} finally {
EphemeralStore.removeArchivingChannel(channelId);
}
};
export const unarchiveChannel = async (serverUrl: string, channelId: 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 {
EphemeralStore.addArchivingChannel(channelId);
await client.unarchiveChannel(channelId);
await setChannelDeleteAt(serverUrl, channelId, 0);
return {error: undefined};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
} finally {
EphemeralStore.removeArchivingChannel(channelId);
}
};
export const convertChannelToPrivate = async (serverUrl: string, channelId: 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 {database} = operator;
const channel = await getChannelById(database, channelId);
if (channel) {
EphemeralStore.addConvertingChannel(channelId);
}
await client.convertChannelToPrivate(channelId);
if (channel) {
channel.prepareUpdate((c) => {
c.type = General.PRIVATE_CHANNEL;
});
await operator.batchRecords([channel]);
}
return {error: undefined};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
} finally {
EphemeralStore.removeConvertingChannel(channelId);
}
};
export const handleKickFromChannel = async (serverUrl: string, channelId: string, event: string = Events.LEAVE_CHANNEL) => {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const currentChannelId = await getCurrentChannelId(database);
if (currentChannelId !== channelId) {
return;
}
const currentServer = await getActiveServer();
if (currentServer?.url === serverUrl) {
const channel = await getChannelById(database, channelId);
DeviceEventEmitter.emit(event, channel?.displayName);
await dismissAllModals();
await popToRoot();
}
const tabletDevice = await isTablet();
if (tabletDevice) {
const teamId = await getCurrentTeamId(database);
await removeChannelFromTeamHistory(operator, teamId, channelId);
const newChannelId = await getNthLastChannelFromTeam(database, teamId, 0, channelId);
if (newChannelId) {
if (currentServer?.url === serverUrl) {
if (newChannelId === Screens.GLOBAL_THREADS) {
await switchToGlobalThreads(serverUrl, teamId, false);
} else {
await switchToChannelById(serverUrl, newChannelId, teamId, true);
}
} else {
await setCurrentTeamAndChannelId(operator, teamId, channelId);
}
} // TODO else jump to "join a channel" screen https://mattermost.atlassian.net/browse/MM-41051
} else {
await setCurrentChannelId(operator, '');
}
} catch (error) {
logDebug('cannot kick user from channel', error);
}
};

View File

@@ -1,25 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IntlShape} from 'react-intl';
import {Alert} from 'react-native';
import {doAppSubmit, postEphemeralCallResponseForCommandArgs} from '@actions/remote/apps';
import {AppCommandParser} from '@components/autocomplete/slash_suggestion/app_command_parser/app_command_parser';
import {AppCallResponseTypes} from '@constants/apps';
import {showPermalink} from '@actions/remote/permalink';
import {Client} from '@client/rest';
import DeepLinkTypes from '@constants/deep_linking';
import DatabaseManager from '@database/manager';
import AppsManager from '@managers/apps_manager';
import IntegrationsManager from '@managers/integrations_manager';
import NetworkManager from '@managers/network_manager';
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 {tryOpenURL} from '@utils/url';
import {getTeammateNameDisplay, queryUsersByUsername} from '@queries/servers/user';
import {showModal} from '@screens/navigation';
import * as DraftUtils from '@utils/draft';
import {matchDeepLink, tryOpenURL} from '@utils/url';
import {displayUsername} from '@utils/user';
import type {Client} from '@client/rest';
import type {IntlShape} from 'react-intl';
import {makeDirectChannel, switchToChannelById, switchToChannelByName} from './channel';
export const executeCommand = async (serverUrl: string, intl: IntlShape, message: string, channelId: string, rootId?: string): Promise<{data?: CommandResponse; error?: string | {message: string}}> => {
import type {DeepLinkChannel, DeepLinkPermalink, DeepLinkDM, DeepLinkGM, DeepLinkPlugin} from '@typings/launch';
export const executeCommand = async (serverUrl: string, intl: IntlShape, message: string, channelId: string, rootId?: string) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
@@ -32,6 +35,14 @@ export const executeCommand = async (serverUrl: string, intl: IntlShape, message
return {error: error as ClientErrorProps};
}
// const config = await queryConfig(operator.database)
// if (config.FeatureFlagAppsEnabled) {
// const parser = new AppCommandParser(serverUrl, intl, channelId, rootId);
// if (parser.isAppCommand(msg)) {
// return executeAppCommand(serverUrl, intl, parser);
// }
// }
const channel = await getChannelById(operator.database, channelId);
const teamId = channel?.teamId || (await getCurrentTeamId(operator.database));
@@ -42,14 +53,6 @@ export const executeCommand = async (serverUrl: string, intl: IntlShape, message
parent_id: rootId,
};
const appsEnabled = await AppsManager.isAppsEnabled(serverUrl);
if (appsEnabled) {
const parser = new AppCommandParser(serverUrl, intl, channelId, teamId, rootId);
if (parser.isAppCommand(message)) {
return executeAppCommand(serverUrl, intl, parser, message, args);
}
}
let msg = filterEmDashForCommand(message);
let cmdLength = msg.indexOf(' ');
@@ -58,11 +61,7 @@ export const executeCommand = async (serverUrl: string, intl: IntlShape, message
}
const cmd = msg.substring(0, cmdLength).toLowerCase();
if (cmd === '/code') {
msg = cmd + ' ' + msg.substring(cmdLength, msg.length).trimEnd();
} else {
msg = cmd + ' ' + msg.substring(cmdLength, msg.length).trim();
}
msg = cmd + msg.substring(cmdLength);
let data;
try {
@@ -78,51 +77,44 @@ export const executeCommand = async (serverUrl: string, intl: IntlShape, message
return {data};
};
const executeAppCommand = async (serverUrl: string, intl: IntlShape, parser: AppCommandParser, msg: string, args: CommandArgs) => {
const {creq, errorMessage} = await parser.composeCommandSubmitCall(msg);
const createErrorMessage = (errMessage: string) => {
return {error: {message: errMessage}};
};
// TODO https://mattermost.atlassian.net/browse/MM-41234
// const executeAppCommand = (serverUrl: string, intl: IntlShape, parser: any) => {
// const {call, errorMessage} = await parser.composeCallFromCommand(msg);
// const createErrorMessage = (errMessage: string) => {
// return {error: {message: errMessage}};
// };
if (!creq) {
return createErrorMessage(errorMessage!);
}
// if (!call) {
// return createErrorMessage(errorMessage!);
// }
const res = await doAppSubmit(serverUrl, creq, intl);
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 as AppCallResponse;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.text) {
postEphemeralCallResponseForCommandArgs(serverUrl, callResp, callResp.text, args);
}
return {data: {}};
case AppCallResponseTypes.FORM:
if (callResp.form) {
showAppForm(callResp.form, creq.context);
}
return {data: {}};
case AppCallResponseTypes.NAVIGATE:
if (callResp.navigate_to_url) {
handleGotoLocation(serverUrl, intl, callResp.navigate_to_url);
}
return {data: {}};
default:
return createErrorMessage(intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResp.type,
}));
}
};
// const res = await dispatch(doAppCall(call, AppCallTypes.SUBMIT, intl));
// if (res.error) {
// const errorResponse = res.error as AppCallResponse;
// return createErrorMessage(errorResponse.error || intl.formatMessage({
// id: 'apps.error.unknown',
// defaultMessage: 'Unknown error.',
// }));
// }
// const callResp = res.data as AppCallResponse;
// switch (callResp.type) {
// case AppCallResponseTypes.OK:
// if (callResp.markdown) {
// dispatch(postEphemeralCallResponseForCommandArgs(callResp, callResp.markdown, args));
// }
// return {data: {}};
// case AppCallResponseTypes.FORM:
// case AppCallResponseTypes.NAVIGATE:
// return {data: {}};
// default:
// return createErrorMessage(intl.formatMessage({
// id: 'apps.error.responses.unknown_type',
// defaultMessage: 'App response type not supported. Response type: {type}.',
// }, {
// type: callResp.type,
// }));
// }
// };
const filterEmDashForCommand = (command: string): string => {
return command.replace(/\u2014/g, '--');
@@ -139,7 +131,54 @@ export const handleGotoLocation = async (serverUrl: string, intl: IntlShape, loc
const match = matchDeepLink(location, serverUrl, config?.SiteURL);
if (match) {
handleDeepLink(match, intl, location);
switch (match.type) {
case DeepLinkTypes.CHANNEL: {
const data = match.data as DeepLinkChannel;
switchToChannelByName(data.serverUrl, data.channelName, data.teamName, DraftUtils.errorBadChannel, intl);
break;
}
case DeepLinkTypes.PERMALINK: {
const data = match.data as DeepLinkPermalink;
showPermalink(serverUrl, data.teamName, data.postId, intl);
break;
}
case DeepLinkTypes.DMCHANNEL: {
const data = match.data as DeepLinkDM;
if (!data.userName) {
DraftUtils.errorUnkownUser(intl);
return {data: false};
}
if (data.serverUrl !== serverUrl) {
if (!database) {
return {error: `${serverUrl} database not found`};
}
}
const user = (await queryUsersByUsername(database, [data.userName]).fetch())[0];
if (!user) {
DraftUtils.errorUnkownUser(intl);
return {data: false};
}
makeDirectChannel(data.serverUrl, user.id, displayUsername(user, intl.locale, await getTeammateNameDisplay(database)), true);
break;
}
case DeepLinkTypes.GROUPCHANNEL: {
const data = match.data as DeepLinkGM;
if (!data.channelId) {
DraftUtils.errorBadChannel(intl);
return {data: false};
}
switchToChannelById(data.serverUrl, data.channelId);
break;
}
case DeepLinkTypes.PLUGIN: {
const data = match.data as DeepLinkPlugin;
showModal('PluginInternal', data.id, {link: location});
break;
}
}
} else {
const {formatMessage} = intl;
const onError = () => Alert.alert(

View File

@@ -2,14 +2,13 @@
// See LICENSE.txt for license information.
import {forceLogoutIfNecessary} from '@actions/remote/session';
import {Client} from '@client/rest';
import {Emoji, General} from '@constants';
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 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 {
@@ -88,17 +87,10 @@ const debouncedFetchEmojiByNames = debounce(async (serverUrl: string) => {
promises.push(client.getCustomEmojiByName(name));
}
const emojis = await Promise.all(promises);
try {
const emojisResult = await Promise.allSettled(promises);
const emojis = emojisResult.reduce<CustomEmoji[]>((result, e) => {
if (e.status === 'fulfilled') {
result.push(e.value);
}
return result;
}, []);
if (emojis.length) {
await operator.handleCustomEmojis({emojis, prepareRecordsOnly: false});
}
await operator.handleCustomEmojis({emojis, prepareRecordsOnly: false});
return {error: undefined};
} catch (error) {
return {error};

View File

@@ -1,77 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {dataRetentionCleanup, setLastServerVersionCheck} from '@actions/local/systems';
import {switchToGlobalThreads} from '@actions/local/thread';
import {switchToChannelById} from '@actions/remote/channel';
import {fetchRoles} from '@actions/remote/role';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import {Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {prepareCommonSystemValues, getCurrentTeamId, getWebSocketLastDisconnected, getCurrentChannelId, getConfig, getLicense} from '@queries/servers/system';
import {queryChannelsById, getDefaultChannelForTeam} from '@queries/servers/channel';
import {prepareModels} from '@queries/servers/entry';
import {prepareCommonSystemValues, getCommonSystemValues, getCurrentTeamId, getWebSocketLastDisconnected, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {getNthLastChannelFromTeam} from '@queries/servers/team';
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 {handleEntryAfterLoadNavigation, registerDeviceToken, syncOtherServers, verifyPushProxy} from './common';
import {deferredAppEntryActions, entry} from './gql_common';
import {AppEntryData, AppEntryError, deferredAppEntryActions, fetchAppEntryData, registerDeviceToken, syncOtherServers, teamsToRemove} from './common';
export async function appEntry(serverUrl: string, since = 0, isUpgrade = false) {
export async function appEntry(serverUrl: string, since = 0) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const {database} = operator;
if (!since) {
registerDeviceToken(serverUrl);
if (Object.keys(DatabaseManager.serverDatabases).length === 1) {
await setLastServerVersionCheck(serverUrl, true);
}
}
// Run data retention cleanup
await dataRetentionCleanup(serverUrl);
// clear lastUnreadChannelId
const removeLastUnreadChannelId = await prepareCommonSystemValues(operator, {lastUnreadChannelId: ''});
if (removeLastUnreadChannelId) {
await operator.batchRecords(removeLastUnreadChannelId);
operator.batchRecords(removeLastUnreadChannelId);
}
const {database} = operator;
const tabletDevice = await isTablet();
const currentTeamId = await getCurrentTeamId(database);
const currentChannelId = await getCurrentChannelId(database);
const lastDisconnectedAt = (await getWebSocketLastDisconnected(database)) || since;
const fetchedData = await fetchAppEntryData(serverUrl, lastDisconnectedAt, currentTeamId);
const fetchedError = (fetchedData as AppEntryError).error;
setTeamLoading(serverUrl, true);
const entryData = await entry(serverUrl, currentTeamId, currentChannelId, since);
if ('error' in entryData) {
setTeamLoading(serverUrl, false);
return {error: entryData.error};
if (fetchedError) {
return {error: fetchedError};
}
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);
const {initialTeamId, teamData, chData, prefData, meData, removeTeamIds, removeChannelIds} = fetchedData as AppEntryData;
const rolesData = await fetchRoles(serverUrl, teamData?.memberships, chData?.memberships, meData?.user, true, true);
if (initialTeamId === currentTeamId) {
if (tabletDevice) {
const cId = await getNthLastChannelFromTeam(database, currentTeamId);
if (cId === Screens.GLOBAL_THREADS) {
switchToGlobalThreads(serverUrl);
} else {
switchToChannelById(serverUrl, cId, initialTeamId);
}
}
} else {
// Immediately set the new team as the current team in the database so that the UI
// renders the correct team.
let channelId = '';
if (tabletDevice) {
const channel = await getDefaultChannelForTeam(database, initialTeamId);
channelId = channel?.id || '';
}
if (channelId) {
switchToChannelById(serverUrl, channelId, initialTeamId);
} else {
setCurrentTeamAndChannelId(operator, initialTeamId, channelId);
}
}
await handleEntryAfterLoadNavigation(serverUrl, teamData.memberships || [], chData?.memberships || [], currentTeamId, currentChannelId, initialTeamId, initialChannelId);
const removeTeams = await teamsToRemove(serverUrl, removeTeamIds);
const dt = Date.now();
await operator.batchRecords(models);
logInfo('ENTRY MODELS BATCHING TOOK', `${Date.now() - dt}ms`);
setTeamLoading(serverUrl, false);
let removeChannels;
if (removeChannelIds?.length) {
removeChannels = await queryChannelsById(database, removeChannelIds).fetch();
}
const {id: currentUserId, locale: currentUserLocale} = meData?.user || (await getCurrentUser(database))!;
const config = await getConfig(database);
const license = await getLicense(database);
const modelPromises = await prepareModels({operator, initialTeamId, removeTeams, removeChannels, teamData, chData, prefData, meData});
if (rolesData.roles?.length) {
modelPromises.push(operator.handleRole({roles: rolesData.roles, prepareRecordsOnly: true}));
}
const models = await Promise.all(modelPromises);
await operator.batchRecords(models.flat());
const {id: currentUserId, locale: currentUserLocale} = meData.user || (await getCurrentUser(database))!;
const {config, license} = await getCommonSystemValues(database);
await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId);
if (!since) {
@@ -79,22 +96,30 @@ export async function appEntry(serverUrl: string, since = 0, isUpgrade = false)
syncOtherServers(serverUrl);
}
verifyPushProxy(serverUrl);
return {userId: currentUserId};
const error = teamData.error || chData?.error || prefData.error || meData.error;
return {error, userId: meData?.user?.id};
}
export async function upgradeEntry(serverUrl: string) {
const dt = Date.now();
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const configAndLicense = await fetchConfigAndLicense(serverUrl, false);
const entryData = await appEntry(serverUrl, 0, true);
const error = configAndLicense.error || entryData.error;
const entry = await appEntry(serverUrl);
const error = configAndLicense.error || entry.error;
if (!error) {
await DatabaseManager.updateServerIdentifier(serverUrl, configAndLicense.config!.DiagnosticId);
await DatabaseManager.setActiveServerDatabase(serverUrl);
const models = await prepareCommonSystemValues(operator, {currentUserId: entry.userId});
if (models?.length) {
await operator.batchRecords(models);
}
DatabaseManager.updateServerIdentifier(serverUrl, configAndLicense.config!.DiagnosticId);
DatabaseManager.setActiveServerDatabase(serverUrl);
deleteV1Data();
}

View File

@@ -1,42 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {dataRetentionCleanup} from '@actions/local/systems';
import {fetchMissingDirectChannelsInfo, fetchMyChannelsForTeam, handleKickFromChannel, MyChannelsRequest} from '@actions/remote/channel';
import {fetchGroupsForMember} from '@actions/remote/groups';
import {fetchPostsForUnreadChannels} from '@actions/remote/post';
import {fetchChannelStats, fetchMissingSidebarInfo, fetchMyChannelsForTeam, markChannelAsRead, MyChannelsRequest} from '@actions/remote/channel';
import {fetchPostsForChannel, fetchPostsForUnreadChannels} from '@actions/remote/post';
import {MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference';
import {fetchRoles} from '@actions/remote/role';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import {fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, handleKickFromTeam, MyTeamsRequest, updateCanJoinTeams} from '@actions/remote/team';
import {syncTeamThreads} from '@actions/remote/thread';
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';
import {fetchAllTeams, fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, MyTeamsRequest} from '@actions/remote/team';
import {fetchMe, MyUserRequest, updateAllUsersSince} from '@actions/remote/user';
import {Preferences} from '@constants';
import DatabaseManager from '@database/manager';
import {getPreferenceValue, getTeammateNameDisplaySetting} from '@helpers/api/preference';
import {selectDefaultTeam} from '@helpers/api/team';
import {DEFAULT_LOCALE} from '@i18n';
import NetworkManager from '@managers/network_manager';
import {getDeviceToken} from '@queries/app/global';
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, 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 {getMemberChannelsFromGQLQuery, gqlToClientChannelMembership} from '@utils/graphql';
import {isTablet} from '@utils/helpers';
import {logDebug} from '@utils/log';
import {processIsCRTEnabled} from '@utils/thread';
import {queryAllServers} from '@queries/app/servers';
import {queryAllChannelsForTeam} from '@queries/servers/channel';
import {getConfig} from '@queries/servers/system';
import {deleteMyTeams, getAvailableTeamIds, queryMyTeams, queryMyTeamsByIds, queryTeamsById} from '@queries/servers/team';
import {isDMorGM} from '@utils/channel';
import {isCRTEnabled} from '@utils/thread';
import {fetchNewThreads} from '../thread';
import type ClientError from '@client/rest/error';
import type {Database, Model} from '@nozbe/watermelondb';
export type AppEntryData = {
initialTeamId: string;
@@ -46,38 +33,16 @@ export type AppEntryData = {
meData: MyUserRequest;
removeTeamIds?: string[];
removeChannelIds?: string[];
isCRTEnabled: boolean;
}
export type AppEntryError = {
error: Error | ClientError | string;
error?: Error | ClientError | string;
}
export type EntryResponse = {
models: Model[];
initialTeamId: string;
initialChannelId: string;
prefData: MyPreferencesRequest;
teamData: MyTeamsRequest;
chData?: MyChannelsRequest;
meData?: MyUserRequest;
} | {
error: unknown;
}
const FETCH_MISSING_DM_TIMEOUT = 2500;
export const FETCH_UNREADS_TIMEOUT = 2500;
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);
};
export const teamsToRemove = async (serverUrl: string, removeTeamIds?: string[]) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return [];
return undefined;
}
const {database} = operator;
@@ -92,87 +57,31 @@ export const teamsToRemove = async (serverUrl: string, removeTeamIds?: string[])
}
}
return [];
return undefined;
};
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);
if ('error' in fetchedData) {
return {error: fetchedData.error};
}
const {initialTeamId, teamData, chData, prefData, meData, removeTeamIds, removeChannelIds, isCRTEnabled} = fetchedData;
const error = teamData.error || chData?.error || prefData.error || meData.error;
if (error) {
return {error};
}
const rolesData = await fetchRoles(serverUrl, teamData.memberships, chData?.memberships, meData.user, true);
const initialChannelId = await entryInitialChannelId(database, channelId, teamId, initialTeamId, meData?.user?.locale || '', chData?.channels, chData?.memberships);
const removeTeams = await teamsToRemove(serverUrl, removeTeamIds);
let removeChannels;
if (removeChannelIds?.length) {
removeChannels = await queryChannelsById(database, removeChannelIds).fetch();
}
const modelPromises = await prepareModels({operator, initialTeamId, removeTeams, removeChannels, teamData, chData, prefData, meData, isCRTEnabled});
if (rolesData.roles?.length) {
modelPromises.push(operator.handleRole({roles: rolesData.roles, prepareRecordsOnly: true}));
}
const models = await Promise.all(modelPromises);
return {models: models.flat(), initialChannelId, initialTeamId, prefData, teamData, chData, meData};
};
export const fetchAppEntryData = async (serverUrl: string, sinceArg: number, initialTeamId = ''): Promise<AppEntryData | AppEntryError> => {
export const fetchAppEntryData = async (serverUrl: string, since: number, initialTeamId: string): Promise<AppEntryData | AppEntryError> => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
let since = sinceArg;
const includeDeletedChannels = true;
const fetchOnly = true;
const confReq = await fetchConfigAndLicense(serverUrl);
const prefData = await fetchMyPreferences(serverUrl, fetchOnly);
const isCRTEnabled = Boolean(prefData.preferences && processIsCRTEnabled(prefData.preferences, confReq.config?.CollapsedThreads, confReq.config?.FeatureFlagCollapsedThreads, confReq.config?.Version));
if (prefData.preferences) {
const crtToggled = await getHasCRTChanged(database, prefData.preferences);
if (crtToggled) {
const currentServerUrl = await DatabaseManager.getActiveServerUrl();
const isSameServer = currentServerUrl === serverUrl;
if (isSameServer) {
since = 0;
}
const {error} = await truncateCrtRelatedTables(serverUrl);
if (error) {
return {error: `Resetting CRT on ${serverUrl} failed`};
}
}
}
await fetchConfigAndLicense(serverUrl);
// Fetch in parallel teams / team membership / channels for current team / user preferences / user
const promises: [Promise<MyTeamsRequest>, Promise<MyChannelsRequest | undefined>, Promise<MyUserRequest>] = [
const promises: [Promise<MyTeamsRequest>, Promise<MyChannelsRequest | undefined>, Promise<MyPreferencesRequest>, Promise<MyUserRequest>] = [
fetchMyTeams(serverUrl, fetchOnly),
initialTeamId ? fetchMyChannelsForTeam(serverUrl, initialTeamId, includeDeletedChannels, since, fetchOnly, false, isCRTEnabled) : Promise.resolve(undefined),
initialTeamId ? fetchMyChannelsForTeam(serverUrl, initialTeamId, includeDeletedChannels, since, fetchOnly) : Promise.resolve(undefined),
fetchMyPreferences(serverUrl, fetchOnly),
fetchMe(serverUrl, fetchOnly),
];
const removeTeamIds: string[] = [];
const resolution = await Promise.all(promises);
const [teamData, , meData] = resolution;
const [teamData, , prefData, meData] = resolution;
let [, chData] = resolution;
if (!initialTeamId && teamData.teams?.length && teamData.memberships?.length) {
@@ -183,11 +92,14 @@ export const fetchAppEntryData = async (serverUrl: string, sinceArg: number, ini
const myTeams = teamData.teams!.filter((t) => teamMembers.has(t.id));
const defaultTeam = selectDefaultTeam(myTeams, meData.user?.locale || DEFAULT_LOCALE, teamOrderPreference, config?.ExperimentalPrimaryTeam);
if (defaultTeam?.id) {
chData = await fetchMyChannelsForTeam(serverUrl, defaultTeam.id, includeDeletedChannels, since, fetchOnly, false, isCRTEnabled);
chData = await fetchMyChannelsForTeam(serverUrl, defaultTeam.id, includeDeletedChannels, since, fetchOnly);
}
}
const removeTeamIds = await getRemoveTeamIds(database, teamData);
const removedFromTeam = teamData.memberships?.filter((m) => m.delete_at > 0);
if (removedFromTeam?.length) {
removeTeamIds.push(...removedFromTeam.map((m) => m.team_id));
}
let data: AppEntryData = {
initialTeamId,
@@ -196,10 +108,13 @@ export const fetchAppEntryData = async (serverUrl: string, sinceArg: number, ini
prefData,
meData,
removeTeamIds,
isCRTEnabled,
};
if (teamData.teams?.length === 0 && !teamData.error) {
// User is no longer a member of any team
const myTeams = await queryMyTeams(database).fetch();
removeTeamIds.push(...(myTeams.map((myTeam) => myTeam.id) || []));
return {
...data,
initialTeamId: '',
@@ -216,7 +131,7 @@ export const fetchAppEntryData = async (serverUrl: string, sinceArg: number, ini
}
const availableTeamIds = await getAvailableTeamIds(database, initialTeamId, teamData.teams, prefData.preferences, meData.user?.locale);
const alternateTeamData = await fetchAlternateTeamData(serverUrl, availableTeamIds, removeTeamIds, includeDeletedChannels, since, fetchOnly, isCRTEnabled);
const alternateTeamData = await fetchAlternateTeamData(serverUrl, availableTeamIds, removeTeamIds, includeDeletedChannels, since, fetchOnly);
data = {
...data,
@@ -246,13 +161,13 @@ export const fetchAppEntryData = async (serverUrl: string, sinceArg: number, ini
export const fetchAlternateTeamData = async (
serverUrl: string, availableTeamIds: string[], removeTeamIds: string[],
includeDeleted = true, since = 0, fetchOnly = false, isCRTEnabled?: boolean) => {
includeDeleted = true, since = 0, fetchOnly = false) => {
let initialTeamId = '';
let chData;
for (const teamId of availableTeamIds) {
// eslint-disable-next-line no-await-in-loop
chData = await fetchMyChannelsForTeam(serverUrl, teamId, includeDeleted, since, fetchOnly, false, isCRTEnabled);
chData = await fetchMyChannelsForTeam(serverUrl, teamId, includeDeleted, since, fetchOnly);
const chError = chData.error as ClientError | undefined;
if (chError?.status_code === 403) {
removeTeamIds.push(teamId);
@@ -269,92 +184,52 @@ export const fetchAlternateTeamData = async (
return {initialTeamId, removeTeamIds};
};
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));
// If team and channel are the requested, return the channel
if (initialTeamId === requestedTeamId && requestedChannel) {
return requestedChannelId;
}
// DM or GMs don't care about changes in teams, so return directly
if (requestedChannel && isDMorGM(requestedChannel)) {
return requestedChannelId;
}
// Check if we are still members of any channel on the history
const teamChannelHistory = await getTeamChannelHistory(database, initialTeamId);
for (const c of teamChannelHistory) {
if (membershipIds.has(c) || c === Screens.GLOBAL_THREADS) {
return c;
}
}
// Check if we are member of the default channel.
const defaultChannel = channels?.find((c) => c.name === General.DEFAULT_CHANNEL && c.team_id === initialTeamId);
const iAmMemberOfTheTeamDefaultChannel = Boolean(defaultChannel && membershipIds.has(defaultChannel.id));
if (iAmMemberOfTheTeamDefaultChannel) {
return defaultChannel!.id;
}
// Get the first channel of the list, based on the locale.
const myFirstTeamChannel = channels?.filter((c) =>
c.team_id === requestedTeamId &&
c.type === General.OPEN_CHANNEL &&
membershipIds.has(c.id),
).sort(sortChannelsByDisplayName.bind(null, locale))[0];
return myFirstTeamChannel?.id || '';
}
export async function restDeferredAppEntryActions(
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,
config: ClientConfig, license: ClientLicense, 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 initial channel
if (initialChannelId) {
fetchPostsForChannel(serverUrl, initialChannelId);
markChannelAsRead(serverUrl, initialChannelId);
fetchChannelStats(serverUrl, initialChannelId);
}
// defer fetching posts for unread channels on initial team
fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId);
// defer sidebar DM & GM profiles
if (chData?.channels?.length && chData.memberships?.length) {
const directChannels = chData.channels.filter(isDMorGM);
const channelsToFetchProfiles = new Set<Channel>(directChannels);
if (channelsToFetchProfiles.size) {
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config, license);
await fetchMissingSidebarInfo(serverUrl, Array.from(channelsToFetchProfiles), currentUserLocale, teammateDisplayNameSetting, currentUserId);
}
}, FETCH_UNREADS_TIMEOUT);
// defer fetching posts for unread channels on initial team
fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId);
}
// defer fetch channels and unread posts for other teams
if (teamData.teams?.length && teamData.memberships?.length) {
fetchTeamsChannelsAndUnreadPosts(serverUrl, since, teamData.teams, teamData.memberships, initialTeamId);
await fetchTeamsChannelsAndUnreadPosts(serverUrl, since, teamData.teams, teamData.memberships, initialTeamId);
}
if (preferences && processIsCRTEnabled(preferences, config.CollapsedThreads, config.FeatureFlagCollapsedThreads, config.Version)) {
if (preferences && isCRTEnabled(preferences, config)) {
if (initialTeamId) {
await syncTeamThreads(serverUrl, initialTeamId);
await fetchNewThreads(serverUrl, initialTeamId, false);
}
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);
await fetchNewThreads(serverUrl, team.id, false);
}
}
}
}
updateCanJoinTeams(serverUrl);
await updateAllUsersSince(serverUrl, since);
// Fetch groups for current user
fetchGroupsForMember(serverUrl, currentUserId);
setTimeout(async () => {
if (channelsToFetchProfiles?.size) {
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config.LockTeammateNameDisplay, config.TeammateNameDisplay, license);
fetchMissingDirectChannelsInfo(serverUrl, Array.from(channelsToFetchProfiles), currentUserLocale, teammateDisplayNameSetting, currentUserId);
}
}, FETCH_MISSING_DM_TIMEOUT);
fetchAllTeams(serverUrl);
updateAllUsersSince(serverUrl, since);
}
export const registerDeviceToken = async (serverUrl: string) => {
@@ -365,89 +240,36 @@ export const registerDeviceToken = async (serverUrl: string) => {
return {error};
}
const deviceToken = await getDeviceToken();
if (deviceToken) {
client.attachDevice(deviceToken);
const appDatabase = DatabaseManager.appDatabase?.database;
if (appDatabase) {
const deviceToken = await getDeviceToken(appDatabase);
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).then(() => {
dataRetentionCleanup(server.url);
});
autoUpdateTimezone(server.url);
const database = DatabaseManager.appDatabase?.database;
if (database) {
const servers = await queryAllServers(database);
for (const server of servers) {
if (server.url !== serverUrl && server.lastActiveAt > 0) {
registerDeviceToken(server.url);
syncAllChannelMembers(server.url);
}
}
}
};
const syncAllChannelMembersAndThreads = async (serverUrl: string) => {
const syncAllChannelMembers = 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);
@@ -457,111 +279,12 @@ const restSyncAllChannelMembers = async (serverUrl: string) => {
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) {
for (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 {
client = NetworkManager.getClient(serverUrl);
} catch (err) {
return;
}
try {
const response = await client.ping(deviceId);
const canReceiveNotifications = response?.data?.CanReceiveNotifications;
switch (canReceiveNotifications) {
case PUSH_PROXY_RESPONSE_NOT_AVAILABLE:
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.PUSH_VERIFICATION_STATUS, value: PUSH_PROXY_STATUS_NOT_AVAILABLE}], prepareRecordsOnly: false});
return;
case PUSH_PROXY_RESPONSE_UNKNOWN:
return;
default:
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.PUSH_VERIFICATION_STATUS, value: PUSH_PROXY_STATUS_VERIFIED}], prepareRecordsOnly: false});
}
} catch (err) {
// Do nothing
}
}
export async function handleEntryAfterLoadNavigation(
serverUrl: string,
teamMembers: TeamMembership[],
channelMembers: ChannelMember[],
currentTeamId: string,
currentChannelId: string,
initialTeamId: string,
initialChannelId: string,
) {
try {
const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const currentTeamIdAfterLoad = await getCurrentTeamId(database);
const currentChannelIdAfterLoad = await getCurrentChannelId(database);
const mountedScreens = NavigationStore.getScreensInStack();
const isChannelScreenMounted = mountedScreens.includes(Screens.CHANNEL);
const isThreadsMounted = mountedScreens.includes(Screens.THREAD);
const tabletDevice = await isTablet();
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) {
await handleKickFromTeam(serverUrl, currentTeamIdAfterLoad);
} else if (currentChannelIdAfterLoad !== currentChannelId) {
// Switched channels while loading
if (!channelMembers.find((m) => m.channel_id === currentChannelIdAfterLoad)) {
if (tabletDevice || isChannelScreenMounted || isThreadsMounted) {
await handleKickFromChannel(serverUrl, currentChannelIdAfterLoad);
} else {
await setCurrentTeamAndChannelId(operator, initialTeamId, initialChannelId);
}
}
} else if (currentChannelIdAfterLoad && currentChannelIdAfterLoad !== initialChannelId) {
if (tabletDevice || isChannelScreenMounted || isThreadsMounted) {
await handleKickFromChannel(serverUrl, currentChannelIdAfterLoad);
} else {
await setCurrentTeamAndChannelId(operator, initialTeamId, initialChannelId);
}
}
} catch (error) {
logDebug('could not manage the entry after load navigation', error);
}
}

View File

@@ -1,295 +0,0 @@
// 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 {fetchDataRetentionPolicy} from '@actions/remote/systems';
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, getIsDataRetentionEnabled} 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);
}
// 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) {
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;
}

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