From 8ea6f1647eb351248b4842cd9d08b5912757cb1d Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 15 Jul 2019 14:24:48 -0300 Subject: [PATCH] [RELEASE] Merge beta into master (#1055) * Bump version to 1.16.0 (#1014) * [IMPROVEMENT] Share credentials with Rocket.Chat.iOS (#982) * :sparkles: Create user table * :sparkles: Introduce user table * :fire: Remove unused table * :heavy_plus_sign: Add userdefaults to storage data * :green_heart: Fix android build * :sparkles: Get credentials from iOS native client * :fire: Remove unused code * :rewind: Revert sign xcode * :bug: Fix first login-logout * :art: Use constants to UserDefaults Keys * :bug: Fix clear server-user-info on logout * :bug: Fix filter null value * :ambulance: Remove user object in logout * :sparkles: Fix get servers from native-client * :ambulance: Fix error on change server * [FIX] Don't run UserDefaults credentials on Android (#1015) * :bug: Fix native credentials (android) * Fix migration loop * [IMPROVEMENT] Hide frequently used emoji tab when empty (#792) * [IMPROVEMENT] Bigger emoji in emoji only messages (#793) * issue #725: bigger emoji in emoji only message * issue-725/add storybook for Message/Emoji * issue-725: update storybook/Message jest snapshot * comment storybook import * allow spaces and line breaks in emoji only message * merge develop * revert unnecessary spacing * [FIX] Empty message if contains only a link (#787) * Fix empty message if contains only a link * :bug: Fix empty space * [IMPROVEMENT] Refactor empty space regex on quote (#1017) * :art: Improve regex to empty space on quote * :art: Improve on regex to empty space on quote * [NEW] Custom fields on signup (#1013) * added custom feilds on registration * added flag as leftIcon and removed lable * added try and catch * typo * [CHORE] Renew provisioning profiles (#1020) * [NEW] Auto-translate (#1012) * Update realm * View original and translate working * Read AutoTranslate_Enabled setting * RocketChat.canAutoTranslate() * AutoTranslateView * Save language * Auto-translate switch * Translate message * [IMPROVEMENT] Use haptics rather than vibration (#1016) * Install expo-haptics * Use expo-haptics rather than RN's Vibration module * [IMPROVEMENT] Use Rest API for file upload (#1005) * removed rn-fetch-blob and use native XMLHttpRequest instead * removed unnessary changes * fix android bug * fix android bug * added tmid support * fix bug * fixed isssue with cacel model * fix problems with audio * done requested changes * fix bug with android * [CHORE] [CI] [TESTS] update detox to make ci pass (#1026) * feat: update detox to 12.11.3 to make CI pass * ci: comment all jobs but leave e2e-test job * commit to rerun IC e2e-test job * ci: uncomment all CI jobs * [NEW] Room swipe actions: mark as read/unread, hide, fav (#976) * added unread and fav feature * changed the layout * fix jest * done requested changes * added requested changes * [FIX] Android build (#1027) * [FIX] Android build * CircleCI error * [FIX] iOS share credentials build (#1028) * [FIX] iOS share credentials build * Use `hasMigration` as a string * [CI] Restore cache on CI (#1029) * feat: add fastlane save\restore cache config; comment not needed jobs; * install fastlane using 'bundle install' * install fastlane using 'sudo bundle install' * uncomment ios build commands * run set up google services in ios folder * add working_directory: ios to ios-build steps * remove 'cd ios' from Fastlane build step * add save\restore cache for npm modules * group save_cache steps * cache fastlane in ios-testflight job * uncomment previously commented jobs\steps * fix: add missing colon * use key for caching: node-modules-{{ checksum ".circleci/config.yml" }}-{{ checksum "yarn.lock" }} * add names for save\restore steps * ci: add `default` step with `working_directory: ~/repo` to ios-build job * return back caching npm: `node-v1-{{ checksum "package.json" }}-{{ arch }}` * fix: add missing curly braces * save\restore cache in e2e-test job; remove {{arch}} from cache names * add names to restore_cache steps in android-build job * add names to save_cache steps in android-build job * add names to all save\restore steps; change checksum package.json to yarn.lock * change `npm` to `NPM` in steps naming * remove {{ checksum circle ci }} from android-build job and fix naming of steps * [FIX] Rooms swipes (#1034) * Regression: on press style feedback * Action button styles * Fix animations * Styles changed * Update subscription without having to wait for socket * Calculate width on RoomsListView instead * [FIX] Decrease bigger emoji size to 30 (#1031) * [FIX] Append server URL on avatar if necessary (#1038) * Comment removeClippedSubviews * Comment width animation * Remove redux from RoomItem * Fix wrong re-render comparison * Remove listener * Raise minDeltaX * memo actions * Spring with native driver * Refactor functions * Fix props issues * Remove RoomItem.height * Long swipe * Refactor animations * this.rowTranslation -> this.transX * Moved state to this * Bump version to 1.16.1 (#1045) * [FIX] Set UserDefaults AppGroup on notification tap (#1047) * [FIX] Auto-translate messages as they arrive * Fix favorite button --- .circleci/config.yml | 86 +- __mocks__/react-native-gesture-handler.js | 1 + .../__snapshots__/Storyshots.test.js.snap | 871 +- android/app/build.gradle | 3 +- .../rocket/reactnative/MainApplication.java | 2 - .../generated/BasePackageList.java | 1 + android/settings.gradle | 2 - app/constants/colors.js | 7 +- app/constants/settings.js | 3 + app/constants/userDefaults.js | 6 + app/containers/Avatar.js | 13 +- app/containers/EmojiPicker/index.js | 22 +- app/containers/MessageActions.js | 35 +- app/containers/MessageBox/Recording.js | 11 +- app/containers/message/Markdown.js | 42 +- app/containers/message/index.js | 22 +- app/containers/message/styles.js | 9 + app/containers/message/utils.js | 12 + app/i18n/locales/en.js | 8 + app/index.js | 2 + app/lib/methods/helpers/normalizeMessage.js | 4 + app/lib/methods/sendFileMessage.js | 186 +- app/lib/realm.js | 57 +- app/lib/rocketchat.js | 62 +- app/presentation/RoomItem/Actions.js | 129 + app/presentation/RoomItem/index.js | 238 +- app/presentation/RoomItem/styles.js | 45 +- app/sagas/deepLinking.js | 11 +- app/sagas/init.js | 51 +- app/sagas/login.js | 18 +- app/sagas/selectServer.js | 24 +- app/utils/vibration.js | 11 - app/views/AutoTranslateView/index.js | 156 + app/views/CreateChannelView.js | 6 +- app/views/DirectoryView/Options.js | 3 +- app/views/LanguageView/index.js | 1 - app/views/RegisterView.js | 118 +- app/views/RoomActionsView/index.js | 19 +- app/views/RoomInfoEditView/SwitchContainer.js | 2 + app/views/RoomMembersView/index.js | 4 +- app/views/RoomView/index.js | 21 +- app/views/RoomsListView/ServerDropdown.js | 7 +- app/views/RoomsListView/index.js | 95 +- app/views/SettingsView/index.js | 8 +- app/views/SidebarView/index.js | 2 +- ios/Gemfile | 2 + ios/Gemfile.lock | 159 + ios/Podfile.lock | 7 + .../Private/EXHaptics/EXHapticsModule.h | 1 + .../Public/EXHaptics/EXHapticsModule.h | 1 + .../Local Podspecs/EXHaptics.podspec.json | 23 + ios/Pods/Manifest.lock | 7 + ios/Pods/Pods.xcodeproj/project.pbxproj | 7639 +++++++++-------- .../EXHaptics/EXHaptics-dummy.m | 5 + .../EXHaptics/EXHaptics-prefix.pch | 12 + .../EXHaptics/EXHaptics.xcconfig | 9 + .../Pods-RocketChatRN.debug.xcconfig | 6 +- .../Pods-RocketChatRN.release.xcconfig | 6 +- ios/RocketChatRN.xcodeproj/project.pbxproj | 83 +- ios/RocketChatRN/Info.plist | 2 +- ios/RocketChatRN/RocketChatRN.entitlements | 4 + ios/fastlane/Fastfile | 1 + package.json | 5 +- storybook/stories/Message.js | 12 + storybook/stories/RoomItem.js | 4 +- yarn.lock | 114 +- 66 files changed, 6441 insertions(+), 4097 deletions(-) create mode 100644 app/constants/userDefaults.js create mode 100644 app/presentation/RoomItem/Actions.js delete mode 100644 app/utils/vibration.js create mode 100644 app/views/AutoTranslateView/index.js create mode 100644 ios/Gemfile create mode 100644 ios/Gemfile.lock create mode 120000 ios/Pods/Headers/Private/EXHaptics/EXHapticsModule.h create mode 120000 ios/Pods/Headers/Public/EXHaptics/EXHapticsModule.h create mode 100644 ios/Pods/Local Podspecs/EXHaptics.podspec.json create mode 100644 ios/Pods/Target Support Files/EXHaptics/EXHaptics-dummy.m create mode 100644 ios/Pods/Target Support Files/EXHaptics/EXHaptics-prefix.pch create mode 100644 ios/Pods/Target Support Files/EXHaptics/EXHaptics.xcconfig diff --git a/.circleci/config.yml b/.circleci/config.yml index 3caf381e33..8e1745caaf 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -15,7 +15,8 @@ jobs: - checkout - restore_cache: - key: node-modules-{{ checksum ".circleci/config.yml" }}-{{ checksum "yarn.lock" }} + name: Restore NPM cache + key: node-modules-{{ checksum "yarn.lock" }} - run: name: Install NPM modules @@ -38,7 +39,8 @@ jobs: yarn codecov - save_cache: - key: node-modules-{{ checksum ".circleci/config.yml" }}-{{ checksum "yarn.lock" }} + key: node-modules-{{ checksum "yarn.lock" }} + name: Save NPM cache paths: - ./node_modules @@ -52,6 +54,10 @@ jobs: steps: - checkout + - restore_cache: + name: Restore NPM cache + key: node-v1-mac-{{ checksum "yarn.lock" }} + - run: name: Install Node 8 command: | @@ -84,6 +90,12 @@ jobs: command: | detox test --configuration ios.sim.release --cleanup + - save_cache: + name: Save NPM cache + key: node-v1-mac-{{ checksum "yarn.lock" }} + paths: + - node_modules + - store_artifacts: path: /tmp/screenshots @@ -103,7 +115,8 @@ jobs: - checkout - restore_cache: - key: node-modules-{{ checksum ".circleci/config.yml" }}-{{ checksum "yarn.lock" }} + name: Restore NPM cache + key: node-modules-{{ checksum "yarn.lock" }} - run: name: Install NPM modules @@ -111,7 +124,8 @@ jobs: yarn - restore_cache: - key: android-{{ checksum ".circleci/config.yml" }}-{{ checksum "android/build.gradle" }}-{{ checksum "android/app/build.gradle" }} + name: Restore gradle cache + key: android-{{ checksum "android/build.gradle" }}-{{ checksum "android/app/build.gradle" }} - run: name: Configure Gradle @@ -155,12 +169,14 @@ jobs: path: /tmp/build/outputs - save_cache: - key: node-modules-{{ checksum ".circleci/config.yml" }}-{{ checksum "yarn.lock" }} + name: Save NPM cache + key: node-modules-{{ checksum "yarn.lock" }} paths: - ./node_modules - save_cache: - key: android-{{ checksum ".circleci/config.yml" }}-{{ checksum "android/build.gradle" }}-{{ checksum "android/app/build.gradle" }} + name: Save gradle cache + key: android-{{ checksum "android/build.gradle" }}-{{ checksum "android/app/build.gradle" }} paths: - ~/.gradle @@ -174,6 +190,14 @@ jobs: steps: - checkout + - restore_cache: + name: Restore gems cache + key: bundle-v1-{{ checksum "ios/Gemfile.lock" }} + + - restore_cache: + name: Restore NPM cache + key: node-v1-mac-{{ checksum "yarn.lock" }} + - run: name: Install Node 8 command: | @@ -184,37 +208,48 @@ jobs: nvm install 8 - run: - name: Update Fastlane + name: Install NPM modules command: | - brew update - brew install ruby - sudo gem install fastlane + yarn - run: - name: Install NPM modules + name: Update Fastlane command: | - yarn + sudo bundle install + working_directory: ios - run: name: Set Google Services command: | - cd ios cp GoogleService-Info.prod.plist GoogleService-Info.plist + working_directory: ios - run: name: Fastlane Build no_output_timeout: 1200 command: | - cd ios agvtool new-version -all $CIRCLE_BUILD_NUM if [[ $MATCH_KEYCHAIN_NAME ]]; then - fastlane ios release + bundle exec fastlane ios release else export MATCH_KEYCHAIN_NAME="temp" export MATCH_KEYCHAIN_PASSWORD="temp" - fastlane ios build + bundle exec fastlane ios build fi + working_directory: ios + + - save_cache: + name: Save NPM cache + key: node-v1-mac-{{ checksum "yarn.lock" }} + paths: + - node_modules + + - save_cache: + name: Save gems cache + key: bundle-v1-{{ checksum "ios/Gemfile.lock" }} + paths: + - vendor/bundle - store_artifacts: path: ios/RocketChatRN.ipa @@ -235,18 +270,27 @@ jobs: - attach_workspace: at: ios + - restore_cache: + name: Restore gems cache + key: bundle-v1-{{ checksum "ios/Gemfile.lock" }} + - run: name: Update Fastlane command: | - brew update - brew install ruby - sudo gem install fastlane + sudo bundle install + working_directory: ios - run: name: Fastlane Tesflight Upload command: | - cd ios - fastlane pilot upload --ipa ios/RocketChatRN.ipa --changelog "$(sh ../.circleci/changelog.sh)" + bundle exec fastlane pilot upload --ipa ios/RocketChatRN.ipa --changelog "$(sh ../.circleci/changelog.sh)" + working_directory: ios + + - save_cache: + name: Save gems cache + key: bundle-v1-{{ checksum "ios/Gemfile.lock" }} + paths: + - vendor/bundle workflows: version: 2 diff --git a/__mocks__/react-native-gesture-handler.js b/__mocks__/react-native-gesture-handler.js index da7b586dd2..2f9960f4ab 100644 --- a/__mocks__/react-native-gesture-handler.js +++ b/__mocks__/react-native-gesture-handler.js @@ -2,3 +2,4 @@ export const RectButton = () => 'View'; export const State = () => 'View'; export const LongPressGestureHandler = () => 'View'; export const BorderlessButton = () => 'View'; +export const PanGestureHandler = () => 'View'; diff --git a/__tests__/__snapshots__/Storyshots.test.js.snap b/__tests__/__snapshots__/Storyshots.test.js.snap index 335bfb8b39..67f5c84e20 100644 --- a/__tests__/__snapshots__/Storyshots.test.js.snap +++ b/__tests__/__snapshots__/Storyshots.test.js.snap @@ -2780,7 +2780,7 @@ exports[`Storyshots Message list 1`] = ` "backgroundColor": "transparent", "color": "#2F343D", "fontFamily": "System", - "fontSize": 16, + "fontSize": 30, "fontWeight": "400", } } @@ -2812,7 +2812,7 @@ exports[`Storyshots Message list 1`] = ` ] } > - Custom Emojis + Single Emoji + + 👏 + + + + + + + + + + + Custom Emojis + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + + @@ -3013,8 +3217,8 @@ exports[`Storyshots Message list 1`] = ` } style={ Object { - "height": 20, - "width": 20, + "height": 30, + "width": 30, } } /> @@ -3029,8 +3233,8 @@ exports[`Storyshots Message list 1`] = ` } style={ Object { - "height": 20, - "width": 20, + "height": 30, + "width": 30, } } /> @@ -3042,6 +3246,657 @@ exports[`Storyshots Message list 1`] = ` + + Single Custom Emojis + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + + + + + + + + + + + + + Normal Emoji + Custom Emojis + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + + + + 🤙 + + + + + + + + + + + + Four emoji + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + + + + 🤙 + + + + 🤙🤙 + + + + + + + + + getPackages() { new RNDeviceInfo(), new PickerPackage(), new VectorIconsPackage(), - new RNFetchBlobPackage(), new RealmReactPackage(), new ReactVideoPackage(), new ReactNativeAudioPackage(), diff --git a/android/app/src/main/java/chat/rocket/reactnative/generated/BasePackageList.java b/android/app/src/main/java/chat/rocket/reactnative/generated/BasePackageList.java index 5e9c777105..88c9764657 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/generated/BasePackageList.java +++ b/android/app/src/main/java/chat/rocket/reactnative/generated/BasePackageList.java @@ -9,6 +9,7 @@ public List getPackageList() { return Arrays.asList( new expo.modules.constants.ConstantsPackage(), new expo.modules.filesystem.FileSystemPackage(), + new expo.modules.haptics.HapticsPackage(), new expo.modules.permissions.PermissionsPackage(), new expo.modules.webbrowser.WebBrowserPackage() ); diff --git a/android/settings.gradle b/android/settings.gradle index 6018bd5298..e29a00fb70 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -18,8 +18,6 @@ include ':react-native-device-info' project(':react-native-device-info').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-device-info/android') include ':react-native-gesture-handler' project(':react-native-gesture-handler').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-gesture-handler/android') -include ':rn-fetch-blob' -project(':rn-fetch-blob').projectDir = new File(rootProject.projectDir, '../node_modules/rn-fetch-blob/android') include ':react-native-image-crop-picker' project(':react-native-image-crop-picker').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-image-crop-picker/android') include ':react-native-i18n' diff --git a/app/constants/colors.js b/app/constants/colors.js index a1fa7cee7c..b65cbc22a6 100644 --- a/app/constants/colors.js +++ b/app/constants/colors.js @@ -1,4 +1,4 @@ -import { isIOS } from '../utils/deviceInfo'; +import { isIOS, isAndroid } from '../utils/deviceInfo'; export const COLOR_DANGER = '#f5455c'; export const COLOR_SUCCESS = '#2de0a5'; @@ -25,3 +25,8 @@ export const HEADER_BACKGROUND = isIOS ? '#f8f8f8' : '#2F343D'; export const HEADER_TITLE = isIOS ? COLOR_TITLE : COLOR_WHITE; export const HEADER_BACK = isIOS ? COLOR_PRIMARY : COLOR_WHITE; export const HEADER_TINT = isIOS ? COLOR_PRIMARY : COLOR_WHITE; + +export const SWITCH_TRACK_COLOR = { + false: isAndroid ? COLOR_DANGER : null, + true: COLOR_SUCCESS +}; diff --git a/app/constants/settings.js b/app/constants/settings.js index 1ae229a182..4e4d993956 100644 --- a/app/constants/settings.js +++ b/app/constants/settings.js @@ -70,5 +70,8 @@ export default { }, API_Gitlab_URL: { type: 'valueAsString' + }, + AutoTranslate_Enabled: { + type: 'valueAsBoolean' } }; diff --git a/app/constants/userDefaults.js b/app/constants/userDefaults.js new file mode 100644 index 0000000000..e21b7b852d --- /dev/null +++ b/app/constants/userDefaults.js @@ -0,0 +1,6 @@ +export const SERVERS = 'kServers'; +export const TOKEN = 'kAuthToken'; +export const USER_ID = 'kUserId'; +export const SERVER_URL = 'kAuthServerURL'; +export const SERVER_NAME = 'kServerName'; +export const SERVER_ICON = 'kServerIconURL'; diff --git a/app/containers/Avatar.js b/app/containers/Avatar.js index 41eea348c6..c616788cf1 100644 --- a/app/containers/Avatar.js +++ b/app/containers/Avatar.js @@ -3,6 +3,10 @@ import PropTypes from 'prop-types'; import { View } from 'react-native'; import FastImage from 'react-native-fast-image'; +const formatUrl = (url, baseUrl, uriSize, avatarAuthURLFragment) => ( + `${ baseUrl }${ url }?format=png&width=${ uriSize }&height=${ uriSize }${ avatarAuthURLFragment }` +); + const Avatar = React.memo(({ text, size, baseUrl, borderRadius, style, avatar, type, children, userId, token }) => { @@ -26,7 +30,14 @@ const Avatar = React.memo(({ avatarAuthURLFragment = `&rc_token=${ token }&rc_uid=${ userId }`; } - const uri = avatar || `${ baseUrl }/avatar/${ room }?format=png&width=${ uriSize }&height=${ uriSize }${ avatarAuthURLFragment }`; + + let uri; + if (avatar) { + uri = avatar.includes('http') ? avatar : formatUrl(avatar, baseUrl, uriSize, avatarAuthURLFragment); + } else { + uri = formatUrl(`/avatar/${ room }`, baseUrl, uriSize, avatarAuthURLFragment); + } + const image = ( { categories.tabs.map((tab, i) => ( - - {this.renderCategory(tab.category, i)} - - )) + (i === 0 && frequentlyUsed.length === 0) ? null // when no frequentlyUsed don't show the tab + : ( + + {this.renderCategory(tab.category, i)} + + ))) } ); diff --git a/app/containers/MessageActions.js b/app/containers/MessageActions.js index bc798c7720..a3f7cbd144 100644 --- a/app/containers/MessageActions.js +++ b/app/containers/MessageActions.js @@ -4,6 +4,8 @@ import { Alert, Clipboard, Share } from 'react-native'; import { connect } from 'react-redux'; import ActionSheet from 'react-native-action-sheet'; import moment from 'moment'; +import * as Haptics from 'expo-haptics'; + import { actionsHide as actionsHideAction, deleteRequest as deleteRequestAction, @@ -13,11 +15,12 @@ import { toggleReactionPicker as toggleReactionPickerAction, toggleStarRequest as toggleStarRequestAction } from '../actions/messages'; -import { vibrate } from '../utils/vibration'; import RocketChat from '../lib/rocketchat'; +import database from '../lib/realm'; import I18n from '../i18n'; import log from '../utils/log'; import Navigation from '../lib/Navigation'; +import { getMessageTranslation } from './message/utils'; @connect( state => ({ @@ -46,7 +49,7 @@ export default class MessageActions extends React.Component { room: PropTypes.object.isRequired, actionMessage: PropTypes.object, toast: PropTypes.element, - // user: PropTypes.object.isRequired, + user: PropTypes.object, deleteRequest: PropTypes.func.isRequired, editInit: PropTypes.func.isRequired, toggleStarRequest: PropTypes.func.isRequired, @@ -127,6 +130,12 @@ export default class MessageActions extends React.Component { this.READ_RECEIPT_INDEX = this.options.length - 1; } + // Toggle Auto-translate + if (props.room.autoTranslate && props.actionMessage.u && props.actionMessage.u._id !== props.user.id) { + this.options.push(I18n.t(props.actionMessage.autoTranslate ? 'View_Original' : 'Translate')); + this.TOGGLE_TRANSLATION_INDEX = this.options.length - 1; + } + // Report this.options.push(I18n.t('Report')); this.REPORT_INDEX = this.options.length - 1; @@ -138,7 +147,7 @@ export default class MessageActions extends React.Component { } setTimeout(() => { this.showActionSheet(); - vibrate(); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); }); } @@ -326,6 +335,23 @@ export default class MessageActions extends React.Component { } } + handleToggleTranslation = async() => { + const { actionMessage, room } = this.props; + try { + const message = database.objectForPrimaryKey('messages', actionMessage._id); + database.write(() => { + message.autoTranslate = !message.autoTranslate; + message._updatedAt = new Date(); + }); + const translatedMessage = getMessageTranslation(message, room.autoTranslateLanguage); + if (!translatedMessage) { + await RocketChat.translateMessage(actionMessage, room.autoTranslateLanguage); + } + } catch (err) { + log('err_toggle_translation', err); + } + } + handleActionPress = (actionIndex) => { if (actionIndex) { switch (actionIndex) { @@ -365,6 +391,9 @@ export default class MessageActions extends React.Component { case this.READ_RECEIPT_INDEX: this.handleReadReceipt(); break; + case this.TOGGLE_TRANSLATION_INDEX: + this.handleToggleTranslation(); + break; default: break; } diff --git a/app/containers/MessageBox/Recording.js b/app/containers/MessageBox/Recording.js index d30a9cf193..96f45c9b72 100644 --- a/app/containers/MessageBox/Recording.js +++ b/app/containers/MessageBox/Recording.js @@ -44,13 +44,14 @@ export default class extends React.PureComponent { this.recordingCanceled = false; this.recording = true; + this.name = `${ Date.now() }.aac`; this.state = { currentTime: '00:00' }; } componentDidMount() { - const audioPath = `${ AudioUtils.CachesDirectoryPath }/${ Date.now() }.aac`; + const audioPath = `${ AudioUtils.CachesDirectoryPath }/${ this.name }`; AudioRecorder.prepareRecordingAtPath(audioPath, { SampleRate: 22050, @@ -84,12 +85,14 @@ export default class extends React.PureComponent { if (!didSucceed) { return onFinish && onFinish(didSucceed); } - - const path = filePath.startsWith('file://') ? filePath.split('file://')[1] : filePath; + if (isAndroid) { + filePath = filePath.startsWith('file://') ? filePath : `file://${ filePath }`; + } const fileInfo = { + name: this.name, type: 'audio/aac', store: 'Uploads', - path + path: filePath }; return onFinish && onFinish(fileInfo); } diff --git a/app/containers/message/Markdown.js b/app/containers/message/Markdown.js index ae8cc77aff..403af4872f 100644 --- a/app/containers/message/Markdown.js +++ b/app/containers/message/Markdown.js @@ -20,6 +20,33 @@ const formatText = text => text.replace( (match, url, title) => `[${ title }](${ url })` ); +const emojiRanges = [ + '\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]', // unicode emoji from https://www.regextester.com/106421 + ':.{1,40}:', // custom emoji + ' |\n' // allow spaces and line breaks +].join('|'); + +const removeAllEmoji = str => str.replace(new RegExp(emojiRanges, 'g'), ''); + +const isOnlyEmoji = str => !removeAllEmoji(str).length; + +const removeOneEmoji = str => str.replace(new RegExp(emojiRanges), ''); + +const emojiCount = (str) => { + let oldLength = 0; + let counter = 0; + + while (oldLength !== str.length) { + oldLength = str.length; + str = removeOneEmoji(str); + if (oldLength !== str.length) { + counter += 1; + } + } + + return counter; +}; + const Markdown = React.memo(({ msg, style, rules, baseUrl, username, isEdited, numberOfLines, mentions, channels, getCustomEmoji, useMarkdown = true }) => { @@ -30,7 +57,7 @@ const Markdown = React.memo(({ if (m) { m = emojify(m, { output: 'unicode' }); } - m = m.replace(/^\[([^\]]*)\]\(([^)]*)\)/, '').trim(); + m = m.replace(/^\[([^\]]*)\]\(([^)]*)\)\s/, '').trim(); if (numberOfLines > 0) { m = m.replace(/[\n]+/g, '\n').trim(); } @@ -39,6 +66,8 @@ const Markdown = React.memo(({ return {m}; } + const isMessageContainsOnlyEmoji = isOnlyEmoji(m) && emojiCount(m) <= 3; + return ( ; + return ( + + ); } return :{content}:; } @@ -102,7 +138,7 @@ const Markdown = React.memo(({ }} style={{ paragraph: styles.paragraph, - text: styles.text, + text: isMessageContainsOnlyEmoji ? styles.textBig : styles.text, codeInline: styles.codeInline, codeBlock: styles.codeBlock, link: styles.link, diff --git a/app/containers/message/index.js b/app/containers/message/index.js index 478055ad09..de62fe758b 100644 --- a/app/containers/message/index.js +++ b/app/containers/message/index.js @@ -4,7 +4,7 @@ import { KeyboardUtils } from 'react-native-keyboard-input'; import Message from './Message'; import debounce from '../../utils/debounce'; -import { SYSTEM_MESSAGES, getCustomEmoji } from './utils'; +import { SYSTEM_MESSAGES, getCustomEmoji, getMessageTranslation } from './utils'; import messagesStatus from '../../constants/messagesStatus'; export default class MessageContainer extends React.Component { @@ -27,6 +27,8 @@ export default class MessageContainer extends React.Component { isReadReceiptEnabled: PropTypes.bool, useRealName: PropTypes.bool, useMarkdown: PropTypes.bool, + autoTranslateRoom: PropTypes.bool, + autoTranslateLanguage: PropTypes.string, status: PropTypes.number, onLongPress: PropTypes.func, onReactionPress: PropTypes.func, @@ -49,12 +51,15 @@ export default class MessageContainer extends React.Component { shouldComponentUpdate(nextProps) { const { - status, item, _updatedAt + status, item, _updatedAt, autoTranslateRoom } = this.props; if (status !== nextProps.status) { return true; } + if (autoTranslateRoom !== nextProps.autoTranslateRoom) { + return true; + } if (item.tmsg !== nextProps.item.tmsg) { return true; } @@ -191,16 +196,23 @@ export default class MessageContainer extends React.Component { render() { const { - item, user, style, archived, baseUrl, useRealName, broadcast, fetchThreadName, customThreadTimeFormat, onOpenFileModal, timeFormat, useMarkdown, isReadReceiptEnabled + item, user, style, archived, baseUrl, useRealName, broadcast, fetchThreadName, customThreadTimeFormat, onOpenFileModal, timeFormat, useMarkdown, isReadReceiptEnabled, autoTranslateRoom, autoTranslateLanguage } = this.props; const { - _id, msg, ts, attachments, urls, reactions, t, avatar, u, alias, editedBy, role, drid, dcount, dlm, tmid, tcount, tlm, tmsg, mentions, channels, unread + _id, msg, ts, attachments, urls, reactions, t, avatar, u, alias, editedBy, role, drid, dcount, dlm, tmid, tcount, tlm, tmsg, mentions, channels, unread, autoTranslate: autoTranslateMessage } = item; + let message = msg; + // "autoTranslateRoom" and "autoTranslateLanguage" are properties from the subscription + // "autoTranslateMessage" is a toggle between "View Original" and "Translate" state + if (autoTranslateRoom && autoTranslateMessage) { + message = getMessageTranslation(item, autoTranslateLanguage) || message; + } + return ( { }); return findByAlias; }; + +export const getMessageTranslation = (message, autoTranslateLanguage) => { + if (!autoTranslateLanguage) { + return null; + } + const { translations } = message; + if (translations) { + const translation = translations.find(trans => trans.language === autoTranslateLanguage); + return translation && translation.value; + } + return null; +}; diff --git a/app/i18n/locales/en.js b/app/i18n/locales/en.js index 08657afabe..e390793f9a 100644 --- a/app/i18n/locales/en.js +++ b/app/i18n/locales/en.js @@ -99,6 +99,7 @@ export default { Are_you_sure_question_mark: 'Are you sure?', Are_you_sure_you_want_to_leave_the_room: 'Are you sure you want to leave the room {{room}}?', Authenticating: 'Authenticating', + Auto_Translate: 'Auto-Translate', Avatar_changed_successfully: 'Avatar changed successfully!', Avatar_Url: 'Avatar URL', Away: 'Away', @@ -155,11 +156,13 @@ export default { Email_or_password_field_is_empty: 'Email or password field is empty', Email: 'Email', email: 'e-mail', + Enable_Auto_Translate: 'Enable Auto-Translate', Enable_markdown: 'Enable markdown', Enable_notifications: 'Enable notifications', Everyone_can_access_this_channel: 'Everyone can access this channel', erasing_room: 'erasing room', Error_uploading: 'Error uploading', + Favorite: 'Favorite', Favorites: 'Favorites', Files: 'Files', File_description: 'File description', @@ -173,6 +176,7 @@ export default { Forgot_Password: 'Forgot Password', Group_by_favorites: 'Group favorites', Group_by_type: 'Group by type', + Hide: 'Hide', Has_joined_the_channel: 'Has joined the channel', Has_joined_the_conversation: 'Has joined the conversation', Has_left_the_channel: 'Has left the channel', @@ -266,6 +270,7 @@ export default { Reactions_are_disabled: 'Reactions are disabled', Reactions_are_enabled: 'Reactions are enabled', Reactions: 'Reactions', + Read: 'Read', Read_Only_Channel: 'Read Only Channel', Read_Only: 'Read Only', Read_Receipt: 'Read Receipt', @@ -343,12 +348,14 @@ export default { Timezone: 'Timezone', topic: 'topic', Topic: 'Topic', + Translate: 'Translate', Try_again: 'Try again', Two_Factor_Authentication: 'Two-factor Authentication', Type_the_channel_name_here: 'Type the channel name here', unarchive: 'unarchive', UNARCHIVE: 'UNARCHIVE', Unblock_user: 'Unblock user', + Unfavorite: 'Unfavorite', Unfollowed_thread: 'Unfollowed thread', Unmute: 'Unmute', unmuted: 'unmuted', @@ -374,6 +381,7 @@ export default { Username_or_email: 'Username or email', Validating: 'Validating', Video_call: 'Video call', + View_Original: 'View Original', Voice_call: 'Voice call', Welcome: 'Welcome', Welcome_to_RocketChat: 'Welcome to Rocket.Chat', diff --git a/app/index.js b/app/index.js index 04afb221a2..0eb58053f4 100644 --- a/app/index.js +++ b/app/index.js @@ -33,6 +33,7 @@ import SearchMessagesView from './views/SearchMessagesView'; import ReadReceiptsView from './views/ReadReceiptView'; import ThreadMessagesView from './views/ThreadMessagesView'; import MessagesView from './views/MessagesView'; +import AutoTranslateView from './views/AutoTranslateView'; import SelectedUsersView from './views/SelectedUsersView'; import CreateChannelView from './views/CreateChannelView'; import LegalView from './views/LegalView'; @@ -116,6 +117,7 @@ const ChatsStack = createStackNavigator({ SelectedUsersView, ThreadMessagesView, MessagesView, + AutoTranslateView, ReadReceiptsView, DirectoryView }, { diff --git a/app/lib/methods/helpers/normalizeMessage.js b/app/lib/methods/helpers/normalizeMessage.js index 39fa9dae05..9005d145fb 100644 --- a/app/lib/methods/helpers/normalizeMessage.js +++ b/app/lib/methods/helpers/normalizeMessage.js @@ -36,6 +36,10 @@ export default (msg) => { if (!Array.isArray(msg.reactions)) { msg.reactions = Object.keys(msg.reactions).map(key => ({ _id: `${ msg._id }${ key }`, emoji: key, usernames: msg.reactions[key].usernames })); } + if (msg.translations && Object.keys(msg.translations).length) { + msg.translations = Object.keys(msg.translations).map(key => ({ _id: `${ msg._id }${ key }`, language: key, value: msg.translations[key] })); + msg.autoTranslate = true; + } msg.urls = msg.urls ? parseUrls(msg.urls) : []; msg._updatedAt = new Date(); // loadHistory returns msg.starred as object diff --git a/app/lib/methods/sendFileMessage.js b/app/lib/methods/sendFileMessage.js index 57d0f9a16e..08da0b3742 100644 --- a/app/lib/methods/sendFileMessage.js +++ b/app/lib/methods/sendFileMessage.js @@ -1,111 +1,129 @@ -import RNFetchBlob from 'rn-fetch-blob'; - import reduxStore from '../createStore'; import database from '../realm'; import log from '../../utils/log'; -const promises = {}; +const uploadQueue = {}; -function _ufsCreate(fileInfo) { - return this.sdk.methodCall('ufsCreate', fileInfo); +export function isUploadActive(path) { + return !!uploadQueue[path]; } -function _ufsComplete(fileId, store, token) { - return this.sdk.methodCall('ufsComplete', fileId, store, token); +export function cancelUpload(path) { + if (uploadQueue[path]) { + uploadQueue[path].abort(); + database.write(() => { + const upload = database.objects('uploads').filtered('path = $0', path); + try { + database.delete(upload); + } catch (e) { + log('err_send_file_message_delete_upload', e); + } + }); + delete uploadQueue[path]; + } } -function _sendFileMessage(rid, data, msg = {}) { - // RC 0.22.0 - return this.sdk.methodCall('sendFileMessage', rid, null, data, msg); -} +export function sendFileMessage(rid, fileInfo, tmid) { + return new Promise((resolve, reject) => { + try { + const { FileUpload_MaxFileSize, Site_Url } = reduxStore.getState().settings; + const { id, token } = reduxStore.getState().login.user; -export function isUploadActive(path) { - return !!promises[path]; -} + // -1 maxFileSize means there is no limit + if (FileUpload_MaxFileSize > -1 && fileInfo.size > FileUpload_MaxFileSize) { + return reject({ error: 'error-file-too-large' }); // eslint-disable-line + } -export async function cancelUpload(path) { - if (promises[path]) { - await promises[path].cancel(); - } -} + const uploadUrl = `${ Site_Url }/api/v1/rooms.upload/${ rid }`; -export async function sendFileMessage(rid, fileInfo, tmid) { - try { - const data = await RNFetchBlob.wrap(fileInfo.path); - if (!fileInfo.size) { - const fileStat = await RNFetchBlob.fs.stat(fileInfo.path); - fileInfo.size = fileStat.size; - fileInfo.name = fileStat.filename; - } + const xhr = new XMLHttpRequest(); + const formData = new FormData(); - const { FileUpload_MaxFileSize } = reduxStore.getState().settings; + fileInfo.rid = rid; - // -1 maxFileSize means there is no limit - if (FileUpload_MaxFileSize > -1 && fileInfo.size > FileUpload_MaxFileSize) { - return Promise.reject({ error: 'error-file-too-large' }); // eslint-disable-line - } + database.write(() => { + try { + database.create('uploads', fileInfo, true); + } catch (e) { + return log('err_send_file_message_create_upload_1', e); + } + }); - fileInfo.rid = rid; + uploadQueue[fileInfo.path] = xhr; + xhr.open('POST', uploadUrl); - database.write(() => { - try { - database.create('uploads', fileInfo, true); - } catch (e) { - return log('err_send_file_message_create_upload_1', e); + formData.append('file', { + uri: fileInfo.path, + type: fileInfo.type, + name: fileInfo.name || 'fileMessage' + }); + + if (fileInfo.description) { + formData.append('description', fileInfo.description); + } + + if (tmid) { + formData.append('tmid', tmid); } - }); - const result = await _ufsCreate.call(this, fileInfo); + xhr.setRequestHeader('X-Auth-Token', token); + xhr.setRequestHeader('X-User-Id', id); + + xhr.upload.onprogress = ({ total, loaded }) => { + database.write(() => { + fileInfo.progress = Math.floor((loaded / total) * 100); + try { + database.create('uploads', fileInfo, true); + } catch (e) { + return log('err_send_file_message_create_upload_2', e); + } + }); + }; - promises[fileInfo.path] = RNFetchBlob.fetch('POST', result.url, { - 'Content-Type': 'octet-stream' - }, data); - // Workaround for https://github.com/joltup/rn-fetch-blob/issues/96 - setTimeout(() => { - if (promises[fileInfo.path] && promises[fileInfo.path].uploadProgress) { - promises[fileInfo.path].uploadProgress((loaded, total) => { + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 400) { // If response is all good... database.write(() => { - fileInfo.progress = Math.floor((loaded / total) * 100); + const upload = database.objects('uploads').filtered('path = $0', fileInfo.path); try { - database.create('uploads', fileInfo, true); + database.delete(upload); + const response = JSON.parse(xhr.response); + resolve(response); } catch (e) { - return log('err_send_file_message_create_upload_2', e); + reject(e); + log('err_send_file_message_delete_upload', e); + } + }); + } else { + database.write(() => { + fileInfo.error = true; + try { + database.create('uploads', fileInfo, true); + const response = JSON.parse(xhr.response); + reject(response); + } catch (err) { + reject(err); + log('err_send_file_message_create_upload_3', err); } }); + } + }; + + xhr.onerror = (e) => { + database.write(() => { + fileInfo.error = true; + try { + database.create('uploads', fileInfo, true); + reject(e); + } catch (err) { + reject(err); + log('err_send_file_message_create_upload_3', err); + } }); - } - }); - await promises[fileInfo.path]; - - const completeResult = await _ufsComplete.call(this, result.fileId, fileInfo.store, result.token); - - await _sendFileMessage.call(this, completeResult.rid, { - _id: completeResult._id, - type: completeResult.type, - size: completeResult.size, - name: completeResult.name, - description: completeResult.description, - url: completeResult.path - }, { - tmid - }); + }; - database.write(() => { - const upload = database.objects('uploads').filtered('path = $0', fileInfo.path); - try { - database.delete(upload); - } catch (e) { - log('err_send_file_message_delete_upload', e); - } - }); - } catch (e) { - database.write(() => { - fileInfo.error = true; - try { - database.create('uploads', fileInfo, true); - } catch (err) { - log('err_send_file_message_create_upload_3', err); - } - }); - } + xhr.send(formData); + } catch (err) { + log('err_send_file_message_create_upload_4', err); + } + }); } diff --git a/app/lib/realm.js b/app/lib/realm.js index f296752b85..7b4277882f 100644 --- a/app/lib/realm.js +++ b/app/lib/realm.js @@ -4,6 +4,20 @@ import Realm from 'realm'; // Realm.clearTestState(); // AsyncStorage.clear(); +const userSchema = { + name: 'user', + primaryKey: 'id', + properties: { + id: 'string', + token: { type: 'string', optional: true }, + username: { type: 'string', optional: true }, + name: { type: 'string', optional: true }, + language: { type: 'string', optional: true }, + status: { type: 'string', optional: true }, + roles: { type: 'string[]', optional: true } + } +}; + const serversSchema = { name: 'servers', primaryKey: 'id', @@ -82,7 +96,9 @@ const subscriptionSchema = { broadcast: { type: 'bool', optional: true }, prid: { type: 'string', optional: true }, draftMessage: { type: 'string', optional: true }, - lastThreadSync: 'date?' + lastThreadSync: 'date?', + autoTranslate: 'bool?', + autoTranslateLanguage: 'string?' } }; @@ -157,6 +173,16 @@ const messagesReactionsSchema = { } }; +const messagesTranslationsSchema = { + name: 'messagesTranslations', + primaryKey: '_id', + properties: { + _id: 'string', + language: 'string', + value: 'string' + } +}; + const messagesEditedBySchema = { name: 'messagesEditedBy', primaryKey: '_id', @@ -198,7 +224,9 @@ const messagesSchema = { replies: 'string[]', mentions: { type: 'list', objectType: 'users' }, channels: { type: 'list', objectType: 'rooms' }, - unread: { type: 'bool', optional: true } + unread: { type: 'bool', optional: true }, + autoTranslate: { type: 'bool', default: false }, + translations: { type: 'list', objectType: 'messagesTranslations' } } }; @@ -232,6 +260,11 @@ const threadsSchema = { tcount: { type: 'int', optional: true }, tlm: { type: 'date', optional: true }, replies: 'string[]', + mentions: { type: 'list', objectType: 'users' }, + channels: { type: 'list', objectType: 'rooms' }, + unread: { type: 'bool', optional: true }, + autoTranslate: { type: 'bool', default: false }, + translations: { type: 'list', objectType: 'messagesTranslations' }, draftMessage: 'string?' } }; @@ -258,7 +291,13 @@ const threadMessagesSchema = { starred: { type: 'bool', optional: true }, editedBy: 'messagesEditedBy', reactions: { type: 'list', objectType: 'messagesReactions' }, - role: { type: 'string', optional: true } + role: { type: 'string', optional: true }, + replies: 'string[]', + mentions: { type: 'list', objectType: 'users' }, + channels: { type: 'list', objectType: 'rooms' }, + unread: { type: 'bool', optional: true }, + autoTranslate: { type: 'bool', default: false }, + translations: { type: 'list', objectType: 'messagesTranslations' } } }; @@ -360,7 +399,8 @@ const schema = [ messagesReactionsSchema, rolesSchema, uploadsSchema, - slashCommandSchema + slashCommandSchema, + messagesTranslationsSchema ]; const inMemorySchema = [usersTypingSchema, activeUsersSchema]; @@ -370,11 +410,12 @@ class DB { serversDB: new Realm({ path: 'default.realm', schema: [ + userSchema, serversSchema ], - schemaVersion: 8, + schemaVersion: 9, migration: (oldRealm, newRealm) => { - if (oldRealm.schemaVersion >= 1 && newRealm.schemaVersion <= 8) { + if (oldRealm.schemaVersion >= 1 && newRealm.schemaVersion <= 9) { const newServers = newRealm.objects('servers'); // eslint-disable-next-line no-plusplus @@ -429,9 +470,9 @@ class DB { return this.databases.activeDB = new Realm({ path: `${ path }.realm`, schema, - schemaVersion: 12, + schemaVersion: 13, migration: (oldRealm, newRealm) => { - if (oldRealm.schemaVersion >= 3 && newRealm.schemaVersion <= 11) { + if (oldRealm.schemaVersion >= 3 && newRealm.schemaVersion <= 13) { const newSubs = newRealm.objects('subscriptions'); newRealm.delete(newSubs); const newMessages = newRealm.objects('messages'); diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js index a0118f5e79..364a438161 100644 --- a/app/lib/rocketchat.js +++ b/app/lib/rocketchat.js @@ -1,6 +1,7 @@ import { AsyncStorage, InteractionManager } from 'react-native'; import semver from 'semver'; import { Rocketchat as RocketchatClient } from '@rocket.chat/sdk'; +import RNUserDefaults from 'rn-user-defaults'; import reduxStore from './createStore'; import defaultSettings from '../constants/settings'; @@ -36,6 +37,7 @@ import sendMessage, { getMessage, sendMessageCall } from './methods/sendMessage' import { sendFileMessage, cancelUpload, isUploadActive } from './methods/sendFileMessage'; import { getDeviceToken } from '../notifications/push'; +import { SERVERS, SERVER_URL } from '../constants/userDefaults'; const TOKEN_KEY = 'reactnativemeteor_usertoken'; const SORT_PREFS_KEY = 'RC_SORT_PREFS_KEY'; @@ -58,9 +60,9 @@ const RocketChat = { }, async getUserToken() { try { - return await AsyncStorage.getItem(TOKEN_KEY); + return await RNUserDefaults.get(TOKEN_KEY); } catch (error) { - console.warn(`AsyncStorage error: ${ error.message }`); + console.warn(`RNUserDefaults error: ${ error.message }`); } }, async getServerInfo(server) { @@ -321,10 +323,26 @@ const RocketChat = { } this.sdk = null; + try { + const servers = await RNUserDefaults.objectForKey(SERVERS); + await RNUserDefaults.setObjectForKey(SERVERS, servers && servers.filter(srv => srv[SERVER_URL] !== server)); + } catch (error) { + console.log('logout_rn_user_defaults', error); + } + + const { serversDB } = database.databases; + + const userId = await RNUserDefaults.get(`${ TOKEN_KEY }-${ server }`); + + serversDB.write(() => { + const user = serversDB.objectForPrimaryKey('user', userId); + serversDB.delete(user); + }); + Promise.all([ - AsyncStorage.removeItem('currentServer'), - AsyncStorage.removeItem(TOKEN_KEY), - AsyncStorage.removeItem(`${ TOKEN_KEY }-${ server }`) + RNUserDefaults.clear('currentServer'), + RNUserDefaults.clear(TOKEN_KEY), + RNUserDefaults.clear(`${ TOKEN_KEY }-${ server }`) ]).catch(error => console.log(error)); try { @@ -564,6 +582,12 @@ const RocketChat = { // RC 0.64.0 return this.sdk.post('rooms.favorite', { roomId, favorite }); }, + toggleRead(read, roomId) { + if (read) { + return this.sdk.post('subscriptions.unread', { roomId }); + } + return this.sdk.post('subscriptions.read', { rid: roomId }); + }, getRoomMembers(rid, allUsers, skip = 0, limit = 10) { // RC 0.42.0 return this.sdk.methodCall('getUsersOfRoom', rid, allUsers, { skip, limit }); @@ -622,6 +646,9 @@ const RocketChat = { // RC 0.48.0 return this.sdk.post(`${ this.roomTypeToApiType(t) }.unarchive`, { roomId }); }, + hideRoom(roomId, t) { + return this.sdk.post(`${ this.roomTypeToApiType(t) }.close`, { roomId }); + }, saveRoomSettings(rid, params) { // RC 0.55.0 return this.sdk.methodCall('saveRoomSettings', rid, params); @@ -863,6 +890,31 @@ const RocketChat = { return this.sdk.get('directory', { query, count, offset, sort }); + }, + canAutoTranslate() { + try { + const AutoTranslate_Enabled = reduxStore.getState().settings && reduxStore.getState().settings.AutoTranslate_Enabled; + if (!AutoTranslate_Enabled) { + return false; + } + const autoTranslatePermission = database.objectForPrimaryKey('permissions', 'auto-translate'); + const userRoles = (reduxStore.getState().login.user && reduxStore.getState().login.user.roles) || []; + return autoTranslatePermission.roles.some(role => userRoles.includes(role)); + } catch (error) { + log('err_can_auto_translate', error); + return false; + } + }, + saveAutoTranslate({ + rid, field, value, options + }) { + return this.sdk.methodCall('autoTranslate.saveSettings', rid, field, value, options); + }, + getSupportedLanguagesAutoTranslate() { + return this.sdk.methodCall('autoTranslate.getSupportedLanguages', 'en'); + }, + translateMessage(message, targetLanguage) { + return this.sdk.methodCall('autoTranslate.translateMessage', message, targetLanguage); } }; diff --git a/app/presentation/RoomItem/Actions.js b/app/presentation/RoomItem/Actions.js new file mode 100644 index 0000000000..8793dc791e --- /dev/null +++ b/app/presentation/RoomItem/Actions.js @@ -0,0 +1,129 @@ +import React from 'react'; +import { Animated, View, Text } from 'react-native'; +import { RectButton } from 'react-native-gesture-handler'; +import PropTypes from 'prop-types'; + +import I18n from '../../i18n'; +import styles, { ACTION_WIDTH, LONG_SWIPE } from './styles'; +import { CustomIcon } from '../../lib/Icons'; + +export const LeftActions = React.memo(({ + transX, isRead, width, onToggleReadPress +}) => { + const translateX = transX.interpolate({ + inputRange: [0, ACTION_WIDTH], + outputRange: [-ACTION_WIDTH, 0] + }); + const translateXIcon = transX.interpolate({ + inputRange: [0, ACTION_WIDTH, LONG_SWIPE - 2, LONG_SWIPE], + outputRange: [0, 0, -LONG_SWIPE + ACTION_WIDTH + 2, 0], + extrapolate: 'clamp' + }); + return ( + + + + + + + {I18n.t(isRead ? 'Unread' : 'Read')} + + + + + + ); +}); + +export const RightActions = React.memo(({ + transX, favorite, width, toggleFav, onHidePress +}) => { + const translateXFav = transX.interpolate({ + inputRange: [-width / 2, -ACTION_WIDTH * 2, 0], + outputRange: [width / 2, width - ACTION_WIDTH * 2, width] + }); + const translateXHide = transX.interpolate({ + inputRange: [-width, -LONG_SWIPE, -ACTION_WIDTH * 2, 0], + outputRange: [0, width - LONG_SWIPE, width - ACTION_WIDTH, width] + }); + return ( + + + + + + {I18n.t(favorite ? 'Unfavorite' : 'Favorite')} + + + + + + + + {I18n.t('Hide')} + + + + + ); +}); + +LeftActions.propTypes = { + transX: PropTypes.object, + isRead: PropTypes.bool, + width: PropTypes.number, + onToggleReadPress: PropTypes.func +}; + +RightActions.propTypes = { + transX: PropTypes.object, + favorite: PropTypes.bool, + width: PropTypes.number, + toggleFav: PropTypes.func, + onHidePress: PropTypes.func +}; diff --git a/app/presentation/RoomItem/index.js b/app/presentation/RoomItem/index.js index 28a296dc7f..1b5105f62b 100644 --- a/app/presentation/RoomItem/index.js +++ b/app/presentation/RoomItem/index.js @@ -1,25 +1,23 @@ import React from 'react'; import moment from 'moment'; import PropTypes from 'prop-types'; -import { View, Text } from 'react-native'; -import { connect } from 'react-redux'; -import { RectButton } from 'react-native-gesture-handler'; +import { View, Text, Animated } from 'react-native'; +import { RectButton, PanGestureHandler, State } from 'react-native-gesture-handler'; import Avatar from '../../containers/Avatar'; import I18n from '../../i18n'; -import styles, { ROW_HEIGHT } from './styles'; +import styles, { + ROW_HEIGHT, ACTION_WIDTH, SMALL_SWIPE, LONG_SWIPE +} from './styles'; import UnreadBadge from './UnreadBadge'; import TypeIcon from './TypeIcon'; import LastMessage from './LastMessage'; +import { LeftActions, RightActions } from './Actions'; export { ROW_HEIGHT }; -const attrs = ['name', 'unread', 'userMentions', 'showLastMessage', 'alert', 'type']; -@connect(state => ({ - userId: state.login.user && state.login.user.id, - username: state.login.user && state.login.user.username, - token: state.login.user && state.login.user.token -})) +const attrs = ['name', 'unread', 'userMentions', 'showLastMessage', 'alert', 'type', 'width', 'isRead', 'favorite']; + export default class RoomItem extends React.Component { static propTypes = { type: PropTypes.string.isRequired, @@ -39,7 +37,13 @@ export default class RoomItem extends React.Component { token: PropTypes.string, avatarSize: PropTypes.number, testID: PropTypes.string, - height: PropTypes.number + width: PropTypes.number, + favorite: PropTypes.bool, + isRead: PropTypes.bool, + rid: PropTypes.string, + toggleFav: PropTypes.func, + toggleRead: PropTypes.func, + hideChannel: PropTypes.func } static defaultProps = { @@ -50,6 +54,19 @@ export default class RoomItem extends React.Component { // eslint-disable-next-line no-useless-constructor constructor(props) { super(props); + this.dragX = new Animated.Value(0); + this.rowOffSet = new Animated.Value(0); + this.transX = Animated.add( + this.rowOffSet, + this.dragX + ); + this.state = { + rowState: 0 // 0: closed, 1: right opened, -1: left opened + }; + this._onGestureEvent = Animated.event( + [{ nativeEvent: { translationX: this.dragX } }] + ); + this._value = 0; } shouldComponentUpdate(nextProps) { @@ -60,13 +77,132 @@ export default class RoomItem extends React.Component { if (oldlastMessage && newLastmessage && oldlastMessage.ts !== newLastmessage.ts) { return true; } - if (_updatedAt && nextProps._updatedAt && nextProps._updatedAt !== _updatedAt) { + if (_updatedAt && nextProps._updatedAt && nextProps._updatedAt.toISOString() !== _updatedAt.toISOString()) { return true; } // eslint-disable-next-line react/destructuring-assignment return attrs.some(key => nextProps[key] !== this.props[key]); } + _onHandlerStateChange = ({ nativeEvent }) => { + if (nativeEvent.oldState === State.ACTIVE) { + this._handleRelease(nativeEvent); + } + }; + + _handleRelease = (nativeEvent) => { + const { translationX } = nativeEvent; + const { rowState } = this.state; + this._value = this._value + translationX; + + let toValue = 0; + if (rowState === 0) { // if no option is opened + if (translationX > 0 && translationX < LONG_SWIPE) { + toValue = ACTION_WIDTH; // open left option if he swipe right but not enough to trigger action + this.setState({ rowState: -1 }); + } else if (translationX >= LONG_SWIPE) { + toValue = 0; + this.toggleRead(); + } else if (translationX < 0 && translationX > -LONG_SWIPE) { + toValue = -2 * ACTION_WIDTH; // open right option if he swipe left + this.setState({ rowState: 1 }); + } else if (translationX <= -LONG_SWIPE) { + toValue = 0; + this.setState({ rowState: 0 }); + this.hideChannel(); + } else { + toValue = 0; + } + } + + if (rowState === -1) { // if left option is opened + if (this._value < SMALL_SWIPE) { + toValue = 0; + this.setState({ rowState: 0 }); + } else if (this._value > LONG_SWIPE) { + toValue = 0; + this.setState({ rowState: 0 }); + this.toggleRead(); + } else { + toValue = ACTION_WIDTH; + } + } + + if (rowState === 1) { // if right option is opened + if (this._value > -2 * SMALL_SWIPE) { + toValue = 0; + this.setState({ rowState: 0 }); + } else if (this._value < -LONG_SWIPE) { + toValue = 0; + this.setState({ rowState: 0 }); + this.hideChannel(); + } else { + toValue = -2 * ACTION_WIDTH; + } + } + this._animateRow(toValue); + } + + _animateRow = (toValue) => { + this.rowOffSet.setValue(this._value); + this._value = toValue; + this.dragX.setValue(0); + Animated.spring(this.rowOffSet, { + toValue, + bounciness: 0, + useNativeDriver: true + }).start(); + } + + close = () => { + this.setState({ rowState: 0 }); + this._animateRow(0); + } + + toggleFav = () => { + const { toggleFav, rid, favorite } = this.props; + if (toggleFav) { + toggleFav(rid, favorite); + } + this.close(); + } + + toggleRead = () => { + const { toggleRead, rid, isRead } = this.props; + if (toggleRead) { + toggleRead(rid, isRead); + } + } + + hideChannel = () => { + const { hideChannel, rid, type } = this.props; + if (hideChannel) { + hideChannel(rid, type); + } + } + + onToggleReadPress = () => { + this.toggleRead(); + this.close(); + } + + onHidePress = () => { + this.hideChannel(); + this.close(); + } + + onPress = () => { + const { rowState } = this.state; + if (rowState !== 0) { + this.close(); + return; + } + const { onPress } = this.props; + if (onPress) { + onPress(); + } + } + formatDate = date => moment(date).calendar(null, { lastDay: `[${ I18n.t('Yesterday') }]`, sameDay: 'h:mm A', @@ -76,7 +212,7 @@ export default class RoomItem extends React.Component { render() { const { - unread, userMentions, name, _updatedAt, alert, testID, height, type, avatarSize, baseUrl, userId, username, token, onPress, id, prid, showLastMessage, lastMessage + unread, userMentions, name, _updatedAt, alert, testID, type, avatarSize, baseUrl, userId, username, token, id, prid, showLastMessage, lastMessage, isRead, width, favorite } = this.props; const date = this.formatDate(_updatedAt); @@ -97,30 +233,60 @@ export default class RoomItem extends React.Component { } return ( - - - - - - - { name } - {_updatedAt ? { date } : null} - - - - - - - - + + + + + + + + + + + { name } + {_updatedAt ? { date } : null} + + + + + + + + + + + ); } } diff --git a/app/presentation/RoomItem/styles.js b/app/presentation/RoomItem/styles.js index 87fb92c944..6ad29fd6d0 100644 --- a/app/presentation/RoomItem/styles.js +++ b/app/presentation/RoomItem/styles.js @@ -6,14 +6,20 @@ import { } from '../../constants/colors'; export const ROW_HEIGHT = 75 * PixelRatio.getFontScale(); +export const ACTION_WIDTH = 80; +export const SMALL_SWIPE = ACTION_WIDTH / 2; +export const LONG_SWIPE = ACTION_WIDTH * 3; export default StyleSheet.create({ container: { flexDirection: 'row', alignItems: 'center', - marginLeft: 14, + paddingLeft: 14, height: ROW_HEIGHT }, + button: { + backgroundColor: COLOR_WHITE + }, centerContainer: { flex: 1, paddingVertical: 10, @@ -93,5 +99,42 @@ export default StyleSheet.create({ }, avatar: { marginRight: 10 + }, + upperContainer: { + overflow: 'hidden' + }, + actionsContainer: { + position: 'absolute', + left: 0, + right: 0, + height: ROW_HEIGHT + }, + actionText: { + color: COLOR_WHITE, + fontSize: 15, + backgroundColor: 'transparent', + justifyContent: 'center', + marginTop: 4, + ...sharedStyles.textSemibold + }, + actionLeftButtonContainer: { + position: 'absolute', + height: ROW_HEIGHT, + backgroundColor: COLOR_PRIMARY, + justifyContent: 'center', + top: 0 + }, + actionRightButtonContainer: { + position: 'absolute', + height: ROW_HEIGHT, + justifyContent: 'center', + top: 0, + backgroundColor: '#54585e' + }, + actionButton: { + width: ACTION_WIDTH, + height: '100%', + alignItems: 'center', + justifyContent: 'center' } }); diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index de7bed5948..8927316026 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -1,8 +1,8 @@ -import { AsyncStorage } from 'react-native'; import { delay } from 'redux-saga'; import { takeLatest, take, select, put, all } from 'redux-saga/effects'; +import RNUserDefaults from 'rn-user-defaults'; import Navigation from '../lib/Navigation'; import * as types from '../actions/actionsTypes'; @@ -11,6 +11,7 @@ import database from '../lib/realm'; import RocketChat from '../lib/rocketchat'; import EventEmitter from '../utils/events'; import { appStart } from '../actions'; +import { isIOS } from '../utils/deviceInfo'; const roomTypes = { channel: 'c', direct: 'd', group: 'p' @@ -33,6 +34,10 @@ const handleOpen = function* handleOpen({ params }) { return; } + if (isIOS) { + yield RNUserDefaults.setName('group.ios.chat.rocket'); + } + let { host } = params; if (!/^(http|https)/.test(host)) { host = `https://${ params.host }`; @@ -43,8 +48,8 @@ const handleOpen = function* handleOpen({ params }) { } const [server, user] = yield all([ - AsyncStorage.getItem('currentServer'), - AsyncStorage.getItem(`${ RocketChat.TOKEN_KEY }-${ host }`) + RNUserDefaults.get('currentServer'), + RNUserDefaults.get(`${ RocketChat.TOKEN_KEY }-${ host }`) ]); // TODO: needs better test diff --git a/app/sagas/init.js b/app/sagas/init.js index 4d92b3fa23..e333cee6dc 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -1,6 +1,7 @@ import { AsyncStorage } from 'react-native'; import { put, takeLatest, all } from 'redux-saga/effects'; import SplashScreen from 'react-native-splash-screen'; +import RNUserDefaults from 'rn-user-defaults'; import * as actions from '../actions'; import { selectServerRequest } from '../actions/server'; @@ -11,14 +12,54 @@ import RocketChat from '../lib/rocketchat'; import log from '../utils/log'; import Navigation from '../lib/Navigation'; import database from '../lib/realm'; +import { + SERVERS, SERVER_ICON, SERVER_NAME, SERVER_URL, TOKEN, USER_ID +} from '../constants/userDefaults'; +import { isIOS } from '../utils/deviceInfo'; const restore = function* restore() { try { - const { token, server } = yield all({ - token: AsyncStorage.getItem(RocketChat.TOKEN_KEY), - server: AsyncStorage.getItem('currentServer') + let hasMigration; + if (isIOS) { + yield RNUserDefaults.setName('group.ios.chat.rocket'); + hasMigration = yield AsyncStorage.getItem('hasMigration'); + } + + let { token, server } = yield all({ + token: RNUserDefaults.get(RocketChat.TOKEN_KEY), + server: RNUserDefaults.get('currentServer') }); + // get native credentials + if (isIOS && !hasMigration) { + const { serversDB } = database.databases; + const servers = yield RNUserDefaults.objectForKey(SERVERS); + if (servers) { + serversDB.write(() => { + servers.forEach(async(serverItem) => { + const serverInfo = { + id: serverItem[SERVER_URL], + name: serverItem[SERVER_NAME], + iconURL: serverItem[SERVER_ICON] + }; + try { + serversDB.create('servers', serverInfo, true); + await RNUserDefaults.set(`${ RocketChat.TOKEN_KEY }-${ serverInfo.id }`, serverItem[USER_ID]); + } catch (e) { + log('err_create_servers', e); + } + }); + }); + yield AsyncStorage.setItem('hasMigration', '1'); + } + + // if not have current + if (servers && servers.length !== 0 && (!token || !server)) { + server = servers[0][SERVER_URL]; + token = servers[0][TOKEN]; + } + } + const sortPreferences = yield RocketChat.getSortPreferences(); yield put(setAllPreferences(sortPreferences)); @@ -27,8 +68,8 @@ const restore = function* restore() { if (!token || !server) { yield all([ - AsyncStorage.removeItem(RocketChat.TOKEN_KEY), - AsyncStorage.removeItem('currentServer') + RNUserDefaults.clear(RocketChat.TOKEN_KEY), + RNUserDefaults.clear('currentServer') ]); yield put(actions.appStart('outside')); } else if (server) { diff --git a/app/sagas/login.js b/app/sagas/login.js index 66b4183adb..369f636ff8 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -1,7 +1,7 @@ -import { AsyncStorage } from 'react-native'; import { put, call, takeLatest, select, take, fork, cancel } from 'redux-saga/effects'; +import RNUserDefaults from 'rn-user-defaults'; import * as types from '../actions/actionsTypes'; import { appStart } from '../actions'; @@ -60,7 +60,7 @@ const fetchUserPresence = function* fetchUserPresence() { const handleLoginSuccess = function* handleLoginSuccess({ user }) { try { const adding = yield select(state => state.server.adding); - yield AsyncStorage.setItem(RocketChat.TOKEN_KEY, user.token); + yield RNUserDefaults.set(RocketChat.TOKEN_KEY, user.token); const server = yield select(getServer); yield put(roomsRequest()); @@ -72,7 +72,17 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) { yield fork(fetchUserPresence); I18n.locale = user.language; - yield AsyncStorage.setItem(`${ RocketChat.TOKEN_KEY }-${ server }`, JSON.stringify(user)); + + const { serversDB } = database.databases; + serversDB.write(() => { + try { + serversDB.create('user', user, true); + } catch (e) { + log('err_set_user_token', e); + } + }); + + yield RNUserDefaults.set(`${ RocketChat.TOKEN_KEY }-${ server }`, user.id); yield put(setUser(user)); EventEmitter.emit('connected'); @@ -105,7 +115,7 @@ const handleLogout = function* handleLogout() { // see if there's other logged in servers and selects first one if (servers.length > 0) { const newServer = servers[0].id; - const token = yield AsyncStorage.getItem(`${ RocketChat.TOKEN_KEY }-${ newServer }`); + const token = yield RNUserDefaults.get(`${ RocketChat.TOKEN_KEY }-${ newServer }`); if (token) { return yield put(selectServerRequest(newServer)); } diff --git a/app/sagas/selectServer.js b/app/sagas/selectServer.js index b6f1efec53..f6793f998d 100644 --- a/app/sagas/selectServer.js +++ b/app/sagas/selectServer.js @@ -1,7 +1,8 @@ import { put, take, takeLatest, fork, cancel, race } from 'redux-saga/effects'; -import { AsyncStorage, Alert } from 'react-native'; +import { Alert } from 'react-native'; +import RNUserDefaults from 'rn-user-defaults'; import Navigation from '../lib/Navigation'; import { SERVER } from '../actions/actionsTypes'; @@ -14,6 +15,7 @@ import RocketChat from '../lib/rocketchat'; import database from '../lib/realm'; import log from '../utils/log'; import I18n from '../i18n'; +import { SERVERS, TOKEN, SERVER_URL } from '../constants/userDefaults'; const getServerInfo = function* getServerInfo({ server, raiseError = true }) { try { @@ -38,13 +40,21 @@ const getServerInfo = function* getServerInfo({ server, raiseError = true }) { const handleSelectServer = function* handleSelectServer({ server, version, fetchVersion }) { try { - yield AsyncStorage.setItem('currentServer', server); - const userStringified = yield AsyncStorage.getItem(`${ RocketChat.TOKEN_KEY }-${ server }`); + const { serversDB } = database.databases; - if (userStringified) { - const user = JSON.parse(userStringified); - yield RocketChat.connect({ server, user }); - yield put(setUser(user)); + yield RNUserDefaults.set('currentServer', server); + const userId = yield RNUserDefaults.get(`${ RocketChat.TOKEN_KEY }-${ server }`); + const user = userId && serversDB.objectForPrimaryKey('user', userId); + + const servers = yield RNUserDefaults.objectForKey(SERVERS); + const userCredentials = servers && servers.find(srv => srv[SERVER_URL] === server); + const userLogin = userCredentials && { + token: userCredentials[TOKEN] + }; + + if (user || userLogin) { + yield RocketChat.connect({ server, user: user || userLogin }); + yield put(setUser(user || userLogin)); yield put(actions.appStart('inside')); } else { yield RocketChat.connect({ server }); diff --git a/app/utils/vibration.js b/app/utils/vibration.js deleted file mode 100644 index a91c3e13cc..0000000000 --- a/app/utils/vibration.js +++ /dev/null @@ -1,11 +0,0 @@ -import { Vibration } from 'react-native'; - -import { isAndroid } from './deviceInfo'; - -const vibrate = () => { - if (isAndroid) { - Vibration.vibrate(30); - } -}; - -export { vibrate }; diff --git a/app/views/AutoTranslateView/index.js b/app/views/AutoTranslateView/index.js new file mode 100644 index 0000000000..d2691632d3 --- /dev/null +++ b/app/views/AutoTranslateView/index.js @@ -0,0 +1,156 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + FlatList, Switch, View, StyleSheet +} from 'react-native'; +import { SafeAreaView, ScrollView } from 'react-navigation'; + +import RocketChat from '../../lib/rocketchat'; +import I18n from '../../i18n'; +// import log from '../../utils/log'; +import StatusBar from '../../containers/StatusBar'; +import { CustomIcon } from '../../lib/Icons'; +import sharedStyles from '../Styles'; +import ListItem from '../../containers/ListItem'; +import Separator from '../../containers/Separator'; +import { + SWITCH_TRACK_COLOR, COLOR_BACKGROUND_CONTAINER, COLOR_WHITE, COLOR_SEPARATOR +} from '../../constants/colors'; +import scrollPersistTaps from '../../utils/scrollPersistTaps'; +import database from '../../lib/realm'; + +const styles = StyleSheet.create({ + contentContainerStyle: { + borderColor: COLOR_SEPARATOR, + borderTopWidth: StyleSheet.hairlineWidth, + borderBottomWidth: StyleSheet.hairlineWidth, + backgroundColor: COLOR_WHITE, + marginTop: 10, + paddingBottom: 30 + }, + sectionSeparator: { + ...sharedStyles.separatorVertical, + backgroundColor: COLOR_BACKGROUND_CONTAINER, + height: 10 + } +}); + +const SectionSeparator = React.memo(() => ); + +export default class AutoTranslateView extends React.Component { + static navigationOptions = () => ({ + title: I18n.t('Auto_Translate') + }) + + static propTypes = { + navigation: PropTypes.object + } + + constructor(props) { + super(props); + this.rid = props.navigation.getParam('rid'); + this.rooms = database.objects('subscriptions').filtered('rid = $0', this.rid); + this.state = { + languages: [], + selectedLanguage: this.rooms[0].autoTranslateLanguage, + enableAutoTranslate: this.rooms[0].autoTranslate + }; + } + + async componentDidMount() { + try { + const languages = await RocketChat.getSupportedLanguagesAutoTranslate(); + this.setState({ languages }); + } catch (error) { + console.log(error); + } + } + + toggleAutoTranslate = async() => { + const { enableAutoTranslate } = this.state; + try { + await RocketChat.saveAutoTranslate({ + rid: this.rid, + field: 'autoTranslate', + value: enableAutoTranslate ? '0' : '1', + options: { defaultLanguage: 'en' } + }); + this.setState({ enableAutoTranslate: !enableAutoTranslate }); + } catch (error) { + console.log(error); + } + } + + saveAutoTranslateLanguage = async(language) => { + try { + await RocketChat.saveAutoTranslate({ + rid: this.rid, + field: 'autoTranslateLanguage', + value: language + }); + this.setState({ selectedLanguage: language }); + } catch (error) { + console.log(error); + } + } + + renderSeparator = () => + + renderIcon = () => + + renderSwitch = () => { + const { enableAutoTranslate } = this.state; + return ( + + ); + } + + renderItem = ({ item }) => { + const { selectedLanguage } = this.state; + const { language, name } = item; + const isSelected = selectedLanguage === language; + + return ( + this.saveAutoTranslateLanguage(language)} + testID={`auto-translate-view-${ language }`} + right={isSelected ? this.renderIcon : null} + /> + ); + } + + render() { + const { languages } = this.state; + return ( + + + + this.renderSwitch()} + /> + + item.language} + renderItem={this.renderItem} + ItemSeparatorComponent={this.renderSeparator} + /> + + + ); + } +} + +console.disableYellowBox = true; diff --git a/app/views/CreateChannelView.js b/app/views/CreateChannelView.js index 2cf346fccb..b29cedbda5 100644 --- a/app/views/CreateChannelView.js +++ b/app/views/CreateChannelView.js @@ -16,10 +16,9 @@ import scrollPersistTaps from '../utils/scrollPersistTaps'; import I18n from '../i18n'; import UserItem from '../presentation/UserItem'; import { showErrorAlert } from '../utils/info'; -import { isAndroid } from '../utils/deviceInfo'; import { CustomHeaderButtons, Item } from '../containers/HeaderButton'; import StatusBar from '../containers/StatusBar'; -import { COLOR_TEXT_DESCRIPTION, COLOR_WHITE } from '../constants/colors'; +import { COLOR_TEXT_DESCRIPTION, COLOR_WHITE, SWITCH_TRACK_COLOR } from '../constants/colors'; const styles = StyleSheet.create({ container: { @@ -245,8 +244,7 @@ export default class CreateChannelView extends React.Component { value={value} onValueChange={onValueChange} testID={`create-channel-${ id }`} - onTintColor='#2de0a5' - tintColor={isAndroid ? '#f5455c' : null} + trackColor={SWITCH_TRACK_COLOR} disabled={disabled} /> diff --git a/app/views/DirectoryView/Options.js b/app/views/DirectoryView/Options.js index 841484152e..31725c118c 100644 --- a/app/views/DirectoryView/Options.js +++ b/app/views/DirectoryView/Options.js @@ -9,6 +9,7 @@ import styles from './styles'; import { CustomIcon } from '../../lib/Icons'; import Check from '../../containers/Check'; import I18n from '../../i18n'; +import { SWITCH_TRACK_COLOR } from '../../constants/colors'; const ANIMATION_DURATION = 200; const ANIMATION_PROPS = { @@ -109,7 +110,7 @@ export default class DirectoryOptions extends PureComponent { {I18n.t('Search_global_users')} {I18n.t('Search_global_users_description')} - + ) diff --git a/app/views/LanguageView/index.js b/app/views/LanguageView/index.js index 045d2e9c5a..5c7522fbb8 100644 --- a/app/views/LanguageView/index.js +++ b/app/views/LanguageView/index.js @@ -46,7 +46,6 @@ const LANGUAGES = [ }), dispatch => ({ setUser: params => dispatch(setUserAction(params)) })) -/** @extends React.Component */ export default class LanguageView extends React.Component { static navigationOptions = () => ({ title: I18n.t('Change_Language') diff --git a/app/views/RegisterView.js b/app/views/RegisterView.js index 6c199a3704..abb8f52b3b 100644 --- a/app/views/RegisterView.js +++ b/app/views/RegisterView.js @@ -5,6 +5,8 @@ import { } from 'react-native'; import { connect } from 'react-redux'; import { SafeAreaView } from 'react-navigation'; +import RNPickerSelect from 'react-native-picker-select'; +import equal from 'deep-equal'; import TextInput from '../containers/TextInput'; import Button from '../containers/Button'; @@ -17,10 +19,13 @@ import { loginRequest as loginRequestAction } from '../actions/login'; import isValidEmail from '../utils/isValidEmail'; import { LegalButton } from '../containers/HeaderButton'; import StatusBar from '../containers/StatusBar'; +import log from '../utils/log'; const shouldUpdateState = ['name', 'email', 'password', 'username', 'saving']; -@connect(null, dispatch => ({ +@connect(state => ({ + Accounts_CustomFields: state.settings.Accounts_CustomFields +}), dispatch => ({ loginRequest: params => dispatch(loginRequestAction(params)) })) export default class RegisterView extends React.Component { @@ -35,15 +40,34 @@ export default class RegisterView extends React.Component { static propTypes = { navigation: PropTypes.object, loginRequest: PropTypes.func, - Site_Name: PropTypes.string + Site_Name: PropTypes.string, + Accounts_CustomFields: PropTypes.string } - state = { - name: '', - email: '', - password: '', - username: '', - saving: false + constructor(props) { + super(props); + const customFields = {}; + this.parsedCustomFields = {}; + if (props.Accounts_CustomFields) { + try { + this.parsedCustomFields = JSON.parse(props.Accounts_CustomFields); + } catch (e) { + log('err_parsing_account_custom_fields', e); + } + } + Object.keys(this.parsedCustomFields).forEach((key) => { + if (this.parsedCustomFields[key].defaultValue) { + customFields[key] = this.parsedCustomFields[key].defaultValue; + } + }); + this.state = { + name: '', + email: '', + password: '', + username: '', + saving: false, + customFields + }; } componentDidMount() { @@ -53,6 +77,10 @@ export default class RegisterView extends React.Component { } shouldComponentUpdate(nextProps, nextState) { + const { customFields } = this.state; + if (!equal(nextState.customFields, customFields)) { + return true; + } // eslint-disable-next-line react/destructuring-assignment return shouldUpdateState.some(key => nextState[key] !== this.state[key]); } @@ -77,9 +105,15 @@ export default class RegisterView extends React.Component { valid = () => { const { - name, email, password, username + name, email, password, username, customFields } = this.state; - return name.trim() && email.trim() && password.trim() && username.trim() && isValidEmail(email); + let requiredCheck = true; + Object.keys(this.parsedCustomFields).forEach((key) => { + if (this.parsedCustomFields[key].required) { + requiredCheck = requiredCheck && customFields[key] && Boolean(customFields[key].trim()); + } + }); + return name.trim() && email.trim() && password.trim() && username.trim() && isValidEmail(email) && requiredCheck; } submit = async() => { @@ -90,13 +124,13 @@ export default class RegisterView extends React.Component { Keyboard.dismiss(); const { - name, email, password, username + name, email, password, username, customFields } = this.state; const { loginRequest } = this.props; try { await RocketChat.register({ - name, email, pass: password, username + name, email, pass: password, username, ...customFields }); await loginRequest({ user: email, password }); } catch (e) { @@ -105,6 +139,64 @@ export default class RegisterView extends React.Component { this.setState({ saving: false }); } + renderCustomFields = () => { + const { customFields } = this.state; + const { Accounts_CustomFields } = this.props; + if (!Accounts_CustomFields) { + return null; + } + try { + return Object.keys(this.parsedCustomFields).map((key, index, array) => { + if (this.parsedCustomFields[key].type === 'select') { + const options = this.parsedCustomFields[key].options.map(option => ({ label: option, value: option })); + return ( + { + const newValue = {}; + newValue[key] = value; + this.setState({ customFields: { ...customFields, ...newValue } }); + }} + value={customFields[key]} + > + { this[key] = e; }} + placeholder={key} + value={customFields[key]} + iconLeft='flag' + testID='register-view-custom-picker' + /> + + ); + } + + return ( + { this[key] = e; }} + key={key} + placeholder={key} + value={customFields[key]} + iconLeft='flag' + onChangeText={(value) => { + const newValue = {}; + newValue[key] = value; + this.setState({ customFields: { ...customFields, ...newValue } }); + }} + onSubmitEditing={() => { + if (array.length - 1 > index) { + return this[array[index + 1]].focus(); + } + this.avatarUrl.focus(); + }} + /> + ); + }); + } catch (error) { + return null; + } + } + render() { const { saving } = this.state; return ( @@ -153,6 +245,8 @@ export default class RegisterView extends React.Component { containerStyle={sharedStyles.inputLastChild} /> + {this.renderCustomFields()} +