From 11e8506c27544e3af29a233935f4c1e709ef17a9 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Sun, 12 Mar 2017 15:22:22 -0300 Subject: [PATCH] moved redux from react native app --- .babelrc | 8 + .eslintrc.json | 213 +++++++ .gitattributes | 1 + .gitignore | 22 + .npmignore | 11 + CONTRIBUTING.md | 36 ++ ISSUE_TEMPLATE.md | 21 + LICENSE.txt | 203 +++++++ Makefile | 41 ++ NOTICE.txt | 184 ++++++ PULL_REQUEST_TEMPLATE.md | 17 + README.md | 15 + package.json | 39 ++ src/actions/channels.js | 753 ++++++++++++++++++++++++ src/actions/errors.js | 40 ++ src/actions/files.js | 38 ++ src/actions/general.js | 122 ++++ src/actions/helpers.js | 106 ++++ src/actions/posts.js | 275 +++++++++ src/actions/preferences.js | 97 ++++ src/actions/teams.js | 253 ++++++++ src/actions/users.js | 488 ++++++++++++++++ src/actions/websocket.js | 489 ++++++++++++++++ src/client/client.js | 803 ++++++++++++++++++++++++++ src/client/fetch_etag.js | 38 ++ src/client/index.js | 6 + src/client/websocket_client.js | 229 ++++++++ src/constants/channels.js | 83 +++ src/constants/constants.js | 90 +++ src/constants/errors.js | 12 + src/constants/files.js | 14 + src/constants/general.js | 38 ++ src/constants/index.js | 39 ++ src/constants/posts.js | 48 ++ src/constants/preferences.js | 21 + src/constants/request_status.js | 9 + src/constants/teams.js | 56 ++ src/constants/users.js | 79 +++ src/constants/websocket.js | 26 + src/reducers/entities/channels.js | 179 ++++++ src/reducers/entities/files.js | 47 ++ src/reducers/entities/general.js | 81 +++ src/reducers/entities/index.js | 24 + src/reducers/entities/posts.js | 203 +++++++ src/reducers/entities/preferences.js | 43 ++ src/reducers/entities/teams.js | 166 ++++++ src/reducers/entities/typing.js | 41 ++ src/reducers/entities/users.js | 230 ++++++++ src/reducers/errors/index.js | 27 + src/reducers/index.js | 12 + src/reducers/requests/channels.js | 175 ++++++ src/reducers/requests/files.js | 21 + src/reducers/requests/general.js | 58 ++ src/reducers/requests/helpers.js | 42 ++ src/reducers/requests/index.js | 22 + src/reducers/requests/posts.js | 98 ++++ src/reducers/requests/preferences.js | 43 ++ src/reducers/requests/teams.js | 109 ++++ src/reducers/requests/users.js | 191 ++++++ src/selectors/entities/channels.js | 172 ++++++ src/selectors/entities/files.js | 21 + src/selectors/entities/general.js | 6 + src/selectors/entities/posts.js | 44 ++ src/selectors/entities/preferences.js | 6 + src/selectors/entities/teams.js | 54 ++ src/selectors/entities/typing.js | 31 + src/selectors/entities/users.js | 126 ++++ src/selectors/errors.js | 6 + src/store/configureStore.dev.js | 57 ++ src/store/configureStore.prod.js | 16 + src/store/index.js | 12 + src/utils/channel_utils.js | 187 ++++++ src/utils/deep_freeze.js | 62 ++ src/utils/event_emitter.js | 59 ++ src/utils/file_utils.js | 42 ++ src/utils/key_mirror.js | 44 ++ src/utils/post_utils.js | 51 ++ src/utils/preference_utils.js | 18 + src/utils/user_utils.js | 50 ++ test/.eslintrc.json | 13 + test/actions/channels.test.js | 449 ++++++++++++++ test/actions/files.test.js | 69 +++ test/actions/general.test.js | 86 +++ test/actions/posts.test.js | 405 +++++++++++++ test/actions/preferences.test.js | 189 ++++++ test/actions/teams.test.js | 241 ++++++++ test/actions/users.test.js | 311 ++++++++++ test/actions/websocket.test.js | 282 +++++++++ test/assets/images/test.png | Bin 0 -> 5054 bytes test/mocha.opts | 5 + test/sanity.test.js | 36 ++ test/selectors/posts.test.js | 74 +++ test/setup.js | 14 + test/test_helper.js | 133 +++++ test/utils/post_utils.test.js | 77 +++ 95 files changed, 10323 insertions(+) create mode 100644 .babelrc create mode 100644 .eslintrc.json create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 CONTRIBUTING.md create mode 100644 ISSUE_TEMPLATE.md create mode 100644 LICENSE.txt create mode 100644 Makefile create mode 100644 NOTICE.txt create mode 100644 PULL_REQUEST_TEMPLATE.md create mode 100644 README.md create mode 100644 package.json create mode 100644 src/actions/channels.js create mode 100644 src/actions/errors.js create mode 100644 src/actions/files.js create mode 100644 src/actions/general.js create mode 100644 src/actions/helpers.js create mode 100644 src/actions/posts.js create mode 100644 src/actions/preferences.js create mode 100644 src/actions/teams.js create mode 100644 src/actions/users.js create mode 100644 src/actions/websocket.js create mode 100644 src/client/client.js create mode 100644 src/client/fetch_etag.js create mode 100644 src/client/index.js create mode 100644 src/client/websocket_client.js create mode 100644 src/constants/channels.js create mode 100644 src/constants/constants.js create mode 100644 src/constants/errors.js create mode 100644 src/constants/files.js create mode 100644 src/constants/general.js create mode 100644 src/constants/index.js create mode 100644 src/constants/posts.js create mode 100644 src/constants/preferences.js create mode 100644 src/constants/request_status.js create mode 100644 src/constants/teams.js create mode 100644 src/constants/users.js create mode 100644 src/constants/websocket.js create mode 100644 src/reducers/entities/channels.js create mode 100644 src/reducers/entities/files.js create mode 100644 src/reducers/entities/general.js create mode 100644 src/reducers/entities/index.js create mode 100644 src/reducers/entities/posts.js create mode 100644 src/reducers/entities/preferences.js create mode 100644 src/reducers/entities/teams.js create mode 100644 src/reducers/entities/typing.js create mode 100644 src/reducers/entities/users.js create mode 100644 src/reducers/errors/index.js create mode 100644 src/reducers/index.js create mode 100644 src/reducers/requests/channels.js create mode 100644 src/reducers/requests/files.js create mode 100644 src/reducers/requests/general.js create mode 100644 src/reducers/requests/helpers.js create mode 100644 src/reducers/requests/index.js create mode 100644 src/reducers/requests/posts.js create mode 100644 src/reducers/requests/preferences.js create mode 100644 src/reducers/requests/teams.js create mode 100644 src/reducers/requests/users.js create mode 100644 src/selectors/entities/channels.js create mode 100644 src/selectors/entities/files.js create mode 100644 src/selectors/entities/general.js create mode 100644 src/selectors/entities/posts.js create mode 100644 src/selectors/entities/preferences.js create mode 100644 src/selectors/entities/teams.js create mode 100644 src/selectors/entities/typing.js create mode 100644 src/selectors/entities/users.js create mode 100644 src/selectors/errors.js create mode 100644 src/store/configureStore.dev.js create mode 100644 src/store/configureStore.prod.js create mode 100644 src/store/index.js create mode 100644 src/utils/channel_utils.js create mode 100644 src/utils/deep_freeze.js create mode 100644 src/utils/event_emitter.js create mode 100644 src/utils/file_utils.js create mode 100644 src/utils/key_mirror.js create mode 100644 src/utils/post_utils.js create mode 100644 src/utils/preference_utils.js create mode 100644 src/utils/user_utils.js create mode 100644 test/.eslintrc.json create mode 100644 test/actions/channels.test.js create mode 100644 test/actions/files.test.js create mode 100644 test/actions/general.test.js create mode 100644 test/actions/posts.test.js create mode 100644 test/actions/preferences.test.js create mode 100644 test/actions/teams.test.js create mode 100644 test/actions/users.test.js create mode 100644 test/actions/websocket.test.js create mode 100644 test/assets/images/test.png create mode 100644 test/mocha.opts create mode 100644 test/sanity.test.js create mode 100644 test/selectors/posts.test.js create mode 100644 test/setup.js create mode 100644 test/test_helper.js create mode 100644 test/utils/post_utils.test.js diff --git a/.babelrc b/.babelrc new file mode 100644 index 000000000..7c3957090 --- /dev/null +++ b/.babelrc @@ -0,0 +1,8 @@ +{ + "presets": ["react-native"], + "plugins": [ + ["module-resolver", { + "root": ["./src", "."] + }] + ] +} diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 000000000..63ab05f98 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,213 @@ +{ + "extends": "eslint:recommended", + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module", + "ecmaFeatures": { + "jsx": true, + "impliedStrict": true, + "modules": true + } + }, + "parser": "babel-eslint", + "plugins": [ + "mocha" + ], + "env": { + "browser": true, + "node": true, + "jquery": true, + "es6": true + }, + "globals": { + "jest": true, + "describe": true, + "it": true, + "expect": true, + "before": true, + "beforeEach": true, + "after": true, + "afterEach": true + }, + "rules": { + "array-bracket-spacing": [2, "never"], + "array-callback-return": 2, + "arrow-body-style": 0, + "arrow-parens": [2, "always"], + "arrow-spacing": [2, { "before": true, "after": true }], + "block-scoped-var": 2, + "brace-style": [2, "1tbs", { "allowSingleLine": false }], + "camelcase": [2, {"properties": "never"}], + "class-methods-use-this": 0, + "comma-dangle": [2, "never"], + "comma-spacing": [2, {"before": false, "after": true}], + "comma-style": [2, "last"], + "complexity": [1, 10], + "computed-property-spacing": [2, "never"], + "consistent-return": 2, + "consistent-this": [2, "self"], + "constructor-super": 2, + "curly": [2, "all"], + "dot-location": [2, "object"], + "dot-notation": 2, + "eqeqeq": [2, "smart"], + "func-call-spacing": [2, "never"], + "func-names": 2, + "func-style": [2, "declaration"], + "generator-star-spacing": [0, {"before": false, "after": true}], + "global-require": 2, + "guard-for-in": 2, + "id-blacklist": 0, + "indent": [2, 4, {"SwitchCase": 0}], + "jsx-quotes": [2, "prefer-single"], + "key-spacing": [2, {"beforeColon": false, "afterColon": true, "mode": "strict"}], + "keyword-spacing": [2, {"before": true, "after": true, "overrides": {}}], + "line-comment-position": 0, + "linebreak-style": 2, + "lines-around-comment": [2, { "beforeBlockComment": true, "beforeLineComment": true, "allowBlockStart": true, "allowBlockEnd": true }], + "max-lines": [1, {"max": 450, "skipBlankLines": true, "skipComments": false}], + "max-nested-callbacks": [2, {"max":2}], + "max-statements-per-line": [2, {"max": 1}], + "multiline-ternary": [1, "never"], + "new-cap": 2, + "new-parens": 2, + "newline-before-return": 0, + "newline-per-chained-call": 0, + "no-alert": 2, + "no-array-constructor": 2, + "no-caller": 2, + "no-case-declarations": 2, + "no-class-assign": 2, + "no-cond-assign": [2, "except-parens"], + "no-confusing-arrow": 2, + "no-console": 2, + "no-const-assign": 2, + "no-constant-condition": 2, + "no-debugger": 2, + "no-div-regex": 2, + "no-dupe-args": 2, + "no-dupe-class-members": 2, + "no-dupe-keys": 2, + "no-duplicate-case": 2, + "no-duplicate-imports": [2, {"includeExports": true}], + "no-else-return": 2, + "no-empty": 2, + "no-empty-function": 2, + "no-empty-pattern": 2, + "no-eval": 2, + "no-ex-assign": 2, + "no-extend-native": 2, + "no-extra-bind": 2, + "no-extra-label": 2, + "no-extra-parens": 0, + "no-extra-semi": 2, + "no-fallthrough": 2, + "no-floating-decimal": 2, + "no-func-assign": 2, + "no-global-assign": 2, + "no-implicit-coercion": 2, + "no-implicit-globals": 0, + "no-implied-eval": 2, + "no-inner-declarations": 0, + "no-invalid-regexp": 2, + "no-irregular-whitespace": 2, + "no-iterator": 2, + "no-labels": 2, + "no-lone-blocks": 2, + "no-lonely-if": 2, + "no-loop-func": 2, + "no-magic-numbers": 0, + "no-mixed-operators": [2, {"allowSamePrecedence": false}], + "no-mixed-spaces-and-tabs": 2, + "no-multi-spaces": [2, { "exceptions": { "Property": false } }], + "no-multi-str": 0, + "no-multiple-empty-lines": [2, {"max": 1}], + "no-native-reassign": 2, + "no-negated-condition": 2, + "no-nested-ternary": 2, + "no-new": 2, + "no-new-func": 2, + "no-new-object": 2, + "no-new-symbol": 2, + "no-new-wrappers": 2, + "no-octal-escape": 2, + "no-param-reassign": 2, + "no-process-env": 2, + "no-process-exit": 2, + "no-proto": 2, + "no-redeclare": 2, + "no-return-assign": [2, "always"], + "no-script-url": 2, + "no-self-assign": [2, {"props": true}], + "no-self-compare": 2, + "no-sequences": 2, + "no-shadow": [2, {"hoist": "functions"}], + "no-shadow-restricted-names": 2, + "no-spaced-func": 2, + "no-tabs": 0, + "no-template-curly-in-string": 2, + "no-ternary": 0, + "no-this-before-super": 2, + "no-throw-literal": 0, + "no-trailing-spaces": [2, { "skipBlankLines": false }], + "no-undef-init": 2, + "no-undefined": 2, + "no-underscore-dangle": 2, + "no-unexpected-multiline": 2, + "no-unmodified-loop-condition": 2, + "no-unneeded-ternary": [2, {"defaultAssignment": false}], + "no-unreachable": 2, + "no-unsafe-finally": 2, + "no-unsafe-negation": 2, + "no-unused-expressions": 2, + "no-unused-vars": [2, {"vars": "all", "args": "after-used"}], + "no-use-before-define": [2, {"classes": false, "functions": false, "variables": false}], + "no-useless-computed-key": 2, + "no-useless-concat": 2, + "no-useless-constructor": 2, + "no-useless-escape": 2, + "no-useless-rename": 2, + "no-var": 0, + "no-void": 2, + "no-warning-comments": 1, + "no-whitespace-before-property": 2, + "no-with": 2, + "object-curly-newline": 0, + "object-curly-spacing": [2, "never"], + "object-property-newline": [2, {"allowMultiplePropertiesPerLine": true}], + "object-shorthand": [2, "always"], + "one-var": [2, "never"], + "one-var-declaration-per-line": 0, + "operator-linebreak": [2, "after"], + "padded-blocks": [2, "never"], + "prefer-arrow-callback": 2, + "prefer-const": 2, + "prefer-numeric-literals": 2, + "prefer-reflect": 2, + "prefer-rest-params": 2, + "prefer-spread": 2, + "prefer-template": 0, + "quote-props": [2, "as-needed"], + "quotes": [2, "single", "avoid-escape"], + "radix": 2, + "require-yield": 2, + "rest-spread-spacing": [2, "never"], + "semi": [2, "always"], + "semi-spacing": [2, {"before": false, "after": true}], + "sort-imports": 0, + "sort-keys": 0, + "space-before-blocks": [2, "always"], + "space-before-function-paren": [2, "never"], + "space-in-parens": [2, "never"], + "space-infix-ops": 2, + "space-unary-ops": [2, { "words": true, "nonwords": false }], + "symbol-description": 2, + "template-curly-spacing": [2, "never"], + "valid-typeof": [2, {"requireStringLiterals": false}], + "vars-on-top": 0, + "wrap-iife": [2, "outside"], + "wrap-regex": 2, + "yoda": [2, "never", {"exceptRange": false, "onlyEquality": false}], + "mocha/no-exclusive-tests": 2 + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..d42ff1835 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.pbxproj -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..8494b4da1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# OSX +# +.DS_Store + + +*.iml +.idea + +# node.js +# +node_modules/ +npm-debug.log +.npminstall + +# Vim +[._]*.s[a-w][a-z] +[._]s[a-w][a-z] +*.un~ +Session.vim +.netrwhist +*~ +tags diff --git a/.npmignore b/.npmignore new file mode 100644 index 000000000..4ab1998d0 --- /dev/null +++ b/.npmignore @@ -0,0 +1,11 @@ +node_modules/ +.idea +test +ISSUE_TEMPLATE.md +Makefile +PULL_REQUEST_TEMPLATE.md +.eslintrc.json +.gitattributes +.gitignore +.npmignore +.npminstall \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..a6d3944f5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,36 @@ +# Code Contribution Guidelines + +Please see the [Mattermost Contribution Guide](http://docs.mattermost.com/developer/contribution-guide.html) which describes the process for making code contributions across Mattermost projects. + +Note: Community work won't start until October 31, and no community pull requests will be accepted before then. + +### Review Process for this Repo + +After following the steps in the [Contribution Guide](http://docs.mattermost.com/developer/contribution-guide.html), submitted pull requests go through the review process outlined below. We aim to start reviewing pull requests in this repo the week they are submitted, but the length of time to complete the process will vary depending on the pull request. + +The one exception may be around release time, where the review process may take longer as the team focuses on our [release process](https://docs.mattermost.com/process/release-process.html). + +#### `Stage 1: PM Review` + +A Product Manager will review the pull request to make sure it: + +1. Fits with our product roadmap +2. Works as expected +3. Meets UX guidelines + +This step is sometimes skipped for bugs or small improvements with a ticket, but always happens for new features or pull requests without a related ticket. + +The Product Manager may come back with some bugs or UI improvements to fix before the pull request moves on to the next stage. + +#### `Stage 2: Dev Review` + +Two developers will review the pull request and either give feedback or `+1` the PR. + +Any comments will need to be addressed before the pull request moves on to the last stage. + +- PRs that do not follow Style Guides cannot be merged + +#### `Stage 3: Ready to Merge` + +The review process is complete, and the pull request will be merged. + diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..4f983d856 --- /dev/null +++ b/ISSUE_TEMPLATE.md @@ -0,0 +1,21 @@ +Submit feature requests to http://www.mattermost.org/feature-requests/. File non-security related bugs here in the following format: + +#### Summary +Issue in one concise sentence. + +#### Environment Information + +- Webapp or React Native app: +- Mattermost Server Version: + +#### Steps to reproduce +How can we reproduce the issue? + +#### Expected behavior +Describe your issue in detail. + +#### Observed behavior +What did you see happen? Please include relevant error messages and/or screenshots. + +#### Possible fixes +If you can, link to the line of code that might be responsible for the problem. diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 000000000..2f00b85f2 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,203 @@ +Copyright 2016 Mattermost, Inc. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..64f57327f --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +.PHONY: check-style clean pre-run test + + +.npminstall: package.json + @if ! [ $(shell command -v npm) ]; then \ + echo "npm is not installed"; \ + exit 1; \ + fi + + @echo Getting dependencies using npm + + npm install --ignore-scripts + + touch $@ + +check-style: | pre-run .npminstall + @echo Checking for style guide compliance + + npm run check + + +clean: + @echo Cleaning app + + npm cache clean + rm -rf node_modules + rm -f .npminstall + +pre-run: + @echo Make sure no previous build are in the folder + + @rm -rf actions + @rm -rf client + @rm -rf constants + @rm -rf reducers + @rm -rf selectors + @rm -rf store + @rm -rf utils + +test: check-style + npm test \ No newline at end of file diff --git a/NOTICE.txt b/NOTICE.txt new file mode 100644 index 000000000..9295bae21 --- /dev/null +++ b/NOTICE.txt @@ -0,0 +1,184 @@ +Mattermost Redux +© 2016 Mattermost, Inc. All Rights Reserved. See LICENSE.txt for license information. + +NOTICES: +-------- + +This document includes a list of open source components used in Mattermost Redux, including those that have been modified. + +-------- + +## harmony-reflect + +[Note: the software referenced below is made available under two licenses. Mattermost, Inc. has elected to license the software pursuant to the Apache License, Version 2.0.] + +This product contains 'harmony-reflect', a shim for ECMAScript 6 Reflect and Proxy objects. + +* HOMEPAGE: + * https://github.com/tvcutsem/harmony-reflect + +* LICENSE: + +Copyright (C) 2011-2014 Software Languages Lab, Vrije Universiteit Brussel +Copyright (C) 2015 Tom Van Cutsem + +This code is dual-licensed under both the Apache License and the MPL + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Version: MPL 1.1 + +The contents of this file are subject to the Mozilla Public License Version +1.1 (the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.mozilla.org/MPL/ + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License +for the specific language governing rights and limitations under the +License. + +--- + +## isomorphic-fetch + +This product contains 'isomorphic-fetch', to provide the fetch function for node by Matt Andrews. + +* HOMEPAGE: + * https://github.com/matthew-andrews/isomorphic-fetch + +* LICENSE: + +The MIT License (MIT) + +Copyright (c) 2015 Matt Andrews + +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. + +--- + +## redux + +This product contains 'redux', a predictable state container for JavaScript apps by Dan Abramov. + +* HOMEPAGE: + * https://github.com/reactjs/redux + +* LICENSE: + +The MIT License (MIT) + +Copyright (c) 2015-present Dan Abramov + +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. + +--- + +## redux-batched-actions + +This product contains 'redux-batched-actions', a batching action creator and higher order reducer for Redux by Tim Shelburne. + +* HOMEPAGE: + * https://github.com/tshelburne/redux-batched-actions + +* LICENSE: + +MIT License + +Copyright (c) 2016 Tim Shelburne + +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. + +--- + +## redux-thunk + +This product contains 'redux-thunk', middleware to allow asynchronous actions in Redux by Dan Abramov. + +* HOMEPAGE: + * https://github.com/gaearon/redux-thunk + +* LICENSE: + +The MIT License (MIT) + +Copyright (c) 2015 Dan Abramov + +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/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..9a579f2f0 --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,17 @@ +Please make sure you've read the [pull request](http://docs.mattermost.com/developer/contribution-guide.html#preparing-a-pull-request) section of our [code contribution guidelines](http://docs.mattermost.com/developer/contribution-guide.html). + +When filling in a section please remove the help text and the above text. + +#### Summary +[A brief description of what this pull request does.] + +#### Ticket Link +[Please link the GitHub issue or Jira ticket this PR addresses.] + +#### Checklist +[Place an '[x]' (no spaces) in all applicable fields. Please remove unrelated fields.] +- [ ] Added or updated unit tests (required for all new features) +- [ ] All new/modified APIs include changes to the drivers + +#### Test Information +This PR was tested on: [Device name(s), OS version(s)] diff --git a/README.md b/README.md new file mode 100644 index 000000000..fee880094 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# Mattermost Redux (unreleased) + +**Supported Server Versions:** Master (no released versions are currently supported) + +This is an unreleased project for replacing the current implementation of the store with Redux. The project is not yet stable, and the instructions are for internal use currently (i.e. probably out-of-date until we stablize). + +We'll post updates to our [Forums](http://forum.mattermost.org/) and [Twitter](https://twitter.com/mattermosthq) when we're ready to bring in more community contributors. + +Mattermost is an open source Slack-alternative used by thousands of companies around the world in 11 languages. Learn more at https://mattermost.com. + +# How to Contribute + +### Contribute Code + +We're not quite ready to accept external contributions yet - when things are ready, issues with a [Help Wanted] title will be posted in the [GitHub Issues section](https://github.com/mattermost/mattermost-mobile/issues). diff --git a/package.json b/package.json new file mode 100644 index 000000000..3ea40d23f --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "mattermost-redux", + "version": "0.0.1", + "private": true, + "dependencies": { + "deep-equal": "1.0.1", + "harmony-reflect": "1.5.1", + "isomorphic-fetch": "2.2.1", + "redux": "3.6.0", + "redux-batched-actions": "0.1.5", + "redux-thunk": "2.2.0", + "reselect": "2.5.4" + }, + "devDependencies": { + "babel-cli": "6.23.0", + "babel-eslint": "7.1.1", + "babel-plugin-module-resolver": "2.5.0", + "babel-polyfill": "6.23.0", + "babel-preset-react-native": "1.9.1", + "babel-register": "6.23.0", + "chai": "3.5.0", + "deep-freeze": "0.0.1", + "eslint": "3.17.1", + "eslint-plugin-mocha": "4.8.0", + "fetch-mock": "5.9.4", + "form-data": "2.1.2", + "mocha": "3.2.0", + "redux-logger": "2.8.2", + "remote-redux-devtools": "0.5.7", + "remote-redux-devtools-on-debugger": "0.7.0", + "ws": "2.2.0" + }, + "scripts": { + "build": "babel src --out-dir lib", + "check": "node_modules/.bin/eslint --ext \".js\" --ignore-pattern node_modules --quiet .", + "test": "NODE_ENV=test mocha --opts test/mocha.opts", + "postinstall": "npm run build && mv lib/* ./ && rm -rf src lib" + } +} diff --git a/src/actions/channels.js b/src/actions/channels.js new file mode 100644 index 000000000..20754948f --- /dev/null +++ b/src/actions/channels.js @@ -0,0 +1,753 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import { + Constants, + ChannelTypes, + Preferences, + PreferencesTypes, + UsersTypes +} from 'constants'; +import {batchActions} from 'redux-batched-actions'; + +import Client from 'client'; + +import {logError, getLogErrorAction} from './errors'; +import {forceLogoutIfNecessary} from './helpers'; + +export function selectChannel(channelId) { + return async (dispatch, getState) => { + try { + dispatch({ + type: ChannelTypes.SELECT_CHANNEL, + data: channelId + }, getState); + } catch (error) { + logError(error)(dispatch); + } + }; +} + +export function createChannel(channel, userId) { + return async (dispatch, getState) => { + dispatch(batchActions([ + { + type: ChannelTypes.CREATE_CHANNEL_REQUEST + }, + { + type: ChannelTypes.CHANNEL_MEMBERS_REQUEST + } + ]), getState); + + let created; + try { + created = await Client.createChannel(channel); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + { + type: ChannelTypes.CREATE_CHANNEL_FAILURE, + error + }, + { + type: ChannelTypes.CHANNEL_MEMBERS_FAILURE, + error + }, + getLogErrorAction(error) + ]), getState); + return null; + } + + const member = { + channel_id: created.id, + user_id: userId, + roles: `${Constants.CHANNEL_USER_ROLE} ${Constants.CHANNEL_ADMIN_ROLE}`, + last_viewed_at: 0, + msg_count: 0, + mention_count: 0, + notify_props: {desktop: 'default', mark_unread: 'all'}, + last_update_at: created.create_at + }; + + const actions = []; + const {channels, myMembers} = getState().entities.channels; + + if (!channels[created.id]) { + actions.push({type: ChannelTypes.RECEIVED_CHANNEL, data: created}); + } + + if (!myMembers[created.id]) { + actions.push({type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER, data: member}); + } + + dispatch(batchActions([ + ...actions, + { + type: ChannelTypes.CREATE_CHANNEL_SUCCESS + }, + { + type: ChannelTypes.CHANNEL_MEMBERS_SUCCESS + } + ]), getState); + + return created; + }; +} + +export function createDirectChannel(teamId, userId, otherUserId) { + return async (dispatch, getState) => { + dispatch({type: ChannelTypes.CREATE_CHANNEL_REQUEST}, getState); + + let created; + try { + created = await Client.createDirectChannel(teamId, otherUserId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: ChannelTypes.CREATE_CHANNEL_FAILURE, error}, + {type: ChannelTypes.CHANNEL_MEMBERS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return null; + } + + const member = { + channel_id: created.id, + user_id: userId, + roles: `${Constants.CHANNEL_USER_ROLE} ${Constants.CHANNEL_ADMIN_ROLE}`, + last_viewed_at: 0, + msg_count: 0, + mention_count: 0, + notify_props: {desktop: 'default', mark_unread: 'all'}, + last_update_at: created.create_at + }; + + dispatch(batchActions([ + { + type: ChannelTypes.RECEIVED_CHANNEL, + data: created + }, + { + type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER, + data: member + }, + { + type: PreferencesTypes.RECEIVED_PREFERENCES, + data: [{category: Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, name: otherUserId, value: 'true'}] + }, + { + type: ChannelTypes.CREATE_CHANNEL_SUCCESS + } + ]), getState); + + return created; + }; +} + +export function updateChannel(channel) { + return async (dispatch, getState) => { + dispatch({type: ChannelTypes.UPDATE_CHANNEL_REQUEST}, getState); + + let updated; + try { + updated = await Client.updateChannel(channel); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + + dispatch(batchActions([ + {type: ChannelTypes.UPDATE_CHANNEL_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: ChannelTypes.RECEIVED_CHANNEL, + data: updated + }, + { + type: ChannelTypes.UPDATE_CHANNEL_SUCCESS + } + ]), getState); + }; +} + +export function updateChannelNotifyProps(userId, teamId, channelId, props) { + return async (dispatch, getState) => { + dispatch({type: ChannelTypes.NOTIFY_PROPS_REQUEST}, getState); + + const data = { + user_id: userId, + channel_id: channelId, + ...props + }; + + let notifyProps; + try { + notifyProps = await Client.updateChannelNotifyProps(teamId, data); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + + dispatch(batchActions([ + {type: ChannelTypes.NOTIFY_PROPS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: ChannelTypes.RECEIVED_CHANNEL_PROPS, + data: { + channel_id: channelId, + notifyProps + } + }, + { + type: ChannelTypes.NOTIFY_PROPS_SUCCESS + } + ]), getState); + }; +} + +export function getChannel(teamId, channelId) { + return async (dispatch, getState) => { + dispatch({type: ChannelTypes.CHANNEL_REQUEST}, getState); + + let data; + try { + data = await Client.getChannel(teamId, channelId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: ChannelTypes.CHANNELS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: ChannelTypes.RECEIVED_CHANNEL, + data: data.channel + }, + { + type: ChannelTypes.CHANNEL_SUCCESS + }, + { + type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER, + data: data.member + } + ]), getState); + }; +} + +export function fetchMyChannelsAndMembers(teamId) { + return async (dispatch, getState) => { + dispatch(batchActions([ + { + type: ChannelTypes.CHANNELS_REQUEST + }, + { + type: ChannelTypes.CHANNEL_MEMBERS_REQUEST + } + ]), getState); + + let channels; + let channelMembers; + try { + const channelsRequest = Client.getChannels(teamId); + const channelMembersRequest = Client.getMyChannelMembers(teamId); + + channels = await channelsRequest; + channelMembers = await channelMembersRequest; + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: ChannelTypes.CHANNELS_FAILURE, error}, + {type: ChannelTypes.CHANNEL_MEMBERS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: ChannelTypes.RECEIVED_CHANNELS, + data: channels + }, + { + type: ChannelTypes.CHANNELS_SUCCESS + }, + { + type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBERS, + data: channelMembers + }, + { + type: ChannelTypes.CHANNEL_MEMBERS_SUCCESS + } + ]), getState); + }; +} + +export function getMyChannelMembers(teamId) { + return async (dispatch, getState) => { + dispatch({type: ChannelTypes.CHANNEL_MEMBERS_REQUEST}, getState); + + let channelMembers; + try { + const channelMembersRequest = Client.getMyChannelMembers(teamId); + + channelMembers = await channelMembersRequest; + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: ChannelTypes.CHANNEL_MEMBERS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBERS, + data: channelMembers + }, + { + type: ChannelTypes.CHANNEL_MEMBERS_SUCCESS + } + ]), getState); + }; +} + +export function leaveChannel(teamId, channelId) { + return async (dispatch, getState) => { + dispatch({type: ChannelTypes.LEAVE_CHANNEL_REQUEST}, getState); + + try { + await Client.leaveChannel(teamId, channelId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: ChannelTypes.LEAVE_CHANNEL_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: ChannelTypes.LEAVE_CHANNEL, + data: channelId + }, + { + type: ChannelTypes.LEAVE_CHANNEL_SUCCESS + } + ]), getState); + }; +} + +export function joinChannel(userId, teamId, channelId, channelName) { + return async (dispatch, getState) => { + dispatch({type: ChannelTypes.JOIN_CHANNEL_REQUEST}, getState); + + let channel; + try { + if (channelId) { + channel = await Client.joinChannel(teamId, channelId); + } else if (channelName) { + channel = await Client.joinChannelByName(teamId, channelName); + } + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: ChannelTypes.JOIN_CHANNEL_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + const channelMember = { + channel_id: channel.id, + user_id: userId, + roles: `${Constants.CHANNEL_USER_ROLE}`, + last_viewed_at: 0, + msg_count: 0, + mention_count: 0, + notify_props: {desktop: 'default', mark_unread: 'all'}, + last_update_at: new Date().getTime() + }; + + dispatch(batchActions([ + { + type: ChannelTypes.RECEIVED_CHANNEL, + data: channel + }, + { + type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER, + data: channelMember + }, + { + type: ChannelTypes.JOIN_CHANNEL_SUCCESS + } + ]), getState); + }; +} + +export function deleteChannel(teamId, channelId) { + return async (dispatch, getState) => { + dispatch({type: ChannelTypes.DELETE_CHANNEL_REQUEST}, getState); + + try { + await Client.deleteChannel(teamId, channelId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: ChannelTypes.DELETE_CHANNEL_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + const entities = getState().entities; + const {channels, currentChannelId} = entities.channels; + if (channelId === currentChannelId) { + const channel = Object.keys(channels).filter((key) => channels[key].name === Constants.DEFAULT_CHANNEL); + let defaultChannelId = ''; + if (channel.length) { + defaultChannelId = channel[0]; + } + + dispatch({type: ChannelTypes.SELECT_CHANNEL, data: defaultChannelId}, getState); + } + + dispatch(batchActions([ + { + type: ChannelTypes.RECEIVED_CHANNEL_DELETED, + data: channelId + }, + { + type: ChannelTypes.DELETE_CHANNEL_SUCCESS + } + ]), getState); + }; +} + +export function viewChannel(teamId, channelId) { + return async (dispatch, getState) => { + const state = getState(); + const {currentChannelId} = state.entities.channels; + let prevChannelId = ''; + + if (channelId !== currentChannelId) { + prevChannelId = currentChannelId; + } + + dispatch({type: ChannelTypes.UPDATE_LAST_VIEWED_REQUEST}, getState); + + try { + // this API should return the timestamp that was set + await Client.viewChannel(teamId, channelId, prevChannelId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: ChannelTypes.UPDATE_LAST_VIEWED_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch({type: ChannelTypes.UPDATE_LAST_VIEWED_SUCCESS}, getState); + }; +} + +export function getMoreChannels(teamId, offset, limit = Constants.CHANNELS_CHUNK_SIZE) { + return async (dispatch, getState) => { + dispatch({type: ChannelTypes.MORE_CHANNELS_REQUEST}, getState); + + let channels; + try { + channels = await Client.getMoreChannels(teamId, offset, limit); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: ChannelTypes.MORE_CHANNELS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return null; + } + + dispatch(batchActions([ + { + type: ChannelTypes.RECEIVED_MORE_CHANNELS, + data: await channels + }, + { + type: ChannelTypes.MORE_CHANNELS_SUCCESS + } + ]), getState); + + return channels; + }; +} + +export function searchMoreChannels(teamId, term) { + return async (dispatch, getState) => { + dispatch({type: ChannelTypes.MORE_CHANNELS_REQUEST}, getState); + + let channels; + try { + channels = await Client.searchMoreChannels(teamId, term); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: ChannelTypes.MORE_CHANNELS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: ChannelTypes.RECEIVED_MORE_CHANNELS, + data: await channels + }, + { + type: ChannelTypes.MORE_CHANNELS_SUCCESS + } + ]), getState); + }; +} + +export function getChannelStats(teamId, channelId) { + return async (dispatch, getState) => { + dispatch({type: ChannelTypes.CHANNEL_STATS_REQUEST}, getState); + + let stat; + try { + stat = await Client.getChannelStats(teamId, channelId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: ChannelTypes.CHANNEL_STATS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: ChannelTypes.RECEIVED_CHANNEL_STATS, + data: stat + }, + { + type: ChannelTypes.CHANNEL_STATS_SUCCESS + } + ]), getState); + }; +} + +export function addChannelMember(teamId, channelId, userId) { + return async (dispatch, getState) => { + dispatch({type: ChannelTypes.ADD_CHANNEL_MEMBER_REQUEST}, getState); + + try { + await Client.addChannelMember(teamId, channelId, userId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: ChannelTypes.ADD_CHANNEL_MEMBER_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: UsersTypes.RECEIVED_PROFILE_IN_CHANNEL, + data: {user_id: userId}, + id: channelId + }, + { + type: ChannelTypes.ADD_CHANNEL_MEMBER_SUCCESS + } + ]), getState); + }; +} + +export function removeChannelMember(teamId, channelId, userId) { + return async (dispatch, getState) => { + dispatch({type: ChannelTypes.REMOVE_CHANNEL_MEMBER_REQUEST}, getState); + + try { + await Client.removeChannelMember(teamId, channelId, userId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: ChannelTypes.REMOVE_CHANNEL_MEMBER_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: UsersTypes.RECEIVED_PROFILE_NOT_IN_CHANNEL, + data: {user_id: userId}, + id: channelId + }, + { + type: ChannelTypes.REMOVE_CHANNEL_MEMBER_SUCCESS + } + ]), getState); + }; +} + +export function updateChannelHeader(channelId, header) { + return async (dispatch, getState) => { + dispatch({ + type: ChannelTypes.UPDATE_CHANNEL_HEADER, + data: { + channelId, + header + } + }, getState); + }; +} + +export function updateChannelPurpose(channelId, purpose) { + return async (dispatch, getState) => { + dispatch({ + type: ChannelTypes.UPDATE_CHANNEL_PURPOSE, + data: { + channelId, + purpose + } + }, getState); + }; +} + +export function markChannelAsRead(channelId, prevChannelId) { + return async (dispatch, getState) => { + const state = getState(); + + const {channels} = state.entities.channels; + let totalMsgCount = 0; + if (channels[channelId]) { + totalMsgCount = channels[channelId].total_msg_count; + } + const actions = [{ + type: ChannelTypes.RECEIVED_LAST_VIEWED, + data: { + channel_id: channelId, + last_viewed_at: new Date().getTime(), + total_msg_count: totalMsgCount + } + }]; + + if (prevChannelId) { + let prevTotalMsgCount = 0; + if (channels[prevChannelId]) { + prevTotalMsgCount = channels[prevChannelId].total_msg_count; + } + actions.push({ + type: ChannelTypes.RECEIVED_LAST_VIEWED, + data: { + channel_id: prevChannelId, + last_viewed_at: new Date().getTime(), + total_msg_count: prevTotalMsgCount + } + }); + } + + dispatch(batchActions([...actions]), getState); + }; +} + +export function markChannelAsUnread(channelId, mentionsArray) { + return async (dispatch, getState) => { + const state = getState(); + const {channels, myMembers} = state.entities.channels; + const {currentUserId} = state.entities.users; + const channel = {...channels[channelId]}; + const member = {...myMembers[channelId]}; + + if (channel && member) { + channel.total_msg_count++; + if (member.notify_props && member.notify_props.mark_unread === Constants.MENTION) { + member.msg_count++; + } + + let mentions = []; + if (mentionsArray) { + mentions = JSON.parse(mentionsArray); + if (mentions.indexOf(currentUserId) !== -1) { + member.mention_count++; + } + } + + dispatch(batchActions([{ + type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER, + data: member + }, { + type: ChannelTypes.RECEIVED_CHANNEL, + data: channel + }]), getState); + } + }; +} + +export function autocompleteChannels(teamId, term) { + return async (dispatch, getState) => { + dispatch({type: ChannelTypes.AUTOCOMPLETE_CHANNELS_REQUEST}, getState); + + let data; + try { + data = await Client.autocompleteChannels(teamId, term); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: ChannelTypes.AUTOCOMPLETE_CHANNELS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: ChannelTypes.RECEIVED_AUTOCOMPLETE_CHANNELS, + data, + teamId + }, + { + type: ChannelTypes.AUTOCOMPLETE_CHANNELS_SUCCESS + } + ]), getState); + }; +} + +export default { + selectChannel, + createChannel, + createDirectChannel, + updateChannel, + updateChannelNotifyProps, + getChannel, + fetchMyChannelsAndMembers, + getMyChannelMembers, + leaveChannel, + joinChannel, + deleteChannel, + viewChannel, + getMoreChannels, + searchMoreChannels, + getChannelStats, + addChannelMember, + removeChannelMember, + updateChannelHeader, + updateChannelPurpose, + markChannelAsRead, + markChannelAsUnread, + autocompleteChannels +}; diff --git a/src/actions/errors.js b/src/actions/errors.js new file mode 100644 index 000000000..6c88f24d2 --- /dev/null +++ b/src/actions/errors.js @@ -0,0 +1,40 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {ErrorTypes} from 'constants'; + +export function dismissErrorObject(index) { + return { + type: ErrorTypes.DISMISS_ERROR, + index + }; +} + +export function dismissError(index) { + return async (dispatch) => { + dispatch(dismissErrorObject(index)); + }; +} + +export function getLogErrorAction(error, displayable = true) { + return { + type: ErrorTypes.LOG_ERROR, + displayable, + error + }; +} + +export function logError(error, displayable = true) { + return async (dispatch) => { + // do something with the incoming error + // like sending it to analytics + + dispatch(getLogErrorAction(error, displayable)); + }; +} + +export function clearErrors() { + return async (dispatch) => { + dispatch({type: ErrorTypes.CLEAR_ERRORS}); + }; +} diff --git a/src/actions/files.js b/src/actions/files.js new file mode 100644 index 000000000..5e5f4bce6 --- /dev/null +++ b/src/actions/files.js @@ -0,0 +1,38 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {batchActions} from 'redux-batched-actions'; + +import Client from 'client'; +import {FilesTypes} from 'constants'; +import {getLogErrorAction} from './errors'; +import {forceLogoutIfNecessary} from './helpers'; + +export function getFilesForPost(teamId, channelId, postId) { + return async (dispatch, getState) => { + dispatch({type: FilesTypes.FETCH_FILES_FOR_POST_REQUEST}, getState); + let files; + + try { + files = await Client.getFileInfosForPost(teamId, channelId, postId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: FilesTypes.FETCH_FILES_FOR_POST_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: FilesTypes.RECEIVED_FILES_FOR_POST, + data: files, + postId + }, + { + type: FilesTypes.FETCH_FILES_FOR_POST_SUCCESS + } + ]), getState); + }; +} diff --git a/src/actions/general.js b/src/actions/general.js new file mode 100644 index 000000000..6e761cb55 --- /dev/null +++ b/src/actions/general.js @@ -0,0 +1,122 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {batchActions} from 'redux-batched-actions'; + +import Client from 'client'; +import {bindClientFunc, FormattedError} from './helpers.js'; +import {GeneralTypes} from 'constants'; +import {getMyChannelMembers} from './channels'; +import {getLogErrorAction} from './errors'; +import {loadMe} from './users'; + +export function getPing() { + return async (dispatch, getState) => { + dispatch({type: GeneralTypes.PING_REQUEST}, getState); + + let data; + const pingError = new FormattedError( + 'mobile.server_ping_failed', + 'Cannot connect to the server. Please check your server URL and internet connection.' + ); + try { + data = await Client.getPing(); + if (!data.version) { + // successful ping but not the right return data + dispatch(batchActions([ + {type: GeneralTypes.PING_FAILURE, error: pingError}, + getLogErrorAction(pingError) + ]), getState); + return; + } + } catch (error) { + dispatch(batchActions([ + {type: GeneralTypes.PING_FAILURE, error: pingError}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch({type: GeneralTypes.PING_SUCCESS, data}, getState); + }; +} + +export function resetPing() { + return async (dispatch, getState) => { + dispatch({type: GeneralTypes.PING_RESET}, getState); + }; +} + +export function getClientConfig() { + return bindClientFunc( + Client.getClientConfig, + GeneralTypes.CLIENT_CONFIG_REQUEST, + [GeneralTypes.CLIENT_CONFIG_RECEIVED, GeneralTypes.CLIENT_CONFIG_SUCCESS], + GeneralTypes.CLIENT_CONFIG_FAILURE + ); +} + +export function getLicenseConfig() { + return bindClientFunc( + Client.getLicenseConfig, + GeneralTypes.CLIENT_LICENSE_REQUEST, + [GeneralTypes.CLIENT_LICENSE_RECEIVED, GeneralTypes.CLIENT_LICENSE_SUCCESS], + GeneralTypes.CLIENT_LICENSE_FAILURE + ); +} + +export function logClientError(message, level = 'ERROR') { + return bindClientFunc( + Client.logClientError, + GeneralTypes.LOG_CLIENT_ERROR_REQUEST, + GeneralTypes.LOG_CLIENT_ERROR_SUCCESS, + GeneralTypes.LOG_CLIENT_ERROR_FAILURE, + message, + level + ); +} + +export function setAppState(state) { + return async (dispatch, getState) => { + dispatch({type: GeneralTypes.RECEIVED_APP_STATE, data: state}, getState); + + if (state) { + const {currentTeamId} = getState().entities.teams; + if (currentTeamId) { + getMyChannelMembers(currentTeamId)(dispatch, getState); + } + } + }; +} + +export function setDeviceToken(token) { + return async (dispatch, getState) => { + dispatch({type: GeneralTypes.RECEIVED_APP_DEVICE_TOKEN, data: token}, getState); + }; +} + +export function setServerVersion(serverVersion) { + return async (dispatch, getState) => { + dispatch({type: GeneralTypes.RECEIVED_SERVER_VERSION, data: serverVersion}, getState); + }; +} + +export function setStoreFromLocalData(data) { + return async (dispatch, getState) => { + Client.setToken(data.token); + Client.setUrl(data.url); + + return loadMe()(dispatch, getState); + }; +} + +export default { + getPing, + getClientConfig, + getLicenseConfig, + logClientError, + setAppState, + setDeviceToken, + setServerVersion, + setStoreFromLocalData +}; diff --git a/src/actions/helpers.js b/src/actions/helpers.js new file mode 100644 index 000000000..c17f4dba2 --- /dev/null +++ b/src/actions/helpers.js @@ -0,0 +1,106 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {batchActions} from 'redux-batched-actions'; +import Client from 'client'; +import {UsersTypes} from 'constants'; +import {getLogErrorAction} from './errors'; +const HTTP_UNAUTHORIZED = 401; + +export async function forceLogoutIfNecessary(err, dispatch) { + if (err.status_code === HTTP_UNAUTHORIZED && err.url.indexOf('/login') === -1) { + dispatch({type: UsersTypes.LOGOUT_REQUEST}); + await Client.logout(); + dispatch({type: UsersTypes.LOGOUT_SUCCESS}); + } +} + +function dispatcher(type, data, dispatch, getState) { + if (type.indexOf('SUCCESS') === -1) { // we don't want to pass the data for the request types + dispatch(requestSuccess(type, data), getState); + } else { + dispatch(requestData(type), getState); + } +} + +export function requestData(type) { + return { + type + }; +} + +export function requestSuccess(type, data) { + return { + type, + data + }; +} + +export function requestFailure(type, error) { + return { + type, + error + }; +} + +export function bindClientFunc(clientFunc, request, success, failure, ...args) { + return async (dispatch, getState) => { + dispatch(requestData(request), getState); + + let data = null; + try { + data = await clientFunc(...args); + } catch (err) { + forceLogoutIfNecessary(err, dispatch); + dispatch(batchActions([ + requestFailure(failure, err), + getLogErrorAction(err) + ]), getState); + return; + } + + if (Array.isArray(success)) { + success.forEach((s) => { + dispatcher(s, data, dispatch, getState); + }); + } else { + dispatcher(success, data, dispatch, getState); + } + }; +} + +// Debounce function based on underscores modified to use es6 and a cb +export function debounce(func, wait, immediate, cb) { + let timeout; + return function fx(...args) { + const runLater = () => { + timeout = null; + if (!immediate) { + Reflect.apply(func, this, args); + if (cb) { + cb(); + } + } + }; + const callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(runLater, wait); + if (callNow) { + Reflect.apply(func, this, args); + if (cb) { + cb(); + } + } + }; +} + +export class FormattedError extends Error { + constructor(id, defaultMessage, values = {}) { + super(defaultMessage); + this.intl = { + id, + defaultMessage, + values + }; + } +} diff --git a/src/actions/posts.js b/src/actions/posts.js new file mode 100644 index 000000000..3d95bddbf --- /dev/null +++ b/src/actions/posts.js @@ -0,0 +1,275 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {batchActions} from 'redux-batched-actions'; +import Client from 'client'; +import {bindClientFunc, forceLogoutIfNecessary} from './helpers'; +import {Constants, PostsTypes} from 'constants'; +import {getLogErrorAction} from './errors'; +import {getProfilesByIds, getStatusesByIds} from './users'; + +async function getProfilesAndStatusesForPosts(list, dispatch, getState) { + const {profiles, statuses} = getState().entities.users; + const posts = list.posts; + const profilesToLoad = []; + const statusesToLoad = []; + + Object.keys(posts).forEach((key) => { + const post = posts[key]; + const userId = post.user_id; + + if (!profiles[userId]) { + profilesToLoad.push(userId); + } + + if (!statuses[userId]) { + statusesToLoad.push(userId); + } + }); + + if (profilesToLoad.length) { + await getProfilesByIds(profilesToLoad)(dispatch, getState); + } + + if (statusesToLoad.length) { + await getStatusesByIds(statusesToLoad)(dispatch, getState); + } +} + +export function createPost(teamId, post) { + return bindClientFunc( + Client.createPost, + PostsTypes.CREATE_POST_REQUEST, + [PostsTypes.RECEIVED_POST, PostsTypes.CREATE_POST_SUCCESS], + PostsTypes.CREATE_POST_FAILURE, + teamId, + post + ); +} + +export function editPost(teamId, post) { + return bindClientFunc( + Client.editPost, + PostsTypes.EDIT_POST_REQUEST, + [PostsTypes.RECEIVED_POST, PostsTypes.EDIT_POST_SUCCESS], + PostsTypes.EDIT_POST_FAILURE, + teamId, + post + ); +} + +export function deletePost(teamId, post) { + return async (dispatch, getState) => { + dispatch({type: PostsTypes.DELETE_POST_REQUEST}, getState); + + try { + await Client.deletePost(teamId, post.channel_id, post.id); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: PostsTypes.DELETE_POST_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: PostsTypes.POST_DELETED, + data: {...post} + }, + { + type: PostsTypes.DELETE_POST_SUCCESS + } + ]), getState); + }; +} + +export function removePost(post) { + return async (dispatch, getState) => { + dispatch({ + type: PostsTypes.REMOVE_POST, + data: {...post} + }, getState); + }; +} + +export function getPost(teamId, channelId, postId) { + return async (dispatch, getState) => { + dispatch({type: PostsTypes.GET_POST_REQUEST}, getState); + + let post; + try { + post = await Client.getPost(teamId, channelId, postId); + getProfilesAndStatusesForPosts(post, dispatch, getState); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: PostsTypes.GET_POST_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: PostsTypes.RECEIVED_POSTS, + data: {...post}, + channelId + }, + { + type: PostsTypes.GET_POST_SUCCESS + } + ]), getState); + }; +} + +export function getPosts(teamId, channelId, offset = 0, limit = Constants.POST_CHUNK_SIZE) { + return async (dispatch, getState) => { + dispatch({type: PostsTypes.GET_POSTS_REQUEST}, getState); + let posts; + + try { + posts = await Client.getPosts(teamId, channelId, offset, limit); + getProfilesAndStatusesForPosts(posts, dispatch, getState); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: PostsTypes.GET_POSTS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return null; + } + + dispatch(batchActions([ + { + type: PostsTypes.RECEIVED_POSTS, + data: posts, + channelId + }, + { + type: PostsTypes.GET_POSTS_SUCCESS + } + ]), getState); + + return posts; + }; +} + +export function getPostsSince(teamId, channelId, since) { + return async (dispatch, getState) => { + dispatch({type: PostsTypes.GET_POSTS_SINCE_REQUEST}, getState); + + let posts; + try { + posts = await Client.getPostsSince(teamId, channelId, since); + getProfilesAndStatusesForPosts(posts, dispatch, getState); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: PostsTypes.GET_POSTS_SINCE_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return null; + } + + dispatch(batchActions([ + { + type: PostsTypes.RECEIVED_POSTS, + data: posts, + channelId + }, + { + type: PostsTypes.GET_POSTS_SINCE_SUCCESS + } + ]), getState); + + return posts; + }; +} + +export function getPostsBefore(teamId, channelId, postId, offset = 0, limit = Constants.POST_CHUNK_SIZE) { + return async (dispatch, getState) => { + dispatch({type: PostsTypes.GET_POSTS_BEFORE_REQUEST}, getState); + + let posts; + try { + posts = await Client.getPostsBefore(teamId, channelId, postId, offset, limit); + getProfilesAndStatusesForPosts(posts, dispatch, getState); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: PostsTypes.GET_POSTS_BEFORE_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return null; + } + + dispatch(batchActions([ + { + type: PostsTypes.RECEIVED_POSTS, + data: posts, + channelId + }, + { + type: PostsTypes.GET_POSTS_BEFORE_SUCCESS + } + ]), getState); + + return posts; + }; +} + +export function getPostsAfter(teamId, channelId, postId, offset = 0, limit = Constants.POST_CHUNK_SIZE) { + return async (dispatch, getState) => { + dispatch({type: PostsTypes.GET_POSTS_AFTER_REQUEST}, getState); + + let posts; + try { + posts = await Client.getPostsAfter(teamId, channelId, postId, offset, limit); + getProfilesAndStatusesForPosts(posts, dispatch, getState); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: PostsTypes.GET_POSTS_AFTER_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return null; + } + + dispatch(batchActions([ + { + type: PostsTypes.RECEIVED_POSTS, + data: posts, + channelId + }, + { + type: PostsTypes.GET_POSTS_AFTER_SUCCESS + } + ]), getState); + + return posts; + }; +} + +export function selectPost(postId) { + return async (dispatch, getState) => { + dispatch({ + type: PostsTypes.RECEIVED_POST_SELECTED, + data: postId + }, getState); + }; +} + +export default { + createPost, + editPost, + deletePost, + removePost, + getPost, + getPosts, + getPostsSince, + getPostsBefore, + getPostsAfter, + selectPost +}; diff --git a/src/actions/preferences.js b/src/actions/preferences.js new file mode 100644 index 000000000..17f034276 --- /dev/null +++ b/src/actions/preferences.js @@ -0,0 +1,97 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {batchActions} from 'redux-batched-actions'; + +import Client from 'client'; +import {Preferences, PreferencesTypes} from 'constants'; +import {getMyPreferences as getMyPreferencesSelector} from 'selectors/entities/preferences'; +import {getCurrentUserId} from 'selectors/entities/users'; +import {getPreferenceKey} from 'utils/preference_utils'; + +import {bindClientFunc, forceLogoutIfNecessary} from './helpers'; + +import {getLogErrorAction} from './errors'; +export function getMyPreferences() { + return bindClientFunc( + Client.getMyPreferences, + PreferencesTypes.MY_PREFERENCES_REQUEST, + [PreferencesTypes.RECEIVED_PREFERENCES, PreferencesTypes.MY_PREFERENCES_SUCCESS], + PreferencesTypes.MY_PREFERENCES_FAILURE + ); +} + +export function savePreferences(preferences) { + return async (dispatch, getState) => { + dispatch({type: PreferencesTypes.SAVE_PREFERENCES_REQUEST}, getState); + + try { + await Client.savePreferences(preferences); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: PreferencesTypes.SAVE_PREFERENCES_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: PreferencesTypes.RECEIVED_PREFERENCES, + data: preferences + }, + { + type: PreferencesTypes.SAVE_PREFERENCES_SUCCESS + } + ]), getState); + }; +} + +export function deletePreferences(preferences) { + return async (dispatch, getState) => { + dispatch({type: PreferencesTypes.DELETE_PREFERENCES_REQUEST}, getState); + + try { + await Client.deletePreferences(preferences); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: PreferencesTypes.DELETE_PREFERENCES_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: PreferencesTypes.DELETED_PREFERENCES, + data: preferences + }, + { + type: PreferencesTypes.DELETE_PREFERENCES_SUCCESS + } + ]), getState); + }; +} + +export function makeDirectChannelVisibleIfNecessary(otherUserId) { + return async (dispatch, getState) => { + const state = getState(); + const myPreferences = getMyPreferencesSelector(state); + const currentUserId = getCurrentUserId(state); + + let preference = myPreferences[getPreferenceKey(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUserId)]; + + if (!preference || preference.value === 'false') { + preference = { + user_id: currentUserId, + category: Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, + name: otherUserId, + value: 'true' + }; + + await savePreferences([preference])(dispatch, getState); + } + }; +} diff --git a/src/actions/teams.js b/src/actions/teams.js new file mode 100644 index 000000000..48ecd8d24 --- /dev/null +++ b/src/actions/teams.js @@ -0,0 +1,253 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {batchActions} from 'redux-batched-actions'; +import Client from 'client'; +import {Constants, TeamsTypes} from 'constants'; +import {getLogErrorAction} from './errors'; +import {bindClientFunc, forceLogoutIfNecessary} from './helpers'; +import {getProfilesByIds, getStatusesByIds} from './users'; + +async function getProfilesAndStatusesForMembers(userIds, dispatch, getState) { + const {profiles, statuses} = getState().entities.users; + const profilesToLoad = []; + const statusesToLoad = []; + + userIds.forEach((userId) => { + if (!profiles[userId]) { + profilesToLoad.push(userId); + } + + if (!statuses[userId]) { + statusesToLoad.push(userId); + } + }); + + if (profilesToLoad.length) { + await getProfilesByIds(profilesToLoad)(dispatch, getState); + } + + if (statusesToLoad.length) { + await getStatusesByIds(statusesToLoad)(dispatch, getState); + } +} + +export function selectTeam(team) { + return async (dispatch, getState) => dispatch({ + type: TeamsTypes.SELECT_TEAM, + data: team.id + }, getState); +} + +export function fetchTeams() { + return bindClientFunc( + Client.getAllTeams, + TeamsTypes.FETCH_TEAMS_REQUEST, + [TeamsTypes.RECEIVED_ALL_TEAMS, TeamsTypes.FETCH_TEAMS_SUCCESS], + TeamsTypes.FETCH_TEAMS_FAILURE + ); +} + +export function getAllTeamListings() { + return bindClientFunc( + Client.getAllTeamListings, + TeamsTypes.TEAM_LISTINGS_REQUEST, + [TeamsTypes.RECEIVED_TEAM_LISTINGS, TeamsTypes.TEAM_LISTINGS_SUCCESS], + TeamsTypes.TEAM_LISTINGS_FAILURE + ); +} + +export function createTeam(userId, team) { + return async (dispatch, getState) => { + dispatch({type: TeamsTypes.CREATE_TEAM_REQUEST}, getState); + + let created; + try { + created = await Client.createTeam(team); + } catch (err) { + forceLogoutIfNecessary(err, dispatch); + dispatch(batchActions([ + {type: TeamsTypes.CREATE_TEAM_FAILURE, error: err}, + getLogErrorAction(err) + ]), getState); + return; + } + + const member = { + team_id: created.id, + user_id: userId, + roles: `${Constants.TEAM_ADMIN_ROLE} ${Constants.TEAM_USER_ROLE}`, + delete_at: 0, + msg_count: 0, + mention_count: 0 + }; + + dispatch(batchActions([ + { + type: TeamsTypes.CREATED_TEAM, + data: created + }, + { + type: TeamsTypes.RECEIVED_MY_TEAM_MEMBERS, + data: [member] + }, + { + type: TeamsTypes.SELECT_TEAM, + data: created.id + }, + { + type: TeamsTypes.CREATE_TEAM_SUCCESS + } + ]), getState); + }; +} + +export function updateTeam(team) { + return bindClientFunc( + Client.updateTeam, + TeamsTypes.UPDATE_TEAM_REQUEST, + [TeamsTypes.UPDATED_TEAM, TeamsTypes.UPDATE_TEAM_SUCCESS], + TeamsTypes.UPDATE_TEAM_FAILURE, + team + ); +} + +export function getMyTeamMembers() { + return bindClientFunc( + Client.getMyTeamMembers, + TeamsTypes.MY_TEAM_MEMBERS_REQUEST, + [TeamsTypes.RECEIVED_MY_TEAM_MEMBERS, TeamsTypes.MY_TEAM_MEMBERS_SUCCESS], + TeamsTypes.MY_TEAM_MEMBERS_FAILURE + ); +} + +export function getTeamMember(teamId, userId) { + return async (dispatch, getState) => { + dispatch({type: TeamsTypes.TEAM_MEMBERS_REQUEST}, getState); + + let member; + try { + member = await Client.getTeamMember(teamId, userId); + getProfilesAndStatusesForMembers([userId], dispatch, getState); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: TeamsTypes.TEAM_MEMBERS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: TeamsTypes.RECEIVED_MEMBERS_IN_TEAM, + data: [member] + }, + { + type: TeamsTypes.TEAM_MEMBERS_SUCCESS + } + ]), getState); + }; +} + +export function getTeamMembersByIds(teamId, userIds) { + return async (dispatch, getState) => { + dispatch({type: TeamsTypes.TEAM_MEMBERS_REQUEST}, getState); + + let members; + try { + members = await Client.getTeamMemberByIds(teamId, userIds); + getProfilesAndStatusesForMembers(userIds, dispatch, getState); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: TeamsTypes.TEAM_MEMBERS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + } + + dispatch(batchActions([ + { + type: TeamsTypes.RECEIVED_MEMBERS_IN_TEAM, + data: members + }, + { + type: TeamsTypes.TEAM_MEMBERS_SUCCESS + } + ]), getState); + }; +} + +export function getTeamStats(teamId) { + return bindClientFunc( + Client.getTeamStats, + TeamsTypes.TEAM_STATS_REQUEST, + [TeamsTypes.RECEIVED_TEAM_STATS, TeamsTypes.TEAM_STATS_SUCCESS], + TeamsTypes.TEAM_STATS_FAILURE, + teamId + ); +} + +export function addUserToTeam(teamId, userId) { + return async (dispatch, getState) => { + dispatch({type: TeamsTypes.ADD_TEAM_MEMBER_REQUEST}, getState); + + try { + await Client.addUserToTeam(teamId, userId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: TeamsTypes.ADD_TEAM_MEMBER_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + const member = { + team_id: teamId, + user_id: userId + }; + + dispatch(batchActions([ + { + type: TeamsTypes.RECEIVED_MEMBER_IN_TEAM, + data: member + }, + { + type: TeamsTypes.ADD_TEAM_MEMBER_SUCCESS + } + ]), getState); + }; +} + +export function removeUserFromTeam(teamId, userId) { + return async (dispatch, getState) => { + dispatch({type: TeamsTypes.REMOVE_TEAM_MEMBER_REQUEST}, getState); + + try { + await Client.removeUserFromTeam(teamId, userId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: TeamsTypes.REMOVE_TEAM_MEMBER_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + const member = { + team_id: teamId, + user_id: userId + }; + + dispatch(batchActions([ + { + type: TeamsTypes.REMOVE_MEMBER_FROM_TEAM, + data: member + }, + { + type: TeamsTypes.REMOVE_TEAM_MEMBER_SUCCESS + } + ]), getState); + }; +} diff --git a/src/actions/users.js b/src/actions/users.js new file mode 100644 index 000000000..5ea8bee1f --- /dev/null +++ b/src/actions/users.js @@ -0,0 +1,488 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {batchActions} from 'redux-batched-actions'; +import Client from 'client'; +import {Constants, PreferencesTypes, UsersTypes, TeamsTypes} from 'constants'; +import {fetchTeams} from './teams'; +import {getLogErrorAction} from './errors'; +import {bindClientFunc, forceLogoutIfNecessary, debounce} from './helpers'; + +export function checkMfa(loginId) { + return async (dispatch, getState) => { + dispatch({type: UsersTypes.CHECK_MFA_REQUEST}, getState); + try { + const mfa = await Client.checkMfa(loginId); + dispatch({type: UsersTypes.CHECK_MFA_SUCCESS}, getState); + return mfa; + } catch (error) { + dispatch(batchActions([ + {type: UsersTypes.CHECK_MFA_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return null; + } + }; +} + +export function login(loginId, password, mfaToken = '') { + return async (dispatch, getState) => { + dispatch({type: UsersTypes.LOGIN_REQUEST}, getState); + + const deviceId = getState().entities.general.deviceToken; + + return Client.login(loginId, password, mfaToken, deviceId). + then(async (data) => { + let teamMembers; + let preferences; + try { + const teamMembersRequest = Client.getMyTeamMembers(); + const preferencesRequest = Client.getMyPreferences(); + + teamMembers = await teamMembersRequest; + preferences = await preferencesRequest; + } catch (error) { + dispatch(batchActions([ + {type: UsersTypes.LOGIN_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + try { + await fetchTeams()(dispatch, getState); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: UsersTypes.LOGIN_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: UsersTypes.RECEIVED_ME, + data + }, + { + type: PreferencesTypes.RECEIVED_PREFERENCES, + data: await preferences + }, + { + type: TeamsTypes.RECEIVED_MY_TEAM_MEMBERS, + data: await teamMembers + }, + { + type: UsersTypes.LOGIN_SUCCESS + } + ]), getState); + }). + catch((error) => { + dispatch(batchActions([ + {type: UsersTypes.LOGIN_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + }); + }; +} + +export function loadMe() { + return async (dispatch, getState) => { + let user; + dispatch({type: UsersTypes.LOGIN_REQUEST}, getState); + try { + user = await Client.getMe(); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: UsersTypes.LOGIN_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + const deviceId = getState().entities.general.deviceToken; + if (deviceId) { + Client.attachDevice(deviceId); + } + + let preferences; + dispatch({type: PreferencesTypes.MY_PREFERENCES_REQUEST}, getState); + try { + preferences = await Client.getMyPreferences(); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: PreferencesTypes.MY_PREFERENCES_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + try { + await fetchTeams()(dispatch, getState); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: TeamsTypes.FETCH_TEAMS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + let teamMembers; + dispatch({type: TeamsTypes.MY_TEAM_MEMBERS_REQUEST}, getState); + try { + teamMembers = await Client.getMyTeamMembers(); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: TeamsTypes.MY_TEAM_MEMBERS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: UsersTypes.RECEIVED_ME, + data: user + }, + { + type: UsersTypes.LOGIN_SUCCESS + }, + { + type: PreferencesTypes.RECEIVED_PREFERENCES, + data: preferences + }, + { + type: PreferencesTypes.MY_PREFERENCES_SUCCESS + }, + { + type: TeamsTypes.RECEIVED_MY_TEAM_MEMBERS, + data: teamMembers + }, + { + type: TeamsTypes.MY_TEAM_MEMBERS_SUCCESS + } + ]), getState); + }; +} + +export function logout() { + return bindClientFunc( + Client.logout, + UsersTypes.LOGOUT_REQUEST, + UsersTypes.LOGOUT_SUCCESS, + UsersTypes.LOGOUT_FAILURE, + ); +} + +export function getProfiles(offset, limit = Constants.PROFILE_CHUNK_SIZE) { + return async (dispatch, getState) => { + dispatch({type: UsersTypes.PROFILES_REQUEST}, getState); + + let profiles; + try { + profiles = await Client.getProfiles(offset, limit); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: UsersTypes.PROFILES_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return null; + } + + dispatch(batchActions([ + { + type: UsersTypes.RECEIVED_PROFILES, + data: profiles + }, + { + type: UsersTypes.PROFILES_SUCCESS + } + ]), getState); + + return profiles; + }; +} + +export function getProfilesByIds(userIds) { + return bindClientFunc( + Client.getProfilesByIds, + UsersTypes.PROFILES_REQUEST, + [UsersTypes.RECEIVED_PROFILES, UsersTypes.PROFILES_SUCCESS], + UsersTypes.PROFILES_FAILURE, + userIds + ); +} + +export function getProfilesInTeam(teamId, offset, limit = Constants.PROFILE_CHUNK_SIZE) { + return async (dispatch, getState) => { + dispatch({type: UsersTypes.PROFILES_IN_TEAM_REQUEST}, getState); + + let profiles; + try { + profiles = await Client.getProfilesInTeam(teamId, offset, limit); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: UsersTypes.PROFILES_IN_TEAM_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: UsersTypes.RECEIVED_PROFILES_IN_TEAM, + data: profiles, + id: teamId + }, + { + type: UsersTypes.RECEIVED_PROFILES, + data: profiles + }, + { + type: UsersTypes.PROFILES_IN_TEAM_SUCCESS + } + ]), getState); + }; +} + +export function getProfilesInChannel(teamId, channelId, offset, limit = Constants.PROFILE_CHUNK_SIZE) { + return async (dispatch, getState) => { + dispatch({type: UsersTypes.PROFILES_IN_CHANNEL_REQUEST}, getState); + + let profiles; + try { + profiles = await Client.getProfilesInChannel(teamId, channelId, offset, limit); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: UsersTypes.PROFILES_IN_CHANNEL_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: UsersTypes.RECEIVED_PROFILES_IN_CHANNEL, + data: profiles, + id: channelId + }, + { + type: UsersTypes.RECEIVED_PROFILES, + data: profiles + }, + { + type: UsersTypes.PROFILES_IN_CHANNEL_SUCCESS + } + ]), getState); + }; +} + +export function getProfilesNotInChannel(teamId, channelId, offset, limit = Constants.PROFILE_CHUNK_SIZE) { + return async (dispatch, getState) => { + dispatch({type: UsersTypes.PROFILES_NOT_IN_CHANNEL_REQUEST}, getState); + + let profiles; + try { + profiles = await Client.getProfilesNotInChannel(teamId, channelId, offset, limit); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: UsersTypes.PROFILES_NOT_IN_CHANNEL_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: UsersTypes.RECEIVED_PROFILES_NOT_IN_CHANNEL, + data: profiles, + id: channelId + }, + { + type: UsersTypes.RECEIVED_PROFILES, + data: profiles + }, + { + type: UsersTypes.PROFILES_NOT_IN_CHANNEL_SUCCESS + } + ]), getState); + }; +} + +// We create an array to hold the id's that we want to get a status for. We build our +// debounced function that will get called after a set period of idle time in which +// the array of id's will be passed to the getStatusesByIds with a cb that clears out +// the array. Helps with performance because instead of making 75 different calls for +// statuses, we are only making one call for 75 ids. +// We could maybe clean it up somewhat by storing the array of ids in redux state possbily? +let ids = []; +const debouncedGetStatusesByIds = debounce(async (dispatch, getState) => { + getStatusesByIds([...new Set(ids)])(dispatch, getState); +}, 20, false, () => { + ids = []; +}); +export function getStatusesByIdsBatchedDebounced(id) { + ids = [...ids, id]; + return debouncedGetStatusesByIds; +} + +export function getStatusesByIds(userIds) { + return bindClientFunc( + Client.getStatusesByIds, + UsersTypes.PROFILES_STATUSES_REQUEST, + [UsersTypes.RECEIVED_STATUSES, UsersTypes.PROFILES_STATUSES_SUCCESS], + UsersTypes.PROFILES_STATUSES_FAILURE, + userIds + ); +} + +export function getSessions(userId) { + return bindClientFunc( + Client.getSessions, + UsersTypes.SESSIONS_REQUEST, + [UsersTypes.RECEIVED_SESSIONS, UsersTypes.SESSIONS_SUCCESS], + UsersTypes.SESSIONS_FAILURE, + userId + ); +} + +export function revokeSession(id) { + return bindClientFunc( + Client.revokeSession, + UsersTypes.REVOKE_SESSION_REQUEST, + [UsersTypes.RECEIVED_REVOKED_SESSION, UsersTypes.REVOKE_SESSION_SUCCESS], + UsersTypes.REVOKE_SESSION_FAILURE, + id + ); +} + +export function getAudits(userId) { + return bindClientFunc( + Client.getAudits, + UsersTypes.AUDITS_REQUEST, + [UsersTypes.RECEIVED_AUDITS, UsersTypes.AUDITS_SUCCESS], + UsersTypes.AUDITS_FAILURE, + userId + ); +} + +export function autocompleteUsersInChannel(teamId, channelId, term) { + return async (dispatch, getState) => { + dispatch({type: UsersTypes.AUTOCOMPLETE_IN_CHANNEL_REQUEST}, getState); + + let data; + try { + data = await Client.autocompleteUsersInChannel(teamId, channelId, term); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: UsersTypes.AUTOCOMPLETE_IN_CHANNEL_FAILURE, error}, + getLogErrorAction(error) + ]), getState); + return; + } + + dispatch(batchActions([ + { + type: UsersTypes.RECEIVED_AUTOCOMPLETE_IN_CHANNEL, + data, + channelId + }, + { + type: UsersTypes.AUTOCOMPLETE_IN_CHANNEL_SUCCESS + } + ]), getState); + }; +} + +export function searchProfiles(term, options) { + return bindClientFunc( + Client.searchProfiles, + UsersTypes.SEARCH_PROFILES_REQUEST, + [UsersTypes.RECEIVED_SEARCH_PROFILES, UsersTypes.SEARCH_PROFILES_SUCCESS], + UsersTypes.SEARCH_PROFILES_FAILURE, + term, + options + ); +} + +let statusIntervalId = ''; +export function startPeriodicStatusUpdates() { + return async (dispatch, getState) => { + clearInterval(statusIntervalId); + + statusIntervalId = setInterval( + () => { + const {statuses} = getState().entities.users; + + if (!statuses) { + return; + } + + const userIds = Object.keys(statuses); + if (!userIds.length) { + return; + } + + getStatusesByIds(userIds)(dispatch, getState); + }, + Constants.STATUS_INTERVAL + ); + }; +} + +export function stopPeriodicStatusUpdates() { + return async () => { + if (statusIntervalId) { + clearInterval(statusIntervalId); + } + }; +} + +export function updateUserNotifyProps(notifyProps) { + return async (dispatch, getState) => { + dispatch({type: UsersTypes.UPDATE_NOTIFY_PROPS_REQUEST}, getState); + + let data; + try { + data = await Client.updateUserNotifyProps(notifyProps); + } catch (error) { + dispatch({type: UsersTypes.UPDATE_NOTIFY_PROPS_FAILURE, error}, getState); + return; + } + + dispatch(batchActions([ + {type: UsersTypes.RECEIVED_ME, data}, + {type: UsersTypes.UPDATE_NOTIFY_PROPS_SUCCESS} + ]), getState); + }; +} + +export default { + checkMfa, + login, + logout, + getProfiles, + getProfilesByIds, + getProfilesInTeam, + getProfilesInChannel, + getProfilesNotInChannel, + getStatusesByIds, + getSessions, + revokeSession, + getAudits, + searchProfiles, + startPeriodicStatusUpdates, + stopPeriodicStatusUpdates, + updateUserNotifyProps +}; diff --git a/src/actions/websocket.js b/src/actions/websocket.js new file mode 100644 index 000000000..a15bec533 --- /dev/null +++ b/src/actions/websocket.js @@ -0,0 +1,489 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {batchActions} from 'redux-batched-actions'; + +import Client from 'client'; +import websocketClient from 'client/websocket_client'; +import {getProfilesByIds, getStatusesByIds} from './users'; +import { + fetchMyChannelsAndMembers, + getChannel, + getChannelStats, + updateChannelHeader, + updateChannelPurpose, + markChannelAsUnread, + markChannelAsRead +} from './channels'; +import { + getPosts, + getPostsSince +} from './posts'; +import {makeDirectChannelVisibleIfNecessary} from './preferences'; +import { + Constants, + ChannelTypes, + GeneralTypes, + PostsTypes, + PreferencesTypes, + TeamsTypes, + UsersTypes, + WebsocketEvents +} from 'constants'; +import {getCurrentChannelStats} from 'selectors/entities/channels'; +import {getUserIdFromChannelName} from 'utils/channel_utils'; +import {isSystemMessage, shouldIgnorePost} from 'utils/post_utils'; +import EventEmitter from 'utils/event_emitter'; + +export function init(platform, siteUrl, token, optionalWebSocket) { + return async (dispatch, getState) => { + const config = getState().entities.general.config; + let connUrl = siteUrl || Client.getUrl(); + const authToken = token || Client.getToken(); + + // replace the protocol with a websocket one + if (connUrl.startsWith('https:')) { + connUrl = connUrl.replace(/^https:/, 'wss:'); + } else { + connUrl = connUrl.replace(/^http:/, 'ws:'); + } + + // append a port number if one isn't already specified + if (!(/:\d+$/).test(connUrl)) { + if (connUrl.startsWith('wss:')) { + connUrl += ':' + (config.WebsocketSecurePort || 443); + } else { + connUrl += ':' + (config.WebsocketPort || 80); + } + } + + connUrl += `${Client.getUrlVersion()}/users/websocket`; + websocketClient.setFirstConnectCallback(handleFirstConnect); + websocketClient.setEventCallback(handleEvent); + websocketClient.setReconnectCallback(handleReconnect); + websocketClient.setCloseCallback(handleClose); + websocketClient.setConnectingCallback(handleConnecting); + + const websocketOpts = { + connectionUrl: connUrl, + platform + }; + + if (optionalWebSocket) { + websocketOpts.webSocketConnector = optionalWebSocket; + } + + return websocketClient.initialize(authToken, dispatch, getState, websocketOpts); + }; +} + +export function close() { + return async (dispatch, getState) => { + websocketClient.close(true); + if (dispatch) { + dispatch({type: GeneralTypes.WEBSOCKET_FAILURE, error: 'Closed'}, getState); + } + }; +} + +function handleConnecting(dispatch, getState) { + dispatch({type: GeneralTypes.WEBSOCKET_REQUEST}, getState); +} + +function handleFirstConnect(dispatch, getState) { + dispatch({type: GeneralTypes.WEBSOCKET_SUCCESS}, getState); +} + +function handleReconnect(dispatch, getState) { + const entities = getState().entities; + const {currentTeamId} = entities.teams; + const {currentChannelId} = entities.channels; + + if (currentTeamId) { + fetchMyChannelsAndMembers(currentTeamId)(dispatch, getState); + + if (currentChannelId) { + loadPostsHelper(currentTeamId, currentChannelId, dispatch, getState); + } + } + + dispatch({type: GeneralTypes.WEBSOCKET_SUCCESS}, getState); +} + +function handleClose(connectFailCount, dispatch, getState) { + dispatch({type: GeneralTypes.WEBSOCKET_FAILURE, error: connectFailCount}, getState); +} + +function handleEvent(msg, dispatch, getState) { + switch (msg.event) { + case WebsocketEvents.POSTED: + case WebsocketEvents.EPHEMERAL_MESSAGE: + handleNewPostEvent(msg, dispatch, getState); + break; + case WebsocketEvents.POST_EDITED: + handlePostEdited(msg, dispatch, getState); + break; + case WebsocketEvents.POST_DELETED: + handlePostDeleted(msg, dispatch, getState); + break; + case WebsocketEvents.LEAVE_TEAM: + handleLeaveTeamEvent(msg, dispatch, getState); + break; + case WebsocketEvents.USER_ADDED: + handleUserAddedEvent(msg, dispatch, getState); + break; + case WebsocketEvents.USER_REMOVED: + handleUserRemovedEvent(msg, dispatch, getState); + break; + case WebsocketEvents.USER_UPDATED: + handleUserUpdatedEvent(msg, dispatch, getState); + break; + case WebsocketEvents.CHANNEL_CREATED: + handleChannelCreatedEvent(msg, dispatch, getState); + break; + case WebsocketEvents.CHANNEL_DELETED: + handleChannelDeletedEvent(msg, dispatch, getState); + break; + case WebsocketEvents.DIRECT_ADDED: + handleDirectAddedEvent(msg, dispatch, getState); + break; + case WebsocketEvents.PREFERENCE_CHANGED: + handlePreferenceChangedEvent(msg, dispatch, getState); + break; + case WebsocketEvents.STATUS_CHANGED: + handleStatusChangedEvent(msg, dispatch, getState); + break; + case WebsocketEvents.TYPING: + handleUserTypingEvent(msg, dispatch, getState); + break; + case WebsocketEvents.HELLO: + handleHelloEvent(msg); + break; + } +} + +async function handleNewPostEvent(msg, dispatch, getState) { + const state = getState(); + const {currentChannelId} = state.entities.channels; + const users = state.entities.users; + const {posts} = state.entities.posts; + const post = JSON.parse(msg.data.post); + const userId = post.user_id; + const teamId = msg.data.team_id; + const status = users.statuses[userId]; + + if (!users.profiles[userId]) { + getProfilesByIds([userId])(dispatch, getState); + } + + if (status !== Constants.ONLINE) { + getStatusesByIds([userId])(dispatch, getState); + } + + switch (post.type) { + case Constants.POST_HEADER_CHANGE: + updateChannelHeader(post.channel_id, post.props.new_header)(dispatch, getState); + break; + case Constants.POST_PURPOSE_CHANGE: + updateChannelPurpose(post.channel_id, post.props.new_purpose)(dispatch, getState); + break; + } + + if (msg.data.channel_type === Constants.DM_CHANNEL) { + const otherUserId = getUserIdFromChannelName(users.currentUserId, msg.data.channel_name); + + makeDirectChannelVisibleIfNecessary(otherUserId)(dispatch, getState); + } + + if (post.root_id && !posts[post.root_id]) { + await Client.getPost(teamId, post.channel_id, post.root_id).then((data) => { + const rootUserId = data.posts[post.root_id].user_id; + const rootStatus = users.statuses[rootUserId]; + if (!users.profiles[rootUserId]) { + getProfilesByIds([rootUserId])(dispatch, getState); + } + + if (rootStatus !== Constants.ONLINE) { + getStatusesByIds([rootUserId])(dispatch, getState); + } + + dispatch({ + type: PostsTypes.RECEIVED_POSTS, + data, + channelId: post.channel_id + }, getState); + }); + } + + dispatch(batchActions([ + { + type: PostsTypes.RECEIVED_POSTS, + data: { + order: [], + posts: { + [post.id]: post + } + }, + channelId: post.channel_id + }, + { + type: WebsocketEvents.STOP_TYPING, + data: { + id: post.channel_id + post.root_id, + userId: post.user_id + } + } + ]), getState); + + if (shouldIgnorePost(post)) { + // if the post type is in the ignore list we'll do nothing with the read state + return; + } + + let markAsRead = false; + if (userId === users.currentUserId && !isSystemMessage(post)) { + // In case the current user posted the message and that message wasn't triggered by a system message + markAsRead = true; + } else if (post.channel_id === currentChannelId) { + // if the post is for the channel that the user is currently viewing we'll mark the channel as read + markAsRead = true; + } + + if (markAsRead) { + markChannelAsRead(post.channel_id)(dispatch, getState); + } else { + markChannelAsUnread(post.channel_id, msg.data.mentions)(dispatch, getState); + } +} + +function handlePostEdited(msg, dispatch, getState) { + const data = JSON.parse(msg.data.post); + + dispatch({type: PostsTypes.RECEIVED_POST, data}, getState); +} + +function handlePostDeleted(msg, dispatch, getState) { + const data = JSON.parse(msg.data.post); + dispatch({type: PostsTypes.POST_DELETED, data}, getState); +} + +function handleLeaveTeamEvent(msg, dispatch, getState) { + const entities = getState().entities; + const {currentTeamId, teams} = entities.teams; + const {currentUserId} = entities.users; + + if (currentUserId === msg.data.user_id) { + dispatch({type: TeamsTypes.LEAVE_TEAM, data: teams[msg.data.team_id]}, getState); + + // if they are on the team being removed deselect the current team and channel + if (currentTeamId === msg.data.team_id) { + EventEmitter.emit('leave_team'); + } + } +} + +function handleUserAddedEvent(msg, dispatch, getState) { + const state = getState(); + const {currentChannelId} = state.entities.channels; + const {currentTeamId} = state.entities.teams; + const {currentUserId} = state.entities.users; + const teamId = msg.data.team_id; + + if (msg.broadcast.channel_id === currentChannelId) { + getChannelStats(teamId, currentChannelId)(dispatch, getState); + } + + if (teamId === currentTeamId && msg.data.user_id === currentUserId) { + getChannel(teamId, msg.broadcast.channel_id)(dispatch, getState); + } +} + +function handleUserRemovedEvent(msg, dispatch, getState) { + const state = getState(); + const {currentChannelId} = state.entities.channels; + const {currentTeamId} = state.entities.teams; + const {currentUserId} = state.entities.users; + + if (msg.broadcast.user_id === currentUserId && currentTeamId) { + fetchMyChannelsAndMembers(currentTeamId)(dispatch, getState); + dispatch({ + type: ChannelTypes.LEAVE_CHANNEL, + data: msg.data.channel_id + }, getState); + } else if (msg.broadcast.channel_id === currentChannelId) { + getChannelStats(currentTeamId, currentChannelId)(dispatch, getState); + } +} + +function handleUserUpdatedEvent(msg, dispatch, getState) { + const entities = getState().entities; + const {currentUserId} = entities.users; + const user = msg.data.user; + + if (user.id !== currentUserId) { + dispatch({ + type: UsersTypes.RECEIVED_PROFILES, + data: { + [user.id]: user + } + }, getState); + } +} + +function handleChannelCreatedEvent(msg, dispatch, getState) { + const {channel_id: channelId, team_id: teamId} = msg.data; + const state = getState(); + const {channels} = state.entities.channels; + const {currentTeamId} = state.entities.teams; + + if (teamId === currentTeamId && !channels[channelId]) { + getChannel(teamId, channelId)(dispatch, getState); + } +} + +function handleChannelDeletedEvent(msg, dispatch, getState) { + const entities = getState().entities; + const {channels, currentChannelId} = entities.channels; + const {currentTeamId} = entities.teams; + + if (msg.broadcast.team_id === currentTeamId) { + if (msg.data.channel_id === currentChannelId) { + let channelId = ''; + const channel = Object.keys(channels).filter((key) => channels[key].name === Constants.DEFAULT_CHANNEL); + + if (channel.length) { + channelId = channel[0]; + } + + dispatch({type: ChannelTypes.SELECT_CHANNEL, data: channelId}, getState); + } + dispatch({type: ChannelTypes.RECEIVED_CHANNEL_DELETED, data: msg.data.channel_id}, getState); + + fetchMyChannelsAndMembers(currentTeamId)(dispatch, getState); + } +} + +function handleDirectAddedEvent(msg, dispatch, getState) { + const state = getState(); + const {currentTeamId} = state.entities.teams; + + getChannel(currentTeamId, msg.broadcast.channel_id)(dispatch, getState); +} + +function handlePreferenceChangedEvent(msg, dispatch, getState) { + const preference = JSON.parse(msg.data.preference); + dispatch({type: PreferencesTypes.RECEIVED_PREFERENCES, data: [preference]}, getState); + + if (preference.category === Constants.CATEGORY_DIRECT_CHANNEL_SHOW) { + const state = getState(); + const users = state.entities.users; + const userId = preference.name; + const status = users.statuses[userId]; + + if (!users.profiles[userId]) { + getProfilesByIds([userId])(dispatch, getState); + } + + if (status !== Constants.ONLINE) { + getStatusesByIds([userId])(dispatch, getState); + } + } +} + +function handleStatusChangedEvent(msg, dispatch, getState) { + dispatch({ + type: UsersTypes.RECEIVED_STATUSES, + data: { + [msg.data.user_id]: msg.data.status + } + }, getState); +} + +function handleHelloEvent(msg) { + const serverVersion = msg.data.server_version; + if (serverVersion && Client.serverVersion !== serverVersion) { + Client.serverVersion = serverVersion; + EventEmitter.emit(Constants.CONFIG_CHANGED, serverVersion); + } +} + +const typingUsers = {}; +function handleUserTypingEvent(msg, dispatch, getState) { + const state = getState(); + const {profiles, statuses} = state.entities.users; + const {config} = state.entities.general; + const userId = msg.data.user_id; + const id = msg.broadcast.channel_id + msg.data.parent_id; + const data = {id, userId}; + + // Create entry + if (!typingUsers[id]) { + typingUsers[id] = {}; + } + + // If we already have this user, clear it's timeout to be deleted + if (typingUsers[id][userId]) { + clearTimeout(typingUsers[id][userId].timeout); + } + + // Set the user and a timeout to remove it + typingUsers[id][userId] = setTimeout(() => { + Reflect.deleteProperty(typingUsers[id], userId); + if (typingUsers[id] === {}) { + Reflect.deleteProperty(typingUsers, id); + } + dispatch({ + type: WebsocketEvents.STOP_TYPING, + data + }, getState); + }, parseInt(config.TimeBetweenUserTypingUpdatesMilliseconds, 10)); + + dispatch({ + type: WebsocketEvents.TYPING, + data + }, getState); + + if (!profiles[userId]) { + getProfilesByIds([userId])(dispatch, getState); + } + + const status = statuses[userId]; + if (status !== Constants.ONLINE) { + getStatusesByIds([userId])(dispatch, getState); + } +} + +// Helpers + +function loadPostsHelper(teamId, channelId, dispatch, getState) { + const {posts, postsByChannel} = getState().entities.posts; + const postsArray = postsByChannel[channelId]; + const latestPostId = postsArray[postsArray.length - 1]; + + let latestPostTime = 0; + if (latestPostId) { + latestPostTime = posts[latestPostId].create_at || 0; + } + + if (Object.keys(posts).length === 0 || postsArray.length < Constants.POST_CHUNK_SIZE || latestPostTime === 0) { + getPosts(teamId, channelId)(dispatch, getState); + } else { + getPostsSince(teamId, channelId, latestPostTime)(dispatch, getState); + } +} + +let lastTimeTypingSent = 0; +export function userTyping(channelId, parentPostId) { + return async (dispatch, getState) => { + const state = getState(); + const config = state.entities.general.config; + const t = Date.now(); + const membersInChannel = getCurrentChannelStats(state).member_count; + + if (((t - lastTimeTypingSent) > config.TimeBetweenUserTypingUpdatesMilliseconds) && + (membersInChannel < config.MaxNotificationsPerChannel) && (config.EnableUserTypingMessages === 'true')) { + websocketClient.userTyping(channelId, parentPostId); + lastTimeTypingSent = t; + } + }; +} diff --git a/src/client/client.js b/src/client/client.js new file mode 100644 index 000000000..49de07384 --- /dev/null +++ b/src/client/client.js @@ -0,0 +1,803 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import EventEmitter from 'utils/event_emitter'; +import {Constants} from 'constants'; + +import fetch from './fetch_etag'; + +const HEADER_AUTH = 'Authorization'; +const HEADER_BEARER = 'BEARER'; +const HEADER_REQUESTED_WITH = 'X-Requested-With'; +const HEADER_TOKEN = 'Token'; +const HEADER_X_VERSION_ID = 'X-Version-Id'; +const HEADER_USER_AGENT = 'User-Agent'; + +export default class Client { + constructor() { + this.logToConsole = false; + this.serverVersion = ''; + this.token = ''; + this.url = ''; + this.urlVersion = '/api/v3'; + this.userAgent = null; + + this.translations = { + connectionError: 'There appears to be a problem with your internet connection.', + unknownError: 'We received an unexpected status code from the server.' + }; + } + + getUrl() { + return this.url; + } + + setUrl(url) { + this.url = url; + } + + setUserAgent(userAgent) { + this.userAgent = userAgent; + } + + getToken() { + return this.token; + } + + setToken(token) { + this.token = token; + } + + getServerVersion() { + return this.serverVersion; + } + + getUrlVersion() { + return this.urlVersion; + } + + getBaseRoute() { + return `${this.url}${this.urlVersion}`; + } + + getAdminRoute() { + return `${this.url}${this.urlVersion}/admin`; + } + + getGeneralRoute() { + return `${this.url}${this.urlVersion}/general`; + } + + getLicenseRoute() { + return `${this.url}${this.urlVersion}/license`; + } + + getTeamsRoute() { + return `${this.url}${this.urlVersion}/teams`; + } + + getPreferencesRoute() { + return `${this.url}${this.urlVersion}/preferences`; + } + + getTeamNeededRoute(teamId) { + return `${this.url}${this.urlVersion}/teams/${teamId}`; + } + + getChannelsRoute(teamId) { + return `${this.url}${this.urlVersion}/teams/${teamId}/channels`; + } + + getChannelNameRoute(teamId, channelName) { + return `${this.url}${this.urlVersion}/teams/${teamId}/channels/name/${channelName}`; + } + + getChannelNeededRoute(teamId, channelId) { + return `${this.url}${this.urlVersion}/teams/${teamId}/channels/${channelId}`; + } + + getCommandsRoute(teamId) { + return `${this.url}${this.urlVersion}/teams/${teamId}/commands`; + } + + getEmojiRoute() { + return `${this.url}${this.urlVersion}/emoji`; + } + + getHooksRoute(teamId) { + return `${this.url}${this.urlVersion}/teams/${teamId}/hooks`; + } + + getPostsRoute(teamId, channelId) { + return `${this.url}${this.urlVersion}/teams/${teamId}/channels/${channelId}/posts`; + } + + getUsersRoute() { + return `${this.url}${this.urlVersion}/users`; + } + + getFilesRoute() { + return `${this.url}${this.urlVersion}/files`; + } + + getOAuthRoute() { + return `${this.url}${this.urlVersion}/oauth`; + } + + getUserNeededRoute(userId) { + return `${this.url}${this.urlVersion}/users/${userId}`; + } + + enableLogErrorsToConsole(enabled) { + this.logToConsole = enabled; + } + + getOptions(options) { + const headers = { + [HEADER_REQUESTED_WITH]: 'XMLHttpRequest' + }; + + if (this.token) { + headers[HEADER_AUTH] = `${HEADER_BEARER} ${this.token}`; + } + + if (this.userAgent) { + headers[HEADER_USER_AGENT] = this.userAgent; + } + + if (options.headers) { + Object.assign(headers, options.headers); + } + + return { + ...options, + headers + }; + } + + // General routes + + getClientConfig = async () => { + return this.doFetch( + `${this.getGeneralRoute()}/client_props`, + {method: 'get'} + ); + }; + + getLicenseConfig = async () => { + return this.doFetch( + `${this.getLicenseRoute()}/client_config`, + {method: 'get'} + ); + }; + + getPing = async () => { + return this.doFetch( + `${this.getGeneralRoute()}/ping`, + {method: 'get'} + ); + }; + + logClientError = async (message, level = 'ERROR') => { + const body = { + message, + level + }; + + return this.doFetch( + `${this.getGeneralRoute()}/log_client`, + {method: 'post', body} + ); + }; + + // User routes + createUser = async (user) => { + return this.createUserWithInvite(user); + }; + + // TODO: add deep linking to emails so we can create accounts from within + // the mobile app + createUserWithInvite = async(user, data, emailHash, inviteId) => { + let url = `${this.getUsersRoute()}/create`; + + url += '?d=' + encodeURIComponent(data); + + if (emailHash) { + url += '&h=' + encodeURIComponent(emailHash); + } + + if (inviteId) { + url += '&iid=' + encodeURIComponent(inviteId); + } + + return this.doFetch( + url, + {method: 'post', body: JSON.stringify(user)} + ); + }; + + checkMfa = async (loginId) => { + return this.doFetch( + `${this.getUsersRoute()}/mfa`, + {method: 'post', body: JSON.stringify({login_id: loginId})} + ); + }; + + login = async (loginId, password, token = '', deviceId = '') => { + const body = { + device_id: deviceId, + login_id: loginId, + password, + token + }; + + const {headers, data} = await this.doFetchWithResponse( + `${this.getUsersRoute()}/login`, + {method: 'post', body: JSON.stringify(body)} + ); + + if (headers.has(HEADER_TOKEN)) { + this.token = headers.get(HEADER_TOKEN); + } + + return data; + }; + + logout = async () => { + const {response} = await this.doFetchWithResponse( + `${this.getUsersRoute()}/logout`, + {method: 'post'} + ); + if (response.ok) { + this.token = ''; + } + this.serverVersion = ''; + return response; + }; + + attachDevice = async (deviceId) => { + return this.doFetch( + `${this.getUsersRoute()}/attach_device`, + {method: 'post', body: JSON.stringify({device_id: deviceId})} + ); + }; + + updateUser = async (user) => { + return this.doFetch( + `${this.getUsersRoute()}/update`, + {method: 'post', body: JSON.stringify(user)} + ); + }; + + updatePassword = async (userId, currentPassword, newPassword) => { + const data = { + user_id: userId, + current_password: currentPassword, + new_password: newPassword + }; + + return this.doFetch( + `${this.getUsersRoute()}/newpassword`, + {method: 'post', body: JSON.stringify(data)} + ); + }; + + updateUserNotifyProps = async (notifyProps) => { + return this.doFetch( + `${this.getUsersRoute()}/update_notify`, + {method: 'post', body: JSON.stringify(notifyProps)} + ); + }; + + updateUserRoles = async (userId, newRoles) => { + return this.doFetch( + `${this.getUserNeededRoute(userId)}/update_roles`, + {method: 'post', body: JSON.stringify({new_roles: newRoles})} + ); + }; + + getMe = async () => { + return this.doFetch( + `${this.getUsersRoute()}/me`, + {method: 'get'} + ); + }; + + getProfiles = async (offset, limit) => { + return this.doFetch( + `${this.getUsersRoute()}/${offset}/${limit}`, + {method: 'get'} + ); + }; + + getProfilesByIds = async (userIds) => { + return this.doFetch( + `${this.getUsersRoute()}/ids`, + {method: 'post', body: JSON.stringify(userIds)} + ); + }; + + getProfilesInTeam = async (teamId, offset, limit) => { + return this.doFetch( + `${this.getTeamNeededRoute(teamId)}/users/${offset}/${limit}`, + {method: 'get'} + ); + }; + + getProfilesInChannel = async (teamId, channelId, offset, limit) => { + return this.doFetch( + `${this.getChannelNeededRoute(teamId, channelId)}/users/${offset}/${limit}`, + {method: 'get'} + ); + }; + + getProfilesNotInChannel = async (teamId, channelId, offset, limit) => { + return this.doFetch( + `${this.getChannelNeededRoute(teamId, channelId)}/users/not_in_channel/${offset}/${limit}`, + {method: 'get'} + ); + }; + + getUser = async (userId) => { + return this.doFetch( + `${this.getUserNeededRoute(userId)}/get`, + {method: 'get'} + ); + }; + + getStatusesByIds = async (userIds) => { + return this.doFetch( + `${this.getUsersRoute()}/status/ids`, + {method: 'post', body: JSON.stringify(userIds)} + ); + }; + + getSessions = async (userId) => { + return this.doFetch( + `${this.getUserNeededRoute(userId)}/sessions`, + {method: 'get'} + ); + }; + + revokeSession = async (id) => { + return this.doFetch( + `${this.getUsersRoute()}/revoke_session`, + {method: 'post', body: JSON.stringify({id})} + ); + }; + + getAudits = async (userId) => { + return this.doFetch( + `${this.getUserNeededRoute(userId)}/audits`, + {method: 'get'} + ); + }; + + getProfilePictureUrl = (userId, lastPictureUpdate) => { + let params = ''; + if (lastPictureUpdate) { + params = `?time=${lastPictureUpdate}`; + } + + return `${this.getUsersRoute()}/${userId}/image${params}`; + }; + + autocompleteUsersInChannel = (teamId, channelId, term) => { + return this.doFetch( + `${this.getChannelNeededRoute(teamId, channelId)}/users/autocomplete?term=${encodeURIComponent(term)}`, + {method: 'get'} + ); + }; + + searchProfiles = (term, options) => { + return this.doFetch( + `${this.getUsersRoute()}/search`, + {method: 'post', body: JSON.stringify({term, ...options})} + ); + }; + + // Team routes + + createTeam = async (team) => { + return this.doFetch( + `${this.getTeamsRoute()}/create`, + {method: 'post', body: JSON.stringify(team)} + ); + }; + + updateTeam = async (team) => { + return this.doFetch( + `${this.getTeamNeededRoute(team.id)}/update`, + {method: 'post', body: JSON.stringify(team)} + ); + }; + + getAllTeams = async () => { + return this.doFetch( + `${this.getTeamsRoute()}/all`, + {method: 'get'} + ); + }; + + getMyTeamMembers = async () => { + return this.doFetch( + `${this.getTeamsRoute()}/members`, + {method: 'get'} + ); + }; + + getAllTeamListings = async () => { + return this.doFetch( + `${this.getTeamsRoute()}/all_team_listings`, + {method: 'get'} + ); + }; + + getTeamMember = async (teamId, userId) => { + return this.doFetch( + `${this.getTeamNeededRoute(teamId)}/members/${userId}`, + {method: 'get'} + ); + }; + + getTeamMemberByIds = async (teamId, userIds) => { + return this.doFetch( + `${this.getTeamNeededRoute(teamId)}/members/ids`, + {method: 'post', body: JSON.stringify(userIds)} + ); + }; + + getTeamStats = async (teamId) => { + return this.doFetch( + `${this.getTeamNeededRoute(teamId)}/stats`, + {method: 'get'} + ); + }; + + addUserToTeam = async (teamId, userId) => { + return this.doFetch( + `${this.getTeamNeededRoute(teamId)}/add_user_to_team`, + {method: 'post', body: JSON.stringify({user_id: userId})} + ); + }; + + removeUserFromTeam = async (teamId, userId) => { + return this.doFetch( + `${this.getTeamNeededRoute(teamId)}/remove_user_from_team`, + {method: 'post', body: JSON.stringify({user_id: userId})} + ); + }; + + // Channel routes + + createChannel = async (channel) => { + return this.doFetch( + `${this.getChannelsRoute(channel.team_id)}/create`, + {method: 'post', body: JSON.stringify(channel)} + ); + }; + + createDirectChannel = async (teamId, userId) => { + return this.doFetch( + `${this.getChannelsRoute(teamId)}/create_direct`, + {method: 'post', body: JSON.stringify({user_id: userId})} + ); + }; + + getChannel = async (teamId, channelId) => { + return this.doFetch( + `${this.getChannelNeededRoute(teamId, channelId)}/`, + {method: 'get'} + ); + }; + + getChannels = async (teamId) => { + return this.doFetch( + `${this.getChannelsRoute(teamId)}/`, + {method: 'get'} + ); + }; + + getMyChannelMembers = async (teamId) => { + return this.doFetch( + `${this.getChannelsRoute(teamId)}/members`, + {method: 'get'} + ); + }; + + updateChannel = async (channel) => { + return this.doFetch( + `${this.getChannelsRoute(channel.team_id)}/update`, + {method: 'post', body: JSON.stringify(channel)} + ); + }; + + updateChannelNotifyProps = async (teamId, data) => { + return this.doFetch( + `${this.getChannelsRoute(teamId)}/update_notify_props`, + {method: 'post', body: JSON.stringify(data)} + ); + }; + + leaveChannel = async (teamId, channelId) => { + return this.doFetch( + `${this.getChannelNeededRoute(teamId, channelId)}/leave`, + {method: 'post'} + ); + }; + + joinChannel = async (teamId, channelId) => { + return this.doFetch( + `${this.getChannelNeededRoute(teamId, channelId)}/join`, + {method: 'post'} + ); + }; + + joinChannelByName = async (teamId, channelName) => { + return this.doFetch( + `${this.getChannelNameRoute(teamId, channelName)}/join`, + {method: 'post'} + ); + }; + + deleteChannel = async (teamId, channelId) => { + return this.doFetch( + `${this.getChannelNeededRoute(teamId, channelId)}/delete`, + {method: 'post'} + ); + }; + + viewChannel = async (teamId, channelId, prevChannelId = '') => { + const data = { + channel_id: channelId, + prev_channel_id: prevChannelId + }; + + return this.doFetch( + `${this.getChannelsRoute(teamId)}/view`, + {method: 'post', body: JSON.stringify(data)} + ); + }; + + getMoreChannels = async (teamId, offset, limit) => { + return this.doFetch( + `${this.getChannelsRoute(teamId)}/more/${offset}/${limit}`, + {method: 'get'} + ); + }; + + searchMoreChannels = async (teamId, term) => { + return this.doFetch( + `${this.getChannelsRoute(teamId)}/more/search`, + {method: 'post', body: JSON.stringify({term})} + ); + }; + + getChannelStats = async (teamId, channelId) => { + return this.doFetch( + `${this.getChannelNeededRoute(teamId, channelId)}/stats`, + {method: 'get'} + ); + }; + + addChannelMember = async (teamId, channelId, userId) => { + return this.doFetch( + `${this.getChannelNeededRoute(teamId, channelId)}/add`, + {method: 'post', body: JSON.stringify({user_id: userId})} + ); + }; + + removeChannelMember = async (teamId, channelId, userId) => { + return this.doFetch( + `${this.getChannelNeededRoute(teamId, channelId)}/remove`, + {method: 'post', body: JSON.stringify({user_id: userId})} + ); + }; + + autocompleteChannels = async (teamId, term) => { + return this.doFetch( + `${this.getChannelsRoute(teamId)}/autocomplete?term=${encodeURIComponent(term)}`, + {method: 'get'} + ); + } + + // Post routes + createPost = async (teamId, post) => { + return this.doFetch( + `${this.getPostsRoute(teamId, post.channel_id)}/create`, + {method: 'post', body: JSON.stringify(post)} + ); + }; + + editPost = async (teamId, post) => { + return this.doFetch( + `${this.getPostsRoute(teamId, post.channel_id)}/update`, + {method: 'post', body: JSON.stringify(post)} + ); + }; + + deletePost = async (teamId, channelId, postId) => { + return this.doFetch( + `${this.getPostsRoute(teamId, channelId)}/${postId}/delete`, + {method: 'post'} + ); + }; + + getPost = async (teamId, channelId, postId) => { + return this.doFetch( + `${this.getPostsRoute(teamId, channelId)}/${postId}/get`, + {method: 'get'} + ); + }; + + getPosts = async (teamId, channelId, offset, limit) => { + return this.doFetch( + `${this.getPostsRoute(teamId, channelId)}/page/${offset}/${limit}`, + {method: 'get'} + ); + }; + + getPostsSince = async (teamId, channelId, since) => { + return this.doFetch( + `${this.getPostsRoute(teamId, channelId)}/since/${since}`, + {method: 'get'} + ); + }; + + getPostsBefore = async (teamId, channelId, postId, offset, limit) => { + return this.doFetch( + `${this.getPostsRoute(teamId, channelId)}/${postId}/before/${offset}/${limit}`, + {method: 'get'} + ); + }; + + getPostsAfter = async (teamId, channelId, postId, offset, limit) => { + return this.doFetch( + `${this.getPostsRoute(teamId, channelId)}/${postId}/after/${offset}/${limit}`, + {method: 'get'} + ); + }; + + getFileInfosForPost = async (teamId, channelId, postId) => { + return this.doFetch( + `${this.getChannelNeededRoute(teamId, channelId)}/posts/${postId}/get_file_infos`, + {method: 'get'} + ); + }; + + uploadFile = async (teamId, channelId, clientId, fileFormData, formBoundary) => { + return this.doFetch( + `${this.getTeamNeededRoute(teamId)}/files/upload`, + { + method: 'post', + headers: { + 'Content-Type': `multipart/form-data; boundary=${formBoundary}` + }, + body: fileFormData + } + ); + }; + + // Preferences routes + getMyPreferences = async () => { + return this.doFetch( + `${this.getPreferencesRoute()}/`, + {method: 'get'} + ); + }; + + savePreferences = async (preferences) => { + return this.doFetch( + `${this.getPreferencesRoute()}/save`, + {method: 'post', body: JSON.stringify(preferences)} + ); + }; + + deletePreferences = async (preferences) => { + return this.doFetch( + `${this.getPreferencesRoute()}/delete`, + {method: 'post', body: JSON.stringify(preferences)} + ); + }; + + getPreferenceCategory = async (category) => { + return this.doFetch( + `${this.getPreferencesRoute()}/${category}`, + {method: 'get'} + ); + }; + + getPreference = async (category, name) => { + return this.doFetch( + `${this.getPreferencesRoute()}/${category}/${name}`, + {method: 'get'} + ); + }; + + // File routes + getFileUrl(fileId) { + return `${this.getFilesRoute()}/${fileId}/get`; + } + + getFileThumbnailUrl(fileId) { + return `${this.getFilesRoute()}/${fileId}/get_thumbnail`; + } + + getFilePreviewUrl(fileId) { + return `${this.getFilesRoute()}/${fileId}/get_preview`; + } + + // Client helpers + doFetch = async (url, options) => { + const {data} = await this.doFetchWithResponse(url, options); + + return data; + }; + + doFetchWithResponse = async (url, options) => { + const response = await fetch(url, this.getOptions(options)); + const headers = parseAndMergeNestedHeaders(response.headers); + + let data; + try { + data = await response.json(); + } catch (err) { + throw { + intl: { + id: 'mobile.request.invalid_response', + defaultMessage: 'Received invalid response from the server.' + } + }; + } + + if (headers.has(HEADER_X_VERSION_ID)) { + const serverVersion = headers.get(HEADER_X_VERSION_ID); + if (serverVersion && this.serverVersion !== serverVersion) { + this.serverVersion = serverVersion; + EventEmitter.emit(Constants.CONFIG_CHANGED, serverVersion); + } + } + + if (response.ok) { + return { + response, + headers, + data + }; + } + + const msg = data.message || ''; + + if (this.logToConsole) { + console.error(msg); // eslint-disable-line no-console + } + + throw { + message: msg, + server_error_id: data.id, + status_code: data.status_code, + url + }; + }; +} + +function parseAndMergeNestedHeaders(originalHeaders) { + // TODO: This is a workaround for https://github.com/matthew-andrews/isomorphic-fetch/issues/97 + // The real solution is to set Access-Control-Expose-Headers on the server + const headers = new Map(); + let nestedHeaders = new Map(); + originalHeaders.forEach((val, key) => { + const capitalizedKey = key.replace(/\b[a-z]/g, (l) => l.toUpperCase()); + let realVal = val; + if (val && val.match(/\n\S+:\s\S+/)) { + const nestedHeaderStrings = val.split('\n'); + realVal = nestedHeaderStrings.shift(); + const moreNestedHeaders = new Map( + nestedHeaderStrings.map((h) => h.split(/:\s/)) + ); + nestedHeaders = new Map([...nestedHeaders, ...moreNestedHeaders]); + } + headers.set(capitalizedKey, realVal); + }); + return new Map([...headers, ...nestedHeaders]); +} diff --git a/src/client/fetch_etag.js b/src/client/fetch_etag.js new file mode 100644 index 000000000..81e67f655 --- /dev/null +++ b/src/client/fetch_etag.js @@ -0,0 +1,38 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const data = {}; +const etags = {}; + +export default (url = null, options = {headers: {}}) => { + url = url || options.url; // eslint-disable-line no-param-reassign + + if (options.method === 'GET' || !options.method) { + const etag = etags[url]; + const cachedResponse = data[`${url}${etag}`]; // ensure etag is for url + if (etag) { + options.headers['If-None-Match'] = etag; + } + + return fetch(url, options). + then((response) => { + if (response.status === 304) { + return cachedResponse.clone(); + } + + if (response.status === 200) { + const responseEtag = response.headers.get('Etag'); + + if (responseEtag) { + data[`${url}${responseEtag}`] = response.clone(); + etags[url] = responseEtag; + } + } + + return response; + }); + } + + // all other requests go straight to fetch + return Reflect.apply(fetch, undefined, [url, options]); //eslint-disable-line no-undefined +}; diff --git a/src/client/index.js b/src/client/index.js new file mode 100644 index 000000000..8f550c485 --- /dev/null +++ b/src/client/index.js @@ -0,0 +1,6 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import Client from './client.js'; + +export default new Client(); \ No newline at end of file diff --git a/src/client/websocket_client.js b/src/client/websocket_client.js new file mode 100644 index 000000000..5fe3db7db --- /dev/null +++ b/src/client/websocket_client.js @@ -0,0 +1,229 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const MAX_WEBSOCKET_FAILS = 7; +const MIN_WEBSOCKET_RETRY_TIME = 3000; // 3 sec +const MAX_WEBSOCKET_RETRY_TIME = 300000; // 5 mins + +let Socket; + +class WebSocketClient { + constructor() { + this.conn = null; + this.connectionUrl = null; + this.token = null; + this.sequence = 1; + this.connectFailCount = 0; + this.eventCallback = null; + this.firstConnectCallback = null; + this.reconnectCallback = null; + this.errorCallback = null; + this.closeCallback = null; + this.connectingCallback = null; + this.dispatch = null; + this.getState = null; + this.stop = false; + this.platform = ''; + } + + initialize(token, dispatch, getState, opts) { + const defaults = { + forceConnection: true, + connectionUrl: this.connectionUrl, + webSocketConnector: WebSocket + }; + + const {connectionUrl, forceConnection, webSocketConnector, platform} = Object.assign({}, defaults, opts); + + if (platform) { + this.platform = platform; + } + + if (forceConnection) { + this.stop = false; + } + + return new Promise((resolve, reject) => { + if (this.conn) { + resolve(); + return; + } + + if (connectionUrl == null) { + console.log('websocket must have connection url'); //eslint-disable-line no-console + reject('websocket must have connection url'); + return; + } + + if (!dispatch) { + console.log('websocket must have a dispatch'); //eslint-disable-line no-console + reject('websocket must have a dispatch'); + return; + } + + if (this.connectFailCount === 0) { + console.log('websocket connecting to ' + connectionUrl); //eslint-disable-line no-console + } + + Socket = webSocketConnector; + if (this.connectingCallback) { + this.connectingCallback(dispatch, getState); + } + + const regex = platform === 'android' ? /^(?:https?|wss?):\/\/[^/]*[^!(:443)]/ : /^(?:https?|wss?):\/\/[^/]*/; + const captured = (regex).exec(connectionUrl); + const origin = captured ? captured[0] : null; + + this.conn = new Socket(connectionUrl, null, {origin}); + this.connectionUrl = connectionUrl; + this.token = token; + this.dispatch = dispatch; + this.getState = getState; + + this.conn.onopen = () => { + if (token) { + this.sendMessage('authentication_challenge', {token}); + } + + if (this.connectFailCount > 0) { + console.log('websocket re-established connection'); //eslint-disable-line no-console + if (this.reconnectCallback) { + this.reconnectCallback(this.dispatch, this.getState); + } + } else if (this.firstConnectCallback) { + this.firstConnectCallback(this.dispatch, this.getState); + resolve(); + } + + this.connectFailCount = 0; + }; + + this.conn.onclose = () => { + this.conn = null; + this.sequence = 1; + + if (this.connectFailCount === 0) { + console.log('websocket closed'); //eslint-disable-line no-console + } + + this.connectFailCount++; + + if (this.closeCallback) { + this.closeCallback(this.connectFailCount, this.dispatch, this.getState); + } + + let retryTime = MIN_WEBSOCKET_RETRY_TIME; + + // If we've failed a bunch of connections then start backing off + if (this.connectFailCount > MAX_WEBSOCKET_FAILS) { + retryTime = MIN_WEBSOCKET_RETRY_TIME * this.connectFailCount; + if (retryTime > MAX_WEBSOCKET_RETRY_TIME) { + retryTime = MAX_WEBSOCKET_RETRY_TIME; + } + } + + setTimeout( + () => { + if (this.stop) { + return; + } + this.initialize(token, dispatch, getState, Object.assign({}, opts, {forceConnection: true})); + }, + retryTime + ); + }; + + this.conn.onerror = (evt) => { + if (this.connectFailCount <= 1) { + console.log('websocket error'); //eslint-disable-line no-console + console.log(evt); //eslint-disable-line no-console + } + + if (this.errorCallback) { + this.errorCallback(evt, this.dispatch, this.getState); + } + }; + + this.conn.onmessage = (evt) => { + const msg = JSON.parse(evt.data); + if (msg.seq_reply) { + if (msg.error) { + console.log(msg); //eslint-disable-line no-console + } + } else if (this.eventCallback) { + this.eventCallback(msg, this.dispatch, this.getState); + } + }; + }); + } + + setConnectingCallback(callback) { + this.connectingCallback = callback; + } + + setEventCallback(callback) { + this.eventCallback = callback; + } + + setFirstConnectCallback(callback) { + this.firstConnectCallback = callback; + } + + setReconnectCallback(callback) { + this.reconnectCallback = callback; + } + + setErrorCallback(callback) { + this.errorCallback = callback; + } + + setCloseCallback(callback) { + this.closeCallback = callback; + } + + close(stop = false) { + this.stop = stop; + this.connectFailCount = 0; + this.sequence = 1; + if (this.conn && this.conn.readyState === Socket.OPEN) { + this.conn.onclose = () => {}; //eslint-disable-line no-empty-function + this.conn.close(); + this.conn = null; + console.log('websocket closed'); //eslint-disable-line no-console + } + } + + sendMessage(action, data) { + const msg = { + action, + seq: this.sequence++, + data + }; + + if (this.conn && this.conn.readyState === Socket.OPEN) { + this.conn.send(JSON.stringify(msg)); + } else if (!this.conn || this.conn.readyState === Socket.CLOSED) { + this.conn = null; + this.initialize(this.token, this.dispatch, this.getState, {forceConnection: true, platform: this.platform}); + } + } + + userTyping(channelId, parentId) { + this.sendMessage('user_typing', { + channel_id: channelId, + parent_id: parentId + }); + } + + getStatuses() { + this.sendMessage('get_statuses', null); + } + + getStatusesByIds(userIds) { + this.sendMessage('get_statuses_by_ids', { + user_ids: userIds + }); + } +} + +export default new WebSocketClient(); diff --git a/src/constants/channels.js b/src/constants/channels.js new file mode 100644 index 000000000..ae5c95619 --- /dev/null +++ b/src/constants/channels.js @@ -0,0 +1,83 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import keyMirror from 'utils/key_mirror'; + +const ChannelTypes = keyMirror({ + CHANNEL_REQUEST: null, + CHANNEL_SUCCESS: null, + CHANNEL_FAILURE: null, + + CHANNELS_REQUEST: null, + CHANNELS_SUCCESS: null, + CHANNELS_FAILURE: null, + + CHANNEL_MEMBERS_REQUEST: null, + CHANNEL_MEMBERS_SUCCESS: null, + CHANNEL_MEMBERS_FAILURE: null, + + CREATE_CHANNEL_REQUEST: null, + CREATE_CHANNEL_SUCCESS: null, + CREATE_CHANNEL_FAILURE: null, + + UPDATE_CHANNEL_REQUEST: null, + UPDATE_CHANNEL_SUCCESS: null, + UPDATE_CHANNEL_FAILURE: null, + + NOTIFY_PROPS_REQUEST: null, + NOTIFY_PROPS_SUCCESS: null, + NOTIFY_PROPS_FAILURE: null, + + LEAVE_CHANNEL_REQUEST: null, + LEAVE_CHANNEL_SUCCESS: null, + LEAVE_CHANNEL_FAILURE: null, + + JOIN_CHANNEL_REQUEST: null, + JOIN_CHANNEL_SUCCESS: null, + JOIN_CHANNEL_FAILURE: null, + + DELETE_CHANNEL_REQUEST: null, + DELETE_CHANNEL_SUCCESS: null, + DELETE_CHANNEL_FAILURE: null, + + UPDATE_LAST_VIEWED_REQUEST: null, + UPDATE_LAST_VIEWED_SUCCESS: null, + UPDATE_LAST_VIEWED_FAILURE: null, + + MORE_CHANNELS_REQUEST: null, + MORE_CHANNELS_SUCCESS: null, + MORE_CHANNELS_FAILURE: null, + + CHANNEL_STATS_REQUEST: null, + CHANNEL_STATS_SUCCESS: null, + CHANNEL_STATS_FAILURE: null, + + ADD_CHANNEL_MEMBER_REQUEST: null, + ADD_CHANNEL_MEMBER_SUCCESS: null, + ADD_CHANNEL_MEMBER_FAILURE: null, + + REMOVE_CHANNEL_MEMBER_REQUEST: null, + REMOVE_CHANNEL_MEMBER_SUCCESS: null, + REMOVE_CHANNEL_MEMBER_FAILURE: null, + + AUTOCOMPLETE_CHANNELS_REQUEST: null, + AUTOCOMPLETE_CHANNELS_SUCCESS: null, + AUTOCOMPLETE_CHANNELS_FAILURE: null, + + SELECT_CHANNEL: null, + LEAVE_CHANNEL: null, + RECEIVED_CHANNEL: null, + RECEIVED_CHANNELS: null, + RECEIVED_MY_CHANNEL_MEMBERS: null, + RECEIVED_MY_CHANNEL_MEMBER: null, + RECEIVED_MORE_CHANNELS: null, + RECEIVED_CHANNEL_STATS: null, + RECEIVED_CHANNEL_PROPS: null, + RECEIVED_CHANNEL_DELETED: null, + RECEIVED_LAST_VIEWED: null, + RECEIVED_AUTOCOMPLETE_CHANNELS: null, + UPDATE_CHANNEL_HEADER: null, + UPDATE_CHANNEL_PURPOSE: null +}); + +export default ChannelTypes; diff --git a/src/constants/constants.js b/src/constants/constants.js new file mode 100644 index 000000000..b44196146 --- /dev/null +++ b/src/constants/constants.js @@ -0,0 +1,90 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const Constants = { + CONFIG_CHANGED: 'config_changed', + + POST_CHUNK_SIZE: 60, + PROFILE_CHUNK_SIZE: 100, + CHANNELS_CHUNK_SIZE: 50, + SEARCH_TIMEOUT_MILLISECONDS: 100, + STATUS_INTERVAL: 60000, + + MENTION: 'mention', + + OFFLINE: 'offline', + AWAY: 'away', + ONLINE: 'online', + + TEAM_USER_ROLE: 'team_user', + TEAM_ADMIN_ROLE: 'team_admin', + + CHANNEL_USER_ROLE: 'channel_user', + CHANNEL_ADMIN_ROLE: 'channel_admin', + + SYSTEM_USER_ROLE: 'system_user', + SYSTEM_ADMIN_ROLE: 'system_admin', + + DEFAULT_CHANNEL: 'town-square', + DM_CHANNEL: 'D', + OPEN_CHANNEL: 'O', + PRIVATE_CHANNEL: 'P', + + POST_DELETED: 'DELETED', + SYSTEM_MESSAGE_PREFIX: 'system_', + + CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show', + CATEGORY_DISPLAY_SETTINGS: 'display_settings', + CATEGORY_FAVORITE_CHANNEL: 'favorite_channel', + DISPLAY_PREFER_NICKNAME: 'nickname_full_name', + DISPLAY_PREFER_FULL_NAME: 'full_name', + + START_OF_NEW_MESSAGES: 'start-of-new-messages', + + POST_HEADER_CHANGE: 'system_header_change', + POST_PURPOSE_CHANGE: 'system_purpose_change', + + PUSH_NOTIFY_APPLE_REACT_NATIVE: 'apple_rn', + PUSH_NOTIFY_ANDROID_REACT_NATIVE: 'android_rn' +}; + +const FileConstants = { + AUDIO_TYPES: ['mp3', 'wav', 'wma', 'm4a', 'flac', 'aac', 'ogg'], + CODE_TYPES: ['as', 'applescript', 'osascript', 'scpt', 'bash', 'sh', 'zsh', 'clj', 'boot', 'cl2', 'cljc', 'cljs', 'cljs.hl', 'cljscm', 'cljx', 'hic', 'coffee', '_coffee', 'cake', 'cjsx', 'cson', 'iced', 'cpp', 'c', 'cc', 'h', 'c++', 'h++', 'hpp', 'cs', 'csharp', 'css', 'd', 'di', 'dart', 'delphi', 'dpr', 'dfm', 'pas', 'pascal', 'freepascal', 'lazarus', 'lpr', 'lfm', 'diff', 'django', 'jinja', 'dockerfile', 'docker', 'erl', 'f90', 'f95', 'fsharp', 'fs', 'gcode', 'nc', 'go', 'groovy', 'handlebars', 'hbs', 'html.hbs', 'html.handlebars', 'hs', 'hx', 'java', 'jsp', 'js', 'jsx', 'json', 'jl', 'kt', 'ktm', 'kts', 'less', 'lisp', 'lua', 'mk', 'mak', 'md', 'mkdown', 'mkd', 'matlab', 'm', 'mm', 'objc', 'obj-c', 'ml', 'perl', 'pl', 'php', 'php3', 'php4', 'php5', 'php6', 'ps', 'ps1', 'pp', 'py', 'gyp', 'r', 'ruby', 'rb', 'gemspec', 'podspec', 'thor', 'irb', 'rs', 'scala', 'scm', 'sld', 'scss', 'st', 'sql', 'swift', 'tex', 'txt', 'vbnet', 'vb', 'bas', 'vbs', 'v', 'veo', 'xml', 'html', 'xhtml', 'rss', 'atom', 'xsl', 'plist', 'yaml'], + IMAGE_TYPES: ['jpg', 'gif', 'bmp', 'png', 'jpeg'], + PATCH_TYPES: ['patch'], + PDF_TYPES: ['pdf'], + PRESENTATION_TYPES: ['ppt', 'pptx'], + SPREADSHEET_TYPES: ['xlsx', 'csv'], + VIDEO_TYPES: ['mp4', 'avi', 'webm', 'mkv', 'wmv', 'mpg', 'mov', 'flv'], + WORD_TYPES: ['doc', 'docx'] +}; + +const PostsTypes = { + ADD_REMOVE: 'system_add_remove', + ADD_TO_CHANNEL: 'system_add_to_channel', + CHANNEL_DELETED: 'system_channel_deleted', + DISPLAYNAME_CHANGE: 'system_displayname_change', + EPHEMERAL: 'system_ephemeral', + HEADER_CHANGE: 'system_header_change', + JOIN_CHANNEL: 'system_join_channel', + JOIN_LEAVE: 'system_join_leave', + LEAVE_CHANNEL: 'system_leave_channel', + PURPOSE_CHANGE: 'system_purpose_change', + REMOVE_FROM_CHANNEL: 'system_remove_from_channel' +}; + +export default { + ...Constants, + ...FileConstants, + ...PostsTypes, + IGNORE_POST_TYPES: [ + PostsTypes.ADD_REMOVE, + PostsTypes.ADD_TO_CHANNEL, + PostsTypes.CHANNEL_DELETED, + PostsTypes.JOIN_LEAVE, + PostsTypes.JOIN_CHANNEL, + PostsTypes.LEAVE_CHANNEL, + PostsTypes.REMOVE_FROM_CHANNEL + ] +}; diff --git a/src/constants/errors.js b/src/constants/errors.js new file mode 100644 index 000000000..a5d19b461 --- /dev/null +++ b/src/constants/errors.js @@ -0,0 +1,12 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import keyMirror from 'utils/key_mirror'; + +const ErrorTypes = keyMirror({ + DISMISS_ERROR: null, + LOG_ERROR: null, + CLEAR_ERRORS: null +}); + +export default ErrorTypes; diff --git a/src/constants/files.js b/src/constants/files.js new file mode 100644 index 000000000..5453bece2 --- /dev/null +++ b/src/constants/files.js @@ -0,0 +1,14 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import keyMirror from 'utils/key_mirror'; + +const FilesTypes = keyMirror({ + FETCH_FILES_FOR_POST_REQUEST: null, + FETCH_FILES_FOR_POST_SUCCESS: null, + FETCH_FILES_FOR_POST_FAILURE: null, + + RECEIVED_FILES_FOR_POST: null +}); + +export default FilesTypes; diff --git a/src/constants/general.js b/src/constants/general.js new file mode 100644 index 000000000..27104886d --- /dev/null +++ b/src/constants/general.js @@ -0,0 +1,38 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import keyMirror from 'utils/key_mirror'; + +const GeneralTypes = keyMirror({ + RECEIVED_APP_STATE: null, + RECEIVED_APP_CREDENTIALS: null, + REMOVED_APP_CREDENTIALS: null, + RECEIVED_APP_DEVICE_TOKEN: null, + + PING_REQUEST: null, + PING_SUCCESS: null, + PING_FAILURE: null, + PING_RESET: null, + + RECEIVED_SERVER_VERSION: null, + + CLIENT_CONFIG_REQUEST: null, + CLIENT_CONFIG_SUCCESS: null, + CLIENT_CONFIG_FAILURE: null, + CLIENT_CONFIG_RECEIVED: null, + + CLIENT_LICENSE_REQUEST: null, + CLIENT_LICENSE_SUCCESS: null, + CLIENT_LICENSE_FAILURE: null, + CLIENT_LICENSE_RECEIVED: null, + + LOG_CLIENT_ERROR_REQUEST: null, + LOG_CLIENT_ERROR_SUCCESS: null, + LOG_CLIENT_ERROR_FAILURE: null, + + WEBSOCKET_REQUEST: null, + WEBSOCKET_SUCCESS: null, + WEBSOCKET_FAILURE: null +}); + +export default GeneralTypes; diff --git a/src/constants/index.js b/src/constants/index.js new file mode 100644 index 000000000..fe55967ce --- /dev/null +++ b/src/constants/index.js @@ -0,0 +1,39 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import Constants from './constants'; +import ChannelTypes from './channels'; +import ErrorTypes from './errors'; +import GeneralTypes from './general'; +import UsersTypes from './users'; +import TeamsTypes from './teams'; +import PostsTypes from './posts'; +import FilesTypes from './files'; +import PreferencesTypes from './preferences'; +import RequestStatus from './request_status'; +import WebsocketEvents from './websocket'; + +const Preferences = { + CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show', + CATEGORY_NOTIFICATIONS: 'notifications', + CATEGORY_THEME: 'theme', + EMAIL_INTERVAL: 'email_interval', + INTERVAL_FIFTEEN_MINUTES: 15 * 60, + INTERVAL_HOUR: 60 * 60, + INTERVAL_IMMEDIATE: 30 // "immediate" is a 30 second interval +}; + +export { + Constants, + ErrorTypes, + GeneralTypes, + UsersTypes, + TeamsTypes, + ChannelTypes, + PostsTypes, + FilesTypes, + PreferencesTypes, + Preferences, + RequestStatus, + WebsocketEvents +}; diff --git a/src/constants/posts.js b/src/constants/posts.js new file mode 100644 index 000000000..5f9b41318 --- /dev/null +++ b/src/constants/posts.js @@ -0,0 +1,48 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import keyMirror from 'utils/key_mirror'; + +const PostsTypes = keyMirror({ + CREATE_POST_REQUEST: null, + CREATE_POST_SUCCESS: null, + CREATE_POST_FAILURE: null, + + EDIT_POST_REQUEST: null, + EDIT_POST_SUCCESS: null, + EDIT_POST_FAILURE: null, + + DELETE_POST_REQUEST: null, + DELETE_POST_SUCCESS: null, + DELETE_POST_FAILURE: null, + + GET_POST_REQUEST: null, + GET_POST_SUCCESS: null, + GET_POST_FAILURE: null, + + GET_POSTS_REQUEST: null, + GET_POSTS_SUCCESS: null, + GET_POSTS_FAILURE: null, + + GET_POSTS_SINCE_REQUEST: null, + GET_POSTS_SINCE_SUCCESS: null, + GET_POSTS_SINCE_FAILURE: null, + + GET_POSTS_BEFORE_REQUEST: null, + GET_POSTS_BEFORE_SUCCESS: null, + GET_POSTS_BEFORE_FAILURE: null, + + GET_POSTS_AFTER_REQUEST: null, + GET_POSTS_AFTER_SUCCESS: null, + GET_POSTS_AFTER_FAILURE: null, + + RECEIVED_POST: null, + RECEIVED_POSTS: null, + RECEIVED_FOCUSED_POST: null, + RECEIVED_POST_SELECTED: null, + RECEIVED_EDIT_POST: null, + POST_DELETED: null, + REMOVE_POST: null +}); + +export default PostsTypes; diff --git a/src/constants/preferences.js b/src/constants/preferences.js new file mode 100644 index 000000000..d26b969af --- /dev/null +++ b/src/constants/preferences.js @@ -0,0 +1,21 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import keyMirror from 'utils/key_mirror'; + +export default keyMirror({ + MY_PREFERENCES_REQUEST: null, + MY_PREFERENCES_SUCCESS: null, + MY_PREFERENCES_FAILURE: null, + + SAVE_PREFERENCES_REQUEST: null, + SAVE_PREFERENCES_SUCCESS: null, + SAVE_PREFERENCES_FAILURE: null, + + DELETE_PREFERENCES_REQUEST: null, + DELETE_PREFERENCES_SUCCESS: null, + DELETE_PREFERENCES_FAILURE: null, + + RECEIVED_PREFERENCES: null, + DELETED_PREFERENCES: null +}); diff --git a/src/constants/request_status.js b/src/constants/request_status.js new file mode 100644 index 000000000..1ff6116e0 --- /dev/null +++ b/src/constants/request_status.js @@ -0,0 +1,9 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +export default { + NOT_STARTED: 'not_started', + STARTED: 'started', + SUCCESS: 'success', + FAILURE: 'failure' +}; diff --git a/src/constants/teams.js b/src/constants/teams.js new file mode 100644 index 000000000..e4ebd7d9b --- /dev/null +++ b/src/constants/teams.js @@ -0,0 +1,56 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import keyMirror from 'utils/key_mirror'; + +const TeamTypes = keyMirror({ + FETCH_TEAMS_REQUEST: null, + FETCH_TEAMS_SUCCESS: null, + FETCH_TEAMS_FAILURE: null, + + CREATE_TEAM_REQUEST: null, + CREATE_TEAM_SUCCESS: null, + CREATE_TEAM_FAILURE: null, + + UPDATE_TEAM_REQUEST: null, + UPDATE_TEAM_SUCCESS: null, + UPDATE_TEAM_FAILURE: null, + + MY_TEAM_MEMBERS_REQUEST: null, + MY_TEAM_MEMBERS_SUCCESS: null, + MY_TEAM_MEMBERS_FAILURE: null, + + TEAM_LISTINGS_REQUEST: null, + TEAM_LISTINGS_SUCCESS: null, + TEAM_LISTINGS_FAILURE: null, + + TEAM_MEMBERS_REQUEST: null, + TEAM_MEMBERS_SUCCESS: null, + TEAM_MEMBERS_FAILURE: null, + + TEAM_STATS_REQUEST: null, + TEAM_STATS_SUCCESS: null, + TEAM_STATS_FAILURE: null, + + ADD_TEAM_MEMBER_REQUEST: null, + ADD_TEAM_MEMBER_SUCCESS: null, + ADD_TEAM_MEMBER_FAILURE: null, + + REMOVE_TEAM_MEMBER_REQUEST: null, + REMOVE_TEAM_MEMBER_SUCCESS: null, + REMOVE_TEAM_MEMBER_FAILURE: null, + + CREATED_TEAM: null, + SELECT_TEAM: null, + UPDATED_TEAM: null, + RECEIVED_ALL_TEAMS: null, + RECEIVED_MY_TEAM_MEMBERS: null, + RECEIVED_TEAM_LISTINGS: null, + RECEIVED_MEMBERS_IN_TEAM: null, + RECEIVED_MEMBER_IN_TEAM: null, + REMOVE_MEMBER_FROM_TEAM: null, + RECEIVED_TEAM_STATS: null, + LEAVE_TEAM: null +}); + +export default TeamTypes; diff --git a/src/constants/users.js b/src/constants/users.js new file mode 100644 index 000000000..eefd5a161 --- /dev/null +++ b/src/constants/users.js @@ -0,0 +1,79 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import keyMirror from 'utils/key_mirror'; + +const UserTypes = keyMirror({ + LOGIN_REQUEST: null, + LOGIN_SUCCESS: null, + LOGIN_FAILURE: null, + + LOGOUT_REQUEST: null, + LOGOUT_SUCCESS: null, + LOGOUT_FAILURE: null, + + PROFILES_REQUEST: null, + PROFILES_SUCCESS: null, + PROFILES_FAILURE: null, + + PROFILES_IN_TEAM_REQUEST: null, + PROFILES_IN_TEAM_SUCCESS: null, + PROFILES_IN_TEAM_FAILURE: null, + + PROFILES_IN_CHANNEL_REQUEST: null, + PROFILES_IN_CHANNEL_SUCCESS: null, + PROFILES_IN_CHANNEL_FAILURE: null, + + PROFILES_NOT_IN_CHANNEL_REQUEST: null, + PROFILES_NOT_IN_CHANNEL_SUCCESS: null, + PROFILES_NOT_IN_CHANNEL_FAILURE: null, + + PROFILES_STATUSES_REQUEST: null, + PROFILES_STATUSES_SUCCESS: null, + PROFILES_STATUSES_FAILURE: null, + + SESSIONS_REQUEST: null, + SESSIONS_SUCCESS: null, + SESSIONS_FAILURE: null, + + REVOKE_SESSION_REQUEST: null, + REVOKE_SESSION_SUCCESS: null, + REVOKE_SESSION_FAILURE: null, + + AUDITS_REQUEST: null, + AUDITS_SUCCESS: null, + AUDITS_FAILURE: null, + + CHECK_MFA_REQUEST: null, + CHECK_MFA_SUCCESS: null, + CHECK_MFA_FAILURE: null, + + AUTOCOMPLETE_IN_CHANNEL_REQUEST: null, + AUTOCOMPLETE_IN_CHANNEL_SUCCESS: null, + AUTOCOMPLETE_IN_CHANNEL_FAILURE: null, + + SEARCH_PROFILES_REQUEST: null, + SEARCH_PROFILES_SUCCESS: null, + SEARCH_PROFILES_FAILURE: null, + + UPDATE_NOTIFY_PROPS_REQUEST: null, + UPDATE_NOTIFY_PROPS_SUCCESS: null, + UPDATE_NOTIFY_PROPS_FAILURE: null, + + RECEIVED_ME: null, + RECEIVED_PROFILES: null, + RECEIVED_SEARCH_PROFILES: null, + RECEIVED_PROFILES_IN_TEAM: null, + RECEIVED_PROFILES_IN_CHANNEL: null, + RECEIVED_PROFILE_IN_CHANNEL: null, + RECEIVED_PROFILES_NOT_IN_CHANNEL: null, + RECEIVED_PROFILE_NOT_IN_CHANNEL: null, + RECEIVED_SESSIONS: null, + RECEIVED_REVOKED_SESSION: null, + RECEIVED_AUDITS: null, + RECEIVED_STATUSES: null, + RECEIVED_AUTOCOMPLETE_IN_CHANNEL: null, + RESET_LOGOUT_STATE: null +}); + +export default UserTypes; diff --git a/src/constants/websocket.js b/src/constants/websocket.js new file mode 100644 index 000000000..3e2be84e4 --- /dev/null +++ b/src/constants/websocket.js @@ -0,0 +1,26 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const WebsocketEvents = { + POSTED: 'posted', + POST_EDITED: 'post_edited', + POST_DELETED: 'post_deleted', + CHANNEL_CREATED: 'channel_created', + CHANNEL_DELETED: 'channel_deleted', + DIRECT_ADDED: 'direct_added', + LEAVE_TEAM: 'leave_team', + USER_ADDED: 'user_added', + USER_REMOVED: 'user_removed', + USER_UPDATED: 'user_updated', + TYPING: 'typing', + STOP_TYPING: 'stop_typing', + PREFERENCE_CHANGED: 'preference_changed', + EPHEMERAL_MESSAGE: 'ephemeral_message', + STATUS_CHANGED: 'status_change', + HELLO: 'hello', + WEBRTC: 'webrtc', + REACTION_ADDED: 'reaction_added', + REACTION_REMOVED: 'reaction_removed' +}; + +export default WebsocketEvents; diff --git a/src/reducers/entities/channels.js b/src/reducers/entities/channels.js new file mode 100644 index 000000000..c38ccd470 --- /dev/null +++ b/src/reducers/entities/channels.js @@ -0,0 +1,179 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {combineReducers} from 'redux'; +import {ChannelTypes, TeamsTypes, UsersTypes} from 'constants'; + +function currentChannelId(state = '', action) { + switch (action.type) { + case ChannelTypes.SELECT_CHANNEL: + return action.data; + case UsersTypes.LOGOUT_SUCCESS: + return ''; + default: + return state; + } +} + +function channels(state = {}, action) { + const nextState = {...state}; + + switch (action.type) { + case ChannelTypes.RECEIVED_CHANNEL: + return { + ...state, + [action.data.id]: action.data + }; + + case ChannelTypes.RECEIVED_CHANNELS: + case ChannelTypes.RECEIVED_MORE_CHANNELS: { + for (const channel of action.data) { + nextState[channel.id] = channel; + } + return nextState; + } + case ChannelTypes.RECEIVED_CHANNEL_DELETED: + Reflect.deleteProperty(nextState, action.data); + return nextState; + case ChannelTypes.RECEIVED_LAST_VIEWED: { + const channelId = action.data.channel_id; + const lastUpdatedAt = action.data.last_viewed_at; + const channel = state[channelId]; + if (!channel) { + return state; + } + return { + ...state, + [channelId]: { + ...channel, + extra_update_at: lastUpdatedAt + } + }; + } + case ChannelTypes.UPDATE_CHANNEL_HEADER: { + const {channelId, header} = action.data; + return { + ...state, + [channelId]: { + ...state[channelId], + header + } + }; + } + case ChannelTypes.UPDATE_CHANNEL_PURPOSE: { + const {channelId, purpose} = action.data; + return { + ...state, + [channelId]: { + ...state[channelId], + purpose + } + }; + } + case UsersTypes.LOGOUT_SUCCESS: + case TeamsTypes.SELECT_TEAM: + return {}; + + default: + return state; + } +} + +function myMembers(state = {}, action) { + const nextState = {...state}; + + switch (action.type) { + case ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER: { + const channelMember = action.data; + return { + ...state, + [channelMember.channel_id]: channelMember + }; + } + case ChannelTypes.RECEIVED_MY_CHANNEL_MEMBERS: { + for (const cm of action.data) { + nextState[cm.channel_id] = cm; + } + return nextState; + } + case ChannelTypes.RECEIVED_CHANNEL_PROPS: { + const member = {...state[action.data.channel_id]}; + member.notify_props = action.data.notifyProps; + + return { + ...state, + [action.data.channel_id]: member + }; + } + case ChannelTypes.RECEIVED_LAST_VIEWED: { + let member = state[action.data.channel_id]; + if (!member) { + return state; + } + member = {...member, + last_viewed_at: action.data.last_viewed_at, + msg_count: action.data.total_msg_count, + mention_count: 0 + }; + + return { + ...state, + [action.data.channel_id]: member + }; + } + case ChannelTypes.LEAVE_CHANNEL: + case ChannelTypes.RECEIVED_CHANNEL_DELETED: + Reflect.deleteProperty(nextState, action.data); + return nextState; + + case UsersTypes.LOGOUT_SUCCESS: + case TeamsTypes.SELECT_TEAM: + return {}; + default: + return state; + } +} + +function stats(state = {}, action) { + switch (action.type) { + case ChannelTypes.RECEIVED_CHANNEL_STATS: { + const nextState = {...state}; + const stat = action.data; + nextState[stat.channel_id] = stat; + + return nextState; + } + case UsersTypes.LOGOUT_SUCCESS: + case TeamsTypes.SELECT_TEAM: + return {}; + default: + return state; + } +} + +function autocompleteChannels(state = [], action) { + switch (action.type) { + case ChannelTypes.RECEIVED_AUTOCOMPLETE_CHANNELS: + return action.data; + default: + return state; + } +} + +export default combineReducers({ + + // the current selected channel + currentChannelId, + + // object where every key is the channel id and has and object with the channel detail + channels, + + //object where every key is the channel id and has and object with the channel members detail + myMembers, + + // object where every key is the channel id and has an object with the channel stats + stats, + + // array containing channel objects that have been matched to the current channel mention term + autocompleteChannels +}); diff --git a/src/reducers/entities/files.js b/src/reducers/entities/files.js new file mode 100644 index 000000000..23f1a5994 --- /dev/null +++ b/src/reducers/entities/files.js @@ -0,0 +1,47 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {combineReducers} from 'redux'; +import {FilesTypes, UsersTypes} from 'constants'; + +function files(state = {}, action) { + switch (action.type) { + case FilesTypes.RECEIVED_FILES_FOR_POST: { + const filesById = action.data.reduce((filesMap, file) => { + return {...filesMap, + [file.id]: file + }; + }, {}); + return {...state, + ...filesById + }; + } + + case UsersTypes.LOGOUT_SUCCESS: + return {}; + default: + return state; + } +} + +function fileIdsByPostId(state = {}, action) { + switch (action.type) { + case FilesTypes.RECEIVED_FILES_FOR_POST: { + const {data, postId} = action; + const filesIdsForPost = data.map((file) => file.id); + return {...state, + [postId]: filesIdsForPost + }; + } + + case UsersTypes.LOGOUT_SUCCESS: + return {}; + default: + return state; + } +} + +export default combineReducers({ + files, + fileIdsByPostId +}); diff --git a/src/reducers/entities/general.js b/src/reducers/entities/general.js new file mode 100644 index 000000000..03591a3b0 --- /dev/null +++ b/src/reducers/entities/general.js @@ -0,0 +1,81 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {combineReducers} from 'redux'; +import {GeneralTypes, UsersTypes} from 'constants'; + +function config(state = {}, action) { + switch (action.type) { + case GeneralTypes.CLIENT_CONFIG_RECEIVED: + return Object.assign({}, state, action.data); + case UsersTypes.LOGOUT_SUCCESS: + return {}; + default: + return state; + } +} + +function appState(state = false, action) { + switch (action.type) { + case GeneralTypes.RECEIVED_APP_STATE: + return action.data; + + default: + return state; + } +} + +function credentials(state = {}, action) { + switch (action.type) { + case GeneralTypes.RECEIVED_APP_CREDENTIALS: + return Object.assign({}, state, action.data); + + case UsersTypes.LOGOUT_SUCCESS: + return {}; + default: + return state; + } +} + +function deviceToken(state = '', action) { + switch (action.type) { + case GeneralTypes.RECEIVED_APP_DEVICE_TOKEN: + return action.data; + + case UsersTypes.LOGOUT_SUCCESS: + return ''; + default: + return state; + } +} + +function license(state = {}, action) { + switch (action.type) { + case GeneralTypes.CLIENT_LICENSE_RECEIVED: + return Object.assign({}, state, action.data); + case UsersTypes.LOGOUT_SUCCESS: + return {}; + default: + return state; + } +} + +function serverVersion(state = '', action) { + switch (action.type) { + case GeneralTypes.RECEIVED_SERVER_VERSION: + return action.data; + case UsersTypes.LOGOUT_SUCCESS: + return ''; + default: + return state; + } +} + +export default combineReducers({ + appState, + credentials, + config, + deviceToken, + license, + serverVersion +}); diff --git a/src/reducers/entities/index.js b/src/reducers/entities/index.js new file mode 100644 index 000000000..7eeb40763 --- /dev/null +++ b/src/reducers/entities/index.js @@ -0,0 +1,24 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {combineReducers} from 'redux'; + +import channels from './channels'; +import general from './general'; +import users from './users'; +import teams from './teams'; +import posts from './posts'; +import files from './files'; +import preferences from './preferences'; +import typing from './typing'; + +export default combineReducers({ + general, + users, + teams, + channels, + posts, + files, + preferences, + typing +}); diff --git a/src/reducers/entities/posts.js b/src/reducers/entities/posts.js new file mode 100644 index 000000000..cc89f038d --- /dev/null +++ b/src/reducers/entities/posts.js @@ -0,0 +1,203 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {Constants, PostsTypes, UsersTypes} from 'constants'; + +function handleReceivedPost(posts = {}, postsByChannel = {}, action) { + const post = action.data; + const channelId = post.channel_id; + + const nextPosts = { + ...posts, + [post.id]: post + }; + + let nextPostsByChannel = postsByChannel; + + // Only change postsByChannel if the order of the posts needs to change + if (!postsByChannel[channelId] || postsByChannel[channelId].indexOf(post.id) === -1) { + // If we don't already have the post, assume it's the most recent one + const postsInChannel = postsByChannel[channelId] || []; + + nextPostsByChannel = {...postsByChannel}; + nextPostsByChannel[channelId] = [ + post.id, + ...postsInChannel + ]; + } + + return {posts: nextPosts, postsByChannel: nextPostsByChannel}; +} + +function handleReceivedPosts(posts = {}, postsByChannel = {}, action) { + const newPosts = action.data.posts; + const channelId = action.channelId; + + const nextPosts = {...posts}; + const nextPostsByChannel = {...postsByChannel}; + const postsInChannel = postsByChannel[channelId] ? [...postsByChannel[channelId]] : []; + + for (const newPost of Object.values(newPosts)) { + if (newPost.delete_at > 0) { + continue; + } + + // Only change the stored post if it's changed since we last received it + if (!nextPosts[newPost.id] || nextPosts[newPost.id].update_at > newPost.update_at) { + nextPosts[newPost.id] = newPost; + } + + if (postsInChannel.indexOf(newPost.id) === -1) { + // Just add the post id to the end of the order and we'll sort it out later + postsInChannel.push(newPost.id); + } + } + + // Sort to ensure that the most recent posts are first + postsInChannel.sort((a, b) => { + if (nextPosts[a].create_at > nextPosts[b].create_at) { + return -1; + } else if (nextPosts[a].create_at < nextPosts[b].create_at) { + return 1; + } + + return 0; + }); + + nextPostsByChannel[channelId] = postsInChannel; + + return {posts: nextPosts, postsByChannel: nextPostsByChannel}; +} + +function handlePostDeleted(posts = {}, postsByChannel = {}, action) { + const post = action.data; + + let nextPosts = posts; + + // We only need to do something if already have the post + if (posts[post.id]) { + nextPosts = {...posts}; + + nextPosts[post.id] = { + ...posts[post.id], + state: Constants.POST_DELETED, + file_ids: [], + has_reactions: false + }; + + // No changes to the order until the user actually removes the post + } + + return {posts: nextPosts, postsByChannel}; +} + +function handleRemovePost(posts = {}, postsByChannel = {}, action) { + const post = action.data; + const channelId = post.channel_id; + + let nextPosts = posts; + let nextPostsByChannel = postsByChannel; + + // We only need to do something if already have the post + if (nextPosts[post.id]) { + nextPosts = {...posts}; + nextPostsByChannel = {...postsByChannel}; + const postsInChannel = postsByChannel[channelId] ? [...postsByChannel[channelId]] : []; + + // Remove the post itself + Reflect.deleteProperty(nextPosts, post.id); + + const index = postsInChannel.indexOf(post.id); + if (index !== -1) { + postsInChannel.splice(index, 1); + } + + // Remove any of its comments + for (const id of postsInChannel) { + if (nextPosts[id].root_id === post.id) { + Reflect.deleteProperty(nextPosts, id); + + const commentIndex = postsInChannel.indexOf(id); + if (commentIndex !== -1) { + postsInChannel.splice(commentIndex, 1); + } + } + } + + nextPostsByChannel[channelId] = postsInChannel; + } + + return {posts: nextPosts, postsByChannel: nextPostsByChannel}; +} + +function handlePosts(posts = {}, postsByChannel = {}, action) { + switch (action.type) { + case PostsTypes.RECEIVED_POST: + return handleReceivedPost(posts, postsByChannel, action); + case PostsTypes.RECEIVED_POSTS: + return handleReceivedPosts(posts, postsByChannel, action); + case PostsTypes.POST_DELETED: + return handlePostDeleted(posts, postsByChannel, action); + case PostsTypes.REMOVE_POST: + return handleRemovePost(posts, postsByChannel, action); + + case UsersTypes.LOGOUT_SUCCESS: + return { + posts: {}, + postsByChannel: {} + }; + default: + return { + posts, + postsByChannel + }; + } +} + +function selectedPostId(state = '', action) { + switch (action.type) { + case PostsTypes.RECEIVED_POST_SELECTED: + return action.data; + case UsersTypes.LOGOUT_SUCCESS: + return ''; + default: + return state; + } +} + +function currentFocusedPostId(state = '', action) { + switch (action.type) { + case UsersTypes.LOGOUT_SUCCESS: + return ''; + default: + return state; + } +} + +export default function(state = {}, action) { + const {posts, postsByChannel} = handlePosts(state.posts, state.postsByChannel, action); + + const nextState = { + + // Object mapping post ids to post objects + posts, + + // Object mapping channel ids to an list of posts ids in that channel with the most recent post first + postsByChannel, + + // The current selected post + selectedPostId: selectedPostId(state.selectedPostId, action), + + // The current selected focused post (permalink view) + currentFocusedPostId: currentFocusedPostId(state.currentFocusedPostId, action) + }; + + if (state.posts === nextState.posts && state.postsByChannel === nextState.postsByChannel && + state.selectedPostId === nextState.selectedPostId && + state.currentFocusedPostId === nextState.currentFocusedPostId) { + // None of the children have changed so don't even let the parent object change + return state; + } + + return nextState; +} diff --git a/src/reducers/entities/preferences.js b/src/reducers/entities/preferences.js new file mode 100644 index 000000000..7342ea9d6 --- /dev/null +++ b/src/reducers/entities/preferences.js @@ -0,0 +1,43 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {combineReducers} from 'redux'; +import {PreferencesTypes, UsersTypes} from 'constants'; + +function getKey(preference) { + return `${preference.category}--${preference.name}`; +} + +function myPreferences(state = {}, action) { + switch (action.type) { + case PreferencesTypes.RECEIVED_PREFERENCES: { + const nextState = {...state}; + + for (const preference of action.data) { + nextState[getKey(preference)] = preference; + } + + return nextState; + } + case PreferencesTypes.DELETED_PREFERENCES: { + const nextState = {...state}; + + for (const preference of action.data) { + Reflect.deleteProperty(nextState, getKey(preference)); + } + + return nextState; + } + + case UsersTypes.LOGOUT_SUCCESS: + return {}; + default: + return state; + } +} + +export default combineReducers({ + + // object where the key is the category-name and has the corresponding value + myPreferences +}); diff --git a/src/reducers/entities/teams.js b/src/reducers/entities/teams.js new file mode 100644 index 000000000..25bfc7269 --- /dev/null +++ b/src/reducers/entities/teams.js @@ -0,0 +1,166 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {combineReducers} from 'redux'; +import {TeamsTypes, UsersTypes} from 'constants'; + +function currentTeamId(state = '', action) { + switch (action.type) { + case TeamsTypes.SELECT_TEAM: + return action.data; + + case UsersTypes.LOGOUT_SUCCESS: + return ''; + default: + return state; + } +} + +function teams(state = {}, action) { + switch (action.type) { + case TeamsTypes.RECEIVED_ALL_TEAMS: + case TeamsTypes.RECEIVED_TEAM_LISTINGS: + return Object.assign({}, state, action.data); + + case TeamsTypes.CREATED_TEAM: + case TeamsTypes.UPDATED_TEAM: + return { + ...state, + [action.data.id]: action.data + }; + + case UsersTypes.LOGOUT_SUCCESS: + return {}; + + default: + return state; + } +} + +function myMembers(state = {}, action) { + switch (action.type) { + case TeamsTypes.RECEIVED_MY_TEAM_MEMBERS: { + const nextState = {}; + const members = action.data; + for (const m of members) { + nextState[m.team_id] = m; + } + return nextState; + } + + case TeamsTypes.LEAVE_TEAM: { + const nextState = {...state}; + const data = action.data; + Reflect.deleteProperty(nextState, data.id); + return nextState; + } + case UsersTypes.LOGOUT_SUCCESS: + return {}; + + default: + return state; + } +} + +function membersInTeam(state = {}, action) { + switch (action.type) { + case TeamsTypes.RECEIVED_MEMBER_IN_TEAM: { + const data = action.data; + const members = new Set(state[data.team_id]); + members.add(data.user_id); + return { + ...state, + [data.team_id]: members + }; + } + case TeamsTypes.RECEIVED_MEMBERS_IN_TEAM: { + const data = action.data; + if (data.length) { + const teamId = data[0].team_id; + const members = new Set(state[teamId]); + for (const member of data) { + members.add(member.user_id); + } + + return { + ...state, + [teamId]: members + }; + } + + return state; + } + case TeamsTypes.REMOVE_MEMBER_FROM_TEAM: { + const data = action.data; + const members = state[data.team_id]; + if (members) { + const set = new Set(members); + set.delete(data.user_id); + return { + ...state, + [data.team_id]: set + }; + } + + return state; + } + case UsersTypes.LOGOUT_SUCCESS: + return {}; + default: + return state; + } +} + +function stats(state = {}, action) { + switch (action.type) { + case TeamsTypes.RECEIVED_TEAM_STATS: { + const stat = action.data; + return { + ...state, + [stat.team_id]: stat + }; + } + case UsersTypes.LOGOUT_SUCCESS: + return {}; + default: + return state; + } +} + +function openTeamIds(state = new Set(), action) { + switch (action.type) { + case TeamsTypes.RECEIVED_TEAM_LISTINGS: { + const teamsData = action.data; + const newState = new Set(); + for (const teamId in teamsData) { + if (teamsData.hasOwnProperty(teamId)) { + newState.add(teamId); + } + } + return newState; + } + default: + return state; + } +} + +export default combineReducers({ + + // the current selected team + currentTeamId, + + // object where every key is the team id and has and object with the team detail + teams, + + //object where every key is the team id and has and object with the team members detail + myMembers, + + // object where every key is the team id and has a Set of user ids that are members in the team + membersInTeam, + + // object where every key is the team id and has an object with the team stats + stats, + + // Set with the team ids the user is not a member of + openTeamIds +}); diff --git a/src/reducers/entities/typing.js b/src/reducers/entities/typing.js new file mode 100644 index 000000000..fd1ca27c4 --- /dev/null +++ b/src/reducers/entities/typing.js @@ -0,0 +1,41 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {WebsocketEvents} from 'constants'; + +export default function typing(state = {}, action) { + const {data, type} = action; + switch (type) { + case WebsocketEvents.TYPING: { + const {id, userId} = data; + + if (id && userId) { + return { + ...state, + [id]: { + ...state[id], + [userId]: true + } + }; + } + return state; + } + case WebsocketEvents.STOP_TYPING: { + const nextState = {...state}; + const {id, userId} = data; + const users = {...nextState[id]}; + if (users) { + Reflect.deleteProperty(users, userId); + } + + nextState[id] = users; + if (!Object.keys(nextState[id]).length) { + Reflect.deleteProperty(nextState, id); + } + + return nextState; + } + default: + return state; + } +} diff --git a/src/reducers/entities/users.js b/src/reducers/entities/users.js new file mode 100644 index 000000000..522c8bc5f --- /dev/null +++ b/src/reducers/entities/users.js @@ -0,0 +1,230 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {combineReducers} from 'redux'; +import {UsersTypes} from 'constants'; + +function profilesToSet(state, action) { + const id = action.id; + const nextSet = new Set(state[id]); + Object.keys(action.data).forEach((key) => { + nextSet.add(key); + }); + + return { + ...state, + [id]: nextSet + }; +} + +function addProfileToSet(state, action) { + const id = action.id; + const nextSet = new Set(state[id]); + nextSet.add(action.data.user_id); + return { + ...state, + [id]: nextSet + }; +} + +function removeProfileFromSet(state, action) { + const id = action.id; + const nextSet = new Set(state[id]); + nextSet.delete(action.data.user_id); + return { + ...state, + [id]: nextSet + }; +} + +function currentUserId(state = '', action) { + switch (action.type) { + case UsersTypes.RECEIVED_ME: + return action.data.id; + + case UsersTypes.LOGOUT_SUCCESS: + return ''; + + } + + return state; +} + +function mySessions(state = [], action) { + switch (action.type) { + case UsersTypes.RECEIVED_SESSIONS: + return [...action.data]; + + case UsersTypes.RECEIVED_REVOKED_SESSION: { + let index = -1; + const length = state.length; + for (let i = 0; i < length; i++) { + if (state[i].id === action.data.id) { + index = i; + break; + } + } + if (index > -1) { + return state.slice(0, index).concat(state.slice(index + 1)); + } + + return state; + } + case UsersTypes.LOGOUT_SUCCESS: + return []; + + default: + return state; + } +} + +function myAudits(state = [], action) { + switch (action.type) { + case UsersTypes.RECEIVED_AUDITS: + return [...action.data]; + + case UsersTypes.LOGOUT_SUCCESS: + return []; + + default: + return state; + } +} + +function profiles(state = {}, action) { + switch (action.type) { + case UsersTypes.RECEIVED_ME: { + return { + ...state, + [action.data.id]: {...action.data} + }; + } + case UsersTypes.RECEIVED_PROFILES: + return Object.assign({}, state, action.data); + + case UsersTypes.LOGOUT_SUCCESS: + return {}; + + default: + return state; + } +} + +function profilesInTeam(state = {}, action) { + switch (action.type) { + case UsersTypes.RECEIVED_PROFILES_IN_TEAM: + return profilesToSet(state, action); + + case UsersTypes.LOGOUT_SUCCESS: + return {}; + + default: + return state; + } +} + +function profilesInChannel(state = {}, action) { + switch (action.type) { + case UsersTypes.RECEIVED_PROFILE_IN_CHANNEL: + return addProfileToSet(state, action); + + case UsersTypes.RECEIVED_PROFILES_IN_CHANNEL: + return profilesToSet(state, action); + + case UsersTypes.RECEIVED_PROFILE_NOT_IN_CHANNEL: + return removeProfileFromSet(state, action); + + case UsersTypes.LOGOUT_SUCCESS: + return {}; + + default: + return state; + } +} + +function profilesNotInChannel(state = {}, action) { + switch (action.type) { + case UsersTypes.RECEIVED_PROFILE_NOT_IN_CHANNEL: + return addProfileToSet(state, action); + + case UsersTypes.RECEIVED_PROFILES_NOT_IN_CHANNEL: + return profilesToSet(state, action); + + case UsersTypes.RECEIVED_PROFILE_IN_CHANNEL: + return removeProfileFromSet(state, action); + + case UsersTypes.LOGOUT_SUCCESS: + return {}; + + default: + return state; + } +} + +function statuses(state = {}, action) { + switch (action.type) { + case UsersTypes.RECEIVED_STATUSES: { + return Object.assign({}, state, action.data); + } + case UsersTypes.LOGOUT_SUCCESS: + return {}; + + default: + return state; + } +} + +function autocompleteUsersInChannel(state = {}, action) { + switch (action.type) { + case UsersTypes.RECEIVED_AUTOCOMPLETE_IN_CHANNEL: + return Object.assign({}, state, {[action.channelId]: action.data}); + default: + return state; + } +} + +function search(state = {}, action) { + switch (action.type) { + case UsersTypes.RECEIVED_SEARCH_PROFILES: + return action.data; + + case UsersTypes.LOGOUT_SUCCESS: + return {}; + + default: + return state; + } +} + +export default combineReducers({ + + // the current selected user + currentUserId, + + // array with the user's sessions + mySessions, + + // array with the user's audits + myAudits, + + // object where every key is a user id and has an object with the users details + profiles, + + // object where every key is a team id and has a Set with the users id that are members of the team + profilesInTeam, + + // object where every key is a channel id and has a Set with the users id that are members of the channel + profilesInChannel, + + // object where every key is a channel id and has a Set with the users id that are members of the channel + profilesNotInChannel, + + // object where every key is the user id and has a value with the current status of each user + statuses, + + // object where every key is a channel id and has a [channelId] object that contains members that are in and out of the current channel + autocompleteUsersInChannel, + + // object where every key is a user id and has an object with the users details + search +}); diff --git a/src/reducers/errors/index.js b/src/reducers/errors/index.js new file mode 100644 index 000000000..7372b74e3 --- /dev/null +++ b/src/reducers/errors/index.js @@ -0,0 +1,27 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {ErrorTypes} from 'constants'; + +export default (state = [], action) => { + switch (action.type) { + case ErrorTypes.DISMISS_ERROR: { + const nextState = [...state]; + nextState.splice(action.index, 1); + + return nextState; + } + case ErrorTypes.LOG_ERROR: { + const nextState = [...state]; + const {displayable, error} = action; + nextState.push({displayable, error}); + + return nextState; + } + case ErrorTypes.CLEAR_ERRORS: { + return []; + } + default: + return state; + } +}; diff --git a/src/reducers/index.js b/src/reducers/index.js new file mode 100644 index 000000000..77b028cb4 --- /dev/null +++ b/src/reducers/index.js @@ -0,0 +1,12 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import entities from './entities'; +import errors from './errors'; +import requests from './requests'; + +export default { + entities, + errors, + requests +}; diff --git a/src/reducers/requests/channels.js b/src/reducers/requests/channels.js new file mode 100644 index 000000000..4c7d668c4 --- /dev/null +++ b/src/reducers/requests/channels.js @@ -0,0 +1,175 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {combineReducers} from 'redux'; +import {ChannelTypes} from 'constants'; + +import {handleRequest, initialRequestState} from './helpers'; + +function getChannel(state = initialRequestState(), action) { + return handleRequest( + ChannelTypes.CHANNEL_REQUEST, + ChannelTypes.CHANNEL_SUCCESS, + ChannelTypes.CHANNEL_FAILURE, + state, + action + ); +} + +function getChannels(state = initialRequestState(), action) { + return handleRequest( + ChannelTypes.CHANNELS_REQUEST, + ChannelTypes.CHANNELS_SUCCESS, + ChannelTypes.CHANNELS_FAILURE, + state, + action + ); +} + +function myMembers(state = initialRequestState(), action) { + return handleRequest( + ChannelTypes.CHANNEL_MEMBERS_REQUEST, + ChannelTypes.CHANNEL_MEMBERS_SUCCESS, + ChannelTypes.CHANNEL_MEMBERS_FAILURE, + state, + action + ); +} + +function createChannel(state = initialRequestState(), action) { + return handleRequest( + ChannelTypes.CREATE_CHANNEL_REQUEST, + ChannelTypes.CREATE_CHANNEL_SUCCESS, + ChannelTypes.CREATE_CHANNEL_FAILURE, + state, + action + ); +} + +function updateChannel(state = initialRequestState(), action) { + return handleRequest( + ChannelTypes.UPDATE_CHANNEL_REQUEST, + ChannelTypes.UPDATE_CHANNEL_SUCCESS, + ChannelTypes.UPDATE_CHANNEL_FAILURE, + state, + action + ); +} + +function updateChannelNotifyProps(state = initialRequestState(), action) { + return handleRequest( + ChannelTypes.NOTIFY_PROPS_REQUEST, + ChannelTypes.NOTIFY_PROPS_SUCCESS, + ChannelTypes.NOTIFY_PROPS_FAILURE, + state, + action + ); +} + +function leaveChannel(state = initialRequestState(), action) { + return handleRequest( + ChannelTypes.LEAVE_CHANNEL_REQUEST, + ChannelTypes.LEAVE_CHANNEL_SUCCESS, + ChannelTypes.LEAVE_CHANNEL_FAILURE, + state, + action + ); +} + +function joinChannel(state = initialRequestState(), action) { + return handleRequest( + ChannelTypes.JOIN_CHANNEL_REQUEST, + ChannelTypes.JOIN_CHANNEL_SUCCESS, + ChannelTypes.JOIN_CHANNEL_FAILURE, + state, + action + ); +} + +function deleteChannel(state = initialRequestState(), action) { + return handleRequest( + ChannelTypes.DELETE_CHANNEL_REQUEST, + ChannelTypes.DELETE_CHANNEL_SUCCESS, + ChannelTypes.DELETE_CHANNEL_FAILURE, + state, + action + ); +} + +function updateLastViewedAt(state = initialRequestState(), action) { + return handleRequest( + ChannelTypes.UPDATE_LAST_VIEWED_REQUEST, + ChannelTypes.UPDATE_LAST_VIEWED_SUCCESS, + ChannelTypes.UPDATE_LAST_VIEWED_FAILURE, + state, + action + ); +} + +function getMoreChannels(state = initialRequestState(), action) { + return handleRequest( + ChannelTypes.MORE_CHANNELS_REQUEST, + ChannelTypes.MORE_CHANNELS_SUCCESS, + ChannelTypes.MORE_CHANNELS_FAILURE, + state, + action + ); +} + +function getChannelStats(state = initialRequestState(), action) { + return handleRequest( + ChannelTypes.CHANNEL_STATS_REQUEST, + ChannelTypes.CHANNEL_STATS_SUCCESS, + ChannelTypes.CHANNEL_STATS_FAILURE, + state, + action + ); +} + +function addChannelMember(state = initialRequestState(), action) { + return handleRequest( + ChannelTypes.ADD_CHANNEL_MEMBER_REQUEST, + ChannelTypes.ADD_CHANNEL_MEMBER_SUCCESS, + ChannelTypes.ADD_CHANNEL_MEMBER_FAILURE, + state, + action + ); +} + +function removeChannelMember(state = initialRequestState(), action) { + return handleRequest( + ChannelTypes.REMOVE_CHANNEL_MEMBER_REQUEST, + ChannelTypes.REMOVE_CHANNEL_MEMBER_SUCCESS, + ChannelTypes.REMOVE_CHANNEL_MEMBER_FAILURE, + state, + action + ); +} + +function autocompleteChannels(state = initialRequestState(), action) { + return handleRequest( + ChannelTypes.AUTOCOMPLETE_CHANNELS_REQUEST, + ChannelTypes.AUTOCOMPLETE_CHANNELS_SUCCESS, + ChannelTypes.AUTOCOMPLETE_CHANNELS_FAILURE, + state, + action + ); +} + +export default combineReducers({ + getChannel, + getChannels, + myMembers, + createChannel, + updateChannel, + updateChannelNotifyProps, + leaveChannel, + joinChannel, + deleteChannel, + updateLastViewedAt, + getMoreChannels, + getChannelStats, + addChannelMember, + removeChannelMember, + autocompleteChannels +}); diff --git a/src/reducers/requests/files.js b/src/reducers/requests/files.js new file mode 100644 index 000000000..16b0ae6eb --- /dev/null +++ b/src/reducers/requests/files.js @@ -0,0 +1,21 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {combineReducers} from 'redux'; +import {FilesTypes} from 'constants'; + +import {handleRequest, initialRequestState} from './helpers'; + +function getFilesForPost(state = initialRequestState(), action) { + return handleRequest( + FilesTypes.FETCH_FILES_FOR_POST_REQUEST, + FilesTypes.FETCH_FILES_FOR_POST_SUCCESS, + FilesTypes.FETCH_FILES_FOR_POST_FAILURE, + state, + action + ); +} + +export default combineReducers({ + getFilesForPost +}); diff --git a/src/reducers/requests/general.js b/src/reducers/requests/general.js new file mode 100644 index 000000000..a91c4356a --- /dev/null +++ b/src/reducers/requests/general.js @@ -0,0 +1,58 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {combineReducers} from 'redux'; +import {GeneralTypes} from 'constants'; + +import {handleRequest, initialRequestState} from './helpers'; + +function server(state = initialRequestState(), action) { + if (action.type === GeneralTypes.PING_RESET) { + return initialRequestState(); + } + + return handleRequest( + GeneralTypes.PING_REQUEST, + GeneralTypes.PING_SUCCESS, + GeneralTypes.PING_FAILURE, + state, + action + ); +} + +function config(state = initialRequestState(), action) { + return handleRequest( + GeneralTypes.CLIENT_CONFIG_REQUEST, + GeneralTypes.CLIENT_CONFIG_SUCCESS, + GeneralTypes.CLIENT_CONFIG_FAILURE, + state, + action + ); +} + +function license(state = initialRequestState(), action) { + return handleRequest( + GeneralTypes.CLIENT_LICENSE_REQUEST, + GeneralTypes.CLIENT_LICENSE_SUCCESS, + GeneralTypes.CLIENT_LICENSE_FAILURE, + state, + action + ); +} + +function websocket(state = initialRequestState(), action) { + return handleRequest( + GeneralTypes.WEBSOCKET_REQUEST, + GeneralTypes.WEBSOCKET_SUCCESS, + GeneralTypes.WEBSOCKET_FAILURE, + state, + action + ); +} + +export default combineReducers({ + server, + config, + license, + websocket +}); diff --git a/src/reducers/requests/helpers.js b/src/reducers/requests/helpers.js new file mode 100644 index 000000000..31aa509ea --- /dev/null +++ b/src/reducers/requests/helpers.js @@ -0,0 +1,42 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {RequestStatus} from 'constants'; + +export function initialRequestState() { + return { + status: RequestStatus.NOT_STARTED, + error: null + }; +} + +export function handleRequest(REQUEST, SUCCESS, FAILURE, state, action) { + switch (action.type) { + case REQUEST: + return { + ...state, + status: RequestStatus.STARTED + }; + case SUCCESS: + return { + ...state, + status: RequestStatus.SUCCESS, + error: null + }; + case FAILURE: { + let error = action.error; + + if (error instanceof Error) { + error = error.hasOwnProperty('intl') ? {...error} : error.toString(); + } + + return { + ...state, + status: RequestStatus.FAILURE, + error + }; + } + default: + return state; + } +} diff --git a/src/reducers/requests/index.js b/src/reducers/requests/index.js new file mode 100644 index 000000000..b65caeda9 --- /dev/null +++ b/src/reducers/requests/index.js @@ -0,0 +1,22 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {combineReducers} from 'redux'; + +import channels from './channels'; +import files from './files'; +import general from './general'; +import posts from './posts'; +import teams from './teams'; +import users from './users'; +import preferences from './preferences'; + +export default combineReducers({ + channels, + files, + general, + posts, + teams, + users, + preferences +}); diff --git a/src/reducers/requests/posts.js b/src/reducers/requests/posts.js new file mode 100644 index 000000000..4f9fe7360 --- /dev/null +++ b/src/reducers/requests/posts.js @@ -0,0 +1,98 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {combineReducers} from 'redux'; +import {PostsTypes} from 'constants'; + +import {handleRequest, initialRequestState} from './helpers'; + +function createPost(state = initialRequestState(), action) { + return handleRequest( + PostsTypes.CREATE_POST_REQUEST, + PostsTypes.CREATE_POST_SUCCESS, + PostsTypes.CREATE_POST_FAILURE, + state, + action + ); +} + +function editPost(state = initialRequestState(), action) { + return handleRequest( + PostsTypes.EDIT_POST_REQUEST, + PostsTypes.EDIT_POST_SUCCESS, + PostsTypes.EDIT_POST_FAILURE, + state, + action + ); +} + +function deletePost(state = initialRequestState(), action) { + return handleRequest( + PostsTypes.DELETE_POST_REQUEST, + PostsTypes.DELETE_POST_SUCCESS, + PostsTypes.DELETE_POST_FAILURE, + state, + action + ); +} + +function getPost(state = initialRequestState(), action) { + return handleRequest( + PostsTypes.GET_POST_REQUEST, + PostsTypes.GET_POST_SUCCESS, + PostsTypes.GET_POST_FAILURE, + state, + action + ); +} + +function getPosts(state = initialRequestState(), action) { + return handleRequest( + PostsTypes.GET_POSTS_REQUEST, + PostsTypes.GET_POSTS_SUCCESS, + PostsTypes.GET_POSTS_FAILURE, + state, + action + ); +} + +function getPostsSince(state = initialRequestState(), action) { + return handleRequest( + PostsTypes.GET_POSTS_SINCE_REQUEST, + PostsTypes.GET_POSTS_SINCE_SUCCESS, + PostsTypes.GET_POSTS_SINCE_FAILURE, + state, + action + ); +} + +function getPostsBefore(state = initialRequestState(), action) { + return handleRequest( + PostsTypes.GET_POSTS_BEFORE_REQUEST, + PostsTypes.GET_POSTS_BEFORE_SUCCESS, + PostsTypes.GET_POSTS_BEFORE_FAILURE, + state, + action + ); +} + +function getPostsAfter(state = initialRequestState(), action) { + return handleRequest( + PostsTypes.GET_POSTS_AFTER_REQUEST, + PostsTypes.GET_POSTS_AFTER_SUCCESS, + PostsTypes.GET_POSTS_AFTER_FAILURE, + state, + action + ); +} + +export default combineReducers({ + createPost, + editPost, + deletePost, + getPost, + getPosts, + getPostsSince, + getPostsBefore, + getPostsAfter +}); diff --git a/src/reducers/requests/preferences.js b/src/reducers/requests/preferences.js new file mode 100644 index 000000000..565e88f39 --- /dev/null +++ b/src/reducers/requests/preferences.js @@ -0,0 +1,43 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {combineReducers} from 'redux'; +import {PreferencesTypes} from 'constants'; + +import {handleRequest, initialRequestState} from './helpers'; + +function getMyPreferences(state = initialRequestState(), action) { + return handleRequest( + PreferencesTypes.MY_PREFERENCES_REQUEST, + PreferencesTypes.MY_PREFERENCES_SUCCESS, + PreferencesTypes.MY_PREFERENCES_FAILURE, + state, + action + ); +} + +function savePreferences(state = initialRequestState(), action) { + return handleRequest( + PreferencesTypes.SAVE_PREFERENCES_REQUEST, + PreferencesTypes.SAVE_PREFERENCES_SUCCESS, + PreferencesTypes.SAVE_PREFERENCES_FAILURE, + state, + action + ); +} + +function deletePreferences(state = initialRequestState(), action) { + return handleRequest( + PreferencesTypes.DELETE_PREFERENCES_REQUEST, + PreferencesTypes.DELETE_PREFERENCES_SUCCESS, + PreferencesTypes.DELETE_PREFERENCES_FAILURE, + state, + action + ); +} + +export default combineReducers({ + getMyPreferences, + savePreferences, + deletePreferences +}); diff --git a/src/reducers/requests/teams.js b/src/reducers/requests/teams.js new file mode 100644 index 000000000..ed2639f97 --- /dev/null +++ b/src/reducers/requests/teams.js @@ -0,0 +1,109 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {combineReducers} from 'redux'; +import {TeamsTypes} from 'constants'; + +import {handleRequest, initialRequestState} from './helpers'; + +function allTeams(state = initialRequestState(), action) { + return handleRequest( + TeamsTypes.FETCH_TEAMS_REQUEST, + TeamsTypes.FETCH_TEAMS_SUCCESS, + TeamsTypes.FETCH_TEAMS_FAILURE, + state, + action + ); +} + +function getAllTeamListings(state = initialRequestState(), action) { + return handleRequest( + TeamsTypes.TEAM_LISTINGS_REQUEST, + TeamsTypes.TEAM_LISTINGS_SUCCESS, + TeamsTypes.TEAM_LISTINGS_FAILURE, + state, + action + ); +} + +function createTeam(state = initialRequestState(), action) { + return handleRequest( + TeamsTypes.CREATE_TEAM_REQUEST, + TeamsTypes.CREATE_TEAM_SUCCESS, + TeamsTypes.CREATE_TEAM_FAILURE, + state, + action + ); +} + +function updateTeam(state = initialRequestState(), action) { + return handleRequest( + TeamsTypes.UPDATE_TEAM_REQUEST, + TeamsTypes.UPDATE_TEAM_SUCCESS, + TeamsTypes.UPDATE_TEAM_FAILURE, + state, + action + ); +} + +function getMyTeamMembers(state = initialRequestState(), action) { + return handleRequest( + TeamsTypes.MY_TEAM_MEMBERS_REQUEST, + TeamsTypes.MY_TEAM_MEMBERS_SUCCESS, + TeamsTypes.MY_TEAM_MEMBERS_FAILURE, + state, + action + ); +} + +function getTeamMembers(state = initialRequestState(), action) { + return handleRequest( + TeamsTypes.TEAM_MEMBERS_REQUEST, + TeamsTypes.TEAM_MEMBERS_SUCCESS, + TeamsTypes.TEAM_MEMBERS_FAILURE, + state, + action + ); +} + +function getTeamStats(state = initialRequestState(), action) { + return handleRequest( + TeamsTypes.TEAM_STATS_REQUEST, + TeamsTypes.TEAM_STATS_SUCCESS, + TeamsTypes.TEAM_STATS_FAILURE, + state, + action + ); +} + +function addUserToTeam(state = initialRequestState(), action) { + return handleRequest( + TeamsTypes.ADD_TEAM_MEMBER_REQUEST, + TeamsTypes.ADD_TEAM_MEMBER_SUCCESS, + TeamsTypes.ADD_TEAM_MEMBER_FAILURE, + state, + action + ); +} + +function removeUserFromTeam(state = initialRequestState(), action) { + return handleRequest( + TeamsTypes.REMOVE_TEAM_MEMBER_REQUEST, + TeamsTypes.REMOVE_TEAM_MEMBER_SUCCESS, + TeamsTypes.REMOVE_TEAM_MEMBER_FAILURE, + state, + action + ); +} + +export default combineReducers({ + allTeams, + getAllTeamListings, + createTeam, + updateTeam, + getMyTeamMembers, + getTeamMembers, + getTeamStats, + addUserToTeam, + removeUserFromTeam +}); diff --git a/src/reducers/requests/users.js b/src/reducers/requests/users.js new file mode 100644 index 000000000..799dd91c8 --- /dev/null +++ b/src/reducers/requests/users.js @@ -0,0 +1,191 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {combineReducers} from 'redux'; +import {UsersTypes, RequestStatus} from 'constants'; + +import {handleRequest, initialRequestState} from './helpers'; + +function checkMfa(state = initialRequestState(), action) { + switch (action.type) { + case UsersTypes.CHECK_MFA_REQUEST: + return {...state, status: RequestStatus.STARTED}; + + case UsersTypes.CHECK_MFA_SUCCESS: + return {...state, status: RequestStatus.SUCCESS, error: null}; + + case UsersTypes.CHECK_MFA_FAILURE: + return {...state, status: RequestStatus.FAILURE, error: action.error}; + + case UsersTypes.LOGOUT_SUCCESS: + return {...state, status: RequestStatus.NOT_STARTED, error: null}; + + default: + return state; + } +} + +function login(state = initialRequestState(), action) { + switch (action.type) { + case UsersTypes.LOGIN_REQUEST: + return {...state, status: RequestStatus.STARTED}; + + case UsersTypes.LOGIN_SUCCESS: + return {...state, status: RequestStatus.SUCCESS, error: null}; + + case UsersTypes.LOGIN_FAILURE: + return {...state, status: RequestStatus.FAILURE, error: action.error}; + + case UsersTypes.LOGOUT_SUCCESS: + return {...state, status: RequestStatus.NOT_STARTED, error: null}; + + default: + return state; + } +} + +function logout(state = initialRequestState(), action) { + switch (action.type) { + case UsersTypes.LOGOUT_REQUEST: + return {...state, status: RequestStatus.STARTED}; + + case UsersTypes.LOGOUT_SUCCESS: + return {...state, status: RequestStatus.SUCCESS, error: null}; + + case UsersTypes.LOGOUT_FAILURE: + return {...state, status: RequestStatus.FAILURE, error: action.error}; + + case UsersTypes.RESET_LOGOUT_STATE: + return initialRequestState(); + + default: + return state; + } +} + +function getProfiles(state = initialRequestState(), action) { + return handleRequest( + UsersTypes.PROFILES_REQUEST, + UsersTypes.PROFILES_SUCCESS, + UsersTypes.PROFILES_FAILURE, + state, + action + ); +} + +function getProfilesInTeam(state = initialRequestState(), action) { + return handleRequest( + UsersTypes.PROFILES_IN_TEAM_REQUEST, + UsersTypes.PROFILES_IN_TEAM_SUCCESS, + UsersTypes.PROFILES_IN_TEAM_FAILURE, + state, + action + ); +} + +function getProfilesInChannel(state = initialRequestState(), action) { + return handleRequest( + UsersTypes.PROFILES_IN_CHANNEL_REQUEST, + UsersTypes.PROFILES_IN_CHANNEL_SUCCESS, + UsersTypes.PROFILES_IN_CHANNEL_FAILURE, + state, + action + ); +} + +function getProfilesNotInChannel(state = initialRequestState(), action) { + return handleRequest( + UsersTypes.PROFILES_NOT_IN_CHANNEL_REQUEST, + UsersTypes.PROFILES_NOT_IN_CHANNEL_SUCCESS, + UsersTypes.PROFILES_NOT_IN_CHANNEL_FAILURE, + state, + action + ); +} + +function getStatusesByIds(state = initialRequestState(), action) { + return handleRequest( + UsersTypes.PROFILES_STATUSES_REQUEST, + UsersTypes.PROFILES_STATUSES_SUCCESS, + UsersTypes.PROFILES_STATUSES_FAILURE, + state, + action + ); +} + +function getSessions(state = initialRequestState(), action) { + return handleRequest( + UsersTypes.SESSIONS_REQUEST, + UsersTypes.SESSIONS_SUCCESS, + UsersTypes.SESSIONS_FAILURE, + state, + action + ); +} + +function revokeSession(state = initialRequestState(), action) { + return handleRequest( + UsersTypes.REVOKE_SESSION_REQUEST, + UsersTypes.REVOKE_SESSION_SUCCESS, + UsersTypes.REVOKE_SESSION_FAILURE, + state, + action + ); +} + +function getAudits(state = initialRequestState(), action) { + return handleRequest( + UsersTypes.AUDITS_REQUEST, + UsersTypes.AUDITS_SUCCESS, + UsersTypes.AUDITS_FAILURE, + state, + action + ); +} + +function autocompleteUsersInChannel(state = initialRequestState(), action) { + return handleRequest( + UsersTypes.AUTOCOMPLETE_IN_CHANNEL_REQUEST, + UsersTypes.AUTOCOMPLETE_IN_CHANNEL_SUCCESS, + UsersTypes.AUTOCOMPLETE_IN_CHANNEL_FAILURE, + state, + action + ); +} + +function searchProfiles(state = initialRequestState(), action) { + return handleRequest( + UsersTypes.SEARCH_PROFILES_REQUEST, + UsersTypes.SEARCH_PROFILES_SUCCESS, + UsersTypes.SEARCH_PROFILES_FAILURE, + state, + action + ); +} + +function updateUserNotifyProps(state = initialRequestState(), action) { + return handleRequest( + UsersTypes.UPDATE_NOTIFY_PROPS_REQUEST, + UsersTypes.UPDATE_NOTIFY_PROPS_SUCCESS, + UsersTypes.UPDATE_NOTIFY_PROPS_FAILURE, + state, + action + ); +} + +export default combineReducers({ + checkMfa, + login, + logout, + getProfiles, + getProfilesInTeam, + getProfilesInChannel, + getProfilesNotInChannel, + getStatusesByIds, + getSessions, + revokeSession, + getAudits, + autocompleteUsersInChannel, + searchProfiles, + updateUserNotifyProps +}); diff --git a/src/selectors/entities/channels.js b/src/selectors/entities/channels.js new file mode 100644 index 000000000..0feb6b9f9 --- /dev/null +++ b/src/selectors/entities/channels.js @@ -0,0 +1,172 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {createSelector} from 'reselect'; +import {getCurrentTeamId, getCurrentTeamMembership} from 'selectors/entities/teams'; +import {getCurrentUserId, getUsers} from 'selectors/entities/users'; +import {buildDisplayableChannelList, getNotMemberChannels, completeDirectChannelInfo} from 'utils/channel_utils'; +import {Constants} from 'constants'; + +function getAllChannels(state) { + return state.entities.channels.channels; +} + +function getAllChannelStats(state) { + return state.entities.channels.stats; +} + +export function getCurrentChannelId(state) { + return state.entities.channels.currentChannelId; +} + +export function getChannelMemberships(state) { + return state.entities.channels.myMembers; +} + +export function getAutocompleteChannels(state) { + return state.entities.channels.autocompleteChannels; +} + +export const getCurrentChannel = createSelector( + getAllChannels, + getCurrentChannelId, + (state) => state.entities.users, + (state) => state.entities.preferences.myPreferences, + (allChannels, currentChannelId, users, myPreferences) => { + const channel = allChannels[currentChannelId]; + if (channel) { + return completeDirectChannelInfo(users, myPreferences, channel); + } + return channel; + } +); + +export const getCurrentChannelMembership = createSelector( + getCurrentChannelId, + getChannelMemberships, + (currentChannelId, channelMemberships) => { + return channelMemberships[currentChannelId] || {}; + } +); + +export const getCurrentChannelStats = createSelector( + getAllChannelStats, + getCurrentChannelId, + (allChannelStats, currentChannelId) => { + return allChannelStats[currentChannelId]; + } +); + +export const getChannelsOnCurrentTeam = createSelector( + getAllChannels, + getCurrentTeamId, + (allChannels, currentTeamId) => { + const channels = []; + + for (const channel of Object.values(allChannels)) { + if (channel.team_id === currentTeamId || channel.team_id === '') { + channels.push(channel); + } + } + + return channels; + } +); + +export const getChannelsByCategory = createSelector( + getCurrentChannelId, + getChannelsOnCurrentTeam, + (state) => state.entities.channels.myMembers, + (state) => state.entities.users, + (state) => state.entities.preferences.myPreferences, + (state) => state.entities.teams, + (currentChannelId, channels, myMembers, usersState, myPreferences, teamsState) => { + const allChannels = channels.map((c) => { + const channel = {...c}; + channel.isCurrent = c.id === currentChannelId; + return channel; + }).filter((c) => myMembers.hasOwnProperty(c.id)); + + return buildDisplayableChannelList(usersState, teamsState, allChannels, myPreferences); + } +); + +export const getDefaultChannel = createSelector( + getAllChannels, + getCurrentTeamId, + (channels, teamId) => { + return Object.values(channels).find((c) => c.team_id === teamId && c.name === Constants.DEFAULT_CHANNEL); + } +); + +export const getMoreChannels = createSelector( + getAllChannels, + getChannelMemberships, + (allChannels, myMembers) => { + return getNotMemberChannels(Object.values(allChannels), myMembers); + } +); + +export const getUnreads = createSelector( + getAllChannels, + getChannelMemberships, + (channels, myMembers) => { + let messageCount = 0; + let mentionCount = 0; + Object.keys(myMembers).forEach((channelId) => { + const channel = channels[channelId]; + const m = myMembers[channelId]; + if (channel && m) { + if (channel.type === 'D') { + mentionCount += channel.total_msg_count - m.msg_count; + } else if (m.mention_count > 0) { + mentionCount += m.mention_count; + } + if (m.notify_props && m.notify_props.mark_unread !== 'mention' && channel.total_msg_count - m.msg_count > 0) { + messageCount += 1; + } + } + }); + + return {messageCount, mentionCount}; + } +); + +export const getAutocompleteChannelWithSections = createSelector( + getChannelMemberships, + getAutocompleteChannels, + (myMembers, autocompleteChannels) => { + const channels = { + myChannels: [], + otherChannels: [] + }; + autocompleteChannels.forEach((c) => { + if (myMembers[c.id]) { + channels.myChannels.push(c); + } else { + channels.otherChannels.push(c); + } + }); + + return channels; + } +); + +export const canManageChannelMembers = createSelector( + getCurrentChannel, + getCurrentChannelMembership, + getCurrentTeamMembership, + getUsers, + getCurrentUserId, + (channel, channelMembership, teamMembership, allUsers, currentUserId) => { + const user = allUsers[currentUserId]; + const roles = `${channelMembership.roles} ${teamMembership.roles} ${user.roles}`; + if (channel.type === Constants.DM_CHANNEL || channel.name === Constants.DEFAULT_CHANNEL) { + return false; + } + if (channel.type === Constants.OPEN_CHANNEL) { + return true; + } + return roles.includes('_admin'); + } +); diff --git a/src/selectors/entities/files.js b/src/selectors/entities/files.js new file mode 100644 index 000000000..f0e6f448e --- /dev/null +++ b/src/selectors/entities/files.js @@ -0,0 +1,21 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {createSelector} from 'reselect'; + +function getAllFiles(state) { + return state.entities.files.files; +} + +function getFilesIdsForPost(state, props) { + return state.entities.files.fileIdsByPostId[props.post.id] || []; +} + +export function makeGetFilesForPost() { + return createSelector( + [getAllFiles, getFilesIdsForPost], + (allFiles, fileIdsForPost) => { + return fileIdsForPost.map((id) => allFiles[id]); + } + ); +} diff --git a/src/selectors/entities/general.js b/src/selectors/entities/general.js new file mode 100644 index 000000000..e1342d66e --- /dev/null +++ b/src/selectors/entities/general.js @@ -0,0 +1,6 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +export function getCurrentUrl(state) { + return state.entities.general.credentials.url; +} diff --git a/src/selectors/entities/posts.js b/src/selectors/entities/posts.js new file mode 100644 index 000000000..a1f76e8fe --- /dev/null +++ b/src/selectors/entities/posts.js @@ -0,0 +1,44 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {createSelector} from 'reselect'; + +export function getAllPosts(state) { + return state.entities.posts.posts; +} + +function getPostIdsInCurrentChannel(state) { + return state.entities.posts.postsByChannel[state.entities.channels.currentChannelId] || []; +} + +export const getPostsInCurrentChannel = createSelector( + getAllPosts, + getPostIdsInCurrentChannel, + (posts, postIds) => { + return postIds.map((id) => posts[id]); + } +); + +// Returns a function that creates a creates a selector that will get the posts for a given thread. +// That selector will take a props object (containing a channelId field and a rootId field) as its +// only argument and will be memoized based on that argument. +export function makeGetPostsForThread() { + return createSelector( + getAllPosts, + (state, props) => state.entities.posts.postsByChannel[props.channelId], + (state, props) => props, + (posts, postIds, {rootId}) => { + const thread = []; + + for (const id of postIds) { + const post = posts[id]; + + if (id === rootId || post.root_id === rootId) { + thread.push(post); + } + } + + return thread; + } + ); +} diff --git a/src/selectors/entities/preferences.js b/src/selectors/entities/preferences.js new file mode 100644 index 000000000..8dbfbb936 --- /dev/null +++ b/src/selectors/entities/preferences.js @@ -0,0 +1,6 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +export function getMyPreferences(state) { + return state.entities.preferences.myPreferences; +} diff --git a/src/selectors/entities/teams.js b/src/selectors/entities/teams.js new file mode 100644 index 000000000..980aac145 --- /dev/null +++ b/src/selectors/entities/teams.js @@ -0,0 +1,54 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {createSelector} from 'reselect'; + +import {getCurrentUrl} from './general'; + +export function getCurrentTeamId(state) { + return state.entities.teams.currentTeamId; +} + +export function getTeams(state) { + return state.entities.teams.teams; +} + +export function getTeamStats(state) { + return state.entities.teams.stats; +} + +export function getTeamMemberships(state) { + return state.entities.teams.myMembers; +} + +export const getCurrentTeam = createSelector( + getTeams, + getCurrentTeamId, + (teams, currentTeamId) => { + return teams[currentTeamId]; + } +); + +export const getCurrentTeamMembership = createSelector( + getCurrentTeamId, + getTeamMemberships, + (currentTeamId, teamMemberships) => { + return teamMemberships[currentTeamId]; + } +); + +export const getCurrentTeamUrl = createSelector( + getCurrentUrl, + getCurrentTeam, + (currentUrl, currentTeam) => { + return `${currentUrl}/${currentTeam.name}`; + } +); + +export const getCurrentTeamStats = createSelector( + getCurrentTeamId, + getTeamStats, + (currentTeamId, teamStats) => { + return teamStats[currentTeamId]; + } +); diff --git a/src/selectors/entities/typing.js b/src/selectors/entities/typing.js new file mode 100644 index 000000000..39f01b254 --- /dev/null +++ b/src/selectors/entities/typing.js @@ -0,0 +1,31 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {createSelector} from 'reselect'; +import {getCurrentChannelId} from './channels'; +import {getMyPreferences} from './preferences'; +import {getUsers} from './users'; +import {displayUsername} from 'utils/user_utils'; + +export const getUsersTyping = createSelector( + getUsers, + getMyPreferences, + getCurrentChannelId, + (state) => state.entities.posts.selectedPostId, + (state) => state.entities.typing, + (profiles, preferences, channelId, parentPostId, typing) => { + const id = channelId + parentPostId; + + if (typing[id]) { + const users = Object.keys(typing[id]); + + if (users.length) { + return users.map((userId) => { + return displayUsername(profiles[userId], preferences); + }); + } + } + + return []; + } +); diff --git a/src/selectors/entities/users.js b/src/selectors/entities/users.js new file mode 100644 index 000000000..7e81996c3 --- /dev/null +++ b/src/selectors/entities/users.js @@ -0,0 +1,126 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {createSelector} from 'reselect'; + +import {getCurrentChannelId, getCurrentChannelMembership} from './channels'; +import {getCurrentTeamMembership} from './teams'; + +export function getCurrentUserId(state) { + return state.entities.users.currentUserId; +} + +export function getProfilesInChannel(state) { + return state.entities.users.profilesInChannel; +} + +export function getProfilesNotInChannel(state) { + return state.entities.users.profilesNotInChannel; +} + +export function getUserStatuses(state) { + return state.entities.users.statuses; +} + +export function getUser(state, id) { + return state.entities.users.profiles[id]; +} + +export function getUsers(state) { + return state.entities.users.profiles; +} + +export function getAutocompleteUsersInChannel(state) { + return state.entities.users.autocompleteUsersInChannel; +} + +export const getCurrentUser = createSelector( + getUsers, + getCurrentUserId, + (profiles, currentUserId) => { + return profiles[currentUserId]; + } +); + +export const getCurrentUserRoles = createSelector( + getCurrentChannelMembership, + getCurrentTeamMembership, + getCurrentUser, + (currentChannelMembership, currentTeamMembership, currentUser) => { + return `${currentTeamMembership.roles} ${currentChannelMembership.roles} ${currentUser.roles}`; + } +); + +export const getProfileSetInCurrentChannel = createSelector( + getCurrentChannelId, + getProfilesInChannel, + (currentChannel, channelProfiles) => { + return channelProfiles[currentChannel]; + } +); + +export const getProfileSetNotInCurrentChannel = createSelector( + getCurrentChannelId, + getProfilesNotInChannel, + (currentChannel, channelProfiles) => { + return channelProfiles[currentChannel]; + } +); + +function sortAndInjectProfiles(profiles, profileSet) { + const currentProfiles = []; + if (typeof profileSet === 'undefined') { + return currentProfiles; + } + + profileSet.forEach((p) => { + currentProfiles.push(profiles[p]); + }); + + const sortedCurrentProfiles = currentProfiles.sort((a, b) => { + const nameA = a.username; + const nameB = b.username; + + return nameA.localeCompare(nameB); + }); + + return sortedCurrentProfiles; +} + +export const getProfilesInCurrentChannel = createSelector( + getUsers, + getProfileSetInCurrentChannel, + (profiles, currentChannelProfileSet) => sortAndInjectProfiles(profiles, currentChannelProfileSet) +); + +export const getProfilesNotInCurrentChannel = createSelector( + getUsers, + getProfileSetNotInCurrentChannel, + (profiles, notInCurrentChannelProfileSet) => sortAndInjectProfiles(profiles, notInCurrentChannelProfileSet) +); + +export function getStatusForUserId(state, userId) { + return getUserStatuses(state)[userId]; +} + +export const getAutocompleteUsersInCurrentChannel = createSelector( + getCurrentChannelId, + getAutocompleteUsersInChannel, + (currentChannelId, autocompleteUsersInChannel) => { + return autocompleteUsersInChannel[currentChannelId] || {}; + } +); + +export const searchProfiles = createSelector( + (state) => state.entities.users.search, + getCurrentUserId, + (users, currentUserId) => { + const profiles = {...users}; + return Object.values(profiles).sort((a, b) => { + const nameA = a.username; + const nameB = b.username; + + return nameA.localeCompare(nameB); + }).filter((p) => p.id !== currentUserId); + } +); diff --git a/src/selectors/errors.js b/src/selectors/errors.js new file mode 100644 index 000000000..89d61787c --- /dev/null +++ b/src/selectors/errors.js @@ -0,0 +1,6 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +export function getDisplayableErrors(state) { + return state.errors.filter((error) => error.displayable); +} diff --git a/src/store/configureStore.dev.js b/src/store/configureStore.dev.js new file mode 100644 index 000000000..7d9d63948 --- /dev/null +++ b/src/store/configureStore.dev.js @@ -0,0 +1,57 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {applyMiddleware, compose, createStore, combineReducers} from 'redux'; +import {enableBatching} from 'redux-batched-actions'; +import devTools from 'remote-redux-devtools'; +import thunk from 'redux-thunk'; + +import serviceReducer from 'reducers'; +import deepFreezeAndThrowOnMutation from 'utils/deep_freeze'; + +export default function configureServiceStore(preloadedState, appReducer, getAppReducer) { + const store = createStore( + createReducer(serviceReducer, appReducer), + preloadedState, + compose( + applyMiddleware(thunk), + devTools({ + name: 'Mattermost', + hostname: 'localhost', + port: 5678 + }) + ) + ); + + if (module.hot) { + // Enable Webpack hot module replacement for reducers + module.hot.accept(() => { + const nextServiceReducer = require('../reducers').default; // eslint-disable-line global-require + let nextAppReducer; + if (getAppReducer) { + nextAppReducer = getAppReducer(); // eslint-disable-line global-require + } + store.replaceReducer(createReducer(nextServiceReducer, nextAppReducer)); + }); + } + + return store; +} + +function createReducer(...reducers) { + const baseReducer = combineReducers(Object.assign({}, ...reducers)); + + return enableFreezing(enableBatching(baseReducer)); +} + +function enableFreezing(reducer) { + return (state, action) => { + const nextState = reducer(state, action); + + if (nextState !== state) { + deepFreezeAndThrowOnMutation(nextState); + } + + return nextState; + }; +} diff --git a/src/store/configureStore.prod.js b/src/store/configureStore.prod.js new file mode 100644 index 000000000..84768c9aa --- /dev/null +++ b/src/store/configureStore.prod.js @@ -0,0 +1,16 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {applyMiddleware, createStore, combineReducers} from 'redux'; +import {enableBatching} from 'redux-batched-actions'; +import thunk from 'redux-thunk'; +import serviceReducer from 'reducers'; + +export default function configureServiceStore(preloadedState, appReducer) { + const baseReducer = combineReducers(Object.assign({}, serviceReducer, appReducer)); + return createStore( + enableBatching(baseReducer), + preloadedState, + applyMiddleware(thunk) + ); +} diff --git a/src/store/index.js b/src/store/index.js new file mode 100644 index 000000000..05ec67267 --- /dev/null +++ b/src/store/index.js @@ -0,0 +1,12 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +/* eslint-disable global-require, no-process-env */ + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./configureStore.prod.js'); +} else { + module.exports = require('./configureStore.dev.js'); +} + +/* eslint-enable global-require, no-process-env */ \ No newline at end of file diff --git a/src/utils/channel_utils.js b/src/utils/channel_utils.js new file mode 100644 index 000000000..4c3dfc9b7 --- /dev/null +++ b/src/utils/channel_utils.js @@ -0,0 +1,187 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {Constants} from '../constants'; +import {displayUsername} from './user_utils'; +import {getPreferencesByCategory} from './preference_utils'; + +const defaultPrefix = 'D'; // fallback for future types +const typeToPrefixMap = {[Constants.OPEN_CHANNEL]: 'A', [Constants.PRIVATE_CHANNEL]: 'B', [Constants.DM_CHANNEL]: 'C'}; + +export function buildDisplayableChannelList(usersState, teamsState, allChannels, myPreferences) { + const missingDMChannels = createMissingDirectChannels(usersState.currentUserId, allChannels, myPreferences); + const channels = allChannels. + concat(missingDMChannels). + map(completeDirectChannelInfo.bind(null, usersState, myPreferences)); + + channels.sort((a, b) => { + const locale = usersState.profiles[usersState.currentUserId].locale; + + return buildDisplayNameAndTypeComparable(a). + localeCompare(buildDisplayNameAndTypeComparable(b), locale, {numeric: true}); + }); + + const favoriteChannels = channels.filter(isFavoriteChannel.bind(null, myPreferences)); + const notFavoriteChannels = channels.filter(not(isFavoriteChannel.bind(null, myPreferences))); + const directChannels = notFavoriteChannels. + filter( + andX( + isDirectChannel, + isDirectChannelVisible.bind(null, usersState.currentUserId, myPreferences) + ) + ); + + return { + favoriteChannels, + publicChannels: notFavoriteChannels.filter(isOpenChannel), + privateChannels: notFavoriteChannels.filter(isPrivateChannel), + directChannels: directChannels.filter( + isConnectedToTeamMember.bind(null, teamsState.membersInTeam[teamsState.currentTeamId]) + ), + directNonTeamChannels: directChannels.filter( + isNotConnectedToTeamMember.bind(null, teamsState.membersInTeam[teamsState.currentTeamId]) + ) + }; +} + +export function getNotMemberChannels(allChannels, myMembers) { + return allChannels.filter(not(isNotMemberOf.bind(this, myMembers))); +} + +export function getDirectChannelName(id, otherId) { + let handle; + + if (otherId > id) { + handle = id + '__' + otherId; + } else { + handle = otherId + '__' + id; + } + + return handle; +} + +export function getChannelByName(channels, name) { + const channelIds = Object.keys(channels); + for (let i = 0; i < channelIds.length; i++) { + const id = channelIds[i]; + if (channels[id].name === name) { + return channels[id]; + } + } + return null; +} + +function isOpenChannel(channel) { + return channel.type === Constants.OPEN_CHANNEL; +} + +function isPrivateChannel(channel) { + return channel.type === Constants.PRIVATE_CHANNEL; +} + +function isConnectedToTeamMember(members, channel) { + return members && members.has(channel.teammate_id); +} + +function isNotConnectedToTeamMember(members, channel) { + if (!members) { + return true; + } + return !members.has(channel.teammate_id); +} + +function isDirectChannel(channel) { + return channel.type === Constants.DM_CHANNEL; +} + +export function isDirectChannelVisible(userId, myPreferences, channel) { + const channelId = getUserIdFromChannelName(userId, channel.name); + const dm = myPreferences[`${Constants.CATEGORY_DIRECT_CHANNEL_SHOW}--${channelId}`]; + return dm && dm.value === 'true'; +} + +function isFavoriteChannel(myPreferences, channel) { + const fav = myPreferences[`${Constants.CATEGORY_FAVORITE_CHANNEL}--${channel.id}`]; + channel.isFavorite = fav && fav.value === 'true'; + return channel.isFavorite; +} + +function createMissingDirectChannels(currentUserId, allChannels, myPreferences) { + const preferences = getPreferencesByCategory(myPreferences, Constants.CATEGORY_DIRECT_CHANNEL_SHOW); + + return Array. + from(preferences). + filter((entry) => entry[1] === 'true'). + map((entry) => entry[0]). + filter((teammateId) => !allChannels.some(isDirectChannelForUser.bind(null, currentUserId, teammateId))). + map(createFakeChannelCurried(currentUserId)); +} + +function isDirectChannelForUser(userId, otherUserId, channel) { + return channel.type === Constants.DM_CHANNEL && getUserIdFromChannelName(userId, channel.name) === otherUserId; +} + +function isNotMemberOf(myMembers, channel) { + return myMembers[channel.id]; +} + +export function getUserIdFromChannelName(userId, channelName) { + const ids = channelName.split('__'); + let otherUserId = ''; + if (ids[0] === userId) { + otherUserId = ids[1]; + } else { + otherUserId = ids[0]; + } + + return otherUserId; +} + +function createFakeChannel(userId, otherUserId) { + return { + name: getDirectChannelName(userId, otherUserId), + last_post_at: 0, + total_msg_count: 0, + type: Constants.DM_CHANNEL, + fake: true + }; +} + +function createFakeChannelCurried(userId) { + return (otherUserId) => createFakeChannel(userId, otherUserId); +} + +export function completeDirectChannelInfo(usersState, myPreferences, channel) { + if (!isDirectChannel(channel)) { + return channel; + } + + const dmChannelClone = {...channel}; + const teammateId = getUserIdFromChannelName(usersState.currentUserId, channel.name); + + return Object.assign(dmChannelClone, { + display_name: displayUsername(usersState.profiles[teammateId], myPreferences), + teammate_id: teammateId, + status: usersState.statuses[teammateId] || 'offline' + }); +} + +export function buildDisplayNameAndTypeComparable(channel) { + return (typeToPrefixMap[channel.type] || defaultPrefix) + channel.display_name.toLocaleLowerCase() + channel.name.toLocaleLowerCase(); +} + +function not(f) { + return (...args) => !f(...args); +} + +function andX(...fns) { + return (...args) => fns.every((f) => f(...args)); +} + +export function cleanUpUrlable(input) { + let cleaned = input.trim().replace(/-/g, ' ').replace(/[^\w\s]/gi, '').toLowerCase().replace(/\s/g, '-'); + cleaned = cleaned.replace(/-{2,}/, '-'); + cleaned = cleaned.replace(/^-+/, ''); + cleaned = cleaned.replace(/-+$/, ''); + return cleaned; +} diff --git a/src/utils/deep_freeze.js b/src/utils/deep_freeze.js new file mode 100644 index 000000000..caaa16b10 --- /dev/null +++ b/src/utils/deep_freeze.js @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +/** + * If your application is accepting different values for the same field over + * time and is doing a diff on them, you can either (1) create a copy or + * (2) ensure that those values are not mutated behind two passes. + * This function helps you with (2) by freezing the object and throwing if + * the user subsequently modifies the value. + * + * There are two caveats with this function: + * - If the call site is not in strict mode, it will only throw when + * mutating existing fields, adding a new one + * will unfortunately fail silently :( + * - If the object is already frozen or sealed, it will not continue the + * deep traversal and will leave leaf nodes unfrozen. + * + * Freezing the object and adding the throw mechanism is expensive and will + * only be used in DEV. + */ +export default function deepFreezeAndThrowOnMutation(object) { + if (typeof object !== 'object' || object === null || Object.isFrozen(object) || Object.isSealed(object)) { + return object; + } + + for (const key in object) { + if (object.hasOwnProperty(key)) { + object.__defineGetter__(key, identity.bind(null, object[key])); // eslint-disable-line no-underscore-dangle + object.__defineSetter__(key, throwOnImmutableMutation.bind(null, key)); // eslint-disable-line no-underscore-dangle + } + } + + Object.freeze(object); + Object.seal(object); + + for (const key in object) { + if (object.hasOwnProperty(key)) { + deepFreezeAndThrowOnMutation(object[key]); + } + } + + return object; +} + +function throwOnImmutableMutation(key, value) { + throw Error( + 'You attempted to set the key `' + key + '` with the value `' + + JSON.stringify(value) + '` on an object that is meant to be immutable ' + + 'and has been frozen.' + ); +} + +function identity(value) { + return value; +} diff --git a/src/utils/event_emitter.js b/src/utils/event_emitter.js new file mode 100644 index 000000000..93d69d02a --- /dev/null +++ b/src/utils/event_emitter.js @@ -0,0 +1,59 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +function isFunction(obj) { + return typeof obj === 'function'; +} + +class EventEmitter { + constructor() { + this.listeners = new Map(); + } + + addListener(label, callback) { + if (!this.listeners.has(label)) { + this.listeners.set(label, []); + } + this.listeners.get(label).push(callback); + } + + on(label, callback) { + this.addListener(label, callback); + } + + removeListener(label, callback) { + const listeners = this.listeners.get(label); + let index; + + if (listeners && listeners.length) { + index = listeners.reduce((i, listener, idx) => { + return (isFunction(listener) && listener === callback) ? idx : i; + }, -1); + + if (index > -1) { + listeners.splice(index, 1); + this.listeners.set(label, listeners); + return true; + } + } + return false; + } + + off(label, callback) { + this.removeListener(label, callback); + } + + emit(label, ...args) { + const listeners = this.listeners.get(label); + + if (listeners && listeners.length) { + listeners.forEach((listener) => { + listener(...args); + }); + return true; + } + return false; + } +} + +export default new EventEmitter(); diff --git a/src/utils/file_utils.js b/src/utils/file_utils.js new file mode 100644 index 000000000..d2ed8b1a8 --- /dev/null +++ b/src/utils/file_utils.js @@ -0,0 +1,42 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {Constants} from 'constants'; + +export function getFormattedFileSize(file) { + const bytes = file.size; + const fileSizes = [ + ['TB', 1024 * 1024 * 1024 * 1024], + ['GB', 1024 * 1024 * 1024], + ['MB', 1024 * 1024], + ['KB', 1024] + ]; + const size = fileSizes.find((unitAndMinBytes) => { + const minBytes = unitAndMinBytes[1]; + return bytes > minBytes; + }); + if (size) { + return `${Math.floor(bytes / size[1])} ${size[0]}`; + } + return `${bytes} B`; +} + +export function getFileType(file) { + const fileExt = file.extension.toLowerCase(); + const fileTypes = [ + 'image', + 'code', + 'pdf', + 'video', + 'audio', + 'spreadsheet', + 'word', + 'presentation', + 'patch' + ]; + return fileTypes.find((fileType) => { + const constForFileTypeExtList = `${fileType}_types`.toUpperCase(); + const fileTypeExts = Constants[constForFileTypeExtList]; + return fileTypeExts.indexOf(fileExt) > -1; + }) || 'other'; +} diff --git a/src/utils/key_mirror.js b/src/utils/key_mirror.js new file mode 100644 index 000000000..4e3c1f9c9 --- /dev/null +++ b/src/utils/key_mirror.js @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +/** + * Constructs an enumeration with keys equal to their value. + * + * For example: + * + * var COLORS = keyMirror({blue: null, red: null}); + * var myColor = COLORS.blue; + * var isColorValid = !!COLORS[myColor]; + * + * The last line could not be performed if the values of the generated enum were + * not equal to their keys. + * + * Input: {key1: val1, key2: val2} + * Output: {key1: key1, key2: key2} + * + * @param {object} obj + * @return {object} + */ +export default function keyMirror(obj) { + if (!(obj instanceof Object && !Array.isArray(obj))) { + throw new Error('keyMirror(...): Argument must be an object.'); + } + + const ret = {}; + for (const key in obj) { + if (!obj.hasOwnProperty(key)) { + continue; + } + + ret[key] = key; + } + + return ret; +} diff --git a/src/utils/post_utils.js b/src/utils/post_utils.js new file mode 100644 index 000000000..85a917b44 --- /dev/null +++ b/src/utils/post_utils.js @@ -0,0 +1,51 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {Constants} from 'constants'; + +export function isSystemMessage(post) { + return post.type && post.type.startsWith(Constants.SYSTEM_MESSAGE_PREFIX); +} + +export function shouldIgnorePost(post) { + return Constants.IGNORE_POST_TYPES.includes(post.type); +} + +export function addDatesToPostList(posts, options = {}) { + const {indicateNewMessages, currentUserId, lastViewedAt} = options; + + const out = []; + + let lastDate = null; + let subsequentPostIsUnread = false; + let subsequentPostUserId; + let postIsUnread; + for (const post of posts) { + if (post.state === Constants.POST_DELETED && post.user_id === currentUserId) { + continue; + } + postIsUnread = post.create_at > lastViewedAt; + if (indicateNewMessages && subsequentPostIsUnread && !postIsUnread && subsequentPostUserId !== currentUserId) { + out.push(Constants.START_OF_NEW_MESSAGES); + } + subsequentPostIsUnread = postIsUnread; + subsequentPostUserId = post.user_id; + + const postDate = new Date(post.create_at); + + // Push on a date header if the last post was on a different day than the current one + if (lastDate && lastDate.toDateString() !== postDate.toDateString()) { + out.push(lastDate); + } + + lastDate = postDate; + out.push(post); + } + + // Push on the date header for the oldest post + if (lastDate) { + out.push(lastDate); + } + + return out; +} diff --git a/src/utils/preference_utils.js b/src/utils/preference_utils.js new file mode 100644 index 000000000..14e2618a4 --- /dev/null +++ b/src/utils/preference_utils.js @@ -0,0 +1,18 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +export function getPreferenceKey(category, name) { + return `${category}--${name}`; +} + +export function getPreferencesByCategory(myPreferences, category) { + const prefix = `${category}--`; + const preferences = new Map(); + Object.keys(myPreferences).forEach((key) => { + if (key.startsWith(prefix)) { + preferences.set(key.substring(prefix.length), myPreferences[key]); + } + }); + + return preferences; +} diff --git a/src/utils/user_utils.js b/src/utils/user_utils.js new file mode 100644 index 000000000..5635000c0 --- /dev/null +++ b/src/utils/user_utils.js @@ -0,0 +1,50 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {Constants} from 'constants'; + +export function getFullName(user) { + if (user.first_name && user.last_name) { + return user.first_name + ' ' + user.last_name; + } else if (user.first_name) { + return user.first_name; + } else if (user.last_name) { + return user.last_name; + } + + return ''; +} + +export function displayUsername(user, myPreferences) { + let nameFormat = 'false'; + const pref = myPreferences[`${Constants.CATEGORY_DISPLAY_SETTINGS}--name_format`]; + if (pref && pref.value) { + nameFormat = pref.value; + } + let username = ''; + + if (user) { + if (nameFormat === Constants.DISPLAY_PREFER_NICKNAME) { + username = user.nickname || getFullName(user); + } else if (nameFormat === Constants.DISPLAY_PREFER_FULL_NAME) { + username = getFullName(user); + } + + if (!username.trim().length) { + username = user.username; + } + } + return username; +} + +export function isAdmin(roles) { + return isSystemAdmin(roles) || isTeamAdmin(roles); +} + +export function isTeamAdmin(roles) { + return roles.includes(Constants.TEAM_ADMIN_ROLE); +} + +export function isSystemAdmin(roles) { + return roles.includes(Constants.SYSTEM_ADMIN_ROLE); +} \ No newline at end of file diff --git a/test/.eslintrc.json b/test/.eslintrc.json new file mode 100644 index 000000000..ffa0ad3c3 --- /dev/null +++ b/test/.eslintrc.json @@ -0,0 +1,13 @@ +{ + "rules": { + "no-console": 0, + "global-require": 0, + "func-names": 0, + "prefer-arrow-callback": 0, + "no-magic-numbers": 0, + "no-unreachable": 0, + "new-cap": 0, + "max-nested-callbacks": 0, + "no-undefined": 0 + } +} diff --git a/test/actions/channels.test.js b/test/actions/channels.test.js new file mode 100644 index 000000000..0396bd376 --- /dev/null +++ b/test/actions/channels.test.js @@ -0,0 +1,449 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import assert from 'assert'; + +import * as Actions from 'actions/channels'; +import {getProfilesByIds} from 'actions/users'; +import Client from 'client'; +import configureStore from 'store'; +import {RequestStatus} from 'constants'; +import TestHelper from 'test/test_helper'; + +describe('Actions.Channels', () => { + let store; + let secondChannel; + before(async () => { + await TestHelper.initBasic(Client); + }); + + beforeEach(() => { + store = configureStore(); + }); + + after(async () => { + await TestHelper.basicClient.logout(); + }); + + it('selectChannel', async () => { + const channelId = TestHelper.generateId(); + + await Actions.selectChannel(channelId)(store.dispatch, store.getState); + + const state = store.getState(); + assert.equal(state.entities.channels.currentChannelId, channelId); + }); + + it('createChannel', async () => { + const channel = { + team_id: TestHelper.basicTeam.id, + name: 'redux-test', + display_name: 'Redux Test', + purpose: 'This is to test redux', + header: 'MM with Redux', + type: 'O' + }; + + await Actions.createChannel(channel, TestHelper.basicUser.id)(store.dispatch, store.getState); + const createRequest = store.getState().requests.channels.createChannel; + const membersRequest = store.getState().requests.channels.myMembers; + if (createRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(createRequest.error)); + } else if (membersRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(membersRequest.error)); + } + const {channels, myMembers} = store.getState().entities.channels; + const channelsCount = Object.keys(channels).length; + const membersCount = Object.keys(myMembers).length; + assert.ok(channels); + assert.ok(myMembers); + assert.ok(channels[Object.keys(myMembers)[0]]); + assert.ok(myMembers[Object.keys(channels)[0]]); + assert.equal(myMembers[Object.keys(channels)[0]].user_id, TestHelper.basicUser.id); + assert.equal(channelsCount, membersCount); + assert.equal(channelsCount, 1); + assert.equal(membersCount, 1); + }); + + it('createDirectChannel', async () => { + const user = await TestHelper.basicClient.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + + await getProfilesByIds([user.id])(store.dispatch, store.getState); + await Actions.createDirectChannel(TestHelper.basicTeam.id, TestHelper.basicUser.id, user.id)(store.dispatch, store.getState); + + const createRequest = store.getState().requests.channels.createChannel; + if (createRequest.status === RequestStatus.FAILURE) { + throw new Error(createRequest.error); + } + + const state = store.getState(); + const {channels, myMembers} = state.entities.channels; + const profiles = state.entities.users.profiles; + const preferences = state.entities.preferences.myPreferences; + const channelsCount = Object.keys(channels).length; + const membersCount = Object.keys(myMembers).length; + + assert.ok(channels, 'channels is empty'); + assert.ok(myMembers, 'members is empty'); + assert.ok(profiles[user.id], 'profiles does not have userId'); + assert.ok(Object.keys(preferences).length, 'preferences is empty'); + assert.ok(channels[Object.keys(myMembers)[0]], 'channels should have the member'); + assert.ok(myMembers[Object.keys(channels)[0]], 'members should belong to channel'); + assert.equal(myMembers[Object.keys(channels)[0]].user_id, TestHelper.basicUser.id); + assert.equal(channelsCount, membersCount); + assert.equal(channels[Object.keys(channels)[0]].type, 'D'); + assert.equal(channelsCount, 1); + assert.equal(membersCount, 1); + }); + + it('updateChannel', async () => { + const channel = { + ...TestHelper.basicChannel, + purpose: 'This is to test redux', + header: 'MM with Redux' + }; + + await Actions.updateChannel(channel)(store.dispatch, store.getState); + + const updateRequest = store.getState().requests.channels.updateChannel; + if (updateRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(updateRequest.error)); + } + + const {channels} = store.getState().entities.channels; + const channelId = Object.keys(channels)[0]; + assert.ok(channelId); + assert.ok(channels[channelId]); + assert.strictEqual(channels[channelId].header, 'MM with Redux'); + }); + + it('getChannel', async () => { + await Actions.getChannel(TestHelper.basicTeam.id, TestHelper.basicChannel.id)(store.dispatch, store.getState); + + const channelRequest = store.getState().requests.channels.getChannel; + if (channelRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(channelRequest.error)); + } + + const {channels, myMembers} = store.getState().entities.channels; + assert.ok(channels[TestHelper.basicChannel.id]); + assert.ok(myMembers[TestHelper.basicChannel.id]); + }); + + it('fetchMyChannelsAndMembers', async () => { + await Actions.fetchMyChannelsAndMembers(TestHelper.basicTeam.id)(store.dispatch, store.getState); + + const channelsRequest = store.getState().requests.channels.getChannels; + const membersRequest = store.getState().requests.channels.myMembers; + if (channelsRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(channelsRequest.error)); + } else if (membersRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(membersRequest.error)); + } + + const {channels, myMembers} = store.getState().entities.channels; + assert.ok(channels); + assert.ok(myMembers); + assert.ok(channels[Object.keys(myMembers)[0]]); + assert.ok(myMembers[Object.keys(channels)[0]]); + assert.equal(Object.keys(channels).length, Object.keys(myMembers).length); + }); + + it('updateChannelNotifyProps', async () => { + const notifyProps = { + mark_unread: 'mention', + desktop: 'none' + }; + + await Actions.fetchMyChannelsAndMembers(TestHelper.basicTeam.id)(store.dispatch, store.getState); + await Actions.updateChannelNotifyProps( + TestHelper.basicUser.id, + TestHelper.basicTeam.id, + TestHelper.basicChannel.id, + notifyProps)(store.dispatch, store.getState); + + const updateRequest = store.getState().requests.channels.updateChannelNotifyProps; + if (updateRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(updateRequest.error)); + } + + const members = store.getState().entities.channels.myMembers; + const member = members[TestHelper.basicChannel.id]; + assert.ok(member); + assert.equal(member.notify_props.mark_unread, 'mention'); + assert.equal(member.notify_props.desktop, 'none'); + }); + + it('leaveChannel', async () => { + await Actions.leaveChannel( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id + )(store.dispatch, store.getState); + + const leaveRequest = store.getState().requests.channels.leaveChannel; + if (leaveRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(leaveRequest.error)); + } + + const {channels, myMembers} = store.getState().entities.channels; + assert.ifError(channels[TestHelper.basicChannel.id]); + assert.ifError(myMembers[TestHelper.basicChannel.id]); + }); + + it('joinChannel', async () => { + await Actions.joinChannel( + TestHelper.basicUser.id, + TestHelper.basicTeam.id, + TestHelper.basicChannel.id + )(store.dispatch, store.getState); + + const joinRequest = store.getState().requests.channels.joinChannel; + if (joinRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(joinRequest.error)); + } + + const {channels, myMembers} = store.getState().entities.channels; + assert.ok(channels[TestHelper.basicChannel.id]); + assert.ok(myMembers[TestHelper.basicChannel.id]); + }); + + it('joinChannelByName', async () => { + const secondClient = TestHelper.createClient(); + const user = await TestHelper.basicClient.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + await secondClient.login(user.email, 'password1'); + + secondChannel = await secondClient.createChannel( + TestHelper.fakeChannel(TestHelper.basicTeam.id)); + + await Actions.joinChannel( + TestHelper.basicUser.id, + TestHelper.basicTeam.id, + null, + secondChannel.name + )(store.dispatch, store.getState); + + const joinRequest = store.getState().requests.channels.joinChannel; + if (joinRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(joinRequest.error)); + } + + const {channels, myMembers} = store.getState().entities.channels; + assert.ok(channels[secondChannel.id]); + assert.ok(myMembers[secondChannel.id]); + }); + + it('deleteChannel', async () => { + await Actions.fetchMyChannelsAndMembers(TestHelper.basicTeam.id)(store.dispatch, store.getState); + await Actions.deleteChannel( + TestHelper.basicTeam.id, + secondChannel.id + )(store.dispatch, store.getState); + + const deleteRequest = store.getState().requests.channels.deleteChannel; + if (deleteRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(deleteRequest.error)); + } + + const {channels, myMembers} = store.getState().entities.channels; + assert.ifError(channels[secondChannel.id]); + assert.ifError(myMembers[secondChannel.id]); + }); + + it('viewChannel', async () => { + const userChannel = await Client.createChannel( + TestHelper.fakeChannel(TestHelper.basicTeam.id) + ); + await Actions.fetchMyChannelsAndMembers(TestHelper.basicTeam.id)(store.dispatch, store.getState); + const members = store.getState().entities.channels.myMembers; + const member = members[TestHelper.basicChannel.id]; + const otherMember = members[userChannel.id]; + assert.ok(member); + assert.ok(otherMember); + + await Actions.viewChannel( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id, + userChannel.id + )(store.dispatch, store.getState); + + const updateRequest = store.getState().requests.channels.updateLastViewedAt; + if (updateRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(updateRequest.error)); + } + }); + + it('getMoreChannels', async () => { + const userClient = TestHelper.createClient(); + const user = await TestHelper.basicClient.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + await userClient.login(user.email, 'password1'); + + const userChannel = await userClient.createChannel( + TestHelper.fakeChannel(TestHelper.basicTeam.id) + ); + + await Actions.getMoreChannels(TestHelper.basicTeam.id, 0)(store.dispatch, store.getState); + + const moreRequest = store.getState().requests.channels.getMoreChannels; + if (moreRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(moreRequest.error)); + } + + const {channels, myMembers} = store.getState().entities.channels; + const channel = channels[userChannel.id]; + + assert.ok(channel); + assert.ifError(myMembers[channel.id]); + }); + + it('getChannelStats', async () => { + await Actions.getChannelStats( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id + )(store.dispatch, store.getState); + + const statsRequest = store.getState().requests.channels.getChannelStats; + if (statsRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(statsRequest.error)); + } + + const {stats} = store.getState().entities.channels; + const stat = stats[TestHelper.basicChannel.id]; + assert.ok(stat); + assert.equal(stat.member_count, 1); + }); + + it('addChannelMember', async () => { + const user = await TestHelper.basicClient.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + + await Actions.addChannelMember( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id, + user.id + )(store.dispatch, store.getState); + + const addRequest = store.getState().requests.channels.addChannelMember; + if (addRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(addRequest.error)); + } + + const {profilesInChannel, profilesNotInChannel} = store.getState().entities.users; + const channel = profilesInChannel[TestHelper.basicChannel.id]; + const notChannel = profilesNotInChannel[TestHelper.basicChannel.id]; + assert.ok(channel); + assert.ok(notChannel); + assert.ok(channel.has(user.id)); + assert.ifError(notChannel.has(user.id)); + }); + + it('removeChannelMember', async () => { + const user = await TestHelper.basicClient.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + + await Actions.addChannelMember( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id, + user.id + )(store.dispatch, store.getState); + + await Actions.removeChannelMember( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id, + user.id + )(store.dispatch, store.getState); + + const removeRequest = store.getState().requests.channels.removeChannelMember; + if (removeRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(removeRequest.error)); + } + + const {profilesInChannel, profilesNotInChannel} = store.getState().entities.users; + const channel = profilesInChannel[TestHelper.basicChannel.id]; + const notChannel = profilesNotInChannel[TestHelper.basicChannel.id]; + assert.ok(channel); + assert.ok(notChannel); + assert.ok(notChannel.has(user.id)); + assert.ifError(channel.has(user.id)); + }); + + it('updateChannelHeader', async () => { + await Actions.getChannel(TestHelper.basicTeam.id, TestHelper.basicChannel.id)(store.dispatch, store.getState); + + const channelRequest = store.getState().requests.channels.getChannel; + if (channelRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(channelRequest.error)); + } + + const header = 'this is an updated test header'; + await Actions.updateChannelHeader( + TestHelper.basicChannel.id, + header + )(store.dispatch, store.getState); + const {channels} = store.getState().entities.channels; + const channel = channels[TestHelper.basicChannel.id]; + assert.ok(channel); + assert.deepEqual(channel.header, header); + }); + + it('updateChannelPurpose', async () => { + await Actions.getChannel(TestHelper.basicTeam.id, TestHelper.basicChannel.id)(store.dispatch, store.getState); + + const channelRequest = store.getState().requests.channels.getChannel; + if (channelRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(channelRequest.error)); + } + + const purpose = 'this is an updated test purpose'; + await Actions.updateChannelPurpose( + TestHelper.basicChannel.id, + purpose + )(store.dispatch, store.getState); + const {channels} = store.getState().entities.channels; + const channel = channels[TestHelper.basicChannel.id]; + assert.ok(channel); + assert.deepEqual(channel.purpose, purpose); + }); + + it('autocompleteChannels', async () => { + await Actions.autocompleteChannels( + TestHelper.basicTeam.id, + '' + )(store.dispatch, store.getState); + + const autocompleteRequest = store.getState().requests.channels.autocompleteChannels; + const data = store.getState().entities.channels.autocompleteChannels; + + if (autocompleteRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(autocompleteRequest.error)); + } + + assert.ok(data.length); + + const channel = data.find((c) => c.id === TestHelper.basicChannel.id); + + assert.ok(channel); + }); +}); diff --git a/test/actions/files.test.js b/test/actions/files.test.js new file mode 100644 index 000000000..cea67e68e --- /dev/null +++ b/test/actions/files.test.js @@ -0,0 +1,69 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import fs from 'fs'; +import assert from 'assert'; + +import * as Actions from 'actions/files'; +import Client from 'client'; +import configureStore from 'store'; +import {RequestStatus} from 'constants'; +import TestHelper from 'test/test_helper'; + +const FormData = require('form-data'); + +describe('Actions.Files', () => { + let store; + before(async () => { + await TestHelper.initBasic(Client); + }); + + beforeEach(() => { + store = configureStore(); + }); + + after(async () => { + await TestHelper.basicClient.logout(); + }); + + it('getFilesForPost', async () => { + const {basicClient, basicTeam, basicChannel} = TestHelper; + const testFileName = 'test.png'; + const testImageData = fs.createReadStream(`test/assets/images/${testFileName}`); + const clientId = TestHelper.generateId(); + + const imageFormData = new FormData(); + imageFormData.append('files', testImageData); + imageFormData.append('channel_id', basicChannel.id); + imageFormData.append('client_ids', clientId); + const formBoundary = imageFormData.getBoundary(); + + const fileUploadResp = await basicClient. + uploadFile(basicTeam.id, basicChannel.id, clientId, imageFormData, formBoundary); + const fileId = fileUploadResp.file_infos[0].id; + + const fakePostForFile = TestHelper.fakePost(basicChannel.id); + fakePostForFile.file_ids = [fileId]; + const postForFile = await basicClient.createPost(basicTeam.id, fakePostForFile); + + await Actions.getFilesForPost( + basicTeam.id, basicChannel.id, postForFile.id + )(store.dispatch, store.getState); + + const filesRequest = store.getState().requests.files.getFilesForPost; + const {files: allFiles, fileIdsByPostId} = store.getState().entities.files; + + if (filesRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(filesRequest.error)); + } + + assert.ok(allFiles); + assert.ok(allFiles[fileId]); + assert.equal(allFiles[fileId].id, fileId); + assert.equal(allFiles[fileId].name, testFileName); + + assert.ok(fileIdsByPostId); + assert.ok(fileIdsByPostId[postForFile.id]); + assert.equal(fileIdsByPostId[postForFile.id][0], fileId); + }); +}); diff --git a/test/actions/general.test.js b/test/actions/general.test.js new file mode 100644 index 000000000..785702a88 --- /dev/null +++ b/test/actions/general.test.js @@ -0,0 +1,86 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import assert from 'assert'; + +import configureStore from 'store'; + +import * as Actions from 'actions/general'; +import Client from 'client'; +import {RequestStatus} from 'constants'; + +import TestHelper from 'test/test_helper'; + +const DEFAULT_SERVER = 'http://localhost:8065'; + +describe('Actions.General', () => { + let store; + before(async () => { + await TestHelper.initBasic(Client); + }); + + beforeEach(() => { + store = configureStore(); + }); + + after(async () => { + await TestHelper.basicClient.logout(); + }); + + it('getPing - Invalid URL', async () => { + Client.setUrl('https://google.com/fake/url'); + await Actions.getPing()(store.dispatch, store.getState); + + const {server} = store.getState().requests.general; + assert.ok(server.status === RequestStatus.FAILURE && server.error); + }); + + it('getPing', async () => { + TestHelper.basicClient.setUrl(DEFAULT_SERVER); + await Actions.getPing()(store.dispatch, store.getState); + + const {server} = store.getState().requests.general; + if (server.status === RequestStatus.FAILED) { + throw new Error(JSON.stringify(server.error)); + } + }); + + it('getClientConfig', async () => { + await Actions.getClientConfig()(store.dispatch, store.getState); + + const configRequest = store.getState().requests.general.config; + if (configRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(configRequest.error)); + } + + const clientConfig = store.getState().entities.general.config; + + // Check a few basic fields since they may change over time + assert.ok(clientConfig.Version); + assert.ok(clientConfig.BuildNumber); + assert.ok(clientConfig.BuildDate); + assert.ok(clientConfig.BuildHash); + }); + + it('getLicenseConfig', async () => { + await Actions.getLicenseConfig()(store.dispatch, store.getState); + + const licenseRequest = store.getState().requests.general.license; + if (licenseRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(licenseRequest.error)); + } + + const licenseConfig = store.getState().entities.general.license; + + // Check a few basic fields since they may change over time + assert.notStrictEqual(licenseConfig.IsLicensed, undefined); + }); + + it('setServerVersion', async () => { + const version = '3.7.0'; + await Actions.setServerVersion(version)(store.dispatch, store.getState); + + const {serverVersion} = store.getState().entities.general; + assert.deepEqual(serverVersion, version); + }); +}); diff --git a/test/actions/posts.test.js b/test/actions/posts.test.js new file mode 100644 index 000000000..8c6632a24 --- /dev/null +++ b/test/actions/posts.test.js @@ -0,0 +1,405 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import assert from 'assert'; + +import * as Actions from 'actions/posts'; +import Client from 'client'; +import configureStore from 'store'; +import {Constants, RequestStatus} from 'constants'; +import TestHelper from 'test/test_helper'; + +describe('Actions.Posts', () => { + let store; + before(async () => { + await TestHelper.initBasic(Client); + }); + + beforeEach(() => { + store = configureStore(); + }); + + after(async () => { + await TestHelper.basicClient.logout(); + }); + + it('createPost', async () => { + const channelId = TestHelper.basicChannel.id; + const post = TestHelper.fakePost(channelId); + + await Actions.createPost( + TestHelper.basicTeam.id, + post + )(store.dispatch, store.getState); + + const state = store.getState(); + const createRequest = state.requests.posts.createPost; + if (createRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(createRequest.error)); + } + + const {posts, postsByChannel} = state.entities.posts; + assert.ok(posts); + assert.ok(postsByChannel); + assert.ok(postsByChannel[channelId]); + + let found = false; + for (const storedPost of Object.values(posts)) { + if (storedPost.message === post.message) { + found = true; + break; + } + } + assert.ok(found, 'failed to find new post in posts'); + + found = false; + for (const postIdInChannel of postsByChannel[channelId]) { + if (posts[postIdInChannel].message === post.message) { + found = true; + break; + } + } + assert.ok(found, 'failed to find new post in postsByChannel'); + }); + + it('editPost', async () => { + const teamId = TestHelper.basicTeam.id; + const channelId = TestHelper.basicChannel.id; + + const post = await Client.createPost( + teamId, + TestHelper.fakePost(channelId) + ); + const message = post.message; + + post.message = `${message} (edited)`; + await Actions.editPost( + teamId, + post + )(store.dispatch, store.getState); + + const state = store.getState(); + const editRequest = state.requests.posts.editPost; + const {posts} = state.entities.posts; + + if (editRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(editRequest.error)); + } + + assert.ok(posts); + assert.ok(posts[post.id]); + + assert.strictEqual( + posts[post.id].message, + `${message} (edited)` + ); + }); + + it('deletePost', async () => { + const teamId = TestHelper.basicTeam.id; + const channelId = TestHelper.basicChannel.id; + + await Actions.createPost( + teamId, + TestHelper.fakePost(channelId) + )(store.dispatch, store.getState); + + const initialPosts = store.getState().entities.posts; + const created = initialPosts.posts[initialPosts.postsByChannel[channelId][0]]; + + await Actions.deletePost(teamId, created)(store.dispatch, store.getState); + + const state = store.getState(); + const deleteRequest = state.requests.posts.deletePost; + const {posts} = state.entities.posts; + + if (deleteRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(deleteRequest.error)); + } + + assert.ok(posts); + assert.ok(posts[created.id]); + + assert.strictEqual( + posts[created.id].state, + Constants.POST_DELETED + ); + }); + + it('removePost', async () => { + const teamId = TestHelper.basicTeam.id; + const channelId = TestHelper.basicChannel.id; + const postId = TestHelper.basicPost.id; + + const post1a = await Client.createPost( + teamId, + {...TestHelper.fakePost(channelId), root_id: postId} + ); + + await Actions.getPosts( + teamId, + channelId + )(store.dispatch, store.getState); + + const postsCount = store.getState().entities.posts.postsByChannel[channelId].length; + + await Actions.removePost( + TestHelper.basicPost + )(store.dispatch, store.getState); + + const {posts, postsByChannel} = store.getState().entities.posts; + + assert.ok(posts); + assert.ok(postsByChannel); + assert.ok(postsByChannel[channelId]); + + // this should count that the basic post and post1a were removed + assert.equal(postsByChannel[channelId].length, postsCount - 2); + assert.ok(!posts[postId]); + assert.ok(!posts[post1a.id]); + }); + + it('getPost', async () => { + const teamId = TestHelper.basicTeam.id; + const channelId = TestHelper.basicChannel.id; + + const post = await Client.createPost( + teamId, + TestHelper.fakePost(channelId) + ); + + await Actions.getPost( + teamId, + channelId, + post.id + )(store.dispatch, store.getState); + + const state = store.getState(); + const getRequest = state.requests.posts.getPost; + const {posts, postsByChannel} = state.entities.posts; + + if (getRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(getRequest.error)); + } + + assert.ok(posts); + assert.ok(postsByChannel); + assert.ok(postsByChannel[channelId]); + + assert.ok(posts[post.id]); + + let found = false; + for (const postIdInChannel of postsByChannel[channelId]) { + if (postIdInChannel === post.id) { + found = true; + break; + } + } + assert.ok(found, 'failed to find post in postsByChannel'); + }); + + it('getPosts', async () => { + const teamId = TestHelper.basicTeam.id; + const channelId = TestHelper.basicChannel.id; + + const post1 = await Client.createPost( + teamId, + TestHelper.fakePost(channelId) + ); + const post1a = await Client.createPost( + teamId, + {...TestHelper.fakePost(channelId), root_id: post1.id} + ); + const post2 = await Client.createPost( + teamId, + TestHelper.fakePost(channelId) + ); + const post3 = await Client.createPost( + teamId, + TestHelper.fakePost(channelId) + ); + const post3a = await Client.createPost( + teamId, + {...TestHelper.fakePost(channelId), root_id: post3.id} + ); + + await Actions.getPosts( + teamId, + channelId + )(store.dispatch, store.getState); + + const state = store.getState(); + const getRequest = state.requests.posts.getPosts; + const {posts, postsByChannel} = state.entities.posts; + + if (getRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(getRequest.error)); + } + + assert.ok(posts); + assert.ok(postsByChannel); + + const postsInChannel = postsByChannel[channelId]; + assert.ok(postsInChannel); + assert.equal(postsInChannel[0], post3a.id, 'wrong order for post3a'); + assert.equal(postsInChannel[1], post3.id, 'wrong order for post3'); + assert.equal(postsInChannel[3], post1a.id, 'wrong order for post1a'); + + assert.ok(posts[post1.id]); + assert.ok(posts[post1a.id]); + assert.ok(posts[post2.id]); + assert.ok(posts[post3.id]); + assert.ok(posts[post3a.id]); + }); + + it('getPostsSince', async () => { + const teamId = TestHelper.basicTeam.id; + const channelId = TestHelper.basicChannel.id; + + const post1 = await Client.createPost( + teamId, + TestHelper.fakePost(channelId) + ); + await Client.createPost( + teamId, + {...TestHelper.fakePost(channelId), root_id: post1.id} + ); + const post2 = await Client.createPost( + teamId, + TestHelper.fakePost(channelId) + ); + const post3 = await Client.createPost( + teamId, + TestHelper.fakePost(channelId) + ); + const post3a = await Client.createPost( + teamId, + {...TestHelper.fakePost(channelId), root_id: post3.id} + ); + + await Actions.getPostsSince( + teamId, + channelId, + post2.create_at + )(store.dispatch, store.getState); + + const state = store.getState(); + const getRequest = state.requests.posts.getPostsSince; + const {posts, postsByChannel} = state.entities.posts; + + if (getRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(getRequest.error)); + } + + assert.ok(posts); + assert.ok(postsByChannel); + + const postsInChannel = postsByChannel[channelId]; + assert.ok(postsInChannel); + assert.equal(postsInChannel[0], post3a.id, 'wrong order for post3a'); + assert.equal(postsInChannel[1], post3.id, 'wrong order for post3'); + assert.equal(postsInChannel.length, 2, 'wrong size'); + }); + + it('getPostsBefore', async () => { + const teamId = TestHelper.basicTeam.id; + const channelId = TestHelper.basicChannel.id; + + const post1 = await Client.createPost( + teamId, + TestHelper.fakePost(channelId) + ); + const post1a = await Client.createPost( + teamId, + {...TestHelper.fakePost(channelId), root_id: post1.id} + ); + const post2 = await Client.createPost( + teamId, + TestHelper.fakePost(channelId) + ); + const post3 = await Client.createPost( + teamId, + TestHelper.fakePost(channelId) + ); + await Client.createPost( + teamId, + {...TestHelper.fakePost(channelId), root_id: post3.id} + ); + + await Actions.getPostsBefore( + teamId, + channelId, + post2.id, + 0, + 10 + )(store.dispatch, store.getState); + + const state = store.getState(); + const getRequest = state.requests.posts.getPostsBefore; + const {posts, postsByChannel} = state.entities.posts; + + if (getRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(getRequest.error)); + } + + assert.ok(posts); + assert.ok(postsByChannel); + + const postsInChannel = postsByChannel[channelId]; + assert.ok(postsInChannel); + assert.equal(postsInChannel[0], post1a.id, 'wrong order for post1a'); + assert.equal(postsInChannel[1], post1.id, 'wrong order for post1'); + assert.equal(postsInChannel.length, 10, 'wrong size'); + }); + + it('getPostsAfter', async () => { + const teamId = TestHelper.basicTeam.id; + const channelId = TestHelper.basicChannel.id; + + const post1 = await Client.createPost( + teamId, + TestHelper.fakePost(channelId) + ); + await Client.createPost( + teamId, + {...TestHelper.fakePost(channelId), root_id: post1.id} + ); + const post2 = await Client.createPost( + teamId, + TestHelper.fakePost(channelId) + ); + const post3 = await Client.createPost( + teamId, + TestHelper.fakePost(channelId) + ); + const post3a = await Client.createPost( + teamId, + {...TestHelper.fakePost(channelId), root_id: post3.id} + ); + + await Actions.getPostsAfter( + teamId, + channelId, + post2.id, + 0, + 10 + )(store.dispatch, store.getState); + + const state = store.getState(); + const getRequest = state.requests.posts.getPostsAfter; + const {posts, postsByChannel} = state.entities.posts; + + if (getRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(getRequest.error)); + } + + assert.ok(posts); + assert.ok(postsByChannel); + + const postsInChannel = postsByChannel[channelId]; + assert.ok(postsInChannel); + assert.equal(postsInChannel[0], post3a.id, 'wrong order for post3a'); + assert.equal(postsInChannel[1], post3.id, 'wrong order for post3'); + assert.equal(postsInChannel.length, 2, 'wrong size'); + }); +}); diff --git a/test/actions/preferences.test.js b/test/actions/preferences.test.js new file mode 100644 index 000000000..b9b763765 --- /dev/null +++ b/test/actions/preferences.test.js @@ -0,0 +1,189 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import assert from 'assert'; + +import configureStore from 'store'; + +import * as Actions from 'actions/preferences'; +import {login} from 'actions/users'; +import Client from 'client'; +import {Preferences, RequestStatus} from 'constants'; + +import TestHelper from 'test/test_helper'; + +describe('Actions.Preferences', () => { + let store; + before(async () => { + await TestHelper.initBasic(Client); + }); + + beforeEach(() => { + store = configureStore(); + }); + + after(async () => { + await TestHelper.basicClient.logout(); + }); + + it('getMyPreferences', async () => { + const user = TestHelper.basicUser; + const existingPreferences = [ + { + user_id: user.id, + category: 'test', + name: 'test1', + value: 'test' + }, + { + user_id: user.id, + category: 'test', + name: 'test2', + value: 'test' + } + ]; + + await Client.savePreferences(existingPreferences); + await Actions.getMyPreferences('1234')(store.dispatch, store.getState); + + const state = store.getState(); + const request = state.requests.preferences.getMyPreferences; + const {myPreferences} = state.entities.preferences; + + if (request.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(request.error)); + } + + assert.ok(myPreferences['test--test1'], 'first preference doesn\'t exist'); + assert.deepEqual(existingPreferences[0], myPreferences['test--test1']); + assert.ok(myPreferences['test--test2'], 'second preference doesn\'t exist'); + assert.deepEqual(existingPreferences[1], myPreferences['test--test2']); + }); + + it('savePrefrences', async () => { + const user = TestHelper.basicUser; + const existingPreferences = [ + { + user_id: user.id, + category: 'test', + name: 'test1', + value: 'test' + } + ]; + + await Client.savePreferences(existingPreferences); + await Actions.getMyPreferences()(store.dispatch, store.getState); + + const preferences = [ + { + user_id: user.id, + category: 'test', + name: 'test2', + value: 'test' + }, + { + user_id: user.id, + category: 'test', + name: 'test3', + value: 'test' + } + ]; + + await Actions.savePreferences(preferences)(store.dispatch, store.getState); + + const state = store.getState(); + const request = state.requests.preferences.savePreferences; + const {myPreferences} = state.entities.preferences; + + if (request.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(request.error)); + } + + assert.ok(myPreferences['test--test1'], 'first preference doesn\'t exist'); + assert.deepEqual(existingPreferences[0], myPreferences['test--test1']); + assert.ok(myPreferences['test--test2'], 'second preference doesn\'t exist'); + assert.deepEqual(preferences[0], myPreferences['test--test2']); + assert.ok(myPreferences['test--test3'], 'third preference doesn\'t exist'); + assert.deepEqual(preferences[1], myPreferences['test--test3']); + }); + + it('deletePreferences', async () => { + const user = TestHelper.basicUser; + const existingPreferences = [ + { + user_id: user.id, + category: 'test', + name: 'test1', + value: 'test' + }, + { + user_id: user.id, + category: 'test', + name: 'test2', + value: 'test' + }, + { + user_id: user.id, + category: 'test', + name: 'test3', + value: 'test' + } + ]; + + await Client.savePreferences(existingPreferences); + await Actions.getMyPreferences()(store.dispatch, store.getState); + await Actions.deletePreferences([ + existingPreferences[0], + existingPreferences[2] + ])(store.dispatch, store.getState); + + const state = store.getState(); + const request = state.requests.preferences.deletePreferences; + const {myPreferences} = state.entities.preferences; + + if (request.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(request.error)); + } + + assert.ok(!myPreferences['test--test1'], 'deleted preference still exists'); + assert.ok(myPreferences['test--test2'], 'second preference doesn\'t exist'); + assert.deepEqual(existingPreferences[1], myPreferences['test--test2']); + assert.ok(!myPreferences['test--test3'], 'third preference doesn\'t exist'); + }); + + it('makeDirectChannelVisibleIfNecessary', async () => { + const user = TestHelper.basicUser; + const user2 = await TestHelper.createClient().createUser(TestHelper.fakeUser()); + + await login(user.email, 'password1')(store.dispatch, store.getState); + + // Test that a new preference is created if non exists + await Actions.makeDirectChannelVisibleIfNecessary(user2.id)(store.dispatch, store.getState); + + let state = store.getState(); + let myPreferences = state.entities.preferences.myPreferences; + let preference = myPreferences[`${Preferences.CATEGORY_DIRECT_CHANNEL_SHOW}--${user2.id}`]; + assert.ok(preference, 'preference for showing direct channel doesn\'t exist'); + assert.equal(preference.value, 'true', 'preference for showing direct channel is not true'); + + // Test that nothing changes if the preference already exists and is true + await Actions.makeDirectChannelVisibleIfNecessary(user2.id)(store.dispatch, store.getState); + + const state2 = store.getState(); + assert.equal(state, state2, 'store should not change since direct channel is already visible'); + + // Test that the preference is updated if it already exists and is false + await Actions.savePreferences([{ + ...preference, + value: 'false' + }])(store.dispatch, store.getState); + + await Actions.makeDirectChannelVisibleIfNecessary(user2.id)(store.dispatch, store.getState); + + state = store.getState(); + myPreferences = state.entities.preferences.myPreferences; + preference = myPreferences[`${Preferences.CATEGORY_DIRECT_CHANNEL_SHOW}--${user2.id}`]; + assert.ok(preference, 'preference for showing direct channel doesn\'t exist'); + assert.equal(preference.value, 'true', 'preference for showing direct channel is not true'); + }).timeout(2000); +}); diff --git a/test/actions/teams.test.js b/test/actions/teams.test.js new file mode 100644 index 000000000..04be21bb0 --- /dev/null +++ b/test/actions/teams.test.js @@ -0,0 +1,241 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import assert from 'assert'; + +import * as Actions from 'actions/teams'; +import Client from 'client'; +import configureStore from 'store'; +import {RequestStatus} from 'constants'; +import TestHelper from 'test/test_helper'; + +describe('Actions.Teams', () => { + let store; + before(async () => { + await TestHelper.initBasic(Client); + }); + + beforeEach(() => { + store = configureStore(); + }); + + after(async () => { + await TestHelper.basicClient.logout(); + }); + + it('selectTeam', async () => { + await Actions.selectTeam(TestHelper.basicTeam)(store.dispatch, store.getState); + const {currentTeamId} = store.getState().entities.teams; + + assert.ok(currentTeamId); + assert.equal(currentTeamId, TestHelper.basicTeam.id); + }); + + it('fetchTeams', async () => { + await Actions.fetchTeams()(store.dispatch, store.getState); + + const teamsRequest = store.getState().requests.teams.allTeams; + const {teams} = store.getState().entities.teams; + + if (teamsRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(teamsRequest.error)); + } + + assert.ok(teams); + assert.ok(teams[TestHelper.basicTeam.id]); + }); + + it('getAllTeamListings', async () => { + const team = {...TestHelper.fakeTeam(), allow_open_invite: true}; + + await Client.createTeam(team); + await Actions.getAllTeamListings()(store.dispatch, store.getState); + + const teamsRequest = store.getState().requests.teams.getAllTeamListings; + const {teams, openTeamIds} = store.getState().entities.teams; + + if (teamsRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(teamsRequest.error)); + } + + assert.ok(Object.keys(teams).length > 0); + for (const teamId in teams) { + if (teams.hasOwnProperty(teamId)) { + assert.ok(openTeamIds.has(teamId)); + } + } + }); + + it('createTeam', async () => { + await Actions.createTeam( + TestHelper.basicUser.id, + TestHelper.fakeTeam() + )(store.dispatch, store.getState); + + const createRequest = store.getState().requests.teams.createTeam; + const {teams, myMembers, currentTeamId} = store.getState().entities.teams; + + if (createRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(createRequest.error)); + } + + const teamId = Object.keys(teams)[0]; + assert.strictEqual(Object.keys(teams).length, 1); + assert.strictEqual(currentTeamId, teamId); + assert.ok(myMembers[teamId]); + }); + + it('updateTeam', async () => { + const displayName = 'The Updated Team'; + const description = 'This is a team created by unit tests'; + const team = { + ...TestHelper.basicTeam, + display_name: displayName, + description + }; + + await Actions.updateTeam(team)(store.dispatch, store.getState); + + const updateRequest = store.getState().requests.teams.updateTeam; + const {teams} = store.getState().entities.teams; + + if (updateRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(updateRequest.error)); + } + + const updated = teams[TestHelper.basicTeam.id]; + assert.ok(updated); + assert.strictEqual(updated.display_name, displayName); + assert.strictEqual(updated.description, description); + }); + + it('getMyTeamMembers', async () => { + await Actions.getMyTeamMembers()(store.dispatch, store.getState); + + const membersRequest = store.getState().requests.teams.getMyTeamMembers; + const members = store.getState().entities.teams.myMembers; + + if (membersRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(membersRequest.error)); + } + + assert.ok(members); + assert.ok(members[TestHelper.basicTeam.id]); + }); + + it('getTeamMember', async () => { + const user = await TestHelper.basicClient.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + + await Actions.getTeamMember(TestHelper.basicTeam.id, user.id)(store.dispatch, store.getState); + + const membersRequest = store.getState().requests.teams.getTeamMembers; + const members = store.getState().entities.teams.membersInTeam; + + if (membersRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(membersRequest.error)); + } + + assert.ok(members[TestHelper.basicTeam.id]); + assert.ok(members[TestHelper.basicTeam.id].has(user.id)); + }); + + it('getTeamMembersByIds', async () => { + const user1 = await TestHelper.basicClient.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + + const user2 = await TestHelper.basicClient.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + + await Actions.getTeamMembersByIds( + TestHelper.basicTeam.id, + [user1.id, user2.id] + )(store.dispatch, store.getState); + + const membersRequest = store.getState().requests.teams.getTeamMembers; + const members = store.getState().entities.teams.membersInTeam; + + if (membersRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(membersRequest.error)); + } + + assert.ok(members[TestHelper.basicTeam.id]); + assert.ok(members[TestHelper.basicTeam.id].has(user1.id)); + assert.ok(members[TestHelper.basicTeam.id].has(user2.id)); + }); + + it('getTeamStats', async () => { + await Actions.getTeamStats(TestHelper.basicTeam.id)(store.dispatch, store.getState); + + const {stats} = store.getState().entities.teams; + const statsRequest = store.getState().requests.teams.getTeamStats; + + if (statsRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(statsRequest.error)); + } + + const stat = stats[TestHelper.basicTeam.id]; + assert.ok(stat); + + // we need to take into account the members of the tests above + assert.equal(stat.total_member_count, 4); + assert.equal(stat.active_member_count, 4); + }); + + it('addUserToTeam', async () => { + const user = await TestHelper.basicClient.createUser(TestHelper.fakeUser()); + + await Actions.addUserToTeam(TestHelper.basicTeam.id, user.id)(store.dispatch, store.getState); + + const membersRequest = store.getState().requests.teams.addUserToTeam; + const members = store.getState().entities.teams.membersInTeam; + + if (membersRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(membersRequest.error)); + } + + assert.ok(members[TestHelper.basicTeam.id]); + assert.ok(members[TestHelper.basicTeam.id].has(user.id)); + }); + + it('removeUserFromTeam', async () => { + const user = await TestHelper.basicClient.createUser(TestHelper.fakeUser()); + + await Actions.addUserToTeam(TestHelper.basicTeam.id, user.id)(store.dispatch, store.getState); + + let state = store.getState(); + let members = state.entities.teams.membersInTeam; + const addRequest = state.requests.teams.addUserToTeam; + + if (addRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(addRequest.error)); + } + + assert.ok(members[TestHelper.basicTeam.id]); + assert.ok(members[TestHelper.basicTeam.id].has(user.id)); + await Actions.removeUserFromTeam(TestHelper.basicTeam.id, user.id)(store.dispatch, store.getState); + state = store.getState(); + + const removeRequest = state.requests.teams.removeUserFromTeam; + + if (removeRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(removeRequest.error)); + } + + members = state.entities.teams.membersInTeam; + assert.ok(members[TestHelper.basicTeam.id]); + assert.ok(!members[TestHelper.basicTeam.id].has(user.id)); + }); +}); diff --git a/test/actions/users.test.js b/test/actions/users.test.js new file mode 100644 index 000000000..9dc813607 --- /dev/null +++ b/test/actions/users.test.js @@ -0,0 +1,311 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import assert from 'assert'; + +import * as Actions from 'actions/users'; +import Client from 'client'; +import configureStore from 'store'; +import {RequestStatus} from 'constants'; +import TestHelper from 'test/test_helper'; + +describe('Actions.Users', () => { + let store; + before(async () => { + await TestHelper.initBasic(Client); + }); + + beforeEach(() => { + store = configureStore(); + }); + + after(async () => { + await TestHelper.basicClient.logout(); + }); + + it('login', async () => { + const user = TestHelper.basicUser; + await TestHelper.basicClient.logout(); + await Actions.login(user.email, 'password1')(store.dispatch, store.getState); + + const state = store.getState(); + const loginRequest = state.requests.users.login; + const {currentUserId, profiles} = state.entities.users; + const preferences = state.entities.preferences.myPreferences; + const teamMembers = state.entities.teams.myMembers; + + if (loginRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(loginRequest.error)); + } + + assert.ok(currentUserId); + assert.ok(profiles); + assert.ok(profiles[currentUserId]); + assert.ok(Object.keys(preferences).length); + + Object.keys(teamMembers).forEach((id) => { + assert.ok(teamMembers[id].team_id); + assert.equal(teamMembers[id].user_id, currentUserId); + }); + }); + + it('logout', async () => { + await Actions.logout()(store.dispatch, store.getState); + + const state = store.getState(); + const logoutRequest = state.requests.users.logout; + const general = state.entities.general; + const users = state.entities.users; + const teams = state.entities.teams; + const channels = state.entities.channels; + const posts = state.entities.posts; + const preferences = state.entities.preferences; + + if (logoutRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(logoutRequest.error)); + } + + assert.deepStrictEqual(general.config, {}, 'config not empty'); + assert.deepStrictEqual(general.license, {}, 'license not empty'); + assert.strictEqual(users.currentUserId, '', 'current user id not empty'); + assert.deepStrictEqual(users.mySessions, [], 'user sessions not empty'); + assert.deepStrictEqual(users.myAudits, [], 'user audits not empty'); + assert.deepStrictEqual(users.profiles, {}, 'user profiles not empty'); + assert.deepStrictEqual(users.profilesInTeam, {}, 'users profiles in team not empty'); + assert.deepStrictEqual(users.profilesInChannel, {}, 'users profiles in channel not empty'); + assert.deepStrictEqual(users.profilesNotInChannel, {}, 'users profiles NOT in channel not empty'); + assert.deepStrictEqual(users.statuses, {}, 'users statuses not empty'); + assert.strictEqual(teams.currentTeamId, '', 'current team id is not empty'); + assert.deepStrictEqual(teams.teams, {}, 'teams is not empty'); + assert.deepStrictEqual(teams.myMembers, {}, 'team members is not empty'); + assert.deepStrictEqual(teams.membersInTeam, {}, 'members in team is not empty'); + assert.deepStrictEqual(teams.stats, {}, 'team stats is not empty'); + assert.deepStrictEqual(teams.openTeamIds, new Set(), 'team open ids is not empty'); + assert.strictEqual(channels.currentChannelId, '', 'current channel id is not empty'); + assert.deepStrictEqual(channels.channels, {}, 'channels is not empty'); + assert.deepStrictEqual(channels.myMembers, {}, 'channel members is not empty'); + assert.deepStrictEqual(channels.stats, {}, 'channel stats is not empty'); + assert.strictEqual(posts.selectedPostId, '', 'selected post id is not empty'); + assert.strictEqual(posts.currentFocusedPostId, '', 'current focused post id is not empty'); + assert.deepStrictEqual(posts.posts, {}, 'posts is not empty'); + assert.deepStrictEqual(posts.postsByChannel, {}, 'posts by channel is not empty'); + assert.deepStrictEqual(preferences.myPreferences, {}, 'user preferences not empty'); + }); + + it('getProfiles', async () => { + await TestHelper.basicClient.login(TestHelper.basicUser.email, 'password1'); + await TestHelper.basicClient.createUser(TestHelper.fakeUser()); + await Actions.getProfiles(0)(store.dispatch, store.getState); + + const profilesRequest = store.getState().requests.users.getProfiles; + const {profiles} = store.getState().entities.users; + + if (profilesRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(profilesRequest.error)); + } + + assert.ok(Object.keys(profiles).length); + }); + + it('getProfilesInTeam', async () => { + await Actions.getProfilesInTeam(TestHelper.basicTeam.id, 0)(store.dispatch, store.getState); + + const profilesRequest = store.getState().requests.users.getProfilesInTeam; + const {profilesInTeam, profiles} = store.getState().entities.users; + + if (profilesRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(profilesRequest.error)); + } + + const team = profilesInTeam[TestHelper.basicTeam.id]; + assert.ok(team); + assert.ok(team.has(TestHelper.basicUser.id)); + assert.equal(Object.keys(profiles).length, team.size, 'profiles != profiles in team'); + }); + + it('getProfilesInChannel', async () => { + await Actions.getProfilesInChannel( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id, + 0 + )(store.dispatch, store.getState); + + const profilesRequest = store.getState().requests.users.getProfilesInChannel; + const {profiles, profilesInChannel} = store.getState().entities.users; + + if (profilesRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(profilesRequest.error)); + } + + const channel = profilesInChannel[TestHelper.basicChannel.id]; + assert.ok(channel.has(TestHelper.basicUser.id)); + assert.equal(Object.keys(profiles).length, channel.size, 'profiles != profiles in channel'); + }); + + it('getProfilesNotInChannel', async () => { + const user = await TestHelper.basicClient.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + + await Actions.getProfilesNotInChannel( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id, + 0 + )(store.dispatch, store.getState); + + const profilesRequest = store.getState().requests.users.getProfilesNotInChannel; + const {profiles, profilesNotInChannel} = store.getState().entities.users; + + if (profilesRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(profilesRequest.error)); + } + + const channel = profilesNotInChannel[TestHelper.basicChannel.id]; + assert.ok(channel.has(user.id)); + assert.equal(Object.keys(profiles).length, channel.size, 'profiles != profiles in channel'); + }); + + it('getStatusesByIds', async () => { + const user = await TestHelper.basicClient.createUser(TestHelper.fakeUser()); + + await Actions.getStatusesByIds( + [TestHelper.basicUser.id, user.id] + )(store.dispatch, store.getState); + + const statusesRequest = store.getState().requests.users.getStatusesByIds; + const statuses = store.getState().entities.users.statuses; + + if (statusesRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(statusesRequest.error)); + } + + assert.ok(statuses[TestHelper.basicUser.id]); + assert.ok(statuses[user.id]); + assert.equal(Object.keys(statuses).length, 2); + }); + + it('getSessions', async () => { + await Actions.getSessions(TestHelper.basicUser.id)(store.dispatch, store.getState); + + const sessionsRequest = store.getState().requests.users.getSessions; + const sessions = store.getState().entities.users.mySessions; + + if (sessionsRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(sessionsRequest.error)); + } + + assert.ok(sessions.length); + assert.equal(sessions[0].user_id, TestHelper.basicUser.id); + }); + + it('revokeSession', async () => { + await Actions.getSessions(TestHelper.basicUser.id)(store.dispatch, store.getState); + + const sessionsRequest = store.getState().requests.users.getSessions; + let sessions = store.getState().entities.users.mySessions; + if (sessionsRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(sessionsRequest.error)); + } + + await Actions.revokeSession(sessions[0].id)(store.dispatch, store.getState); + + const revokeRequest = store.getState().requests.users.revokeSession; + if (revokeRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(revokeRequest.error)); + } + + sessions = store.getState().entities.users.mySessions; + assert.ok(sessions.length === 0); + }); + + it('revokeSession and logout', async () => { + await TestHelper.basicClient.login(TestHelper.basicUser.email, 'password1'); + await Actions.getSessions(TestHelper.basicUser.id)(store.dispatch, store.getState); + + const sessionsRequest = store.getState().requests.users.getSessions; + const sessions = store.getState().entities.users.mySessions; + + if (sessionsRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(sessionsRequest.error)); + } + + await Actions.revokeSession(sessions[0].id)(store.dispatch, store.getState); + + const revokeRequest = store.getState().requests.users.revokeSession; + if (revokeRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(revokeRequest.error)); + } + + await Actions.getProfiles(0)(store.dispatch, store.getState); + + const logoutRequest = store.getState().requests.users.logout; + if (logoutRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(logoutRequest.error)); + } + }); + + it('getAudits', async () => { + await TestHelper.basicClient.login(TestHelper.basicUser.email, 'password1'); + await Actions.getAudits(TestHelper.basicUser.id)(store.dispatch, store.getState); + + const auditsRequest = store.getState().requests.users.getAudits; + const audits = store.getState().entities.users.myAudits; + + if (auditsRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(auditsRequest.error)); + } + + assert.ok(audits.length); + assert.equal(audits[0].user_id, TestHelper.basicUser.id); + }); + + it('autocompleteUsersInChannel', async () => { + await Actions.autocompleteUsersInChannel( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id, + '' + )(store.dispatch, store.getState); + + const autocompleteRequest = store.getState().requests.users.autocompleteUsersInChannel; + const data = store.getState().entities.users.autocompleteUsersInChannel; + + if (autocompleteRequest.status === RequestStatus.FAILURE) { + throw new Error(JSON.stringify(autocompleteRequest.error)); + } + + assert.ok(data[TestHelper.basicChannel.id]); + assert.ok(data[TestHelper.basicChannel.id].in_channel); + assert.ok(data[TestHelper.basicChannel.id].out_of_channel); + }); + + it('updateUserNotifyProps', async () => { + await Actions.login(TestHelper.basicUser.email, 'password1')(store.dispatch, store.getState); + + const state = store.getState(); + const currentUser = state.entities.users.profiles[state.entities.users.currentUserId]; + const notifyProps = currentUser.notify_props; + + await Actions.updateUserNotifyProps({ + ...notifyProps, + comments: 'any', + email: 'false', + first_name: 'false', + mention_keys: '', + user_id: currentUser.id + })(store.dispatch, store.getState); + + setTimeout(() => { + const updatedState = store.getState(); + const updatedCurrentUser = updatedState.entities.users.profiles[state.entities.users.currentUserId]; + const updateNotifyProps = updatedCurrentUser.notify_props; + + assert.equal(updateNotifyProps.comments, 'any'); + assert.equal(updateNotifyProps.email, 'false'); + assert.equal(updateNotifyProps.first_name, 'false'); + assert.equal(updateNotifyProps.mention_keys, ''); + }, 1000); + }); +}); diff --git a/test/actions/websocket.test.js b/test/actions/websocket.test.js new file mode 100644 index 000000000..b6adabbda --- /dev/null +++ b/test/actions/websocket.test.js @@ -0,0 +1,282 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import assert from 'assert'; +import * as Actions from 'actions/websocket'; +import * as ChannelActions from 'actions/channels'; +import * as TeamActions from 'actions/teams'; +import * as GeneralActions from 'actions/general'; + +import Client from 'client'; +import configureStore from 'store'; +import {Constants, RequestStatus} from 'constants'; +import TestHelper from 'test/test_helper'; + +describe('Actions.Websocket', () => { + let store; + before(async () => { + store = configureStore(); + await TestHelper.initBasic(Client); + const webSocketConnector = require('ws'); + return await Actions.init( + 'ios', + null, + null, + webSocketConnector + )(store.dispatch, store.getState); + }); + + after(async () => { + Actions.close()(); + await TestHelper.basicClient.logout(); + }); + + it('WebSocket Connect', () => { + const ws = store.getState().requests.general.websocket; + assert.ok(ws.status === RequestStatus.SUCCESS); + }); + + it('Websocket Handle New Post', async () => { + const client = TestHelper.createClient(); + const user = await client.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + await client.login(user.email, 'password1'); + + await Client.addChannelMember( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id, + user.id); + + const post = {...TestHelper.fakePost(), channel_id: TestHelper.basicChannel.id}; + await client.createPost(TestHelper.basicTeam.id, post); + + const entities = store.getState().entities; + const {posts, postsByChannel} = entities.posts; + const channelId = TestHelper.basicChannel.id; + const postId = postsByChannel[channelId][0]; + + assert.ok(posts[postId].message.indexOf('Unit Test') > -1); + }); + + it('Websocket Handle Post Edited', async () => { + let post = {...TestHelper.fakePost(), channel_id: TestHelper.basicChannel.id}; + const client = TestHelper.createClient(); + const user = await client.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + + await Client.addChannelMember( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id, + user.id); + + await client.login(user.email, 'password1'); + + post = await client.createPost(TestHelper.basicTeam.id, post); + post.message += ' (edited)'; + + await client.editPost(TestHelper.basicTeam.id, post); + + store.subscribe(async () => { + const entities = store.getState().entities; + const {posts} = entities.posts; + assert.ok(posts[post.id].message.indexOf('(edited)') > -1); + }); + }); + + it('Websocket Handle Post Deleted', async () => { + const client = TestHelper.createClient(); + const user = await client.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + + await Client.addChannelMember( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id, + user.id); + + await client.login(user.email, 'password1'); + let post = TestHelper.fakePost(); + post.channel_id = TestHelper.basicChannel.id; + post = await client.createPost(TestHelper.basicTeam.id, post); + + await client.deletePost(TestHelper.basicTeam.id, post.channel_id, post.id); + + store.subscribe(async () => { + const entities = store.getState().entities; + const {posts} = entities.posts; + assert.strictEqual(posts[post.id].state, Constants.POST_DELETED); + }); + }); + + it('WebSocket Leave Team', async () => { + const client = TestHelper.createClient(); + const user = await client.createUser(TestHelper.fakeUser()); + await client.login(user.email, 'password1'); + const team = await client.createTeam(TestHelper.fakeTeam()); + const channel = await client.createChannel(TestHelper.fakeChannel(team.id)); + await client.addUserToTeam(team.id, TestHelper.basicUser.id); + await client.addChannelMember(team.id, channel.id, TestHelper.basicUser.id); + + await GeneralActions.setStoreFromLocalData({ + url: Client.getUrl(), + token: Client.getToken() + })(store.dispatch, store.getState); + await TeamActions.selectTeam(team)(store.dispatch, store.getState); + await ChannelActions.selectChannel(channel.id)(store.dispatch, store.getState); + await client.removeUserFromTeam(team.id, TestHelper.basicUser.id); + + const {myMembers} = store.getState().entities.teams; + assert.ifError(myMembers[team.id]); + }).timeout(3000); + + it('Websocket Handle User Added', async () => { + const client = TestHelper.createClient(); + const user = await client.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + + await TeamActions.selectTeam(TestHelper.basicTeam)(store.dispatch, store.getState); + + await ChannelActions.addChannelMember( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id, + user.id + )(store.dispatch, store.getState); + + const entities = store.getState().entities; + const profilesInChannel = entities.users.profilesInChannel; + assert.ok(profilesInChannel[TestHelper.basicChannel.id].has(user.id)); + }); + + it('Websocket Handle User Removed', async () => { + await TeamActions.selectTeam(TestHelper.basicTeam)(store.dispatch, store.getState); + + const user = await TestHelper.basicClient.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + + await ChannelActions.addChannelMember( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id, + user.id + )(store.dispatch, store.getState); + + await ChannelActions.removeChannelMember( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id, + user.id + )(store.dispatch, store.getState); + + const state = store.getState(); + const entities = state.entities; + const profilesNotInChannel = entities.users.profilesNotInChannel; + + assert.ok(profilesNotInChannel[TestHelper.basicChannel.id].has(user.id)); + }); + + it('Websocket Handle User Updated', async () => { + const client = TestHelper.createClient(); + const user = await client.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + + await client.login(user.email, 'password1'); + await client.updateUser({...user, first_name: 'tester4'}); + + store.subscribe(() => { + const state = store.getState(); + const entities = state.entities; + const profiles = entities.users.profiles; + + assert.strictEqual(profiles[user.id].first_name, 'tester4'); + }); + }); + + it('Websocket Handle Channel Created', (done) => { + async function test() { + await TeamActions.selectTeam(TestHelper.basicTeam)(store.dispatch, store.getState); + const channel = await Client.createChannel(TestHelper.fakeChannel(TestHelper.basicTeam.id)); + + setTimeout(() => { + const state = store.getState(); + const entities = state.entities; + const {channels, myMembers} = entities.channels; + + assert.ok(channels[channel.id]); + assert.ok(myMembers[channel.id]); + done(); + }, 1000); + } + + test(); + }); + + it('Websocket Handle Channel Deleted', (done) => { + async function test() { + await TeamActions.selectTeam(TestHelper.basicTeam)(store.dispatch, store.getState); + await ChannelActions.fetchMyChannelsAndMembers(TestHelper.basicTeam.id)(store.dispatch, store.getState); + await ChannelActions.selectChannel(TestHelper.basicChannel.id)(store.dispatch, store.getState); + await Client.deleteChannel( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id + ); + + setTimeout(() => { + const state = store.getState(); + const entities = state.entities; + const {channels, currentChannelId} = entities.channels; + + assert.ok(channels[currentChannelId].name === Constants.DEFAULT_CHANNEL); + done(); + }, 500); + } + + test(); + }); + + it('Websocket Handle Direct Channel', (done) => { + async function test() { + const client = TestHelper.createClient(); + const user = await client.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + + await client.login(user.email, 'password1'); + await TeamActions.selectTeam(TestHelper.basicTeam)(store.dispatch, store.getState); + + setTimeout(() => { + const entities = store.getState().entities; + const {channels} = entities.channels; + assert.ok(Object.keys(channels).length); + done(); + }, 500); + + await client.createDirectChannel(TestHelper.basicTeam.id, TestHelper.basicUser.id); + } + + test(); + }); +}); diff --git a/test/assets/images/test.png b/test/assets/images/test.png new file mode 100644 index 0000000000000000000000000000000000000000..13ede8580481b96b347c2d70075dac61988fbb78 GIT binary patch literal 5054 zcmZvAcQo5?*gjRYs#4UbhFC>wl~#>fLF`dg)TXIwt<)A(t-Yzel_*-9qSUO>QY*A( zkeG=TBSu1a^F6;me(xXeIiK@z)WivB=Y~h!`^=K!2x-9k2o{8laqh8y?u-vzaWz@&dyH8 z{~q@KBD8m&pPr&5AIu4fFREyrZEj(`{15wnp(Gxlq~!2LpHH#4-Qv=-jZKWM33-2i zzou?MQJv7%v0vLTcU$u4*T8Oh#nJD9%6+u=q&h@x8jo4-MvnxXExr2 z;`594YwAgh=w&nOlgTNJp2^t`o;Wjm^k--@B4#^1dn^9qvboK=hxf_U^hQYd(ZHX* z%9`b8_S>l$Xmy>vFO>&P-(OUZW!s1n@m&=xRBsAun zL^{Q7tb6*bxwzxrewY=!yKG@gT3$hG>aW;7pA)>hWN5bE&_wvretvp3|KQR5eWexq z7u(5cvjTT8+D03pk;GZlf`ZzDyxN?I)S@b6J2jIyzi@thj4^z&=K5wcD(;}Ul}sS4 zIlGb%4;PfcxWISoZ~S(03+F_o*Is#UC8nINuI*RXou8b{@{8@3eLWr?ofDHjU0dJD z$|D~htynwG-&5F(N!Y8ZMct7(ClWWKNC7|7^U;QWQ@`1$eEiscsfvw}sSja--&o=!RZcpLov2yU)R!+@DjI z$zs)I%vogkTByA-B!^bWg!B#;y=nPbT}J!aGND@bUQeO@xHtuk`QhQ5kyiN*P3{w zd?S=l83+&aNtZS5RBwg9F6hu`4Y==8&;e=XVm=_~j`sl%2vvX*ku_YBu0`|E1Yb%u z$M8f*s$^u3Qad1&z<)pxzqfh}Fzh~{+Z#Y?IM@QDP=*;M3lA{%yKO+^9Gy2__pqIP zNQZoj^wXw&VWY?;Z;aq9d&~TtJGeL0fc~V_9d96#a&hXflBJJr`EW^O{UO8BS^W8V z{dzyO2gI}a4;^*Pjq(}ezp!#GS%-5!&ErnlOfj5Zu=tMD}9 zMteTXVE#@Q{ECJVjz*(XK^~WQ3Rj6gK_XuYUh8nOEj$4U!k!Ly!Dh1F_C_Uk$kHol z-x+<-B0=MINx1U)6=?kV`^JbG1I5f-%k zJ`?nlsck%9+S6yte$mRWrN2%UWhB012Y*hg{lLbef#njEI|WU~AwREb2m^xyL+F`b zuuJhq{?3Dy%;f_9h?T}j)WUWgG`TNx@Upue3u+sCIypbm%rPBI2Wa)LMjWvW$=n;2DqIAAir4u|~ZP>FzrjV-d$L`iG3WfR~(7A&$ zz~1wJwWg4KQDfpcCzT4Cw;OIwfE-zd-7Pq5wIA;Dr?`#{J&tCd$!aG>nTR@5pubx?A5EQ;RT=pRkiWN&=p4d65IjS5LYt4Pf0S0t}IjwXRTpGIk#0H%uqKzc#IS@Wq-or zIQj32)6-C*)hqWa20oO%xwuxRSa8y=o_k(Ifd7<%@8O~-vy90I6H3q%ZTejcwAIElH*dv=<3$f6w<&sFLhY*x zJzL=Ydf32Yt34WKts?f3>zDoHr#KJXD9E@+ z0%2JtePhtKo>DWaSDS7AytZj(9&~SJv(@IKYbLC+ED$sr7pmMS++;BsB|7QbD&M6SGEU52vpEU0~91moh#iy+Z^8vk`LL495=p(^fA5Sz&!$31m=1C*k^^4p~^ zKtG6hej$JDrPUt_*+p-uR?E$_=Kc%;=Yv&>rkGC^$i58YLZ#QKh_Wlde1T_SOzVk=yR zvlzGqX(<=bY8aexNEr%jzn?wX5fP7Lq)7fHEt&R7hu0=H3+%es5)u@$eGx=J3$oua zdjCa_vA9#PHGo&h>`T1LST3^0OG44y)P;6P85Fyaaw!*76g7xF4wNNbJ}y3m$j>UA zKY2_TU;XCCAVhe~9bEnpaq+hu%cb^#Sv~9Sb**3cR}u_PF=pIkZ%+sx)`=j%= z#bfM9xcyDeoaEiI7F%3%=uC*SjZaMY_U#78JN{TnUe(wViEPRCPN;Ddu*3{wM}KewH8OrRO`mXBaVY~0^sM0@JyZQ zmtIoOnud5n79C_s%rK~bv~$6S^zO9iL^rD}VKOIAnL3(b@??mG&G6C9b~;VDx~3Ym zLDREC_QJmfiJ$9|_(H;`T?Rr{-6p50WozK~;i97p{L{Lya!)>pcOjqozGPZ#Vrkz< zlkPxDUi)vcOoc0N%e4{Sw>1@OA(&CL8PZA)wlr0H#JF0#%$yTQ&w8H!FA@wj5bCN5 zls;gqf$)hR@Lhic7@_@fCvoe>uZ5zzGt(C_Xk6G2??2=pH4owD?p zQE%yFH60IrSwwwzLq4YkSc7rc-Oe6SzYO)C(mZ_IEBSgv_hVY~p84j21Qj5dxcMxh z$ihJq_{JOFo`6*=!&KtuHFZRz)Zfy2H7bqGgRlu5Cak z<<2~5llP8w-rK<>Wmjgf;-OjiEmWDxd=?ecd8}hJ+eM)pgia>O?xypDksrh&@E9gP&%m2miXtEeUl~d8ZX7EQOdU?4x zRm+ETcO1k54!5mOYe+-v6+8k3hMbggXUrV%#>DB#|l#;A4(>$UcQ zoAbdYeVyq$nx2a}kK@6W5acN`kop!er%{>usU1Qe`GjjYdLnxGV{igLX$~eJpYQ7y ze$yRiN+<}Gn%%znyC0DM1qRALe0%vuhm1|^c`g`YG0j|;Wj-xm>?x`?Br|TAw8xRR znPotsF(2jp)yu)Y|CHe7^RyYd$Se0vh(4-XqErc~jW282QJ;P)ou42KuXx*wrG-q8 zExbpp z6+Q`w21m>!bL2VPug7B2Ybyekte*Mbp`S}k5`(%S2X>44k{^07z2K@Lf<&Yu+@Q$p zw-Q^#B@ae7Tbf*}1;hSZ`70&1)W2tZIfb{mE_1gRzFdwQi8ZQ4xbQ{9YsfnG&|2%h zL=OB>84RB;RTA~IXYKW?dHFovXhh=uxA`71!8Wp=4xGRncFD6wdQZkxC2YodkqEb-9wx38)y-k@R(K| z8m>!js-)+P(sJivI|^(iI~P5tymy3vKVu{xq$G(QaF$s-<^FqW6a5T)Zl5R;-b9a) z%(Y59eAD?nD4zw0>%nLq2TtldB-L%3+N?^+Y%f{z$`zkZ=44#sr$Vax0Dziw|MxGZ p;}zXC7s`7BAY6eIB}e~O5hEKr(va>eUq|Vr($zB3tOh%T{ttS0UM>It literal 0 HcmV?d00001 diff --git a/test/mocha.opts b/test/mocha.opts new file mode 100644 index 000000000..036c64f85 --- /dev/null +++ b/test/mocha.opts @@ -0,0 +1,5 @@ +--compilers js:babel-core/register +--require test/setup.js +--require babel-polyfill +--require isomorphic-fetch +--recursive diff --git a/test/sanity.test.js b/test/sanity.test.js new file mode 100644 index 000000000..af9e881f5 --- /dev/null +++ b/test/sanity.test.js @@ -0,0 +1,36 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +global.WebSocket = require('ws'); + +// Set up a global hooks to make debugging tests less of a pain +before(() => { + process.on('unhandledRejection', (reason) => { + // Rethrow so that tests will actually fail and not just timeout + throw reason; + }); +}); + +// Ensure that everything is imported correctly for testing +describe('Sanity test', () => { + it('Promise', (done) => { + Promise.resolve(true).then(() => { + done(); + }).catch((err) => { + done(err); + }); + }); + + it('async/await', async () => { + await Promise.resolve(true); + }); + + it('fetch', (done) => { + fetch('http://example.com').then(() => { + done(); + }).catch(() => { + // No internet connection, but fetch still returned at least + done(); + }); + }); +}); diff --git a/test/selectors/posts.test.js b/test/selectors/posts.test.js new file mode 100644 index 000000000..894b8ce9a --- /dev/null +++ b/test/selectors/posts.test.js @@ -0,0 +1,74 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import assert from 'assert'; + +import {makeGetPostsForThread} from 'selectors/entities/posts'; +import deepFreezeAndThrowOnMutation from 'utils/deep_freeze'; + +describe('Selectors.Posts', () => { + describe('makeGetPostsForThread', () => { + const posts = { + a: {id: 'a', channel_id: '1'}, + b: {id: 'b', channel_id: '1'}, + c: {id: 'c', root_id: 'a', channel_id: '1'}, + d: {id: 'd', root_id: 'b', channel_id: '1'}, + e: {id: 'e', root_id: 'a', channel_id: '1'}, + f: {id: 'f', channel_id: 'f'} + }; + const testState = deepFreezeAndThrowOnMutation({ + entities: { + posts: { + posts, + postsByChannel: { + 1: ['a', 'b', 'c', 'd', 'e', 'f'] + } + } + } + }); + + it('should return single post with no children', () => { + const getPostsForThread = makeGetPostsForThread(); + + assert.deepEqual(getPostsForThread(testState, {channelId: '1', rootId: 'f'}), [posts.f]); + }); + + it('should return post with children', () => { + const getPostsForThread = makeGetPostsForThread(); + + assert.deepEqual(getPostsForThread(testState, {channelId: '1', rootId: 'a'}), [posts.a, posts.c, posts.e]); + }); + + it('should return memoized result for identical props', () => { + const getPostsForThread = makeGetPostsForThread(); + + const props = {channelId: '1', rootId: 'a'}; + const result = getPostsForThread(testState, props); + + assert.equal(result, getPostsForThread(testState, props)); + }); + + it('should return different result for different props', () => { + const getPostsForThread = makeGetPostsForThread(); + + const result = getPostsForThread(testState, {channelId: '1', rootId: 'a'}); + + assert.notEqual(result, getPostsForThread(testState, {channelId: '1', rootId: 'a'})); + assert.deepEqual(result, getPostsForThread(testState, {channelId: '1', rootId: 'a'})); + }); + + it('should return memoized result for multiple selectors with different props', () => { + const getPostsForThread1 = makeGetPostsForThread(); + const getPostsForThread2 = makeGetPostsForThread(); + + const props1 = {channelId: '1', rootId: 'a'}; + const result1 = getPostsForThread1(testState, props1); + + const props2 = {channelId: '1', rootId: 'b'}; + const result2 = getPostsForThread2(testState, props2); + + assert.equal(result1, getPostsForThread1(testState, props1)); + assert.equal(result2, getPostsForThread2(testState, props2)); + }); + }); +}); diff --git a/test/setup.js b/test/setup.js new file mode 100644 index 000000000..caf1a1b0b --- /dev/null +++ b/test/setup.js @@ -0,0 +1,14 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +/* eslint-disable */ + +import fs from 'fs'; +import path from 'path'; +import register from 'babel-core/register'; + +const rcPath = path.join(__dirname, '..', '.babelrc'); +const source = fs.readFileSync(rcPath).toString(); +const config = JSON.parse(source); + +register(config); diff --git a/test/test_helper.js b/test/test_helper.js new file mode 100644 index 000000000..50d4ccfac --- /dev/null +++ b/test/test_helper.js @@ -0,0 +1,133 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import assert from 'assert'; + +import Client from 'client/client'; + +const DEFAULT_SERVER = 'http://localhost:8065'; +const PASSWORD = 'password1'; + +class TestHelper { + constructor() { + this.basicClient = null; + + this.basicUser = null; + this.basicTeam = null; + this.basicChannel = null; + this.basicPost = null; + } + + assertStatusOkay = (data) => { + assert(data); + assert(data.status === 'OK'); + }; + + generateId = () => { + // Implementation taken from http://stackoverflow.com/a/2117523 + let id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'; + + id = id.replace(/[xy]/g, (c) => { + const r = Math.floor(Math.random() * 16); + + let v; + if (c === 'x') { + v = r; + } else { + v = (r & 0x3) | 0x8; + } + + return v.toString(16); + }); + + return 'uid' + id; + }; + + createClient = () => { + const client = new Client(); + + client.setUrl(DEFAULT_SERVER); + + return client; + }; + + fakeEmail = () => { + return 'success' + this.generateId() + '@simulator.amazonses.com'; + }; + + fakeUser = () => { + return { + email: this.fakeEmail(), + allow_marketing: true, + password: PASSWORD, + username: this.generateId() + }; + }; + + fakeTeam = () => { + const name = this.generateId(); + let inviteId = this.generateId(); + if (inviteId.length > 32) { + inviteId = inviteId.substring(0, 32); + } + + return { + name, + display_name: `Unit Test ${name}`, + type: 'O', + email: this.fakeEmail(), + allowed_domains: '', + invite_id: inviteId + }; + }; + + fakeChannel = (teamId) => { + const name = this.generateId(); + + return { + name, + team_id: teamId, + display_name: `Unit Test ${name}`, + type: 'O' + }; + }; + + fakeChannelMember = (userId, channelId) => { + return { + user_id: userId, + channel_id: channelId, + notify_props: {}, + roles: 'system_user' + }; + }; + + fakePost = (channelId) => { + return { + channel_id: channelId, + message: `Unit Test ${this.generateId()}` + }; + }; + + initBasic = async (client = this.createClient()) => { + client.setUrl(DEFAULT_SERVER); + this.basicClient = client; + + this.basicUser = await client.createUser(this.fakeUser()); + await client.login(this.basicUser.email, PASSWORD); + + this.basicTeam = await client.createTeam(this.fakeTeam()); + + this.basicChannel = await client.createChannel(this.fakeChannel(this.basicTeam.id)); + this.basicPost = await client.createPost(this.basicTeam.id, this.fakePost(this.basicChannel.id)); + + return { + client: this.basicClient, + user: this.basicUser, + team: this.basicTeam, + channel: this.basicChannel, + post: this.basicPost + }; + }; +} + +export default new TestHelper(); diff --git a/test/utils/post_utils.test.js b/test/utils/post_utils.test.js new file mode 100644 index 000000000..920563530 --- /dev/null +++ b/test/utils/post_utils.test.js @@ -0,0 +1,77 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import assert from 'assert'; + +import {addDatesToPostList} from 'utils/post_utils'; + +describe('addDatesToPostList', () => { + it('single post', () => { + const input = [{create_at: 1486533600000}]; + + const output = addDatesToPostList(input); + + assert.notEqual(input, output); + assert.deepEqual(output, [ + input[0], + new Date(input[0].create_at) + ]); + }); + + it('two posts on same day', () => { + const input = [ + {create_at: 1486533600000}, + {create_at: 1486533601000} + ]; + + const output = addDatesToPostList(input); + + assert.notEqual(input, output); + assert.deepEqual(output, [ + input[0], + input[1], + new Date(input[1].create_at) + ]); + }); + + it('two posts on different days', () => { + const input = [ + {create_at: 1486533600000}, + {create_at: 1486620000000} + ]; + + const output = addDatesToPostList(input); + + assert.notEqual(input, output); + assert.deepEqual(output, [ + input[0], + new Date(input[0].create_at), + input[1], + new Date(input[1].create_at) + ]); + }); + + it('multiple posts', () => { + const input = [ + {create_at: 1486533600000}, + {create_at: 1486533601000}, + {create_at: 1486620000000}, + {create_at: 1486706400000}, + {create_at: 1486706401000} + ]; + + const output = addDatesToPostList(input); + + assert.notEqual(input, output); + assert.deepEqual(output, [ + input[0], + input[1], + new Date(input[1].create_at), + input[2], + new Date(input[2].create_at), + input[3], + input[4], + new Date(input[4].create_at) + ]); + }); +});