diff --git a/.babelrc b/.babelrc index d0cf03dfa..34739bdc9 100644 --- a/.babelrc +++ b/.babelrc @@ -1,4 +1,9 @@ { "presets": ["react-native"], - "plugins": ["transform-decorators-legacy"] + "plugins": ["transform-decorators-legacy"], + "env": { + "production": { + "plugins": ["transform-remove-console"] + } + } } diff --git a/.circleci/changelog.sh b/.circleci/changelog.sh new file mode 100644 index 000000000..deb042836 --- /dev/null +++ b/.circleci/changelog.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +git log --format="%cd" -n 14 --date=short | sort -u -r | while read DATE ; do + echo $DATE + GIT_PAGER=cat git log --no-merges --format="- %s" --since="$DATE 00:00:00" --until="$DATE 24:00:00" + echo +done diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 000000000..95ae45cbd --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,238 @@ +defaults: &defaults + working_directory: ~/repo + +version: 2 +jobs: + lint-testunit: + <<: *defaults + docker: + - image: circleci/node:8 + + environment: + CODECOV_TOKEN: caa771ab-3d45-4756-8e2a-e1f25996fef6 + + steps: + - checkout + + - run: + name: Install NPM modules + command: | + npm install + # npm install codecov + + - run: + name: Lint + command: | + npm run lint + + - run: + name: Test + command: | + npm test + + - run: + name: Codecov + command: | + npx codecov + + android-build: + <<: *defaults + docker: + - image: circleci/android:api-26-alpha + + environment: + GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx4096m -XX:+HeapDumpOnOutOfMemoryError" + JVM_OPTS: -Xmx4096m + TERM: dumb + BASH_ENV: "~/.nvm/nvm.sh" + + steps: + - checkout + + - run: + name: Install Node 8 + command: | + curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.6/install.sh | bash + source ~/.nvm/nvm.sh + nvm install 8 + + - restore_cache: + key: node-modules-{{ checksum ".circleci/config.yml" }}-{{ checksum "package.json" }} + + - run: + name: Install NPM modules + command: | + npm install + + - restore_cache: + key: android-{{ checksum ".circleci/config.yml" }}-{{ checksum "android/build.gradle" }}-{{ checksum "android/app/build.gradle" }} + + - run: + name: Configure Gradle + command: | + cd android + + echo -e "" > ./gradle.properties + + if [[ $KEYSTORE ]]; then + echo $KEYSTORE_BASE64 | base64 --decode > ./app/$KEYSTORE + echo -e "KEYSTORE=$KEYSTORE" >> ./gradle.properties + echo -e "KEYSTORE_PASSWORD=$KEYSTORE_PASSWORD" >> ./gradle.properties + echo -e "KEY_ALIAS=$KEY_ALIAS" >> ./gradle.properties + echo -e "KEY_PASSWORD=$KEYSTORE_PASSWORD" >> ./gradle.properties + fi + + echo -e "VERSIONCODE=$CIRCLE_BUILD_NUM" >> ./gradle.properties + + echo -e "" > ./app/fabric.properties + echo -e "apiKey=$FABRIC_KEY" >> ./app/fabric.properties + echo -e "apiSecret=$FABRIC_SECRET" >> ./app/fabric.properties + + - run: + name: Install Android Depedencies + command: | + cd android + ./gradlew androidDependencies + + - run: + name: Build Android App + command: | + cd android + if [[ $KEYSTORE ]]; then + ./gradlew assembleRelease + else + ./gradlew assembleDebug + fi + + mkdir -p /tmp/build + + mv app/build/outputs /tmp/build/ + + - store_artifacts: + path: /tmp/build/outputs + + - save_cache: + key: node-modules-{{ checksum ".circleci/config.yml" }}-{{ checksum "package.json" }} + paths: + - ./node_modules + + - save_cache: + key: android-{{ checksum ".circleci/config.yml" }}-{{ checksum "android/build.gradle" }}-{{ checksum "android/app/build.gradle" }} + paths: + - ~/.gradle + + ios-build: + macos: + xcode: "9.0" + + environment: + BASH_ENV: "~/.nvm/nvm.sh" + + steps: + - checkout + + - run: + name: Install Node 8 + command: | + curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.6/install.sh | bash + source ~/.nvm/nvm.sh + # https://github.com/creationix/nvm/issues/1394 + set +e + nvm install 8 + + - run: + name: Update Fastlane + command: | + brew update + brew install ruby + sudo gem install fastlane + + - run: + name: Install NPM modules + command: | + rm -rf node_modules + # npm install --save react-native@0.51 + npm install + # npm install react-native + + - run: + name: Fix known build error + command: | + # Fix error https://github.com/facebook/react-native/issues/14382 + cd node_modules/react-native/scripts/ + curl https://raw.githubusercontent.com/facebook/react-native/5c53f89dd86160301feee024bce4ce0c89e8c187/scripts/ios-configure-glog.sh > ios-configure-glog.sh + chmod +x ios-configure-glog.sh + + - run: + name: Fastlane Build + no_output_timeout: 1200 + command: | + cd ios + agvtool new-version -all $CIRCLE_BUILD_NUM + /usr/libexec/PlistBuddy -c "Set Fabric:APIKey $FABRIC_KEY" ./RocketChatRN/Info.plist + echo -e "./Fabric.framework/run $FABRIC_KEY $FABRIC_SECRET" > ./RocketChatRN/Fabric.sh + + if [[ $MATCH_KEYCHAIN_NAME ]]; then + fastlane ios release + else + export MATCH_KEYCHAIN_NAME="temp" + export MATCH_KEYCHAIN_PASSWORD="temp" + fastlane ios build + fi + + - store_artifacts: + path: ios/RocketChatRN.ipa + + - persist_to_workspace: + root: . + paths: + - ios/*.ipa + - ios/fastlane/report.xml + + ios-testflight: + macos: + xcode: "9.0" + + steps: + - checkout + + - attach_workspace: + at: ios + + - run: + name: Update Fastlane + command: | + brew update + brew install ruby + sudo gem install fastlane + + - run: + name: Fastlane Tesflight Upload + command: | + cd ios + fastlane pilot upload --ipa ios/RocketChatRN.ipa --changelog "$(sh ../.circleci/changelog.sh)" + +workflows: + version: 2 + build-and-test: + jobs: + - lint-testunit + + - ios-build: + requires: + - lint-testunit + - ios-testflight: + requires: + - ios-build + filters: + branches: + only: + - develop + - master + # - ios-testflight: + # requires: + # - ios-hold-testflight + + - android-build: + requires: + - lint-testunit diff --git a/.eslintrc b/.eslintrc.js similarity index 90% rename from .eslintrc rename to .eslintrc.js index a55acafff..94d3a23e1 100644 --- a/.eslintrc +++ b/.eslintrc.js @@ -1,4 +1,11 @@ -{ +module.exports = { + "settings": { + "import/resolver": { + "node": { + "extensions": [".js", ".ios.js", ".android.js"] + } + } + }, "parser": "babel-eslint", "extends": "airbnb", "parserOptions": { @@ -30,6 +37,7 @@ "react/no-unused-prop-types": [2, { "skipShapeProps": true }], + "react/no-did-mount-set-state": 0, "react/no-multi-comp": [0], "react/jsx-indent": [2, "tab"], "react/jsx-indent-props": [2, "tab"], @@ -37,6 +45,7 @@ "jsx-quotes": [2, "prefer-single"], "jsx-a11y/href-no-hash": 0, "import/prefer-default-export": 0, + "camelcase": 0, "no-underscore-dangle": 0, "no-return-assign": 0, "no-param-reassign": 0, @@ -115,9 +124,10 @@ "prefer-const": 2, "object-shorthand": 2, "consistent-return": 0, - "global-require": "off" + "global-require": "off", + "react/prop-types": [0, { skipUndeclared: true }] }, "globals": { "__DEV__": true } -} +}; diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..3d2c4a2b6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,19 @@ +Before writing an issue, please make sure you're talking about the native application and not the Cordova one. If you are looking to open an issue to the Cordova application, go to this URL: https://github.com/RocketChat/Rocket.Chat.Cordova. + +- Your Rocket.Chat.Android app version: #### + + +- Your Rocket.Chat.iOS app version: #### + + +- Your Rocket.Chat server version: #### + +- Device model (or emulator) you're running with: #### + + + +**The app isn't connecting to your server?** +Make sure your server supports WebSocket. These are the minimum requirements for Apache 2.4 and Nginx 1.3 or greater. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..c868686af --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ + +@RocketChat/ReactNative + + +Closes #ISSUE_NUMBER + + diff --git a/.gitignore b/.gitignore index 5b8a7f053..1a68faf71 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ build/ .idea .gradle local.properties +fabric.properties *.iml # node.js @@ -55,3 +56,5 @@ fastlane/Preview.html fastlane/screenshots coverage + +.vscode/ diff --git a/.snyk b/.snyk new file mode 100644 index 000000000..1c6af145e --- /dev/null +++ b/.snyk @@ -0,0 +1,26 @@ +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.7.1 +ignore: {} +# patches apply the minimum changes required to fix a vulnerability +patch: + 'npm:debug:20170905': + - react-native > connect > debug: + patched: '2017-09-29T23:29:20.238Z' + - react-native > connect > express-session > debug: + patched: '2017-09-29T23:29:20.238Z' + - react-native > connect > finalhandler > debug: + patched: '2017-09-29T23:29:20.238Z' + - react-native > connect > morgan > debug: + patched: '2017-09-29T23:29:20.238Z' + - react-native > connect > serve-index > debug: + patched: '2017-09-29T23:29:20.238Z' + - react-native > connect > body-parser > debug: + patched: '2017-09-29T23:29:20.238Z' + - react-native > connect > compression > debug: + patched: '2017-09-29T23:29:20.238Z' + - react-native > connect > connect-timeout > debug: + patched: '2017-09-29T23:29:20.238Z' + - react-native > connect > serve-static > send > debug: + patched: '2017-09-29T23:29:20.238Z' + - realm > extract-zip > debug: + patched: '2017-09-29T23:29:20.238Z' diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..de5de8728 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015-2018 Rocket.Chat Technologies Corp. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md index 64074bdff..321649b79 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Rocket.Chat React Native Mobile +[![Greenkeeper badge](https://badges.greenkeeper.io/RocketChat/Rocket.Chat.ReactNative.svg)](https://greenkeeper.io/) + [![Build Status](https://img.shields.io/travis/RocketChat/Rocket.Chat.ReactNative/master.svg)](https://travis-ci.org/RocketChat/Rocket.Chat.ReactNative) [![Project Dependencies](https://david-dm.org/RocketChat/Rocket.Chat.ReactNative.svg)](https://david-dm.org/RocketChat/Rocket.Chat.ReactNative) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/bb15e2392a71473ea59d3f634f35c54e)](https://www.codacy.com/app/RocketChat/Rocket.Chat.ReactNative?utm_source=github.com&utm_medium=referral&utm_content=RocketChat/Rocket.Chat.ReactNative&utm_campaign=badger) @@ -10,74 +12,32 @@ **Supported Server Versions:** 0.58.0+ (We are working to support earlier versions) -# Installing Dependencies +# Installing dependencies Follow the [React Native Getting Started Guide](https://facebook.github.io/react-native/docs/getting-started.html) for detailed instructions on setting up your local machine for development. -# Detailed configuration: - -## Mac - -- General requirements - - - XCode 8.3 - - Install required packages using homebrew: - ```bash - $ brew install watchman - $ brew install yarn - ``` - -- Clone repository and configure: +# How to run +- Clone repository and install dependencies: ```bash $ git clone git@github.com:RocketChat/Rocket.Chat.ReactNative.git $ cd Rocket.Chat.ReactNative - $ npm install $ npm install -g react-native-cli + $ yarn + ``` +- Configuration + ```bash + $ yarn fabric-ios --key="YOUR_API_KEY" --secret="YOUR_API_SECRET" + $ yarn fabric-android --key="YOUR_API_KEY" --secret="YOUR_API_SECRET" ``` - Run application ```bash - $ react-native run-ios + $ yarn ios ``` ```bash - $ react-native run-android + $ yarn android ``` -## Linux: - -- General requiriments: - - - JDK 7 or greater - - Android SDK - - Virtualbox - - An Android emulator: Genymotion or Android emulator. If using genymotion ensure that it uses existing adb tools (Settings: "Use custom Android SDK Tools") - - Install watchman (do this globally): - ```bash - $ git clone https://github.com/facebook/watchman.git - $ cd watchman - $ git checkout master - $ ./autogen.sh - $ ./configure make - $ sudo make install - ``` - Configure your kernel to accept a lot of file watches, using a command like: - ```bash - $ sudo sysctl -w fs.inotify.max_user_watches=1048576 - ``` - -- Clone repository and configure: - ```bash - $ git clone git@github.com:RocketChat/Rocket.Chat.ReactNative.git - $ cd Rocket.Chat.ReactNative - $ npm install - $ npm install -g react-native-cli - ``` - -- Run application - - Start emulator - - Start react packager: `$ react-native start` - - Run in emulator: `$ react-native run-android` - # Storybook - General requirements - Install storybook diff --git a/__tests__/RoomItem.js b/__tests__/RoomItem.js index 1244a4437..253b5d45c 100644 --- a/__tests__/RoomItem.js +++ b/__tests__/RoomItem.js @@ -1,32 +1,41 @@ -import 'react-native'; +import {View} from 'react-native'; +import { Provider } from 'react-redux'; + +import { createStore, combineReducers } from 'redux'; + +const reducers = combineReducers({login:() => ({user: {}}), settings:() => ({})}); +const store = createStore(reducers); + import React from 'react'; -import RoomItem from '../app/components/RoomItem'; +import RoomItem from '../app/presentation/RoomItem'; // Note: test renderer must be required after react-native. import renderer from 'react-test-renderer'; +const date = new Date(2017, 10, 10, 10); + jest.mock('react-native-img-cache', () => { return { CachedImage: 'View' } }); it('renders correctly', () => { - expect(renderer.create().toJSON()).toMatchSnapshot(); + expect(renderer.create().toJSON()).toMatchSnapshot(); }); it('render unread', () => { - expect(renderer.create().toJSON()).toMatchSnapshot(); + expect(renderer.create().toJSON()).toMatchSnapshot(); }); it('render unread +999', () => { - expect(renderer.create().toJSON()).toMatchSnapshot(); + expect(renderer.create().toJSON()).toMatchSnapshot(); }); it('render no icon', () => { - expect(renderer.create().toJSON()).toMatchSnapshot(); + expect(renderer.create().toJSON()).toMatchSnapshot(); }); it('render private group', () => { - expect(renderer.create( ).toJSON()).toMatchSnapshot(); + expect(renderer.create( ).toJSON()).toMatchSnapshot(); }); it('render channel', () => { - expect(renderer.create().toJSON()).toMatchSnapshot(); + expect(renderer.create().toJSON()).toMatchSnapshot(); }); diff --git a/__tests__/__snapshots__/RoomItem.js.snap b/__tests__/__snapshots__/RoomItem.js.snap index 11d20cb8f..81cea847b 100644 --- a/__tests__/__snapshots__/RoomItem.js.snap +++ b/__tests__/__snapshots__/RoomItem.js.snap @@ -1,503 +1,1041 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`render channel 1`] = ` - + - -  - + + +  + + + + + + general + + + Nov 10 + + + + + - - general - `; exports[`render no icon 1`] = ` - - + - name - + + + + + + + + + + name + + + Nov 10 + + + + + + `; exports[`render private group 1`] = ` - - + - private-group - + + + + + + + + + + private-group + + + Nov 10 + + + + + + + `; exports[`render unread +999 1`] = ` - + - - NA - + + + NA + + + + + + + + name + + + Nov 10 + + + + + 999+ + + + + - - name - - - 999+ - `; exports[`render unread 1`] = ` - + - - NA - + + + NA + + + + + + + + name + + + Nov 10 + + + + + 1 + + + + - - name - - - 1 - `; exports[`renders correctly 1`] = ` - + - - NA - + + + NA + + + + + + + + name + + + Nov 10 + + + + + - - name - `; diff --git a/__tests__/__snapshots__/Storyshots.test.js.snap b/__tests__/__snapshots__/Storyshots.test.js.snap index c07669703..86e9a4e19 100644 --- a/__tests__/__snapshots__/Storyshots.test.js.snap +++ b/__tests__/__snapshots__/Storyshots.test.js.snap @@ -9,11 +9,10 @@ exports[`Storyshots Avatar avatar 1`] = ` Object { "alignItems": "center", "justifyContent": "center", - "overflow": "hidden", }, Object { "backgroundColor": "#3F51B5", - "borderRadius": 5, + "borderRadius": 4, "height": 25, "width": 25, }, @@ -23,8 +22,7 @@ exports[`Storyshots Avatar avatar 1`] = ` > TE + AA + BB + TE + @@ -160,10 +156,10 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = ` @@ -192,1052 +179,1952 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = ` Array [ Object { "alignItems": "center", - "justifyContent": "center", - "overflow": "hidden", - }, - Object { - "backgroundColor": "#8BC34A", - "borderRadius": 20, - "height": 40, - "width": 40, + "borderBottomColor": "#ddd", + "borderBottomWidth": 0.5, + "flexDirection": "row", + "paddingHorizontal": 16, + "paddingVertical": 12, }, undefined, ] } > - - RC - - - - - rocket.cat - - - - - - RC - - - - - rocket.cat - - - - - - RC - - - - - rocket.cat - - - 1 - - - - - - LC - - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - - 9 - - - - - - LC - - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - - 99 - - - - - - LC - - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - - 100 - - - - - - LC - - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - - 999+ - - - - - - W - - - - W - - - - - - WW - - - - WW - - - - - + > + RC + - - - + + + + + rocket.cat + + + Nov 10 + + + + + + + + - - + + + RC + + + + + + + + rocket.cat + + + Nov 10 + + + + + + + + + + + RC + + + + + + + + rocket.cat + + + Nov 10 + + + + + 1 + + + + + + + + + + LC + + + + + + + + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries + + + Nov 10 + + + + + 9 + + + + + + + + + + LC + + + + + + + + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries + + + Nov 10 + + + + + 99 + + + + + + + + + + LC + + + + + + + + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries + + + Nov 10 + + + + + 100 + + + + + + + + + + LC + + + + + + + + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries + + + Nov 10 + + + + + 999+ + + + + + + + + + + LC + + + + + + + + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries + + + Nov 10 + + + + + @ 999+ + + + + + + + + + + W + + + + + + + + W + + + Nov 10 + + + + + + + + + + + WW + + + + + + + + WW + + + Nov 10 + + + + + + + + + + + + + + + + + + + + + + Nov 10 + + + + + diff --git a/android/app/BUCK b/android/app/BUCK index ea619878b..a6395be89 100644 --- a/android/app/BUCK +++ b/android/app/BUCK @@ -45,12 +45,12 @@ android_library( android_build_config( name = "build_config", - package = "com.rocketchatrn", + package = "chat.rocket.reactnative", ) android_resource( name = "res", - package = "com.rocketchatrn", + package = "chat.rocket.reactnative", res = "src/main/res", ) diff --git a/android/app/build.gradle b/android/app/build.gradle index 7129e4ac0..1183adc62 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -94,22 +94,22 @@ android { buildToolsVersion "25.0.1" defaultConfig { - applicationId "com.rocketchatrn" + applicationId "chat.rocket.reactnative" minSdkVersion 16 targetSdkVersion 22 - versionCode 1 - versionName "1.0" + versionCode VERSIONCODE as Integer + versionName "1.1" ndk { abiFilters "armeabi-v7a", "x86" } } signingConfigs { release { - if (project.hasProperty('ROCKETCHAT_RN_RELEASE_STORE_FILE')) { - storeFile file(ROCKETCHAT_RN_RELEASE_STORE_FILE) - storePassword ROCKETCHAT_RN_RELEASE_STORE_PASSWORD - keyAlias ROCKETCHAT_RN_RELEASE_KEY_ALIAS - keyPassword ROCKETCHAT_RN_RELEASE_KEY_PASSWORD + if (project.hasProperty('KEYSTORE')) { + storeFile file(KEYSTORE) + storePassword KEYSTORE_PASSWORD + keyAlias KEY_ALIAS + keyPassword KEY_PASSWORD } } } @@ -143,17 +143,50 @@ android { } } +buildscript { + repositories { + maven { url 'https://maven.fabric.io/public' } + } + + dependencies { + // These docs use an open ended version so that our plugin + // can be updated quickly in response to Android tooling updates + + // We recommend changing it to the latest version from our changelog: + // https://docs.fabric.io/android/changelog.html#fabric-gradle-plugin + classpath 'io.fabric.tools:gradle:1.+' + } +} + +apply plugin: 'io.fabric' + +repositories { + maven { url 'https://maven.fabric.io/public' } +} + dependencies { - compile project(':react-native-navigation') + compile project(':react-native-fabric') + compile project(':react-native-audio') + compile project(":reactnativekeyboardinput") + compile project(':react-native-splash-screen') + compile project(':react-native-video') + compile project(':react-native-push-notification') compile project(':react-native-svg') compile project(':react-native-image-picker') compile project(':react-native-vector-icons') compile project(':react-native-fetch-blob') compile project(':react-native-zeroconf') + compile project(':react-native-toast') compile project(':realm') compile fileTree(dir: "libs", include: ["*.jar"]) compile "com.android.support:appcompat-v7:23.0.1" + compile 'com.android.support:customtabs:23.0.1' compile "com.facebook.react:react-native:+" // From node_modules + compile 'com.facebook.fresco:fresco:1.7.1' + compile 'com.facebook.fresco:animated-gif:1.7.1' + compile('com.crashlytics.sdk.android:crashlytics:2.9.1@aar') { + transitive = true; + } } // Run this once to be able to run the application with BUCK diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d498890f4..0137ae666 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,5 @@ @@ -7,6 +7,16 @@ + + + + + + + + @@ -28,6 +38,28 @@ + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/assets/fonts/Feather.ttf b/android/app/src/main/assets/fonts/Feather.ttf new file mode 100755 index 000000000..244854c54 Binary files /dev/null and b/android/app/src/main/assets/fonts/Feather.ttf differ diff --git a/android/app/src/main/assets/fonts/icomoon.ttf b/android/app/src/main/assets/fonts/icomoon.ttf new file mode 100755 index 000000000..3601ae802 Binary files /dev/null and b/android/app/src/main/assets/fonts/icomoon.ttf differ diff --git a/android/app/src/main/java/com/rocketchatrn/CustomTabsAndroid.java b/android/app/src/main/java/com/rocketchatrn/CustomTabsAndroid.java new file mode 100644 index 000000000..e0a9db1fe --- /dev/null +++ b/android/app/src/main/java/com/rocketchatrn/CustomTabsAndroid.java @@ -0,0 +1,56 @@ +package chat.rocket.reactnative; + +import android.content.Intent; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.support.customtabs.CustomTabsIntent; +import android.widget.Toast; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; + +import java.util.List; + +import chat.rocket.reactnative.R; + +/** + * Launches custom tabs. + */ + +public class CustomTabsAndroid extends ReactContextBaseJavaModule { + + + public CustomTabsAndroid(ReactApplicationContext reactContext) { + super(reactContext); + } + + @Override + public String getName() { + return "CustomTabsAndroid"; + } + + @ReactMethod + public void openURL(String url) throws NullPointerException { + CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(); + CustomTabsIntent customTabsIntent = builder.build(); + + if (CustomTabsHelper.isChromeCustomTabsSupported(getReactApplicationContext())) { + customTabsIntent.launchUrl(getReactApplicationContext().getCurrentActivity(), Uri.parse(url)); + } else { + //open in browser + Intent i = new Intent(Intent.ACTION_VIEW); + i.setData(Uri.parse(url)); + //ensure browser is present + final List customTabsApps = getReactApplicationContext() + .getCurrentActivity().getPackageManager().queryIntentActivities(i, 0); + + if (customTabsApps.size() > 0) { + getReactApplicationContext().startActivity(i); + } else { + // no browser + Toast.makeText(getReactApplicationContext(), R.string.no_browser_found, Toast.LENGTH_SHORT).show(); + } + } + } +} diff --git a/android/app/src/main/java/com/rocketchatrn/CustomTabsHelper.java b/android/app/src/main/java/com/rocketchatrn/CustomTabsHelper.java new file mode 100644 index 000000000..b7f3eb72f --- /dev/null +++ b/android/app/src/main/java/com/rocketchatrn/CustomTabsHelper.java @@ -0,0 +1,24 @@ +package chat.rocket.reactnative; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.ResolveInfo; + +import java.util.List; + +/** + * Contains helper methods for custom tabs. + */ + +public class CustomTabsHelper { + + private static final String SERVICE_ACTION = "android.support.customtabs.action.CustomTabsService"; + private static final String CHROME_PACKAGE = "com.android.chrome"; + + public static boolean isChromeCustomTabsSupported(final Context context) { + Intent serviceIntent = new Intent(SERVICE_ACTION); + serviceIntent.setPackage(CHROME_PACKAGE); + List resolveInfos = context.getPackageManager().queryIntentServices(serviceIntent, 0); + return !(resolveInfos == null || resolveInfos.isEmpty()); + } +} diff --git a/android/app/src/main/java/com/rocketchatrn/MainActivity.java b/android/app/src/main/java/com/rocketchatrn/MainActivity.java index 57a754e0b..a539ec30f 100644 --- a/android/app/src/main/java/com/rocketchatrn/MainActivity.java +++ b/android/app/src/main/java/com/rocketchatrn/MainActivity.java @@ -1,7 +1,26 @@ -package com.rocketchatrn; +package chat.rocket.reactnative; -import com.reactnativenavigation.controllers.SplashActivity; +import android.os.Bundle; +import com.facebook.react.ReactActivity; +import org.devio.rn.splashscreen.SplashScreen; +import com.crashlytics.android.Crashlytics; +import io.fabric.sdk.android.Fabric; -public class MainActivity extends SplashActivity { +public class MainActivity extends ReactActivity { + /** + * Returns the name of the main component registered from JavaScript. + * This is used to schedule rendering of the component. + */ + @Override + protected String getMainComponentName() { + return "RocketChatRN"; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + SplashScreen.show(this); + super.onCreate(savedInstanceState); + Fabric.with(this, new Crashlytics()); + } } diff --git a/android/app/src/main/java/com/rocketchatrn/MainApplication.java b/android/app/src/main/java/com/rocketchatrn/MainApplication.java index 277cfd500..689cee99f 100644 --- a/android/app/src/main/java/com/rocketchatrn/MainApplication.java +++ b/android/app/src/main/java/com/rocketchatrn/MainApplication.java @@ -1,9 +1,8 @@ -package com.rocketchatrn; +package chat.rocket.reactnative; import android.app.Application; import com.facebook.react.ReactApplication; -// import com.reactnativenavigation.NavigationReactPackage; import com.horcrux.svg.SvgPackage; import com.imagepicker.ImagePickerPackage; import com.oblador.vectoricons.VectorIconsPackage; @@ -14,31 +13,55 @@ import com.facebook.react.ReactNativeHost; import com.facebook.react.ReactPackage; import com.facebook.react.shell.MainReactPackage; import com.facebook.soloader.SoLoader; -import com.reactnativenavigation.NavigationApplication; +import com.dieam.reactnativepushnotification.ReactNativePushNotificationPackage; +import com.brentvatne.react.ReactVideoPackage; +import com.remobile.toast.RCTToastPackage; +import com.wix.reactnativekeyboardinput.KeyboardInputPackage; +import com.rnim.rn.audio.ReactNativeAudioPackage; +import com.smixx.fabric.FabricPackage; import java.util.Arrays; import java.util.List; +import org.devio.rn.splashscreen.SplashScreenReactPackage; -public class MainApplication extends NavigationApplication { +public class MainApplication extends Application implements ReactApplication { + + private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) { @Override - public boolean isDebug() { - // Make sure you are using BuildConfig from your own application + public boolean getUseDeveloperSupport() { return BuildConfig.DEBUG; } + @Override protected List getPackages() { return Arrays.asList( - new SvgPackage(), - new ImagePickerPackage(), - new VectorIconsPackage(), - new RNFetchBlobPackage(), - new ZeroconfReactPackage(), - new RealmReactPackage() + new MainReactPackage(), + new SvgPackage(), + new ImagePickerPackage(), + new VectorIconsPackage(), + new RNFetchBlobPackage(), + new ZeroconfReactPackage(), + new RealmReactPackage(), + new ReactNativePushNotificationPackage(), + new ReactVideoPackage(), + new SplashScreenReactPackage(), + new RCTToastPackage(), + new ReactNativeAudioPackage(), + new KeyboardInputPackage(MainApplication.this), + new RocketChatNativePackage(), + new FabricPackage() ); } + }; - @Override - public List createAdditionalReactPackages() { - return getPackages(); - } + @Override + public ReactNativeHost getReactNativeHost() { + return mReactNativeHost; + } + + @Override + public void onCreate() { + super.onCreate(); + SoLoader.init(this, /* native exopackage */ false); + } } diff --git a/android/app/src/main/java/com/rocketchatrn/RocketChatNativePackage.java b/android/app/src/main/java/com/rocketchatrn/RocketChatNativePackage.java new file mode 100644 index 000000000..16cef0197 --- /dev/null +++ b/android/app/src/main/java/com/rocketchatrn/RocketChatNativePackage.java @@ -0,0 +1,33 @@ +package chat.rocket.reactnative; + +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.JavaScriptModule; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.uimanager.ViewManager; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class RocketChatNativePackage implements ReactPackage { + + public List> createJSModules() { + return Collections.emptyList(); + } + + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + List managers = new ArrayList<>(); + return managers; + } + + @Override + public List createNativeModules( + ReactApplicationContext reactContext) { + List modules = new ArrayList<>(); + modules.add(new CustomTabsAndroid(reactContext)); + return modules; + } + +} diff --git a/android/app/src/main/res/drawable-hdpi/launch_screen.png b/android/app/src/main/res/drawable-hdpi/launch_screen.png new file mode 100644 index 000000000..f2dee7926 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/launch_screen.png differ diff --git a/android/app/src/main/res/drawable-hdpi/node_modules_reactnavigation_src_views_assets_backicon.png b/android/app/src/main/res/drawable-hdpi/node_modules_reactnavigation_src_views_assets_backicon.png new file mode 100644 index 000000000..ad03a63bf Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/node_modules_reactnavigation_src_views_assets_backicon.png differ diff --git a/app/images/logo.png b/android/app/src/main/res/drawable-mdpi/app_images_logo.png similarity index 100% rename from app/images/logo.png rename to android/app/src/main/res/drawable-mdpi/app_images_logo.png diff --git a/android/app/src/main/res/drawable-mdpi/launch_screen.png b/android/app/src/main/res/drawable-mdpi/launch_screen.png new file mode 100644 index 000000000..1b7967402 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/launch_screen.png differ diff --git a/android/app/src/main/res/drawable-mdpi/node_modules_reactnavigation_src_views_assets_backicon.png b/android/app/src/main/res/drawable-mdpi/node_modules_reactnavigation_src_views_assets_backicon.png new file mode 100644 index 000000000..083db295f Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/node_modules_reactnavigation_src_views_assets_backicon.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/launch_screen.png b/android/app/src/main/res/drawable-xhdpi/launch_screen.png new file mode 100644 index 000000000..1c0eef013 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/launch_screen.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/node_modules_reactnavigation_src_views_assets_backicon.png b/android/app/src/main/res/drawable-xhdpi/node_modules_reactnavigation_src_views_assets_backicon.png new file mode 100644 index 000000000..6de0a1cbb Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/node_modules_reactnavigation_src_views_assets_backicon.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/launch_screen.png b/android/app/src/main/res/drawable-xxhdpi/launch_screen.png new file mode 100644 index 000000000..b9a85b34b Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/launch_screen.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/node_modules_reactnavigation_src_views_assets_backicon.png b/android/app/src/main/res/drawable-xxhdpi/node_modules_reactnavigation_src_views_assets_backicon.png new file mode 100644 index 000000000..15a983a67 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/node_modules_reactnavigation_src_views_assets_backicon.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/launch_screen.png b/android/app/src/main/res/drawable-xxxhdpi/launch_screen.png new file mode 100644 index 000000000..603e7c1fa Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/launch_screen.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/node_modules_reactnavigation_src_views_assets_backicon.png b/android/app/src/main/res/drawable-xxxhdpi/node_modules_reactnavigation_src_views_assets_backicon.png new file mode 100644 index 000000000..17e52e855 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/node_modules_reactnavigation_src_views_assets_backicon.png differ diff --git a/android/app/src/main/res/layout/launch_screen.xml b/android/app/src/main/res/layout/launch_screen.xml new file mode 100644 index 000000000..86d6a72e7 --- /dev/null +++ b/android/app/src/main/res/layout/launch_screen.xml @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_notification.png b/android/app/src/main/res/mipmap-hdpi/ic_notification.png new file mode 100644 index 000000000..912112d2c Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_notification.png differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..56779d1e1 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1 @@ + #660B0B0B \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 758ba54dd..23925e195 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,3 +1,5 @@ RocketChatRN + + No Browser Found diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 319eb0ca1..654ec9502 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -3,6 +3,7 @@ diff --git a/android/gradle.properties b/android/gradle.properties index 1fd964e90..d94d12020 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -11,10 +11,10 @@ # The setting is particularly useful for tweaking memory settings. # Default value: -Xmx10248m -XX:MaxPermSize=256m # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 - # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true android.useDeprecatedNdk=true +VERSIONCODE=999999999 diff --git a/android/settings.gradle b/android/settings.gradle index aacf704b9..bba1bd27f 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,6 +1,14 @@ rootProject.name = 'RocketChatRN' -include ':react-native-navigation' -project(':react-native-navigation').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-navigation/android/app/') +include ':react-native-fabric' +project(':react-native-fabric').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-fabric/android') +include ':react-native-audio' +project(':react-native-audio').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-audio/android') +include ':reactnativekeyboardinput' +project(':reactnativekeyboardinput').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-keyboard-input/lib/android') +include ':react-native-splash-screen' +project(':react-native-splash-screen').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-splash-screen/android') +include ':react-native-video' +project(':react-native-video').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-video/android') include ':react-native-svg' project(':react-native-svg').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-svg/android') include ':react-native-image-picker' @@ -13,5 +21,8 @@ include ':react-native-zeroconf' project(':react-native-zeroconf').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-zeroconf/android') include ':realm' project(':realm').projectDir = new File(rootProject.projectDir, '../node_modules/realm/android') - +include ':react-native-push-notification' +project(':react-native-push-notification').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-push-notification/android') +include ':react-native-toast' +project(':react-native-toast').projectDir = new File(settingsDir, '../node_modules/@remobile/react-native-toast/android') include ':app' diff --git a/app/ReactotronConfig.js b/app/ReactotronConfig.js new file mode 100644 index 000000000..c6d66e7a3 --- /dev/null +++ b/app/ReactotronConfig.js @@ -0,0 +1,13 @@ +/* eslint-disable */ +import Reactotron from 'reactotron-react-native'; +import { reactotronRedux } from 'reactotron-redux'; +import sagaPlugin from 'reactotron-redux-saga' + +if (__DEV__) { + Reactotron + .configure() + .useReactNative() + .use(reactotronRedux()) + .use(sagaPlugin()) + .connect(); +} diff --git a/app/actions/actionsTypes.js b/app/actions/actionsTypes.js index 0aca14405..f4daf9a81 100644 --- a/app/actions/actionsTypes.js +++ b/app/actions/actionsTypes.js @@ -1,24 +1,107 @@ - const REQUEST = 'REQUEST'; const SUCCESS = 'SUCCESS'; const FAILURE = 'FAILURE'; const defaultTypes = [REQUEST, SUCCESS, FAILURE]; function createRequestTypes(base, types = defaultTypes) { const res = {}; - types.forEach(type => res[type] = `${ base }_${ type }`); + types.forEach(type => (res[type] = `${ base }_${ type }`)); return res; } // Login events -export const LOGIN = createRequestTypes('LOGIN', [...defaultTypes, 'SET_TOKEN', 'SUBMIT']); -export const ROOMS = createRequestTypes('ROOMS'); +export const LOGIN = createRequestTypes('LOGIN', [ + ...defaultTypes, + 'SET_TOKEN', + 'RESTORE_TOKEN', + 'SUBMIT', + 'REGISTER_SUBMIT', + 'REGISTER_REQUEST', + 'REGISTER_SUCCESS', + 'REGISTER_INCOMPLETE', + 'SET_USERNAME_SUBMIT', + 'SET_USERNAME_REQUEST', + 'SET_USERNAME_SUCCESS', + 'OPEN', + 'CLOSE', + 'SET_SERVICES', + 'REMOVE_SERVICES' +]); +export const FORGOT_PASSWORD = createRequestTypes('FORGOT_PASSWORD', [ + ...defaultTypes, + 'INIT' +]); +export const USER = createRequestTypes('USER', ['SET']); +export const ROOMS = createRequestTypes('ROOMS', [...defaultTypes, 'SET_SEARCH']); +export const ROOM = createRequestTypes('ROOM', [ + 'ADD_USER_TYPING', + 'REMOVE_USER_TYPING', + 'SOMEONE_TYPING', + 'OPEN', + 'CLOSE', + 'LEAVE', + 'ERASE', + 'USER_TYPING', + 'MESSAGE_RECEIVED', + 'SET_LAST_OPEN', + 'LAYOUT_ANIMATION' +]); export const APP = createRequestTypes('APP', ['READY', 'INIT']); -export const MESSAGES = createRequestTypes('MESSAGES'); -export const CREATE_CHANNEL = createRequestTypes('CREATE_CHANNEL', [...defaultTypes, 'REQUEST_USERS', 'SUCCESS_USERS', 'FAILURE_USERS', 'SET_USERS']); +export const MESSAGES = createRequestTypes('MESSAGES', [ + ...defaultTypes, + 'ACTIONS_SHOW', + 'ACTIONS_HIDE', + 'ERROR_ACTIONS_SHOW', + 'ERROR_ACTIONS_HIDE', + 'DELETE_REQUEST', + 'DELETE_SUCCESS', + 'DELETE_FAILURE', + 'EDIT_INIT', + 'EDIT_CANCEL', + 'EDIT_REQUEST', + 'EDIT_SUCCESS', + 'EDIT_FAILURE', + 'TOGGLE_STAR_REQUEST', + 'TOGGLE_STAR_SUCCESS', + 'TOGGLE_STAR_FAILURE', + 'PERMALINK_REQUEST', + 'PERMALINK_SUCCESS', + 'PERMALINK_FAILURE', + 'PERMALINK_CLEAR', + 'TOGGLE_PIN_REQUEST', + 'TOGGLE_PIN_SUCCESS', + 'TOGGLE_PIN_FAILURE', + 'SET_INPUT', + 'CLEAR_INPUT', + 'TOGGLE_REACTION_PICKER' +]); +export const CREATE_CHANNEL = createRequestTypes('CREATE_CHANNEL', [ + ...defaultTypes, + 'REQUEST_USERS', + 'SUCCESS_USERS', + 'FAILURE_USERS', + 'SET_USERS', + 'ADD_USER', + 'REMOVE_USER', + 'RESET' +]); export const NAVIGATION = createRequestTypes('NAVIGATION', ['SET']); -export const SERVER = createRequestTypes('SERVER', [...defaultTypes, 'SELECT', 'CHANGED', 'ADD']); -export const METEOR = createRequestTypes('METEOR_CONNECT', [...defaultTypes, 'DISCONNECT']); +export const SERVER = createRequestTypes('SERVER', [ + ...defaultTypes, + 'SELECT', + 'CHANGED', + 'ADD', + 'GOTO_ADD' +]); +export const METEOR = createRequestTypes('METEOR_CONNECT', [...defaultTypes, 'DISCONNECT', 'DISCONNECT_BY_USER']); export const LOGOUT = 'LOGOUT'; // logout is always success +export const ACTIVE_USERS = createRequestTypes('ACTIVE_USERS', ['SET']); +export const ROLES = createRequestTypes('ROLES', ['SET']); +export const STARRED_MESSAGES = createRequestTypes('STARRED_MESSAGES', ['OPEN', 'CLOSE', 'MESSAGES_RECEIVED', 'MESSAGE_UNSTARRED']); +export const PINNED_MESSAGES = createRequestTypes('PINNED_MESSAGES', ['OPEN', 'CLOSE', 'MESSAGES_RECEIVED', 'MESSAGE_UNPINNED']); +export const MENTIONED_MESSAGES = createRequestTypes('MENTIONED_MESSAGES', ['OPEN', 'CLOSE', 'MESSAGES_RECEIVED']); +export const SNIPPETED_MESSAGES = createRequestTypes('SNIPPETED_MESSAGES', ['OPEN', 'CLOSE', 'MESSAGES_RECEIVED']); +export const ROOM_FILES = createRequestTypes('ROOM_FILES', ['OPEN', 'CLOSE', 'MESSAGES_RECEIVED']); export const INCREMENT = 'INCREMENT'; export const DECREMENT = 'DECREMENT'; + diff --git a/app/actions/activeUsers.js b/app/actions/activeUsers.js new file mode 100644 index 000000000..1e7c5ecb7 --- /dev/null +++ b/app/actions/activeUsers.js @@ -0,0 +1,8 @@ +import * as types from './actionsTypes'; + +export function setActiveUser(data) { + return { + type: types.ACTIVE_USERS.SET, + data + }; +} diff --git a/app/actions/connect.js b/app/actions/connect.js index 412c096f3..40049f28e 100644 --- a/app/actions/connect.js +++ b/app/actions/connect.js @@ -25,3 +25,8 @@ export function disconnect(err) { err }; } +export function disconnect_by_user() { + return { + type: types.METEOR.DISCONNECT_BY_USER + }; +} diff --git a/app/actions/createChannel.js b/app/actions/createChannel.js index df4cad35a..435a42ddc 100644 --- a/app/actions/createChannel.js +++ b/app/actions/createChannel.js @@ -21,7 +21,6 @@ export function createChannelFailure(err) { }; } - export function createChannelRequestUsers(data) { return { type: types.CREATE_CHANNEL.REQUEST_USERS, @@ -49,3 +48,23 @@ export function createChannelFailureUsers(err) { err }; } + +export function addUser(user) { + return { + type: types.CREATE_CHANNEL.ADD_USER, + user + }; +} + +export function removeUser(user) { + return { + type: types.CREATE_CHANNEL.REMOVE_USER, + user + }; +} + +export function reset() { + return { + type: types.CREATE_CHANNEL.RESET + }; +} diff --git a/app/actions/index.js b/app/actions/index.js index dba8b7e18..b597699b4 100644 --- a/app/actions/index.js +++ b/app/actions/index.js @@ -19,12 +19,33 @@ export function setCurrentServer(server) { }; } +export function addSettings(settings) { + return { + type: types.ADD_SETTINGS, + payload: settings + }; +} export function setAllSettings(settings) { return { type: types.SET_ALL_SETTINGS, payload: settings }; } + +export function setAllPermissions(permissions) { + return { + type: types.SET_ALL_PERMISSIONS, + payload: permissions + }; +} + +export function setCustomEmojis(emojis) { + return { + type: types.SET_CUSTOM_EMOJIS, + payload: emojis + }; +} + export function login() { return { type: 'LOGIN' diff --git a/app/actions/login.js b/app/actions/login.js index 7969c8090..875c46706 100644 --- a/app/actions/login.js +++ b/app/actions/login.js @@ -13,6 +13,50 @@ export function loginRequest(credentials) { }; } +export function registerSubmit(credentials) { + return { + type: types.LOGIN.REGISTER_SUBMIT, + credentials + }; +} +export function registerRequest(credentials) { + return { + type: types.LOGIN.REGISTER_REQUEST, + credentials + }; +} +export function registerSuccess(credentials) { + return { + type: types.LOGIN.REGISTER_SUCCESS, + credentials + }; +} +export function registerIncomplete() { + return { + type: types.LOGIN.REGISTER_INCOMPLETE + }; +} + +export function setUsernameSubmit(credentials) { + return { + type: types.LOGIN.SET_USERNAME_SUBMIT, + credentials + }; +} + +export function setUsernameRequest(credentials) { + return { + type: types.LOGIN.SET_USERNAME_REQUEST, + credentials + }; +} + +export function setUsernameSuccess() { + return { + type: types.LOGIN.SET_USERNAME_SUCCESS + }; +} + export function loginSuccess(user) { return { type: types.LOGIN.SUCCESS, @@ -31,8 +75,14 @@ export function loginFailure(err) { export function setToken(user = {}) { return { type: types.LOGIN.SET_TOKEN, - token: user.token, - user + ...user + }; +} + +export function restoreToken(token) { + return { + type: types.LOGIN.RESTORE_TOKEN, + token }; } @@ -41,3 +91,61 @@ export function logout() { type: types.LOGOUT }; } + +export function forgotPasswordInit() { + return { + type: types.FORGOT_PASSWORD.INIT + }; +} + +export function forgotPasswordRequest(email) { + return { + type: types.FORGOT_PASSWORD.REQUEST, + email + }; +} + +export function forgotPasswordSuccess() { + return { + type: types.FORGOT_PASSWORD.SUCCESS + }; +} + +export function forgotPasswordFailure(err) { + return { + type: types.FORGOT_PASSWORD.FAILURE, + err + }; +} + +export function setUser(action) { + return { + type: types.USER.SET, + ...action + }; +} + +export function open() { + return { + type: types.LOGIN.OPEN + }; +} + +export function close() { + return { + type: types.LOGIN.CLOSE + }; +} + +export function setLoginServices(data) { + return { + type: types.LOGIN.SET_SERVICES, + data + }; +} + +export function removeLoginServices() { + return { + type: types.LOGIN.REMOVE_SERVICES + }; +} diff --git a/app/actions/mentionedMessages.js b/app/actions/mentionedMessages.js new file mode 100644 index 000000000..5c938bbce --- /dev/null +++ b/app/actions/mentionedMessages.js @@ -0,0 +1,21 @@ +import * as types from './actionsTypes'; + +export function openMentionedMessages(rid) { + return { + type: types.MENTIONED_MESSAGES.OPEN, + rid + }; +} + +export function closeMentionedMessages() { + return { + type: types.MENTIONED_MESSAGES.CLOSE + }; +} + +export function mentionedMessagesReceived(messages) { + return { + type: types.MENTIONED_MESSAGES.MESSAGES_RECEIVED, + messages + }; +} diff --git a/app/actions/messages.js b/app/actions/messages.js index 2f92816ec..ed9bf4971 100644 --- a/app/actions/messages.js +++ b/app/actions/messages.js @@ -19,3 +19,167 @@ export function messagesFailure(err) { err }; } + +export function actionsShow(actionMessage) { + return { + type: types.MESSAGES.ACTIONS_SHOW, + actionMessage + }; +} + +export function actionsHide() { + return { + type: types.MESSAGES.ACTIONS_HIDE + }; +} + +export function errorActionsShow(actionMessage) { + return { + type: types.MESSAGES.ERROR_ACTIONS_SHOW, + actionMessage + }; +} + +export function errorActionsHide() { + return { + type: types.MESSAGES.ERROR_ACTIONS_HIDE + }; +} + +export function deleteRequest(message) { + return { + type: types.MESSAGES.DELETE_REQUEST, + message + }; +} + +export function deleteSuccess() { + return { + type: types.MESSAGES.DELETE_SUCCESS + }; +} + +export function deleteFailure() { + return { + type: types.MESSAGES.DELETE_FAILURE + }; +} + + +export function editInit(message) { + return { + type: types.MESSAGES.EDIT_INIT, + message + }; +} + +export function editCancel() { + return { + type: types.MESSAGES.EDIT_CANCEL + }; +} + +export function editRequest(message) { + return { + type: types.MESSAGES.EDIT_REQUEST, + message + }; +} + +export function editSuccess() { + return { + type: types.MESSAGES.EDIT_SUCCESS + }; +} + +export function editFailure() { + return { + type: types.MESSAGES.EDIT_FAILURE + }; +} + +export function toggleStarRequest(message) { + return { + type: types.MESSAGES.TOGGLE_STAR_REQUEST, + message + }; +} + +export function toggleStarSuccess() { + return { + type: types.MESSAGES.TOGGLE_STAR_SUCCESS + }; +} + +export function toggleStarFailure() { + return { + type: types.MESSAGES.TOGGLE_STAR_FAILURE + }; +} + +export function permalinkRequest(message) { + return { + type: types.MESSAGES.PERMALINK_REQUEST, + message + }; +} + +export function permalinkSuccess(permalink) { + return { + type: types.MESSAGES.PERMALINK_SUCCESS, + permalink + }; +} + +export function permalinkFailure(err) { + return { + type: types.MESSAGES.PERMALINK_FAILURE, + err + }; +} + +export function permalinkClear() { + return { + type: types.MESSAGES.PERMALINK_CLEAR + }; +} + +export function togglePinRequest(message) { + return { + type: types.MESSAGES.TOGGLE_PIN_REQUEST, + message + }; +} + +export function togglePinSuccess() { + return { + type: types.MESSAGES.TOGGLE_PIN_SUCCESS + }; +} + +export function togglePinFailure(err) { + return { + type: types.MESSAGES.TOGGLE_PIN_FAILURE, + err + }; +} + +export function setInput(message) { + return { + type: types.MESSAGES.SET_INPUT, + message + }; +} + +export function clearInput() { + return { + type: types.MESSAGES.CLEAR_INPUT + }; +} + +export function toggleReactionPicker(message) { + return { + type: types.MESSAGES.TOGGLE_REACTION_PICKER, + message + }; +} diff --git a/app/actions/pinnedMessages.js b/app/actions/pinnedMessages.js new file mode 100644 index 000000000..e344b441f --- /dev/null +++ b/app/actions/pinnedMessages.js @@ -0,0 +1,28 @@ +import * as types from './actionsTypes'; + +export function openPinnedMessages(rid) { + return { + type: types.PINNED_MESSAGES.OPEN, + rid + }; +} + +export function closePinnedMessages() { + return { + type: types.PINNED_MESSAGES.CLOSE + }; +} + +export function pinnedMessagesReceived(messages) { + return { + type: types.PINNED_MESSAGES.MESSAGES_RECEIVED, + messages + }; +} + +export function pinnedMessageUnpinned(messageId) { + return { + type: types.PINNED_MESSAGES.MESSAGE_UNPINNED, + messageId + }; +} diff --git a/app/actions/roles.js b/app/actions/roles.js new file mode 100644 index 000000000..074111985 --- /dev/null +++ b/app/actions/roles.js @@ -0,0 +1,8 @@ +import * as types from './actionsTypes'; + +export function setRoles(data) { + return { + type: types.ROLES.SET, + data + }; +} diff --git a/app/actions/room.js b/app/actions/room.js new file mode 100644 index 000000000..ef6430c3b --- /dev/null +++ b/app/actions/room.js @@ -0,0 +1,77 @@ +import * as types from './actionsTypes'; + + +export function removeUserTyping(username) { + return { + type: types.ROOM.REMOVE_USER_TYPING, + username + }; +} + +export function someoneTyping(data) { + return { + type: types.ROOM.SOMEONE_TYPING, + ...data + }; +} + +export function addUserTyping(username) { + return { + type: types.ROOM.ADD_USER_TYPING, + username + }; +} + +export function openRoom(room) { + return { + type: types.ROOM.OPEN, + room + }; +} + +export function closeRoom() { + return { + type: types.ROOM.CLOSE + }; +} + +export function leaveRoom(rid) { + return { + type: types.ROOM.LEAVE, + rid + }; +} + +export function eraseRoom(rid) { + return { + type: types.ROOM.ERASE, + rid + }; +} + +export function userTyping(status = true) { + return { + type: types.ROOM.USER_TYPING, + status + }; +} + +export function roomMessageReceived(message) { + return { + type: types.ROOM.MESSAGE_RECEIVED, + message + }; +} + +export function setLastOpen(date = new Date()) { + return { + type: types.ROOM.SET_LAST_OPEN, + date + }; +} + +export function layoutAnimation() { + return { + type: types.ROOM.LAYOUT_ANIMATION + }; +} diff --git a/app/actions/roomFiles.js b/app/actions/roomFiles.js new file mode 100644 index 000000000..e8c674b7b --- /dev/null +++ b/app/actions/roomFiles.js @@ -0,0 +1,21 @@ +import * as types from './actionsTypes'; + +export function openRoomFiles(rid) { + return { + type: types.ROOM_FILES.OPEN, + rid + }; +} + +export function closeRoomFiles() { + return { + type: types.ROOM_FILES.CLOSE + }; +} + +export function roomFilesReceived(messages) { + return { + type: types.ROOM_FILES.MESSAGES_RECEIVED, + messages + }; +} diff --git a/app/actions/rooms.js b/app/actions/rooms.js index bdbf94d05..1555a8c85 100644 --- a/app/actions/rooms.js +++ b/app/actions/rooms.js @@ -1,5 +1,6 @@ import * as types from './actionsTypes'; + export function roomsRequest() { return { type: types.ROOMS.REQUEST @@ -18,3 +19,10 @@ export function roomsFailure(err) { err }; } + +export function setSearch(searchText) { + return { + type: types.ROOMS.SET_SEARCH, + searchText + }; +} diff --git a/app/actions/server.js b/app/actions/server.js index cfc46ad89..b334981b5 100644 --- a/app/actions/server.js +++ b/app/actions/server.js @@ -41,3 +41,9 @@ export function changedServer(server) { server }; } + +export function gotoAddServer() { + return { + type: SERVER.GOTO_ADD + }; +} diff --git a/app/actions/snippetedMessages.js b/app/actions/snippetedMessages.js new file mode 100644 index 000000000..54c6edeea --- /dev/null +++ b/app/actions/snippetedMessages.js @@ -0,0 +1,21 @@ +import * as types from './actionsTypes'; + +export function openSnippetedMessages(rid) { + return { + type: types.SNIPPETED_MESSAGES.OPEN, + rid + }; +} + +export function closeSnippetedMessages() { + return { + type: types.SNIPPETED_MESSAGES.CLOSE + }; +} + +export function snippetedMessagesReceived(messages) { + return { + type: types.SNIPPETED_MESSAGES.MESSAGES_RECEIVED, + messages + }; +} diff --git a/app/actions/starredMessages.js b/app/actions/starredMessages.js new file mode 100644 index 000000000..36b701a86 --- /dev/null +++ b/app/actions/starredMessages.js @@ -0,0 +1,28 @@ +import * as types from './actionsTypes'; + +export function openStarredMessages(rid) { + return { + type: types.STARRED_MESSAGES.OPEN, + rid + }; +} + +export function closeStarredMessages() { + return { + type: types.STARRED_MESSAGES.CLOSE + }; +} + +export function starredMessagesReceived(messages) { + return { + type: types.STARRED_MESSAGES.MESSAGES_RECEIVED, + messages + }; +} + +export function starredMessageUnstarred(messageId) { + return { + type: types.STARRED_MESSAGES.MESSAGE_UNSTARRED, + messageId + }; +} diff --git a/app/animations/collapse.js b/app/animations/collapse.js new file mode 100644 index 000000000..05cfce52a --- /dev/null +++ b/app/animations/collapse.js @@ -0,0 +1,64 @@ +import { View, Animated } from 'react-native'; + +import PropTypes from 'prop-types'; +import React from 'react'; + +export default class Panel extends React.Component { + static propTypes = { + open: PropTypes.bool.isRequired, + children: PropTypes.node.isRequired, + style: PropTypes.object + } + constructor(props) { + super(props); + this.state = { + animation: new Animated.Value() + }; + this.first = true; + this.open = false; + this.opacity = 0; + } + componentDidMount() { + const initialValue = !this.props.open ? this.height : 0; + this.state.animation.setValue(initialValue); + } + componentWillReceiveProps(nextProps) { + if (this.first) { + this.first = false; + if (!this.props.open) { + this.state.animation.setValue(0); + return; + } + } + if (this.open === nextProps.open) { + return; + } + this.open = nextProps.open; + const initialValue = !nextProps.open ? this.height : 0; + const finalValue = !nextProps.open ? 0 : this.height; + + this.state.animation.setValue(initialValue); + Animated.timing( + this.state.animation, + { + toValue: finalValue, + duration: 150, + useNativeDriver: true + } + ).start(); + } + set _height(h) { + this.height = h || this.height; + } + render() { + return ( + + this._height = nativeEvent.layout.height} style={{ position: !this.first ? 'relative' : 'absolute' }}> + {this.props.children} + + + ); + } +} diff --git a/app/animations/fade.js b/app/animations/fade.js index b9f276903..97693d03a 100644 --- a/app/animations/fade.js +++ b/app/animations/fade.js @@ -17,9 +17,6 @@ export default class Fade extends React.Component { this.state = { visible: props.visible }; - } - - componentWillMount() { this._visibility = new Animated.Value(this.props.visible ? 1 : 0); } @@ -29,7 +26,8 @@ export default class Fade extends React.Component { } Animated.timing(this._visibility, { toValue: nextProps.visible ? 1 : 0, - duration: 300 + duration: 300, + useNativeDriver: true }).start(() => { this.setState({ visible: nextProps.visible }); }); diff --git a/app/components/KeyboardView.js b/app/components/KeyboardView.js deleted file mode 100644 index ba1e50ea0..000000000 --- a/app/components/KeyboardView.js +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { KeyboardAvoidingView, Platform } from 'react-native'; - -export default class KeyboardView extends React.PureComponent { - static propTypes = { - style: KeyboardAvoidingView.propTypes.style, - keyboardVerticalOffset: PropTypes.number, - children: PropTypes.oneOfType([ - PropTypes.arrayOf(PropTypes.node), - PropTypes.node - ]) - } - - render() { - return ( - - {this.props.children} - - ); - } -} diff --git a/app/components/Message.js b/app/components/Message.js deleted file mode 100644 index b1dee59ed..000000000 --- a/app/components/Message.js +++ /dev/null @@ -1,88 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { View, Text, StyleSheet } from 'react-native'; -import { emojify } from 'react-emojione'; -import Markdown from 'react-native-easy-markdown'; -import moment from 'moment'; -import Avatar from './avatar'; -import Card from './message/card'; - -const styles = StyleSheet.create({ - content: { - flexGrow: 1 - }, - message: { - padding: 12, - paddingTop: 6, - paddingBottom: 6, - flexDirection: 'row', - transform: [{ scaleY: -1 }] - }, - texts: { - flex: 1 - }, - msg: { - flex: 1 - }, - username: { - fontWeight: 'bold' - }, - usernameView: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 2 - }, - alias: { - fontSize: 10, - color: '#888', - paddingLeft: 5 - }, - time: { - fontSize: 10, - color: '#888', - paddingLeft: 5 - } -}); - -export default class Message extends React.PureComponent { - static propTypes = { - item: PropTypes.object.isRequired, - baseUrl: PropTypes.string.isRequired, - Message_TimeFormat: PropTypes.string.isRequired - } - attachments() { - return this.props.item.attachments.length ? : null; - } - render() { - const { item } = this.props; - - const extraStyle = {}; - if (item.temp) { - extraStyle.opacity = 0.3; - } - - const msg = emojify(item.msg, { output: 'unicode' }); - - const username = item.alias || item.u.username; - - const time = moment(item.ts).format(this.props.Message_TimeFormat); - - return ( - - - - - - {username} - - {item.alias && @{item.u.username}}{time} - - {this.attachments()} - - {msg} - - - - ); - } -} diff --git a/app/components/MessageBox.js b/app/components/MessageBox.js deleted file mode 100644 index e1f809073..000000000 --- a/app/components/MessageBox.js +++ /dev/null @@ -1,94 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { View, TextInput, StyleSheet } from 'react-native'; -import Icon from 'react-native-vector-icons/MaterialIcons'; -import ImagePicker from 'react-native-image-picker'; -import RocketChat from '../lib/rocketchat'; - -const styles = StyleSheet.create({ - textBox: { - paddingTop: 1, - borderTopWidth: 1, - borderTopColor: '#ccc', - backgroundColor: '#fff', - flexDirection: 'row', - alignItems: 'center' - }, - textBoxInput: { - height: 40, - alignSelf: 'stretch', - backgroundColor: '#fff', - flexGrow: 1 - }, - fileButton: { - color: '#aaa', - paddingLeft: 23, - paddingRight: 20, - paddingTop: 10, - paddingBottom: 10, - fontSize: 20 - } -}); - -export default class MessageBox extends React.PureComponent { - static propTypes = { - onSubmit: PropTypes.func.isRequired, - rid: PropTypes.string.isRequired - } - - submit(message) { - const text = message; - if (text.trim() === '') { - return; - } - if (this.component) { - this.component.setNativeProps({ text: '' }); - } - this.props.onSubmit(text); - } - - addFile = () => { - const options = { - customButtons: [{ - name: 'import', title: 'Import File From' - }] - }; - - ImagePicker.showImagePicker(options, (response) => { - if (response.didCancel) { - console.log('User cancelled image picker'); - } else if (response.error) { - console.log('ImagePicker Error: ', response.error); - } else if (response.customButton) { - console.log('User tapped custom button: ', response.customButton); - } else { - const fileInfo = { - name: response.fileName, - size: response.fileSize, - type: response.type || 'image/jpeg', - // description: '', - store: 'Uploads' - }; - RocketChat.sendFileMessage(this.props.rid, fileInfo, response.data); - } - }); - } - - render() { - return ( - - - this.component = component} - style={styles.textBoxInput} - returnKeyType='send' - onSubmitEditing={event => this.submit(event.nativeEvent.text)} - blurOnSubmit={false} - placeholder='New message' - underlineColorAndroid='transparent' - defaultValue={''} - /> - - ); - } -} diff --git a/app/components/RoomItem.js b/app/components/RoomItem.js deleted file mode 100644 index b1957787e..000000000 --- a/app/components/RoomItem.js +++ /dev/null @@ -1,123 +0,0 @@ -import React from 'react'; -import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; -import PropTypes from 'prop-types'; -import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; -import Avatar from './avatar'; -import avatarInitialsAndColor from '../utils/avatarInitialsAndColor'; - -const styles = StyleSheet.create({ - container: { - // flex: 1, - flexDirection: 'row', - paddingLeft: 16, - paddingRight: 16, - height: 56, - alignItems: 'center' - }, - number: { - minWidth: 20, - borderRadius: 5, - backgroundColor: '#1d74f5', - color: '#fff', - textAlign: 'center', - overflow: 'hidden', - fontSize: 14, - paddingLeft: 5, - paddingRight: 5 - }, - roomName: { - flex: 1, - fontSize: 16, - color: '#444', - marginLeft: 16, - marginRight: 4 - }, - iconContainer: { - height: 40, - width: 40, - borderRadius: 20, - overflow: 'hidden', - justifyContent: 'center', - alignItems: 'center' - }, - icon: { - fontSize: 20, - color: '#fff' - }, - avatar: { - width: 40, - height: 40, - position: 'absolute', - borderRadius: 20 - }, - avatarInitials: { - fontSize: 20, - color: '#ffffff' - } -}); - -export default class RoomItem extends React.PureComponent { - static propTypes = { - type: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - unread: PropTypes.number, - baseUrl: PropTypes.string, - onPress: PropTypes.func - } - - get icon() { - const { type, name, baseUrl } = this.props; - - const icon = { - d: 'at', - c: 'pound', - p: 'lock', - l: 'account' - }[type]; - - if (!icon) { - return null; - } - - if (type === 'd') { - return ( - - ); - } - - const { color } = avatarInitialsAndColor(name); - - return ( - - - - ); - } - - renderNumber = (unread) => { - if (!unread || unread <= 0) { - return; - } - - if (unread >= 1000) { - unread = '999+'; - } - - return ( - - { unread } - - ); - } - - render() { - const { unread, name } = this.props; - return ( - - {this.icon} - { name } - {this.renderNumber(unread)} - - ); - } -} diff --git a/app/components/avatar.js b/app/components/avatar.js deleted file mode 100644 index 8cc05a7fc..000000000 --- a/app/components/avatar.js +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { StyleSheet, Text, View } from 'react-native'; -import { CachedImage } from 'react-native-img-cache'; -import avatarInitialsAndColor from '../utils/avatarInitialsAndColor'; - -const styles = StyleSheet.create({ - iconContainer: { - overflow: 'hidden', - justifyContent: 'center', - alignItems: 'center' - }, - avatar: { - position: 'absolute' - }, - avatarInitials: { - color: '#ffffff' - } }); - -class Avatar extends React.PureComponent { - render() { - const { text = '', size = 25, baseUrl = this.props.baseUrl, - borderRadius = 5, style, avatar } = this.props; - const { initials, color } = avatarInitialsAndColor(`${ text }`); - return ( - - {initials} - { (avatar || baseUrl) && } - ); - } -} - -Avatar.propTypes = { - style: PropTypes.object, - baseUrl: PropTypes.string, - text: PropTypes.string.isRequired, - avatar: PropTypes.string, - size: PropTypes.number, - borderRadius: PropTypes.number -}; -export default Avatar; diff --git a/app/components/message/card.js b/app/components/message/card.js deleted file mode 100644 index c039c26d8..000000000 --- a/app/components/message/card.js +++ /dev/null @@ -1,88 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Meteor from 'react-native-meteor'; -import { connect } from 'react-redux'; -import { CachedImage } from 'react-native-img-cache'; -import { Text, TouchableOpacity } from 'react-native'; -import { Navigation } from 'react-native-navigation'; -import { - Card, - CardImage, - // CardTitle, - CardContent - // CardAction -} from 'react-native-card-view'; -import RocketChat from '../../lib/rocketchat'; - -const close = () => Navigation.dismissModal({ - animationType: 'slide-down' -}); - -const CustomButton = ({ text }) => ( - - {text} - -); - -CustomButton.propTypes = { - text: PropTypes.string -}; - -Navigation.registerComponent('CustomButton', () => CustomButton); - -@connect(state => ({ - base: state.settings.Site_Url, - canShowList: state.login.token.length || state.login.user.token -})) - -export default class Cards extends React.PureComponent { - static propTypes = { - data: PropTypes.object.isRequired, - base: PropTypes.string - } - constructor() { - super(); - const user = Meteor.user(); - this.state = {}; - RocketChat.getUserToken().then((token) => { - this.setState({ img: `${ this.props.base }${ this.props.data.image_url }?rc_uid=${ user._id }&rc_token=${ token }` }); - }); - } - _onPressButton() { - Navigation.showModal({ - screen: 'Photo', - title: this.props.data.title, // title of the screen as appears in the nav bar (optional) - passProps: { image: this.state.img }, - // navigatorStyle: {}, // override the navigator style for the screen, see "Styling the navigator" below (optional) - navigatorButtons: { - leftButtons: [{ - id: 'custom-button', - component: 'CustomButton', - passProps: { - text: 'close' - } - }] - }, // override the nav buttons for the screen, see "Adding buttons to the navigator" below (optional) - animationType: 'slide-up' // 'none' / 'slide-up' , appear animation for the modal (optional, default 'slide-up') - }); - } - render() { - return this.state.img ? ( - this._onPressButton()}> - - - - - - {this.props.data.title} - {this.props.data.description} - - - - ) : - {this.props.data.title}; - } -} diff --git a/app/constants/colors.js b/app/constants/colors.js index 145e665be..5caf08c58 100644 --- a/app/constants/colors.js +++ b/app/constants/colors.js @@ -1,2 +1,9 @@ export const AVATAR_COLORS = ['#F44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5', '#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50', '#8BC34A', '#CDDC39', '#FFC107', '#FF9800', '#FF5722', '#795548', '#9E9E9E', '#607D8B']; export const ESLINT_FIX = null; +export const COLOR_DANGER = '#f5455c'; +export const STATUS_COLORS = { + online: '#2de0a5', + busy: COLOR_DANGER, + away: '#ffd21f', + offline: '#cbced1' +}; diff --git a/app/constants/messagesStatus.js b/app/constants/messagesStatus.js new file mode 100644 index 000000000..4f945f939 --- /dev/null +++ b/app/constants/messagesStatus.js @@ -0,0 +1,5 @@ +export default { + SENT: 0, + TEMP: 1, + ERROR: 2 +}; diff --git a/app/constants/types.js b/app/constants/types.js index f772c2b92..3e65838cf 100644 --- a/app/constants/types.js +++ b/app/constants/types.js @@ -1,2 +1,5 @@ export const SET_CURRENT_SERVER = 'SET_CURRENT_SERVER'; export const SET_ALL_SETTINGS = 'SET_ALL_SETTINGS'; +export const SET_ALL_PERMISSIONS = 'SET_ALL_PERMISSIONS'; +export const SET_CUSTOM_EMOJIS = 'SET_CUSTOM_EMOJIS'; +export const ADD_SETTINGS = 'ADD_SETTINGS'; diff --git a/app/containers/Avatar.js b/app/containers/Avatar.js new file mode 100644 index 000000000..3ab6c266b --- /dev/null +++ b/app/containers/Avatar.js @@ -0,0 +1,88 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { StyleSheet, Text, View, ViewPropTypes } from 'react-native'; +import { CachedImage } from 'react-native-img-cache'; +import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; +import avatarInitialsAndColor from '../utils/avatarInitialsAndColor'; + +const styles = StyleSheet.create({ + iconContainer: { + // overflow: 'hidden', + justifyContent: 'center', + alignItems: 'center' + }, + avatar: { + position: 'absolute' + }, + avatarInitials: { + color: '#ffffff' + } +}); + +@connect(state => ({ + baseUrl: state.settings.Site_Url || state.server ? state.server.server : '' +})) +export default class Avatar extends React.PureComponent { + static propTypes = { + style: ViewPropTypes.style, + baseUrl: PropTypes.string, + text: PropTypes.string.isRequired, + avatar: PropTypes.string, + size: PropTypes.number, + borderRadius: PropTypes.number, + type: PropTypes.string, + children: PropTypes.object + }; + render() { + const { + text = '', size = 25, baseUrl, borderRadius = 4, style, avatar, type = 'd' + } = this.props; + const { initials, color } = avatarInitialsAndColor(`${ text }`); + + const iconContainerStyle = { + backgroundColor: color, + width: size, + height: size, + borderRadius + }; + + const avatarInitialsStyle = { + fontSize: size / 2 + }; + + const avatarStyle = { + width: size, + height: size, + borderRadius + }; + + if (type === 'd') { + const uri = avatar || `${ baseUrl }/avatar/${ text }`; + const image = (avatar || baseUrl) && ( + + ); + return ( + + {initials} + {image} + {this.props.children} + ); + } + + const icon = { + c: 'pound', + p: 'lock', + l: 'account' + }[type]; + + return ( + + + + ); + } +} diff --git a/app/components/banner.js b/app/containers/Banner.js similarity index 99% rename from app/components/banner.js rename to app/containers/Banner.js index 1192500bc..5cc765c39 100644 --- a/app/components/banner.js +++ b/app/containers/Banner.js @@ -30,9 +30,16 @@ export default class Banner extends React.PureComponent { authenticating: PropTypes.bool, offline: PropTypes.bool } - render() { const { connecting, authenticating, offline } = this.props; + + if (offline) { + return ( + + offline... + + ); + } if (connecting) { return ( @@ -48,13 +55,7 @@ export default class Banner extends React.PureComponent { ); } - if (offline) { - return ( - - offline... - - ); - } + return null; } } diff --git a/app/containers/EmojiPicker/CustomEmoji.js b/app/containers/EmojiPicker/CustomEmoji.js new file mode 100644 index 000000000..8215f0ea6 --- /dev/null +++ b/app/containers/EmojiPicker/CustomEmoji.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { ViewPropTypes } from 'react-native'; +import PropTypes from 'prop-types'; +import { CachedImage } from 'react-native-img-cache'; +import { connect } from 'react-redux'; + +@connect(state => ({ + baseUrl: state.settings.Site_Url +})) +export default class CustomEmoji extends React.Component { + static propTypes = { + baseUrl: PropTypes.string.isRequired, + emoji: PropTypes.object.isRequired, + style: ViewPropTypes.style + } + shouldComponentUpdate() { + return false; + } + render() { + const { baseUrl, emoji, style } = this.props; + return ( + + ); + } +} diff --git a/app/containers/EmojiPicker/EmojiCategory.js b/app/containers/EmojiPicker/EmojiCategory.js new file mode 100644 index 000000000..3e3823ba8 --- /dev/null +++ b/app/containers/EmojiPicker/EmojiCategory.js @@ -0,0 +1,71 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Text, TouchableOpacity, Platform } from 'react-native'; +import { emojify } from 'react-emojione'; +import { responsive } from 'react-native-responsive-ui'; +import { OptimizedFlatList } from 'react-native-optimized-flatlist'; +import styles from './styles'; +import CustomEmoji from './CustomEmoji'; +import scrollPersistTaps from '../../utils/scrollPersistTaps'; + +const emojisPerRow = Platform.OS === 'ios' ? 8 : 9; + +const renderEmoji = (emoji, size) => { + if (emoji.isCustom) { + return ; + } + return ( + + {emojify(`:${ emoji }:`, { output: 'unicode' })} + + ); +}; + + +@responsive +export default class EmojiCategory extends React.Component { + static propTypes = { + emojis: PropTypes.any, + window: PropTypes.any, + onEmojiSelected: PropTypes.func, + emojisPerRow: PropTypes.number, + width: PropTypes.number + }; + constructor(props) { + super(props); + const { width, height } = this.props.window; + + this.size = Math.min(this.props.width || width, height) / (this.props.emojisPerRow || emojisPerRow); + this.emojis = props.emojis; + } + + shouldComponentUpdate() { + return false; + } + + renderItem(emoji, size) { + return ( + this.props.onEmojiSelected(emoji)} + > + {renderEmoji(emoji, size)} + ); + } + + render() { + return ( + (item.isCustom && item.content) || item} + data={this.props.emojis} + renderItem={({ item }) => this.renderItem(item, this.size)} + numColumns={emojisPerRow} + initialNumToRender={45} + getItemLayout={(data, index) => ({ length: this.size, offset: this.size * index, index })} + removeClippedSubviews + {...scrollPersistTaps} + /> + ); + } +} diff --git a/app/containers/EmojiPicker/TabBar.js b/app/containers/EmojiPicker/TabBar.js new file mode 100644 index 000000000..4886006a6 --- /dev/null +++ b/app/containers/EmojiPicker/TabBar.js @@ -0,0 +1,31 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { View, TouchableOpacity, Text } from 'react-native'; +import styles from './styles'; + +export default class TabBar extends React.PureComponent { + static propTypes = { + goToPage: PropTypes.func, + activeTab: PropTypes.number, + tabs: PropTypes.array, + tabEmojiStyle: PropTypes.object + } + + render() { + return ( + + {this.props.tabs.map((tab, i) => ( + this.props.goToPage(i)} + style={styles.tab} + > + {tab} + {this.props.activeTab === i ? : } + + ))} + + ); + } +} diff --git a/app/containers/EmojiPicker/categories.js b/app/containers/EmojiPicker/categories.js new file mode 100644 index 000000000..a95f67cf6 --- /dev/null +++ b/app/containers/EmojiPicker/categories.js @@ -0,0 +1,44 @@ +const list = ['frequentlyUsed', 'custom', 'people', 'nature', 'food', 'activity', 'travel', 'objects', 'symbols', 'flags']; +const tabs = [ + { + tabLabel: '🕒', + category: list[0] + }, + { + tabLabel: '🚀', + category: list[1] + }, + { + tabLabel: '😃', + category: list[2] + }, + { + tabLabel: '🐶', + category: list[3] + }, + { + tabLabel: '🍔', + category: list[4] + }, + { + tabLabel: '⚽', + category: list[5] + }, + { + tabLabel: '🚌', + category: list[6] + }, + { + tabLabel: '💡', + category: list[7] + }, + { + tabLabel: '💛', + category: list[8] + }, + { + tabLabel: '🏁', + category: list[9] + } +]; +export default { list, tabs }; diff --git a/app/containers/EmojiPicker/index.js b/app/containers/EmojiPicker/index.js new file mode 100644 index 000000000..104b496a3 --- /dev/null +++ b/app/containers/EmojiPicker/index.js @@ -0,0 +1,141 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { ScrollView } from 'react-native'; +import ScrollableTabView from 'react-native-scrollable-tab-view'; +import _ from 'lodash'; +import { emojify } from 'react-emojione'; +import TabBar from './TabBar'; +import EmojiCategory from './EmojiCategory'; +import styles from './styles'; +import categories from './categories'; +import database from '../../lib/realm'; +import { emojisByCategory } from '../../emojis'; + +const scrollProps = { + keyboardShouldPersistTaps: 'always', + keyboardDismissMode: 'none' +}; + +export default class EmojiPicker extends Component { + static propTypes = { + onEmojiSelected: PropTypes.func, + tabEmojiStyle: PropTypes.object, + emojisPerRow: PropTypes.number, + width: PropTypes.number + }; + + constructor(props) { + super(props); + this.state = { + frequentlyUsed: [], + customEmojis: [] + }; + this.frequentlyUsed = database.objects('frequentlyUsedEmoji').sorted('count', true); + this.customEmojis = database.objects('customEmojis'); + this.updateFrequentlyUsed = this.updateFrequentlyUsed.bind(this); + this.updateCustomEmojis = this.updateCustomEmojis.bind(this); + } + // + // shouldComponentUpdate(nextProps) { + // return false; + // } + + componentDidMount() { + requestAnimationFrame(() => this.setState({ show: true })); + this.frequentlyUsed.addListener(this.updateFrequentlyUsed); + this.customEmojis.addListener(this.updateCustomEmojis); + this.updateFrequentlyUsed(); + this.updateCustomEmojis(); + } + componentWillUnmount() { + this.frequentlyUsed.removeAllListeners(); + this.customEmojis.removeAllListeners(); + } + + onEmojiSelected(emoji) { + if (emoji.isCustom) { + const count = this._getFrequentlyUsedCount(emoji.content); + this._addFrequentlyUsed({ + content: emoji.content, extension: emoji.extension, count, isCustom: true + }); + this.props.onEmojiSelected(`:${ emoji.content }:`); + } else { + const content = emoji; + const count = this._getFrequentlyUsedCount(content); + this._addFrequentlyUsed({ content, count, isCustom: false }); + const shortname = `:${ emoji }:`; + this.props.onEmojiSelected(emojify(shortname, { output: 'unicode' }), shortname); + } + } + + _addFrequentlyUsed = (emoji) => { + database.write(() => { + database.create('frequentlyUsedEmoji', emoji, true); + }); + } + _getFrequentlyUsedCount = (content) => { + const emojiRow = this.frequentlyUsed.filtered('content == $0', content); + return emojiRow.length ? emojiRow[0].count + 1 : 1; + } + updateFrequentlyUsed() { + const frequentlyUsed = _.map(this.frequentlyUsed.slice(), (item) => { + if (item.isCustom) { + return item; + } + return emojify(`${ item.content }`, { output: 'unicode' }); + }); + this.setState({ frequentlyUsed }); + } + + updateCustomEmojis() { + const customEmojis = _.map(this.customEmojis.slice(), item => + ({ content: item.name, extension: item.extension, isCustom: true })); + this.setState({ customEmojis }); + } + + renderCategory(category, i) { + let emojis = []; + if (i === 0) { + emojis = this.state.frequentlyUsed; + } else if (i === 1) { + emojis = this.state.customEmojis; + } else { + emojis = emojisByCategory[category]; + } + return ( + this.onEmojiSelected(emoji)} + style={styles.categoryContainer} + size={this.props.emojisPerRow} + width={this.props.width} + /> + ); + } + + render() { + if (!this.state.show) { + return null; + } + return ( + // + } + contentProps={scrollProps} + > + { + categories.tabs.map((tab, i) => ( + + {this.renderCategory(tab.category, i)} + + )) + } + + // + ); + } +} diff --git a/app/containers/EmojiPicker/styles.js b/app/containers/EmojiPicker/styles.js new file mode 100644 index 000000000..038d11b20 --- /dev/null +++ b/app/containers/EmojiPicker/styles.js @@ -0,0 +1,57 @@ +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + flex: 1 + }, + tabsContainer: { + height: 45, + flexDirection: 'row', + paddingTop: 5 + }, + tab: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingBottom: 10 + }, + tabEmoji: { + fontSize: 20, + color: 'black' + }, + activeTabLine: { + position: 'absolute', + left: 0, + right: 0, + height: 2, + backgroundColor: '#007aff', + bottom: 0 + }, + tabLine: { + position: 'absolute', + left: 0, + right: 0, + height: 2, + backgroundColor: 'rgba(0,0,0,0.05)', + bottom: 0 + }, + categoryContainer: { + flex: 1, + alignItems: 'flex-start' + }, + categoryInner: { + flexWrap: 'wrap', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-start', + flex: 1 + }, + categoryEmoji: { + color: 'black', + backgroundColor: 'transparent', + textAlign: 'center' + }, + customCategoryEmoji: { + margin: 4 + } +}); diff --git a/app/containers/Header.js b/app/containers/Header.js new file mode 100644 index 000000000..277f73fec --- /dev/null +++ b/app/containers/Header.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { View, StyleSheet, Platform } from 'react-native'; +import PropTypes from 'prop-types'; +import SafeAreaView from 'react-native-safe-area-view'; + +let platformContainerStyles; +if (Platform.OS === 'ios') { + platformContainerStyles = { + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: 'rgba(0, 0, 0, .3)' + }; +} else { + platformContainerStyles = { + shadowColor: 'black', + shadowOpacity: 0.1, + shadowRadius: StyleSheet.hairlineWidth, + shadowOffset: { + height: StyleSheet.hairlineWidth + }, + elevation: 4 + }; +} + +const height = Platform.OS === 'ios' ? 44 : 56; +const backgroundColor = Platform.OS === 'ios' ? '#F7F7F7' : '#FFF'; +const styles = StyleSheet.create({ + container: { + backgroundColor, + ...platformContainerStyles + }, + appBar: { + height, + backgroundColor + } +}); + +export default class Header extends React.PureComponent { + static propTypes = { + subview: PropTypes.object.isRequired + } + + render() { + return ( + + + {this.props.subview} + + + ); + } +} diff --git a/app/containers/MessageActions.js b/app/containers/MessageActions.js new file mode 100644 index 000000000..0a82d50ff --- /dev/null +++ b/app/containers/MessageActions.js @@ -0,0 +1,347 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Alert, Clipboard, Vibration, Share } from 'react-native'; +import { connect } from 'react-redux'; +import ActionSheet from 'react-native-actionsheet'; +import * as moment from 'moment'; + +import { + deleteRequest, + editInit, + toggleStarRequest, + permalinkRequest, + permalinkClear, + togglePinRequest, + setInput, + actionsHide, + toggleReactionPicker +} from '../actions/messages'; +import { showToast } from '../utils/info'; +import RocketChat from '../lib/rocketchat'; + +@connect( + state => ({ + showActions: state.messages.showActions, + actionMessage: state.messages.actionMessage, + user: state.login.user, + permissions: state.permissions, + permalink: state.messages.permalink, + Message_AllowDeleting: state.settings.Message_AllowDeleting, + Message_AllowDeleting_BlockDeleteInMinutes: state.settings.Message_AllowDeleting_BlockDeleteInMinutes, + Message_AllowEditing: state.settings.Message_AllowEditing, + Message_AllowEditing_BlockEditInMinutes: state.settings.Message_AllowEditing_BlockEditInMinutes, + Message_AllowPinning: state.settings.Message_AllowPinning, + Message_AllowStarring: state.settings.Message_AllowStarring + }), + dispatch => ({ + actionsHide: () => dispatch(actionsHide()), + deleteRequest: message => dispatch(deleteRequest(message)), + editInit: message => dispatch(editInit(message)), + toggleStarRequest: message => dispatch(toggleStarRequest(message)), + permalinkRequest: message => dispatch(permalinkRequest(message)), + permalinkClear: () => dispatch(permalinkClear()), + togglePinRequest: message => dispatch(togglePinRequest(message)), + setInput: message => dispatch(setInput(message)), + toggleReactionPicker: message => dispatch(toggleReactionPicker(message)) + }) +) +export default class MessageActions extends React.Component { + static propTypes = { + actionsHide: PropTypes.func.isRequired, + showActions: PropTypes.bool.isRequired, + room: PropTypes.object, + actionMessage: PropTypes.object, + user: PropTypes.object, + permissions: PropTypes.object.isRequired, + deleteRequest: PropTypes.func.isRequired, + editInit: PropTypes.func.isRequired, + toggleStarRequest: PropTypes.func.isRequired, + permalinkRequest: PropTypes.func.isRequired, + permalinkClear: PropTypes.func.isRequired, + togglePinRequest: PropTypes.func.isRequired, + setInput: PropTypes.func.isRequired, + permalink: PropTypes.string, + toggleReactionPicker: PropTypes.func.isRequired, + Message_AllowDeleting: PropTypes.bool, + Message_AllowDeleting_BlockDeleteInMinutes: PropTypes.number, + Message_AllowEditing: PropTypes.bool, + Message_AllowEditing_BlockEditInMinutes: PropTypes.number, + Message_AllowPinning: PropTypes.bool, + Message_AllowStarring: PropTypes.bool + }; + + constructor(props) { + super(props); + this.state = { + copyPermalink: false, + reply: false, + quote: false + }; + this.handleActionPress = this.handleActionPress.bind(this); + this.options = ['']; + this.setPermissions(this.props.permissions); + } + + async componentWillReceiveProps(nextProps) { + if (nextProps.showActions !== this.props.showActions && nextProps.showActions) { + const { actionMessage } = nextProps; + // Cancel + this.options = ['Cancel']; + this.CANCEL_INDEX = 0; + // Reply + if (!this.isRoomReadOnly()) { + this.options.push('Reply'); + this.REPLY_INDEX = this.options.length - 1; + } + // Edit + if (this.allowEdit(nextProps)) { + this.options.push('Edit'); + this.EDIT_INDEX = this.options.length - 1; + } + // Permalink + this.options.push('Copy Permalink'); + this.PERMALINK_INDEX = this.options.length - 1; + // Copy + this.options.push('Copy Message'); + this.COPY_INDEX = this.options.length - 1; + // Share + this.options.push('Share Message'); + this.SHARE_INDEX = this.options.length - 1; + // Quote + if (!this.isRoomReadOnly()) { + this.options.push('Quote'); + this.QUOTE_INDEX = this.options.length - 1; + } + // Star + if (this.props.Message_AllowStarring) { + this.options.push(actionMessage.starred ? 'Unstar' : 'Star'); + this.STAR_INDEX = this.options.length - 1; + } + // Pin + if (this.props.Message_AllowPinning) { + this.options.push(actionMessage.pinned ? 'Unpin' : 'Pin'); + this.PIN_INDEX = this.options.length - 1; + } + // Reaction + if (!this.isRoomReadOnly() || this.canReactWhenReadOnly()) { + this.options.push('Add Reaction'); + this.REACTION_INDEX = this.options.length - 1; + } + // Delete + if (this.allowDelete(nextProps)) { + this.options.push('Delete'); + this.DELETE_INDEX = this.options.length - 1; + } + setTimeout(() => { + this.ActionSheet.show(); + Vibration.vibrate(50); + }); + } else if (this.props.permalink !== nextProps.permalink && nextProps.permalink) { + // copy permalink + if (this.state.copyPermalink) { + this.setState({ copyPermalink: false }); + await Clipboard.setString(nextProps.permalink); + showToast('Permalink copied to clipboard!'); + this.props.permalinkClear(); + // quote + } else if (this.state.quote) { + this.setState({ quote: false }); + const msg = `[ ](${ nextProps.permalink }) `; + this.props.setInput({ msg }); + + // reply + } else if (this.state.reply) { + this.setState({ reply: false }); + let msg = `[ ](${ nextProps.permalink }) `; + + // if original message wasn't sent by current user and neither from a direct room + if (this.props.user.username !== this.props.actionMessage.u.username && this.props.room.t !== 'd') { + msg += `@${ this.props.actionMessage.u.username } `; + } + this.props.setInput({ msg }); + } + } + } + + componentDidUpdate() { + this.setPermissions(this.props.permissions); + } + + setPermissions() { + const permissions = ['edit-message', 'delete-message', 'force-delete-message']; + const result = RocketChat.hasPermission(permissions, this.props.room.rid); + this.hasEditPermission = result[permissions[0]]; + this.hasDeletePermission = result[permissions[1]]; + this.hasForceDeletePermission = result[permissions[2]]; + } + + isOwn = props => props.actionMessage.u && props.actionMessage.u._id === props.user.id; + + isRoomReadOnly = () => this.props.room.ro; + + canReactWhenReadOnly = () => this.props.room.reactWhenReadOnly; + + allowEdit = (props) => { + if (this.isRoomReadOnly()) { + return false; + } + const editOwn = this.isOwn(props); + const { Message_AllowEditing: isEditAllowed } = this.props; + if (!(this.hasEditPermission || (isEditAllowed && editOwn))) { + return false; + } + const blockEditInMinutes = this.props.Message_AllowEditing_BlockEditInMinutes; + if (blockEditInMinutes) { + let msgTs; + if (props.actionMessage.ts != null) { + msgTs = moment(props.actionMessage.ts); + } + let currentTsDiff; + if (msgTs != null) { + currentTsDiff = moment().diff(msgTs, 'minutes'); + } + return currentTsDiff < blockEditInMinutes; + } + return true; + } + + allowDelete = (props) => { + if (this.isRoomReadOnly()) { + return false; + } + const deleteOwn = this.isOwn(props); + const { Message_AllowDeleting: isDeleteAllowed } = this.props; + if (!(this.hasDeletePermission || (isDeleteAllowed && deleteOwn) || this.hasForceDeletePermission)) { + return false; + } + if (this.hasForceDeletePermission) { + return true; + } + const blockDeleteInMinutes = this.props.Message_AllowDeleting_BlockDeleteInMinutes; + if (blockDeleteInMinutes != null && blockDeleteInMinutes !== 0) { + let msgTs; + if (props.actionMessage.ts != null) { + msgTs = moment(props.actionMessage.ts); + } + let currentTsDiff; + if (msgTs != null) { + currentTsDiff = moment().diff(msgTs, 'minutes'); + } + return currentTsDiff < blockDeleteInMinutes; + } + return true; + } + + handleDelete() { + Alert.alert( + 'Are you sure?', + 'You will not be able to recover this message!', + [ + { + text: 'Cancel', + style: 'cancel' + }, + { + text: 'Yes, delete it!', + style: 'destructive', + onPress: () => this.props.deleteRequest(this.props.actionMessage) + } + ], + { cancelable: false } + ); + } + + handleEdit() { + const { _id, msg, rid } = this.props.actionMessage; + this.props.editInit({ _id, msg, rid }); + } + + handleCopy = async() => { + await Clipboard.setString(this.props.actionMessage.msg); + showToast('Copied to clipboard!'); + } + + handleShare = async() => { + Share.share({ + message: this.props.actionMessage.msg.content.replace(/<(?:.|\n)*?>/gm, '') + }); + }; + + handleStar() { + this.props.toggleStarRequest(this.props.actionMessage); + } + + handlePermalink() { + this.setState({ copyPermalink: true }); + this.props.permalinkRequest(this.props.actionMessage); + } + + handlePin() { + this.props.togglePinRequest(this.props.actionMessage); + } + + handleReply() { + this.setState({ reply: true }); + this.props.permalinkRequest(this.props.actionMessage); + } + + handleQuote() { + this.setState({ quote: true }); + this.props.permalinkRequest(this.props.actionMessage); + } + + handleReaction() { + this.props.toggleReactionPicker(this.props.actionMessage); + } + + handleActionPress = (actionIndex) => { + switch (actionIndex) { + case this.REPLY_INDEX: + this.handleReply(); + break; + case this.EDIT_INDEX: + this.handleEdit(); + break; + case this.PERMALINK_INDEX: + this.handlePermalink(); + break; + case this.COPY_INDEX: + this.handleCopy(); + break; + case this.SHARE_INDEX: + this.handleShare(); + break; + case this.QUOTE_INDEX: + this.handleQuote(); + break; + case this.STAR_INDEX: + this.handleStar(); + break; + case this.PIN_INDEX: + this.handlePin(); + break; + case this.REACTION_INDEX: + this.handleReaction(); + break; + case this.DELETE_INDEX: + this.handleDelete(); + break; + default: + break; + } + this.props.actionsHide(); + } + + render() { + return ( + this.ActionSheet = o} + title='Messages actions' + options={this.options} + cancelButtonIndex={this.CANCEL_INDEX} + destructiveButtonIndex={this.DELETE_INDEX} + onPress={this.handleActionPress} + /> + ); + } +} diff --git a/app/containers/MessageBox/EmojiKeyboard.js b/app/containers/MessageBox/EmojiKeyboard.js new file mode 100644 index 000000000..9a6cb8b91 --- /dev/null +++ b/app/containers/MessageBox/EmojiKeyboard.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { View } from 'react-native'; +import { KeyboardRegistry } from 'react-native-keyboard-input'; +import { Provider } from 'react-redux'; +import store from '../../lib/createStore'; +import EmojiPicker from '../EmojiPicker'; +import styles from './styles'; + +export default class EmojiKeyboard extends React.PureComponent { + onEmojiSelected = (emoji) => { + KeyboardRegistry.onItemSelected('EmojiKeyboard', { emoji }); + } + render() { + return ( + + + this.onEmojiSelected(emoji)} /> + + + ); + } +} +KeyboardRegistry.registerKeyboard('EmojiKeyboard', () => EmojiKeyboard); diff --git a/app/containers/MessageBox/Recording.js b/app/containers/MessageBox/Recording.js new file mode 100644 index 000000000..10e32b898 --- /dev/null +++ b/app/containers/MessageBox/Recording.js @@ -0,0 +1,127 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { View, SafeAreaView, Platform, PermissionsAndroid, Text } from 'react-native'; +import { AudioRecorder, AudioUtils } from 'react-native-audio'; +import Icon from 'react-native-vector-icons/MaterialIcons'; +import styles from './styles'; + +export const _formatTime = function(seconds) { + let minutes = Math.floor(seconds / 60); + seconds %= 60; + if (minutes < 10) { minutes = `0${ minutes }`; } + if (seconds < 10) { seconds = `0${ seconds }`; } + return `${ minutes }:${ seconds }`; +}; + +export default class extends React.PureComponent { + static propTypes = { + onFinish: PropTypes.func.isRequired + } + + static async permission() { + if (Platform.OS !== 'android') { + return true; + } + + const rationale = { + title: 'Microphone Permission', + message: 'Rocket Chat needs access to your microphone so you can send audio message.' + }; + + const result = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.RECORD_AUDIO, rationale); + return result === true || result === PermissionsAndroid.RESULTS.GRANTED; + } + + constructor() { + super(); + + this.recordingCanceled = false; + this.state = { + currentTime: '00:00' + }; + } + + componentDidMount() { + const audioPath = `${ AudioUtils.CachesDirectoryPath }/${ Date.now() }.aac`; + + AudioRecorder.prepareRecordingAtPath(audioPath, { + SampleRate: 22050, + Channels: 1, + AudioQuality: 'Low', + AudioEncoding: 'aac' + }); + + AudioRecorder.onProgress = (data) => { + this.setState({ + currentTime: _formatTime(Math.floor(data.currentTime)) + }); + }; + // + AudioRecorder.onFinished = (data) => { + if (!this.recordingCanceled && Platform.OS === 'ios') { + this._finishRecording(data.status === 'OK', data.audioFileURL); + } + }; + AudioRecorder.startRecording(); + } + + _finishRecording(didSucceed, filePath) { + if (!didSucceed) { + return this.props.onFinish && this.props.onFinish(didSucceed); + } + + const path = filePath.startsWith('file://') ? filePath.split('file://')[1] : filePath; + const fileInfo = { + type: 'audio/aac', + store: 'Uploads', + path + }; + return this.props.onFinish && this.props.onFinish(fileInfo); + } + + finishAudioMessage = async() => { + try { + const filePath = await AudioRecorder.stopRecording(); + if (Platform.OS === 'android') { + this._finishRecording(true, filePath); + } + } catch (err) { + this._finishRecording(false); + console.error(err); + } + } + + cancelAudioMessage = async() => { + this.recordingCanceled = true; + await AudioRecorder.stopRecording(); + return this._finishRecording(false); + } + + render() { + return ( + + + + {this.state.currentTime} + + + ); + } +} diff --git a/app/containers/MessageBox/index.js b/app/containers/MessageBox/index.js new file mode 100644 index 000000000..d88dcaa95 --- /dev/null +++ b/app/containers/MessageBox/index.js @@ -0,0 +1,523 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { View, TextInput, FlatList, Text, TouchableOpacity } from 'react-native'; +import Icon from 'react-native-vector-icons/MaterialIcons'; +import ImagePicker from 'react-native-image-picker'; +import { connect } from 'react-redux'; +import { emojify } from 'react-emojione'; +import { KeyboardAccessoryView } from 'react-native-keyboard-input'; +import { isIphoneX } from 'react-native-iphone-x-helper'; + +import { userTyping, layoutAnimation } from '../../actions/room'; +import RocketChat from '../../lib/rocketchat'; +import { editRequest, editCancel, clearInput } from '../../actions/messages'; +import styles from './styles'; +import MyIcon from '../icons'; +import database from '../../lib/realm'; +import Avatar from '../Avatar'; +import CustomEmoji from '../EmojiPicker/CustomEmoji'; +import { emojis } from '../../emojis'; +import Recording from './Recording'; +import './EmojiKeyboard'; + + +const MENTIONS_TRACKING_TYPE_USERS = '@'; +const MENTIONS_TRACKING_TYPE_EMOJIS = ':'; + +const onlyUnique = function onlyUnique(value, index, self) { + return self.indexOf(({ _id }) => value._id === _id) === index; +}; + +@connect(state => ({ + room: state.room, + message: state.messages.message, + editing: state.messages.editing, + baseUrl: state.settings.Site_Url || state.server ? state.server.server : '' +}), dispatch => ({ + editCancel: () => dispatch(editCancel()), + editRequest: message => dispatch(editRequest(message)), + typing: status => dispatch(userTyping(status)), + clearInput: () => dispatch(clearInput()), + layoutAnimation: () => dispatch(layoutAnimation()) +})) +export default class MessageBox extends React.PureComponent { + static propTypes = { + onSubmit: PropTypes.func.isRequired, + rid: PropTypes.string.isRequired, + editCancel: PropTypes.func.isRequired, + editRequest: PropTypes.func.isRequired, + baseUrl: PropTypes.string.isRequired, + message: PropTypes.object, + editing: PropTypes.bool, + typing: PropTypes.func, + clearInput: PropTypes.func, + layoutAnimation: PropTypes.func + } + + constructor(props) { + super(props); + this.state = { + text: '', + mentions: [], + showMentionsContainer: false, + showEmojiKeyboard: false, + recording: false + }; + this.users = []; + this.rooms = []; + this.emojis = []; + this.customEmojis = []; + this._onEmojiSelected = this._onEmojiSelected.bind(this); + } + componentWillReceiveProps(nextProps) { + if (this.props.message !== nextProps.message && nextProps.message.msg) { + this.setState({ text: nextProps.message.msg }); + this.component.focus(); + } else if (!nextProps.message) { + this.setState({ text: '' }); + } + } + + onChangeText(text) { + this.setState({ text }); + + requestAnimationFrame(() => { + const { start, end } = this.component._lastNativeSelection; + + const cursor = Math.max(start, end); + + const lastNativeText = this.component._lastNativeText; + + const regexp = /(#|@|:)([a-z0-9._-]+)$/im; + + const result = lastNativeText.substr(0, cursor).match(regexp); + + if (!result) { + return this.stopTrackingMention(); + } + const [, lastChar, name] = result; + + this.identifyMentionKeyword(name, lastChar); + }); + } + + onKeyboardResigned() { + this.closeEmoji(); + } + + get leftButtons() { + const { editing } = this.props; + if (editing) { + return ( this.editCancel()} + />); + } + return !this.state.showEmojiKeyboard ? ( this.openEmoji()} + accessibilityLabel='Open emoji selector' + accessibilityTraits='button' + name='mood' + />) : ( this.closeEmoji()} + style={styles.actionButtons} + accessibilityLabel='Close emoji selector' + accessibilityTraits='button' + name='keyboard' + />); + } + get rightButtons() { + const icons = []; + + if (this.state.text) { + icons.push( this.submit(this.state.text)} + />); + return icons; + } + icons.push( this.recordAudioMessage()} + />); + icons.push( this.addFile()} + />); + return icons; + } + + addFile = () => { + const options = { + maxHeight: 1960, + maxWidth: 1960, + quality: 0.8, + customButtons: [{ + name: 'import', title: 'Import File From' + }] + }; + ImagePicker.showImagePicker(options, (response) => { + if (response.didCancel) { + console.log('User cancelled image picker'); + } else if (response.error) { + console.log('ImagePicker Error: ', response.error); + } else if (response.customButton) { + console.log('User tapped custom button: ', response.customButton); + } else { + const fileInfo = { + name: response.fileName, + size: response.fileSize, + type: response.type || 'image/jpeg', + // description: '', + store: 'Uploads' + }; + RocketChat.sendFileMessage(this.props.rid, fileInfo, response.data); + } + }); + } + editCancel() { + this.props.editCancel(); + this.setState({ text: '' }); + } + async openEmoji() { + await this.setState({ + showEmojiKeyboard: true + }); + } + + async recordAudioMessage() { + const recording = await Recording.permission(); + this.setState({ recording }); + } + + finishAudioMessage = async(fileInfo) => { + if (fileInfo) { + RocketChat.sendFileMessage(this.props.rid, fileInfo); + } + this.setState({ + recording: false + }); + } + + closeEmoji() { + this.setState({ showEmojiKeyboard: false }); + } + + submit(message) { + this.setState({ text: '' }); + this.closeEmoji(); + this.stopTrackingMention(); + this.props.typing(false); + if (message.trim() === '') { + return; + } + // if is editing a message + const { editing } = this.props; + if (editing) { + const { _id, rid } = this.props.message; + this.props.editRequest({ _id, msg: message, rid }); + } else { + // if is submiting a new message + this.props.onSubmit(message); + } + this.props.clearInput(); + } + + _getFixedMentions(keyword) { + if ('all'.indexOf(keyword) !== -1) { + this.users = [{ _id: -1, username: 'all', desc: 'all' }, ...this.users]; + } + if ('here'.indexOf(keyword) !== -1) { + this.users = [{ _id: -2, username: 'here', desc: 'active users' }, ...this.users]; + } + } + + async _getUsers(keyword) { + this.users = database.objects('users'); + if (keyword) { + this.users = this.users.filtered('username CONTAINS[c] $0', keyword); + } + this._getFixedMentions(keyword); + this.setState({ mentions: this.users.slice() }); + + const usernames = []; + + if (keyword && this.users.length > 7) { + return; + } + + this.users.forEach(user => usernames.push(user.username)); + + if (this.oldPromise) { + this.oldPromise(); + } + try { + const results = await Promise.race([ + RocketChat.spotlight(keyword, usernames, { users: true }), + new Promise((resolve, reject) => (this.oldPromise = reject)) + ]); + database.write(() => { + results.users.forEach((user) => { + database.create('users', user, true); + }); + }); + } catch (e) { + console.log('spotlight canceled'); + } finally { + delete this.oldPromise; + this.users = database.objects('users').filtered('username CONTAINS[c] $0', keyword).slice(); + this._getFixedMentions(keyword); + this.setState({ mentions: this.users }); + } + } + + async _getRooms(keyword = '') { + this.roomsCache = this.roomsCache || []; + this.rooms = database.objects('subscriptions') + .filtered('t != $0', 'd'); + if (keyword) { + this.rooms = this.rooms.filtered('name CONTAINS[c] $0', keyword); + } + + const rooms = []; + this.rooms.forEach(room => rooms.push(room)); + + this.roomsCache.forEach((room) => { + if (room.name && room.name.toUpperCase().indexOf(keyword.toUpperCase()) !== -1) { + rooms.push(room); + } + }); + + if (rooms.length > 3) { + this.setState({ mentions: rooms }); + return; + } + + if (this.oldPromise) { + this.oldPromise(); + } + + try { + const results = await Promise.race([ + RocketChat.spotlight(keyword, [...rooms, ...this.roomsCache].map(r => r.name), { rooms: true }), + new Promise((resolve, reject) => (this.oldPromise = reject)) + ]); + this.roomsCache = [...this.roomsCache, ...results.rooms].filter(onlyUnique); + this.setState({ mentions: [...rooms.slice(), ...results.rooms] }); + } catch (e) { + console.log('spotlight canceled'); + } finally { + delete this.oldPromise; + } + } + + _getEmojis(keyword) { + if (keyword) { + this.customEmojis = database.objects('customEmojis').filtered('name CONTAINS[c] $0', keyword).slice(0, 4); + this.emojis = emojis.filter(emoji => emoji.indexOf(keyword) !== -1).slice(0, 4); + const mergedEmojis = [...this.customEmojis, ...this.emojis]; + this.setState({ mentions: mergedEmojis }); + } + } + + stopTrackingMention() { + this.setState({ + showMentionsContainer: false, + mentions: [], + trackingType: '' + }); + this.users = []; + this.rooms = []; + this.customEmojis = []; + this.emojis = []; + } + + identifyMentionKeyword(keyword, type) { + if (!this.state.showMentionsContainer) { + this.props.layoutAnimation(); + } + this.setState({ + showMentionsContainer: true, + showEmojiKeyboard: false, + trackingType: type + }); + this.updateMentions(keyword, type); + } + + updateMentions = (keyword, type) => { + if (type === MENTIONS_TRACKING_TYPE_USERS) { + this._getUsers(keyword); + } else if (type === MENTIONS_TRACKING_TYPE_EMOJIS) { + this._getEmojis(keyword); + } else { + this._getRooms(keyword); + } + } + + _onPressMention(item) { + const msg = this.component._lastNativeText; + + const { start, end } = this.component._lastNativeSelection; + + const cursor = Math.max(start, end); + + const regexp = /([a-z0-9._-]+)$/im; + + const result = msg.substr(0, cursor).replace(regexp, ''); + const mentionName = this.state.trackingType === MENTIONS_TRACKING_TYPE_EMOJIS ? + `${ item.name || item }:` : (item.username || item.name); + const text = `${ result }${ mentionName } ${ msg.slice(cursor) }`; + this.component.setNativeProps({ text }); + this.setState({ text }); + this.component.focus(); + requestAnimationFrame(() => this.stopTrackingMention()); + } + _onEmojiSelected(keyboardId, params) { + const { text } = this.state; + const { emoji } = params; + let newText = ''; + + // if messagebox has an active cursor + if (this.component._lastNativeSelection) { + const { start, end } = this.component._lastNativeSelection; + const cursor = Math.max(start, end); + newText = `${ text.substr(0, cursor) }${ emoji }${ text.substr(cursor) }`; + } else { + // if messagebox doesn't have a cursor, just append selected emoji + newText = `${ text }${ emoji }`; + } + this.component.setNativeProps({ text: newText }); + this.setState({ text: newText }); + } + renderFixedMentionItem = item => ( + this._onPressMention(item)} + > + {item.username} + Notify {item.desc} in this room + + ) + renderMentionEmoji = (item) => { + if (item.name) { + return ( + + ); + } + return ( + + {emojify(`:${ item }:`, { output: 'unicode' })} + + ); + } + renderMentionItem = (item) => { + if (item.username === 'all' || item.username === 'here') { + return this.renderFixedMentionItem(item); + } + return ( + this._onPressMention(item)} + > + {this.state.trackingType === MENTIONS_TRACKING_TYPE_EMOJIS ? + [ + this.renderMentionEmoji(item), + :{ item.name || item }: + ] + : [ + , + { item.username || item.name } + ] + } + + ); + } + renderMentions = () => ( + this.renderMentionItem(item)} + keyExtractor={item => item._id || item} + keyboardShouldPersistTaps='always' + /> + ); + + renderContent() { + if (this.state.recording) { + return (); + } + return ( + [ + this.renderMentions(), + + {this.leftButtons} + this.component = component} + style={styles.textBoxInput} + returnKeyType='default' + keyboardType='twitter' + blurOnSubmit={false} + placeholder='New Message' + onChangeText={text => this.onChangeText(text)} + value={this.state.text} + underlineColorAndroid='transparent' + defaultValue='' + multiline + placeholderTextColor='#9EA2A8' + /> + {this.rightButtons} + + ] + ); + } + + render() { + return ( + [ + this.renderContent()} + kbInputRef={this.component} + kbComponent={this.state.showEmojiKeyboard ? 'EmojiKeyboard' : null} + onKeyboardResigned={() => this.onKeyboardResigned()} + onItemSelected={this._onEmojiSelected} + trackInteractive + // revealKeyboardInteractive + requiresSameParentToManageScrollView + />, + isIphoneX() ? : null + ] + ); + } +} diff --git a/app/containers/MessageBox/styles.js b/app/containers/MessageBox/styles.js new file mode 100644 index 000000000..5c0388bc1 --- /dev/null +++ b/app/containers/MessageBox/styles.js @@ -0,0 +1,85 @@ +import { StyleSheet, Platform } from 'react-native'; + +const MENTION_HEIGHT = 50; + +export default StyleSheet.create({ + textBox: { + backgroundColor: '#fff', + flex: 0, + alignItems: 'center', + borderTopWidth: 1, + borderTopColor: '#D8D8D8', + zIndex: 2 + }, + textArea: { + flexDirection: 'row', + alignItems: 'center', + flexGrow: 0, + backgroundColor: '#fff' + }, + textBoxInput: { + textAlignVertical: 'center', + maxHeight: 120, + flexGrow: 1, + width: 1, + paddingTop: 15, + paddingBottom: 15, + paddingLeft: 0, + paddingRight: 0 + }, + editing: { + backgroundColor: '#fff5df' + }, + actionButtons: { + color: '#2F343D', + fontSize: 20, + textAlign: 'center', + padding: 15, + paddingHorizontal: 21, + flex: 0 + }, + mentionList: { + maxHeight: MENTION_HEIGHT * 4, + borderTopColor: '#ECECEC', + borderTopWidth: 1, + paddingHorizontal: 5, + backgroundColor: '#fff' + }, + mentionItem: { + height: MENTION_HEIGHT, + backgroundColor: '#F7F8FA', + borderBottomWidth: 1, + borderBottomColor: '#ECECEC', + flexDirection: 'row', + alignItems: 'center' + }, + mentionItemCustomEmoji: { + margin: 8, + width: 30, + height: 30 + }, + mentionItemEmoji: { + width: 46, + height: 36, + fontSize: Platform.OS === 'ios' ? 30 : 25, + textAlign: 'center' + }, + fixedMentionAvatar: { + fontWeight: 'bold', + textAlign: 'center', + width: 46 + }, + emojiKeyboardContainer: { + flex: 1, + borderTopColor: '#ECECEC', + borderTopWidth: 1 + }, + iphoneXArea: { + height: 50, + backgroundColor: '#fff', + position: 'absolute', + bottom: 0, + left: 0, + right: 0 + } +}); diff --git a/app/containers/MessageErrorActions.js b/app/containers/MessageErrorActions.js new file mode 100644 index 000000000..4f3439fae --- /dev/null +++ b/app/containers/MessageErrorActions.js @@ -0,0 +1,76 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import ActionSheet from 'react-native-actionsheet'; + +import { errorActionsHide } from '../actions/messages'; +import RocketChat from '../lib/rocketchat'; +import database from '../lib/realm'; + +@connect( + state => ({ + showErrorActions: state.messages.showErrorActions, + actionMessage: state.messages.actionMessage + }), + dispatch => ({ + errorActionsHide: () => dispatch(errorActionsHide()) + }) +) +export default class MessageErrorActions extends React.Component { + static propTypes = { + errorActionsHide: PropTypes.func.isRequired, + showErrorActions: PropTypes.bool.isRequired, + actionMessage: PropTypes.object + }; + + constructor(props) { + super(props); + this.handleActionPress = this.handleActionPress.bind(this); + this.options = ['Cancel', 'Delete', 'Resend']; + this.CANCEL_INDEX = 0; + this.DELETE_INDEX = 1; + this.RESEND_INDEX = 2; + } + + componentWillReceiveProps(nextProps) { + if (nextProps.showErrorActions !== this.props.showErrorActions && nextProps.showErrorActions) { + this.ActionSheet.show(); + } + } + + handleResend = () => RocketChat.resendMessage(this.props.actionMessage._id); + + handleDelete = () => { + database.write(() => { + const msg = database.objects('messages').filtered('_id = $0', this.props.actionMessage._id); + database.delete(msg); + }); + } + + handleActionPress = (actionIndex) => { + switch (actionIndex) { + case this.RESEND_INDEX: + this.handleResend(); + break; + case this.DELETE_INDEX: + this.handleDelete(); + break; + default: + break; + } + this.props.errorActionsHide(); + } + + render() { + return ( + this.ActionSheet = o} + title='Messages actions' + options={this.options} + cancelButtonIndex={this.CANCEL_INDEX} + destructiveButtonIndex={this.DELETE_INDEX} + onPress={this.handleActionPress} + /> + ); + } +} diff --git a/app/containers/Routes.js b/app/containers/Routes.js new file mode 100644 index 000000000..067327970 --- /dev/null +++ b/app/containers/Routes.js @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import SplashScreen from 'react-native-splash-screen'; +import { appInit } from '../actions'; + +import AuthRoutes from './routes/AuthRoutes'; +import PublicRoutes from './routes/PublicRoutes'; +import * as NavigationService from './routes/NavigationService'; + +@connect( + state => ({ + login: state.login, + app: state.app, + background: state.app.background + }), + dispatch => bindActionCreators({ + appInit + }, dispatch) +) +export default class Routes extends React.Component { + static propTypes = { + login: PropTypes.object.isRequired, + app: PropTypes.object.isRequired, + appInit: PropTypes.func.isRequired + } + + componentDidMount() { + if (this.props.app.ready) { + return SplashScreen.hide(); + } + this.props.appInit(); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.app.ready && this.props.app.ready !== nextProps.app.ready) { + SplashScreen.hide(); + } + } + + componentDidUpdate() { + NavigationService.setNavigator(this.navigator); + } + + render() { + const { login } = this.props; + + if (this.props.app.starting) { + return null; + } + + if (!login.token || login.isRegistering) { + return ( this.navigator = nav} />); + } + return ( this.navigator = nav} />); + } +} diff --git a/app/containers/Sidebar.js b/app/containers/Sidebar.js new file mode 100644 index 000000000..9e9122751 --- /dev/null +++ b/app/containers/Sidebar.js @@ -0,0 +1,135 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { ScrollView, Text, View, StyleSheet, FlatList, TouchableHighlight } from 'react-native'; +import { connect } from 'react-redux'; + +import database from '../lib/realm'; +import { setServer, gotoAddServer } from '../actions/server'; +import { logout } from '../actions/login'; + +const styles = StyleSheet.create({ + scrollView: { + paddingTop: 20 + }, + imageContainer: { + width: '100%', + alignItems: 'center' + }, + image: { + width: 200, + height: 200, + borderRadius: 100 + }, + serverTitle: { + fontSize: 16, + color: 'grey', + padding: 10, + width: '100%' + }, + serverItem: { + backgroundColor: 'white', + padding: 10, + flex: 1 + }, + selectedServer: { + backgroundColor: '#eeeeee' + } +}); +const keyExtractor = item => item.id; +@connect(state => ({ + server: state.server.server +}), dispatch => ({ + selectServer: server => dispatch(setServer(server)), + logout: () => dispatch(logout()), + gotoAddServer: () => dispatch(gotoAddServer()) +})) +export default class Sidebar extends Component { + static propTypes = { + server: PropTypes.string.isRequired, + selectServer: PropTypes.func.isRequired, + navigation: PropTypes.object.isRequired, + logout: PropTypes.func.isRequired, + gotoAddServer: PropTypes.func.isRequired + } + + constructor(props) { + super(props); + this.state = { servers: [] }; + } + + componentDidMount() { + database.databases.serversDB.addListener('change', this.updateState); + this.setState(this.getState()); + } + + componentWillUnmount() { + database.databases.serversDB.removeListener('change', this.updateState); + } + + onItemPress = ({ route, focused }) => { + this.props.navigation.navigate({ key: 'DrawerClose', routeName: 'DrawerClose' }); + if (!focused) { + this.props.navigation.navigate(route.routeName, undefined); + } + } + + onPressItem = (item) => { + this.props.selectServer(item.id); + this.props.navigation.navigate({ key: 'DrawerClose', routeName: 'DrawerClose' }); + } + + getState = () => ({ + servers: database.databases.serversDB.objects('servers') + }) + + updateState = () => { + this.setState(this.getState()); + } + + renderItem = ({ item, separators }) => ( + + { this.onPressItem(item); }} + > + + + {item.id} + + + + ); + + render() { + return ( + + + + { this.props.logout(); }} + > + + + Logout + + + + { this.props.gotoAddServer(); }} + > + + + Add Server + + + + + + ); + } +} diff --git a/app/containers/TextInput.js b/app/containers/TextInput.js new file mode 100644 index 000000000..3abf75459 --- /dev/null +++ b/app/containers/TextInput.js @@ -0,0 +1,88 @@ +import React from 'react'; +import { View, StyleSheet, Text, TextInput } from 'react-native'; +import PropTypes from 'prop-types'; + +import Icon from 'react-native-vector-icons/FontAwesome'; + +import sharedStyles from '../views/Styles'; +import { COLOR_DANGER } from '../constants/colors'; + +const styles = StyleSheet.create({ + inputContainer: { + marginBottom: 20 + }, + label: { + marginBottom: 4, + fontSize: 16 + }, + input: { + paddingTop: 12, + paddingBottom: 12, + paddingHorizontal: 10, + borderWidth: 2, + borderRadius: 2, + backgroundColor: 'white', + borderColor: 'rgba(0,0,0,.15)', + color: 'black' + }, + labelError: { + color: COLOR_DANGER + }, + inputError: { + color: COLOR_DANGER, + borderColor: COLOR_DANGER + }, + wrap: { + flex: 1, + position: 'relative' + }, + icon: { + position: 'absolute', + right: 0, + padding: 10, + color: 'rgba(0,0,0,.45)' + } +}); + + +export default class RCTextInput extends React.PureComponent { + static propTypes = { + label: PropTypes.string, + error: PropTypes.object, + secureTextEntry: PropTypes.bool + } + static defaultProps = { + error: {} + } + state = { + showPassword: false + } + + get icon() { return ; } + + tooglePassword = () => this.setState({ showPassword: !this.state.showPassword }) + + render() { + const { + label, error, secureTextEntry, ...inputProps + } = this.props; + const { showPassword } = this.state; + return ( + + { label && {label} } + + + {secureTextEntry && this.icon} + + {error.error && {error.reason}} + + ); + } +} diff --git a/app/containers/Typing.js b/app/containers/Typing.js new file mode 100644 index 000000000..0e2db6e1f --- /dev/null +++ b/app/containers/Typing.js @@ -0,0 +1,42 @@ +import React from 'react'; + +import PropTypes from 'prop-types'; +import { StyleSheet, Text, Keyboard } from 'react-native'; +import { connect } from 'react-redux'; + +const styles = StyleSheet.create({ + typing: { + + transform: [{ scaleY: -1 }], + fontWeight: 'bold', + paddingHorizontal: 15, + height: 25 + } +}); + +@connect(state => ({ + username: state.login.user && state.login.user.username, + usersTyping: state.room.usersTyping +})) + +export default class Typing extends React.Component { + shouldComponentUpdate(nextProps) { + return this.props.usersTyping.join() !== nextProps.usersTyping.join(); + } + onPress = () => { + Keyboard.dismiss(); + } + get usersTyping() { + const users = this.props.usersTyping.filter(_username => this.props.username !== _username); + return users.length ? `${ users.join(' ,') } ${ users.length > 1 ? 'are' : 'is' } typing` : ''; + } + render() { + return ( this.onPress()}>{this.usersTyping}); + } +} + + +Typing.propTypes = { + username: PropTypes.string, + usersTyping: PropTypes.array +}; diff --git a/app/containers/icons.js b/app/containers/icons.js new file mode 100644 index 000000000..236466031 --- /dev/null +++ b/app/containers/icons.js @@ -0,0 +1,4 @@ +import { createIconSetFromIcoMoon } from 'react-native-vector-icons'; +import iconConfig from '../icons.json'; + +export default createIconSetFromIcoMoon(iconConfig); diff --git a/app/containers/message/Audio.js b/app/containers/message/Audio.js new file mode 100644 index 000000000..d9db162a2 --- /dev/null +++ b/app/containers/message/Audio.js @@ -0,0 +1,162 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { View, StyleSheet, TouchableOpacity, Text, Easing } from 'react-native'; +import Video from 'react-native-video'; +import Icon from 'react-native-vector-icons/MaterialIcons'; +import Slider from 'react-native-slider'; +import Markdown from './Markdown'; + + +const styles = StyleSheet.create({ + audioContainer: { + flex: 1, + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + height: 50, + margin: 5, + backgroundColor: '#eee', + borderRadius: 6 + }, + playPauseButton: { + width: 50, + alignItems: 'center', + backgroundColor: 'transparent', + borderRightColor: '#ccc', + borderRightWidth: 1 + }, + playPauseIcon: { + color: '#ccc', + backgroundColor: 'transparent' + }, + progressContainer: { + flex: 1, + justifyContent: 'center', + height: '100%', + marginHorizontal: 10 + }, + label: { + color: '#888', + fontSize: 10 + }, + currentTime: { + position: 'absolute', + left: 0, + bottom: 2 + }, + duration: { + position: 'absolute', + right: 0, + bottom: 2 + } +}); + +const formatTime = (t = 0, duration = 0) => { + const time = Math.min( + Math.max(t, 0), + duration + ); + const formattedMinutes = Math.floor(time / 60).toFixed(0).padStart(2, 0); + const formattedSeconds = Math.floor(time % 60).toFixed(0).padStart(2, 0); + return `${ formattedMinutes }:${ formattedSeconds }`; +}; + +export default class Audio extends React.PureComponent { + static propTypes = { + file: PropTypes.object.isRequired, + baseUrl: PropTypes.string.isRequired, + user: PropTypes.object.isRequired + } + + constructor(props) { + super(props); + this.onLoad = this.onLoad.bind(this); + this.onProgress = this.onProgress.bind(this); + this.onEnd = this.onEnd.bind(this); + const { baseUrl, file, user } = props; + this.state = { + currentTime: 0, + duration: 0, + paused: true, + uri: `${ baseUrl }${ file.audio_url }?rc_uid=${ user.id }&rc_token=${ user.token }` + }; + } + + onLoad(data) { + this.setState({ duration: data.duration > 0 ? data.duration : 0 }); + } + + onProgress(data) { + if (data.currentTime < this.state.duration) { + this.setState({ currentTime: data.currentTime }); + } + } + + onEnd() { + this.setState({ paused: true, currentTime: 0 }); + requestAnimationFrame(() => { + this.player.seek(0); + }); + } + + getCurrentTime() { + return formatTime(this.state.currentTime, this.state.duration); + } + + getDuration() { + return formatTime(this.state.duration); + } + + togglePlayPause() { + this.setState({ paused: !this.state.paused }); + } + + render() { + const { uri, paused } = this.state; + const { description } = this.props.file; + return ( + + + + + + ); + } +} diff --git a/app/containers/message/Emoji.js b/app/containers/message/Emoji.js new file mode 100644 index 000000000..edbcd470c --- /dev/null +++ b/app/containers/message/Emoji.js @@ -0,0 +1,26 @@ +import React from 'react'; +import { Text, ViewPropTypes } from 'react-native'; +import PropTypes from 'prop-types'; +import { emojify } from 'react-emojione'; +import CustomEmoji from '../EmojiPicker/CustomEmoji'; + +export default class Emoji extends React.PureComponent { + static propTypes = { + content: PropTypes.string, + standardEmojiStyle: Text.propTypes.style, + customEmojiStyle: ViewPropTypes.style, + customEmojis: PropTypes.object.isRequired + }; + render() { + const { + content, standardEmojiStyle, customEmojiStyle, customEmojis + } = this.props; + const parsedContent = content.replace(/^:|:$/g, ''); + const emojiExtension = customEmojis[parsedContent]; + if (emojiExtension) { + const emoji = { extension: emojiExtension, content: parsedContent }; + return ; + } + return { emojify(`${ content }`, { output: 'unicode' }) }; + } +} diff --git a/app/containers/message/Image.js b/app/containers/message/Image.js new file mode 100644 index 000000000..942676dcc --- /dev/null +++ b/app/containers/message/Image.js @@ -0,0 +1,86 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { CachedImage } from 'react-native-img-cache'; +import { Text, TouchableOpacity, View, StyleSheet } from 'react-native'; +import PhotoModal from './PhotoModal'; + +const styles = StyleSheet.create({ + button: { + flex: 1, + flexDirection: 'column', + height: 320, + borderColor: '#ccc', + borderWidth: 1, + borderRadius: 6 + }, + image: { + flex: 1, + height: undefined, + width: undefined, + resizeMode: 'contain' + }, + labelContainer: { + height: 62, + alignItems: 'center', + justifyContent: 'center' + }, + imageName: { + fontSize: 12, + alignSelf: 'center', + fontStyle: 'italic' + }, + message: { + alignSelf: 'center', + fontWeight: 'bold' + } +}); + +export default class Image extends React.PureComponent { + static propTypes = { + file: PropTypes.object.isRequired, + baseUrl: PropTypes.string.isRequired, + user: PropTypes.object.isRequired + } + + state = { modalVisible: false }; + + getDescription() { + if (this.props.file.description) { + return {this.props.file.description}; + } + } + + _onPressButton() { + this.setState({ + modalVisible: true + }); + } + + render() { + const { baseUrl, file, user } = this.props; + const img = `${ baseUrl }${ file.image_url }?rc_uid=${ user.id }&rc_token=${ user.token }`; + return ( + + this._onPressButton()} + style={styles.button} + > + + + {this.props.file.title} + {this.getDescription()} + + + this.setState({ modalVisible: false })} + /> + + ); + } +} diff --git a/app/containers/message/Markdown.js b/app/containers/message/Markdown.js new file mode 100644 index 000000000..1b50f901d --- /dev/null +++ b/app/containers/message/Markdown.js @@ -0,0 +1,153 @@ +import React from 'react'; +import { Text, StyleSheet, ViewPropTypes } from 'react-native'; +import PropTypes from 'prop-types'; +import EasyMarkdown from 'react-native-easy-markdown'; // eslint-disable-line +import SimpleMarkdown from 'simple-markdown'; +import { emojify } from 'react-emojione'; +import styles from './styles'; +import CustomEmoji from '../EmojiPicker/CustomEmoji'; + +const BlockCode = ({ node, state }) => ( + + {node.content} + +); +const mentionStyle = { color: '#13679a' }; + +const Markdown = ({ + msg, customEmojis, style, markdownStyle, customRules, renderInline +}) => { + if (!msg) { + return null; + } + msg = emojify(msg, { output: 'unicode' }); + + const defaultRules = { + username: { + order: -1, + match: SimpleMarkdown.inlineRegex(/^@[0-9a-zA-Z-_.]+/), + parse: capture => ({ content: capture[0] }), + react: (node, output, state) => ({ + type: 'custom', + key: state.key, + props: { + children: ( + alert('Username')} + > + {node.content} + + ) + } + }) + }, + heading: { + order: -2, + match: SimpleMarkdown.inlineRegex(/^#[0-9a-zA-Z-_.]+/), + parse: capture => ({ content: capture[0] }), + react: (node, output, state) => ({ + type: 'custom', + key: state.key, + props: { + children: ( + alert('Room')} + > + {node.content} + + ) + } + }) + }, + fence: { + order: -3, + match: SimpleMarkdown.blockRegex(/^ *(`{3,}|~{3,}) *(\S+)? *\n([\s\S]+?)\s*\1 *(?:\n *)+\n/), + parse: capture => ({ + lang: capture[2] || undefined, + content: capture[3] + }), + react: (node, output, state) => ({ + type: 'custom', + key: state.key, + props: { + children: ( + + ) + } + }) + }, + blockCode: { + order: -4, + match: SimpleMarkdown.blockRegex(/^(```)\s*([\s\S]*?[^`])\s*\1(?!```)/), + parse: capture => ({ content: capture[2] }), + react: (node, output, state) => ({ + type: 'custom', + key: state.key, + props: { + children: ( + + ) + } + }) + }, + customEmoji: { + order: -5, + match: SimpleMarkdown.inlineRegex(/^:([0-9a-zA-Z-_.]+):/), + parse: capture => ({ content: capture }), + react: (node, output, state) => { + const element = { + type: 'custom', + key: state.key, + props: { + children: {node.content[0]} + } + }; + const content = node.content[1]; + const emojiExtension = customEmojis[content]; + if (emojiExtension) { + const emoji = { extension: emojiExtension, content }; + element.props.children = ( + + ); + } + return element; + } + } + }; + + const codeStyle = StyleSheet.flatten(styles.codeStyle); + style = StyleSheet.flatten(style); + return ( + {msg} + + ); +}; + +Markdown.propTypes = { + msg: PropTypes.string.isRequired, + customEmojis: PropTypes.object, + // eslint-disable-next-line react/no-typos + style: ViewPropTypes.style, + markdownStyle: PropTypes.object, + customRules: PropTypes.object, + renderInline: PropTypes.bool +}; + +BlockCode.propTypes = { + node: PropTypes.object, + state: PropTypes.object +}; + +export default Markdown; diff --git a/app/containers/message/PhotoModal.js b/app/containers/message/PhotoModal.js new file mode 100644 index 000000000..7d378794e --- /dev/null +++ b/app/containers/message/PhotoModal.js @@ -0,0 +1,65 @@ +import React from 'react'; +import { ScrollView, View, Text, TouchableWithoutFeedback } from 'react-native'; +import { CachedImage } from 'react-native-img-cache'; +import PropTypes from 'prop-types'; +import Modal from 'react-native-modal'; + +const styles = { + imageWrapper: { + alignItems: 'stretch', + flex: 1 + }, + image: { + flex: 1 + }, + titleContainer: { + width: '100%', + alignItems: 'center', + marginVertical: 10 + }, + title: { + color: '#ffffff', + textAlign: 'center', + fontSize: 16, + fontWeight: '600' + } +}; + +export default class PhotoModal extends React.PureComponent { + static propTypes = { + title: PropTypes.string.isRequired, + image: PropTypes.string.isRequired, + isVisible: PropTypes.bool, + onClose: PropTypes.func.isRequired + } + render() { + const { + image, isVisible, onClose, title + } = this.props; + return ( + + + + {title} + + + + + + + + + + + ); + } +} diff --git a/app/containers/message/QuoteMark.js b/app/containers/message/QuoteMark.js new file mode 100644 index 000000000..2c010c6e2 --- /dev/null +++ b/app/containers/message/QuoteMark.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import PropTypes from 'prop-types'; + +const styles = StyleSheet.create({ + quoteSign: { + borderWidth: 2, + borderRadius: 4, + height: '100%', + marginRight: 5 + } +}); + +const QuoteMark = ({ color }) => ; + +QuoteMark.propTypes = { + color: PropTypes.string +}; + +export default QuoteMark; diff --git a/app/containers/message/ReactionsModal.js b/app/containers/message/ReactionsModal.js new file mode 100644 index 000000000..08491518f --- /dev/null +++ b/app/containers/message/ReactionsModal.js @@ -0,0 +1,124 @@ +import React from 'react'; +import { View, Text, TouchableWithoutFeedback, FlatList, StyleSheet } from 'react-native'; +import PropTypes from 'prop-types'; +import Modal from 'react-native-modal'; +import Icon from 'react-native-vector-icons/MaterialIcons'; +import Emoji from './Emoji'; + +const styles = StyleSheet.create({ + titleContainer: { + width: '100%', + alignItems: 'center', + paddingVertical: 10 + }, + title: { + color: '#ffffff', + textAlign: 'center', + fontSize: 16, + fontWeight: '600' + }, + reactCount: { + color: '#dddddd', + fontSize: 10 + }, + peopleReacted: { + color: '#ffffff', + fontWeight: '500' + }, + peopleItemContainer: { + flex: 1, + flexDirection: 'column', + justifyContent: 'center' + }, + emojiContainer: { + width: 50, + height: 50, + alignItems: 'center', + justifyContent: 'center' + }, + itemContainer: { + height: 50, + flexDirection: 'row' + }, + listContainer: { + flex: 1 + }, + closeButton: { + position: 'absolute', + left: 0, + top: 10, + color: '#ffffff' + } +}); +const standardEmojiStyle = { fontSize: 20 }; +const customEmojiStyle = { width: 20, height: 20 }; +export default class ReactionsModal extends React.PureComponent { + static propTypes = { + isVisible: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + reactions: PropTypes.object.isRequired, + user: PropTypes.object.isRequired, + customEmojis: PropTypes.object.isRequired + } + renderItem = (item) => { + const count = item.usernames.length; + let usernames = item.usernames.slice(0, 3) + .map(username => (username.value === this.props.user.username ? 'you' : username.value)).join(', '); + if (count > 3) { + usernames = `${ usernames } and more ${ count - 3 }`; + } else { + usernames = usernames.replace(/,(?=[^,]*$)/, ' and'); + } + return ( + + + + + + + {count === 1 ? '1 person' : `${ count } people`} reacted + + { usernames } + + + ); + } + + render() { + const { + isVisible, onClose, reactions + } = this.props; + return ( + + + + + Reactions + + + + this.renderItem(item)} + keyExtractor={item => item.emoji} + /> + + + ); + } +} diff --git a/app/containers/message/Reply.js b/app/containers/message/Reply.js new file mode 100644 index 000000000..f6df68380 --- /dev/null +++ b/app/containers/message/Reply.js @@ -0,0 +1,157 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import PropTypes from 'prop-types'; +import moment from 'moment'; + +import Markdown from './Markdown'; +import QuoteMark from './QuoteMark'; +import Avatar from '../Avatar'; +import openLink from '../../utils/openLink'; + + +const styles = StyleSheet.create({ + button: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + marginTop: 2, + alignSelf: 'flex-end' + }, + quoteSign: { + borderWidth: 2, + borderRadius: 4, + borderColor: '#a0a0a0', + height: '100%', + marginRight: 5 + }, + attachmentContainer: { + flex: 1, + flexDirection: 'column' + }, + authorContainer: { + flexDirection: 'row', + alignItems: 'center' + }, + author: { + fontWeight: 'bold', + marginHorizontal: 5, + flex: 1 + }, + time: { + fontSize: 10, + fontWeight: 'normal', + color: '#888', + marginLeft: 5 + }, + fieldsContainer: { + flex: 1, + flexWrap: 'wrap', + flexDirection: 'row' + }, + fieldContainer: { + flexDirection: 'column', + padding: 10 + }, + fieldTitle: { + fontWeight: 'bold' + } +}); + +const onPress = (attachment) => { + const url = attachment.title_link || attachment.author_link; + if (!url) { + return; + } + openLink(attachment.title_link || attachment.author_link); +}; + +// Support +const formatText = text => + text.replace( + new RegExp('(?:<|<)((?:https|http):\\/\\/[^\\|]+)\\|(.+?)(?=>|>)(?:>|>)', 'gm'), + (match, url, title) => `[${ title }](${ url })` + ); + +const Reply = ({ attachment, timeFormat }) => { + if (!attachment) { + return null; + } + + const renderAvatar = () => { + if (!attachment.author_icon && !attachment.author_name) { + return null; + } + return ( + + ); + }; + + const renderAuthor = () => ( + attachment.author_name ? {attachment.author_name} : null + ); + + const renderTime = () => { + const time = attachment.ts ? moment(attachment.ts).format(timeFormat) : null; + return time ? { time } : null; + }; + + const renderTitle = () => { + if (!(attachment.author_icon || attachment.author_name || attachment.ts)) { + return null; + } + return ( + + {renderAvatar()} + {renderAuthor()} + {renderTime()} + + ); + }; + + const renderText = () => ( + attachment.text ? : null + ); + + const renderFields = () => { + if (!attachment.fields) { + return null; + } + + return ( + + {attachment.fields.map(field => ( + + {field.title} + {field.value} + + ))} + + ); + }; + + return ( + onPress(attachment)} + style={styles.button} + > + + + {renderTitle()} + {renderText()} + {renderFields()} + {attachment.attachments && attachment.attachments.map(attach => )} + + + ); +}; + +Reply.propTypes = { + attachment: PropTypes.object.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default Reply; diff --git a/app/containers/message/Url.js b/app/containers/message/Url.js new file mode 100644 index 000000000..5ec48a999 --- /dev/null +++ b/app/containers/message/Url.js @@ -0,0 +1,74 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet, Image } from 'react-native'; +import PropTypes from 'prop-types'; + +import QuoteMark from './QuoteMark'; +import openLink from '../../utils/openLink'; + +const styles = StyleSheet.create({ + button: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + marginVertical: 2 + }, + quoteSign: { + borderWidth: 2, + borderRadius: 4, + borderColor: '#a0a0a0', + height: '100%', + marginRight: 5 + }, + image: { + height: 80, + width: 80, + resizeMode: 'cover', + borderRadius: 6 + }, + textContainer: { + flex: 1, + height: '100%', + flexDirection: 'column', + padding: 4, + justifyContent: 'flex-start', + alignItems: 'flex-start' + }, + title: { + fontWeight: 'bold', + fontSize: 12 + }, + description: { + fontSize: 12 + } +}); + +const onPress = (url) => { + openLink(url); +}; +const Url = ({ url }) => { + if (!url) { + return null; + } + return ( + onPress(url.url)} style={styles.button}> + + {url.image ? + + : null + } + + {url.title} + {url.description} + + + ); +}; + +Url.propTypes = { + url: PropTypes.object.isRequired +}; + +export default Url; diff --git a/app/containers/message/User.js b/app/containers/message/User.js new file mode 100644 index 000000000..4921b48e8 --- /dev/null +++ b/app/containers/message/User.js @@ -0,0 +1,83 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { View, Text, StyleSheet } from 'react-native'; +import moment from 'moment'; +import Icon from 'react-native-vector-icons/FontAwesome'; +import Avatar from '../Avatar'; + +const styles = StyleSheet.create({ + username: { + fontWeight: 'bold' + }, + usernameView: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 2 + }, + alias: { + fontSize: 10, + color: '#888', + paddingLeft: 5 + }, + time: { + fontSize: 10, + color: '#888', + paddingLeft: 5 + }, + edited: { + marginLeft: 5, + flexDirection: 'row', + alignItems: 'center' + } +}); + +export default class User extends React.PureComponent { + static propTypes = { + item: PropTypes.object.isRequired, + Message_TimeFormat: PropTypes.string.isRequired, + onPress: PropTypes.func, + baseUrl: PropTypes.string + } + + renderEdited(item) { + if (!item.editedBy) { + return null; + } + return ( + + + + + ); + } + + render() { + const { item } = this.props; + + const extraStyle = {}; + if (item.temp) { + extraStyle.opacity = 0.3; + } + + const username = item.alias || item.u.username; + const aliasUsername = item.alias ? (@{item.u.username}) : null; + const time = moment(item.ts).format(this.props.Message_TimeFormat); + + return ( + + + {username} + + {aliasUsername} + {time} + {this.renderEdited(item)} + + ); + } +} diff --git a/app/containers/message/Video.js b/app/containers/message/Video.js new file mode 100644 index 000000000..0b8a019ab --- /dev/null +++ b/app/containers/message/Video.js @@ -0,0 +1,84 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { View, StyleSheet, TouchableOpacity, Image, Platform } from 'react-native'; +import Modal from 'react-native-modal'; +import VideoPlayer from 'react-native-video-controls'; +import Markdown from './Markdown'; +import openLink from '../../utils/openLink'; + +const SUPPORTED_TYPES = ['video/quicktime', 'video/mp4', ...(Platform.OS === 'ios' ? [] : ['video/webm', 'video/3gp', 'video/mkv'])]; +const isTypeSupported = type => SUPPORTED_TYPES.indexOf(type) !== -1; + +const styles = StyleSheet.create({ + container: { + flex: 1, + height: 100, + margin: 5 + }, + modal: { + margin: 0, + backgroundColor: '#000' + }, + image: { + flex: 1, + width: null, + height: null, + resizeMode: 'contain' + } +}); + +export default class Video extends React.PureComponent { + static propTypes = { + file: PropTypes.object.isRequired, + baseUrl: PropTypes.string.isRequired, + user: PropTypes.object.isRequired + } + + state = { isVisible: false }; + + toggleModal() { + this.setState({ + isVisible: !this.state.isVisible + }); + } + + open() { + if (isTypeSupported(this.props.file.video_type)) { + return this.toggleModal(); + } + openLink(this.state.uri); + } + + render() { + const { isVisible } = this.state; + const { video_url, description } = this.props.file; + const { baseUrl, user } = this.props; + const uri = `${ baseUrl }${ video_url }?rc_uid=${ user.id }&rc_token=${ user.token }`; + return ( + + this.open()} + > + + + + this.toggleModal()} + > + this.toggleModal()} + disableVolume + /> + + + ); + } +} diff --git a/app/containers/message/index.js b/app/containers/message/index.js new file mode 100644 index 000000000..babf15cec --- /dev/null +++ b/app/containers/message/index.js @@ -0,0 +1,316 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { View, TouchableHighlight, Text, TouchableOpacity, Vibration, ViewPropTypes } from 'react-native'; +import { connect } from 'react-redux'; +import Icon from 'react-native-vector-icons/MaterialIcons'; +import moment from 'moment'; +import equal from 'deep-equal'; +import { KeyboardUtils } from 'react-native-keyboard-input'; + +import { actionsShow, errorActionsShow, toggleReactionPicker } from '../../actions/messages'; +import Image from './Image'; +import User from './User'; +import Avatar from '../Avatar'; +import Audio from './Audio'; +import Video from './Video'; +import Markdown from './Markdown'; +import Url from './Url'; +import Reply from './Reply'; +import ReactionsModal from './ReactionsModal'; +import Emoji from './Emoji'; +import messageStatus from '../../constants/messagesStatus'; +import styles from './styles'; + +@connect(state => ({ + message: state.messages.message, + editing: state.messages.editing, + customEmojis: state.customEmojis +}), dispatch => ({ + actionsShow: actionMessage => dispatch(actionsShow(actionMessage)), + errorActionsShow: actionMessage => dispatch(errorActionsShow(actionMessage)), + toggleReactionPicker: message => dispatch(toggleReactionPicker(message)) +})) +export default class Message extends React.Component { + static propTypes = { + status: PropTypes.any, + item: PropTypes.object.isRequired, + reactions: PropTypes.any.isRequired, + baseUrl: PropTypes.string.isRequired, + Message_TimeFormat: PropTypes.string.isRequired, + message: PropTypes.object.isRequired, + user: PropTypes.object.isRequired, + editing: PropTypes.bool, + errorActionsShow: PropTypes.func, + customEmojis: PropTypes.object, + toggleReactionPicker: PropTypes.func, + onReactionPress: PropTypes.func, + style: ViewPropTypes.style, + onLongPress: PropTypes.func, + _updatedAt: PropTypes.instanceOf(Date), + archived: PropTypes.bool + } + + static defaultProps = { + onLongPress: () => {}, + _updatedAt: new Date(), + archived: false + } + + constructor(props) { + super(props); + this.state = { reactionsModal: false }; + this.onClose = this.onClose.bind(this); + } + + shouldComponentUpdate(nextProps, nextState) { + if (!equal(this.props.reactions, nextProps.reactions)) { + return true; + } + if (this.state.reactionsModal !== nextState.reactionsModal) { + return true; + } + return this.props._updatedAt.toGMTString() !== nextProps._updatedAt.toGMTString() || this.props.status !== nextProps.status; + } + + onPress = () => { + KeyboardUtils.dismiss(); + } + + onLongPress() { + this.props.onLongPress(this.parseMessage()); + } + + onErrorPress() { + this.props.errorActionsShow(this.parseMessage()); + } + + onReactionPress(emoji) { + this.props.onReactionPress(emoji, this.props.item._id); + } + onClose() { + this.setState({ reactionsModal: false }); + } + onReactionLongPress() { + this.setState({ reactionsModal: true }); + Vibration.vibrate(50); + } + + getInfoMessage() { + let message = ''; + const { + t, role, msg, u + } = this.props.item; + + if (t === 'rm') { + message = 'Message removed'; + } else if (t === 'uj') { + message = 'Has joined the channel.'; + } else if (t === 'r') { + message = `Room name changed to: ${ msg } by ${ u.username }`; + } else if (t === 'message_pinned') { + message = 'Message pinned'; + } else if (t === 'ul') { + message = 'Has left the channel.'; + } else if (t === 'ru') { + message = `User ${ msg } removed by ${ u.username }`; + } else if (t === 'au') { + message = `User ${ msg } added by ${ u.username }`; + } else if (t === 'user-muted') { + message = `User ${ msg } muted by ${ u.username }`; + } else if (t === 'user-unmuted') { + message = `User ${ msg } unmuted by ${ u.username }`; + } else if (t === 'subscription-role-added') { + message = `${ msg } was set ${ role } by ${ u.username }`; + } else if (t === 'subscription-role-removed') { + message = `${ msg } is no longer ${ role } by ${ u.username }`; + } else if (t === 'room_changed_description') { + message = `Room description changed to: ${ msg } by ${ u.username }`; + } else if (t === 'room_changed_announcement') { + message = `Room announcement changed to: ${ msg } by ${ u.username }`; + } else if (t === 'room_changed_topic') { + message = `Room topic changed to: ${ msg } by ${ u.username }`; + } else if (t === 'room_changed_privacy') { + message = `Room type changed to: ${ msg } by ${ u.username }`; + } + + return message; + } + + parseMessage = () => JSON.parse(JSON.stringify(this.props.item)); + + isInfoMessage() { + return [ + 'r', + 'au', + 'ru', + 'ul', + 'uj', + 'rm', + 'user-muted', + 'user-unmuted', + 'message_pinned', + 'subscription-role-added', + 'subscription-role-removed', + 'room_changed_description', + 'room_changed_announcement', + 'room_changed_topic', + 'room_changed_privacy' + ].includes(this.props.item.t); + } + + isDeleted() { + return this.props.item.t === 'rm'; + } + + isTemp() { + return this.props.item.status === messageStatus.TEMP || this.props.item.status === messageStatus.ERROR; + } + + hasError() { + return this.props.item.status === messageStatus.ERROR; + } + + attachments() { + if (this.props.item.attachments.length === 0) { + return null; + } + + const file = this.props.item.attachments[0]; + const { baseUrl, user } = this.props; + if (file.image_type) { + return ; + } else if (file.audio_type) { + return