Merge 4.37.0 into master (#5025)

This commit is contained in:
Diego Mello 2023-04-12 10:46:52 -03:00 committed by GitHub
commit acdfc6ddd9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
199 changed files with 4389 additions and 5805 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: |
export RUNNING_E2E_TESTS=true
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
export RUNNING_E2E_TESTS=true
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'
}
}
};

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

View File

@ -18,7 +18,7 @@
<img alt="Download on App Store" src="https://user-images.githubusercontent.com/7317008/43209852-4ca39622-904b-11e8-8ce1-cdc3aee76ae9.png" height=43> <img alt="Download on App Store" src="https://user-images.githubusercontent.com/7317008/43209852-4ca39622-904b-11e8-8ce1-cdc3aee76ae9.png" height=43>
</a> </a>
Check [our docs](https://docs.rocket.chat/installation/mobile-and-desktop-apps#mobile-apps) for beta and Experimental versions. Check [our docs](https://docs.rocket.chat/use-rocket.chat/rocket.chat-mobile) for beta and Experimental versions.
## Reporting an Issue ## Reporting an Issue

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

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.36.0" 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

@ -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

@ -1,13 +1,11 @@
import { Q } from '@nozbe/watermelondb'; import React from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { shallowEqual, useSelector } from 'react-redux'; import { shallowEqual, useSelector } from 'react-redux';
import { Observable, Subscription } from 'rxjs';
import { IApplicationState, TSubscriptionModel, TUserModel } from '../../definitions'; import { IApplicationState } from '../../definitions';
import database from '../../lib/database';
import { getUserSelector } from '../../selectors/login'; import { getUserSelector } from '../../selectors/login';
import Avatar from './Avatar'; import Avatar from './Avatar';
import { IAvatar } from './interfaces'; import { IAvatar } from './interfaces';
import { useAvatarETag } from './useAvatarETag';
const AvatarContainer = ({ const AvatarContainer = ({
style, style,
@ -23,17 +21,13 @@ const AvatarContainer = ({
isStatic, isStatic,
rid rid
}: IAvatar): React.ReactElement => { }: IAvatar): React.ReactElement => {
const subscription = useRef<Subscription>();
const [avatarETag, setAvatarETag] = useState<string | undefined>('');
const isDirect = () => type === 'd';
const server = useSelector((state: IApplicationState) => state.share.server.server || state.server.server); const server = useSelector((state: IApplicationState) => state.share.server.server || state.server.server);
const serverVersion = useSelector((state: IApplicationState) => state.share.server.version || state.server.version); const serverVersion = useSelector((state: IApplicationState) => state.share.server.version || state.server.version);
const { id, token } = useSelector( const { id, token, username } = useSelector(
(state: IApplicationState) => ({ (state: IApplicationState) => ({
id: getUserSelector(state).id, id: getUserSelector(state).id,
token: getUserSelector(state).token token: getUserSelector(state).token,
username: getUserSelector(state).username
}), }),
shallowEqual shallowEqual
); );
@ -48,41 +42,7 @@ const AvatarContainer = ({
true true
); );
const init = async () => { const { avatarETag } = useAvatarETag({ username, text, type, rid, id });
const db = database.active;
const usersCollection = db.get('users');
const subsCollection = db.get('subscriptions');
let record;
try {
if (isDirect()) {
const [user] = await usersCollection.query(Q.where('username', text)).fetch();
record = user;
} else if (rid) {
record = await subsCollection.find(rid);
}
} catch {
// Record not found
}
if (record) {
const observable = record.observe() as Observable<TSubscriptionModel | TUserModel>;
subscription.current = observable.subscribe(r => {
setAvatarETag(r.avatarETag);
});
}
};
useEffect(() => {
if (!avatarETag) {
init();
}
return () => {
if (subscription?.current?.unsubscribe) {
subscription.current.unsubscribe();
}
};
}, [text, type, size, avatarETag, externalProviderUrl]);
return ( return (
<Avatar <Avatar

View File

@ -0,0 +1,85 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import Button from '../Button';
import AvatarContainer from './AvatarContainer';
import { IAvatar } from './interfaces';
import I18n from '../../i18n';
import { useTheme } from '../../theme';
import { BUTTON_HIT_SLOP } from '../message/utils';
import { useAppSelector } from '../../lib/hooks';
import { compareServerVersion } from '../../lib/methods/helpers';
import sharedStyles from '../../views/Styles';
const styles = StyleSheet.create({
editAvatarButton: {
marginTop: 8,
paddingVertical: 8,
paddingHorizontal: 12,
marginBottom: 0,
height: undefined
},
textButton: {
fontSize: 12,
...sharedStyles.textSemibold
}
});
interface IAvatarContainer extends Omit<IAvatar, 'size'> {
handleEdit?: () => void;
}
const AvatarWithEdit = ({
style,
text = '',
avatar,
emoji,
borderRadius,
type,
children,
onPress,
getCustomEmoji,
isStatic,
rid,
handleEdit
}: IAvatarContainer): React.ReactElement => {
const { colors } = useTheme();
const { serverVersion } = useAppSelector(state => ({
serverVersion: state.server.version
}));
return (
<>
<AvatarContainer
style={style}
text={text}
avatar={avatar}
emoji={emoji}
size={120}
borderRadius={borderRadius}
type={type}
children={children}
onPress={onPress}
getCustomEmoji={getCustomEmoji}
isStatic={isStatic}
rid={rid}
/>
{handleEdit && serverVersion && compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '3.6.0') ? (
<Button
title={I18n.t('Edit')}
type='secondary'
backgroundColor={colors.editAndUploadButtonAvatar}
onPress={handleEdit}
testID='avatar-edit-button'
style={styles.editAvatarButton}
styleText={styles.textButton}
color={colors.titleText}
hitSlop={BUTTON_HIT_SLOP}
/>
) : null}
</>
);
};
export default AvatarWithEdit;

View File

@ -0,0 +1,5 @@
import Avatar from './AvatarContainer';
export { default as AvatarWithEdit } from './AvatarWithEdit';
export default Avatar;

View File

@ -0,0 +1,67 @@
import { Q } from '@nozbe/watermelondb';
import { useEffect, useState } from 'react';
import { Observable, Subscription } from 'rxjs';
import { TLoggedUserModel, TSubscriptionModel, TUserModel } from '../../definitions';
import database from '../../lib/database';
export const useAvatarETag = ({
username,
text,
type = '',
rid,
id
}: {
type?: string;
username: string;
text: string;
rid?: string;
id: string;
}) => {
const [avatarETag, setAvatarETag] = useState<string | undefined>('');
const isDirect = () => type === 'd';
useEffect(() => {
let subscription: Subscription;
if (!avatarETag) {
const observeAvatarETag = async () => {
const db = database.active;
const usersCollection = db.get('users');
const subsCollection = db.get('subscriptions');
let record;
try {
if (username === text) {
const serversDB = database.servers;
const userCollections = serversDB.get('users');
const user = await userCollections.find(id);
record = user;
} else if (isDirect()) {
const [user] = await usersCollection.query(Q.where('username', text)).fetch();
record = user;
} else if (rid) {
record = await subsCollection.find(rid);
}
} catch {
// Record not found
}
if (record) {
const observable = record.observe() as Observable<TSubscriptionModel | TUserModel | TLoggedUserModel>;
subscription = observable.subscribe(r => {
setAvatarETag(r.avatarETag);
});
}
};
observeAvatarETag();
return () => {
if (subscription?.unsubscribe) {
subscription.unsubscribe();
}
};
}
}, [text]);
return { avatarETag };
};

View File

@ -14,7 +14,7 @@ interface IButtonProps extends PlatformTouchableProps {
loading?: boolean; loading?: boolean;
color?: string; color?: string;
fontSize?: number; fontSize?: number;
styleText?: StyleProp<TextStyle>[]; styleText?: StyleProp<TextStyle> | StyleProp<TextStyle>[];
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({

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

@ -6,7 +6,7 @@ import scrollPersistTaps from '../lib/methods/helpers/scrollPersistTaps';
interface IKeyboardViewProps extends KeyboardAwareScrollViewProps { interface IKeyboardViewProps extends KeyboardAwareScrollViewProps {
keyboardVerticalOffset?: number; keyboardVerticalOffset?: number;
scrollEnabled?: boolean; scrollEnabled?: boolean;
children: React.ReactElement[] | React.ReactElement; children: React.ReactElement[] | React.ReactElement | null | (React.ReactElement | null)[];
} }
const KeyboardView = ({ style, contentContainerStyle, scrollEnabled, keyboardVerticalOffset, children }: IKeyboardViewProps) => ( const KeyboardView = ({ style, contentContainerStyle, scrollEnabled, keyboardVerticalOffset, children }: IKeyboardViewProps) => (

View File

@ -487,7 +487,9 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
this.handleTyping(!isTextEmpty); this.handleTyping(!isTextEmpty);
const { start, end } = this.selection; const { start, end } = this.selection;
const cursor = Math.max(start, end); const cursor = Math.max(start, end);
const txt = cursor < text.length ? text.substr(0, cursor).split(' ') : text.split(' '); const whiteSpaceOrBreakLineRegex = /[\s\n]+/;
const txt =
cursor < text.length ? text.substr(0, cursor).split(whiteSpaceOrBreakLineRegex) : text.split(whiteSpaceOrBreakLineRegex);
const lastWord = txt[txt.length - 1]; const lastWord = txt[txt.length - 1];
const result = lastWord.substring(1); const result = lastWord.substring(1);

View File

@ -24,8 +24,16 @@ const formatMsg = ({ lastMessage, type, showLastMessage, username, useRealName }
const isLastMessageSentByMe = lastMessage.u.username === username; const isLastMessageSentByMe = lastMessage.u.username === username;
if (!lastMessage.msg && lastMessage.attachments && Object.keys(lastMessage.attachments).length) { if (!lastMessage.msg && lastMessage.attachments && Object.keys(lastMessage.attachments).length) {
const user = isLastMessageSentByMe ? I18n.t('You') : lastMessage.u.username; const userAttachment = () => {
return I18n.t('User_sent_an_attachment', { user }); if (isLastMessageSentByMe) {
return I18n.t('You');
}
if (useRealName && lastMessage.u.name) {
return lastMessage.u.name;
}
return lastMessage.u.username;
};
return I18n.t('User_sent_an_attachment', { user: userAttachment() });
} }
// Encrypted message pending decrypt // Encrypted message pending decrypt

View File

@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { fireEvent, render } from '@testing-library/react-native'; import { fireEvent, render } from '@testing-library/react-native';
import { TextInputProps } from 'react-native';
import SearchBox from '.'; import SearchBox from '.';
@ -11,35 +10,35 @@ const testSearchInputs = {
testID: 'search-box-text-input' testID: 'search-box-text-input'
}; };
const Render = ({ onChangeText, testID }: TextInputProps) => <SearchBox testID={testID} onChangeText={onChangeText} />;
describe('SearchBox', () => { describe('SearchBox', () => {
it('should render the searchbox component', () => { it('should render the searchbox component', () => {
const { findByTestId } = render(<Render onChangeText={testSearchInputs.onChangeText} testID={testSearchInputs.testID} />); const { findByTestId } = render(<SearchBox onChangeText={testSearchInputs.onChangeText} testID={testSearchInputs.testID} />);
expect(findByTestId('searchbox')).toBeTruthy(); expect(findByTestId('searchbox')).toBeTruthy();
}); });
it('should not render clear-input icon', async () => { it('should not render clear-input icon', async () => {
const { queryByTestId } = render(<Render onChangeText={testSearchInputs.onChangeText} testID={testSearchInputs.testID} />); const { queryByTestId } = render(<SearchBox onChangeText={testSearchInputs.onChangeText} testID={testSearchInputs.testID} />);
const clearInput = await queryByTestId('clear-text-input'); const clearInput = await queryByTestId('clear-text-input');
expect(clearInput).toBeNull(); expect(clearInput).toBeNull();
}); });
it('should input new value with onChangeText function', async () => { it('should input new value with onChangeText function', async () => {
const { findByTestId } = render(<Render onChangeText={onChangeTextMock} testID={testSearchInputs.testID} />); const { findByTestId } = render(<SearchBox onChangeText={onChangeTextMock} testID={testSearchInputs.testID} />);
const component = await findByTestId(testSearchInputs.testID); const component = await findByTestId(testSearchInputs.testID);
fireEvent.changeText(component, 'new-input-value'); fireEvent.changeText(component, 'new-input-value');
expect(onChangeTextMock).toHaveBeenCalledWith('new-input-value'); expect(onChangeTextMock).toHaveBeenCalledWith('new-input-value');
}); });
// we need skip this test for now, until discovery how handle with functions effect it('should clear input when clear icon is pressed', async () => {
// https://github.com/callstack/react-native-testing-library/issues/978 const { findByTestId } = render(<SearchBox onChangeText={onChangeTextMock} testID={testSearchInputs.testID} />);
it.skip('should clear input when call onCancelSearch function', async () => {
const { findByTestId } = render(<Render testID={'input-with-value'} onChangeText={onChangeTextMock} />);
const component = await findByTestId('clear-text-input'); const component = await findByTestId(testSearchInputs.testID);
fireEvent.press(component, 'input-with-value'); fireEvent.changeText(component, 'new-input-value');
expect(onChangeTextMock).toHaveBeenCalledWith('input-with-value');
const clearTextInput = await findByTestId('clear-text-input');
fireEvent.press(clearTextInput);
expect(onChangeTextMock).toHaveBeenCalledWith('');
}); });
}); });

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,17 +32,9 @@ 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_again') : i18n.t('Call_back')}
</Text> </Text>
</Touchable> </Touchable>
<Text style={style.callBack}>{i18n.t('Call_was_not_answered')}</Text> <Text style={style.callBack}>{i18n.t('Call_was_not_answered')}</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

@ -126,6 +126,12 @@ export const Links = () => (
<View style={styles.container}> <View style={styles.container}>
<Markdown msg='[Markdown link](https://rocket.chat): `[description](url)`' theme={theme} /> <Markdown msg='[Markdown link](https://rocket.chat): `[description](url)`' theme={theme} />
<Markdown msg='<https://rocket.chat|Formatted Link>: `<url|description>`' theme={theme} /> <Markdown msg='<https://rocket.chat|Formatted Link>: `<url|description>`' theme={theme} />
<Markdown msg='[Markdown link](https://rocket.chat) and the text with default style' theme={theme} />
<Markdown
msg='[Markdown link](https://rocket.chat) and the text with a color specific as auxiliaryText'
theme={theme}
style={[{ color: themes[theme].auxiliaryText }]}
/>
</View> </View>
); );

View File

@ -140,10 +140,10 @@ class Markdown extends PureComponent<IMarkdownProps, any> {
} }
renderText = ({ context, literal }: { context: []; literal: string }) => { renderText = ({ context, literal }: { context: []; literal: string }) => {
const { numberOfLines, style = [] } = this.props; const { numberOfLines } = this.props;
const defaultStyle = [this.isMessageContainsOnlyEmoji ? styles.textBig : {}, ...context.map(type => styles[type])]; const defaultStyle = [this.isMessageContainsOnlyEmoji ? styles.textBig : {}, ...context.map(type => styles[type])];
return ( return (
<Text accessibilityLabel={literal} style={[styles.text, defaultStyle, ...style]} numberOfLines={numberOfLines}> <Text accessibilityLabel={literal} style={[styles.text, defaultStyle]} numberOfLines={numberOfLines}>
{literal} {literal}
</Text> </Text>
); );
@ -193,12 +193,12 @@ class Markdown extends PureComponent<IMarkdownProps, any> {
}; };
renderParagraph = ({ children }: any) => { renderParagraph = ({ children }: any) => {
const { numberOfLines, style, theme } = this.props; const { numberOfLines, style = [], theme } = this.props;
if (!children || children.length === 0) { if (!children || children.length === 0) {
return null; return null;
} }
return ( return (
<Text style={[styles.text, style, { color: themes[theme!].bodyText }]} numberOfLines={numberOfLines}> <Text style={[styles.text, { color: themes[theme!].bodyText }, ...style]} numberOfLines={numberOfLines}>
{children} {children}
</Text> </Text>
); );

View File

@ -17,7 +17,7 @@ const OrderedList = ({ value }: IOrderedListProps): React.ReactElement => {
{value.map(item => ( {value.map(item => (
<View style={styles.row} key={item.number?.toString()}> <View style={styles.row} key={item.number?.toString()}>
<Text style={[styles.text, { color: colors.bodyText }]}>{item.number}. </Text> <Text style={[styles.text, { color: colors.bodyText }]}>{item.number}. </Text>
<Text style={{ color: colors.bodyText }}> <Text style={[styles.inline, { color: colors.bodyText }]}>
<Inline value={item.value} /> <Inline value={item.value} />
</Text> </Text>
</View> </View>

View File

@ -18,7 +18,7 @@ const UnorderedList = ({ value }: IUnorderedListProps) => {
{value.map(item => ( {value.map(item => (
<View style={styles.row}> <View style={styles.row}>
<Text style={[styles.text, { color: themes[theme].bodyText }]}>- </Text> <Text style={[styles.text, { color: themes[theme].bodyText }]}>- </Text>
<Text style={{ color: themes[theme].bodyText }}> <Text style={[styles.inline, { color: themes[theme].bodyText }]}>
<Inline value={item.value} /> <Inline value={item.value} />
</Text> </Text>
</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

@ -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

@ -634,6 +634,28 @@ export const URL = () => (
</> </>
); );
export const URLImagePreview = () => (
<>
<Message
urls={[
{
url: 'https://www.google.com/logos/doodles/2022/seasonal-holidays-2022-6753651837109831.4-law.gif',
title: 'Google',
description:
"Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for."
}
]}
/>
<Message
urls={[
{
url: 'https://www.google.com/logos/doodles/2022/seasonal-holidays-2022-6753651837109831.4-law.gif'
}
]}
/>
</>
);
export const CustomFields = () => ( export const CustomFields = () => (
<> <>
<Message <Message

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

@ -232,7 +232,9 @@ const Reply = React.memo(
return ( return (
<> <>
{/* The testID is to test properly quoted messages using it as ancestor */}
<Touchable <Touchable
testID={`reply-${attachment?.author_name}-${attachment?.text}`}
onPress={onPress} onPress={onPress}
style={[ style={[
styles.button, styles.button,
@ -247,6 +249,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 +259,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

@ -1,4 +1,4 @@
import React, { useContext } from 'react'; import React, { useContext, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import Clipboard from '@react-native-clipboard/clipboard'; import Clipboard from '@react-native-clipboard/clipboard';
import FastImage from 'react-native-fast-image'; import FastImage from 'react-native-fast-image';
@ -48,38 +48,34 @@ const styles = StyleSheet.create({
height: 150, height: 150,
borderTopLeftRadius: 4, borderTopLeftRadius: 4,
borderTopRightRadius: 4 borderTopRightRadius: 4
},
imageWithoutContent: {
borderRadius: 4
},
loading: {
height: 0,
borderWidth: 0
} }
}); });
const UrlImage = React.memo(
({ image }: { image: string }) => {
const { baseUrl, user } = useContext(MessageContext);
if (!image) {
return null;
}
image = image.includes('http') ? image : `${baseUrl}/${image}?rc_uid=${user.id}&rc_token=${user.token}`;
return <FastImage source={{ uri: image }} style={styles.image} resizeMode={FastImage.resizeMode.cover} />;
},
(prevProps, nextProps) => prevProps.image === nextProps.image
);
const UrlContent = React.memo( const UrlContent = React.memo(
({ title, description, theme }: { title: string; description: string; theme: TSupportedThemes }) => ( ({ title, description }: { title: string; description: string }) => {
const { colors } = useTheme();
return (
<View style={styles.textContainer}> <View style={styles.textContainer}>
{title ? ( {title ? (
<Text style={[styles.title, { color: themes[theme].tintColor }]} numberOfLines={2}> <Text style={[styles.title, { color: colors.tintColor }]} numberOfLines={2}>
{title} {title}
</Text> </Text>
) : null} ) : null}
{description ? ( {description ? (
<Text style={[styles.description, { color: themes[theme].auxiliaryText }]} numberOfLines={2}> <Text style={[styles.description, { color: colors.auxiliaryText }]} numberOfLines={2}>
{description} {description}
</Text> </Text>
) : null} ) : null}
</View> </View>
), );
},
(prevProps, nextProps) => { (prevProps, nextProps) => {
if (prevProps.title !== nextProps.title) { if (prevProps.title !== nextProps.title) {
return false; return false;
@ -87,16 +83,18 @@ const UrlContent = React.memo(
if (prevProps.description !== nextProps.description) { if (prevProps.description !== nextProps.description) {
return false; return false;
} }
if (prevProps.theme !== nextProps.theme) {
return false;
}
return true; return true;
} }
); );
type TImageLoadedState = 'loading' | 'done' | 'error';
const Url = React.memo( const Url = React.memo(
({ url, index, theme }: { url: IUrl; index: number; theme: TSupportedThemes }) => { ({ url, index, theme }: { url: IUrl; index: number; theme: TSupportedThemes }) => {
if (!url || url?.ignoreParse) { const [imageLoadedState, setImageLoadedState] = useState<TImageLoadedState>('loading');
const { baseUrl, user } = useContext(MessageContext);
if (!url || url?.ignoreParse || imageLoadedState === 'error') {
return null; return null;
} }
@ -107,6 +105,13 @@ const Url = React.memo(
EventEmitter.emit(LISTENER, { message: I18n.t('Copied_to_clipboard') }); EventEmitter.emit(LISTENER, { message: I18n.t('Copied_to_clipboard') });
}; };
const hasContent = url.title || url.description;
let image = url.image || url.url;
if (image) {
image = image.includes('http') ? image : `${baseUrl}/${image}?rc_uid=${user.id}&rc_token=${user.token}`;
}
return ( return (
<Touchable <Touchable
onPress={onPress} onPress={onPress}
@ -118,13 +123,22 @@ const Url = React.memo(
{ {
backgroundColor: themes[theme].chatComponentBackground, backgroundColor: themes[theme].chatComponentBackground,
borderColor: themes[theme].borderColor borderColor: themes[theme].borderColor
} },
imageLoadedState === 'loading' && styles.loading
]} ]}
background={Touchable.Ripple(themes[theme].bannerBackground)} background={Touchable.Ripple(themes[theme].bannerBackground)}
> >
<> <>
<UrlImage image={url.image} /> {image ? (
<UrlContent title={url.title} description={url.description} theme={theme} /> <FastImage
source={{ uri: image }}
style={[styles.image, !hasContent && styles.imageWithoutContent, imageLoadedState === 'loading' && styles.loading]}
resizeMode={FastImage.resizeMode.cover}
onError={() => setImageLoadedState('error')}
onLoad={() => setImageLoadedState('done')}
/>
) : null}
{hasContent ? <UrlContent title={url.title} description={url.description} /> : null}
</> </>
</Touchable> </Touchable>
); );
@ -146,7 +160,6 @@ const Urls = React.memo(
(oldProps, newProps) => dequal(oldProps.urls, newProps.urls) (oldProps, newProps) => dequal(oldProps.urls, newProps.urls)
); );
UrlImage.displayName = 'MessageUrlImage';
UrlContent.displayName = 'MessageUrlContent'; UrlContent.displayName = 'MessageUrlContent';
Url.displayName = 'MessageUrl'; Url.displayName = 'MessageUrl';
Urls.displayName = 'MessageUrls'; Urls.displayName = 'MessageUrls';

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

@ -147,7 +147,10 @@ export interface IMessage extends IMessageFromServer {
editedAt?: string | Date; editedAt?: string | Date;
} }
export type TMessageModel = IMessage & Model; export type TMessageModel = IMessage &
Model & {
asPlain: () => IMessage;
};
export type TAnyMessageModel = TMessageModel | TThreadModel | TThreadMessageModel; export type TAnyMessageModel = TMessageModel | TThreadModel | TThreadMessageModel;
export type TTypeMessages = IMessageFromServer | ILoadMoreMessage | IMessage; export type TTypeMessages = IMessageFromServer | ILoadMoreMessage | IMessage;

View File

@ -17,16 +17,14 @@ export interface IAvatarButton {
} }
export interface IAvatar { export interface IAvatar {
data: {} | string | null; data: string | null;
url?: string; url?: string;
contentType?: string; contentType?: string;
service?: any; service?: any;
} }
export interface IAvatarSuggestion { export interface IAvatarSuggestion {
[service: string]: {
url: string; url: string;
blob: string; blob: string;
contentType: string; contentType: string;
};
} }

View File

@ -38,4 +38,7 @@ export interface IThread extends IMessage {
draftMessage?: string; draftMessage?: string;
} }
export type TThreadModel = IThread & Model; export type TThreadModel = IThread &
Model & {
asPlain: () => IMessage;
};

View File

@ -6,4 +6,7 @@ export interface IThreadMessage extends IMessage {
tmsg?: string; tmsg?: string;
} }
export type TThreadMessageModel = IThreadMessage & Model; export type TThreadMessageModel = IThreadMessage &
Model & {
asPlain: () => IMessage;
};

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

@ -0,0 +1 @@
export type TChangeAvatarViewContext = 'profile' | 'room';

View File

@ -33,6 +33,7 @@ export type ChatEndpoints = {
GET: (params: { roomId: IServerRoom['_id']; text?: string; offset: number; count: number }) => { GET: (params: { roomId: IServerRoom['_id']; text?: string; offset: number; count: number }) => {
messages: IMessageFromServer[]; messages: IMessageFromServer[];
total: number; total: number;
count: number;
}; };
}; };
'chat.getThreadsList': { 'chat.getThreadsList': {

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;
title?: boolean;
}; };
'video-conference.list': {
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

@ -768,7 +768,7 @@
"Convert_to_Team_Warning": "You are converting this Channel to a Team. All Members will be kept.", "Convert_to_Team_Warning": "You are converting this Channel to a Team. All Members will be kept.",
"Move_to_Team": "Move to Team", "Move_to_Team": "Move to Team",
"Move_Channel_Paragraph": "Moving a channel inside a team means that this channel will be added in the teams context, however, all channels members, which are not members of the respective team, will still have access to this channel, but will not be added as teams members. \n\nAll channels management will still be made by the owners of this channel.\n\nTeams members and even teams owners, if not a member of this channel, can not have access to the channels content. \n\nPlease notice that the Teams owner will be able remove members from the Channel.", "Move_Channel_Paragraph": "Moving a channel inside a team means that this channel will be added in the teams context, however, all channels members, which are not members of the respective team, will still have access to this channel, but will not be added as teams members. \n\nAll channels management will still be made by the owners of this channel.\n\nTeams members and even teams owners, if not a member of this channel, can not have access to the channels content. \n\nPlease notice that the Teams owner will be able remove members from the Channel.",
"Move_to_Team_Warning": "After reading the previous intructions about this behavior, do you still want to move this channel to the selected team?", "Move_to_Team_Warning": "After reading the previous instructions about this behavior, do you still want to move this channel to the selected team?",
"Load_More": "Load More", "Load_More": "Load More",
"Load_Newer": "Load Newer", "Load_Newer": "Load Newer",
"Load_Older": "Load Older", "Load_Older": "Load Older",
@ -877,6 +877,26 @@
"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",
"Upload_image": "Upload image",
"Delete_image": "Delete image",
"Images_uploaded": "Images uploaded",
"Avatar": "Avatar",
"insert_Avatar_URL": "insert image URL here",
"Discard_changes":"Discard changes?",
"Discard":"Discard",
"Discard_changes_description":"All changes will be lost if you go back without saving.",
"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_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.", "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" "Learn_more": "Learn more"

View File

@ -876,6 +876,14 @@
"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",
"Upload_image": "Carregar imagem",
"Delete_image": "Deletar imagem",
"Images_uploaded": "Imagens carregadas",
"Avatar": "Avatar",
"insert_Avatar_URL": "insira o URL da imagem aqui",
"Discard_changes":"Descartar alterações?",
"Discard":"Descartar",
"Discard_changes_description":"Todas as alterações serão perdidas, se você sair sem salvar.",
"Presence_Cap_Warning_Title": "Status do usuário desabilitado temporariamente", "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." "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

@ -59,6 +59,7 @@ export const colors = {
buttonText: '#ffffff', buttonText: '#ffffff',
passcodeBackground: '#EEEFF1', passcodeBackground: '#EEEFF1',
passcodeButtonActive: '#E4E7EA', passcodeButtonActive: '#E4E7EA',
editAndUploadButtonAvatar: '#E4E7EA',
passcodeLockIcon: '#6C727A', passcodeLockIcon: '#6C727A',
passcodePrimary: '#2F343D', passcodePrimary: '#2F343D',
passcodeSecondary: '#6C727A', passcodeSecondary: '#6C727A',
@ -128,6 +129,7 @@ export const colors = {
buttonText: '#ffffff', buttonText: '#ffffff',
passcodeBackground: '#030C1B', passcodeBackground: '#030C1B',
passcodeButtonActive: '#0B182C', passcodeButtonActive: '#0B182C',
editAndUploadButtonAvatar: '#0B182C',
passcodeLockIcon: '#6C727A', passcodeLockIcon: '#6C727A',
passcodePrimary: '#FFFFFF', passcodePrimary: '#FFFFFF',
passcodeSecondary: '#CBCED1', passcodeSecondary: '#CBCED1',
@ -197,6 +199,7 @@ export const colors = {
buttonText: '#ffffff', buttonText: '#ffffff',
passcodeBackground: '#000000', passcodeBackground: '#000000',
passcodeButtonActive: '#0E0D0D', passcodeButtonActive: '#0E0D0D',
editAndUploadButtonAvatar: '#0E0D0D',
passcodeLockIcon: '#6C727A', passcodeLockIcon: '#6C727A',
passcodePrimary: '#FFFFFF', passcodePrimary: '#FFFFFF',
passcodeSecondary: '#CBCED1', passcodeSecondary: '#CBCED1',

View File

@ -85,4 +85,47 @@ export default class Message extends Model {
@json('md', sanitizer) md; @json('md', sanitizer) md;
@field('comment') comment; @field('comment') comment;
asPlain() {
return {
id: this.id,
rid: this.subscription.id,
msg: this.msg,
t: this.t,
ts: this.ts,
u: this.u,
alias: this.alias,
parseUrls: this.parseUrls,
groupable: this.groupable,
avatar: this.avatar,
emoji: this.emoji,
attachments: this.attachments,
urls: this.urls,
_updatedAt: this._updatedAt,
status: this.status,
pinned: this.pinned,
starred: this.starred,
editedBy: this.editedBy,
reactions: this.reactions,
role: this.role,
drid: this.drid,
dcount: this.dcount,
dlm: this.dlm,
tmid: this.tmid,
tcount: this.tcount,
tlm: this.tlm,
replies: this.replies,
mentions: this.mentions,
channels: this.channels,
unread: this.unread,
autoTranslate: this.autoTranslate,
translations: this.translations,
tmsg: this.tmsg,
blocks: this.blocks,
e2e: this.e2e,
tshow: this.tshow,
md: this.md,
comment: this.comment
};
}
} }

View File

@ -77,4 +77,42 @@ export default class Thread extends Model {
@field('e2e') e2e; @field('e2e') e2e;
@field('draft_message') draftMessage; @field('draft_message') draftMessage;
asPlain() {
return {
id: this.id,
msg: this.msg,
t: this.t,
ts: this.ts,
u: this.u,
alias: this.alias,
parseUrls: this.parseUrls,
groupable: this.groupable,
avatar: this.avatar,
emoji: this.emoji,
attachments: this.attachments,
urls: this.urls,
_updatedAt: this._updatedAt,
status: this.status,
pinned: this.pinned,
starred: this.starred,
editedBy: this.editedBy,
reactions: this.reactions,
role: this.role,
drid: this.drid,
dcount: this.dcount,
dlm: this.dlm,
tmid: this.tmid,
tcount: this.tcount,
tlm: this.tlm,
replies: this.replies,
mentions: this.mentions,
channels: this.channels,
unread: this.unread,
autoTranslate: this.autoTranslate,
translations: this.translations,
e2e: this.e2e,
draftMessage: this.draftMessage
};
}
} }

View File

@ -77,4 +77,42 @@ export default class ThreadMessage extends Model {
@field('draft_message') draftMessage; @field('draft_message') draftMessage;
@field('e2e') e2e; @field('e2e') e2e;
asPlain() {
return {
id: this.id,
msg: this.msg,
t: this.t,
ts: this.ts,
u: this.u,
rid: this.rid,
alias: this.alias,
parseUrls: this.parseUrls,
groupable: this.groupable,
avatar: this.avatar,
emoji: this.emoji,
attachments: this.attachments,
urls: this.urls,
_updatedAt: this._updatedAt,
status: this.status,
pinned: this.pinned,
starred: this.starred,
editedBy: this.editedBy,
reactions: this.reactions,
role: this.role,
drid: this.drid,
dcount: this.dcount,
dlm: this.dlm,
tcount: this.tcount,
tlm: this.tlm,
replies: this.replies,
mentions: this.mentions,
channels: this.channels,
unread: this.unread,
autoTranslate: this.autoTranslate,
translations: this.translations,
draftMessage: this.draftMessage,
e2e: this.e2e
};
}
} }

View File

@ -13,7 +13,7 @@ export const getMessageById = async (messageId: string | null) => {
try { try {
const result = await messageCollection.find(messageId); const result = await messageCollection.find(messageId);
return result; return result;
} catch (error) { } catch {
return null; return null;
} }
}; };

View File

@ -0,0 +1,25 @@
import { store } from '../../store/auxStore';
import { IAttachment, IMessage } from '../../../definitions';
import { getAvatarURL } from '../../methods/helpers';
export function createQuoteAttachment(message: IMessage, messageLink: string): IAttachment {
const { server, version: serverVersion } = store.getState().server;
const externalProviderUrl = (store.getState().settings?.Accounts_AvatarExternalProviderUrl as string) || '';
return {
text: message.msg,
...('translations' in message && { translations: message?.translations }),
message_link: messageLink,
author_name: message.alias || message.u.username,
author_icon: getAvatarURL({
avatar: message.u?.username && `/avatar/${message.u?.username}`,
type: message.t,
userId: message.u?._id,
server,
serverVersion,
externalProviderUrl
}),
attachments: message.attachments || [],
ts: message.ts
};
}

View File

@ -0,0 +1,12 @@
import { getMessageUrlRegex } from './getMessageUrlRegex';
describe('Should regex', () => {
test('a common quote separated by space', () => {
const quote = '[ ](https://open.rocket.chat/group/room?msg=rid) test';
expect(quote.match(getMessageUrlRegex())).toStrictEqual(['https://open.rocket.chat/group/room?msg=rid']);
});
test('a quote separated by break line', () => {
const quote = '[ ](https://open.rocket.chat/group/room?msg=rid)\ntest';
expect(quote.match(getMessageUrlRegex())).toStrictEqual(['https://open.rocket.chat/group/room?msg=rid']);
});
});

View File

@ -0,0 +1,3 @@
// https://github.com/RocketChat/Rocket.Chat/blob/0226236b871d12c62338111c70b65d5d406447a3/apps/meteor/lib/getMessageUrlRegex.ts#L1-L2
export const getMessageUrlRegex = (): RegExp =>
/([A-Za-z]{3,9}):\/\/([-;:&=\+\$,\w]+@{1})?([-A-Za-z0-9\.]+)+:?(\d+)?((\/[-\+=!:~%\/\.@\,\w]*)?\??([-\+=&!:;%@\/\.\,\w]+)?(?:#([^\s\)]+))?)?/g;

View File

@ -0,0 +1,14 @@
import { IMessage } from '../../../definitions';
export const mapMessageFromAPI = ({ attachments, tlm, ts, _updatedAt, ...message }: IMessage) => ({
...message,
ts: new Date(ts),
...(tlm && { tlm: new Date(tlm) }),
_updatedAt: new Date(_updatedAt),
...(attachments && {
attachments: attachments.map(({ ts, ...attachment }) => ({
...(ts && { ts: new Date(ts) }),
...(attachment as any)
}))
})
});

View File

@ -0,0 +1,17 @@
import { TMessageModel } from '../../../definitions';
export const mapMessageFromDB = (messageModel: TMessageModel) => {
const parsedMessage = messageModel.asPlain();
return {
...parsedMessage,
ts: new Date(parsedMessage.ts),
...(parsedMessage.tlm && { tlm: new Date(parsedMessage.tlm) }),
_updatedAt: new Date(parsedMessage._updatedAt),
...(parsedMessage.attachments && {
attachments: parsedMessage.attachments.map(({ ts, ...attachment }) => ({
...(ts && { ts: new Date(ts) }),
...(attachment as any)
}))
})
};
};

View File

@ -2,7 +2,9 @@ import EJSON from 'ejson';
import { Base64 } from 'js-base64'; import { Base64 } from 'js-base64';
import SimpleCrypto from 'react-native-simple-crypto'; import SimpleCrypto from 'react-native-simple-crypto';
import ByteBuffer from 'bytebuffer'; import ByteBuffer from 'bytebuffer';
import parse from 'url-parse';
import getSingleMessage from '../methods/getSingleMessage';
import { IMessage, IUser } from '../../definitions'; import { IMessage, IUser } from '../../definitions';
import Deferred from './helpers/deferred'; import Deferred from './helpers/deferred';
import { debounce } from '../methods/helpers'; import { debounce } from '../methods/helpers';
@ -21,6 +23,11 @@ import {
import { Encryption } from './index'; import { Encryption } from './index';
import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../constants'; import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../constants';
import { Services } from '../services'; import { Services } from '../services';
import { getMessageUrlRegex } from './helpers/getMessageUrlRegex';
import { mapMessageFromAPI } from './helpers/mapMessageFromApi';
import { mapMessageFromDB } from './helpers/mapMessageFromDB';
import { createQuoteAttachment } from './helpers/createQuoteAttachment';
import { getMessageById } from '../database/services/Message';
export default class EncryptionRoom { export default class EncryptionRoom {
ready: boolean; ready: boolean;
@ -268,12 +275,15 @@ export default class EncryptionRoom {
tmsg = await this.decryptText(tmsg); tmsg = await this.decryptText(tmsg);
} }
return { const decryptedMessage: IMessage = {
...message, ...message,
tmsg, tmsg,
msg, msg,
e2e: E2E_STATUS.DONE e2e: 'done'
}; };
const decryptedMessageWithQuote = await this.decryptQuoteAttachment(decryptedMessage);
return decryptedMessageWithQuote;
} }
} catch { } catch {
// Do nothing // Do nothing
@ -281,4 +291,37 @@ export default class EncryptionRoom {
return message; return message;
}; };
async decryptQuoteAttachment(message: IMessage) {
const urls = message?.msg?.match(getMessageUrlRegex()) || [];
await Promise.all(
urls.map(async (url: string) => {
const parsedUrl = parse(url, true);
const messageId = parsedUrl.query?.msg;
if (!messageId) {
return;
}
// From local db
const messageFromDB = await getMessageById(messageId);
if (messageFromDB && messageFromDB.e2e === 'done') {
const decryptedQuoteMessage = mapMessageFromDB(messageFromDB);
message.attachments = message.attachments || [];
const quoteAttachment = createQuoteAttachment(decryptedQuoteMessage, url);
return message.attachments.push(quoteAttachment);
}
// From API
const quotedMessageObject = await getSingleMessage(messageId);
if (!quotedMessageObject) {
return;
}
const decryptedQuoteMessage = await this.decrypt(mapMessageFromAPI(quotedMessageObject));
message.attachments = message.attachments || [];
const quoteAttachment = createQuoteAttachment(decryptedQuoteMessage, url);
return message.attachments.push(quoteAttachment);
})
);
return message;
}
} }

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,100 @@
import React, { useEffect, useState } from 'react';
import { useActionSheet } from '../../containers/ActionSheet';
import StartACallActionSheet from '../../containers/UIKit/VideoConferenceBlock/components/StartACallActionSheet';
import { ISubscription, SubscriptionType } from '../../definitions';
import i18n from '../../i18n';
import { getUserSelector } from '../../selectors/login';
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 = async () => {
if (isServer5OrNewer) return setShowCallOption(true);
const room = await getSubscriptionByRoomId(rid);
if (room) {
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
});
}
};
useEffect(() => {
handleShowCallOption();
}, []);
return { showInitCallActionSheet, showCallOption };
};

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

@ -0,0 +1,20 @@
import ensureSecureProtocol from './ensureSecureProtocol';
describe('Add the protocol https at the begin of the URL', () => {
it('return the link as original when sent with https at the begin', () => {
const linkHttps = 'https://www.google.com';
expect(ensureSecureProtocol(linkHttps)).toBe(linkHttps);
});
it('return the link as original when sent with http at the begin', () => {
const linkHttp = 'http://www.google.com';
expect(ensureSecureProtocol(linkHttp)).toBe(linkHttp);
});
it("return a new link with protocol at the begin when there isn't the protocol at the begin", () => {
const linkWithoutProtocol = 'www.google.com';
expect(ensureSecureProtocol(linkWithoutProtocol)).toBe('https://www.google.com');
});
it('return the link correctly when the original starts with double slash, because the server is returning that', () => {
const linkWithDoubleSlashAtBegin = '//www.google.com';
expect(ensureSecureProtocol(linkWithDoubleSlashAtBegin)).toBe('https://www.google.com');
});
});

View File

@ -0,0 +1,10 @@
// If the link does not have the protocol at the beginning, we are inserting https as the default,
// since by convention the most used is the secure protocol, with the same behavior as the web.
const ensureSecureProtocol = (url: string): string => {
if (!url.toLowerCase().startsWith('http')) {
return `https://${url.replace('//', '')}`;
}
return url;
};
export default ensureSecureProtocol;

View File

@ -0,0 +1,32 @@
import { formatUrl } from './getAvatarUrl';
jest.mock('react-native', () => ({ PixelRatio: { get: () => 1 } }));
describe('formatUrl function', () => {
test('formats the default URL to get the user avatar', () => {
const url = 'https://mobile.rocket.chat/avatar/reinaldoneto';
const size = 30;
const query = '&extraparam=true';
const expected = 'https://mobile.rocket.chat/avatar/reinaldoneto?format=png&size=30&extraparam=true';
const result = formatUrl(url, size, query);
expect(result).toEqual(expected);
});
test('formats an external provider URI to get the user avatar', () => {
const url = 'https://open.rocket.chat/avatar/reinaldoneto';
const size = 30;
const query = undefined;
const expected = 'https://open.rocket.chat/avatar/reinaldoneto?format=png&size=30';
const result = formatUrl(url, size, query);
expect(result).toEqual(expected);
});
test('formats an external provider URI that already includes a query to get the user avatar', () => {
const url = 'https://open.rocket.chat/avatar?rcusername=reinaldoneto';
const size = 30;
const query = undefined;
const expected = 'https://open.rocket.chat/avatar?rcusername=reinaldoneto&format=png&size=30';
const result = formatUrl(url, size, query);
expect(result).toEqual(expected);
});
});

View File

@ -4,7 +4,10 @@ import { SubscriptionType } from '../../../definitions';
import { IAvatar } from '../../../containers/Avatar/interfaces'; import { IAvatar } from '../../../containers/Avatar/interfaces';
import { compareServerVersion } from './compareServerVersion'; import { compareServerVersion } from './compareServerVersion';
const formatUrl = (url: string, size: number, query?: string) => `${url}?format=png&size=${PixelRatio.get() * size}${query}`; export const formatUrl = (url: string, size: number, query?: string) => {
const hasQuestionMark = /\/[^\/?]+\?/.test(url);
return `${url}${hasQuestionMark ? '&' : '?'}format=png&size=${PixelRatio.get() * size}${query || ''}`;
};
export const getAvatarURL = ({ export const getAvatarURL = ({
type, type,

View File

@ -0,0 +1,10 @@
import { Image } from 'react-native';
export const isImageURL = async (url: string): Promise<boolean> => {
try {
const result = await Image.prefetch(url);
return result;
} catch {
return false;
}
};

View File

@ -14,3 +14,4 @@ export * from './server';
export * from './url'; export * from './url';
export * from './isValidEmail'; export * from './isValidEmail';
export * from './random'; export * from './random';
export * from './image';

View File

@ -5,6 +5,7 @@ import parse from 'url-parse';
import { themes } from '../../constants'; import { themes } from '../../constants';
import { TSupportedThemes } from '../../../theme'; import { TSupportedThemes } from '../../../theme';
import UserPreferences from '../userPreferences'; import UserPreferences from '../userPreferences';
import ensureSecureProtocol from './ensureSecureProtocol';
export const DEFAULT_BROWSER_KEY = 'DEFAULT_BROWSER_KEY'; export const DEFAULT_BROWSER_KEY = 'DEFAULT_BROWSER_KEY';
@ -37,9 +38,9 @@ const appSchemeURL = (url: string, browser: string): string => {
}; };
const openLink = async (url: string, theme: TSupportedThemes = 'light'): Promise<void> => { const openLink = async (url: string, theme: TSupportedThemes = 'light'): Promise<void> => {
url = ensureSecureProtocol(url);
try { try {
const browser = UserPreferences.getString(DEFAULT_BROWSER_KEY); const browser = UserPreferences.getString(DEFAULT_BROWSER_KEY);
if (browser === 'inApp') { if (browser === 'inApp') {
await WebBrowser.openBrowserAsync(url, { await WebBrowser.openBrowserAsync(url, {
toolbarColor: themes[theme].headerBackground, toolbarColor: themes[theme].headerBackground,

View File

@ -86,7 +86,9 @@ class ReviewApp {
positiveEventCount = 0; positiveEventCount = 0;
pushPositiveEvent = () => { pushPositiveEvent = () => {
if (!isFDroidBuild) { if (isFDroidBuild || process.env.RUNNING_E2E_TESTS === 'true') {
return;
}
if (this.positiveEventCount >= numberOfPositiveEvent) { if (this.positiveEventCount >= numberOfPositiveEvent) {
return; return;
} }
@ -94,7 +96,6 @@ class ReviewApp {
if (this.positiveEventCount === numberOfPositiveEvent) { if (this.positiveEventCount === numberOfPositiveEvent) {
tryReview(); tryReview();
} }
}
}; };
} }

View File

@ -1,7 +1,7 @@
import { KeyboardAwareScrollViewProps } from '@codler/react-native-keyboard-aware-scroll-view'; import { KeyboardAwareScrollViewProps } from '@codler/react-native-keyboard-aware-scroll-view';
const scrollPersistTaps: Partial<KeyboardAwareScrollViewProps> = { const scrollPersistTaps: Partial<KeyboardAwareScrollViewProps> = {
keyboardShouldPersistTaps: 'always', keyboardShouldPersistTaps: 'handled',
keyboardDismissMode: 'interactive' keyboardDismissMode: 'interactive'
}; };

View File

@ -291,7 +291,7 @@ export default function subscribeRooms() {
const [type, data] = ddpMessage.fields.args; const [type, data] = ddpMessage.fields.args;
const [, ev] = ddpMessage.fields.eventName.split('/'); const [, ev] = ddpMessage.fields.eventName.split('/');
if (/userData/.test(ev)) { if (/userData/.test(ev)) {
const [{ diff }] = ddpMessage.fields.args; const [{ diff, unset }] = ddpMessage.fields.args;
if (diff?.statusLivechat) { if (diff?.statusLivechat) {
store.dispatch(setUser({ statusLivechat: diff.statusLivechat })); store.dispatch(setUser({ statusLivechat: diff.statusLivechat }));
} }
@ -301,6 +301,12 @@ export default function subscribeRooms() {
if ((['settings.preferences.alsoSendThreadToChannel'] as any) in diff) { if ((['settings.preferences.alsoSendThreadToChannel'] as any) in diff) {
store.dispatch(setUser({ alsoSendThreadToChannel: diff['settings.preferences.alsoSendThreadToChannel'] })); store.dispatch(setUser({ alsoSendThreadToChannel: diff['settings.preferences.alsoSendThreadToChannel'] }));
} }
if (diff?.avatarETag) {
store.dispatch(setUser({ avatarETag: diff.avatarETag }));
}
if (unset?.avatarETag) {
store.dispatch(setUser({ avatarETag: '' }));
}
} }
if (/subscriptions/.test(ev)) { if (/subscriptions/.test(ev)) {
if (type === 'removed') { if (type === 'removed') {

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

@ -561,7 +561,7 @@ export const saveRoomSettings = (
rid: string, rid: string,
params: { params: {
roomName?: string; roomName?: string;
roomAvatar?: string; roomAvatar?: string | null;
roomDescription?: string; roomDescription?: string;
roomTopic?: string; roomTopic?: string;
roomAnnouncement?: string; roomAnnouncement?: string;
@ -602,7 +602,7 @@ export const getRoomRoles = (
// RC 0.65.0 // RC 0.65.0
sdk.get(`${roomTypeToApiType(type)}.roles`, { roomId }); sdk.get(`${roomTypeToApiType(type)}.roles`, { roomId });
export const getAvatarSuggestion = (): Promise<IAvatarSuggestion> => export const getAvatarSuggestion = (): Promise<{ [service: string]: IAvatarSuggestion }> =>
// RC 0.51.0 // RC 0.51.0
sdk.methodCallWrapper('getAvatarSuggestion'); sdk.methodCallWrapper('getAvatarSuggestion');
@ -936,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

@ -247,6 +247,22 @@ const handleLogout = function* handleLogout({ forcedByServer, message }) {
}; };
const handleSetUser = function* handleSetUser({ user }) { const handleSetUser = function* handleSetUser({ user }) {
if ('avatarETag' in user) {
const userId = yield select(state => state.login.user.id);
const serversDB = database.servers;
const userCollections = serversDB.get('users');
yield serversDB.write(async () => {
try {
const userRecord = await userCollections.find(userId);
await userRecord.update(record => {
record.avatarETag = user.avatarETag;
});
} catch {
//
}
});
}
setLanguage(user?.language); setLanguage(user?.language);
if (user?.statusLivechat && isOmnichannelModuleAvailable()) { if (user?.statusLivechat && isOmnichannelModuleAvailable()) {

View File

@ -68,6 +68,7 @@ import AddChannelTeamView from '../views/AddChannelTeamView';
import AddExistingChannelView from '../views/AddExistingChannelView'; import AddExistingChannelView from '../views/AddExistingChannelView';
import SelectListView from '../views/SelectListView'; import SelectListView from '../views/SelectListView';
import DiscussionsView from '../views/DiscussionsView'; import DiscussionsView from '../views/DiscussionsView';
import ChangeAvatarView from '../views/ChangeAvatarView';
import { import {
AdminPanelStackParamList, AdminPanelStackParamList,
ChatsStackParamList, ChatsStackParamList,
@ -96,6 +97,7 @@ const ChatsStackNavigator = () => {
<ChatsStack.Screen name='SelectListView' component={SelectListView} options={SelectListView.navigationOptions} /> <ChatsStack.Screen name='SelectListView' component={SelectListView} options={SelectListView.navigationOptions} />
<ChatsStack.Screen name='RoomInfoView' component={RoomInfoView} options={RoomInfoView.navigationOptions} /> <ChatsStack.Screen name='RoomInfoView' component={RoomInfoView} options={RoomInfoView.navigationOptions} />
<ChatsStack.Screen name='RoomInfoEditView' component={RoomInfoEditView} options={RoomInfoEditView.navigationOptions} /> <ChatsStack.Screen name='RoomInfoEditView' component={RoomInfoEditView} options={RoomInfoEditView.navigationOptions} />
<ChatsStack.Screen name='ChangeAvatarView' component={ChangeAvatarView} />
<ChatsStack.Screen name='RoomMembersView' component={RoomMembersView} /> <ChatsStack.Screen name='RoomMembersView' component={RoomMembersView} />
<ChatsStack.Screen name='DiscussionsView' component={DiscussionsView} /> <ChatsStack.Screen name='DiscussionsView' component={DiscussionsView} />
<ChatsStack.Screen <ChatsStack.Screen
@ -151,6 +153,7 @@ const ProfileStackNavigator = () => {
> >
<ProfileStack.Screen name='ProfileView' component={ProfileView} options={ProfileView.navigationOptions} /> <ProfileStack.Screen name='ProfileView' component={ProfileView} options={ProfileView.navigationOptions} />
<ProfileStack.Screen name='UserPreferencesView' component={UserPreferencesView} /> <ProfileStack.Screen name='UserPreferencesView' component={UserPreferencesView} />
<ProfileStack.Screen name='ChangeAvatarView' component={ChangeAvatarView} />
<ProfileStack.Screen name='UserNotificationPrefView' component={UserNotificationPrefView} /> <ProfileStack.Screen name='UserNotificationPrefView' component={UserNotificationPrefView} />
<ProfileStack.Screen name='PickerView' component={PickerView} options={PickerView.navigationOptions} /> <ProfileStack.Screen name='PickerView' component={PickerView} options={PickerView.navigationOptions} />
</ProfileStack.Navigator> </ProfileStack.Navigator>

View File

@ -17,6 +17,7 @@ import RoomsListView from '../../views/RoomsListView';
import RoomActionsView from '../../views/RoomActionsView'; import RoomActionsView from '../../views/RoomActionsView';
import RoomInfoView from '../../views/RoomInfoView'; import RoomInfoView from '../../views/RoomInfoView';
import RoomInfoEditView from '../../views/RoomInfoEditView'; import RoomInfoEditView from '../../views/RoomInfoEditView';
import ChangeAvatarView from '../../views/ChangeAvatarView';
import RoomMembersView from '../../views/RoomMembersView'; import RoomMembersView from '../../views/RoomMembersView';
import SearchMessagesView from '../../views/SearchMessagesView'; import SearchMessagesView from '../../views/SearchMessagesView';
import SelectedUsersView from '../../views/SelectedUsersView'; import SelectedUsersView from '../../views/SelectedUsersView';
@ -128,6 +129,7 @@ const ModalStackNavigator = React.memo(({ navigation }: INavigation) => {
<ModalStack.Screen name='RoomInfoView' component={RoomInfoView} options={RoomInfoView.navigationOptions} /> <ModalStack.Screen name='RoomInfoView' component={RoomInfoView} options={RoomInfoView.navigationOptions} />
<ModalStack.Screen name='SelectListView' component={SelectListView} /> <ModalStack.Screen name='SelectListView' component={SelectListView} />
<ModalStack.Screen name='RoomInfoEditView' component={RoomInfoEditView} options={RoomInfoEditView.navigationOptions} /> <ModalStack.Screen name='RoomInfoEditView' component={RoomInfoEditView} options={RoomInfoEditView.navigationOptions} />
<ModalStack.Screen name='ChangeAvatarView' component={ChangeAvatarView} />
<ModalStack.Screen name='RoomMembersView' component={RoomMembersView} /> <ModalStack.Screen name='RoomMembersView' component={RoomMembersView} />
<ModalStack.Screen <ModalStack.Screen
name='SearchMessagesView' name='SearchMessagesView'

View File

@ -6,6 +6,7 @@ import { IMessage } from '../../definitions/IMessage';
import { ISubscription, SubscriptionType, TSubscriptionModel } from '../../definitions/ISubscription'; import { ISubscription, SubscriptionType, TSubscriptionModel } from '../../definitions/ISubscription';
import { ILivechatDepartment } from '../../definitions/ILivechatDepartment'; import { ILivechatDepartment } from '../../definitions/ILivechatDepartment';
import { ILivechatTag } from '../../definitions/ILivechatTag'; import { ILivechatTag } from '../../definitions/ILivechatTag';
import { TChangeAvatarViewContext } from '../../definitions/TChangeAvatarViewContext';
export type MasterDetailChatsStackParamList = { export type MasterDetailChatsStackParamList = {
RoomView: { RoomView: {
@ -58,6 +59,12 @@ export type ModalStackParamList = {
onSearch?: Function; onSearch?: Function;
isRadio?: boolean; isRadio?: boolean;
}; };
ChangeAvatarView: {
context: TChangeAvatarViewContext;
titleHeader?: string;
room?: ISubscription;
t?: SubscriptionType;
};
RoomInfoEditView: { RoomInfoEditView: {
rid: string; rid: string;
}; };

View File

@ -13,6 +13,7 @@ import { ModalStackParamList } from './MasterDetailStack/types';
import { TThreadModel } from '../definitions'; import { TThreadModel } from '../definitions';
import { ILivechatDepartment } from '../definitions/ILivechatDepartment'; import { ILivechatDepartment } from '../definitions/ILivechatDepartment';
import { ILivechatTag } from '../definitions/ILivechatTag'; import { ILivechatTag } from '../definitions/ILivechatTag';
import { TChangeAvatarViewContext } from '../definitions/TChangeAvatarViewContext';
export type ChatsStackParamList = { export type ChatsStackParamList = {
ModalStackNavigator: NavigatorScreenParams<ModalStackParamList>; ModalStackNavigator: NavigatorScreenParams<ModalStackParamList>;
@ -181,6 +182,12 @@ export type ChatsStackParamList = {
onlyAudio?: boolean; onlyAudio?: boolean;
videoConf?: boolean; videoConf?: boolean;
}; };
ChangeAvatarView: {
context: TChangeAvatarViewContext;
titleHeader?: string;
room?: ISubscription;
t?: SubscriptionType;
};
}; };
export type ProfileStackParamList = { export type ProfileStackParamList = {
@ -195,6 +202,12 @@ export type ProfileStackParamList = {
goBack?: Function; goBack?: Function;
onChangeValue: Function; onChangeValue: Function;
}; };
ChangeAvatarView: {
context: TChangeAvatarViewContext;
titleHeader?: string;
room?: ISubscription;
t?: SubscriptionType;
};
}; };
export type SettingsStackParamList = { export type SettingsStackParamList = {

View File

@ -0,0 +1,56 @@
import React, { useState, useEffect } from 'react';
import { Text, View } from 'react-native';
import { IAvatar } from '../../definitions';
import { Services } from '../../lib/services';
import I18n from '../../i18n';
import styles from './styles';
import { useTheme } from '../../theme';
import AvatarSuggestionItem from './AvatarSuggestionItem';
const AvatarSuggestion = ({
onPress,
username,
resetAvatar
}: {
onPress: (value: IAvatar) => void;
username?: string;
resetAvatar?: () => void;
}) => {
const [avatarSuggestions, setAvatarSuggestions] = useState<IAvatar[]>([]);
const { colors } = useTheme();
useEffect(() => {
const getAvatarSuggestion = async () => {
const result = await Services.getAvatarSuggestion();
const suggestions = Object.keys(result).map(service => {
const { url, blob, contentType } = result[service];
return {
url,
data: blob,
service,
contentType
};
});
setAvatarSuggestions(suggestions);
};
getAvatarSuggestion();
}, []);
return (
<View style={styles.containerImagesUploaded}>
<Text style={[styles.itemLabel, { color: colors.titleText }]}>{I18n.t('Images_uploaded')}</Text>
<View style={styles.containerAvatarSuggestion}>
{username && resetAvatar ? (
<AvatarSuggestionItem text={`@${username}`} testID={`reset-avatar-suggestion`} onPress={resetAvatar} />
) : null}
{avatarSuggestions.slice(0, 7).map(item => (
<AvatarSuggestionItem item={item} testID={`${item?.service}-avatar-suggestion`} onPress={onPress} />
))}
</View>
</View>
);
};
export default AvatarSuggestion;

View File

@ -0,0 +1,40 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { IAvatar } from '../../definitions';
import Avatar from '../../containers/Avatar';
import { useTheme } from '../../theme';
const styles = StyleSheet.create({
container: {
width: 64,
height: 64,
alignItems: 'center',
justifyContent: 'center',
marginRight: 20,
marginBottom: 12,
borderRadius: 4
}
});
const AvatarSuggestionItem = ({
item,
onPress,
text,
testID
}: {
item?: IAvatar;
testID?: string;
onPress: Function;
text?: string;
}) => {
const { colors } = useTheme();
return (
<View key={item?.service} testID={testID} style={[styles.container, { backgroundColor: colors.borderColor }]}>
<Avatar avatar={item?.url} text={text} size={64} onPress={() => onPress(item)} />
</View>
);
};
export default AvatarSuggestionItem;

View File

@ -0,0 +1,29 @@
import React from 'react';
import I18n from '../../i18n';
import { FormTextInput } from '../../containers/TextInput';
import { useDebounce, isImageURL } from '../../lib/methods/helpers';
const AvatarUrl = ({ submit }: { submit: (value: string) => void }) => {
const handleChangeText = useDebounce(async (value: string) => {
if (value) {
const result = await isImageURL(value);
if (result) {
return submit(value);
}
return submit('');
}
}, 500);
return (
<FormTextInput
label={I18n.t('Avatar_Url')}
placeholder={I18n.t('insert_Avatar_URL')}
onChangeText={handleChangeText}
testID='change-avatar-view-avatar-url'
containerStyle={{ marginBottom: 0 }}
/>
);
};
export default AvatarUrl;

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,4 @@
import ImagePicker, { Image as ImageInterface } from 'react-native-image-crop-picker';
export type Image = ImageInterface;
export default ImagePicker;

View File

@ -0,0 +1,250 @@
import React, { useEffect, useLayoutEffect, useReducer, useRef, useState } from 'react';
import { ScrollView, View } from 'react-native';
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { shallowEqual } from 'react-redux';
import KeyboardView from '../../containers/KeyboardView';
import sharedStyles from '../Styles';
import scrollPersistTaps from '../../lib/methods/helpers/scrollPersistTaps';
import { showConfirmationAlert, showErrorAlert } from '../../lib/methods/helpers/info';
import StatusBar from '../../containers/StatusBar';
import { useTheme } from '../../theme';
import SafeAreaView from '../../containers/SafeAreaView';
import * as List from '../../containers/List';
import styles from './styles';
import { useAppSelector } from '../../lib/hooks';
import { getUserSelector } from '../../selectors/login';
import Avatar from '../../containers/Avatar';
import AvatarPresentational from '../../containers/Avatar/Avatar';
import AvatarUrl from './AvatarUrl';
import Button from '../../containers/Button';
import I18n from '../../i18n';
import { ChatsStackParamList } from '../../stacks/types';
import { IAvatar } from '../../definitions';
import AvatarSuggestion from './AvatarSuggestion';
import log from '../../lib/methods/helpers/log';
import { changeRoomsAvatar, changeUserAvatar, resetUserAvatar } from './submitServices';
import ImagePicker, { Image } from './ImagePicker';
enum AvatarStateActions {
CHANGE_AVATAR = 'CHANGE_AVATAR',
RESET_USER_AVATAR = 'RESET_USER_AVATAR',
RESET_ROOM_AVATAR = 'RESET_ROOM_AVATAR'
}
interface IReducerAction {
type: AvatarStateActions;
payload?: Partial<IState>;
}
interface IState extends IAvatar {
resetUserAvatar: string;
}
const initialState = {
data: '',
url: '',
contentType: '',
service: '',
resetUserAvatar: ''
};
function reducer(state: IState, action: IReducerAction) {
const { type, payload } = action;
if (type in AvatarStateActions) {
return {
...initialState,
...payload
};
}
return state;
}
const ChangeAvatarView = () => {
const [state, dispatch] = useReducer(reducer, initialState);
const [saving, setSaving] = useState(false);
const { colors } = useTheme();
const { userId, username, server } = useAppSelector(
state => ({
userId: getUserSelector(state).id,
username: getUserSelector(state).username,
server: state.server.server
}),
shallowEqual
);
const isDirty = useRef<boolean>(false);
const navigation = useNavigation<StackNavigationProp<ChatsStackParamList, 'ChangeAvatarView'>>();
const { context, titleHeader, room, t } = useRoute<RouteProp<ChatsStackParamList, 'ChangeAvatarView'>>().params;
useLayoutEffect(() => {
navigation.setOptions({
title: titleHeader || I18n.t('Avatar')
});
}, [titleHeader, navigation]);
useEffect(() => {
navigation.addListener('beforeRemove', e => {
if (!isDirty.current) {
return;
}
e.preventDefault();
showConfirmationAlert({
title: I18n.t('Discard_changes'),
message: I18n.t('Discard_changes_description'),
confirmationText: I18n.t('Discard'),
onPress: () => {
navigation.dispatch(e.data.action);
}
});
});
}, [navigation]);
const dispatchAvatar = (action: IReducerAction) => {
isDirty.current = true;
dispatch(action);
};
const submit = async () => {
try {
setSaving(true);
if (context === 'room' && room?.rid) {
// Change Rooms Avatar
await changeRoomsAvatar(room.rid, state?.data);
} else if (state?.url) {
// Change User's Avatar
await changeUserAvatar(state);
} else if (state.resetUserAvatar) {
// Change User's Avatar
await resetUserAvatar(userId);
}
isDirty.current = false;
} catch (e: any) {
log(e);
return showErrorAlert(e.message, I18n.t('Oops'));
} finally {
setSaving(false);
}
return navigation.goBack();
};
const pickImage = async () => {
const options = {
cropping: true,
compressImageQuality: 0.8,
freeStyleCropEnabled: true,
cropperAvoidEmptySpaceAroundImage: false,
cropperChooseText: I18n.t('Choose'),
cropperCancelText: I18n.t('Cancel'),
includeBase64: true
};
try {
const response: Image = await ImagePicker.openPicker(options);
dispatchAvatar({
type: AvatarStateActions.CHANGE_AVATAR,
payload: { url: response.path, data: `data:image/jpeg;base64,${response.data}`, service: 'upload' }
});
} catch (error) {
log(error);
}
};
const deletingRoomAvatar = context === 'room' && state.data === null;
return (
<KeyboardView
style={{ backgroundColor: colors.auxiliaryBackground }}
contentContainerStyle={sharedStyles.container}
keyboardVerticalOffset={128}
>
<StatusBar />
<SafeAreaView testID='change-avatar-view'>
<ScrollView
contentContainerStyle={sharedStyles.containerScrollView}
testID='change-avatar-view-list'
{...scrollPersistTaps}
>
<View style={styles.avatarContainer} testID='change-avatar-view-avatar'>
{deletingRoomAvatar ? (
<AvatarPresentational
text={room?.name || state.resetUserAvatar || username}
avatar={state?.url}
isStatic={state?.url}
size={120}
type={t}
server={server}
/>
) : (
<Avatar
text={room?.name || state.resetUserAvatar || username}
avatar={state?.url}
isStatic={state?.url}
size={120}
type={t}
rid={room?.rid}
/>
)}
</View>
{context === 'profile' ? (
<AvatarUrl
submit={value =>
dispatchAvatar({
type: AvatarStateActions.CHANGE_AVATAR,
payload: { url: value, data: value, service: 'url' }
})
}
/>
) : null}
<List.Separator style={styles.separator} />
{context === 'profile' ? (
<AvatarSuggestion
resetAvatar={() =>
dispatchAvatar({
type: AvatarStateActions.RESET_USER_AVATAR,
payload: { resetUserAvatar: `@${username}` }
})
}
username={username}
onPress={value =>
dispatchAvatar({
type: AvatarStateActions.CHANGE_AVATAR,
payload: value
})
}
/>
) : null}
<Button
title={I18n.t('Upload_image')}
type='secondary'
disabled={saving}
backgroundColor={colors.editAndUploadButtonAvatar}
onPress={pickImage}
testID='change-avatar-view-upload-image'
/>
{context === 'room' ? (
<Button
title={I18n.t('Delete_image')}
type='primary'
disabled={saving}
backgroundColor={colors.dangerColor}
onPress={() => dispatchAvatar({ type: AvatarStateActions.RESET_ROOM_AVATAR, payload: { data: null } })}
testID='change-avatar-view-delete-my-account'
/>
) : null}
<Button
title={I18n.t('Save')}
disabled={!isDirty.current || saving}
type='primary'
loading={saving}
onPress={submit}
testID='change-avatar-view-submit'
/>
</ScrollView>
</SafeAreaView>
</KeyboardView>
);
};
export default ChangeAvatarView;

View File

@ -0,0 +1,27 @@
import { StyleSheet } from 'react-native';
import sharedStyles from '../Styles';
export default StyleSheet.create({
avatarContainer: {
alignItems: 'center',
justifyContent: 'center',
marginBottom: 24
},
separator: {
marginVertical: 16
},
itemLabel: {
marginBottom: 12,
fontSize: 14,
...sharedStyles.textSemibold
},
containerImagesUploaded: {
flex: 1
},
containerAvatarSuggestion: {
flex: 1,
flexWrap: 'wrap',
flexDirection: 'row'
}
});

View File

@ -0,0 +1,14 @@
import I18n from '../../i18n';
export const handleError = (e: any, action: string) => {
if (e.data && e.data.error.includes('[error-too-many-requests]')) {
throw new Error(e.data.error);
}
if (e.error && e.error === 'error-avatar-invalid-url') {
throw new Error(I18n.t(e.error, { url: e.details.url }));
}
if (I18n.isTranslated(e.error)) {
throw new Error(I18n.t(e.error));
}
throw new Error(I18n.t('There_was_an_error_while_action', { action: I18n.t(action) }));
};

View File

@ -0,0 +1,29 @@
import { Services } from '../../lib/services';
import log from '../../lib/methods/helpers/log';
import { IAvatar } from '../../definitions';
import { handleError } from './submitHelpers';
export const changeRoomsAvatar = async (rid: string, roomAvatar: string | null) => {
try {
await Services.saveRoomSettings(rid, { roomAvatar });
} catch (e) {
log(e);
return handleError(e, 'changing_avatar');
}
};
export const changeUserAvatar = async (avatarUpload: IAvatar) => {
try {
await Services.setAvatarFromService(avatarUpload);
} catch (e) {
return handleError(e, 'changing_avatar');
}
};
export const resetUserAvatar = async (userId: string) => {
try {
await Services.resetAvatar(userId);
} catch (e) {
return handleError(e, 'changing_avatar');
}
};

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

@ -1,4 +1,4 @@
import React, { useEffect, useLayoutEffect, useState } from 'react'; import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { FlatList, StyleSheet } from 'react-native'; import { FlatList, StyleSheet } from 'react-native';
import { StackNavigationOptions, StackNavigationProp } from '@react-navigation/stack'; import { StackNavigationOptions, StackNavigationProp } from '@react-navigation/stack';
import { HeaderBackButton } from '@react-navigation/elements'; import { HeaderBackButton } from '@react-navigation/elements';
@ -46,12 +46,13 @@ const DiscussionsView = ({ navigation, route }: IDiscussionsViewProps): React.Re
const [discussions, setDiscussions] = useState<IMessageFromServer[]>([]); const [discussions, setDiscussions] = useState<IMessageFromServer[]>([]);
const [search, setSearch] = useState<IMessageFromServer[]>([]); const [search, setSearch] = useState<IMessageFromServer[]>([]);
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [total, setTotal] = useState(0); const total = useRef(0);
const [searchTotal, setSearchTotal] = useState(0); const searchText = useRef('');
const offset = useRef(0);
const { colors } = useTheme(); const { colors } = useTheme();
const load = async (text = '') => { const load = async () => {
if (loading) { if (loading) {
return; return;
} }
@ -60,18 +61,18 @@ const DiscussionsView = ({ navigation, route }: IDiscussionsViewProps): React.Re
try { try {
const result = await Services.getDiscussions({ const result = await Services.getDiscussions({
roomId: rid, roomId: rid,
offset: isSearching ? search.length : discussions.length, offset: offset.current,
count: API_FETCH_COUNT, count: API_FETCH_COUNT,
text text: searchText.current
}); });
if (result.success) { if (result.success) {
offset.current += result.count;
total.current = result.total;
if (isSearching) { if (isSearching) {
setSearch(result.messages); setSearch(prevState => (offset.current ? [...prevState, ...result.messages] : result.messages));
setSearchTotal(result.total);
} else { } else {
setDiscussions(result.messages); setDiscussions(result.messages);
setTotal(result.total);
} }
} }
setLoading(false); setLoading(false);
@ -81,15 +82,19 @@ const DiscussionsView = ({ navigation, route }: IDiscussionsViewProps): React.Re
} }
}; };
const onSearchChangeText = useDebounce(async (text: string) => { const onSearchChangeText = useDebounce((text: string) => {
setIsSearching(true); setIsSearching(true);
await load(text); setSearch([]);
searchText.current = text;
offset.current = 0;
load();
}, 500); }, 500);
const onCancelSearchPress = () => { const onCancelSearchPress = () => {
setIsSearching(false); setIsSearching(false);
setSearch([]); setSearch([]);
setSearchTotal(0); searchText.current = '';
offset.current = 0;
}; };
const onSearchPress = () => { const onSearchPress = () => {
@ -181,12 +186,12 @@ const DiscussionsView = ({ navigation, route }: IDiscussionsViewProps): React.Re
<FlatList <FlatList
data={isSearching ? search : discussions} data={isSearching ? search : discussions}
renderItem={renderItem} renderItem={renderItem}
keyExtractor={(item: any) => item.msg} keyExtractor={(item: any) => item._id}
style={{ backgroundColor: colors.backgroundColor }} style={{ backgroundColor: colors.backgroundColor }}
contentContainerStyle={styles.contentContainer} contentContainerStyle={styles.contentContainer}
onEndReachedThreshold={0.5} onEndReachedThreshold={0.5}
removeClippedSubviews={isIOS} removeClippedSubviews={isIOS}
onEndReached={() => (isSearching ? searchTotal : total) > API_FETCH_COUNT ?? load()} onEndReached={() => isSearching && offset.current < total.current && load()}
ItemSeparatorComponent={List.Separator} ItemSeparatorComponent={List.Separator}
ListFooterComponent={loading ? <ActivityIndicator /> : null} ListFooterComponent={loading ? <ActivityIndicator /> : null}
scrollIndicatorInsets={{ right: 1 }} scrollIndicatorInsets={{ right: 1 }}

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 }}

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