Compare commits

...

22 Commits

Author SHA1 Message Date
Gleidson Daniel Silva 4dfc9c70f3
[FIX] Keyboard not showing all emojis or showing cut emojis (#4919)
* fix the logic based on window width and make the keyboard dynamic

* fix var naming and math

* fix alignment of emojis

* add comment

* wip
2023-03-09 17:17:20 -03:00
Piyush Gupta 513e8ee636
[FIX] Increase border radius on MessageAvatar as small (#4837)
* fixed border radius of 4 on avatar

* fixed lint

* Update tests

---------

Co-authored-by: Diego Mello <diegolmello@gmail.com>
2023-03-08 15:34:39 -03:00
Mister-H aa57237004
[FIX] Order of quoted message done according to desktop version (#4739)
Co-authored-by: Diego Mello <diegolmello@gmail.com>
2023-03-08 11:20:01 -03:00
Diego Mello 58391964cb
[FIX] Message not rendering E2EE data if md exists (#4951) 2023-03-07 10:57:57 -03:00
Diego Mello 412f62eb2a
Chore: Update Detox to 20.1.2 (#4866) 2023-03-07 09:28:51 -03:00
Reinaldo Neto 4526b7f871
[FIX] UGC Rules Text align (#4934) 2023-03-02 16:04:52 -03:00
Diego Mello 27efa89dac
Bump version to 4.37.0 (#4938) 2023-03-02 13:22:40 -03:00
Gleidson Daniel Silva 3fbb7b5720
[IMPROVE] Brings the operation of the video call closer to the web (#4883)
* rename CallAgainActionSheet to StartACallActionSheet

* remove useVideoConf and use videoConfJoin directly

* consider phone on calls

* fix text shrink

* fix mic audio

* change the behavior of call icon on header and RoomInfo

* update types

* update types and variables names

* revert old type

* fix issue on old servers

* rename to a correct naming

* fix translation

* revamp call icon

* add error handling to videoconf capabilities

* lint

* fix role logic

* change const name

* rename comp

* remove commented code

* fix types and apply correct logic

* fix naming

* correct the import

* update icon size

* create timer function for videoConf bellow 5.0

* add subscription to useVideoConf hook

---------

Co-authored-by: Diego Mello <diegolmello@gmail.com>
2023-03-01 15:26:56 -03:00
Reinaldo Neto 4d5ff8aba1
[FIX] Omnichannel's icon svg fallback when the link returns an error (#4916)
* [FIX] Omnichannel's icon svg fallback when the link returns an error

* add activity indicator

* switch between activity indicator to custom icon

* reuse the same component as const
2023-02-27 17:31:28 -03:00
Reinaldo Neto 4336f0db40
[FIX] "Allow Reaction" does not work properly on "Read Only" rooms (#4864)
* [FIX] "Allow Reaction" does not work properly on "Read Only" rooms

* fix the handle of message box

---------

Co-authored-by: Gleidson Daniel Silva <gleidson10daniel@hotmail.com>
2023-02-24 11:37:56 -03:00
Gleidson Daniel Silva 4686d4f6f8
[FIX] Text color and fit content of TaskList (#4906)
* fix regression on plain text

* update stories

* fix where to add the color

* fit content taskList

---------

Co-authored-by: Reinaldo Neto <reinaldonetof@hotmail.com>
2023-02-16 10:07:19 -03:00
Gleidson Daniel Silva 58f28fb488
[FIX] Remove some permissions and keep only basic Bluetooth permission on Android (#4912) 2023-02-15 14:19:44 -03:00
lingohub[bot] 6bb4adaecd
Language update from LingoHub 🤖 on 2023-02-14Z (#4911)
* Language update from LingoHub 🤖

Project Name: Rocket.Chat.ReactNative
Project Link: https://translate.lingohub.com/rocketchat/dashboard/rocket-dot-chat-dot-reactnative
User: Diego Mello

Easy language translations with LingoHub 🚀

* Language update from LingoHub 🤖

Project Name: Rocket.Chat.ReactNative
Project Link: https://translate.lingohub.com/rocketchat/dashboard/rocket-dot-chat-dot-reactnative
User: Diego Mello

Easy language translations with LingoHub 🚀

---------

Co-authored-by: Diego Mello <diego.mello@rocket.chat>
2023-02-14 18:00:41 -03:00
Diego Mello 197616b94c
Chore: Remove deprecated typing stream (#4888) 2023-02-14 15:49:16 -03:00
Diego Mello edd0333be1
Chore: Update React Native to 0.68.6 (#4881) 2023-02-14 12:06:37 -03:00
Diego Mello 8c47187f70
[NEW] Presence Cap (#4900) 2023-02-14 10:47:56 -03:00
Gleidson Daniel Silva c4a2ce20c6
Regression: Adds idempotent prop to delete audio (#4862) 2023-02-06 16:07:23 -03:00
Reinaldo Neto 68f6eb40de
Chore: Hooks app/views/InviteUsersEditView (#4670)
* Chore: Hooks app/views/InviteUsersEditView

* minor tweak

* switch value name
2023-02-02 18:41:32 -03:00
Diego Mello 59c76d3956
Bump version to 4.36.0 (#4884) 2023-02-02 16:51:16 -03:00
Reinaldo Neto 9396b08ead
[FIX] Quote rendering with leading empty space on mobile only (#4778)
* [FIX] Quote rendering with leading empty space on mobile only

* update the message storyshot

* compareServerVersion to connection string

* fix time

* fix lint

* update to servers greater or equal than 6.0

* refactor tests

* minor tweak

---------

Co-authored-by: GleidsonDaniel <gleidson10daniel@hotmail.com>
2023-02-02 00:17:09 -03:00
Reinaldo Neto a927746d7f
[FIX] Show read receipts when it isn't read yet (#4865)
* [FIX] Show read receipts when it isn't read yet

* minor tweak
2023-02-02 00:10:27 -03:00
Reinaldo Neto 21e4818af7
[FIX] Team icons on Share View and searching on RoomsListView (#4833)
* [FIX] Team icons on Share View

* fix the teams when searching
2023-02-02 00:04:17 -03:00
173 changed files with 3744 additions and 5812 deletions

View File

@ -1,6 +1,9 @@
defaults: &defaults defaults: &defaults
working_directory: ~/repo working_directory: ~/repo
orbs:
android: circleci/android@2.1.2
macos: &macos macos: &macos
macos: macos:
xcode: "14.2.0" xcode: "14.2.0"
@ -327,6 +330,14 @@ commands:
working_directory: ios working_directory: ios
- save_cache: *save-gems-cache - save_cache: *save-gems-cache
create-e2e-account-file:
description: "Create e2e account file"
steps:
- run:
command: |
echo $E2E_ACCOUNT | base64 --decode > ./e2e_account.ts
working_directory: e2e
version: 2.1 version: 2.1
# EXECUTORS # EXECUTORS
@ -438,6 +449,94 @@ jobs:
- upload-to-google-play-beta: - upload-to-google-play-beta:
official: true official: true
e2e-build-android:
<<: *defaults
executor:
name: android/android-machine
resource-class: xlarge
tag: 2022.12.1
environment:
<<: *android-env
steps:
- checkout
- restore_cache: *restore-npm-cache-linux
- run: *install-npm-modules
- save_cache: *save-npm-cache-linux
- restore_cache: *restore-gradle-cache
- run:
name: Configure Gradle
command: |
echo -e "" > ./gradle.properties
# echo -e "android.enableAapt2=false" >> ./gradle.properties
echo -e "android.useAndroidX=true" >> ./gradle.properties
echo -e "android.enableJetifier=true" >> ./gradle.properties
echo -e "newArchEnabled=false" >> ./gradle.properties
echo -e "FLIPPER_VERSION=0.125.0" >> ./gradle.properties
echo -e "VERSIONCODE=$CIRCLE_BUILD_NUM" >> ./gradle.properties
echo -e "APPLICATION_ID=chat.rocket.reactnative" >> ./gradle.properties
echo -e "BugsnagAPIKey=$BUGSNAG_KEY" >> ./gradle.properties
echo $KEYSTORE_EXPERIMENTAL_BASE64 | base64 --decode > ./app/$KEYSTORE_EXPERIMENTAL
echo -e "KEYSTORE=$KEYSTORE_EXPERIMENTAL" >> ./gradle.properties
echo -e "KEYSTORE_PASSWORD=$KEYSTORE_EXPERIMENTAL_PASSWORD" >> ./gradle.properties
echo -e "KEY_ALIAS=$KEYSTORE_EXPERIMENTAL_ALIAS" >> ./gradle.properties
echo -e "KEY_PASSWORD=$KEYSTORE_EXPERIMENTAL_PASSWORD" >> ./gradle.properties
working_directory: android
- run:
name: Build Android
command: |
echo "RUNNING_E2E_TESTS=true" > ./.env
yarn e2e:android-build
- save_cache: *save-gradle-cache
- store_artifacts:
path: android/app/build/outputs/apk/experimentalPlay/release/app-experimental-play-release.apk
- store_artifacts:
path: android/app/build/outputs/apk/androidTest/experimentalPlay/release/app-experimental-play-release-androidTest.apk
- persist_to_workspace:
root: /home/circleci/repo
paths:
- android/app/build/outputs/apk/
e2e-test-android:
<<: *defaults
executor:
name: android/android-machine
resource-class: xlarge
tag: 2022.12.1
parallelism: 4
steps:
- checkout
- attach_workspace:
at: /home/circleci/repo
- restore_cache: *restore-npm-cache-linux
- run: *install-npm-modules
- save_cache: *save-npm-cache-linux
- run: mkdir ~/junit
- create-e2e-account-file
- android/create-avd:
avd-name: Pixel_API_31_AOSP
install: true
system-image: system-images;android-31;default;x86_64
- run:
name: Setup emulator
command: |
echo "hw.lcd.density = 440" >> ~/.android/avd/Pixel_API_31_AOSP.avd/config.ini
echo "hw.lcd.height = 2280" >> ~/.android/avd/Pixel_API_31_AOSP.avd/config.ini
echo "hw.lcd.width = 1080" >> ~/.android/avd/Pixel_API_31_AOSP.avd/config.ini
- run:
name: Run Detox Tests
command: |
TEST=$(circleci tests glob "e2e/tests/**/*.ts" | circleci tests split --split-by=timings)
yarn e2e:android-test $TEST
- store_artifacts:
path: artifacts
- run:
command: cp junit.xml ~/junit/
when: always
- store_test_results:
path: ~/junit
- store_artifacts:
path: ~/junit
# iOS builds # iOS builds
ios-build-experimental: ios-build-experimental:
executor: mac-env executor: mac-env
@ -461,11 +560,89 @@ jobs:
- upload-to-testflight: - upload-to-testflight:
official: true official: true
e2e-build-ios:
executor: mac-env
steps:
- checkout
- restore_cache: *restore-gems-cache
- restore_cache: *restore-npm-cache-mac
- run: *install-npm-modules
- run: *update-fastlane-ios
- save_cache: *save-npm-cache-mac
- save_cache: *save-gems-cache
- manage-pods
- run:
name: Configure Detox
command: |
brew tap wix/brew
brew install applesimutils
- run:
name: Build
command: |
/usr/libexec/PlistBuddy -c "Set :bugsnag:apiKey $BUGSNAG_KEY" ./ios/RocketChatRN/Info.plist
/usr/libexec/PlistBuddy -c "Set :bugsnag:apiKey $BUGSNAG_KEY" ./ios/ShareRocketChatRN/Info.plist
yarn detox clean-framework-cache && yarn detox build-framework-cache
echo "RUNNING_E2E_TESTS=true" > ./.env
yarn e2e:ios-build
- persist_to_workspace:
root: /Users/distiller/project
paths:
- ios/build/Build/Products/Release-iphonesimulator/Rocket.Chat Experimental.app
e2e-test-ios:
executor: mac-env
parallelism: 5
steps:
- checkout
- attach_workspace:
at: /Users/distiller/project
- restore_cache: *restore-npm-cache-mac
- run: *install-npm-modules
- save_cache: *save-npm-cache-mac
- run: mkdir ~/junit
- run:
name: Configure Detox
command: |
brew tap wix/brew
brew install applesimutils
yarn detox clean-framework-cache && yarn detox build-framework-cache
- create-e2e-account-file
- run:
name: Run tests
command: |
TEST=$(circleci tests glob "e2e/tests/**/*.ts" | circleci tests split --split-by=timings)
yarn e2e:ios-test $TEST
- store_artifacts:
path: artifacts
- run:
command: cp junit.xml ~/junit/
when: always
- store_test_results:
path: ~/junit
- store_artifacts:
path: ~/junit
workflows: workflows:
build-and-test: build-and-test:
jobs: jobs:
- lint-testunit - lint-testunit
# E2E tests
- e2e-hold:
type: approval
- e2e-build-ios:
requires:
- e2e-hold
- e2e-test-ios:
requires:
- e2e-build-ios
- e2e-build-android:
requires:
- e2e-hold
- e2e-test-android:
requires:
- e2e-build-android
# iOS Experimental # iOS Experimental
- ios-hold-build-experimental: - ios-hold-build-experimental:
type: approval type: approval

91
.detoxrc.js Normal file
View File

@ -0,0 +1,91 @@
/** @type {Detox.DetoxConfig} */
module.exports = {
testRunner: {
args: {
$0: 'jest',
config: 'e2e/jest.config.js'
},
retries: process.env.CI ? 3 : 0
},
artifacts: {
plugins: {
screenshot: 'failing',
video: 'failing',
uiHierarchy: 'enabled'
}
},
apps: {
'ios.debug': {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/Rocket.Chat Experimental.app',
build:
'xcodebuild -workspace ios/RocketChatRN.xcworkspace -scheme RocketChatRN -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build'
},
'ios.release': {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/Rocket.Chat Experimental.app',
build:
'xcodebuild -workspace ios/RocketChatRN.xcworkspace -scheme RocketChatRN -configuration Release -sdk iphonesimulator -derivedDataPath ios/build'
},
'android.debug': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/experimentalPlay/debug/app-experimental-play-debug.apk',
build:
'cd android ; ./gradlew assembleExperimentalPlayDebug assembleExperimentalPlayDebugAndroidTest -DtestBuildType=debug ; cd -',
reversePorts: [8081]
},
'android.release': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/experimentalPlay/release/app-experimental-play-release.apk',
build:
'cd android ; ./gradlew assembleExperimentalPlayRelease assembleExperimentalPlayReleaseAndroidTest -DtestBuildType=release ; cd -'
}
},
devices: {
simulator: {
type: 'ios.simulator',
device: {
type: 'iPhone 14'
}
},
attached: {
type: 'android.attached',
device: {
adbName: '.*'
}
},
emulator: {
type: 'android.emulator',
device: {
avdName: 'Pixel_API_31_AOSP'
},
headless: process.env.CI ? true : false
}
},
configurations: {
'ios.sim.debug': {
device: 'simulator',
app: 'ios.debug'
},
'ios.sim.release': {
device: 'simulator',
app: 'ios.release'
},
'android.att.debug': {
device: 'attached',
app: 'android.debug'
},
'android.att.release': {
device: 'attached',
app: 'android.release'
},
'android.emu.debug': {
device: 'emulator',
app: 'android.debug'
},
'android.emu.release': {
device: 'emulator',
app: 'android.release'
}
}
};

2
.env Normal file
View File

@ -0,0 +1,2 @@
# DON'T COMMIT THIS FILE
RUNNING_E2E_TESTS=

View File

@ -240,19 +240,8 @@ module.exports = {
}, },
{ {
files: ['e2e/**'], files: ['e2e/**'],
globals: {
by: true,
detox: true,
device: true,
element: true,
waitFor: true
},
rules: { rules: {
'import/no-extraneous-dependencies': 0, 'no-await-in-loop': 0
'no-await-in-loop': 0,
'no-restricted-syntax': 0,
// TODO: remove this rule when update Detox to 20 and test if the namespace Detox is available
'no-undef': 1
} }
} }
] ]

1
.gitignore vendored
View File

@ -67,5 +67,6 @@ e2e/docker/rc_test_env/docker-compose.yml
e2e/docker/data/db e2e/docker/data/db
e2e/e2e_account.js e2e/e2e_account.js
e2e/e2e_account.ts e2e/e2e_account.ts
junit.xml
*.p8 *.p8

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -147,7 +147,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode VERSIONCODE as Integer versionCode VERSIONCODE as Integer
versionName "4.35.1" versionName "4.37.0"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
if (!isFoss) { if (!isFoss) {
manifestPlaceholders = [BugsnagAPIKey: BugsnagAPIKey as String] manifestPlaceholders = [BugsnagAPIKey: BugsnagAPIKey as String]
@ -250,6 +250,7 @@ android {
release { release {
minifyEnabled enableProguardInReleaseBuilds minifyEnabled enableProguardInReleaseBuilds
setProguardFiles([getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro']) setProguardFiles([getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'])
proguardFile "${rootProject.projectDir}/../node_modules/detox/android/detox/proguard-rules-app.pro"
signingConfig signingConfigs.release signingConfig signingConfigs.release
if (!isFoss) { if (!isFoss) {
firebaseCrashlytics { firebaseCrashlytics {
@ -268,6 +269,11 @@ android {
// pickFirst '**/x86_64/libc++_shared.so' // pickFirst '**/x86_64/libc++_shared.so'
// } // }
// FIXME: Remove when we update RN
packagingOptions {
pickFirst '**/*.so'
}
// applicationVariants are e.g. debug, release // applicationVariants are e.g. debug, release
flavorDimensions "app", "type" flavorDimensions "app", "type"
@ -280,10 +286,6 @@ android {
dimension = "app" dimension = "app"
buildConfigField "boolean", "IS_OFFICIAL", "false" buildConfigField "boolean", "IS_OFFICIAL", "false"
} }
e2e {
dimension = "app"
buildConfigField "boolean", "IS_OFFICIAL", "false"
}
foss { foss {
dimension = "type" dimension = "type"
buildConfigField "boolean", "FDROID_BUILD", "true" buildConfigField "boolean", "FDROID_BUILD", "true"
@ -311,16 +313,6 @@ android {
java.srcDirs = ['src/main/java', 'src/play/java'] java.srcDirs = ['src/main/java', 'src/play/java']
manifest.srcFile 'src/play/AndroidManifest.xml' manifest.srcFile 'src/play/AndroidManifest.xml'
} }
e2ePlayDebug {
java.srcDirs = ['src/main/java', 'src/play/java']
res.srcDirs = ['src/experimental/res']
manifest.srcFile 'src/play/AndroidManifest.xml'
}
e2ePlayRelease {
java.srcDirs = ['src/main/java', 'src/play/java']
res.srcDirs = ['src/experimental/res']
manifest.srcFile 'src/play/AndroidManifest.xml'
}
} }
applicationVariants.all { variant -> applicationVariants.all { variant ->
@ -385,8 +377,9 @@ dependencies {
implementation "com.github.bumptech.glide:glide:4.9.0" implementation "com.github.bumptech.glide:glide:4.9.0"
annotationProcessor "com.github.bumptech.glide:compiler:4.9.0" annotationProcessor "com.github.bumptech.glide:compiler:4.9.0"
implementation "com.tencent:mmkv-static:1.2.10" implementation "com.tencent:mmkv-static:1.2.10"
androidTestImplementation('com.wix:detox:+') { transitive = true } androidTestImplementation('com.wix:detox:+')
androidTestImplementation 'junit:junit:4.12' implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'com.facebook.soloader:soloader:0.10.4'
} }
if (isNewArchitectureEnabled()) { if (isNewArchitectureEnabled()) {

View File

@ -18,7 +18,7 @@ public class DetoxTest {
@Rule @Rule
// Replace 'MainActivity' with the value of android:name entry in // Replace 'MainActivity' with the value of android:name entry in
// <activity> in AndroidManifest.xml // <activity> in AndroidManifest.xml
public ActivityTestRule<MainActivity> mActivityRule = new ActivityTestRule<>(MainActivity.class, false, false); public ActivityTestRule<chat.rocket.reactnative.MainActivity> mActivityRule = new ActivityTestRule<>(chat.rocket.reactnative.MainActivity.class, false, false);
@Test @Test
public void runDetoxTests() { public void runDetoxTests() {

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:tools="http://schemas.android.com/tools">
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user"
tools:ignore="AcceptsUserCertificates" />
</trust-anchors>
</base-config>
</network-security-config>

View File

@ -12,14 +12,8 @@
<uses-permission android:name="android.permission.AUDIO_CAPTURE" /> <uses-permission android:name="android.permission.AUDIO_CAPTURE" />
<!-- permissions related to jitsi call --> <!-- permissions related to jitsi call -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" android:usesPermissionFlags="neverForLocation" tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="28"/>
<uses-permission-sdk-23 android:name="android.permission.ACCESS_FINE_LOCATION" tools:targetApi="Q"/>
<application <application
android:name="chat.rocket.reactnative.MainApplication" android:name="chat.rocket.reactnative.MainApplication"
android:allowBackup="false" android:allowBackup="false"

View File

@ -7,4 +7,8 @@
tools:ignore="AcceptsUserCertificates" /> tools:ignore="AcceptsUserCertificates" />
</trust-anchors> </trust-anchors>
</base-config> </base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">localhost</domain>
</domain-config>
</network-security-config> </network-security-config>

View File

@ -1,9 +1,5 @@
import org.apache.tools.ant.taskdefs.condition.Os import org.apache.tools.ant.taskdefs.condition.Os
def safeExtGet(prop, fallback) {
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
def taskRequests = getGradle().getStartParameter().getTaskRequests().toString().toLowerCase() def taskRequests = getGradle().getStartParameter().getTaskRequests().toString().toLowerCase()
@ -75,5 +71,38 @@ allprojects {
google() google()
maven { url 'https://maven.google.com' } maven { url 'https://maven.google.com' }
maven { url 'https://www.jitpack.io' } maven { url 'https://www.jitpack.io' }
// https://stackoverflow.com/a/74333788/5447468
// TODO: remove once we update RN
exclusiveContent {
// We get React Native's Android binaries exclusively through npm,
// from a local Maven repo inside node_modules/react-native/.
// (The use of exclusiveContent prevents looking elsewhere like Maven Central
// and potentially getting a wrong version.)
filter {
includeGroup "com.facebook.react"
}
forRepository {
maven {
// NOTE: if you are in a monorepo, you may have "$rootDir/../../../node_modules/react-native/android"
url "$rootDir/../node_modules/react-native/android"
}
}
}
}
}
subprojects { subproject ->
afterEvaluate {
if (!project.name.equalsIgnoreCase("app") && project.hasProperty("android")) {
android {
compileSdkVersion 31
buildToolsVersion "31.0.0"
defaultConfig {
minSdkVersion 23
targetSdkVersion 31
}
}
}
} }
} }

View File

@ -40,7 +40,14 @@ export const INQUIRY = createRequestTypes('INQUIRY', [
'QUEUE_UPDATE', 'QUEUE_UPDATE',
'QUEUE_REMOVE' 'QUEUE_REMOVE'
]); ]);
export const APP = createRequestTypes('APP', ['START', 'READY', 'INIT', 'INIT_LOCAL_SETTINGS', 'SET_MASTER_DETAIL']); export const APP = createRequestTypes('APP', [
'START',
'READY',
'INIT',
'INIT_LOCAL_SETTINGS',
'SET_MASTER_DETAIL',
'SET_NOTIFICATION_PRESENCE_CAP'
]);
export const MESSAGES = createRequestTypes('MESSAGES', ['REPLY_BROADCAST']); export const MESSAGES = createRequestTypes('MESSAGES', ['REPLY_BROADCAST']);
export const CREATE_CHANNEL = createRequestTypes('CREATE_CHANNEL', [...defaultTypes]); export const CREATE_CHANNEL = createRequestTypes('CREATE_CHANNEL', [...defaultTypes]);
export const CREATE_DISCUSSION = createRequestTypes('CREATE_DISCUSSION', [...defaultTypes]); export const CREATE_DISCUSSION = createRequestTypes('CREATE_DISCUSSION', [...defaultTypes]);

View File

@ -12,7 +12,11 @@ interface ISetMasterDetail extends Action {
isMasterDetail: boolean; isMasterDetail: boolean;
} }
export type TActionApp = IAppStart & ISetMasterDetail; interface ISetNotificationPresenceCap extends Action {
show: boolean;
}
export type TActionApp = IAppStart & ISetMasterDetail & ISetNotificationPresenceCap;
interface Params { interface Params {
root: RootEnum; root: RootEnum;
@ -51,3 +55,10 @@ export function setMasterDetail(isMasterDetail: boolean): ISetMasterDetail {
isMasterDetail isMasterDetail
}; };
} }
export function setNotificationPresenceCap(show: boolean): ISetNotificationPresenceCap {
return {
type: APP.SET_NOTIFICATION_PRESENCE_CAP,
show
};
}

View File

@ -1,4 +1,5 @@
export const mappedIcons = { export const mappedIcons = {
'status-disabled': 59837,
'lamp-bulb': 59836, 'lamp-bulb': 59836,
'phone-in': 59835, 'phone-in': 59835,
'basketball': 59776, 'basketball': 59776,

File diff suppressed because one or more lines are too long

View File

@ -1,34 +1,31 @@
import React from 'react'; import React from 'react';
import { useWindowDimensions } from 'react-native';
import { FlatList } from 'react-native-gesture-handler'; import { FlatList } from 'react-native-gesture-handler';
import { EMOJI_BUTTON_SIZE } from './styles';
import scrollPersistTaps from '../../lib/methods/helpers/scrollPersistTaps';
import { IEmoji } from '../../definitions/IEmoji'; import { IEmoji } from '../../definitions/IEmoji';
import scrollPersistTaps from '../../lib/methods/helpers/scrollPersistTaps';
import { PressableEmoji } from './PressableEmoji'; import { PressableEmoji } from './PressableEmoji';
import { EMOJI_BUTTON_SIZE } from './styles';
interface IEmojiCategoryProps { interface IEmojiCategoryProps {
emojis: IEmoji[]; emojis: IEmoji[];
onEmojiSelected: (emoji: IEmoji) => void; onEmojiSelected: (emoji: IEmoji) => void;
tabLabel?: string; // needed for react-native-scrollable-tab-view only tabLabel?: string; // needed for react-native-scrollable-tab-view only
parentWidth: number;
} }
const EmojiCategory = ({ onEmojiSelected, emojis }: IEmojiCategoryProps): React.ReactElement | null => { const EmojiCategory = ({ onEmojiSelected, emojis, parentWidth }: IEmojiCategoryProps): React.ReactElement | null => {
const { width } = useWindowDimensions(); if (!parentWidth) {
const numColumns = Math.trunc(width / EMOJI_BUTTON_SIZE);
const marginHorizontal = (width % EMOJI_BUTTON_SIZE) / 2;
const renderItem = ({ item }: { item: IEmoji }) => <PressableEmoji emoji={item} onPress={onEmojiSelected} />;
if (!width) {
return null; return null;
} }
const numColumns = Math.trunc(parentWidth / EMOJI_BUTTON_SIZE);
const marginHorizontal = (parentWidth % EMOJI_BUTTON_SIZE) / 2;
const renderItem = ({ item }: { item: IEmoji }) => <PressableEmoji emoji={item} onPress={onEmojiSelected} />;
return ( return (
<FlatList <FlatList
// needed to update the numColumns when the width changes key={`emoji-category-${parentWidth}`}
key={`emoji-category-${width}`}
keyExtractor={item => (typeof item === 'string' ? item : item.name)} keyExtractor={item => (typeof item === 'string' ? item : item.name)}
data={emojis} data={emojis}
renderItem={renderItem} renderItem={renderItem}

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import { View } from 'react-native'; import { View } from 'react-native';
import ScrollableTabView from 'react-native-scrollable-tab-view'; import ScrollableTabView from 'react-native-scrollable-tab-view';
@ -20,6 +20,8 @@ const EmojiPicker = ({
searchedEmojis = [] searchedEmojis = []
}: IEmojiPickerProps): React.ReactElement | null => { }: IEmojiPickerProps): React.ReactElement | null => {
const { colors } = useTheme(); const { colors } = useTheme();
const [parentWidth, setParentWidth] = useState(0);
const { frequentlyUsed, loaded } = useFrequentlyUsedEmoji(); const { frequentlyUsed, loaded } = useFrequentlyUsedEmoji();
const allCustomEmojis: ICustomEmojis = useAppSelector( const allCustomEmojis: ICustomEmojis = useAppSelector(
@ -50,7 +52,14 @@ const EmojiPicker = ({
if (!emojis.length) { if (!emojis.length) {
return null; return null;
} }
return <EmojiCategory emojis={emojis} onEmojiSelected={(emoji: IEmoji) => handleEmojiSelect(emoji)} tabLabel={label} />; return (
<EmojiCategory
parentWidth={parentWidth}
emojis={emojis}
onEmojiSelected={(emoji: IEmoji) => handleEmojiSelect(emoji)}
tabLabel={label}
/>
);
}; };
if (!loaded) { if (!loaded) {
@ -58,9 +67,13 @@ const EmojiPicker = ({
} }
return ( return (
<View style={styles.emojiPickerContainer}> <View style={styles.emojiPickerContainer} onLayout={e => setParentWidth(e.nativeEvent.layout.width)}>
{searching ? ( {searching ? (
<EmojiCategory emojis={searchedEmojis} onEmojiSelected={(emoji: IEmoji) => handleEmojiSelect(emoji)} /> <EmojiCategory
emojis={searchedEmojis}
onEmojiSelected={(emoji: IEmoji) => handleEmojiSelect(emoji)}
parentWidth={parentWidth}
/>
) : ( ) : (
<ScrollableTabView <ScrollableTabView
renderTabBar={() => <TabBar />} renderTabBar={() => <TabBar />}

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { StyleSheet } from 'react-native'; import { StyleSheet, View } from 'react-native';
import { STATUS_COLORS } from '../../lib/constants';
import UnreadBadge from '../UnreadBadge'; import UnreadBadge from '../UnreadBadge';
const styles = StyleSheet.create({ const styles = StyleSheet.create({
@ -15,6 +16,8 @@ const styles = StyleSheet.create({
} }
}); });
export const Badge = ({ ...props }): React.ReactElement => <UnreadBadge {...props} style={styles.badgeContainer} small />; export const BadgeUnread = ({ ...props }): React.ReactElement => <UnreadBadge {...props} style={styles.badgeContainer} small />;
export default Badge; export const BadgeWarn = (): React.ReactElement => (
<View style={[styles.badgeContainer, { width: 10, height: 10, backgroundColor: STATUS_COLORS.disabled }]} />
);

View File

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { View } from 'react-native';
import { Header, HeaderBackground } from '@react-navigation/elements'; import { Header, HeaderBackground } from '@react-navigation/elements';
import { NavigationContainer } from '@react-navigation/native'; import { NavigationContainer } from '@react-navigation/native';
import { SafeAreaProvider } from 'react-native-safe-area-context'; import { SafeAreaProvider } from 'react-native-safe-area-context';
@ -103,9 +104,10 @@ export const Badge = () => (
<HeaderExample <HeaderExample
left={() => ( left={() => (
<HeaderButton.Container left> <HeaderButton.Container left>
<HeaderButton.Item iconName='threads' badge={() => <HeaderButton.Badge tunread={[1]} />} /> <HeaderButton.Item iconName='threads' badge={() => <HeaderButton.BadgeUnread tunread={[1]} />} />
<HeaderButton.Item iconName='threads' badge={() => <HeaderButton.Badge tunread={[1]} tunreadUser={[1]} />} /> <HeaderButton.Item iconName='threads' badge={() => <HeaderButton.BadgeUnread tunread={[1]} tunreadUser={[1]} />} />
<HeaderButton.Item iconName='threads' badge={() => <HeaderButton.Badge tunread={[1]} tunreadGroup={[1]} />} /> <HeaderButton.Item iconName='threads' badge={() => <HeaderButton.BadgeUnread tunread={[1]} tunreadGroup={[1]} />} />
<HeaderButton.Drawer badge={() => <HeaderButton.BadgeWarn />} />
</HeaderButton.Container> </HeaderButton.Container>
)} )}
/> />
@ -114,20 +116,23 @@ export const Badge = () => (
const ThemeStory = ({ theme }: { theme: TSupportedThemes }) => ( const ThemeStory = ({ theme }: { theme: TSupportedThemes }) => (
<ThemeContext.Provider value={{ theme, colors: colors[theme] }}> <ThemeContext.Provider value={{ theme, colors: colors[theme] }}>
<HeaderExample <View style={{ flexDirection: 'column' }}>
left={() => ( <HeaderExample
<HeaderButton.Container left> left={() => (
<HeaderButton.Item iconName='threads' /> <HeaderButton.Container left>
</HeaderButton.Container> <HeaderButton.Drawer badge={() => <HeaderButton.BadgeWarn />} />
)} <HeaderButton.Item iconName='threads' />
right={() => ( </HeaderButton.Container>
<HeaderButton.Container> )}
<HeaderButton.Item title='Threads' /> right={() => (
<HeaderButton.Item iconName='threads' badge={() => <HeaderButton.Badge tunread={[1]} />} /> <HeaderButton.Container>
</HeaderButton.Container> <HeaderButton.Item title='Threads' />
)} <HeaderButton.Item iconName='threads' badge={() => <HeaderButton.BadgeUnread tunread={[1]} />} />
colors={colors[theme]} </HeaderButton.Container>
/> )}
colors={colors[theme]}
/>
</View>
</ThemeContext.Provider> </ThemeContext.Provider>
); );

View File

@ -1,4 +1,4 @@
export { default as Container } from './HeaderButtonContainer'; export { default as Container } from './HeaderButtonContainer';
export { default as Item } from './HeaderButtonItem'; export { default as Item } from './HeaderButtonItem';
export { default as Badge } from './HeaderButtonItemBadge'; export * from './HeaderButtonItemBadge';
export * from './Common'; export * from './Common';

View File

@ -1062,7 +1062,7 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
}; };
formatReplyMessage = async (replyingMessage: IMessage, message = '') => { formatReplyMessage = async (replyingMessage: IMessage, message = '') => {
const { user, roomType, replyWithMention } = this.props; const { user, roomType, replyWithMention, serverVersion } = this.props;
const permalink = await this.getPermalink(replyingMessage); const permalink = await this.getPermalink(replyingMessage);
let msg = `[ ](${permalink}) `; let msg = `[ ](${permalink}) `;
@ -1071,7 +1071,8 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
msg += `@${replyingMessage?.u?.username} `; msg += `@${replyingMessage?.u?.username} `;
} }
return `${msg} ${message}`; const connectionString = compareServerVersion(serverVersion, 'lowerThan', '5.0.0') ? ' ' : '\n';
return `${msg}${connectionString}${message}`;
}; };
updateMentions = (keyword: any, type: string) => { updateMentions = (keyword: any, type: string) => {

View File

@ -46,6 +46,7 @@ const RoomHeaderContainer = React.memo(
const connecting = useSelector((state: IApplicationState) => state.meteor.connecting || state.server.loading); const connecting = useSelector((state: IApplicationState) => state.meteor.connecting || state.server.loading);
const usersTyping = useSelector((state: IApplicationState) => state.usersTyping, shallowEqual); const usersTyping = useSelector((state: IApplicationState) => state.usersTyping, shallowEqual);
const connected = useSelector((state: IApplicationState) => state.meteor.connected); const connected = useSelector((state: IApplicationState) => state.meteor.connected);
const presenceDisabled = useSelector((state: IApplicationState) => state.settings.Presence_broadcast_disabled);
const activeUser = useSelector( const activeUser = useSelector(
(state: IApplicationState) => (roomUserId ? state.activeUsers?.[roomUserId] : undefined), (state: IApplicationState) => (roomUserId ? state.activeUsers?.[roomUserId] : undefined),
shallowEqual shallowEqual
@ -61,9 +62,13 @@ const RoomHeaderContainer = React.memo(
if (connected) { if (connected) {
if ((type === 'd' || (tmid && roomUserId)) && activeUser) { if ((type === 'd' || (tmid && roomUserId)) && activeUser) {
const { status: statusActiveUser, statusText: statusTextActiveUser } = activeUser; if (presenceDisabled) {
status = statusActiveUser; status = 'disabled';
statusText = statusTextActiveUser; } else {
const { status: statusActiveUser, statusText: statusTextActiveUser } = activeUser;
status = statusActiveUser;
statusText = statusTextActiveUser;
}
} else if (type === 'l' && visitor?.status) { } else if (type === 'l' && visitor?.status) {
const { status: statusVisitor } = visitor; const { status: statusVisitor } = visitor;
status = statusVisitor; status = statusVisitor;

View File

@ -65,6 +65,7 @@ export const UserStatus = () => (
<RoomItem status='busy' /> <RoomItem status='busy' />
<RoomItem status='offline' /> <RoomItem status='offline' />
<RoomItem status='loading' /> <RoomItem status='loading' />
<RoomItem status='disabled' />
<RoomItem status='wrong' /> <RoomItem status='wrong' />
</> </>
); );

View File

@ -8,11 +8,17 @@ export const useUserStatus = (
id?: string id?: string
): { connected: boolean; status: TUserStatus } => { ): { connected: boolean; status: TUserStatus } => {
const connected = useAppSelector(state => state.meteor.connected); const connected = useAppSelector(state => state.meteor.connected);
const presenceDisabled = useAppSelector(state => state.settings.Presence_broadcast_disabled);
const userStatus = useAppSelector(state => state.activeUsers[id || '']?.status); const userStatus = useAppSelector(state => state.activeUsers[id || '']?.status);
let status = 'loading'; let status = 'loading';
if (connected) { if (connected) {
if (type === 'd') { if (type === 'd') {
status = userStatus || 'loading'; if (presenceDisabled) {
status = 'disabled';
} else {
status = userStatus || 'loading';
}
} else if (type === 'l' && liveChatStatus) { } else if (type === 'l' && liveChatStatus) {
status = liveChatStatus; status = liveChatStatus;
} }

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import { StyleProp, ViewStyle } from 'react-native'; import { StyleProp, ViewStyle } from 'react-native';
import { SvgUri } from 'react-native-svg'; import { SvgUri } from 'react-native-svg';
@ -29,22 +29,12 @@ interface IOmnichannelRoomIconProps {
} }
export const OmnichannelRoomIcon = ({ size, style, sourceType, status }: IOmnichannelRoomIconProps) => { export const OmnichannelRoomIcon = ({ size, style, sourceType, status }: IOmnichannelRoomIconProps) => {
const [loading, setLoading] = useState(true);
const [svgError, setSvgError] = useState(false);
const baseUrl = useAppSelector(state => state.server?.server); const baseUrl = useAppSelector(state => state.server?.server);
const connected = useAppSelector(state => state.meteor?.connected); const connected = useAppSelector(state => state.meteor?.connected);
if (sourceType?.type === OmnichannelSourceType.APP && sourceType.id && sourceType.sidebarIcon && connected) { const customIcon = (
return (
<SvgUri
height={size}
width={size}
color={STATUS_COLORS[status || 'offline']}
uri={`${baseUrl}/api/apps/public/${sourceType.id}/get-sidebar-icon?icon=${sourceType.sidebarIcon}`}
style={style}
/>
);
}
return (
<CustomIcon <CustomIcon
name={iconMap[sourceType?.type || 'other']} name={iconMap[sourceType?.type || 'other']}
size={size} size={size}
@ -52,4 +42,23 @@ export const OmnichannelRoomIcon = ({ size, style, sourceType, status }: IOmnich
color={STATUS_COLORS[status || 'offline']} color={STATUS_COLORS[status || 'offline']}
/> />
); );
if (!svgError && sourceType?.type === OmnichannelSourceType.APP && sourceType.id && sourceType.sidebarIcon && connected) {
return (
<>
<SvgUri
height={size}
width={size}
color={STATUS_COLORS[status || 'offline']}
uri={`${baseUrl}/api/apps/public/${sourceType.id}/get-sidebar-icon?icon=${sourceType.sidebarIcon}`}
style={style}
onError={() => setSvgError(true)}
onLoad={() => setLoading(false)}
/>
{loading ? customIcon : null}
</>
);
}
return customIcon;
}; };

View File

@ -6,9 +6,15 @@ import { IStatus } from './definition';
import { useAppSelector } from '../../lib/hooks'; import { useAppSelector } from '../../lib/hooks';
const StatusContainer = ({ id, style, size = 32, ...props }: Omit<IStatus, 'status'>): React.ReactElement => { const StatusContainer = ({ id, style, size = 32, ...props }: Omit<IStatus, 'status'>): React.ReactElement => {
const status = useAppSelector(state => const status = useAppSelector(state => {
state.meteor.connected ? state.activeUsers[id] && state.activeUsers[id].status : 'loading' if (state.settings.Presence_broadcast_disabled) {
) as TUserStatus; return 'disabled';
}
if (state.meteor.connected) {
return state.activeUsers[id] && state.activeUsers[id].status;
}
return 'loading';
}) as TUserStatus;
return <Status size={size} style={style} status={status} {...props} />; return <Status size={size} style={style} status={status} {...props} />;
}; };

View File

@ -6,7 +6,6 @@ import i18n from '../../../../i18n';
import { getSubscriptionByRoomId } from '../../../../lib/database/services/Subscription'; import { getSubscriptionByRoomId } from '../../../../lib/database/services/Subscription';
import { useAppSelector } from '../../../../lib/hooks'; import { useAppSelector } from '../../../../lib/hooks';
import { getRoomAvatar, getUidDirectMessage } from '../../../../lib/methods/helpers'; import { getRoomAvatar, getUidDirectMessage } from '../../../../lib/methods/helpers';
import { videoConfStartAndJoin } from '../../../../lib/methods/videoConf';
import { useTheme } from '../../../../theme'; import { useTheme } from '../../../../theme';
import { useActionSheet } from '../../../ActionSheet'; import { useActionSheet } from '../../../ActionSheet';
import AvatarContainer from '../../../Avatar'; import AvatarContainer from '../../../Avatar';
@ -16,12 +15,12 @@ import { BUTTON_HIT_SLOP } from '../../../message/utils';
import StatusContainer from '../../../Status'; import StatusContainer from '../../../Status';
import useStyle from './styles'; import useStyle from './styles';
export default function CallAgainActionSheet({ rid }: { rid: string }): React.ReactElement { export default function StartACallActionSheet({ rid, initCall }: { rid: string; initCall: Function }): React.ReactElement {
const style = useStyle(); const style = useStyle();
const { colors } = useTheme(); const { colors } = useTheme();
const [user, setUser] = useState({ username: '', avatar: '', uid: '', rid: '' }); const [user, setUser] = useState({ username: '', avatar: '', uid: '' });
const [phone, setPhone] = useState(true); const [mic, setMic] = useState(true);
const [camera, setCamera] = useState(false); const [cam, setCam] = useState(false);
const username = useAppSelector(state => state.login.user.username); const username = useAppSelector(state => state.login.user.username);
const { hideActionSheet } = useActionSheet(); const { hideActionSheet } = useActionSheet();
@ -31,7 +30,7 @@ export default function CallAgainActionSheet({ rid }: { rid: string }): React.Re
const room = await getSubscriptionByRoomId(rid); const room = await getSubscriptionByRoomId(rid);
const uid = (await getUidDirectMessage(room)) as string; const uid = (await getUidDirectMessage(room)) as string;
const avt = getRoomAvatar(room); const avt = getRoomAvatar(room);
setUser({ uid, username: room?.name || '', avatar: avt, rid: room?.id || '' }); setUser({ uid, username: room?.name || '', avatar: avt });
})(); })();
}, [rid]); }, [rid]);
@ -43,25 +42,27 @@ export default function CallAgainActionSheet({ rid }: { rid: string }): React.Re
<Text style={style.actionSheetHeaderTitle}>{i18n.t('Start_a_call')}</Text> <Text style={style.actionSheetHeaderTitle}>{i18n.t('Start_a_call')}</Text>
<View style={style.actionSheetHeaderButtons}> <View style={style.actionSheetHeaderButtons}>
<Touchable <Touchable
onPress={() => setCamera(!camera)} onPress={() => setCam(!cam)}
style={[style.iconCallContainer, camera && style.enabledBackground, { marginRight: 6 }]} style={[style.iconCallContainer, cam && style.enabledBackground, { marginRight: 6 }]}
hitSlop={BUTTON_HIT_SLOP} hitSlop={BUTTON_HIT_SLOP}
> >
<CustomIcon name={camera ? 'camera' : 'camera-disabled'} size={16} color={handleColor(camera)} /> <CustomIcon name={cam ? 'camera' : 'camera-disabled'} size={20} color={handleColor(cam)} />
</Touchable> </Touchable>
<Touchable <Touchable
onPress={() => setPhone(!phone)} onPress={() => setMic(!mic)}
style={[style.iconCallContainer, phone && style.enabledBackground]} style={[style.iconCallContainer, mic && style.enabledBackground]}
hitSlop={BUTTON_HIT_SLOP} hitSlop={BUTTON_HIT_SLOP}
> >
<CustomIcon name={phone ? 'microphone' : 'microphone-disabled'} size={16} color={handleColor(phone)} /> <CustomIcon name={mic ? 'microphone' : 'microphone-disabled'} size={20} color={handleColor(mic)} />
</Touchable> </Touchable>
</View> </View>
</View> </View>
<View style={style.actionSheetUsernameContainer}> <View style={style.actionSheetUsernameContainer}>
<AvatarContainer text={user.avatar} size={36} /> <AvatarContainer text={user.avatar} size={36} />
<StatusContainer size={16} id={user.uid} style={{ marginLeft: 8, marginRight: 6 }} /> <StatusContainer size={16} id={user.uid} style={{ marginLeft: 8, marginRight: 6 }} />
<Text style={style.actionSheetUsername}>{user.username}</Text> <Text style={style.actionSheetUsername} numberOfLines={1}>
{user.username}
</Text>
</View> </View>
<View style={style.actionSheetPhotoContainer}> <View style={style.actionSheetPhotoContainer}>
<AvatarContainer size={62} text={username} /> <AvatarContainer size={62} text={username} />
@ -70,7 +71,7 @@ export default function CallAgainActionSheet({ rid }: { rid: string }): React.Re
onPress={() => { onPress={() => {
hideActionSheet(); hideActionSheet();
setTimeout(() => { setTimeout(() => {
videoConfStartAndJoin(user.rid, camera); initCall({ cam, mic });
}, 100); }, 100);
}} }}
title={i18n.t('Call')} title={i18n.t('Call')}

View File

@ -3,17 +3,16 @@ import { Text } from 'react-native';
import Touchable from 'react-native-platform-touchable'; import Touchable from 'react-native-platform-touchable';
import i18n from '../../../../i18n'; import i18n from '../../../../i18n';
import { useVideoConf } from '../../../../lib/hooks/useVideoConf'; import { videoConfJoin } from '../../../../lib/methods/videoConf';
import useStyle from './styles'; import useStyle from './styles';
import { VideoConferenceBaseContainer } from './VideoConferenceBaseContainer'; import { VideoConferenceBaseContainer } from './VideoConferenceBaseContainer';
const VideoConferenceDirect = React.memo(({ blockId }: { blockId: string }) => { const VideoConferenceDirect = React.memo(({ blockId }: { blockId: string }) => {
const style = useStyle(); const style = useStyle();
const { joinCall } = useVideoConf();
return ( return (
<VideoConferenceBaseContainer variant='incoming'> <VideoConferenceBaseContainer variant='incoming'>
<Touchable style={style.callToActionButton} onPress={() => joinCall(blockId)}> <Touchable style={style.callToActionButton} onPress={() => videoConfJoin(blockId)}>
<Text style={style.callToActionButtonText}>{i18n.t('Join')}</Text> <Text style={style.callToActionButtonText}>{i18n.t('Join')}</Text>
</Touchable> </Touchable>
<Text style={style.callBack}>{i18n.t('Waiting_for_answer')}</Text> <Text style={style.callBack}>{i18n.t('Waiting_for_answer')}</Text>

View File

@ -6,9 +6,7 @@ import { IUser } from '../../../../definitions';
import { VideoConferenceType } from '../../../../definitions/IVideoConference'; import { VideoConferenceType } from '../../../../definitions/IVideoConference';
import i18n from '../../../../i18n'; import i18n from '../../../../i18n';
import { useAppSelector } from '../../../../lib/hooks'; import { useAppSelector } from '../../../../lib/hooks';
import { useSnaps } from '../../../../lib/hooks/useSnaps'; import { useVideoConf } from '../../../../lib/hooks/useVideoConf';
import { useActionSheet } from '../../../ActionSheet';
import CallAgainActionSheet from './CallAgainActionSheet';
import { CallParticipants, TCallUsers } from './CallParticipants'; import { CallParticipants, TCallUsers } from './CallParticipants';
import useStyle from './styles'; import useStyle from './styles';
import { VideoConferenceBaseContainer } from './VideoConferenceBaseContainer'; import { VideoConferenceBaseContainer } from './VideoConferenceBaseContainer';
@ -26,8 +24,7 @@ export default function VideoConferenceEnded({
}): React.ReactElement { }): React.ReactElement {
const style = useStyle(); const style = useStyle();
const username = useAppSelector(state => state.login.user.username); const username = useAppSelector(state => state.login.user.username);
const { showActionSheet } = useActionSheet(); const { showInitCallActionSheet } = useVideoConf(rid);
const snaps = useSnaps([1250]);
const onlyAuthorOnCall = users.length === 1 && users.some(user => user.username === createdBy.username); const onlyAuthorOnCall = users.length === 1 && users.some(user => user.username === createdBy.username);
@ -35,15 +32,7 @@ export default function VideoConferenceEnded({
<VideoConferenceBaseContainer variant='ended'> <VideoConferenceBaseContainer variant='ended'>
{type === 'direct' ? ( {type === 'direct' ? (
<> <>
<Touchable <Touchable style={style.callToActionCallBack} onPress={showInitCallActionSheet}>
style={style.callToActionCallBack}
onPress={() =>
showActionSheet({
children: <CallAgainActionSheet rid={rid} />,
snaps
})
}
>
<Text style={style.callToActionCallBackText}> <Text style={style.callToActionCallBackText}>
{createdBy.username === username ? i18n.t('Call_back') : i18n.t('Call_again')} {createdBy.username === username ? i18n.t('Call_back') : i18n.t('Call_again')}
</Text> </Text>

View File

@ -3,18 +3,17 @@ import { Text } from 'react-native';
import Touchable from 'react-native-platform-touchable'; import Touchable from 'react-native-platform-touchable';
import i18n from '../../../../i18n'; import i18n from '../../../../i18n';
import { useVideoConf } from '../../../../lib/hooks/useVideoConf'; import { videoConfJoin } from '../../../../lib/methods/videoConf';
import { CallParticipants, TCallUsers } from './CallParticipants'; import { CallParticipants, TCallUsers } from './CallParticipants';
import useStyle from './styles'; import useStyle from './styles';
import { VideoConferenceBaseContainer } from './VideoConferenceBaseContainer'; import { VideoConferenceBaseContainer } from './VideoConferenceBaseContainer';
export default function VideoConferenceOutgoing({ users, blockId }: { users: TCallUsers; blockId: string }): React.ReactElement { export default function VideoConferenceOutgoing({ users, blockId }: { users: TCallUsers; blockId: string }): React.ReactElement {
const style = useStyle(); const style = useStyle();
const { joinCall } = useVideoConf();
return ( return (
<VideoConferenceBaseContainer variant='outgoing'> <VideoConferenceBaseContainer variant='outgoing'>
<Touchable style={style.callToActionButton} onPress={() => joinCall(blockId)}> <Touchable style={style.callToActionButton} onPress={() => videoConfJoin(blockId)}>
<Text style={style.callToActionButtonText}>{i18n.t('Join')}</Text> <Text style={style.callToActionButtonText}>{i18n.t('Join')}</Text>
</Touchable> </Touchable>
<CallParticipants users={users} /> <CallParticipants users={users} />

View File

@ -100,7 +100,8 @@ export default function useStyle() {
actionSheetUsername: { actionSheetUsername: {
fontSize: 16, fontSize: 16,
...sharedStyles.textBold, ...sharedStyles.textBold,
color: colors.passcodePrimary color: colors.passcodePrimary,
flexShrink: 1
}, },
enabledBackground: { enabledBackground: {
backgroundColor: colors.conferenceCallEnabledIconBackground backgroundColor: colors.conferenceCallEnabledIconBackground

View File

@ -2,7 +2,7 @@
import { BlockContext } from '@rocket.chat/ui-kit'; import { BlockContext } from '@rocket.chat/ui-kit';
import React, { useContext, useState } from 'react'; import React, { useContext, useState } from 'react';
import { useVideoConf } from '../../lib/hooks/useVideoConf'; import { videoConfJoin } from '../../lib/methods/videoConf';
import { IText } from './interfaces'; import { IText } from './interfaces';
export const textParser = ([{ text }]: IText[]) => text; export const textParser = ([{ text }]: IText[]) => text;
@ -40,7 +40,6 @@ export const useBlockContext = ({ blockId, actionId, appId, initialValue }: IUse
const { action, appId: appIdFromContext, viewId, state, language, errors, values = {} } = useContext(KitContext); const { action, appId: appIdFromContext, viewId, state, language, errors, values = {} } = useContext(KitContext);
const { value = initialValue } = values[actionId] || {}; const { value = initialValue } = values[actionId] || {};
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { joinCall } = useVideoConf();
const error = errors && actionId && errors[actionId]; const error = errors && actionId && errors[actionId];
@ -58,7 +57,7 @@ export const useBlockContext = ({ blockId, actionId, appId, initialValue }: IUse
try { try {
if (appId === 'videoconf-core' && blockId) { if (appId === 'videoconf-core' && blockId) {
setLoading(false); setLoading(false);
return joinCall(blockId); return videoConfJoin(blockId);
} }
await action({ await action({
blockId, blockId,

View File

@ -16,11 +16,13 @@ const styles = StyleSheet.create({
}, },
bottomContainerText: { bottomContainerText: {
...sharedStyles.textRegular, ...sharedStyles.textRegular,
fontSize: 13 fontSize: 13,
textAlign: 'center'
}, },
bottomContainerTextBold: { bottomContainerTextBold: {
...sharedStyles.textSemibold, ...sharedStyles.textSemibold,
fontSize: 13 fontSize: 13,
textAlign: 'center'
} }
}); });

View File

@ -18,13 +18,29 @@ import MarkdownContext from './MarkdownContext';
interface IParagraphProps { interface IParagraphProps {
value: ParagraphProps['value']; value: ParagraphProps['value'];
forceTrim?: boolean;
} }
const Inline = ({ value }: IParagraphProps): React.ReactElement | null => { const Inline = ({ value, forceTrim }: IParagraphProps): React.ReactElement | null => {
const { useRealName, username, navToRoomInfo, mentions, channels } = useContext(MarkdownContext); const { useRealName, username, navToRoomInfo, mentions, channels } = useContext(MarkdownContext);
return ( return (
<Text style={styles.inline}> <Text style={styles.inline}>
{value.map(block => { {value.map((block, index) => {
// We are forcing trim when is a `[ ](https://https://open.rocket.chat/) plain_text`
// to clean the empty spaces
if (forceTrim) {
if (index === 0 && block.type === 'LINK') {
block.value.label.value =
// Need to update the @rocket.chat/message-parser to understand that the label can be a Markup | Markup[]
// https://github.com/RocketChat/fuselage/blob/461ecf661d9ff4a46390957c915e4352fa942a7c/packages/message-parser/src/definitions.ts#L141
// @ts-ignore
block.value?.label?.value?.toString().trimLeft() || block?.value?.label?.[0]?.value?.toString().trimLeft();
}
if (index === 1 && block.type !== 'LINK') {
block.value = block.value?.toString().trimLeft();
}
}
switch (block.type) { switch (block.type) {
case 'IMAGE': case 'IMAGE':
return <Image value={block.value} />; return <Image value={block.value} />;

View File

@ -381,27 +381,6 @@ export const BlockQuote = () => (
</View> </View>
); );
const rocketChatLink = [
{
type: 'PARAGRAPH',
value: [
{
type: 'LINK',
value: {
src: {
type: 'PLAIN_TEXT',
value: 'https://rocket.chat'
},
label: {
type: 'PLAIN_TEXT',
value: 'https://rocket.chat'
}
}
}
]
}
];
const markdownLink = [ const markdownLink = [
{ {
type: 'PARAGRAPH', type: 'PARAGRAPH',
@ -487,7 +466,6 @@ const markdownLinkWithEmphasis = [
export const Links = () => ( export const Links = () => (
<View style={styles.container}> <View style={styles.container}>
<NewMarkdown tokens={rocketChatLink} />
<NewMarkdown tokens={markdownLink} /> <NewMarkdown tokens={markdownLink} />
<NewMarkdown tokens={markdownLinkWithEmphasis} /> <NewMarkdown tokens={markdownLinkWithEmphasis} />
</View> </View>
@ -806,3 +784,128 @@ export const InlineKatex = () => (
<NewMarkdown tokens={inlineKatex} /> <NewMarkdown tokens={inlineKatex} />
</View> </View>
); );
const messageQuote = {
/**
# Hello head 1
[ ](https://google.com)
*/
headAndLink: [
{ type: 'HEADING', level: 1, value: [{ type: 'PLAIN_TEXT', value: 'Hello head 1' }] },
{ type: 'LINE_BREAK' },
{
type: 'PARAGRAPH',
value: [
{
type: 'LINK',
value: { src: { type: 'PLAIN_TEXT', value: 'https://google.com' }, label: { type: 'PLAIN_TEXT', value: ' ' } }
}
]
}
],
/**
# Head 1 as the first line then line break and after paragraph
bla bla bla bla bla bla
bla bla bla bla bla bla
[ ](https://google.com)
*/
headTextAndLink: [
{
type: 'HEADING',
level: 1,
value: [{ type: 'PLAIN_TEXT', value: 'Head 1 as the first line then line break and after paragraph' }]
},
{ type: 'LINE_BREAK' },
{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: 'bla bla bla bla bla bla ' }] },
{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: 'bla bla bla bla bla bla ' }] },
{
type: 'PARAGRAPH',
value: [
{
type: 'LINK',
value: { src: { type: 'PLAIN_TEXT', value: 'https://google.com' }, label: { type: 'PLAIN_TEXT', value: ' ' } }
}
]
}
],
/**
[ ](permalink from message)\n# Head 1 after a forced line break
asdas asd asd asd
*/
headTextAndQuote: [
{
type: 'PARAGRAPH',
value: [
{
type: 'LINK',
value: {
src: { type: 'PLAIN_TEXT', value: 'https://open.rocket.chat/direct/subaru123?msg=QB42gWcaO6BgqtLTo' },
label: { type: 'PLAIN_TEXT', value: ' ' }
}
},
{ type: 'PLAIN_TEXT', value: ' ' }
]
},
{ type: 'HEADING', level: 1, value: [{ type: 'PLAIN_TEXT', value: 'Head 1 after a forced line break' }] },
{ type: 'LINE_BREAK' },
{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: 'Description' }] }
],
/**
[ ](https://google.com) *There is a link before this bold separated by single space*
*/
linkAndBoldText: [
{
type: 'PARAGRAPH',
value: [
{
type: 'LINK',
value: { src: { type: 'PLAIN_TEXT', value: 'https://google.com' }, label: { type: 'PLAIN_TEXT', value: ' ' } }
},
{ type: 'PLAIN_TEXT', value: ' ' },
{ type: 'BOLD', value: [{ type: 'PLAIN_TEXT', value: 'There is a link before this bold separated by single space' }] }
]
}
],
simpleQuote: [
{
type: 'PARAGRAPH',
value: [
{
type: 'LINK',
value: {
src: {
type: 'PLAIN_TEXT',
value: 'https://open.rocket.chat/group/quoteeee9798789?msg=ZZp6t2dCRX4TqExht'
},
// format of label for servers greater or equal than 6.0
label: [
{
type: 'PLAIN_TEXT',
value: ' '
}
]
}
}
]
},
{
type: 'PARAGRAPH',
value: [
{
type: 'PLAIN_TEXT',
value: 'Quoting a message wrote before'
}
]
}
]
};
export const MessageQuote = () => (
<View style={styles.container}>
<NewMarkdown tokens={messageQuote.headAndLink} />
<NewMarkdown tokens={messageQuote.headTextAndLink} />
<NewMarkdown tokens={messageQuote.headTextAndQuote} />
<NewMarkdown tokens={messageQuote.linkAndBoldText} />
<NewMarkdown tokens={messageQuote.simpleQuote} />
</View>
);

View File

@ -12,10 +12,28 @@ interface IParagraphProps {
} }
const Paragraph = ({ value }: IParagraphProps) => { const Paragraph = ({ value }: IParagraphProps) => {
let forceTrim = false;
const { theme } = useTheme(); const { theme } = useTheme();
if (
value?.[0]?.type === 'LINK' &&
// Need to update the @rocket.chat/message-parser to understand that the label can be a Markup | Markup[]
// https://github.com/RocketChat/fuselage/blob/461ecf661d9ff4a46390957c915e4352fa942a7c/packages/message-parser/src/definitions.ts#L141
// @ts-ignore
(value?.[0]?.value?.label?.value?.toString().trim() === '' || value?.[0]?.value?.label?.[0]?.value?.toString().trim() === '')
) {
// We are returning null when we receive a message like this: `[ ](https://open.rocket.chat/)\nplain_text`
// to avoid render a line empty above the the message
if (value.length === 1) {
return null;
}
if (value.length === 2 && value?.[1]?.type === 'PLAIN_TEXT' && value?.[1]?.value?.toString().trim() === '') {
return null;
}
forceTrim = true;
}
return ( return (
<Text style={[styles.text, { color: themes[theme].bodyText }]}> <Text style={[styles.text, { color: themes[theme].bodyText }]}>
<Inline value={value} /> <Inline value={value} forceTrim={forceTrim} />
</Text> </Text>
); );
}; };

View File

@ -8,7 +8,7 @@ interface IPlainProps {
value: PlainProps['value']; value: PlainProps['value'];
} }
const Plain = ({ value }: IPlainProps) => ( const Plain = ({ value }: IPlainProps): React.ReactElement => (
<Text accessibilityLabel={value} style={styles.plainText}> <Text accessibilityLabel={value} style={styles.plainText}>
{value} {value}
</Text> </Text>

View File

@ -4,7 +4,6 @@ import { Tasks as TasksProps } from '@rocket.chat/message-parser';
import Inline from './Inline'; import Inline from './Inline';
import styles from '../styles'; import styles from '../styles';
import { themes } from '../../../lib/constants';
import { useTheme } from '../../../theme'; import { useTheme } from '../../../theme';
interface ITasksProps { interface ITasksProps {
@ -12,13 +11,15 @@ interface ITasksProps {
} }
const TaskList = ({ value = [] }: ITasksProps) => { const TaskList = ({ value = [] }: ITasksProps) => {
const { theme } = useTheme(); const { colors } = useTheme();
return ( return (
<View> <View>
{value.map(item => ( {value.map(item => (
<View style={styles.row}> <View style={styles.row}>
<Text style={[styles.text, { color: themes[theme].bodyText }]}>{item.status ? '- [x] ' : '- [ ] '}</Text> <Text style={[styles.text, { color: colors.bodyText }]}>{item.status ? '- [x] ' : '- [ ] '}</Text>
<Inline value={item.value} /> <Text style={[styles.inline, { color: colors.bodyText }]}>
<Inline value={item.value} />
</Text>
</View> </View>
))} ))}
</View> </View>

View File

@ -10,12 +10,12 @@ import { themes } from '../../lib/constants';
import { IMessageCallButton } from './interfaces'; import { IMessageCallButton } from './interfaces';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
const CallButton = React.memo(({ callJitsi }: IMessageCallButton) => { const CallButton = React.memo(({ handleEnterCall }: IMessageCallButton) => {
const { theme } = useTheme(); const { theme } = useTheme();
return ( return (
<View style={styles.buttonContainer}> <View style={styles.buttonContainer}>
<Touchable <Touchable
onPress={callJitsi} onPress={handleEnterCall}
background={Touchable.Ripple(themes[theme].bannerBackground)} background={Touchable.Ripple(themes[theme].bannerBackground)}
style={[styles.button, { backgroundColor: themes[theme].tintColor }]} style={[styles.button, { backgroundColor: themes[theme].tintColor }]}
hitSlop={BUTTON_HIT_SLOP} hitSlop={BUTTON_HIT_SLOP}

View File

@ -1,14 +1,20 @@
import React from 'react'; import React from 'react';
import { themes } from '../../../../lib/constants';
import { CustomIcon } from '../../../CustomIcon'; import { CustomIcon } from '../../../CustomIcon';
import styles from '../../styles'; import styles from '../../styles';
import { useTheme } from '../../../../theme'; import { useTheme } from '../../../../theme';
const ReadReceipt = React.memo(({ isReadReceiptEnabled, unread }: { isReadReceiptEnabled?: boolean; unread?: boolean }) => { const ReadReceipt = React.memo(({ isReadReceiptEnabled, unread }: { isReadReceiptEnabled?: boolean; unread?: boolean }) => {
const { theme } = useTheme(); const { colors } = useTheme();
if (isReadReceiptEnabled && !unread && unread !== null) { if (isReadReceiptEnabled) {
return <CustomIcon name='check' color={themes[theme].tintColor} size={16} style={styles.rightIcons} />; return (
<CustomIcon
name='check'
color={!unread && unread !== null ? colors.tintColor : colors.auxiliaryTintColor}
size={16}
style={styles.rightIcons}
/>
);
} }
return null; return null;
}); });

View File

@ -53,7 +53,7 @@ const Content = React.memo(
content = ( content = (
<Markdown <Markdown
msg={props.msg} msg={props.msg}
md={props.md} md={props.type !== 'e2e' ? props.md : undefined}
getCustomEmoji={props.getCustomEmoji} getCustomEmoji={props.getCustomEmoji}
enableMessageParser={user.enableMessageParserEarlyAdoption} enableMessageParser={user.enableMessageParserEarlyAdoption}
username={user.username} username={user.username}

View File

@ -18,7 +18,7 @@ const MessageAvatar = React.memo(({ isHeader, avatar, author, small, navToRoomIn
style={small ? styles.avatarSmall : styles.avatar} style={small ? styles.avatarSmall : styles.avatar}
text={avatar ? '' : author.username} text={avatar ? '' : author.username}
size={small ? 20 : 36} size={small ? 20 : 36}
borderRadius={small ? 2 : 4} borderRadius={4}
onPress={author._id === user.id ? undefined : () => navToRoomInfo(navParam)} onPress={author._id === user.id ? undefined : () => navToRoomInfo(navParam)}
getCustomEmoji={getCustomEmoji} getCustomEmoji={getCustomEmoji}
avatar={avatar} avatar={avatar}

View File

@ -247,6 +247,8 @@ const Reply = React.memo(
> >
<View style={styles.attachmentContainer}> <View style={styles.attachmentContainer}>
<Title attachment={attachment} timeFormat={timeFormat} theme={theme} /> <Title attachment={attachment} timeFormat={timeFormat} theme={theme} />
<Description attachment={attachment} getCustomEmoji={getCustomEmoji} theme={theme} />
<UrlImage image={attachment.thumb_url} />
<Attachments <Attachments
attachments={attachment.attachments} attachments={attachment.attachments}
getCustomEmoji={getCustomEmoji} getCustomEmoji={getCustomEmoji}
@ -255,8 +257,6 @@ const Reply = React.memo(
isReply isReply
id={messageId} id={messageId}
/> />
<UrlImage image={attachment.thumb_url} />
<Description attachment={attachment} getCustomEmoji={getCustomEmoji} theme={theme} />
<Fields attachment={attachment} getCustomEmoji={getCustomEmoji} theme={theme} /> <Fields attachment={attachment} getCustomEmoji={getCustomEmoji} theme={theme} />
{loading ? ( {loading ? (
<View style={[styles.backdrop]}> <View style={[styles.backdrop]}>

View File

@ -50,7 +50,7 @@ interface IMessageContainerProps {
showAttachment: (file: IAttachment) => void; showAttachment: (file: IAttachment) => void;
onReactionLongPress?: (item: TAnyMessageModel) => void; onReactionLongPress?: (item: TAnyMessageModel) => void;
navToRoomInfo: (navParam: IRoomInfoParam) => void; navToRoomInfo: (navParam: IRoomInfoParam) => void;
callJitsi?: () => void; handleEnterCall?: () => void;
blockAction?: (params: { actionId: string; appId: string; value: string; blockId: string; rid: string; mid: string }) => void; blockAction?: (params: { actionId: string; appId: string; value: string; blockId: string; rid: string; mid: string }) => void;
onAnswerButtonPress?: (message: string, tmid?: string, tshow?: boolean) => void; onAnswerButtonPress?: (message: string, tmid?: string, tshow?: boolean) => void;
threadBadgeColor?: string; threadBadgeColor?: string;
@ -69,7 +69,6 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC
static defaultProps = { static defaultProps = {
getCustomEmoji: () => null, getCustomEmoji: () => null,
onLongPress: () => {}, onLongPress: () => {},
callJitsi: () => {},
blockAction: () => {}, blockAction: () => {},
archived: false, archived: false,
broadcast: false, broadcast: false,
@ -338,7 +337,7 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC
navToRoomInfo, navToRoomInfo,
getCustomEmoji, getCustomEmoji,
isThreadRoom, isThreadRoom,
callJitsi, handleEnterCall,
blockAction, blockAction,
rid, rid,
threadBadgeColor, threadBadgeColor,
@ -456,7 +455,7 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC
showAttachment={showAttachment} showAttachment={showAttachment}
getCustomEmoji={getCustomEmoji} getCustomEmoji={getCustomEmoji}
navToRoomInfo={navToRoomInfo} navToRoomInfo={navToRoomInfo}
callJitsi={callJitsi} handleEnterCall={handleEnterCall}
blockAction={blockAction} blockAction={blockAction}
highlighted={highlighted} highlighted={highlighted}
comment={comment} comment={comment}

View File

@ -40,7 +40,7 @@ export interface IMessageBroadcast {
} }
export interface IMessageCallButton { export interface IMessageCallButton {
callJitsi?: () => void; handleEnterCall?: () => void;
} }
export interface IMessageContent { export interface IMessageContent {

View File

@ -4,37 +4,34 @@ import type { IRoom } from './IRoom';
import type { IUser } from './IUser'; import type { IUser } from './IUser';
import type { IMessage } from './IMessage'; import type { IMessage } from './IMessage';
export enum VideoConferenceStatus { export declare enum VideoConferenceStatus {
CALLING = 0, CALLING = 0,
STARTED = 1, STARTED = 1,
EXPIRED = 2, EXPIRED = 2,
ENDED = 3, ENDED = 3,
DECLINED = 4 DECLINED = 4
} }
export declare type DirectCallInstructions = {
export type DirectCallInstructions = {
type: 'direct'; type: 'direct';
callee: IUser['_id']; calleeId: IUser['_id'];
callId: string; callId: string;
}; };
export declare type ConferenceInstructions = {
export type ConferenceInstructions = {
type: 'videoconference'; type: 'videoconference';
callId: string; callId: string;
rid: IRoom['_id']; rid: IRoom['_id'];
}; };
export declare type LivechatInstructions = {
export type LivechatInstructions = {
type: 'livechat'; type: 'livechat';
callId: string; callId: string;
}; };
export declare type VideoConferenceType =
export type VideoConferenceType = DirectCallInstructions['type'] | ConferenceInstructions['type'] | LivechatInstructions['type']; | DirectCallInstructions['type']
| ConferenceInstructions['type']
| LivechatInstructions['type'];
export interface IVideoConferenceUser extends Pick<Required<IUser>, '_id' | 'username' | 'name' | 'avatarETag'> { export interface IVideoConferenceUser extends Pick<Required<IUser>, '_id' | 'username' | 'name' | 'avatarETag'> {
ts: Date; ts: Date;
} }
export interface IVideoConference extends IRocketChatRecord { export interface IVideoConference extends IRocketChatRecord {
type: VideoConferenceType; type: VideoConferenceType;
rid: string; rid: string;
@ -45,51 +42,68 @@ export interface IVideoConference extends IRocketChatRecord {
ended?: IMessage['_id']; ended?: IMessage['_id'];
}; };
url?: string; url?: string;
createdBy: Pick<Required<IUser>, '_id' | 'username' | 'name'>;
createdBy: Pick<IUser, '_id' | 'username' | 'name'>;
createdAt: Date; createdAt: Date;
endedBy?: Pick<Required<IUser>, '_id' | 'username' | 'name'>;
endedBy?: Pick<IUser, '_id' | 'username' | 'name'>;
endedAt?: Date; endedAt?: Date;
providerName: string; providerName: string;
providerData?: Record<string, any>; providerData?: Record<string, any>;
ringing?: boolean; ringing?: boolean;
} }
export interface IDirectVideoConference extends IVideoConference { export interface IDirectVideoConference extends IVideoConference {
type: 'direct'; type: 'direct';
} }
export interface IGroupVideoConference extends IVideoConference { export interface IGroupVideoConference extends IVideoConference {
type: 'videoconference'; type: 'videoconference';
anonymousUsers: number; anonymousUsers: number;
title: string; title: string;
} }
export interface ILivechatVideoConference extends IVideoConference { export interface ILivechatVideoConference extends IVideoConference {
type: 'livechat'; type: 'livechat';
} }
export declare type VideoConference = IDirectVideoConference | IGroupVideoConference | ILivechatVideoConference;
export type VideoConference = IDirectVideoConference | IGroupVideoConference | ILivechatVideoConference; export declare type VideoConferenceInstructions = DirectCallInstructions | ConferenceInstructions | LivechatInstructions;
export declare const isDirectVideoConference: (call: VideoConference | undefined | null) => call is IDirectVideoConference;
export type VideoConferenceInstructions = DirectCallInstructions | ConferenceInstructions | LivechatInstructions; export declare const isGroupVideoConference: (call: VideoConference | undefined | null) => call is IGroupVideoConference;
export declare const isLivechatVideoConference: (call: VideoConference | undefined | null) => call is ILivechatVideoConference;
export const isDirectVideoConference = (call: VideoConference | undefined | null): call is IDirectVideoConference => declare type GroupVideoConferenceCreateData = Omit<IGroupVideoConference, 'createdBy'> & {
call?.type === 'direct'; createdBy: IUser['_id'];
};
export const isGroupVideoConference = (call: VideoConference | undefined | null): call is IGroupVideoConference => declare type DirectVideoConferenceCreateData = Omit<IDirectVideoConference, 'createdBy'> & {
call?.type === 'videoconference'; createdBy: IUser['_id'];
};
export const isLivechatVideoConference = (call: VideoConference | undefined | null): call is ILivechatVideoConference => declare type LivechatVideoConferenceCreateData = Omit<ILivechatVideoConference, 'createdBy'> & {
call?.type === 'livechat'; createdBy: IUser['_id'];
};
type GroupVideoConferenceCreateData = Omit<IGroupVideoConference, 'createdBy'> & { createdBy: IUser['_id'] }; export declare type VideoConferenceCreateData = AtLeast<
type DirectVideoConferenceCreateData = Omit<IDirectVideoConference, 'createdBy'> & { createdBy: IUser['_id'] };
type LivechatVideoConferenceCreateData = Omit<ILivechatVideoConference, 'createdBy'> & { createdBy: IUser['_id'] };
export type VideoConferenceCreateData = AtLeast<
DirectVideoConferenceCreateData | GroupVideoConferenceCreateData | LivechatVideoConferenceCreateData, DirectVideoConferenceCreateData | GroupVideoConferenceCreateData | LivechatVideoConferenceCreateData,
'createdBy' | 'type' | 'rid' | 'providerName' | 'providerData' 'createdBy' | 'type' | 'rid' | 'providerName' | 'providerData'
>; >;
export type VideoConferenceCapabilities = {
mic?: boolean;
cam?: boolean;
title?: boolean;
};
export type VideoConfStartProps = { roomId: string; title?: string; allowRinging?: boolean };
export type VideoConfJoinProps = {
callId: string;
state?: {
mic?: boolean;
cam?: boolean;
};
};
export type VideoConfCancelProps = {
callId: string;
};
export type VideoConfListProps = {
roomId: string;
count?: number;
offset?: number;
};
export type VideoConfInfoProps = { callId: string };

View File

@ -1,3 +1,3 @@
export const STATUSES = ['offline', 'online', 'away', 'busy'] as const; export const STATUSES = ['offline', 'online', 'away', 'busy', 'disabled'] as const;
export type TUserStatus = typeof STATUSES[number]; export type TUserStatus = typeof STATUSES[number];

View File

@ -1,27 +1,45 @@
import { VideoConference } from '../../IVideoConference'; import {
VideoConfCancelProps,
VideoConference,
VideoConferenceCapabilities,
VideoConferenceInstructions,
VideoConfInfoProps,
VideoConfJoinProps,
VideoConfListProps,
VideoConfStartProps
} from '../../IVideoConference';
import { PaginatedResult } from '../helpers/PaginatedResult';
export type VideoConferenceEndpoints = { export type VideoConferenceEndpoints = {
'video-conference/jitsi.update-timeout': {
POST: (params: { roomId: string }) => void;
};
'video-conference.join': {
POST: (params: { callId: string; state: { cam: boolean } }) => { url: string; providerName: string };
};
'video-conference.start': { 'video-conference.start': {
POST: (params: { roomId: string }) => { url: string }; POST: (params: VideoConfStartProps) => { data: VideoConferenceInstructions & { providerName: string } };
};
'video-conference.join': {
POST: (params: VideoConfJoinProps) => { url: string; providerName: string };
}; };
'video-conference.cancel': { 'video-conference.cancel': {
POST: (params: { callId: string }) => void; POST: (params: VideoConfCancelProps) => void;
}; };
'video-conference.info': { 'video-conference.info': {
GET: (params: { callId: string }) => VideoConference & { GET: (params: VideoConfInfoProps) => VideoConference & { capabilities: VideoConferenceCapabilities };
capabilities: { };
mic?: boolean;
cam?: boolean; 'video-conference.list': {
title?: boolean; GET: (params: VideoConfListProps) => PaginatedResult<{ data: VideoConference[] }>;
}; };
};
'video-conference.capabilities': {
GET: () => { providerName: string; capabilities: VideoConferenceCapabilities };
};
'video-conference.providers': {
GET: () => { data: { key: string; label: string }[] };
};
'video-conference/jitsi.update-timeout': {
POST: (params: { roomId: string }) => void;
}; };
}; };

View File

@ -12,3 +12,6 @@ declare module 'react-native-restart';
declare module 'react-native-jitsi-meet'; declare module 'react-native-jitsi-meet';
declare module 'rn-root-view'; declare module 'rn-root-view';
declare module 'react-native-math-view'; declare module 'react-native-math-view';
declare module '@env' {
export const RUNNING_E2E_TESTS: string;
}

View File

@ -876,5 +876,20 @@
"Call": "Call", "Call": "Call",
"Reply_in_direct_message": "Reply in Direct Message", "Reply_in_direct_message": "Reply in Direct Message",
"room_archived": "archived room", "room_archived": "archived room",
"room_unarchived": "unarchived room" "room_unarchived": "unarchived room",
"no-videoconf-provider-app-header": "Conference call not available",
"no-videoconf-provider-app-body": "Conference call apps can be installed in the Rocket.Chat marketplace by a workspace admin.",
"admin-no-videoconf-provider-app-header": "Conference call not enabled",
"admin-no-videoconf-provider-app-body": "Conference call apps are available in the Rocket.Chat marketplace.",
"no-active-video-conf-provider-header": "Conference call not enabled",
"no-active-video-conf-provider-body": "A workspace admin needs to enable the conference call feature first.",
"admin-no-active-video-conf-provider-header": "Conference call not enabled",
"admin-no-active-video-conf-provider-body": "Configure conference calls in order to make it available on this workspace.",
"video-conf-provider-not-configured-header": "Conference call not enabled",
"video-conf-provider-not-configured-body": "A workspace admin needs to enable the conference calls feature first.",
"admin-video-conf-provider-not-configured-header": "Conference call not enabled",
"admin-video-conf-provider-not-configured-body": "Configure conference calls in order to make it available on this workspace.",
"Presence_Cap_Warning_Title": "User status temporarily disabled",
"Presence_Cap_Warning_Description": "Active connections have reached the limit for the workspace, thus the service that handles user status is disabled. It can be re-enabled manually in workspace settings.",
"Learn_more": "Learn more"
} }

View File

@ -875,5 +875,7 @@
"Call": "Ligar", "Call": "Ligar",
"Reply_in_direct_message": "Responder por mensagem direta", "Reply_in_direct_message": "Responder por mensagem direta",
"room_archived": "{{username}} arquivou a sala", "room_archived": "{{username}} arquivou a sala",
"room_unarchived": "{{username}} desarquivou a sala" "room_unarchived": "{{username}} desarquivou a sala",
"Presence_Cap_Warning_Title": "Status do usuário desabilitado temporariamente",
"Presence_Cap_Warning_Description": "O limite de conexões ativas para a workspace foi atingido, por isso o serviço responsável pela presença dos usuários está temporariamente desabilitado. Ele pode ser reabilitado manualmente nas configurações da workspace."
} }

View File

@ -3,7 +3,8 @@ export const STATUS_COLORS: any = {
busy: '#f5455c', busy: '#f5455c',
away: '#ffd21f', away: '#ffd21f',
offline: '#cbced1', offline: '#cbced1',
loading: '#9ea2a8' loading: '#9ea2a8',
disabled: '#F38C39'
}; };
export const SWITCH_TRACK_COLOR = { export const SWITCH_TRACK_COLOR = {

View File

@ -1,3 +1,4 @@
// 🚨🚨 48 settings after login. Pay attention not to reach 50 as that's the limit per request.
export const defaultSettings = { export const defaultSettings = {
Accounts_AllowEmailChange: { Accounts_AllowEmailChange: {
type: 'valueAsBoolean' type: 'valueAsBoolean'
@ -229,5 +230,8 @@ export const defaultSettings = {
}, },
Number_of_users_autocomplete_suggestions: { Number_of_users_autocomplete_suggestions: {
type: 'valueAsNumber' type: 'valueAsNumber'
},
Presence_broadcast_disabled: {
type: 'valueAsBoolean'
} }
} as const; } as const;

View File

@ -8,5 +8,6 @@ export * from './localAuthentication';
export * from './localPath'; export * from './localPath';
export * from './messagesStatus'; export * from './messagesStatus';
export * from './messageTypeLoad'; export * from './messageTypeLoad';
export * from './notifications';
export * from './defaultSettings'; export * from './defaultSettings';
export * from './tablet'; export * from './tablet';

View File

@ -0,0 +1 @@
export const NOTIFICATION_PRESENCE_CAP = 'NOTIFICATION_PRESENCE_CAP';

View File

@ -1,27 +0,0 @@
import { useCallback } from 'react';
import { TActionSheetOptionsItem, useActionSheet } from '../../containers/ActionSheet';
import i18n from '../../i18n';
import { videoConfJoin } from '../methods/videoConf';
export const useVideoConf = (): { joinCall: (blockId: string) => void } => {
const { showActionSheet } = useActionSheet();
const joinCall = useCallback(blockId => {
const options: TActionSheetOptionsItem[] = [
{
title: i18n.t('Video_call'),
icon: 'camera',
onPress: () => videoConfJoin(blockId, true)
},
{
title: i18n.t('Voice_call'),
icon: 'microphone',
onPress: () => videoConfJoin(blockId, false)
}
];
showActionSheet({ options });
}, []);
return { joinCall };
};

View File

@ -0,0 +1,113 @@
import React, { useEffect, useState } from 'react';
import { Q } from '@nozbe/watermelondb';
import { useActionSheet } from '../../containers/ActionSheet';
import StartACallActionSheet from '../../containers/UIKit/VideoConferenceBlock/components/StartACallActionSheet';
import { ISubscription, SubscriptionType, TSubscriptionModel } from '../../definitions';
import i18n from '../../i18n';
import { getUserSelector } from '../../selectors/login';
import database from '../database';
import { getSubscriptionByRoomId } from '../database/services/Subscription';
import { callJitsi } from '../methods';
import { compareServerVersion, showErrorAlert } from '../methods/helpers';
import { videoConfStartAndJoin } from '../methods/videoConf';
import { Services } from '../services';
import { useAppSelector } from './useAppSelector';
import { useSnaps } from './useSnaps';
const availabilityErrors = {
NOT_CONFIGURED: 'video-conf-provider-not-configured',
NOT_ACTIVE: 'no-active-video-conf-provider',
NO_APP: 'no-videoconf-provider-app'
} as const;
const handleErrors = (isAdmin: boolean, error: typeof availabilityErrors[keyof typeof availabilityErrors]) => {
if (isAdmin) return showErrorAlert(i18n.t(`admin-${error}-body`), i18n.t(`admin-${error}-header`));
return showErrorAlert(i18n.t(`${error}-body`), i18n.t(`${error}-header`));
};
export const useVideoConf = (rid: string): { showInitCallActionSheet: () => Promise<void>; showCallOption: boolean } => {
const [showCallOption, setShowCallOption] = useState(false);
const serverVersion = useAppSelector(state => state.server.version);
const jitsiEnabled = useAppSelector(state => state.settings.Jitsi_Enabled);
const jitsiEnableTeams = useAppSelector(state => state.settings.Jitsi_Enable_Teams);
const jitsiEnableChannels = useAppSelector(state => state.settings.Jitsi_Enable_Channels);
const user = useAppSelector(state => getUserSelector(state));
const isServer5OrNewer = compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '5.0.0');
const { showActionSheet } = useActionSheet();
const snaps = useSnaps([1250]);
const handleShowCallOption = (room: TSubscriptionModel) => {
if (isServer5OrNewer) return setShowCallOption(true);
const isJitsiDisabledForTeams = room.teamMain && !jitsiEnableTeams;
const isJitsiDisabledForChannels = !room.teamMain && (room.t === 'p' || room.t === 'c') && !jitsiEnableChannels;
if (room.t === SubscriptionType.DIRECT) return setShowCallOption(!!jitsiEnabled);
if (room.t === SubscriptionType.CHANNEL) return setShowCallOption(!isJitsiDisabledForChannels);
if (room.t === SubscriptionType.GROUP) return setShowCallOption(!isJitsiDisabledForTeams);
return setShowCallOption(false);
};
const canInitAnCall = async () => {
if (isServer5OrNewer) {
try {
await Services.videoConferenceGetCapabilities();
return true;
} catch (error: any) {
const isAdmin = !!['admin'].find(role => user.roles?.includes(role));
switch (error?.error) {
case availabilityErrors.NOT_CONFIGURED:
return handleErrors(isAdmin, availabilityErrors.NOT_CONFIGURED);
case availabilityErrors.NOT_ACTIVE:
return handleErrors(isAdmin, availabilityErrors.NOT_ACTIVE);
case availabilityErrors.NO_APP:
return handleErrors(isAdmin, availabilityErrors.NO_APP);
default:
return handleErrors(isAdmin, availabilityErrors.NOT_CONFIGURED);
}
}
}
return true;
};
const initCall = async ({ cam, mic }: { cam: boolean; mic: boolean }) => {
if (isServer5OrNewer) return videoConfStartAndJoin({ rid, cam, mic });
const room = (await getSubscriptionByRoomId(rid)) as ISubscription;
callJitsi({ room, cam });
};
const showInitCallActionSheet = async () => {
const canInit = await canInitAnCall();
if (canInit) {
showActionSheet({
children: <StartACallActionSheet rid={rid} initCall={initCall} />,
snaps
});
}
};
const initSubscription = () => {
try {
const db = database.active;
const observeSubCollection = db.get('subscriptions').query(Q.where('rid', rid)).observe();
const subObserveQuery = observeSubCollection.subscribe(data => {
if (data[0]) {
handleShowCallOption(data[0]);
subObserveQuery.unsubscribe();
}
});
} catch (e) {
console.log("observeSubscriptions: Can't find subscription to observe");
}
};
useEffect(() => {
initSubscription();
}, []);
return { showInitCallActionSheet, showCallOption };
};

View File

@ -52,7 +52,7 @@ export const deleteAllAudioFiles = async (serverUrl: string): Promise<void> => {
try { try {
const serverUrlParsed = sanitizeString(serverUrl); const serverUrlParsed = sanitizeString(serverUrl);
const path = `${FileSystem.documentDirectory}audios/${serverUrlParsed}`; const path = `${FileSystem.documentDirectory}audios/${serverUrlParsed}`;
await FileSystem.deleteAsync(path); await FileSystem.deleteAsync(path, { idempotent: true });
} catch (error) { } catch (error) {
log(error); log(error);
} }

View File

@ -46,8 +46,8 @@ export function callJitsiWithoutServer(path: string): void {
Navigation.navigate('JitsiMeetView', { url, onlyAudio: false }); Navigation.navigate('JitsiMeetView', { url, onlyAudio: false });
} }
export async function callJitsi(room: ISubscription, onlyAudio = false): Promise<void> { export async function callJitsi({ room, cam = false }: { room: ISubscription; cam?: boolean }): Promise<void> {
logEvent(onlyAudio ? events.RA_JITSI_AUDIO : events.RA_JITSI_VIDEO); logEvent(cam ? events.RA_JITSI_AUDIO : events.RA_JITSI_VIDEO);
const url = await jitsiURL({ room }); const url = await jitsiURL({ room });
Navigation.navigate('JitsiMeetView', { url, onlyAudio, rid: room?.rid }); Navigation.navigate('JitsiMeetView', { url, onlyAudio: cam, rid: room?.rid });
} }

View File

@ -11,6 +11,7 @@ import database from '../database';
import sdk from '../services/sdk'; import sdk from '../services/sdk';
import protectedFunction from './helpers/protectedFunction'; import protectedFunction from './helpers/protectedFunction';
import { parseSettings, _prepareSettings } from './parseSettings'; import { parseSettings, _prepareSettings } from './parseSettings';
import { setPresenceCap } from './getUsersPresence';
const serverInfoKeys = [ const serverInfoKeys = [
'Site_Name', 'Site_Name',
@ -157,8 +158,11 @@ export async function getSettings(): Promise<void> {
const data: IData[] = result.settings || []; const data: IData[] = result.settings || [];
const filteredSettings: IPreparedSettings[] = _prepareSettings(data); const filteredSettings: IPreparedSettings[] = _prepareSettings(data);
const filteredSettingsIds = filteredSettings.map(s => s._id); const filteredSettingsIds = filteredSettings.map(s => s._id);
const parsedSettings = parseSettings(filteredSettings);
reduxStore.dispatch(addSettings(parseSettings(filteredSettings))); reduxStore.dispatch(addSettings(parsedSettings));
setPresenceCap(parsedSettings.Presence_broadcast_disabled);
// filter server info // filter server info
const serverInfo = filteredSettings.filter(i1 => serverInfoKeys.includes(i1._id)); const serverInfo = filteredSettings.filter(i1 => serverInfoKeys.includes(i1._id));

View File

@ -9,6 +9,9 @@ import database from '../database';
import { IUser } from '../../definitions'; import { IUser } from '../../definitions';
import sdk from '../services/sdk'; import sdk from '../services/sdk';
import { compareServerVersion } from './helpers'; import { compareServerVersion } from './helpers';
import userPreferences from './userPreferences';
import { NOTIFICATION_PRESENCE_CAP } from '../constants';
import { setNotificationPresenceCap } from '../../actions/app';
export const _activeUsersSubTimeout: { activeUsersSubTimeout: boolean | ReturnType<typeof setTimeout> | number } = { export const _activeUsersSubTimeout: { activeUsersSubTimeout: boolean | ReturnType<typeof setTimeout> | number } = {
activeUsersSubTimeout: false activeUsersSubTimeout: false
@ -124,3 +127,16 @@ export function getUserPresence(uid: string) {
usersBatch.push(uid); usersBatch.push(uid);
} }
} }
export const setPresenceCap = async (enabled: boolean) => {
if (enabled) {
const notificationPresenceCap = await userPreferences.getBool(NOTIFICATION_PRESENCE_CAP);
if (notificationPresenceCap !== false) {
userPreferences.setBool(NOTIFICATION_PRESENCE_CAP, true);
reduxStore.dispatch(setNotificationPresenceCap(true));
}
} else {
userPreferences.removeItem(NOTIFICATION_PRESENCE_CAP);
reduxStore.dispatch(setNotificationPresenceCap(false));
}
};

View File

@ -1,5 +1,7 @@
import { Alert, Linking } from 'react-native'; import { Alert, Linking } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
// eslint-disable-next-line import/no-unresolved
import { RUNNING_E2E_TESTS } from '@env';
import I18n from '../../../i18n'; import I18n from '../../../i18n';
import { isFDroidBuild, STORE_REVIEW_LINK } from '../../constants'; import { isFDroidBuild, STORE_REVIEW_LINK } from '../../constants';
@ -86,14 +88,15 @@ class ReviewApp {
positiveEventCount = 0; positiveEventCount = 0;
pushPositiveEvent = () => { pushPositiveEvent = () => {
if (!isFDroidBuild) { if (isFDroidBuild || RUNNING_E2E_TESTS === 'true') {
if (this.positiveEventCount >= numberOfPositiveEvent) { return;
return; }
} if (this.positiveEventCount >= numberOfPositiveEvent) {
this.positiveEventCount += 1; return;
if (this.positiveEventCount === numberOfPositiveEvent) { }
tryReview(); this.positiveEventCount += 1;
} if (this.positiveEventCount === numberOfPositiveEvent) {
tryReview();
} }
}; };
} }

View File

@ -38,7 +38,8 @@ export const localSearchSubscription = async ({ text = '', filterUsers = true, f
t: item.t, t: item.t,
encrypted: item.encrypted, encrypted: item.encrypted,
lastMessage: item.lastMessage, lastMessage: item.lastMessage,
status: item.status status: item.status,
teamMain: item.teamMain
})) as ISearchLocal[]; })) as ISearchLocal[];
return search; return search;

View File

@ -138,6 +138,23 @@ export default class RoomSubscription {
reduxStore.dispatch(removeUserTyping(name)); reduxStore.dispatch(removeUserTyping(name));
} }
} }
} else if (ev === 'user-activity') {
const { user } = reduxStore.getState().login;
const { UI_Use_Real_Name } = reduxStore.getState().settings;
const { subscribedRoom } = reduxStore.getState().room;
if (subscribedRoom !== _rid) {
return;
}
const [name, activities] = ddpMessage.fields.args;
const key = UI_Use_Real_Name ? 'name' : 'username';
if (name !== user[key]) {
if (activities.includes('user-typing')) {
reduxStore.dispatch(addUserTyping(name));
}
if (!activities.length) {
reduxStore.dispatch(removeUserTyping(name));
}
}
} else if (ev === 'deleteMessage') { } else if (ev === 'deleteMessage') {
InteractionManager.runAfterInteractions(async () => { InteractionManager.runAfterInteractions(async () => {
if (ddpMessage && ddpMessage.fields && ddpMessage.fields.args.length > 0) { if (ddpMessage && ddpMessage.fields && ddpMessage.fields.args.length > 0) {

View File

@ -16,42 +16,43 @@ class UserPreferences {
getString(key: string): string | null { getString(key: string): string | null {
try { try {
return this.mmkv.getString(key) || null; return this.mmkv.getString(key) ?? null;
} catch { } catch {
return null; return null;
} }
} }
setString(key: string, value: string): boolean | undefined { setString(key: string, value: string): boolean | undefined {
return this.mmkv.setString(key, value) || undefined; return this.mmkv.setString(key, value) ?? undefined;
} }
getBool(key: string): boolean | null { getBool(key: string): boolean | null {
try { try {
return this.mmkv.getBool(key) || null; console.log(this.mmkv.getBool(key));
return this.mmkv.getBool(key) ?? null;
} catch { } catch {
return null; return null;
} }
} }
setBool(key: string, value: boolean): boolean | undefined { setBool(key: string, value: boolean): boolean | undefined {
return this.mmkv.setBool(key, value) || undefined; return this.mmkv.setBool(key, value) ?? undefined;
} }
getMap(key: string): object | null { getMap(key: string): object | null {
try { try {
return this.mmkv.getMap(key) || null; return this.mmkv.getMap(key) ?? null;
} catch { } catch {
return null; return null;
} }
} }
setMap(key: string, value: object): boolean | undefined { setMap(key: string, value: object): boolean | undefined {
return this.mmkv.setMap(key, value) || undefined; return this.mmkv.setMap(key, value) ?? undefined;
} }
removeItem(key: string): boolean | undefined { removeItem(key: string): boolean | undefined {
return this.mmkv.removeItem(key) || undefined; return this.mmkv.removeItem(key) ?? undefined;
} }
} }

View File

@ -19,9 +19,9 @@ const handleBltPermission = async (): Promise<Permission[]> => {
return [PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION]; return [PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION];
}; };
export const videoConfJoin = async (callId: string, cam: boolean) => { export const videoConfJoin = async (callId: string, cam?: boolean, mic?: boolean): Promise<void> => {
try { try {
const result = await Services.videoConferenceJoin(callId, cam); const result = await Services.videoConferenceJoin(callId, cam, mic);
if (result.success) { if (result.success) {
if (isAndroid) { if (isAndroid) {
const bltPermission = await handleBltPermission(); const bltPermission = await handleBltPermission();
@ -44,11 +44,11 @@ export const videoConfJoin = async (callId: string, cam: boolean) => {
} }
}; };
export const videoConfStartAndJoin = async (rid: string, cam: boolean) => { export const videoConfStartAndJoin = async ({ rid, cam, mic }: { rid: string; cam?: boolean; mic?: boolean }): Promise<void> => {
try { try {
const videoConfResponse: any = await Services.videoConferenceStart(rid); const videoConfResponse = await Services.videoConferenceStart(rid);
if (videoConfResponse.success) { if (videoConfResponse.success) {
videoConfJoin(videoConfResponse.data.callId, cam); videoConfJoin(videoConfResponse.data.callId, cam, mic);
} }
} catch (e) { } catch (e) {
showErrorAlert(i18n.t('error-init-video-conf')); showErrorAlert(i18n.t('error-init-video-conf'));

View File

@ -0,0 +1,27 @@
import BackgroundTimer from 'react-native-background-timer';
import { Services } from '../services';
let interval: number | null = null;
export const initVideoConfTimer = (rid: string): void => {
if (rid) {
Services.updateJitsiTimeout(rid).catch((e: unknown) => console.log(e));
if (interval) {
BackgroundTimer.clearInterval(interval);
BackgroundTimer.stopBackgroundTimer();
interval = null;
}
interval = BackgroundTimer.setInterval(() => {
Services.updateJitsiTimeout(rid).catch((e: unknown) => console.log(e));
}, 10000);
}
};
export const endVideoConfTimer = (): void => {
if (interval) {
BackgroundTimer.clearInterval(interval);
interval = null;
BackgroundTimer.stopBackgroundTimer();
}
};

View File

@ -20,7 +20,16 @@ import { updatePermission } from '../../actions/permissions';
import EventEmitter from '../methods/helpers/events'; import EventEmitter from '../methods/helpers/events';
import { updateSettings } from '../../actions/settings'; import { updateSettings } from '../../actions/settings';
import { defaultSettings, MIN_ROCKETCHAT_VERSION } from '../constants'; import { defaultSettings, MIN_ROCKETCHAT_VERSION } from '../constants';
import { getSettings, IActiveUsers, unsubscribeRooms, _activeUsers, _setUser, _setUserTimer, onRolesChanged } from '../methods'; import {
getSettings,
IActiveUsers,
unsubscribeRooms,
_activeUsers,
_setUser,
_setUserTimer,
onRolesChanged,
setPresenceCap
} from '../methods';
import { compareServerVersion, isIOS, isSsl } from '../methods/helpers'; import { compareServerVersion, isIOS, isSsl } from '../methods/helpers';
interface IServices { interface IServices {
@ -144,6 +153,10 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr
}); });
} }
store.dispatch(updateSettings(_id, value)); store.dispatch(updateSettings(_id, value));
if (_id === 'Presence_broadcast_disabled') {
setPresenceCap(value);
}
} catch (e) { } catch (e) {
log(e); log(e);
} }

View File

@ -812,10 +812,14 @@ export const addUsersToRoom = (rid: string): Promise<boolean> => {
}; };
export const emitTyping = (room: IRoom, typing = true) => { export const emitTyping = (room: IRoom, typing = true) => {
const { login, settings } = reduxStore.getState(); const { login, settings, server } = reduxStore.getState();
const { UI_Use_Real_Name } = settings; const { UI_Use_Real_Name } = settings;
const { version: serverVersion } = server;
const { user } = login; const { user } = login;
const name = UI_Use_Real_Name ? user.name : user.username; const name = UI_Use_Real_Name ? user.name : user.username;
if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '4.0.0')) {
return sdk.methodCall('stream-notify-room', `${room}/user-activity`, name, typing ? ['user-typing'] : []);
}
return sdk.methodCall('stream-notify-room', `${room}/typing`, name, typing); return sdk.methodCall('stream-notify-room', `${room}/typing`, name, typing);
}; };
@ -932,8 +936,10 @@ export function getUserInfo(userId: string) {
export const toggleFavorite = (roomId: string, favorite: boolean) => sdk.post('rooms.favorite', { roomId, favorite }); export const toggleFavorite = (roomId: string, favorite: boolean) => sdk.post('rooms.favorite', { roomId, favorite });
export const videoConferenceJoin = (callId: string, cam: boolean) => export const videoConferenceJoin = (callId: string, cam?: boolean, mic?: boolean) =>
sdk.post('video-conference.join', { callId, state: { cam } }); sdk.post('video-conference.join', { callId, state: { cam: !!cam, mic: mic === undefined ? true : mic } });
export const videoConferenceGetCapabilities = () => sdk.get('video-conference.capabilities');
export const videoConferenceStart = (roomId: string) => sdk.post('video-conference.start', { roomId }); export const videoConferenceStart = (roomId: string) => sdk.post('video-conference.start', { roomId });

View File

@ -6,7 +6,7 @@ import { twoFactor } from './twoFactor';
import { isSsl } from '../methods/helpers/url'; import { isSsl } from '../methods/helpers/url';
import { store as reduxStore } from '../store/auxStore'; import { store as reduxStore } from '../store/auxStore';
import { Serialized, MatchPathPattern, OperationParams, PathFor, ResultFor } from '../../definitions/rest/helpers'; import { Serialized, MatchPathPattern, OperationParams, PathFor, ResultFor } from '../../definitions/rest/helpers';
import { random } from '../methods/helpers'; import { compareServerVersion, random } from '../methods/helpers';
class Sdk { class Sdk {
private sdk: typeof Rocketchat; private sdk: typeof Rocketchat;
@ -162,7 +162,22 @@ class Sdk {
} }
subscribeRoom(...args: any[]) { subscribeRoom(...args: any[]) {
return this.current.subscribeRoom(...args); const { server } = reduxStore.getState();
const { version: serverVersion } = server;
const topic = 'stream-notify-room';
let eventUserTyping;
if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '4.0.0')) {
eventUserTyping = this.subscribe(topic, `${args[0]}/user-activity`, ...args);
} else {
eventUserTyping = this.subscribe(topic, `${args[0]}/typing`, ...args);
}
// Taken from https://github.com/RocketChat/Rocket.Chat.js.SDK/blob/454b4ba784095057b8de862eb99340311b672e15/lib/drivers/ddp.ts#L555
return Promise.all([
this.subscribe('stream-room-messages', args[0], ...args),
eventUserTyping,
this.subscribe(topic, `${args[0]}/deleteMessage`, ...args)
]);
} }
unsubscribe(subscription: any[]) { unsubscribe(subscription: any[]) {

View File

@ -1,4 +1,4 @@
import { appStart, appInit, setMasterDetail } from '../actions/app'; import { appStart, appInit, setMasterDetail, setNotificationPresenceCap, appReady } from '../actions/app';
import { initialState } from './app'; import { initialState } from './app';
import { mockedStore } from './mockedStore'; import { mockedStore } from './mockedStore';
import { RootEnum } from '../definitions'; import { RootEnum } from '../definitions';
@ -20,6 +20,9 @@ describe('test reducer', () => {
mockedStore.dispatch(appInit()); mockedStore.dispatch(appInit());
const { ready } = mockedStore.getState().app; const { ready } = mockedStore.getState().app;
expect(ready).toEqual(false); expect(ready).toEqual(false);
mockedStore.dispatch(appReady());
const { ready: ready2 } = mockedStore.getState().app;
expect(ready2).toEqual(true);
}); });
it('should return ready state after dispatch setMasterDetail action', () => { it('should return ready state after dispatch setMasterDetail action', () => {
@ -41,4 +44,13 @@ describe('test reducer', () => {
expect(foreground).toEqual(false); expect(foreground).toEqual(false);
expect(background).toEqual(true); expect(background).toEqual(true);
}); });
it('should return correct state after dispatch setNotificationPresenceCap action', () => {
mockedStore.dispatch(setNotificationPresenceCap(true));
const { notificationPresenceCap } = mockedStore.getState().app;
expect(notificationPresenceCap).toEqual(true);
mockedStore.dispatch(setNotificationPresenceCap(false));
const { notificationPresenceCap: notificationPresenceCap2 } = mockedStore.getState().app;
expect(notificationPresenceCap2).toEqual(false);
});
}); });

View File

@ -9,6 +9,7 @@ export interface IApp {
ready: boolean; ready: boolean;
foreground: boolean; foreground: boolean;
background: boolean; background: boolean;
notificationPresenceCap: boolean;
} }
export const initialState: IApp = { export const initialState: IApp = {
@ -17,7 +18,8 @@ export const initialState: IApp = {
text: undefined, text: undefined,
ready: false, ready: false,
foreground: true, foreground: true,
background: false background: false,
notificationPresenceCap: false
}; };
export default function app(state = initialState, action: TActionApp): IApp { export default function app(state = initialState, action: TActionApp): IApp {
@ -55,6 +57,11 @@ export default function app(state = initialState, action: TActionApp): IApp {
...state, ...state,
isMasterDetail: action.isMasterDetail isMasterDetail: action.isMasterDetail
}; };
case APP.SET_NOTIFICATION_PRESENCE_CAP:
return {
...state,
notificationPresenceCap: action.show
};
default: default:
return state; return state;
} }

View File

@ -105,11 +105,7 @@ const ChatsStackNavigator = () => {
/> />
<ChatsStack.Screen name='SelectedUsersView' component={SelectedUsersView} /> <ChatsStack.Screen name='SelectedUsersView' component={SelectedUsersView} />
<ChatsStack.Screen name='InviteUsersView' component={InviteUsersView} /> <ChatsStack.Screen name='InviteUsersView' component={InviteUsersView} />
<ChatsStack.Screen <ChatsStack.Screen name='InviteUsersEditView' component={InviteUsersEditView} />
name='InviteUsersEditView'
component={InviteUsersEditView}
options={InviteUsersEditView.navigationOptions}
/>
<ChatsStack.Screen name='MessagesView' component={MessagesView} /> <ChatsStack.Screen name='MessagesView' component={MessagesView} />
<ChatsStack.Screen name='AutoTranslateView' component={AutoTranslateView} options={AutoTranslateView.navigationOptions} /> <ChatsStack.Screen name='AutoTranslateView' component={AutoTranslateView} options={AutoTranslateView.navigationOptions} />
<ChatsStack.Screen name='DirectoryView' component={DirectoryView} options={DirectoryView.navigationOptions} /> <ChatsStack.Screen name='DirectoryView' component={DirectoryView} options={DirectoryView.navigationOptions} />

View File

@ -142,11 +142,7 @@ const ModalStackNavigator = React.memo(({ navigation }: INavigation) => {
component={AddExistingChannelView} component={AddExistingChannelView}
options={AddExistingChannelView.navigationOptions} options={AddExistingChannelView.navigationOptions}
/> />
<ModalStack.Screen <ModalStack.Screen name='InviteUsersEditView' component={InviteUsersEditView} />
name='InviteUsersEditView'
component={InviteUsersEditView}
options={InviteUsersEditView.navigationOptions}
/>
<ModalStack.Screen name='MessagesView' component={MessagesView} /> <ModalStack.Screen name='MessagesView' component={MessagesView} />
<ModalStack.Screen name='AutoTranslateView' component={AutoTranslateView} options={AutoTranslateView.navigationOptions} /> <ModalStack.Screen name='AutoTranslateView' component={AutoTranslateView} options={AutoTranslateView.navigationOptions} />
<ModalStack.Screen <ModalStack.Screen

View File

@ -243,7 +243,7 @@ class DirectoryView extends React.Component<IDirectoryViewProps, IDirectoryViewS
title: item.name as string, title: item.name as string,
onPress: () => this.onPressItem(item), onPress: () => this.onPressItem(item),
baseUrl, baseUrl,
testID: `directory-view-item-${item.name}`.toLowerCase(), testID: `directory-view-item-${item.name}`,
style, style,
user, user,
theme, theme,

View File

@ -0,0 +1,92 @@
import React from 'react';
import { TextInputProps } from 'react-native';
import RNPickerSelect from 'react-native-picker-select';
import { useDispatch } from 'react-redux';
import { inviteLinksSetParams } from '../../actions/inviteLinks';
import { useTheme } from '../../theme';
import { useAppSelector } from '../../lib/hooks';
import I18n from '../../i18n';
import styles from './styles';
import { events, logEvent } from '../../lib/methods/helpers/log';
const OPTIONS = {
days: [
{
label: '1',
value: 1
},
{
label: '7',
value: 7
},
{
label: '15',
value: 15
},
{
label: '30',
value: 30
}
],
maxUses: [
{
label: '1',
value: 1
},
{
label: '5',
value: 5
},
{
label: '10',
value: 10
},
{
label: '25',
value: 25
},
{
label: '50',
value: 50
},
{
label: '100',
value: 100
}
]
};
const Picker = ({ param, first }: { param: 'days' | 'maxUses'; first: string }): JSX.Element => {
const { colors } = useTheme();
const inviteLinkParam = useAppSelector(state => state.inviteLinks[param]);
const dispatch = useDispatch();
const onValueChangePicker = (value: number) => {
logEvent(events.IU_EDIT_SET_LINK_PARAM);
const params = {
[param]: value
};
dispatch(inviteLinksSetParams(params));
};
const textInputStyle: TextInputProps = { style: { ...styles.pickerText, color: colors.actionTintColor } };
const firstEl = [
{
label: I18n.t(first),
value: 0
}
];
return (
<RNPickerSelect
style={{ viewContainer: styles.viewContainer }}
value={inviteLinkParam}
textInputProps={textInputStyle}
useNativeAndroidPickerStyle={false}
onValueChange={value => onValueChangePicker(value)}
items={firstEl.concat(OPTIONS[param])}
/>
);
};
export default Picker;

View File

@ -1,149 +1,54 @@
import { StackNavigationOptions } from '@react-navigation/stack'; import { StackNavigationProp } from '@react-navigation/stack';
import React from 'react'; import React, { useLayoutEffect } from 'react';
import { TextInputProps, View } from 'react-native'; import { View } from 'react-native';
import RNPickerSelect from 'react-native-picker-select'; import { useDispatch } from 'react-redux';
import { connect } from 'react-redux'; import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
import { inviteLinksCreate, inviteLinksSetParams } from '../../actions/inviteLinks'; import { inviteLinksCreate } from '../../actions/inviteLinks';
import { themes } from '../../lib/constants';
import Button from '../../containers/Button'; import Button from '../../containers/Button';
import * as List from '../../containers/List'; import * as List from '../../containers/List';
import SafeAreaView from '../../containers/SafeAreaView'; import SafeAreaView from '../../containers/SafeAreaView';
import StatusBar from '../../containers/StatusBar'; import StatusBar from '../../containers/StatusBar';
import { IApplicationState, IBaseScreen } from '../../definitions';
import I18n from '../../i18n'; import I18n from '../../i18n';
import { ChatsStackParamList } from '../../stacks/types'; import { ChatsStackParamList } from '../../stacks/types';
import { withTheme } from '../../theme';
import { events, logEvent } from '../../lib/methods/helpers/log'; import { events, logEvent } from '../../lib/methods/helpers/log';
import styles from './styles'; import styles from './styles';
import Picker from './Picker';
const OPTIONS = { const InviteUsersEditView = () => {
days: [ const navigation = useNavigation<StackNavigationProp<ChatsStackParamList, 'InviteUsersEditView'>>();
{ const { rid } = useRoute<RouteProp<ChatsStackParamList, 'InviteUsersEditView'>>().params;
label: '1', const dispatch = useDispatch();
value: 1
},
{
label: '7',
value: 7
},
{
label: '15',
value: 15
},
{
label: '30',
value: 30
}
],
maxUses: [
{
label: '1',
value: 1
},
{
label: '5',
value: 5
},
{
label: '10',
value: 10
},
{
label: '25',
value: 25
},
{
label: '50',
value: 50
},
{
label: '100',
value: 100
}
]
};
interface IInviteUsersEditViewProps extends IBaseScreen<ChatsStackParamList, 'InviteUsersEditView'> { useLayoutEffect(() => {
days: number; navigation.setOptions({
maxUses: number; title: I18n.t('Invite_users')
} });
}, [navigation]);
class InviteUsersEditView extends React.Component<IInviteUsersEditViewProps, any> { const createInviteLink = () => {
static navigationOptions = (): StackNavigationOptions => ({
title: I18n.t('Invite_users')
});
private rid: string;
constructor(props: IInviteUsersEditViewProps) {
super(props);
this.rid = props.route.params?.rid;
}
onValueChangePicker = (key: string, value: number) => {
const { dispatch } = this.props;
logEvent(events.IU_EDIT_SET_LINK_PARAM);
const params = {
[key]: value
};
dispatch(inviteLinksSetParams(params));
};
createInviteLink = () => {
const { dispatch, navigation } = this.props;
logEvent(events.IU_EDIT_CREATE_LINK); logEvent(events.IU_EDIT_CREATE_LINK);
dispatch(inviteLinksCreate(this.rid)); dispatch(inviteLinksCreate(rid));
navigation.pop(); navigation.pop();
}; };
renderPicker = (key: 'days' | 'maxUses', first: string) => { return (
const { props } = this; <SafeAreaView>
const { theme } = props; <List.Container>
const textInputStyle: TextInputProps = { style: { ...styles.pickerText, color: themes[theme].actionTintColor } }; <StatusBar />
const firstEl = [ <List.Section>
{ <List.Separator />
label: I18n.t(first), <List.Item title='Expiration_Days' right={() => <Picker param={'days'} first={'Never'} />} />
value: 0 <List.Separator />
} <List.Item title='Max_number_of_uses' right={() => <Picker param='maxUses' first='No_limit' />} />
]; <List.Separator />
return ( </List.Section>
<RNPickerSelect <View style={styles.innerContainer}>
style={{ viewContainer: styles.viewContainer }} <Button title={I18n.t('Generate_New_Link')} type='primary' onPress={createInviteLink} />
value={props[key]} </View>
textInputProps={textInputStyle} </List.Container>
useNativeAndroidPickerStyle={false} </SafeAreaView>
placeholder={{}} );
onValueChange={value => this.onValueChangePicker(key, value)} };
items={firstEl.concat(OPTIONS[key])}
/>
);
};
render() { export default InviteUsersEditView;
return (
<SafeAreaView>
<List.Container>
<StatusBar />
<List.Section>
<List.Separator />
<List.Item title='Expiration_Days' right={() => this.renderPicker('days', 'Never')} />
<List.Separator />
<List.Item title='Max_number_of_uses' right={() => this.renderPicker('maxUses', 'No_limit')} />
<List.Separator />
</List.Section>
<View style={styles.innerContainer}>
<Button title={I18n.t('Generate_New_Link')} type='primary' onPress={this.createInviteLink} />
</View>
</List.Container>
</SafeAreaView>
);
}
}
const mapStateToProps = (state: IApplicationState) => ({
days: state.inviteLinks.days,
maxUses: state.inviteLinks.maxUses
});
export default connect(mapStateToProps)(withTheme(InviteUsersEditView));

View File

@ -1,14 +1,13 @@
import { activateKeepAwake, deactivateKeepAwake } from 'expo-keep-awake';
import React from 'react'; import React from 'react';
import { BackHandler, NativeEventSubscription } from 'react-native'; import { BackHandler, NativeEventSubscription } from 'react-native';
import BackgroundTimer from 'react-native-background-timer';
import { isAppInstalled, openAppWithUri } from 'react-native-send-intent'; import { isAppInstalled, openAppWithUri } from 'react-native-send-intent';
import WebView from 'react-native-webview'; import WebView from 'react-native-webview';
import { WebViewMessage, WebViewNavigation } from 'react-native-webview/lib/WebViewTypes'; import { WebViewMessage, WebViewNavigation } from 'react-native-webview/lib/WebViewTypes';
import { activateKeepAwake, deactivateKeepAwake } from 'expo-keep-awake';
import { IBaseScreen } from '../definitions'; import { IBaseScreen } from '../definitions';
import { events, logEvent } from '../lib/methods/helpers/log'; import { events, logEvent } from '../lib/methods/helpers/log';
import { Services } from '../lib/services'; import { endVideoConfTimer, initVideoConfTimer } from '../lib/methods/videoConfTimer';
import { ChatsStackParamList } from '../stacks/types'; import { ChatsStackParamList } from '../stacks/types';
import { withTheme } from '../theme'; import { withTheme } from '../theme';
@ -20,7 +19,6 @@ class JitsiMeetView extends React.Component<TJitsiMeetViewProps> {
private rid: string; private rid: string;
private url: string; private url: string;
private videoConf: boolean; private videoConf: boolean;
private jitsiTimeout: number | null;
private backHandler!: NativeEventSubscription; private backHandler!: NativeEventSubscription;
constructor(props: TJitsiMeetViewProps) { constructor(props: TJitsiMeetViewProps) {
@ -28,7 +26,6 @@ class JitsiMeetView extends React.Component<TJitsiMeetViewProps> {
this.rid = props.route.params?.rid; this.rid = props.route.params?.rid;
this.url = props.route.params?.url; this.url = props.route.params?.url;
this.videoConf = !!props.route.params?.videoConf; this.videoConf = !!props.route.params?.videoConf;
this.jitsiTimeout = null;
} }
componentDidMount() { componentDidMount() {
@ -50,10 +47,8 @@ class JitsiMeetView extends React.Component<TJitsiMeetViewProps> {
componentWillUnmount() { componentWillUnmount() {
logEvent(this.videoConf ? events.LIVECHAT_VIDEOCONF_TERMINATE : events.JM_CONFERENCE_TERMINATE); logEvent(this.videoConf ? events.LIVECHAT_VIDEOCONF_TERMINATE : events.JM_CONFERENCE_TERMINATE);
if (this.jitsiTimeout && !this.videoConf) { if (!this.videoConf) {
BackgroundTimer.clearInterval(this.jitsiTimeout); endVideoConfTimer();
this.jitsiTimeout = null;
BackgroundTimer.stopBackgroundTimer();
} }
this.backHandler.remove(); this.backHandler.remove();
deactivateKeepAwake(); deactivateKeepAwake();
@ -64,15 +59,7 @@ class JitsiMeetView extends React.Component<TJitsiMeetViewProps> {
onConferenceJoined = () => { onConferenceJoined = () => {
logEvent(this.videoConf ? events.LIVECHAT_VIDEOCONF_JOIN : events.JM_CONFERENCE_JOIN); logEvent(this.videoConf ? events.LIVECHAT_VIDEOCONF_JOIN : events.JM_CONFERENCE_JOIN);
if (this.rid && !this.videoConf) { if (this.rid && !this.videoConf) {
Services.updateJitsiTimeout(this.rid).catch((e: unknown) => console.log(e)); initVideoConfTimer(this.rid);
if (this.jitsiTimeout) {
BackgroundTimer.clearInterval(this.jitsiTimeout);
BackgroundTimer.stopBackgroundTimer();
this.jitsiTimeout = null;
}
this.jitsiTimeout = BackgroundTimer.setInterval(() => {
Services.updateJitsiTimeout(this.rid).catch((e: unknown) => console.log(e));
}, 10000);
} }
}; };
@ -90,7 +77,7 @@ class JitsiMeetView extends React.Component<TJitsiMeetViewProps> {
render() { render() {
return ( return (
<WebView <WebView
source={{ uri: `${this.url}&config.disableDeepLinking=true` }} source={{ uri: `${this.url}${this.url.includes('#config') ? '&' : '#'}config.disableDeepLinking=true` }}
onMessage={({ nativeEvent }) => this.onNavigationStateChange(nativeEvent)} onMessage={({ nativeEvent }) => this.onNavigationStateChange(nativeEvent)}
onNavigationStateChange={this.onNavigationStateChange} onNavigationStateChange={this.onNavigationStateChange}
style={{ flex: 1 }} style={{ flex: 1 }}

View File

@ -9,6 +9,7 @@ import { useAppSelector } from '../lib/hooks';
import { events, logEvent } from '../lib/methods/helpers/log'; import { events, logEvent } from '../lib/methods/helpers/log';
import { getUserSelector } from '../selectors/login'; import { getUserSelector } from '../selectors/login';
import { ChatsStackParamList } from '../stacks/types'; import { ChatsStackParamList } from '../stacks/types';
import { endVideoConfTimer, initVideoConfTimer } from '../lib/methods/videoConfTimer';
const formatUrl = (url: string, baseUrl: string, uriSize: number, avatarAuthURLFragment: string) => const formatUrl = (url: string, baseUrl: string, uriSize: number, avatarAuthURLFragment: string) =>
`${baseUrl}/avatar/${url}?format=png&width=${uriSize}&height=${uriSize}${avatarAuthURLFragment}`; `${baseUrl}/avatar/${url}?format=png&width=${uriSize}&height=${uriSize}${avatarAuthURLFragment}`;
@ -16,7 +17,7 @@ const formatUrl = (url: string, baseUrl: string, uriSize: number, avatarAuthURLF
const JitsiMeetView = (): React.ReactElement => { const JitsiMeetView = (): React.ReactElement => {
const { goBack } = useNavigation(); const { goBack } = useNavigation();
const { const {
params: { url, onlyAudio, videoConf } params: { url, onlyAudio, videoConf, rid }
} = useRoute<RouteProp<ChatsStackParamList, 'JitsiMeetView'>>(); } = useRoute<RouteProp<ChatsStackParamList, 'JitsiMeetView'>>();
const user = useAppSelector(state => getUserSelector(state)); const user = useAppSelector(state => getUserSelector(state));
const baseUrl = useAppSelector(state => state.server.server); const baseUrl = useAppSelector(state => state.server.server);
@ -60,8 +61,10 @@ const JitsiMeetView = (): React.ReactElement => {
} }
}; };
logEvent(videoConf ? events.LIVECHAT_VIDEOCONF_JOIN : events.JM_CONFERENCE_JOIN); logEvent(videoConf ? events.LIVECHAT_VIDEOCONF_JOIN : events.JM_CONFERENCE_JOIN);
if (!videoConf) initVideoConfTimer(rid);
await JitsiMeet.launchJitsiMeetView(conferenceOptions); await JitsiMeet.launchJitsiMeetView(conferenceOptions);
logEvent(videoConf ? events.LIVECHAT_VIDEOCONF_TERMINATE : events.JM_CONFERENCE_TERMINATE); logEvent(videoConf ? events.LIVECHAT_VIDEOCONF_TERMINATE : events.JM_CONFERENCE_TERMINATE);
if (!videoConf) endVideoConfTimer();
goBack(); goBack();
}; };

View File

@ -0,0 +1,25 @@
import React from 'react';
import * as List from '../../../containers/List';
import i18n from '../../../i18n';
import { useVideoConf } from '../../../lib/hooks/useVideoConf';
export default function CallSection({ rid }: { rid: string }): React.ReactElement | null {
const { showCallOption, showInitCallActionSheet } = useVideoConf(rid);
if (showCallOption)
return (
<List.Section>
<List.Separator />
<List.Item
title={i18n.t('Call')}
onPress={showInitCallActionSheet}
testID='room-actions-call'
left={() => <List.Icon name='phone' />}
showActionIndicator
/>
<List.Separator />
</List.Section>
);
return null;
}

View File

@ -33,7 +33,7 @@ import sharedStyles from '../Styles';
import styles from './styles'; import styles from './styles';
import { ERoomType } from '../../definitions/ERoomType'; import { ERoomType } from '../../definitions/ERoomType';
import { E2E_ROOM_TYPES, SWITCH_TRACK_COLOR, themes } from '../../lib/constants'; import { E2E_ROOM_TYPES, SWITCH_TRACK_COLOR, themes } from '../../lib/constants';
import { callJitsi, getPermalinkChannel } from '../../lib/methods'; import { getPermalinkChannel } from '../../lib/methods';
import { import {
canAutoTranslate as canAutoTranslateMethod, canAutoTranslate as canAutoTranslateMethod,
getRoomAvatar, getRoomAvatar,
@ -48,9 +48,9 @@ import { getSubscriptionByRoomId } from '../../lib/database/services/Subscriptio
import { IActionSheetProvider, withActionSheet } from '../../containers/ActionSheet'; import { IActionSheetProvider, withActionSheet } from '../../containers/ActionSheet';
import { MasterDetailInsideStackParamList } from '../../stacks/MasterDetailStack/types'; import { MasterDetailInsideStackParamList } from '../../stacks/MasterDetailStack/types';
import { closeLivechat } from '../../lib/methods/helpers/closeLivechat'; import { closeLivechat } from '../../lib/methods/helpers/closeLivechat';
import { videoConfStartAndJoin } from '../../lib/methods/videoConf';
import { ILivechatDepartment } from '../../definitions/ILivechatDepartment'; import { ILivechatDepartment } from '../../definitions/ILivechatDepartment';
import { ILivechatTag } from '../../definitions/ILivechatTag'; import { ILivechatTag } from '../../definitions/ILivechatTag';
import CallSection from './components/CallSection';
interface IOnPressTouch { interface IOnPressTouch {
<T extends keyof ChatsStackParamList>(item: { route?: T; params?: ChatsStackParamList[T]; event?: Function }): void; <T extends keyof ChatsStackParamList>(item: { route?: T; params?: ChatsStackParamList[T]; event?: Function }): void;
@ -730,16 +730,6 @@ class RoomActionsView extends React.Component<IRoomActionsViewProps, IRoomAction
} }
}; };
startVideoConf = ({ video }: { video: boolean }): void => {
const { room } = this.state;
const { serverVersion } = this.props;
if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '5.0.0')) {
videoConfStartAndJoin(room.rid, video);
} else {
callJitsi(room, !video);
}
};
renderRoomInfo = () => { renderRoomInfo = () => {
const { room, member } = this.state; const { room, member } = this.state;
const { rid, name, t, topic, source } = room; const { rid, name, t, topic, source } = room;
@ -815,63 +805,6 @@ class RoomActionsView extends React.Component<IRoomActionsViewProps, IRoomAction
); );
}; };
renderJitsi = () => {
const { room } = this.state;
const {
jitsiEnabled,
jitsiEnableTeams,
jitsiEnableChannels,
serverVersion,
videoConf_Enable_DMs,
videoConf_Enable_Channels,
videoConf_Enable_Groups,
videoConf_Enable_Teams
} = this.props;
const isJitsiDisabledForTeams = room.teamMain && !jitsiEnableTeams;
const isJitsiDisabledForChannels = !room.teamMain && (room.t === 'p' || room.t === 'c') && !jitsiEnableChannels;
const isVideoConfDisabledForTeams = !!room.teamMain && !videoConf_Enable_Teams;
const isVideoConfDisabledForChannels = !room.teamMain && room.t === 'c' && !videoConf_Enable_Channels;
const isVideoConfDisabledForGroups = !room.teamMain && room.t === 'p' && !videoConf_Enable_Groups;
const isVideoConfDisabledForDirect = !room.teamMain && room.t === 'd' && !videoConf_Enable_DMs;
if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '5.0.0')) {
if (
isVideoConfDisabledForTeams ||
isVideoConfDisabledForChannels ||
isVideoConfDisabledForGroups ||
isVideoConfDisabledForDirect
) {
return null;
}
} else if (!jitsiEnabled || isJitsiDisabledForTeams || isJitsiDisabledForChannels) {
return null;
}
return (
<List.Section>
<List.Separator />
<List.Item
title='Voice_call'
onPress={() => this.startVideoConf({ video: false })}
testID='room-actions-voice'
left={() => <List.Icon name='phone' />}
showActionIndicator
/>
<List.Separator />
<List.Item
title='Video_call'
onPress={() => this.startVideoConf({ video: true })}
testID='room-actions-video'
left={() => <List.Icon name='camera' />}
showActionIndicator
/>
<List.Separator />
</List.Section>
);
};
renderE2EEncryption = () => { renderE2EEncryption = () => {
const { room } = this.state; const { room } = this.state;
const { encryptionEnabled } = this.props; const { encryptionEnabled } = this.props;
@ -1108,7 +1041,7 @@ class RoomActionsView extends React.Component<IRoomActionsViewProps, IRoomAction
<StatusBar /> <StatusBar />
<List.Container testID='room-actions-scrollview'> <List.Container testID='room-actions-scrollview'>
{this.renderRoomInfo()} {this.renderRoomInfo()}
{this.renderJitsi()} <CallSection rid={rid} />
{this.renderE2EEncryption()} {this.renderE2EEncryption()}
<List.Section> <List.Section>
<List.Separator /> <List.Separator />
@ -1299,13 +1232,6 @@ class RoomActionsView extends React.Component<IRoomActionsViewProps, IRoomAction
const mapStateToProps = (state: IApplicationState) => ({ const mapStateToProps = (state: IApplicationState) => ({
userId: getUserSelector(state).id, userId: getUserSelector(state).id,
jitsiEnabled: (state.settings.Jitsi_Enabled || false) as boolean,
jitsiEnableTeams: (state.settings.Jitsi_Enable_Teams || false) as boolean,
jitsiEnableChannels: (state.settings.Jitsi_Enable_Channels || false) as boolean,
videoConf_Enable_DMs: (state.settings.VideoConf_Enable_DMs ?? true) as boolean,
videoConf_Enable_Channels: (state.settings.VideoConf_Enable_Channels ?? true) as boolean,
videoConf_Enable_Groups: (state.settings.VideoConf_Enable_Groups ?? true) as boolean,
videoConf_Enable_Teams: (state.settings.VideoConf_Enable_Teams ?? true) as boolean,
encryptionEnabled: state.encryption.enabled, encryptionEnabled: state.encryption.enabled,
serverVersion: state.server.version, serverVersion: state.server.version,
isMasterDetail: state.app.isMasterDetail, isMasterDetail: state.app.isMasterDetail,

View File

@ -655,7 +655,6 @@ class RoomInfoEditView extends React.Component<IRoomInfoEditViewProps, IRoomInfo
label={I18n.t('Password')} label={I18n.t('Password')}
value={joinCode} value={joinCode}
onChangeText={value => this.setState({ joinCode: value })} onChangeText={value => this.setState({ joinCode: value })}
onSubmitEditing={this.submit}
secureTextEntry secureTextEntry
testID='room-info-edit-view-password' testID='room-info-edit-view-password'
/> />

View File

@ -0,0 +1,48 @@
import React from 'react';
import { Text } from 'react-native';
import { BorderlessButton } from 'react-native-gesture-handler';
import { CustomIcon, TIconsName } from '../../../containers/CustomIcon';
import styles from '../styles';
import { useTheme } from '../../../theme';
import { useVideoConf } from '../../../lib/hooks/useVideoConf';
import i18n from '../../../i18n';
import { useAppSelector } from '../../../lib/hooks';
import { compareServerVersion } from '../../../lib/methods/helpers';
// TODO: change other icons on future
function UserInfoButton({
danger,
iconName,
onPress,
label,
showIcon
}: {
danger?: boolean;
iconName: TIconsName;
onPress?: (prop: any) => void;
label: string;
showIcon?: boolean;
}): React.ReactElement | null {
const { colors } = useTheme();
const color = danger ? colors.dangerColor : colors.actionTintColor;
if (showIcon)
return (
<BorderlessButton testID={`room-info-view-${iconName}`} onPress={onPress} style={styles.roomButton}>
<CustomIcon name={iconName} size={30} color={color} />
<Text style={[styles.roomButtonText, { color }]}>{label}</Text>
</BorderlessButton>
);
return null;
}
export function CallButton({ rid, isDirect }: { rid: string; isDirect: boolean }): React.ReactElement | null {
const { showCallOption, showInitCallActionSheet } = useVideoConf(rid);
const serverVersion = useAppSelector(state => state.server.version);
const greaterThanFive = compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '5.0.0');
const showIcon = greaterThanFive ? showCallOption : showCallOption && isDirect;
return <UserInfoButton onPress={showInitCallActionSheet} iconName='phone' label={i18n.t('Call')} showIcon={showIcon} />;
}

View File

@ -1,43 +1,43 @@
import { CompositeNavigationProp, RouteProp } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import isEmpty from 'lodash/isEmpty';
import React from 'react'; import React from 'react';
import { ScrollView, Text, View } from 'react-native'; import { ScrollView, Text, View } from 'react-native';
import { BorderlessButton } from 'react-native-gesture-handler'; import { BorderlessButton } from 'react-native-gesture-handler';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import UAParser from 'ua-parser-js';
import isEmpty from 'lodash/isEmpty';
import { StackNavigationProp } from '@react-navigation/stack';
import { CompositeNavigationProp, RouteProp } from '@react-navigation/native';
import { Observable, Subscription } from 'rxjs'; import { Observable, Subscription } from 'rxjs';
import UAParser from 'ua-parser-js';
import { CustomIcon, TIconsName } from '../../containers/CustomIcon';
import Status from '../../containers/Status';
import Avatar from '../../containers/Avatar'; import Avatar from '../../containers/Avatar';
import sharedStyles from '../Styles'; import { CustomIcon, TIconsName } from '../../containers/CustomIcon';
import RoomTypeIcon from '../../containers/RoomTypeIcon';
import I18n from '../../i18n';
import * as HeaderButton from '../../containers/HeaderButton'; import * as HeaderButton from '../../containers/HeaderButton';
import StatusBar from '../../containers/StatusBar';
import log, { events, logEvent } from '../../lib/methods/helpers/log';
import { themes } from '../../lib/constants';
import { TSupportedThemes, withTheme } from '../../theme';
import { MarkdownPreview } from '../../containers/markdown'; import { MarkdownPreview } from '../../containers/markdown';
import { LISTENER } from '../../containers/Toast'; import RoomTypeIcon from '../../containers/RoomTypeIcon';
import EventEmitter from '../../lib/methods/helpers/events';
import SafeAreaView from '../../containers/SafeAreaView'; import SafeAreaView from '../../containers/SafeAreaView';
import { goRoom } from '../../lib/methods/helpers/goRoom'; import Status from '../../containers/Status';
import Navigation from '../../lib/navigation/appNavigation'; import StatusBar from '../../containers/StatusBar';
import Livechat from './Livechat'; import { LISTENER } from '../../containers/Toast';
import Channel from './Channel'; import { IApplicationState, ISubscription, IUser, SubscriptionType, TSubscriptionModel } from '../../definitions';
import Direct from './Direct';
import styles from './styles';
import { ChatsStackParamList } from '../../stacks/types';
import { MasterDetailInsideStackParamList } from '../../stacks/MasterDetailStack/types';
import { SubscriptionType, TSubscriptionModel, ISubscription, IUser, IApplicationState } from '../../definitions';
import { ILivechatVisitor } from '../../definitions/ILivechatVisitor'; import { ILivechatVisitor } from '../../definitions/ILivechatVisitor';
import { callJitsi } from '../../lib/methods'; import I18n from '../../i18n';
import { getRoomTitle, getUidDirectMessage, hasPermission } from '../../lib/methods/helpers'; import { themes } from '../../lib/constants';
import { Services } from '../../lib/services';
import { getSubscriptionByRoomId } from '../../lib/database/services/Subscription'; import { getSubscriptionByRoomId } from '../../lib/database/services/Subscription';
import { getRoomTitle, getUidDirectMessage, hasPermission } from '../../lib/methods/helpers';
import EventEmitter from '../../lib/methods/helpers/events';
import { goRoom } from '../../lib/methods/helpers/goRoom';
import { handleIgnore } from '../../lib/methods/helpers/handleIgnore'; import { handleIgnore } from '../../lib/methods/helpers/handleIgnore';
import log, { events, logEvent } from '../../lib/methods/helpers/log';
import Navigation from '../../lib/navigation/appNavigation';
import { Services } from '../../lib/services';
import { MasterDetailInsideStackParamList } from '../../stacks/MasterDetailStack/types';
import { ChatsStackParamList } from '../../stacks/types';
import { TSupportedThemes, withTheme } from '../../theme';
import sharedStyles from '../Styles';
import Channel from './Channel';
import { CallButton } from './components/UserInfoButton';
import Direct from './Direct';
import Livechat from './Livechat';
import styles from './styles';
interface IGetRoomTitle { interface IGetRoomTitle {
room: ISubscription; room: ISubscription;
@ -386,11 +386,6 @@ class RoomInfoView extends React.Component<IRoomInfoViewProps, IRoomInfoViewStat
} }
}; };
videoCall = () => {
const { room } = this.state;
callJitsi(room);
};
handleBlockUser = async (rid: string, blocked: string, block: boolean) => { handleBlockUser = async (rid: string, blocked: string, block: boolean) => {
logEvent(events.RI_TOGGLE_BLOCK_USER); logEvent(events.RI_TOGGLE_BLOCK_USER);
try { try {
@ -425,8 +420,7 @@ class RoomInfoView extends React.Component<IRoomInfoViewProps, IRoomInfoViewStat
}; };
renderButtons = () => { renderButtons = () => {
const { roomFromRid, roomUser } = this.state; const { roomFromRid, roomUser, room } = this.state;
const { jitsiEnabled } = this.props;
const isFromDm = roomFromRid?.rid ? new RegExp(roomUser._id).test(roomFromRid.rid) : false; const isFromDm = roomFromRid?.rid ? new RegExp(roomUser._id).test(roomFromRid.rid) : false;
const isDirectFromSaved = this.isDirect && this.fromRid && roomFromRid; const isDirectFromSaved = this.isDirect && this.fromRid && roomFromRid;
@ -442,9 +436,7 @@ class RoomInfoView extends React.Component<IRoomInfoViewProps, IRoomInfoViewStat
return ( return (
<View style={styles.roomButtonsContainer}> <View style={styles.roomButtonsContainer}>
{this.renderButton(() => this.handleCreateDirectMessage(this.goRoom), 'message', I18n.t('Message'))} {this.renderButton(() => this.handleCreateDirectMessage(this.goRoom), 'message', I18n.t('Message'))}
{jitsiEnabled && this.isDirect <CallButton isDirect={this.isDirect} rid={room.rid} />
? this.renderButton(() => this.handleCreateDirectMessage(this.videoCall), 'camera', I18n.t('Video_call'))
: null}
{isDirectFromSaved && !isFromDm && !isDmWithMyself {isDirectFromSaved && !isFromDm && !isDmWithMyself
? this.renderButton( ? this.renderButton(
() => handleIgnore(roomUser._id, !isIgnored, roomFromRid.rid), () => handleIgnore(roomUser._id, !isIgnored, roomFromRid.rid),

View File

@ -10,8 +10,7 @@ import * as HeaderButton from '../../containers/HeaderButton';
import database from '../../lib/database'; import database from '../../lib/database';
import { getUserSelector } from '../../selectors/login'; import { getUserSelector } from '../../selectors/login';
import { events, logEvent } from '../../lib/methods/helpers/log'; import { events, logEvent } from '../../lib/methods/helpers/log';
import { isTeamRoom } from '../../lib/methods/helpers/room'; import { IApplicationState, ISubscription, SubscriptionType, TMessageModel, TSubscriptionModel } from '../../definitions';
import { IApplicationState, SubscriptionType, TMessageModel, TSubscriptionModel } from '../../definitions';
import { ChatsStackParamList } from '../../stacks/types'; import { ChatsStackParamList } from '../../stacks/types';
import { TActionSheetOptionsItem } from '../../containers/ActionSheet'; import { TActionSheetOptionsItem } from '../../containers/ActionSheet';
import i18n from '../../i18n'; import i18n from '../../i18n';
@ -20,12 +19,11 @@ import { onHoldLivechat, returnLivechat } from '../../lib/services/restApi';
import { closeLivechat as closeLivechatService } from '../../lib/methods/helpers/closeLivechat'; import { closeLivechat as closeLivechatService } from '../../lib/methods/helpers/closeLivechat';
import { Services } from '../../lib/services'; import { Services } from '../../lib/services';
import { ILivechatDepartment } from '../../definitions/ILivechatDepartment'; import { ILivechatDepartment } from '../../definitions/ILivechatDepartment';
import HeaderCallButton from './components/HeaderCallButton';
interface IRightButtonsProps { interface IRightButtonsProps extends Pick<ISubscription, 't'> {
userId?: string; userId?: string;
threadsEnabled: boolean; threadsEnabled: boolean;
rid?: string;
t: string;
tmid?: string; tmid?: string;
teamId?: string; teamId?: string;
isMasterDetail: boolean; isMasterDetail: boolean;
@ -43,6 +41,7 @@ interface IRightButtonsProps {
livechatRequestComment: boolean; livechatRequestComment: boolean;
showActionSheet: Function; showActionSheet: Function;
departmentId?: string; departmentId?: string;
rid?: string;
} }
interface IRigthButtonsState { interface IRigthButtonsState {
@ -338,7 +337,7 @@ class RightButtonsContainer extends Component<IRightButtonsProps, IRigthButtonsS
render() { render() {
const { isFollowingThread, tunread, tunreadUser, tunreadGroup } = this.state; const { isFollowingThread, tunread, tunreadUser, tunreadGroup } = this.state;
const { t, tmid, threadsEnabled, teamId, joined } = this.props; const { t, tmid, threadsEnabled, rid } = this.props;
if (t === 'l') { if (t === 'l') {
if (!this.isOmnichannelPreview()) { if (!this.isOmnichannelPreview()) {
@ -363,15 +362,13 @@ class RightButtonsContainer extends Component<IRightButtonsProps, IRigthButtonsS
} }
return ( return (
<HeaderButton.Container> <HeaderButton.Container>
{isTeamRoom({ teamId, joined }) ? ( {rid ? <HeaderCallButton rid={rid} /> : null}
<HeaderButton.Item iconName='channel-public' onPress={this.goTeamChannels} testID='room-view-header-team-channels' />
) : null}
{threadsEnabled ? ( {threadsEnabled ? (
<HeaderButton.Item <HeaderButton.Item
iconName='threads' iconName='threads'
onPress={this.goThreadsView} onPress={this.goThreadsView}
testID='room-view-header-threads' testID='room-view-header-threads'
badge={() => <HeaderButton.Badge tunread={tunread} tunreadUser={tunreadUser} tunreadGroup={tunreadGroup} />} badge={() => <HeaderButton.BadgeUnread tunread={tunread} tunreadUser={tunreadUser} tunreadGroup={tunreadGroup} />}
/> />
) : null} ) : null}
<HeaderButton.Item iconName='search' onPress={this.goSearchView} testID='room-view-search' /> <HeaderButton.Item iconName='search' onPress={this.goSearchView} testID='room-view-search' />

View File

@ -0,0 +1,12 @@
import React from 'react';
import * as HeaderButton from '../../../containers/HeaderButton';
import { useVideoConf } from '../../../lib/hooks/useVideoConf';
export default function HeaderCallButton({ rid }: { rid: string }): React.ReactElement | null {
const { showInitCallActionSheet, showCallOption } = useVideoConf(rid);
if (showCallOption)
return <HeaderButton.Item iconName='phone' onPress={showInitCallActionSheet} testID='room-view-header-call' />;
return null;
}

View File

@ -627,7 +627,7 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
joined={joined} joined={joined}
status={room.status} status={room.status}
omnichannelPermissions={omnichannelPermissions} omnichannelPermissions={omnichannelPermissions}
t={this.t || t} t={(this.t || t) as SubscriptionType}
encrypted={encrypted} encrypted={encrypted}
navigation={navigation} navigation={navigation}
toggleFollowThread={this.toggleFollowThread} toggleFollowThread={this.toggleFollowThread}
@ -785,13 +785,22 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
}); });
}; };
handleCloseEmoji = (action?: Function, params?: any) => {
if (this.messagebox?.current) {
return this.messagebox?.current.closeEmojiAndAction(action, params);
}
if (action) {
return action(params);
}
};
errorActionsShow = (message: TAnyMessageModel) => { errorActionsShow = (message: TAnyMessageModel) => {
this.messagebox?.current?.closeEmojiAndAction(this.messageErrorActions?.showMessageErrorActions, message); this.handleCloseEmoji(this.messageErrorActions?.showMessageErrorActions, message);
}; };
showActionSheet = (options: any) => { showActionSheet = (options: any) => {
const { showActionSheet } = this.props; const { showActionSheet } = this.props;
this.messagebox?.current?.closeEmojiAndAction(showActionSheet, options); this.handleCloseEmoji(showActionSheet, options);
}; };
onEditInit = (message: TAnyMessageModel) => { onEditInit = (message: TAnyMessageModel) => {
@ -850,7 +859,7 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
}; };
onReactionInit = (message: TAnyMessageModel) => { onReactionInit = (message: TAnyMessageModel) => {
this.messagebox?.current?.closeEmojiAndAction(() => { this.handleCloseEmoji(() => {
this.setState({ selectedMessage: message }, this.showReactionPicker); this.setState({ selectedMessage: message }, this.showReactionPicker);
}); });
}; };
@ -865,7 +874,7 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
if (message.tmid && !this.tmid) { if (message.tmid && !this.tmid) {
return; return;
} }
this.messagebox?.current?.closeEmojiAndAction(this.messageActions?.showMessageActions, message); this.handleCloseEmoji(this.messageActions?.showMessageActions, message);
}; };
showAttachment = (attachment: IAttachment) => { showAttachment = (attachment: IAttachment) => {
@ -894,7 +903,7 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
this.setState({ selectedMessage: message }); this.setState({ selectedMessage: message });
const { showActionSheet } = this.props; const { showActionSheet } = this.props;
const { selectedMessage } = this.state; const { selectedMessage } = this.state;
this.messagebox?.current?.closeEmojiAndAction(showActionSheet, { this.handleCloseEmoji(showActionSheet, {
children: <ReactionsList reactions={selectedMessage?.reactions} getCustomEmoji={this.getCustomEmoji} />, children: <ReactionsList reactions={selectedMessage?.reactions} getCustomEmoji={this.getCustomEmoji} />,
snaps: ['50%', '80%'], snaps: ['50%', '80%'],
enableContentPanningGesture: false enableContentPanningGesture: false
@ -1188,6 +1197,11 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
if ('id' in item && item.t === E2E_MESSAGE_TYPE && item.e2e !== E2E_STATUS.DONE) { if ('id' in item && item.t === E2E_MESSAGE_TYPE && item.e2e !== E2E_STATUS.DONE) {
name = I18n.t('Encrypted_message'); name = I18n.t('Encrypted_message');
} }
if (!jumpToMessageId) {
setTimeout(() => {
sendLoadingEvent({ visible: false });
}, 300);
}
return navigation.push('RoomView', { return navigation.push('RoomView', {
rid: this.rid, rid: this.rid,
tmid: item.tmid, tmid: item.tmid,
@ -1220,14 +1234,15 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
}); });
}; };
handleCallJitsi = () => { // OLD METHOD - support versions before 5.0.0
handleEnterCall = () => {
const { room } = this.state; const { room } = this.state;
if ('id' in room) { if ('id' in room) {
const { jitsiTimeout } = room; const { jitsiTimeout } = room;
if (jitsiTimeout && jitsiTimeout < new Date()) { if (jitsiTimeout && jitsiTimeout < new Date()) {
showErrorAlert(I18n.t('Call_already_ended')); showErrorAlert(I18n.t('Call_already_ended'));
} else { } else {
callJitsi(room); callJitsi({ room });
} }
} }
}; };
@ -1373,14 +1388,14 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
autoTranslateLanguage={'id' in room ? room.autoTranslateLanguage : undefined} autoTranslateLanguage={'id' in room ? room.autoTranslateLanguage : undefined}
navToRoomInfo={this.navToRoomInfo} navToRoomInfo={this.navToRoomInfo}
getCustomEmoji={this.getCustomEmoji} getCustomEmoji={this.getCustomEmoji}
callJitsi={this.handleCallJitsi} handleEnterCall={this.handleEnterCall}
blockAction={this.blockAction} blockAction={this.blockAction}
threadBadgeColor={this.getBadgeColor(item?.id)} threadBadgeColor={this.getBadgeColor(item?.id)}
toggleFollowThread={this.toggleFollowThread} toggleFollowThread={this.toggleFollowThread}
jumpToMessage={this.jumpToMessageByUrl} jumpToMessage={this.jumpToMessageByUrl}
highlighted={highlightedMessage === item.id} highlighted={highlightedMessage === item.id}
theme={theme} theme={theme}
closeEmojiAndAction={this.messagebox?.current?.closeEmojiAndAction} closeEmojiAndAction={this.handleCloseEmoji}
/> />
); );
} }

View File

@ -86,6 +86,7 @@ interface IRoomsListViewProps {
StoreLastMessage: boolean; StoreLastMessage: boolean;
useRealName: boolean; useRealName: boolean;
isMasterDetail: boolean; isMasterDetail: boolean;
notificationPresenceCap: boolean;
subscribedRoom: string; subscribedRoom: string;
width: number; width: number;
insets: { insets: {
@ -146,6 +147,7 @@ const shouldUpdateProps = [
'StoreLastMessage', 'StoreLastMessage',
'theme', 'theme',
'isMasterDetail', 'isMasterDetail',
'notificationPresenceCap',
'refreshing', 'refreshing',
'queueSize', 'queueSize',
'inquiryEnabled', 'inquiryEnabled',
@ -255,21 +257,18 @@ class RoomsListView extends React.Component<IRoomsListViewProps, IRoomsListViewS
shouldComponentUpdate(nextProps: IRoomsListViewProps, nextState: IRoomsListViewState) { shouldComponentUpdate(nextProps: IRoomsListViewProps, nextState: IRoomsListViewState) {
const { chatsUpdate, searching, item, canCreateRoom, omnichannelsUpdate } = this.state; const { chatsUpdate, searching, item, canCreateRoom, omnichannelsUpdate } = this.state;
// eslint-disable-next-line react/destructuring-assignment
const propsUpdated = shouldUpdateProps.some(key => nextProps[key] !== this.props[key]); const propsUpdated = shouldUpdateProps.some(key => nextProps[key] !== this.props[key]);
if (propsUpdated) { if (propsUpdated) {
return true; return true;
} }
// check if some display props are changed to force update when focus this view again // check if some display props are changed to force update when focus this view again
// eslint-disable-next-line react/destructuring-assignment
const displayUpdated = displayPropsShouldUpdate.some(key => nextProps[key] !== this.props[key]); const displayUpdated = displayPropsShouldUpdate.some(key => nextProps[key] !== this.props[key]);
if (displayUpdated) { if (displayUpdated) {
this.shouldUpdate = true; this.shouldUpdate = true;
} }
// check if some sort preferences are changed to getSubscription() when focus this view again // check if some sort preferences are changed to getSubscription() when focus this view again
// eslint-disable-next-line react/destructuring-assignment
const sortPreferencesUpdate = sortPreferencesShouldUpdate.some(key => nextProps[key] !== this.props[key]); const sortPreferencesUpdate = sortPreferencesShouldUpdate.some(key => nextProps[key] !== this.props[key]);
if (sortPreferencesUpdate) { if (sortPreferencesUpdate) {
this.sortPreferencesChanged = true; this.sortPreferencesChanged = true;
@ -339,6 +338,7 @@ class RoomsListView extends React.Component<IRoomsListViewProps, IRoomsListViewS
showUnread, showUnread,
subscribedRoom, subscribedRoom,
isMasterDetail, isMasterDetail,
notificationPresenceCap,
insets, insets,
createTeamPermission, createTeamPermission,
createPublicChannelPermission, createPublicChannelPermission,
@ -366,7 +366,11 @@ class RoomsListView extends React.Component<IRoomsListViewProps, IRoomsListViewS
if (isMasterDetail && item?.rid !== subscribedRoom && subscribedRoom !== prevProps.subscribedRoom) { if (isMasterDetail && item?.rid !== subscribedRoom && subscribedRoom !== prevProps.subscribedRoom) {
this.setState({ item: { rid: subscribedRoom } as ISubscription }); this.setState({ item: { rid: subscribedRoom } as ISubscription });
} }
if (insets.left !== prevProps.insets.left || insets.right !== prevProps.insets.right) { if (
insets.left !== prevProps.insets.left ||
insets.right !== prevProps.insets.right ||
notificationPresenceCap !== prevProps.notificationPresenceCap
) {
this.setHeader(); this.setHeader();
} }
@ -421,7 +425,7 @@ class RoomsListView extends React.Component<IRoomsListViewProps, IRoomsListViewS
getHeader = (): StackNavigationOptions => { getHeader = (): StackNavigationOptions => {
const { searching, canCreateRoom } = this.state; const { searching, canCreateRoom } = this.state;
const { navigation, isMasterDetail } = this.props; const { navigation, isMasterDetail, notificationPresenceCap } = this.props;
if (searching) { if (searching) {
return { return {
headerTitleAlign: 'left', headerTitleAlign: 'left',
@ -451,6 +455,7 @@ class RoomsListView extends React.Component<IRoomsListViewProps, IRoomsListViewS
: // @ts-ignore : // @ts-ignore
() => navigation.toggleDrawer() () => navigation.toggleDrawer()
} }
badge={() => (notificationPresenceCap ? <HeaderButton.BadgeWarn /> : null)}
/> />
), ),
headerTitle: () => <RoomsListHeaderView />, headerTitle: () => <RoomsListHeaderView />,
@ -1034,6 +1039,7 @@ class RoomsListView extends React.Component<IRoomsListViewProps, IRoomsListViewS
const mapStateToProps = (state: IApplicationState) => ({ const mapStateToProps = (state: IApplicationState) => ({
user: getUserSelector(state), user: getUserSelector(state),
isMasterDetail: state.app.isMasterDetail, isMasterDetail: state.app.isMasterDetail,
notificationPresenceCap: state.app.notificationPresenceCap,
server: state.server.server, server: state.server.server,
changingServer: state.server.changingServer, changingServer: state.server.changingServer,
searchText: state.rooms.searchText, searchText: state.rooms.searchText,

View File

@ -250,7 +250,8 @@ class ShareListView extends React.Component<IShareListViewProps, IState> {
prid: item.prid, prid: item.prid,
uids: item.uids, uids: item.uids,
usernames: item.usernames, usernames: item.usernames,
topic: item.topic topic: item.topic,
teamMain: item.teamMain
})); }));
}; };
@ -380,6 +381,7 @@ class ShareListView extends React.Component<IShareListViewProps, IState> {
type={item.prid ? 'discussion' : item.t} type={item.prid ? 'discussion' : item.t}
onPress={() => this.shareMessage(item)} onPress={() => this.shareMessage(item)}
testID={`share-extension-item-${item.name}`} testID={`share-extension-item-${item.name}`}
teamMain={item.teamMain}
/> />
); );
}; };

View File

@ -1,17 +1,19 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { DrawerNavigationProp } from '@react-navigation/drawer'; import { DrawerNavigationProp } from '@react-navigation/drawer';
import { DrawerNavigationState } from '@react-navigation/native'; import { DrawerNavigationState } from '@react-navigation/native';
import { ScrollView, Text, TouchableWithoutFeedback, View } from 'react-native'; import { Alert, ScrollView, Text, TouchableWithoutFeedback, View, Linking } from 'react-native';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { dequal } from 'dequal'; import { dequal } from 'dequal';
import { Dispatch } from 'redux';
import Avatar from '../../containers/Avatar'; import Avatar from '../../containers/Avatar';
import Status from '../../containers/Status/Status'; import Status from '../../containers/Status/Status';
import { events, logEvent } from '../../lib/methods/helpers/log'; import { events, logEvent } from '../../lib/methods/helpers/log';
import I18n from '../../i18n'; import I18n from '../../i18n';
import scrollPersistTaps from '../../lib/methods/helpers/scrollPersistTaps'; import scrollPersistTaps from '../../lib/methods/helpers/scrollPersistTaps';
import userPreferences from '../../lib/methods/userPreferences';
import { CustomIcon } from '../../containers/CustomIcon'; import { CustomIcon } from '../../containers/CustomIcon';
import { themes } from '../../lib/constants'; import { NOTIFICATION_PRESENCE_CAP, STATUS_COLORS, themes } from '../../lib/constants';
import { TSupportedThemes, withTheme } from '../../theme'; import { TSupportedThemes, withTheme } from '../../theme';
import { getUserSelector } from '../../selectors/login'; import { getUserSelector } from '../../selectors/login';
import SafeAreaView from '../../containers/SafeAreaView'; import SafeAreaView from '../../containers/SafeAreaView';
@ -21,6 +23,7 @@ import styles from './styles';
import { DrawerParamList } from '../../stacks/types'; import { DrawerParamList } from '../../stacks/types';
import { IApplicationState, IUser } from '../../definitions'; import { IApplicationState, IUser } from '../../definitions';
import * as List from '../../containers/List'; import * as List from '../../containers/List';
import { setNotificationPresenceCap } from '../../actions/app';
interface ISidebarState { interface ISidebarState {
showStatus: boolean; showStatus: boolean;
@ -29,6 +32,7 @@ interface ISidebarState {
interface ISidebarProps { interface ISidebarProps {
baseUrl: string; baseUrl: string;
navigation?: DrawerNavigationProp<DrawerParamList>; navigation?: DrawerNavigationProp<DrawerParamList>;
dispatch: Dispatch;
state?: DrawerNavigationState<DrawerParamList>; state?: DrawerNavigationState<DrawerParamList>;
Site_Name: string; Site_Name: string;
user: IUser; user: IUser;
@ -36,6 +40,8 @@ interface ISidebarProps {
loadingServer: boolean; loadingServer: boolean;
useRealName: boolean; useRealName: boolean;
allowStatusMessage: boolean; allowStatusMessage: boolean;
notificationPresenceCap: boolean;
Presence_broadcast_disabled: boolean;
isMasterDetail: boolean; isMasterDetail: boolean;
viewStatisticsPermission: string[]; viewStatisticsPermission: string[];
viewRoomAdministrationPermission: string[]; viewRoomAdministrationPermission: string[];
@ -59,8 +65,10 @@ class Sidebar extends Component<ISidebarProps, ISidebarState> {
baseUrl, baseUrl,
state, state,
isMasterDetail, isMasterDetail,
notificationPresenceCap,
useRealName, useRealName,
theme, theme,
Presence_broadcast_disabled,
viewStatisticsPermission, viewStatisticsPermission,
viewRoomAdministrationPermission, viewRoomAdministrationPermission,
viewUserAdministrationPermission, viewUserAdministrationPermission,
@ -91,9 +99,15 @@ class Sidebar extends Component<ISidebarProps, ISidebarState> {
if (nextProps.isMasterDetail !== isMasterDetail) { if (nextProps.isMasterDetail !== isMasterDetail) {
return true; return true;
} }
if (nextProps.notificationPresenceCap !== notificationPresenceCap) {
return true;
}
if (nextProps.useRealName !== useRealName) { if (nextProps.useRealName !== useRealName) {
return true; return true;
} }
if (nextProps.Presence_broadcast_disabled !== Presence_broadcast_disabled) {
return true;
}
if (!dequal(nextProps.viewStatisticsPermission, viewStatisticsPermission)) { if (!dequal(nextProps.viewStatisticsPermission, viewStatisticsPermission)) {
return true; return true;
} }
@ -156,6 +170,33 @@ class Sidebar extends Component<ISidebarProps, ISidebarState> {
navigation?.closeDrawer(); navigation?.closeDrawer();
}; };
onPressLearnMorePresenceCap = () => {
Linking.openURL('https://go.rocket.chat/i/presence-cap-learn-more');
};
onPressPresenceLearnMore = () => {
const { dispatch } = this.props;
dispatch(setNotificationPresenceCap(false));
userPreferences.setBool(NOTIFICATION_PRESENCE_CAP, false);
Alert.alert(
I18n.t('Presence_Cap_Warning_Title'),
I18n.t('Presence_Cap_Warning_Description'),
[
{
text: I18n.t('Learn_more'),
onPress: this.onPressLearnMorePresenceCap,
style: 'cancel'
},
{
text: I18n.t('Close'),
style: 'default'
}
],
{ cancelable: false }
);
};
renderAdmin = () => { renderAdmin = () => {
const { theme, isMasterDetail } = this.props; const { theme, isMasterDetail } = this.props;
if (!this.getIsAdmin()) { if (!this.getIsAdmin()) {
@ -219,14 +260,27 @@ class Sidebar extends Component<ISidebarProps, ISidebarState> {
}; };
renderCustomStatus = () => { renderCustomStatus = () => {
const { user, theme } = this.props; const { user, theme, Presence_broadcast_disabled, notificationPresenceCap } = this.props;
let status = user?.status;
if (Presence_broadcast_disabled) {
status = 'disabled';
}
let right: React.ReactElement | undefined = <CustomIcon name='edit' size={20} color={themes[theme!].titleText} />;
if (notificationPresenceCap) {
right = <View style={[styles.customStatusDisabled, { backgroundColor: STATUS_COLORS.disabled }]} />;
} else if (Presence_broadcast_disabled) {
right = undefined;
}
return ( return (
<SidebarItem <SidebarItem
text={user.statusText || I18n.t('Edit_Status')} text={user.statusText || I18n.t('Edit_Status')}
left={<Status size={24} status={user?.status} />} left={<Status size={24} status={status} />}
theme={theme!} theme={theme!}
right={<CustomIcon name='edit' size={20} color={themes[theme!].titleText} />} right={right}
onPress={() => this.sidebarNavigate('StatusView')} onPress={() => (Presence_broadcast_disabled ? this.onPressPresenceLearnMore() : this.sidebarNavigate('StatusView'))}
testID={`sidebar-custom-status-${user.status}`} testID={`sidebar-custom-status-${user.status}`}
/> />
); );
@ -294,6 +348,8 @@ const mapStateToProps = (state: IApplicationState) => ({
loadingServer: state.server.loading, loadingServer: state.server.loading,
useRealName: state.settings.UI_Use_Real_Name as boolean, useRealName: state.settings.UI_Use_Real_Name as boolean,
allowStatusMessage: state.settings.Accounts_AllowUserStatusMessageChange as boolean, allowStatusMessage: state.settings.Accounts_AllowUserStatusMessageChange as boolean,
Presence_broadcast_disabled: state.settings.Presence_broadcast_disabled as boolean,
notificationPresenceCap: state.app.notificationPresenceCap,
isMasterDetail: state.app.isMasterDetail, isMasterDetail: state.app.isMasterDetail,
viewStatisticsPermission: state.permissions['view-statistics'] as string[], viewStatisticsPermission: state.permissions['view-statistics'] as string[],
viewRoomAdministrationPermission: state.permissions['view-room-administration'] as string[], viewRoomAdministrationPermission: state.permissions['view-room-administration'] as string[],

View File

@ -63,5 +63,10 @@ export default StyleSheet.create({
}, },
inverted: { inverted: {
transform: [{ scaleY: -1 }] transform: [{ scaleY: -1 }]
},
customStatusDisabled: {
width: 10,
height: 10,
borderRadius: 5
} }
}); });

View File

@ -1,16 +1,11 @@
import React from 'react'; import React from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, View } from 'react-native';
import { createImageProgress } from 'react-native-image-progress';
import * as Progress from 'react-native-progress';
import FastImage from 'react-native-fast-image'; import FastImage from 'react-native-fast-image';
import sharedStyles from '../Styles';
import { themes } from '../../lib/constants'; import { themes } from '../../lib/constants';
import { isTablet } from '../../lib/methods/helpers'; import { isTablet } from '../../lib/methods/helpers';
import { TSupportedThemes } from '../../theme'; import { TSupportedThemes } from '../../theme';
const ImageProgress = createImageProgress(FastImage);
const SIZE = 96; const SIZE = 96;
const MARGIN_TOP = isTablet ? 0 : 64; const MARGIN_TOP = isTablet ? 0 : 64;
const BORDER_RADIUS = 8; const BORDER_RADIUS = 8;
@ -27,50 +22,20 @@ const styles = StyleSheet.create({
width: SIZE, width: SIZE,
height: SIZE, height: SIZE,
borderRadius: BORDER_RADIUS borderRadius: BORDER_RADIUS
},
fallback: {
width: SIZE,
height: SIZE,
borderRadius: BORDER_RADIUS,
alignItems: 'center',
justifyContent: 'center'
},
initial: {
...sharedStyles.textBold,
fontSize: 42
} }
}); });
const getInitial = (url: string) => url && url.replace(/http(s?):\/\//, '').slice(0, 1);
interface IFallback {
theme: TSupportedThemes;
initial: string;
}
const Fallback = ({ theme, initial }: IFallback) => (
<View style={[styles.container, styles.fallback, { backgroundColor: themes[theme].dangerColor }]}>
<Text style={[styles.initial, { color: themes[theme].buttonText }]}>{initial}</Text>
</View>
);
interface IServerAvatar { interface IServerAvatar {
theme: TSupportedThemes; theme: TSupportedThemes;
url: string; url: string;
image: string; image: string;
} }
// TODO: missing skeleton
const ServerAvatar = React.memo(({ theme, url, image }: IServerAvatar) => ( const ServerAvatar = React.memo(({ theme, url, image }: IServerAvatar) => (
<View style={styles.container}> <View style={styles.container}>
{image && ( {image && (
<ImageProgress <FastImage style={[styles.image, { borderColor: themes[theme].borderColor }]} source={{ uri: `${url}/${image}` }} />
style={[styles.image, { borderColor: themes[theme].borderColor }]}
source={{ uri: `${url}/${image}` }}
resizeMode={FastImage.resizeMode.cover}
indicator={Progress.Pie}
indicatorProps={{
color: themes[theme].actionTintColor
}}
renderError={() => <Fallback theme={theme} initial={getInitial(url)} />}
/>
)} )}
</View> </View>
)); ));

View File

@ -3,7 +3,8 @@ module.exports = {
plugins: [ plugins: [
['@babel/plugin-proposal-decorators', { legacy: true }], ['@babel/plugin-proposal-decorators', { legacy: true }],
'react-native-reanimated/plugin', 'react-native-reanimated/plugin',
'@babel/plugin-transform-named-capturing-groups-regex' '@babel/plugin-transform-named-capturing-groups-regex',
['module:react-native-dotenv']
], ],
env: { env: {
production: { production: {

View File

@ -1,8 +0,0 @@
{
"timeout": 300000,
"recursive": true,
"bail": true,
"require": ["ts-node/register"],
"file": "e2e/tests/init.ts",
"extension": ["ts"]
}

View File

@ -1,84 +1,69 @@
# E2E Testing ## Overview and folder structure
## Contents WIP: End-to-end tests are a work in progress and they're going to change.
1. [Prepare test environment](##-1.-Prepare-test-environment) ```
2. [Prepare test data](##-2.-Prepare-test-data) |-- e2e
3. [Running tests](##-3.-Running-tests) |-- helpers
4. [FAQ](##-FAQ) |-- tests
|-- data.ts
|-- e2e_account.ts
```
### 1. Prepare test environment - `e2e/helpers`
- This folder contains a few functions to setup and help write tests.
- `e2e/tests`
- This folder contains the actual test files
- It's currently split into `assorted`, `onboarding`, `room`, and `team` folders
- There's not a clear convention on where a test should be placed yet, but the folders above exist to try to separate them into features
- Keep every test file truly idempotent
- Each file can only impact on the tests written inside of it
- They should not impact on other files, so pay attention on the data you use
- `data.ts`
- Contains seeds to common test data, like server url, public channels, etc
- Currently we point to https://mobile.rocket.chat as main server
- Pointing to a local server is not recommended yet, as you would need to create a few public channels and change some permissions
- Ideally we should point to a docker or even a mocked server, but that's tbd
- Try not to add new data there. Use random values instead.
- It's hard to keep track of where each value is used
- `e2e_account.ts`
- Contains user and password with correct permissions on main server
- Check `e2e_account.example.ts` for structure
- It needs to be added manually on local (it's already set on CI)
- Ask Diego Mello for credentials
#### 1.1. A Rocket.Chat server ## Shared config
- Change `.env` to `RUNNING_E2E_TESTS=true`
- You can also `RUNNING_E2E_TESTS=true yarn start reset-cache`, but it's easier to change the file as long as you don't commit it
Either ## Setup and run iOS
* Install Rocket.Chat meteor app by following this [guide](https://developer.rocket.chat/rocket.chat/rocket-chat-environment-setup). - Install applesimutils
```
brew tap wix/brew
brew install applesimutils
```
Or ### Run on debug mode
- Build the app with `yarn e2e:ios-build-debug`
- Test the app with `yarn e2e:ios-test-debug`
* Use the local Docker environment available in this folder. You can start the environment using `./e2e/docker/controlRCDemoEnv.sh startandwait`, or you can use the packaged start & run script (see step 3). Either way, you'll need [Docker](https://docs.docker.com/engine/install/) and [Docker Compose](https://docs.docker.com/compose/install/). ### Run on release mode
- Build the app with `yarn e2e:ios-build`
- Test the app with `yarn e2e:ios-test`
#### 1.2. Set up detox ## Setup and run Android
* Install dependencies by following this [guide](https://github.com/wix/Detox/blob/master/docs/Introduction.GettingStarted.md#step-1-install-dependencies) (only Step 1). - Create AVD
- It's important to create the same emulator as on CI. Read more: https://wix.github.io/Detox/docs/guide/android-dev-env
```
sh ./scripts/create-avd.sh
```
### 2. Prepare test data ### Run on debug mode
- Build the app with `yarn e2e:android-build-debug`
- Test the app with `yarn e2e:android-test-debug`
* If you're running your own Rocket.Chat server, ensure it's started (e.g. `meteor npm start` in the server project directory). ### Run on release mode
* Edit `e2e/data.ts`: - Build the app with `yarn e2e:android-build`
* Set the `server` to the address of the server under test - Test the app with `yarn e2e:android-test`
* Create a file called `e2e_account.ts`, in the same folder as `data.ts`. Set the `adminUser` and `adminPassword` to an admin user on that environment (or a user with at least `create-user` and `create-c` permissions). The example of how to create this file is on `e2e/e2e_account.example.ts`
* Working example configs exist in `./e2e/data/`. Setting `FORCE_DEFAULT_DOCKER_DATA` to `1` in the `runTestsInDocker.sh` script will use the example config automatically
### 3. Running tests
#### 3.1. iOS
* Build app with detox: `detox build -c ios.sim.release`
* Open Simulator which is used in tests (check in package.json under detox section) from Xcode and make sure that software keyboard is being displayed. To toggle keyboard press `cmd+K`.
* Run tests: `detox test -c ios.sim.release`, or, if choosing Docker you can run the packaged environment & runner (`./e2e/docker/runTestsInDocker.sh`) which will start the Docker infrastructure, run the tests and tear it down again once done.
#### 3.2. Android
* Build app with detox: `detox build -c android.emu.debug`
* Run: `react-native start`
* Run Android emulator with name `ANDROID_API_28` via Android studio or `cd /Users/USERNAME/Library/Android/sdk/emulator/ && ./emulator -avd ANDROID_API_28`
Note: if you need to run tests on different Android emulator then simply change emulator name in ./package.json detox configurations
* Run tests: `detox test -c android.emu.debug`
#### 3.3 Running a subset of tests
Tests have been grouped into subfolders. You can choose to run just one group of tests by running, for example:
`detox test ./e2e/tests/onboarding -c ios.sim.release`
To do the same with the Docker runner:
`./e2e/docker/runTestsInDocker.sh onboarding`
### 4. FAQ
#### 4.1. Detox build fails
* Delete `node_modules`, `ios/build`, `android/build`:
`rm -rf node_modules && rm -rf ios/build && rm -rf android/build`
* Install packages: `yarn install`
* Kill metro bundler server by closing terminal or with following command: `lsof -ti:8081 | xargs kill`
* Clear metro bundler cache: `watchman watch-del-all && rm -rf $TMPDIR/react-native-packager-cache-* && rm -rf $TMPDIR/metro-bundler-cache-*`
* Make sure you have all required [environment](##-1.-Prepare-test-environment).
* Now try building again with `detox build` (with specific configuration).
#### 4.2. Detox iOS test run fails
* Check if your meteor app is running by opening `localhost:3000` in browser.
* Make sure software keyboard is displayed in simulator when focusing some input. To enable keyboard press `cmd+K`.
* Make sure you have prepared all [test data](##-2.-Prepare-test-data).
* Sometimes detox e2e tests fail for no reason so all you can do is simply re-run again.
### 5. Todo
* TOTP test
* Push notifications
* Deep linking
* Intermittent connectivity

View File

@ -1,7 +1,7 @@
/* eslint-disable import/extensions, import/no-unresolved */
import random from './helpers/random';
// @ts-ignore // @ts-ignore
// eslint-disable-next-line import/no-unresolved, import/extensions
import account from './e2e_account'; import account from './e2e_account';
import random from './helpers/random';
export interface IUser { export interface IUser {
username: string; username: string;
@ -11,40 +11,12 @@ export interface IUser {
export type TData = typeof data; export type TData = typeof data;
export type TDataKeys = keyof TData; export type TDataKeys = keyof TData;
export type TDataUsers = keyof typeof data.users;
export type TDataChannels = keyof typeof data.channels; export type TDataChannels = keyof typeof data.channels;
export type TUserRegularChannels = keyof typeof data.userRegularChannels;
export type TDataGroups = keyof typeof data.groups;
export type TDataTeams = keyof typeof data.teams;
const value: string = random(20);
const data = { const data = {
server: 'https://mobile.rocket.chat', server: 'https://mobile.rocket.chat',
...account,
alternateServer: 'https://stable.rocket.chat', alternateServer: 'https://stable.rocket.chat',
users: { ...account,
regular: {
username: `userone${value}`,
password: '123',
email: `mobile+regular${value}@rocket.chat`
},
alternate: {
username: `usertwo${value}`,
password: '123',
email: `mobile+alternate${value}@rocket.chat`,
totpSecret: 'NA4GOMZGHBQSK6KEFRVT62DMGJJGSYZJFZIHO3ZOGVXWCYZ6MMZQ'
},
profileChanges: {
username: `userthree${value}`,
password: '123',
email: `mobile+profileChanges${value}@rocket.chat`
},
existing: {
username: `existinguser${value}`,
password: '123',
email: `mobile+existing${value}@rocket.chat`
}
},
channels: { channels: {
detoxpublic: { detoxpublic: {
name: 'detox-public' name: 'detox-public'
@ -54,48 +26,15 @@ const data = {
joinCode: '123' joinCode: '123'
} }
}, },
userRegularChannels: { randomUser: (): { username: string; name: string; password: string; email: string } => {
detoxpublic: { const randomVal = random();
name: `detox-public-${value}` return {
} username: `user${randomVal}`,
}, name: `user${randomVal}`, // FIXME: apply a different name
groups: { password: `password${randomVal}`,
private: { email: `mobile+${randomVal}@rocket.chat`
name: `detox-private-${value}` };
}, }
alternate: {
name: `detox-alternate-${value}`
},
alternate2: {
name: `detox-alternate2-${value}`
}
},
teams: {
private: {
name: `detox-team-${value}`
}
},
registeringUser: {
username: `newuser${value}`,
password: `password${value}`,
email: `mobile+registering${value}@rocket.chat`
},
registeringUser2: {
username: `newusertwo${value}`,
password: `passwordtwo${value}`,
email: `mobile+registeringtwo${value}@rocket.chat`
},
registeringUser3: {
username: `newuserthree${value}`,
password: `passwordthree${value}`,
email: `mobile+registeringthree${value}@rocket.chat`
},
registeringUser4: {
username: `newuserfour${value}`,
password: `passwordfour${value}`,
email: `mobile+registeringfour${value}@rocket.chat`
},
random: value
}; };
export default data; export default data;

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