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
working_directory: ~/repo
orbs:
android: circleci/android@2.1.2
macos: &macos
macos:
xcode: "14.2.0"
@ -327,6 +330,14 @@ commands:
working_directory: ios
- 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
# EXECUTORS
@ -438,6 +449,94 @@ jobs:
- upload-to-google-play-beta:
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-build-experimental:
executor: mac-env
@ -461,11 +560,89 @@ jobs:
- upload-to-testflight:
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:
build-and-test:
jobs:
- 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-hold-build-experimental:
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/**'],
globals: {
by: true,
detox: true,
device: true,
element: true,
waitFor: true
},
rules: {
'import/no-extraneous-dependencies': 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
'no-await-in-loop': 0
}
}
]

1
.gitignore vendored
View File

@ -67,5 +67,6 @@ e2e/docker/rc_test_env/docker-compose.yml
e2e/docker/data/db
e2e/e2e_account.js
e2e/e2e_account.ts
junit.xml
*.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>
</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

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

View File

@ -18,7 +18,7 @@ public class DetoxTest {
@Rule
// Replace 'MainActivity' with the value of android:name entry in
// <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
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" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">localhost</domain>
</domain-config>
</network-security-config>

View File

@ -1,9 +1,5 @@
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.
buildscript {
def taskRequests = getGradle().getStartParameter().getTaskRequests().toString().toLowerCase()
@ -75,5 +71,38 @@ allprojects {
google()
maven { url 'https://maven.google.com' }
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, { useEffect, useRef, useState } from 'react';
import React from 'react';
import { shallowEqual, useSelector } from 'react-redux';
import { Observable, Subscription } from 'rxjs';
import { IApplicationState, TSubscriptionModel, TUserModel } from '../../definitions';
import database from '../../lib/database';
import { IApplicationState } from '../../definitions';
import { getUserSelector } from '../../selectors/login';
import Avatar from './Avatar';
import { IAvatar } from './interfaces';
import { useAvatarETag } from './useAvatarETag';
const AvatarContainer = ({
style,
@ -23,17 +21,13 @@ const AvatarContainer = ({
isStatic,
rid
}: 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 serverVersion = useSelector((state: IApplicationState) => state.share.server.version || state.server.version);
const { id, token } = useSelector(
const { id, token, username } = useSelector(
(state: IApplicationState) => ({
id: getUserSelector(state).id,
token: getUserSelector(state).token
token: getUserSelector(state).token,
username: getUserSelector(state).username
}),
shallowEqual
);
@ -48,41 +42,7 @@ const AvatarContainer = ({
true
);
const init = async () => {
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]);
const { avatarETag } = useAvatarETag({ username, text, type, rid, id });
return (
<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;
color?: string;
fontSize?: number;
styleText?: StyleProp<TextStyle>[];
styleText?: StyleProp<TextStyle> | StyleProp<TextStyle>[];
}
const styles = StyleSheet.create({

View File

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

View File

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

View File

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

View File

@ -487,7 +487,9 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
this.handleTyping(!isTextEmpty);
const { start, end } = this.selection;
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 result = lastWord.substring(1);

View File

@ -24,8 +24,16 @@ const formatMsg = ({ lastMessage, type, showLastMessage, username, useRealName }
const isLastMessageSentByMe = lastMessage.u.username === username;
if (!lastMessage.msg && lastMessage.attachments && Object.keys(lastMessage.attachments).length) {
const user = isLastMessageSentByMe ? I18n.t('You') : lastMessage.u.username;
return I18n.t('User_sent_an_attachment', { user });
const userAttachment = () => {
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

View File

@ -1,6 +1,5 @@
import React from 'react';
import { fireEvent, render } from '@testing-library/react-native';
import { TextInputProps } from 'react-native';
import SearchBox from '.';
@ -11,35 +10,35 @@ const testSearchInputs = {
testID: 'search-box-text-input'
};
const Render = ({ onChangeText, testID }: TextInputProps) => <SearchBox testID={testID} onChangeText={onChangeText} />;
describe('SearchBox', () => {
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();
});
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');
expect(clearInput).toBeNull();
});
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);
fireEvent.changeText(component, 'new-input-value');
expect(onChangeTextMock).toHaveBeenCalledWith('new-input-value');
});
// we need skip this test for now, until discovery how handle with functions effect
// https://github.com/callstack/react-native-testing-library/issues/978
it.skip('should clear input when call onCancelSearch function', async () => {
const { findByTestId } = render(<Render testID={'input-with-value'} onChangeText={onChangeTextMock} />);
it('should clear input when clear icon is pressed', async () => {
const { findByTestId } = render(<SearchBox onChangeText={onChangeTextMock} testID={testSearchInputs.testID} />);
const component = await findByTestId('clear-text-input');
fireEvent.press(component, 'input-with-value');
expect(onChangeTextMock).toHaveBeenCalledWith('input-with-value');
const component = await findByTestId(testSearchInputs.testID);
fireEvent.changeText(component, 'new-input-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 { useAppSelector } from '../../../../lib/hooks';
import { getRoomAvatar, getUidDirectMessage } from '../../../../lib/methods/helpers';
import { videoConfStartAndJoin } from '../../../../lib/methods/videoConf';
import { useTheme } from '../../../../theme';
import { useActionSheet } from '../../../ActionSheet';
import AvatarContainer from '../../../Avatar';
@ -16,12 +15,12 @@ import { BUTTON_HIT_SLOP } from '../../../message/utils';
import StatusContainer from '../../../Status';
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 { colors } = useTheme();
const [user, setUser] = useState({ username: '', avatar: '', uid: '', rid: '' });
const [phone, setPhone] = useState(true);
const [camera, setCamera] = useState(false);
const [user, setUser] = useState({ username: '', avatar: '', uid: '' });
const [mic, setMic] = useState(true);
const [cam, setCam] = useState(false);
const username = useAppSelector(state => state.login.user.username);
const { hideActionSheet } = useActionSheet();
@ -31,7 +30,7 @@ export default function CallAgainActionSheet({ rid }: { rid: string }): React.Re
const room = await getSubscriptionByRoomId(rid);
const uid = (await getUidDirectMessage(room)) as string;
const avt = getRoomAvatar(room);
setUser({ uid, username: room?.name || '', avatar: avt, rid: room?.id || '' });
setUser({ uid, username: room?.name || '', avatar: avt });
})();
}, [rid]);
@ -43,25 +42,27 @@ export default function CallAgainActionSheet({ rid }: { rid: string }): React.Re
<Text style={style.actionSheetHeaderTitle}>{i18n.t('Start_a_call')}</Text>
<View style={style.actionSheetHeaderButtons}>
<Touchable
onPress={() => setCamera(!camera)}
style={[style.iconCallContainer, camera && style.enabledBackground, { marginRight: 6 }]}
onPress={() => setCam(!cam)}
style={[style.iconCallContainer, cam && style.enabledBackground, { marginRight: 6 }]}
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
onPress={() => setPhone(!phone)}
style={[style.iconCallContainer, phone && style.enabledBackground]}
onPress={() => setMic(!mic)}
style={[style.iconCallContainer, mic && style.enabledBackground]}
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>
</View>
</View>
<View style={style.actionSheetUsernameContainer}>
<AvatarContainer text={user.avatar} size={36} />
<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 style={style.actionSheetPhotoContainer}>
<AvatarContainer size={62} text={username} />
@ -70,7 +71,7 @@ export default function CallAgainActionSheet({ rid }: { rid: string }): React.Re
onPress={() => {
hideActionSheet();
setTimeout(() => {
videoConfStartAndJoin(user.rid, camera);
initCall({ cam, mic });
}, 100);
}}
title={i18n.t('Call')}

View File

@ -3,17 +3,16 @@ import { Text } from 'react-native';
import Touchable from 'react-native-platform-touchable';
import i18n from '../../../../i18n';
import { useVideoConf } from '../../../../lib/hooks/useVideoConf';
import { videoConfJoin } from '../../../../lib/methods/videoConf';
import useStyle from './styles';
import { VideoConferenceBaseContainer } from './VideoConferenceBaseContainer';
const VideoConferenceDirect = React.memo(({ blockId }: { blockId: string }) => {
const style = useStyle();
const { joinCall } = useVideoConf();
return (
<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>
</Touchable>
<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 i18n from '../../../../i18n';
import { useAppSelector } from '../../../../lib/hooks';
import { useSnaps } from '../../../../lib/hooks/useSnaps';
import { useActionSheet } from '../../../ActionSheet';
import CallAgainActionSheet from './CallAgainActionSheet';
import { useVideoConf } from '../../../../lib/hooks/useVideoConf';
import { CallParticipants, TCallUsers } from './CallParticipants';
import useStyle from './styles';
import { VideoConferenceBaseContainer } from './VideoConferenceBaseContainer';
@ -26,8 +24,7 @@ export default function VideoConferenceEnded({
}): React.ReactElement {
const style = useStyle();
const username = useAppSelector(state => state.login.user.username);
const { showActionSheet } = useActionSheet();
const snaps = useSnaps([1250]);
const { showInitCallActionSheet } = useVideoConf(rid);
const onlyAuthorOnCall = users.length === 1 && users.some(user => user.username === createdBy.username);
@ -35,17 +32,9 @@ export default function VideoConferenceEnded({
<VideoConferenceBaseContainer variant='ended'>
{type === 'direct' ? (
<>
<Touchable
style={style.callToActionCallBack}
onPress={() =>
showActionSheet({
children: <CallAgainActionSheet rid={rid} />,
snaps
})
}
>
<Touchable style={style.callToActionCallBack} onPress={showInitCallActionSheet}>
<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>
</Touchable>
<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 i18n from '../../../../i18n';
import { useVideoConf } from '../../../../lib/hooks/useVideoConf';
import { videoConfJoin } from '../../../../lib/methods/videoConf';
import { CallParticipants, TCallUsers } from './CallParticipants';
import useStyle from './styles';
import { VideoConferenceBaseContainer } from './VideoConferenceBaseContainer';
export default function VideoConferenceOutgoing({ users, blockId }: { users: TCallUsers; blockId: string }): React.ReactElement {
const style = useStyle();
const { joinCall } = useVideoConf();
return (
<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>
</Touchable>
<CallParticipants users={users} />

View File

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

View File

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

View File

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

View File

@ -126,6 +126,12 @@ export const Links = () => (
<View style={styles.container}>
<Markdown msg='[Markdown link](https://rocket.chat): `[description](url)`' 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 File

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

View File

@ -17,7 +17,7 @@ const OrderedList = ({ value }: IOrderedListProps): React.ReactElement => {
{value.map(item => (
<View style={styles.row} key={item.number?.toString()}>
<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} />
</Text>
</View>

View File

@ -18,7 +18,7 @@ const UnorderedList = ({ value }: IUnorderedListProps) => {
{value.map(item => (
<View style={styles.row}>
<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} />
</Text>
</View>

View File

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

View File

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

View File

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

View File

@ -232,7 +232,9 @@ const Reply = React.memo(
return (
<>
{/* The testID is to test properly quoted messages using it as ancestor */}
<Touchable
testID={`reply-${attachment?.author_name}-${attachment?.text}`}
onPress={onPress}
style={[
styles.button,
@ -247,6 +249,8 @@ const Reply = React.memo(
>
<View style={styles.attachmentContainer}>
<Title attachment={attachment} timeFormat={timeFormat} theme={theme} />
<Description attachment={attachment} getCustomEmoji={getCustomEmoji} theme={theme} />
<UrlImage image={attachment.thumb_url} />
<Attachments
attachments={attachment.attachments}
getCustomEmoji={getCustomEmoji}
@ -255,8 +259,6 @@ const Reply = React.memo(
isReply
id={messageId}
/>
<UrlImage image={attachment.thumb_url} />
<Description attachment={attachment} getCustomEmoji={getCustomEmoji} theme={theme} />
<Fields attachment={attachment} getCustomEmoji={getCustomEmoji} theme={theme} />
{loading ? (
<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 Clipboard from '@react-native-clipboard/clipboard';
import FastImage from 'react-native-fast-image';
@ -48,38 +48,34 @@ const styles = StyleSheet.create({
height: 150,
borderTopLeftRadius: 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(
({ title, description, theme }: { title: string; description: string; theme: TSupportedThemes }) => (
<View style={styles.textContainer}>
{title ? (
<Text style={[styles.title, { color: themes[theme].tintColor }]} numberOfLines={2}>
{title}
</Text>
) : null}
{description ? (
<Text style={[styles.description, { color: themes[theme].auxiliaryText }]} numberOfLines={2}>
{description}
</Text>
) : null}
</View>
),
({ title, description }: { title: string; description: string }) => {
const { colors } = useTheme();
return (
<View style={styles.textContainer}>
{title ? (
<Text style={[styles.title, { color: colors.tintColor }]} numberOfLines={2}>
{title}
</Text>
) : null}
{description ? (
<Text style={[styles.description, { color: colors.auxiliaryText }]} numberOfLines={2}>
{description}
</Text>
) : null}
</View>
);
},
(prevProps, nextProps) => {
if (prevProps.title !== nextProps.title) {
return false;
@ -87,16 +83,18 @@ const UrlContent = React.memo(
if (prevProps.description !== nextProps.description) {
return false;
}
if (prevProps.theme !== nextProps.theme) {
return false;
}
return true;
}
);
type TImageLoadedState = 'loading' | 'done' | 'error';
const Url = React.memo(
({ 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;
}
@ -107,6 +105,13 @@ const Url = React.memo(
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 (
<Touchable
onPress={onPress}
@ -118,13 +123,22 @@ const Url = React.memo(
{
backgroundColor: themes[theme].chatComponentBackground,
borderColor: themes[theme].borderColor
}
},
imageLoadedState === 'loading' && styles.loading
]}
background={Touchable.Ripple(themes[theme].bannerBackground)}
>
<>
<UrlImage image={url.image} />
<UrlContent title={url.title} description={url.description} theme={theme} />
{image ? (
<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>
);
@ -146,7 +160,6 @@ const Urls = React.memo(
(oldProps, newProps) => dequal(oldProps.urls, newProps.urls)
);
UrlImage.displayName = 'MessageUrlImage';
UrlContent.displayName = 'MessageUrlContent';
Url.displayName = 'MessageUrl';
Urls.displayName = 'MessageUrls';

View File

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

View File

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

View File

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

View File

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

View File

@ -38,4 +38,7 @@ export interface IThread extends IMessage {
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;
}
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 { IMessage } from './IMessage';
export enum VideoConferenceStatus {
export declare enum VideoConferenceStatus {
CALLING = 0,
STARTED = 1,
EXPIRED = 2,
ENDED = 3,
DECLINED = 4
}
export type DirectCallInstructions = {
export declare type DirectCallInstructions = {
type: 'direct';
callee: IUser['_id'];
calleeId: IUser['_id'];
callId: string;
};
export type ConferenceInstructions = {
export declare type ConferenceInstructions = {
type: 'videoconference';
callId: string;
rid: IRoom['_id'];
};
export type LivechatInstructions = {
export declare type LivechatInstructions = {
type: 'livechat';
callId: string;
};
export type VideoConferenceType = DirectCallInstructions['type'] | ConferenceInstructions['type'] | LivechatInstructions['type'];
export declare type VideoConferenceType =
| DirectCallInstructions['type']
| ConferenceInstructions['type']
| LivechatInstructions['type'];
export interface IVideoConferenceUser extends Pick<Required<IUser>, '_id' | 'username' | 'name' | 'avatarETag'> {
ts: Date;
}
export interface IVideoConference extends IRocketChatRecord {
type: VideoConferenceType;
rid: string;
@ -45,51 +42,68 @@ export interface IVideoConference extends IRocketChatRecord {
ended?: IMessage['_id'];
};
url?: string;
createdBy: Pick<IUser, '_id' | 'username' | 'name'>;
createdBy: Pick<Required<IUser>, '_id' | 'username' | 'name'>;
createdAt: Date;
endedBy?: Pick<IUser, '_id' | 'username' | 'name'>;
endedBy?: Pick<Required<IUser>, '_id' | 'username' | 'name'>;
endedAt?: Date;
providerName: string;
providerData?: Record<string, any>;
ringing?: boolean;
}
export interface IDirectVideoConference extends IVideoConference {
type: 'direct';
}
export interface IGroupVideoConference extends IVideoConference {
type: 'videoconference';
anonymousUsers: number;
title: string;
}
export interface ILivechatVideoConference extends IVideoConference {
type: 'livechat';
}
export type VideoConference = IDirectVideoConference | IGroupVideoConference | ILivechatVideoConference;
export type VideoConferenceInstructions = DirectCallInstructions | ConferenceInstructions | LivechatInstructions;
export const isDirectVideoConference = (call: VideoConference | undefined | null): call is IDirectVideoConference =>
call?.type === 'direct';
export const isGroupVideoConference = (call: VideoConference | undefined | null): call is IGroupVideoConference =>
call?.type === 'videoconference';
export const isLivechatVideoConference = (call: VideoConference | undefined | null): call is ILivechatVideoConference =>
call?.type === 'livechat';
type GroupVideoConferenceCreateData = Omit<IGroupVideoConference, 'createdBy'> & { createdBy: IUser['_id'] };
type DirectVideoConferenceCreateData = Omit<IDirectVideoConference, 'createdBy'> & { createdBy: IUser['_id'] };
type LivechatVideoConferenceCreateData = Omit<ILivechatVideoConference, 'createdBy'> & { createdBy: IUser['_id'] };
export type VideoConferenceCreateData = AtLeast<
export declare type VideoConference = IDirectVideoConference | IGroupVideoConference | ILivechatVideoConference;
export declare type VideoConferenceInstructions = DirectCallInstructions | ConferenceInstructions | LivechatInstructions;
export declare const isDirectVideoConference: (call: VideoConference | undefined | null) => call is IDirectVideoConference;
export declare const isGroupVideoConference: (call: VideoConference | undefined | null) => call is IGroupVideoConference;
export declare const isLivechatVideoConference: (call: VideoConference | undefined | null) => call is ILivechatVideoConference;
declare type GroupVideoConferenceCreateData = Omit<IGroupVideoConference, 'createdBy'> & {
createdBy: IUser['_id'];
};
declare type DirectVideoConferenceCreateData = Omit<IDirectVideoConference, 'createdBy'> & {
createdBy: IUser['_id'];
};
declare type LivechatVideoConferenceCreateData = Omit<ILivechatVideoConference, 'createdBy'> & {
createdBy: IUser['_id'];
};
export declare type VideoConferenceCreateData = AtLeast<
DirectVideoConferenceCreateData | GroupVideoConferenceCreateData | LivechatVideoConferenceCreateData,
'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 }) => {
messages: IMessageFromServer[];
total: number;
count: number;
};
};
'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 = {
'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': {
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': {
POST: (params: { callId: string }) => void;
POST: (params: VideoConfCancelProps) => void;
};
'video-conference.info': {
GET: (params: { callId: string }) => VideoConference & {
capabilities: {
mic?: boolean;
cam?: boolean;
title?: boolean;
};
};
GET: (params: VideoConfInfoProps) => VideoConference & { capabilities: VideoConferenceCapabilities };
};
'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 'rn-root-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.",
"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_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_Newer": "Load Newer",
"Load_Older": "Load Older",
@ -877,6 +877,26 @@
"Reply_in_direct_message": "Reply in Direct Message",
"room_archived": "archived 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_Description": "Active connections have reached the limit for the workspace, thus the service that handles user status is disabled. It can be re-enabled manually in workspace settings.",
"Learn_more": "Learn more"

View File

@ -876,6 +876,14 @@
"Reply_in_direct_message": "Responder por mensagem direta",
"room_archived": "{{username}} arquivou 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_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',
passcodeBackground: '#EEEFF1',
passcodeButtonActive: '#E4E7EA',
editAndUploadButtonAvatar: '#E4E7EA',
passcodeLockIcon: '#6C727A',
passcodePrimary: '#2F343D',
passcodeSecondary: '#6C727A',
@ -128,6 +129,7 @@ export const colors = {
buttonText: '#ffffff',
passcodeBackground: '#030C1B',
passcodeButtonActive: '#0B182C',
editAndUploadButtonAvatar: '#0B182C',
passcodeLockIcon: '#6C727A',
passcodePrimary: '#FFFFFF',
passcodeSecondary: '#CBCED1',
@ -197,6 +199,7 @@ export const colors = {
buttonText: '#ffffff',
passcodeBackground: '#000000',
passcodeButtonActive: '#0E0D0D',
editAndUploadButtonAvatar: '#0E0D0D',
passcodeLockIcon: '#6C727A',
passcodePrimary: '#FFFFFF',
passcodeSecondary: '#CBCED1',

View File

@ -85,4 +85,47 @@ export default class Message extends Model {
@json('md', sanitizer) md;
@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('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('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 {
const result = await messageCollection.find(messageId);
return result;
} catch (error) {
} catch {
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 SimpleCrypto from 'react-native-simple-crypto';
import ByteBuffer from 'bytebuffer';
import parse from 'url-parse';
import getSingleMessage from '../methods/getSingleMessage';
import { IMessage, IUser } from '../../definitions';
import Deferred from './helpers/deferred';
import { debounce } from '../methods/helpers';
@ -21,6 +23,11 @@ import {
import { Encryption } from './index';
import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../constants';
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 {
ready: boolean;
@ -268,12 +275,15 @@ export default class EncryptionRoom {
tmsg = await this.decryptText(tmsg);
}
return {
const decryptedMessage: IMessage = {
...message,
tmsg,
msg,
e2e: E2E_STATUS.DONE
e2e: 'done'
};
const decryptedMessageWithQuote = await this.decryptQuoteAttachment(decryptedMessage);
return decryptedMessageWithQuote;
}
} catch {
// Do nothing
@ -281,4 +291,37 @@ export default class EncryptionRoom {
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 });
}
export async function callJitsi(room: ISubscription, onlyAudio = false): Promise<void> {
logEvent(onlyAudio ? events.RA_JITSI_AUDIO : events.RA_JITSI_VIDEO);
export async function callJitsi({ room, cam = false }: { room: ISubscription; cam?: boolean }): Promise<void> {
logEvent(cam ? events.RA_JITSI_AUDIO : events.RA_JITSI_VIDEO);
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 { 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 = ({
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 './isValidEmail';
export * from './random';
export * from './image';

View File

@ -5,6 +5,7 @@ import parse from 'url-parse';
import { themes } from '../../constants';
import { TSupportedThemes } from '../../../theme';
import UserPreferences from '../userPreferences';
import ensureSecureProtocol from './ensureSecureProtocol';
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> => {
url = ensureSecureProtocol(url);
try {
const browser = UserPreferences.getString(DEFAULT_BROWSER_KEY);
if (browser === 'inApp') {
await WebBrowser.openBrowserAsync(url, {
toolbarColor: themes[theme].headerBackground,

View File

@ -86,14 +86,15 @@ class ReviewApp {
positiveEventCount = 0;
pushPositiveEvent = () => {
if (!isFDroidBuild) {
if (this.positiveEventCount >= numberOfPositiveEvent) {
return;
}
this.positiveEventCount += 1;
if (this.positiveEventCount === numberOfPositiveEvent) {
tryReview();
}
if (isFDroidBuild || process.env.RUNNING_E2E_TESTS === 'true') {
return;
}
if (this.positiveEventCount >= numberOfPositiveEvent) {
return;
}
this.positiveEventCount += 1;
if (this.positiveEventCount === numberOfPositiveEvent) {
tryReview();
}
};
}

View File

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

View File

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

View File

@ -19,9 +19,9 @@ const handleBltPermission = async (): Promise<Permission[]> => {
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 {
const result = await Services.videoConferenceJoin(callId, cam);
const result = await Services.videoConferenceJoin(callId, cam, mic);
if (result.success) {
if (isAndroid) {
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 {
const videoConfResponse: any = await Services.videoConferenceStart(rid);
const videoConfResponse = await Services.videoConferenceStart(rid);
if (videoConfResponse.success) {
videoConfJoin(videoConfResponse.data.callId, cam);
videoConfJoin(videoConfResponse.data.callId, cam, mic);
}
} catch (e) {
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,
params: {
roomName?: string;
roomAvatar?: string;
roomAvatar?: string | null;
roomDescription?: string;
roomTopic?: string;
roomAnnouncement?: string;
@ -602,7 +602,7 @@ export const getRoomRoles = (
// RC 0.65.0
sdk.get(`${roomTypeToApiType(type)}.roles`, { roomId });
export const getAvatarSuggestion = (): Promise<IAvatarSuggestion> =>
export const getAvatarSuggestion = (): Promise<{ [service: string]: IAvatarSuggestion }> =>
// RC 0.51.0
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 videoConferenceJoin = (callId: string, cam: boolean) =>
sdk.post('video-conference.join', { callId, state: { cam } });
export const videoConferenceJoin = (callId: string, cam?: boolean, mic?: boolean) =>
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 });

View File

@ -247,6 +247,22 @@ const handleLogout = function* handleLogout({ forcedByServer, message }) {
};
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);
if (user?.statusLivechat && isOmnichannelModuleAvailable()) {

View File

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

View File

@ -17,6 +17,7 @@ import RoomsListView from '../../views/RoomsListView';
import RoomActionsView from '../../views/RoomActionsView';
import RoomInfoView from '../../views/RoomInfoView';
import RoomInfoEditView from '../../views/RoomInfoEditView';
import ChangeAvatarView from '../../views/ChangeAvatarView';
import RoomMembersView from '../../views/RoomMembersView';
import SearchMessagesView from '../../views/SearchMessagesView';
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='SelectListView' component={SelectListView} />
<ModalStack.Screen name='RoomInfoEditView' component={RoomInfoEditView} options={RoomInfoEditView.navigationOptions} />
<ModalStack.Screen name='ChangeAvatarView' component={ChangeAvatarView} />
<ModalStack.Screen name='RoomMembersView' component={RoomMembersView} />
<ModalStack.Screen
name='SearchMessagesView'

View File

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

View File

@ -13,6 +13,7 @@ import { ModalStackParamList } from './MasterDetailStack/types';
import { TThreadModel } from '../definitions';
import { ILivechatDepartment } from '../definitions/ILivechatDepartment';
import { ILivechatTag } from '../definitions/ILivechatTag';
import { TChangeAvatarViewContext } from '../definitions/TChangeAvatarViewContext';
export type ChatsStackParamList = {
ModalStackNavigator: NavigatorScreenParams<ModalStackParamList>;
@ -181,6 +182,12 @@ export type ChatsStackParamList = {
onlyAudio?: boolean;
videoConf?: boolean;
};
ChangeAvatarView: {
context: TChangeAvatarViewContext;
titleHeader?: string;
room?: ISubscription;
t?: SubscriptionType;
};
};
export type ProfileStackParamList = {
@ -195,6 +202,12 @@ export type ProfileStackParamList = {
goBack?: Function;
onChangeValue: Function;
};
ChangeAvatarView: {
context: TChangeAvatarViewContext;
titleHeader?: string;
room?: ISubscription;
t?: SubscriptionType;
};
};
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,
onPress: () => this.onPressItem(item),
baseUrl,
testID: `directory-view-item-${item.name}`.toLowerCase(),
testID: `directory-view-item-${item.name}`,
style,
user,
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 { StackNavigationOptions, StackNavigationProp } from '@react-navigation/stack';
import { HeaderBackButton } from '@react-navigation/elements';
@ -46,12 +46,13 @@ const DiscussionsView = ({ navigation, route }: IDiscussionsViewProps): React.Re
const [discussions, setDiscussions] = useState<IMessageFromServer[]>([]);
const [search, setSearch] = useState<IMessageFromServer[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [total, setTotal] = useState(0);
const [searchTotal, setSearchTotal] = useState(0);
const total = useRef(0);
const searchText = useRef('');
const offset = useRef(0);
const { colors } = useTheme();
const load = async (text = '') => {
const load = async () => {
if (loading) {
return;
}
@ -60,18 +61,18 @@ const DiscussionsView = ({ navigation, route }: IDiscussionsViewProps): React.Re
try {
const result = await Services.getDiscussions({
roomId: rid,
offset: isSearching ? search.length : discussions.length,
offset: offset.current,
count: API_FETCH_COUNT,
text
text: searchText.current
});
if (result.success) {
offset.current += result.count;
total.current = result.total;
if (isSearching) {
setSearch(result.messages);
setSearchTotal(result.total);
setSearch(prevState => (offset.current ? [...prevState, ...result.messages] : result.messages));
} else {
setDiscussions(result.messages);
setTotal(result.total);
}
}
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);
await load(text);
setSearch([]);
searchText.current = text;
offset.current = 0;
load();
}, 500);
const onCancelSearchPress = () => {
setIsSearching(false);
setSearch([]);
setSearchTotal(0);
searchText.current = '';
offset.current = 0;
};
const onSearchPress = () => {
@ -181,12 +186,12 @@ const DiscussionsView = ({ navigation, route }: IDiscussionsViewProps): React.Re
<FlatList
data={isSearching ? search : discussions}
renderItem={renderItem}
keyExtractor={(item: any) => item.msg}
keyExtractor={(item: any) => item._id}
style={{ backgroundColor: colors.backgroundColor }}
contentContainerStyle={styles.contentContainer}
onEndReachedThreshold={0.5}
removeClippedSubviews={isIOS}
onEndReached={() => (isSearching ? searchTotal : total) > API_FETCH_COUNT ?? load()}
onEndReached={() => isSearching && offset.current < total.current && load()}
ItemSeparatorComponent={List.Separator}
ListFooterComponent={loading ? <ActivityIndicator /> : null}
scrollIndicatorInsets={{ right: 1 }}

View File

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

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