snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
<!-- Thank you for contributing a pull request! Here are a few tips to help you:
1. If this is your first contribution, make sure you've read the Contribution Checklist https://developers.mattermost.com/contribute/getting-started/contribution-checklist/
2. Read our blog post about "Submitting Great PRs" https://developers.mattermost.com/blog/2019-01-24-submitting-great-prs
3. Take a look at other repository specific documentation at https://developers.mattermost.com/contribute
-->
#### Summary
<!--
A description of what this pull request does.
-->
#### Ticket Link
<!--
If applicable, please include both or either of the following links:
Fixes https://github.com/mattermost/mattermost/issues/XXX
Jira https://mattermost.atlassian.net/browse/MM-XXX
-->
#### Related Pull Requests
<!--
List all PRs related to resolving a ticket. For instance, if you submitted a PR to `mattermost/mattermost`, please include it here.
-->
- Has server changes (please link here)
- Has mobile changes (please link here)
#### Screenshots
<!--
If the PR includes UI changes, include screenshots/GIFs.
For an easier comparison of UI changes a table (template below) can be used.
| before | after |
|----|----|
| <insert before screenshot here> | <insert after screenshot here> |
-->
#### Release Note
<!--
Add a release note for each of the following conditions:
* Config changes (additions, deletions, updates)
* API additions—new endpoint, new response fields, or newly accepted request parameters
* Database changes (any)
* Websocket additions or changes
* Anything noteworthy to a Mattermost instance administrator (err on the side of over-communicating)
* New features and improvements, including behavioural changes, UI changes and CLI changes
* Bug fixes and fixes of previous known issues
* Deprecation warnings, breaking changes, or compatibility notes
If no release notes are required write NONE. Use past-tense. Newlines are stripped.
Examples:
```
Added new API endpoints POST /api/v4/foo, GET api/v4/foo, and GET api/v4/foo/:foo_id.
```
```
Added a new config setting ServiceSettings.FooBar. Added a new column Foo to the Users table.
```
```
NONE
```
-->
```release-note
```

View File

@@ -0,0 +1,99 @@
name: Performance Benchmarks
on:
workflow_call:
jobs:
cypress-run:
runs-on: ubuntu-latest-4-cores
name: Run Cypress benchmarks
services:
postgres:
image: postgres
env:
POSTGRES_PASSWORD: mostest_password
POSTGRES_USER: mmuser
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Check out web app
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: ".nvmrc"
- name: Download and install Cypress
uses: cypress-io/github-action@e44ee0fa67251ab7e2d854d85b3a466577f47d14 # v7.1.3
with:
runTests: false
working-directory: e2e/cypress
- name: Download the thing
working-directory: e2e/cypress
run: |
if [[ "${{ github.event.pull_request.head.sha }}" != "" ]]; then
echo "Using server build from pr-builds.mattermost.com"
curl https://pr-builds.mattermost.com/mattermost-webapp/commit/${{ github.event.pull_request.head.sha }}/mattermost-enterprise-linux-amd64.tar.gz > mattermost.tar.gz
tar xf mattermost.tar.gz
else
echo "Using server build from releases.mattermost.com with web app build from pr-builds.mattermost.com"
# Note that the server commit might not be the latest if we merge server and web app branches at the same time
curl https://s3.amazonaws.com/releases.mattermost.com/gitlab/bundle/${{ github.ref_name }}/mattermost-enterprise-linux-amd64.tar.gz > mattermost-server.tar.gz
curl https://pr-builds.mattermost.com/mattermost-webapp/commit/${{ github.sha }}/mattermost-webapp.tar.gz > mattermost-webapp.tar.gz
# Unzip the server, replace the web app, and re-add product files
tar xf mattermost-server.tar.gz
mv mattermost/client client-original
tar xf mattermost-webapp.tar.gz
mv client mattermost
mv client-original/products mattermost/client/products
fi
- name: Run the E2E tests
uses: cypress-io/github-action@e44ee0fa67251ab7e2d854d85b3a466577f47d14 # v7.1.3
with:
browser: chrome
config: video=true,videosFolder=tests/videos
install: false
spec: ./tests/integration/performance/*
start: npm run benchmarks:run-server
wait-on: http://localhost:8065
working-directory: e2e/cypress
env:
MM_LOGSETTINGS_ENABLECONSOLE: false
MM_LOGSETTINGS_FILELEVEL: debug
MM_SQLSETTINGS_DATASOURCE: postgres://mmuser:mostest_password@localhost/postgres?sslmode=disable&connect_timeout=10&binary_parameters=yes
MM_TEAMSETTINGS_ENABLEOPENSERVER: true
- name: Upload Cypress logs
if: ${{ always() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Cypress logs
path: e2e/cypress/tests/integration/performance/logs/
retention-days: 3
- name: Upload Mattermost logs
if: ${{ always() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Mattermost logs
path: e2e/cypress/mattermost/logs/mattermost.log
retention-days: 3
- name: Upload screenshots
if: ${{ failure() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Test screenshots
path: e2e/cypress/tests/screenshots
retention-days: 3
- name: Upload videos
if: ${{ always() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Test Videos
path: e2e/cypress/tests/videos

29
webapp/channels/.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
*.swp
.idea
*.tar.gz
tests/reports
.DS_Store
mattermost-webapp.iml
.vscode/
coverage
*.tsbuildinfo
# Environment variables
.env*
*.crt
*.key
mattermost-license.txt
# Vim temporary files
[._]*.s[a-w][a-z]
[._]s[a-w][a-z]
*.un~
Session.vim
.netrwhist
*~
.eslintcache
.stylelintcache
# i18n temporary check file
.i18n-check.tmp.json

View File

@@ -0,0 +1,104 @@
{
"ignore": {
"@formatjs/intl-pluralrules": {
"versions": "",
"reason": "MM-34912 - Breaking changes to how locale data is loaded."
},
"@formatjs/intl-relativetimeformat": {
"versions": "",
"reason": "MM-34912 - Breaking changes to how locale data is loaded."
},
"@stripe/react-stripe-js": {
"versions": "",
"reason": "MM-34916 - Upgrades involve sensitive parts of the code."
},
"@stripe/stripe-js": {
"versions": "",
"reason": "MM-34916 - Upgrades involve sensitive parts of the code."
},
"bootstrap": {
"versions": "",
"reason": "MM-10786 - Bootstrap is heavily integrated in the code base and signficantly out of date."
},
"@types/bootstrap": {
"versions": "",
"reason": "MM-10786 - Bootstrap is heavily integrated in the code base and signficantly out of date."
},
"chart.js": {
"versions": "",
"reason": "MM-34915 - Breaking changes to how components are loaded."
},
"emoji-datasource": {
"versions": "",
"reason": "MM-36772 - Emoji updates are handled separately from regular dependencies."
},
"emoji-datasource-apple": {
"versions": "",
"reason": "MM-36772 - Emoji updates are handled separately from regular dependencies."
},
"p-queue": {
"versions": "",
"reason": "MM-34914 - Breaking changes to how modules are loaded."
},
"pdfjs-dist": {
"versions": "",
"reason": "MM-32929 - Breaking changes to how library is loaded and used."
},
"react": {
"versions": "",
"reason": "MM-45255 - React updates are handled separately from regular dependencies."
},
"@types/react": {
"versions": "",
"reason": "MM-45255 - React updates are handled separately from regular dependencies."
},
"react-dom": {
"versions": "",
"reason": "MM-45255 - React updates are handled separately from regular dependencies."
},
"@types/react-dom": {
"versions": "",
"reason": "MM-45255 - React updates are handled separately from regular dependencies."
},
"react-is": {
"versions": "",
"reason": "MM-45255 - React updates are handled separately from regular dependencies."
},
"@types/react-is": {
"versions": "",
"reason": "MM-45255 - React updates are handled separately from regular dependencies."
},
"react-overlays": {
"versions": "",
"reason": "MM-10786 - Bootstrap is heavily integrated in the code base and signficantly out of date."
},
"redux-persist": {
"versions": "",
"reason": "MM-34911 - Library is no longer supported and we're significantly out of date on it."
},
"serialize-error": {
"versions": "",
"reason": "MM-34914 - Breaking changes to how modules are loaded."
},
"@types/marked": {
"versions": "",
"reason": "Ignored since our forked version is wildly different from upstream."
},
"@types/react-bootstrap": {
"versions": "",
"reason": "MM-10786 - Bootstrap is heavily integrated in the code base and signficantly out of date."
},
"react-select": {
"versions": "",
"reason": "MM-18031 - Breaking changes."
},
"@types/react-select": {
"versions": "",
"reason": "MM-18031 - Breaking changes."
},
"flexsearch": {
"versions": "",
"reason": "MM-37116 - Breaking changes in API"
}
}
}

View File

@@ -0,0 +1 @@
dist/

View File

@@ -0,0 +1,42 @@
{
"extends": [
"stylelint-config-idiomatic-order",
"stylelint-config-recommended-scss"
],
"plugins": [
"@stylistic/stylelint-plugin"
],
"rules": {
"@stylistic/indentation": 4,
"@stylistic/no-missing-end-of-source-newline": true,
"no-descending-specificity": null,
"font-family-no-missing-generic-family-keyword": null,
"property-no-unknown": [
true,
{
"ignoreProperties": ["scrollbar-3dlight-color"]
}
],
"block-no-empty": [
true,
{
"ignore": ["comments"]
}
],
"declaration-property-value-disallowed-list": [
{
"color": ["/--denim-button-bg/"],
"background-color": ["/--denim-button-bg/"],
"border-color": ["/--denim-button-bg/"],
"background": ["/--denim-button-bg/"],
"border": ["/--denim-button-bg/"],
"fill": ["/--denim-button-bg/"]
},
{
"message": "The --denim-button-bg and --denim-button-bg-rgb variables are deprecated. Please use --button-bg or --button-bg-rgb instead."
}
],
"scss/load-no-partial-leading-underscore": null,
"scss/at-extend-no-missing-placeholder": null
}
}

View File

@@ -0,0 +1,73 @@
# CLAUDE: `webapp/channels/`
## Purpose
- Main Mattermost web client workspace; almost every UI or Redux change flows through this package.
- Runs as an npm workspace – use `--workspace=channels` when installing deps or running scripts.
- Builds a federated bundle consumed by the server and plugins.
## Local Commands
- `npm run dev-server --workspace=channels` – hot-reload development server.
- `npm run build --workspace=channels` – production bundle (invokes webpack config in this folder).
- `npm run test --workspace=channels` / `npm run test:watch --workspace=channels`.
- `npm run check --workspace=channels` and `npm run fix --workspace=channels` for lint/style fixes.
## Directory Structure (src/)
```
src/
├── components/ # React components organized by feature (300+ subdirectories)
├── actions/ # Redux action creators (sync and async thunks)
├── selectors/ # Redux selectors for deriving state
├── reducers/ # Redux reducers for state management
├── utils/ # Utility functions and helpers
├── tests/ # Test utilities and helpers
├── i18n/ # Internationalization files
├── sass/ # Global SCSS styles and theme variables
├── types/ # TypeScript type definitions specific to the web app
├── store/ # Redux store configuration with redux-persist
├── plugins/ # Plugin integration points
├── packages/
│ └── mattermost-redux/ # Core Redux layer (actions, reducers, selectors)
├── entry.tsx # Application entry point
└── root.tsx # Root React component
```
## State Management
- **Redux + Redux Thunk**: Central state management using Redux with thunk middleware for async actions.
- **Redux Persist**: State persistence using localForage with cross-tab synchronization.
- **Mattermost Redux**: Core Redux logic (`state.entities.*` for server data).
- **State Views**: `state.views.*` for UI state (modals, sidebars, preferences).
- **Client4**: Singleton HTTP client for API requests. Should only be used in Redux actions.
## Key Files
- `package.json` – workspace-specific scripts, env vars, and browserlist targets.
- `webpack.config.js` – module federation + alias map; update remotes or exposes here only when necessary.
- `jest.config.js` – test roots, transformers, moduleNameMapper for workspace aliases.
- `tsconfig.json` – project references for `src`, `tests`, and embedded packages.
## TypeScript Configuration
- **Strict Mode**: TypeScript strict mode enabled with `strictNullChecks`
- **Path Aliases**: Configured for `@mattermost/*` packages and `mattermost-redux/*`
- **Composite Projects**: Uses TypeScript project references for workspace packages
- **No Any**: Avoid `any` types; legacy code may have them but new code should be typed
## Module Federation Notes
- Use `channels/src/module_registry.ts` to register async chunks; never import plugin remotes synchronously.
- Exposed modules must stay backward compatible; document any break in `webapp/README.md`.
- When adding a new remote, coordinate with server config (see `webpack.config.js` → `remotes`).
- Prefer wrapping plugin surfaces in adapter components so that federated boundaries remain stable.
## Dependencies & UI Stack
- React 18, Redux 5, React Router 5, React Intl, Floating UI, Compass Icons, Monaco.
- Follow `webapp/STYLE_GUIDE.md → Dependencies & Packages` before introducing new libs.
- `@mattermost/types`, `@mattermost/client`, and `platform/components` are first-party packages; import via full package names, not deep relative paths.
## Common Gotchas
- Postinstall builds platform packages—if TypeScript types feel stale, re-run `npm install` at repo root.
- Use `npm add <pkg> --workspace=channels` to avoid polluting other workspaces.
- Environment-specific overrides live in `config/` on the server side; do not hard-code URLs or feature flags here.
- Webpack aliases mirror tsconfig paths; keep both in sync when adding a new alias.
## References
- `webapp/STYLE_GUIDE.md → Automated Style Checking`, `Dependencies & Packages`.
- `webapp/README.md` for high-level architecture and release info.

202
webapp/channels/LICENSE.txt Normal file
View File

@@ -0,0 +1,202 @@
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.

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
const {dependencies} = require('./package.json');
const corejsVersion = dependencies['core-js'];
const config = {
presets: [
['@babel/preset-env', {
targets: {
chrome: 110,
firefox: 102,
edge: 110,
safari: '16.4',
},
corejs: corejsVersion,
useBuiltIns: 'usage',
shippedProposals: true,
}],
['@babel/preset-react', {
useBuiltIns: true,
}],
['@babel/typescript', {
allExtensions: true,
isTSX: true,
}],
],
plugins: [
[
'babel-plugin-styled-components',
{
ssr: false,
fileName: false,
},
],
],
sourceType: 'unambiguous',
};
module.exports = config;

1
webapp/channels/build/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
test-results.xml

View File

@@ -0,0 +1,3 @@
FROM node:14.16
RUN apt-get update && apt-get install -y make gcc nasm libpng-dev
CMD [ "node" ]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,502 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/*
* This script will generate the emoji files for both the webapp and server to use emojis from 'emoji-datasource' npm package
* It will generate the following files:
* '<rootDir>/webapp/channels/src/utils/emoji.ts'
* '<rootDir>/webapp/channels/src/sass/components/_emojisprite.scss'
* '<rootDir>/webapp/channels/src/utils/emoji.json'
* '<rootDir>/server/public/model/emoji_data.go',
*
* For help on how to use this script, run:
* npm run make-emojis -- --help
*/
import * as fs from 'node:fs';
import * as fsPromise from 'node:fs/promises';
import path from 'node:path';
import * as url from 'node:url';
import chalk from 'chalk';
import jsonCategories from 'emoji-datasource/categories.json' assert {type: 'json'};
import jsonData from 'emoji-datasource/emoji.json' assert {type: 'json'};
import yargs from 'yargs';
import additionalShortnames from './additional_shortnames.json' assert {type: 'json'};
const EMOJI_SIZE = 64;
const EMOJI_SIZE_PADDED = EMOJI_SIZE + 2; // 1px per side
const EMOJI_DEFAULT_SKIN = 'default';
const endResults = [];
const currentDir = path.dirname(url.fileURLToPath(import.meta.url));
const rootDir = path.resolve(currentDir, '..', '..', '..', '..');
const serverRootDir = path.resolve(rootDir, 'server');
const webappRootDir = path.resolve(rootDir, 'webapp');
const argv = yargs(process.argv.slice(1)).
scriptName('make-emojis').
usage('Usage : npm run $0 -- [args]').
example('npm run $0 -- --excluded-emoji-file ./excludedEmojis.txt', 'removes mentioned emojis from the app').
option('excluded-emoji-file', {
description: 'Path to a file containing emoji short names to exclude',
type: 'string',
}).
help().
epilog('Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.').
argv;
const argsExcludedEmojiFile = argv['excluded-emoji-file'];
function log(level = '', text) {
if (level === 'info') {
// eslint-disable-next-line no-console
console.log(chalk.cyan(`[INFO]: ${text}`));
} else if (level === 'warn') {
// eslint-disable-next-line no-console
console.log(chalk.yellow(`[WARN]: ${text}`));
} else if (level === 'error') {
// eslint-disable-next-line no-console
console.error(chalk.red(`[ERRO]: ${text}`));
} else if (level === 'success') {
// eslint-disable-next-line no-console
console.log(chalk.green(`[SUCC]: ${text}`));
} else {
// eslint-disable-next-line no-console
console.log(text);
}
}
function writeToFileAndPrint(fileName, filePath, data) {
const promise = fsPromise.writeFile(filePath, data, writeOptions);
promise.then(() => {
log('info', `"${fileName}" generated successfully in "${filePath}"`);
}).catch((err) => {
log('error', `Failed to generate "${fileName}": ${err}`);
});
return promise;
}
function copyFileAndPrint(source, destination, fileName, print = true) {
const promise = fsPromise.copyFile(source, destination);
promise.then(() => {
if (print) {
log('info', `"${fileName}" copied successfully to "${destination}"`);
}
}).catch((err) => {
log('error', `Failed to copy "${fileName}": ${err}`);
});
return promise;
}
function normalizeCategoryName(category) {
return category.toLowerCase().replace(' & ', '-');
}
// Copy emoji images from 'emoji-datasource-apple'
const emojiDataSourceDir = path.resolve(webappRootDir, `node_modules/emoji-datasource-apple/img/apple/${EMOJI_SIZE}/`);
const emojiImagesDir = path.resolve(webappRootDir, 'channels', 'src', 'images', 'emoji');
const readDirPromise = fsPromise.readdir(emojiDataSourceDir);
endResults.push(readDirPromise);
readDirPromise.then((images) => {
for (const imageFile of images) {
endResults.push(copyFileAndPrint(path.join(emojiDataSourceDir, imageFile), path.join(emojiImagesDir, imageFile), imageFile, false));
}
});
// Missing emojis from Apple emoji set ["medical_symbol", "male_sign", and "female_sign"]
// @see https://github.com/iamcal/emoji-data#image-sources
const missingEmojis = ['2640-fe0f', '2642-fe0f', '2695-fe0f'];
// Copy the missing from google emoji set
const missingEmojiDataSourceDir = path.resolve(webappRootDir, `node_modules/emoji-datasource-google/img/google/${EMOJI_SIZE}/`);
const readMissingDirPromise = fsPromise.readdir(missingEmojiDataSourceDir);
endResults.push(readMissingDirPromise);
readMissingDirPromise.then(() => {
for (const missingEmoji of missingEmojis) {
endResults.push(
copyFileAndPrint(path.join(missingEmojiDataSourceDir, `${missingEmoji}.png`), path.join(emojiImagesDir, `${missingEmoji}.png`), `Missed ${missingEmoji}`));
}
});
// Copy mattermost emoji image
const webappImagesDir = path.resolve(webappRootDir, 'channels', 'src', 'images');
endResults.push(copyFileAndPrint(path.resolve(webappImagesDir, 'icon64x64.png'), path.resolve(webappImagesDir, 'emoji/mattermost.png'), 'mattermost-emoji'));
const sheetSource = path.resolve(webappRootDir, `node_modules/emoji-datasource-apple/img/apple/sheets/${EMOJI_SIZE}.png`);
const sheetAbsoluteFile = path.resolve(webappRootDir, 'channels', 'src', 'images/emoji-sheets/apple-sheet.png');
const sheetFile = 'images/emoji-sheets/apple-sheet.png';
// Copy sheet image
endResults.push(copyFileAndPrint(sheetSource, sheetAbsoluteFile, 'emoji-sheet'));
// we'll load it as a two dimensional array so we can generate a Map out of it
const emojiIndicesByAlias = [];
const emojiIndicesByUnicode = [];
const emojiIndicesByCategory = new Map();
const emojiIndicesByCategoryAndSkin = new Map();
const emojiIndicesByCategoryNoSkin = new Map();
const categoryNamesSet = new Set();
const emojiImagesByAlias = [];
const emojiFilePositions = new Map();
const skinCodes = {
'1F3FB': 'light_skin_tone',
'1F3FC': 'medium_light_skin_tone',
'1F3FD': 'medium_skin_tone',
'1F3FE': 'medium_dark_skin_tone',
'1F3FF': 'dark_skin_tone',
default: 'default',
};
const skinNames = {
'1F3FB': 'LIGHT SKIN TONE',
'1F3FC': 'MEDIUM LIGHT SKIN TONE',
'1F3FD': 'MEDIUM SKIN TONE',
'1F3FE': 'MEDIUM DARK SKIN TONE',
'1F3FF': 'DARK SKIN TONE',
};
const control = new AbortController();
const writeOptions = {
encoding: 'utf8',
signal: control.signal,
};
// Extract excluded emoji shortnames as an array
const excludedEmoji = [];
if (argsExcludedEmojiFile) {
fs.readFileSync(path.normalize(argsExcludedEmojiFile), 'utf-8').split(/\r?\n/).forEach((line) => {
excludedEmoji.push(line);
});
log('warn', `The following emoji(s) will be excluded from the webapp: \n${excludedEmoji}\n`);
}
// Remove unwanted emoji
const filteredEmojiJson = jsonData.filter((element) => !excludedEmoji.some((e) => element.short_names.includes(e)));
function generateEmojiSkinVariations(emoji) {
if (!emoji.skin_variations) {
return [];
}
return Object.keys(emoji.skin_variations).map((skinCode) => {
// if skin codes ever change this will produce a null_light_skin_tone
const skins = skinCode.split('-');
const skinShortName = skins.map((code) => skinCodes[code]).join('_');
const skinName = skins.map((code) => skinNames[code]).join(', ');
const variation = {...emoji.skin_variations[skinCode]};
variation.short_name = `${emoji.short_name}_${skinShortName}`;
variation.short_names = emoji.short_names.map((alias) => `${alias}_${skinShortName}`);
variation.name = `${emoji.name}: ${skinName}`;
variation.category = emoji.category;
variation.skins = skins;
return variation;
});
}
// populate skin tones as full emojis
const fullEmoji = [...filteredEmojiJson];
filteredEmojiJson.forEach((emoji) => {
const variations = generateEmojiSkinVariations(emoji);
fullEmoji.push(...variations);
});
// add old shortnames to maintain backwards compatibility with gemoji
fullEmoji.forEach((emoji) => {
if (emoji.short_name in additionalShortnames) {
emoji.short_names.push(...additionalShortnames[emoji.short_name]);
}
});
// add built-in custom emojis
fullEmoji.push({
id: 'mattermost',
name: 'mattermost',
unified: '',
image: 'mattermost.png',
short_name: 'mattermost',
short_names: ['mattermost'],
category: 'custom',
});
fullEmoji.sort((emojiA, emojiB) => emojiA.sort_order - emojiB.sort_order);
function addIndexToMap(emojiMap, key, ...indexes) {
const newList = emojiMap.get(key) || [];
newList.push(...indexes);
emojiMap.set(key, newList);
}
const skinset = new Set();
fullEmoji.forEach((emoji, index) => {
if (emoji.unified) {
emojiIndicesByUnicode.push([emoji.unified.toLowerCase(), index]);
}
const safeCat = normalizeCategoryName(emoji.category);
emoji.category = safeCat;
addIndexToMap(emojiIndicesByCategory, safeCat, index);
if (emoji.skins || emoji.skin_variations) {
const skin = (emoji.skins && emoji.skins[0]) || EMOJI_DEFAULT_SKIN;
skinset.add(skin);
const categoryBySkin = emojiIndicesByCategoryAndSkin.get(skin) || new Map();
addIndexToMap(categoryBySkin, safeCat, index);
emojiIndicesByCategoryAndSkin.set(skin, categoryBySkin);
} else {
addIndexToMap(emojiIndicesByCategoryNoSkin, safeCat, index);
}
categoryNamesSet.add(safeCat);
emojiIndicesByAlias.push(...emoji.short_names.map((alias) => [alias, index]));
const file = emoji.image.split('.')[0];
emoji.fileName = emoji.image;
emoji.image = file;
if (emoji.category !== 'custom') {
let x = emoji.sheet_x * EMOJI_SIZE_PADDED;
if (x !== 0) {
x += 'px';
}
let y = emoji.sheet_y * EMOJI_SIZE_PADDED;
if (y !== 0) {
y += 'px';
}
emojiFilePositions.set(file, `-${x} -${y};`);
}
emojiImagesByAlias.push(...emoji.short_names.map((alias) => `"${alias}": "${file}"`));
});
function trimPropertiesFromEmoji(emoji) {
if (Object.hasOwn(emoji, 'non_qualified')) {
Reflect.deleteProperty(emoji, 'non_qualified');
}
if (Object.hasOwn(emoji, 'docomo')) {
Reflect.deleteProperty(emoji, 'docomo');
}
if (Object.hasOwn(emoji, 'au')) {
Reflect.deleteProperty(emoji, 'au');
}
if (Object.hasOwn(emoji, 'softbank')) {
Reflect.deleteProperty(emoji, 'softbank');
}
if (Object.hasOwn(emoji, 'google')) {
Reflect.deleteProperty(emoji, 'google');
}
if (Object.hasOwn(emoji, 'sheet_x')) {
Reflect.deleteProperty(emoji, 'sheet_x');
}
if (Object.hasOwn(emoji, 'sheet_y')) {
Reflect.deleteProperty(emoji, 'sheet_y');
}
if (Object.hasOwn(emoji, 'added_in')) {
Reflect.deleteProperty(emoji, 'added_in');
}
if (Object.hasOwn(emoji, 'has_img_apple')) {
Reflect.deleteProperty(emoji, 'has_img_apple');
}
if (Object.hasOwn(emoji, 'has_img_google')) {
Reflect.deleteProperty(emoji, 'has_img_google');
}
if (Object.hasOwn(emoji, 'has_img_twitter')) {
Reflect.deleteProperty(emoji, 'has_img_twitter');
}
if (Object.hasOwn(emoji, 'has_img_facebook')) {
Reflect.deleteProperty(emoji, 'has_img_facebook');
}
if (Object.hasOwn(emoji, 'source_index')) {
Reflect.deleteProperty(emoji, 'source_index');
}
if (Object.hasOwn(emoji, 'sort_order')) {
Reflect.deleteProperty(emoji, 'sort_order');
}
if (Object.hasOwn(emoji, 'subcategory')) {
Reflect.deleteProperty(emoji, 'subcategory');
}
if (Object.hasOwn(emoji, 'image')) {
Reflect.deleteProperty(emoji, 'image');
}
if (Object.hasOwn(emoji, 'fileName')) {
Reflect.deleteProperty(emoji, 'fileName');
}
return emoji;
}
// Removed properties that are not needed for the webapp
const trimmedDownEmojis = fullEmoji.map((emoji) => trimPropertiesFromEmoji(emoji));
// Create the emoji.json file
const webappUtilsEmojiDir = path.resolve(webappRootDir, 'channels', 'src', 'utils', 'emoji.json');
endResults.push(writeToFileAndPrint('emoji.json', webappUtilsEmojiDir, JSON.stringify(trimmedDownEmojis, null, 4)));
const categoryList = Object.keys(jsonCategories).filter((item) => item !== 'Component').map(normalizeCategoryName);
const categoryNames = ['recent', ...categoryList, 'custom'];
const writeableSkinCategories = [];
const skinnedCats = [];
for (const skin of emojiIndicesByCategoryAndSkin.keys()) {
writeableSkinCategories.push(`['${skin}', new Map(${JSON.stringify(Array.from(emojiIndicesByCategoryAndSkin.get(skin)))})]`);
skinnedCats.push(`['${skin}', genSkinnedCategories('${skin}')]`);
}
// Generate contents of emoji.ts out of the emoji.json parsing we did earlier
const emojiJSX = `// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// This file is automatically generated via \`/webapp/channels/build/make_emojis.mjs\`. Do not modify it manually.
/* eslint-disable */
import memoize from 'memoize-one';
import emojis from 'utils/emoji.json';
import spriteSheet from '${sheetFile}';
export const Emojis = emojis;
export const EmojiIndicesByAlias = new Map(${JSON.stringify(emojiIndicesByAlias)});
export const EmojiIndicesByUnicode = new Map(${JSON.stringify(emojiIndicesByUnicode)});
export const CategoryNames = ${JSON.stringify(categoryNames)};
const AllEmojiIndicesByCategory = new Map(${JSON.stringify(Array.from(emojiIndicesByCategory))});
const EmojiIndicesByCategoryAndSkin = new Map([${writeableSkinCategories.join(', ')}]);
const EmojiIndicesByCategoryNoSkin = new Map(${JSON.stringify(Array.from(emojiIndicesByCategoryNoSkin))});
const skinCodes = ${JSON.stringify(skinCodes)};
// Generate the list of indices that belong to each category by an specified skin
function genSkinnedCategories(skin: string) {
const result = new Map();
for (const cat of CategoryNames) {
const indices = [];
const skinCat = (EmojiIndicesByCategoryAndSkin.get(skin) || new Map()).get(cat) || [];
indices.push(...(EmojiIndicesByCategoryNoSkin.get(cat) || []));
indices.push(...skinCat);
result.set(cat, indices);
}
return result;
}
const getSkinnedCategories = memoize(genSkinnedCategories);
export const EmojiIndicesByCategory = new Map([${skinnedCats.join(', ')}]);
`;
// Create the emoji.ts file
const webappUtilsEmojiTSDir = path.resolve(webappRootDir, 'channels', 'src', 'utils', 'emoji.ts');
endResults.push(writeToFileAndPrint('emoji.ts', webappUtilsEmojiTSDir, emojiJSX));
const serverEmojiDataDir = path.resolve(serverRootDir, 'public', 'model', 'emoji_data.go');
const emojiGo = `// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// This file is automatically generated via \`/webapp/channels/build/make_emojis.mjs\`. Do not modify it manually.
package model
var SystemEmojis = map[string]string{
${emojiImagesByAlias.join(`,
`)},
}
`;
// Create the emoji_data.go file
const goPromise = writeToFileAndPrint('emoji_data.go', serverEmojiDataDir, emojiGo);
endResults.push(goPromise);
// Create individual emoji styles
const cssCats = categoryNames.filter((cat) => cat !== 'custom').map((cat) => `.emoji-category-${cat} { background-image: url('${sheetFile}'); }`);
const cssEmojis = [];
for (const key of emojiFilePositions.keys()) {
cssEmojis.push(`.emoji-${key} { background-position: ${emojiFilePositions.get(key)} }`);
}
const cssRules = `.emojisprite-preview {
width: ${EMOJI_SIZE_PADDED}px;
max-width: none;
height: ${EMOJI_SIZE_PADDED}px;
background-repeat: no-repeat;
cursor: pointer;
-moz-transform: scale(0.5);
transform: scale(0.5);
transform-origin: 0 0;
@supports (zoom: 0.5) {
-moz-transform: none;
transform: none;
zoom: 0.5;
}
}
.emojisprite {
width: ${EMOJI_SIZE_PADDED}px;
max-width: none;
height: ${EMOJI_SIZE_PADDED}px;
background-repeat: no-repeat;
border-radius: 18px;
cursor: pointer;
-moz-transform: scale(0.35);
transform: scale(0.35);
}
.emojisprite-loading {
width: ${EMOJI_SIZE_PADDED}px;
max-width: none;
height: ${EMOJI_SIZE_PADDED}px;
background-image: none !important;
background-repeat: no-repeat;
border-radius: 18px;
cursor: pointer;
-moz-transform: scale(0.35);
transform: scale(0.35);
}
@supports (zoom: 0.35) {
.emojisprite,
.emojisprite-loading {
-moz-transform: none;
transform: none;
zoom: 0.35;
}
}
${cssCats.join('\n')}
${cssEmojis.join('\n')}
`;
// Create the emojisprite.scss file
const emojispriteDir = path.resolve(webappRootDir, 'channels', 'src', 'sass', 'components', '_emojisprite.scss');
endResults.push(writeToFileAndPrint('_emojisprite.scss', emojispriteDir, cssRules));
log('', '\n');
log('info', 'Running "make-emojis" script...');
Promise.all(endResults).then(() => {
log('warn', 'Remember to npm run "i18n-extract" as categories might have changed.');
log('warn', 'Remember to run "gofmt -w ./server/public/model/emoji_data.go" from the root of the repository.');
log('success', 'make-emojis script completed successfully.');
}).catch((err) => {
control.abort(); // cancel any other file writing
log('error', `There was an error while running make-emojis script: ${err}`);
});

View File

@@ -0,0 +1,35 @@
# Notice.txt File Configuration
We are automatically generating Notice.txt by using first-level dependencies of the project. The related pipeline uses `config.yaml` stored in this folder.
## Configuration
Sample:
```
title: "Mattermost Web App"
copyright: "© 2017-present Mattermost, Inc. All Rights Reserved. See LICENSE.txt for license information."
description: "This document includes a list of open source components used in Mattermost Web App, including those that have been modified."
reviewers:
- "mattermost/release-managers"
- "mattermost/web-platform"
search:
- "package.json"
- "packages/**/package.json"
dependencies:
- "wix"
devDependencies:
- "webpack"
```
| Field | Type | Purpose |
| :-- | :-- | :-- |
| title | string | Field content will be used as a title of the application. See first line of `NOTICE.txt` file. |
| copyright | string | Field content will be used as a copyright message. See second line of `NOTICE.txt` file. |
| description | string | Field content will be used as notice file description. See third line of `NOTICE.txt` file. |
| reviewers | array of GitHub user/teams | Those will be automatically assigned to the PRs as reviewers. |
| dependencies | array | If any dependency name mentioned, it will be automatically added even if it is not a first-level dependency. |
| devDependencies | array | If any dependency name mentioned, it will be added when it is referenced in devDependency section. |
| search | array | Pipeline will search for package.json files located. Globstar format is supported `packages/**/package.json`. |

View File

@@ -0,0 +1,16 @@
---
title: "Mattermost Web App"
copyright: "© 2017-present Mattermost, Inc. All Rights Reserved. See LICENSE.txt for license information."
description: "This document includes a list of open source components used in Mattermost Web App, including those that have been modified."
reviewers:
- "mattermost/release-managers"
- "mattermost/web-platform"
search:
- "package.json"
- "packages/**/package.json"
dependencies: []
devDependencies:
- "webpack"
...

View File

@@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {defineConfig, globalIgnores} from 'eslint/config';
import formatjsPlugin from 'eslint-plugin-formatjs';
import noOnlyTestsPlugin from 'eslint-plugin-no-only-tests';
import eslintPlugin from '@mattermost/eslint-plugin';
export default defineConfig([
...eslintPlugin.configs.react,
globalIgnores(['./dist']),
{
plugins: {
formatjs: formatjsPlugin,
},
rules: {
'react/prop-types': [
2,
{
ignore: [
'location',
'history',
'component',
],
},
],
'react/no-unknown-property': [
2,
{
ignore: [
'mask-type',
],
},
],
'react/style-prop-object': [2, {
allow: ['Timestamp'],
}],
'formatjs/enforce-default-message': 2,
'formatjs/enforce-id': 2,
'formatjs/enforce-placeholders': 2,
'formatjs/no-invalid-icu': 2,
'formatjs/no-multiple-plurals': 1,
'formatjs/no-multiple-whitespaces': 2,
'formatjs/no-literal-string-in-jsx': 1,
'formatjs/prefer-formatted-message': 1,
'formatjs/no-useless-message': 1,
'formatjs/prefer-pound-in-plural': 0,
'react/jsx-fragments': ['error', 'syntax'],
},
},
{
files: ['**/*.test.js', '**/*.test.jsx', '**/*.test.ts', '**/*.test.tsx'],
plugins: {
'no-only-tests': noOnlyTestsPlugin,
},
rules: {
'no-only-tests/no-only-tests': ['error', {focus: ['only', 'skip']}],
},
},
{
ignores: ['src/packages/mattermost-redux/**'],
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{
paths: [{
name: 'mattermost-redux/types/actions',
importNames: ['DispatchFunc', 'GetStateFunc', 'ActionFunc', 'ActionFuncAsync', 'ThunkActionFunc'],
message: 'Use the web app version of it from types/store',
}],
patterns: [{
group: ['@mattermost/client/src/*', '@mattermost/components/src/*', '@mattermost/types/src/*'],
message: "Don't include the src folder when importing from packages in webapp/platform",
}],
},
],
},
},
{
settings: {
'import/resolver': 'webpack',
formatjs: {
additionalFunctionNames: ['localizeMessage', 'defineMessage'],
},
},
},
]);

View File

@@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/** @type {import('jest').Config} */
const baseConfig = require('./jest.config.js');
const config = {
...baseConfig,
displayName: 'channels',
testPathIgnorePatterns: [
'/node_modules/',
'<rootDir>/src/packages/mattermost-redux/',
],
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/packages/mattermost-redux/**',
],
coveragePathIgnorePatterns: [
'/node_modules/',
'src/packages/mattermost-redux/',
],
};
module.exports = config;

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/** @type {import('jest').Config} */
const config = {
testPathIgnorePatterns: ['/node_modules/'],
clearMocks: true,
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
],
coveragePathIgnorePatterns: [
'/node_modules/',
'mattermost-redux/src/selectors/create_selector',
],
coverageReporters: ['json', 'lcov', 'text-summary'],
fakeTimers: {
doNotFake: ['performance'],
},
moduleNameMapper: {
'^@mattermost/(components)$': '<rootDir>/../platform/$1/src',
'^@mattermost/(client)$': '<rootDir>/../platform/$1/src',
'^@mattermost/(types)/(.*)$': '<rootDir>/../platform/$1/src/$2',
'^@mattermost/shared/(.*)$': '<rootDir>/../platform/shared/src/$1',
'^mattermost-redux/test/(.*)$':
'<rootDir>/src/packages/mattermost-redux/test/$1',
'^mattermost-redux/(.*)$': '<rootDir>/src/packages/mattermost-redux/src/$1',
'^pdfjs-dist/.*': '<rootDir>/src/tests/pdfjs_mock.ts',
'^.+\\.(jpg|jpeg|png|apng|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'<rootDir>/src/tests/image_url_mock.json',
'^.+\\.(css|less|scss)$': 'identity-obj-proxy',
'^.*i18n.*\\.(json)$': '<rootDir>/src/tests/i18n_mock.json',
},
moduleDirectories: ['src', 'node_modules'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
transformIgnorePatterns: [
'node_modules/(?!react-native|react-router|pdfjs-dist|p-queue|p-timeout|@mattermost/compass-icons|cidr-regex|ip-regex|serialize-error|@tiptap|marked|lowlight|devlop|fault|hast-util-to-text|@types/hast|unist-util-find-after)',
],
transform: {
'^.+\\.(js|jsx|ts|tsx|mjs)$': 'babel-jest',
},
setupFiles: ['jest-canvas-mock'],
setupFilesAfterEnv: ['<rootDir>/src/tests/setup_jest.ts'],
testEnvironment: 'jsdom',
testTimeout: 60000,
testEnvironmentOptions: {
url: 'http://localhost:8065',
},
watchPlugins: [
'jest-watch-typeahead/filename',
'jest-watch-typeahead/testname',
],
snapshotFormat: {
escapeString: true,
printBasicPrototype: true,
},
};
module.exports = config;

View File

@@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/** @type {import('jest').Config} */
const baseConfig = require('./jest.config.js');
const config = {
...baseConfig,
displayName: 'mattermost-redux',
testMatch: [
'<rootDir>/src/packages/mattermost-redux/src/**/*.test.{js,jsx,ts,tsx}',
],
collectCoverageFrom: [
'src/packages/mattermost-redux/src/**/*.{js,jsx,ts,tsx}',
],
coveragePathIgnorePatterns: [
'/node_modules/',
'src/packages/mattermost-redux/src/selectors/create_selector',
],
};
module.exports = config;

View File

@@ -0,0 +1,203 @@
{
"name": "mattermost-webapp",
"browser": {
"./client/web_client.jsx": "./client/browser_web_client.jsx"
},
"version": "12.0.0",
"private": true,
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.7.7",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0",
"@atlaskit/pragmatic-drag-and-drop-react-drop-indicator": "3.2.12",
"@floating-ui/react": "0.27.20",
"@giphy/js-fetch-api": "5.7.0",
"@giphy/react-components": "10.1.0",
"@guyplusplus/turndown-plugin-gfm": "1.0.7",
"@hello-pangea/dnd": "18.0.1",
"@mattermost/client": "12.0.0",
"@mattermost/desktop-api": "6.3.0-2",
"@mattermost/shared": "12.0.0",
"@mattermost/types": "12.0.0",
"@mui/base": "5.0.0-beta.70",
"@mui/material": "5.18.0",
"@mui/styled-engine-sc": "5.14.12",
"@redux-devtools/extension": "3.3.0",
"@tanstack/react-table": "8.10.7",
"@tippyjs/react": "4.2.6",
"@tiptap/extension-code-block-lowlight": "3.20.0",
"@tiptap/extension-link": "3.20.0",
"@tiptap/extension-placeholder": "3.20.0",
"@tiptap/extension-table": "3.20.0",
"@tiptap/extension-table-cell": "3.20.0",
"@tiptap/extension-table-header": "3.20.0",
"@tiptap/extension-table-row": "3.20.0",
"@tiptap/markdown": "3.20.0",
"@tiptap/react": "3.20.0",
"@tiptap/starter-kit": "3.20.0",
"@types/color-hash": "1.0.2",
"@types/react-is": "19.2.0",
"@types/turndown": "5.0.5",
"bootstrap": "3.4.1",
"buffer": "6.0.3",
"chart.js": "3.8.2",
"classnames": "2.3.2",
"color-contrast-checker": "2.1.0",
"color-hash": "2.0.1",
"core-js": "3.49.0",
"country-list": "2.2.0",
"crypto-browserify": "3.12.0",
"css-vars-ponyfill": "2.4.8",
"date-fns": "2.29.3",
"emoji-regex": "10.2.1",
"exif2css": "1.3.0",
"fast-deep-equal": "3.1.3",
"flexsearch": "0.6.32",
"font-awesome": "4.7.0",
"highlight.js": "11.6.0",
"history": "4.10.1",
"hoist-non-react-statics": "3.3.2",
"html-to-react": "1.6.0",
"ipaddr.js": "2.1.0",
"katex": "0.16.21",
"localforage": "1.10.0",
"localforage-observable": "2.1.1",
"lodash": "4.18.1",
"lowlight": "3.3.0",
"luxon": "3.6.1",
"mark.js": "8.11.1",
"marked": "github:mattermost/marked#c211e4ca4a8182f82cc16738d9fbcd7d026ce011",
"memoize-one": "6.0.0",
"moment-timezone": "0.5.38",
"monaco-editor": "0.52.2",
"monaco-editor-webpack-plugin": "7.1.0",
"p-queue": "7.3.0",
"pdfjs-dist": "4.10.38",
"process": "0.11.10",
"prop-types": "15.8.1",
"react": "19.2.8",
"react-bootstrap": "github:mattermost/react-bootstrap#d693ec8082954f0e6d3e826ca6161979b29001cd",
"react-color": "2.19.3",
"react-day-picker": "8.10.2",
"react-dom": "19.2.8",
"react-intl": "*",
"react-is": "19.2.8",
"react-overlays": "5.2.1",
"react-redux": "9.2.0",
"react-router-dom": "5.3.4",
"react-select": "5.9.0",
"react-transition-group": "4.4.5",
"react-virtualized-auto-sizer": "1.0.25",
"react-window": "1.8.11",
"react-window-infinite-loader": "1.0.10",
"rebound": "0.1.0",
"redux": "5.0.1",
"redux-batched-actions": "0.5.0",
"redux-persist": "6.0.0",
"redux-thunk": "3.1.0",
"regenerator-runtime": "0.13.10",
"semver": "7.6.3",
"serialize-error": "11.0.3",
"shallow-equals": "1.0.0",
"simplebar-react": "3.3.2",
"smooth-scroll-into-view-if-needed": "2.0.2",
"stream-browserify": "3.0.0",
"styled-components": "5.3.7",
"timezones.json": "1.7.1",
"tinycolor2": "1.6.0",
"turndown": "7.2.2",
"web-vitals": "5.0.3",
"zen-observable": "0.10.0"
},
"devDependencies": {
"@deanwhillier/jest-matchmedia-mock": "1.2.0",
"@mattermost/calls-common": "0.27.0",
"@mattermost/eslint-plugin": "*",
"@stylistic/stylelint-plugin": "3.1.2",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.8.0",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/country-list": "2.1.0",
"@types/jest": "30.0.0",
"@types/katex": "0.11.0",
"@types/lodash": "4.14.202",
"@types/luxon": "3.0.2",
"@types/mark.js": "8.11.6",
"@types/marked": "0.7.4",
"@types/react": "19.2.18",
"@types/react-bootstrap": "0.32.35",
"@types/react-color": "3.0.6",
"@types/react-dom": "19.2.6",
"@types/react-router-dom": "5.3.3",
"@types/react-transition-group": "4.4.12",
"@types/react-virtualized-auto-sizer": "1.0.1",
"@types/react-window": "1.8.5",
"@types/react-window-infinite-loader": "1.0.6",
"@types/redux-mock-store": "1.5.0",
"@types/regenerator-runtime": "0.13.8",
"@types/scheduler": "0.16.8",
"@types/semver": "7.5.8",
"@types/shallow-equals": "1.0.3",
"@types/styled-components": "5.1.36",
"@types/tinycolor2": "1.4.6",
"@types/zen-observable": "0.8.7",
"babel-plugin-styled-components": "2.1.4",
"copy-webpack-plugin": "14.0.0",
"emoji-datasource": "6.1.1",
"emoji-datasource-apple": "6.1.1",
"emoji-datasource-google": "6.1.1",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-formatjs": "6.4.15",
"eslint-plugin-no-only-tests": "3.4.0",
"external-remotes-plugin": "1.0.0",
"html-loader": "5.1.0",
"html-webpack-plugin": "5.5.0",
"identity-obj-proxy": "3.0.0",
"image-minimizer-webpack-plugin": "5.0.0",
"jest": "30.1.3",
"jest-canvas-mock": "2.5.0",
"jest-cli": "30.1.3",
"jest-environment-jsdom": "30.1.0",
"jest-watch-typeahead": "3.0.1",
"nock": "13.2.8",
"node-fetch": "2.7.0",
"redux-mock-store": "1.5.4",
"redux-persist-node-storage": "2.0.0",
"sharp": "0.34.5",
"stylelint": "16.10.0",
"stylelint-config-idiomatic-order": "10.0.0",
"stylelint-config-recommended-scss": "14.1.0",
"stylelint-order": "6.0.4",
"stylelint-scss": "6.8.1",
"svgo": "2.8.2",
"typescript-eslint-language-service": "5.0.5",
"webpack-pwa-manifest": "4.3.0",
"yargs": "17.7.2"
},
"overrides": {
"@mattermost/desktop-api": {
"typescript": "$typescript"
}
},
"scripts": {
"check": "npm run check:eslint && npm run check:stylelint",
"check:eslint": "eslint ./src eslint.config.mjs --quiet --cache",
"check:stylelint": "stylelint \"**/*.{css,scss}\" --cache",
"fix": "eslint ./src eslint.config.mjs --quiet --fix --cache && stylelint \"**/*.{css,scss}\" --fix --cache",
"build": "cross-env NODE_ENV=production webpack",
"run": "webpack --progress --watch",
"dev-server": "webpack serve --mode development",
"test": "cross-env TZ=Etc/UTC LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 jest",
"test:watch": "cross-env TZ=Etc/UTC LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 jest --watch",
"test:updatesnapshot": "cross-env TZ=Etc/UTC LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 jest --updateSnapshot",
"test:debug": "cross-env TZ=Etc/UTC LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 jest --forceExit --detectOpenHandles --verbose",
"test-ci": "cross-env TZ=Etc/UTC LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 jest --ci --maxWorkers=100% --forceExit --detectOpenHandles --logHeapUsage --coverage",
"clean": "rm -rf dist node_modules .eslintcache .stylelintcache tsconfig.tsbuildinfo",
"stats": "cross-env NODE_ENV=production webpack --profile --json > webpack_stats.json",
"i18n-extract": "formatjs extract 'src/**/*.{js,jsx,ts,tsx}' '../platform/shared/src/**/*.{js,jsx,ts,tsx}' --additional-function-names localizeMessage --ignore '**/*.d.ts' --ignore '**/*.test.*' --out-file src/i18n/en.json --format scripts/formatter.js --id-interpolation-pattern '[sha512:contenthash:base64:6]' --preserve-whitespace",
"i18n-extract:check": "npm run i18n-extract -- --throws --out-file .i18n-check.tmp.json && diff src/i18n/en.json .i18n-check.tmp.json && rm -f .i18n-check.tmp.json || (rm -f .i18n-check.tmp.json && echo '\nen.json is out of sync with code. \nTo update: npm run i18n-extract' && exit 1)",
"i18n-verify-translations": "node scripts/check_icu.mjs src/i18n",
"check-types": "tsc -b",
"make-emojis": "node --experimental-json-modules build/emoji/make_emojis.mjs"
}
}

View File

@@ -0,0 +1,265 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* Deterministic ICU lint for the webapp locale catalogs.
*
* Every check below fails the run, ordered here from breaks-at-runtime to
* renders-but-wrong. There is no way to exempt a key: a translation that
* deviates from its source is either wrong, or the source is encoding English
* grammar the other languages cannot follow, and both are worth fixing rather
* than recording.
*
* - the file is valid JSON and every value is a string
* - every value parses with @formatjs/icu-messageformat-parser, the parser
* react-intl runs in production, so "parses here" implies "parses there"
* - a translation never invents a variable the source does not have. An
* unknown variable throws MISSING_VALUE at format time.
* - a translation never invents a tag the source does not have. An unknown
* tag renders as raw markup.
* - a translation does not drop a variable or tag the source has. Dropping
* one renders fine, it just quietly loses a value or a link.
* - a variable shared with the source is not used in a weaker role than the
* source uses it. Demoting {count, plural, ...} to a bare {count} still
* parses and still renders, it just silently stops pluralizing. The
* opposite direction is allowed: see PROMOTIONS.
* - an unescaped ASCII apostrophe immediately before < or { where the source
* has no such quoting. In ICU that opens a quoted literal which swallows the tag
* or variable and everything after it. The message still parses, so this
* check is the only thing that catches it.
*
* Key parity is a separate question from whether the entries that exist are
* correct. An extra key, one en.json does not have, is always an error: nothing
* will ever read it. A missing key is an error by default and a warning under
* --warn-missing-keys, and is not a runtime defect either way, because
* react-intl falls back to the source message.
*
* Usage:
* node scripts/check_icu.mjs <i18n-dir> [--warn-missing-keys]
*/
import fs from 'fs';
import {createRequire} from 'node:module';
import {pathToFileURL} from 'node:url';
import path from 'path';
// Resolved through react-intl: a bare import gets whichever copy npm hoisted.
const reactIntlRequire = createRequire(import.meta.resolve('react-intl'));
const {parse} = await import(pathToFileURL(reactIntlRequire.resolve('@formatjs/icu-messageformat-parser')).href);
const SOURCE = 'en.json';
const argv = process.argv.slice(2);
const warnMissingKeys = argv.includes('--warn-missing-keys');
const [i18nDir, ...extra] = argv.filter((a) => a !== '--warn-missing-keys');
if (!i18nDir || extra.length) {
console.error('usage: node scripts/check_icu.mjs <i18n-dir> [--warn-missing-keys]');
process.exit(2);
}
const readCatalog = (name) => fs.readFileSync(path.join(i18nDir, name), 'utf8');
// Sorted because readdir order is filesystem dependent, and the report has to
// be stable across runs and diffable between them.
let localeNames;
try {
localeNames = fs.readdirSync(i18nDir, {withFileTypes: true}).
filter((entry) => entry.isFile() && path.extname(entry.name) === '.json' && entry.name !== SOURCE).
map((entry) => entry.name).
sort();
} catch (e) {
console.error(`cannot read the i18n directory ${i18nDir}: ${e.message}`);
process.exit(2);
}
if (localeNames.length === 0) {
console.error(`no locale catalogs beside ${SOURCE} in ${i18nDir}`);
process.exit(2);
}
let en;
try {
en = JSON.parse(readCatalog(SOURCE));
} catch (e) {
console.error(`cannot read ${path.join(i18nDir, SOURCE)}: ${e.message}`);
process.exit(2);
}
// A lone ASCII apostrophe before < or { opens an ICU quoted literal that
// swallows the tag or variable. A doubled one does not: '' is the escape for a
// literal apostrophe, and l''<link> is a documented way to write an elision. So
// only an unpaired apostrophe is a defect.
const UNESCAPED_APOSTROPHE = /(?<!')'(?!')[<{]/;
const errors = [];
const warnings = [];
// Element type ids from @formatjs/icu-messageformat-parser's TYPE enum. They
// are inlined as numbers in the AST, so name them for legible messages.
const TYPES = {
1: 'argument',
2: 'number',
3: 'date',
4: 'time',
5: 'select',
6: 'plural',
8: 'tag',
};
/**
* Maps the role the source uses a variable in to the roles a translation may
* promote it to.
*
* English needs a plural far less often than the languages it is translated
* into: "{count} items were deleted" is one sentence in English and four in
* Russian. Wrapping a bare {count} in {count, plural, ...} is how a translator
* makes that sentence grammatical, and it loses nothing -- so it is allowed,
* even though the source never asked for a plural. Demotion is the reverse and
* stays fatal.
*
* The formatting types are deliberately absent. They are not interchangeable
* with each other or reachable from a bare argument: {x, number} over a string
* renders NaN and {x, date} over a non-date renders "Invalid Date", so a
* translation reaching for one the source did not use is a defect, not a
* grammar fix.
*/
const PROMOTIONS = {
argument: new Set(['plural', 'select']),
number: new Set(['plural']),
};
/**
* Map every variable and tag in an AST to the set of types it is used as. A
* single name is regularly used in more than one role -- "{count, number} new
* {count, plural, one {message} other {messages}}" uses count as both a number
* and a plural -- so this has to be a set per name, not a type per name.
*
* Plural categories are deliberately not compared: which categories a message
* needs is a property of the locale, not of the source string.
*/
function argTypes(ast, out = new Map()) {
for (const el of ast) {
if (TYPES[el.type] && el.value !== undefined) {
if (!out.has(el.value)) {
out.set(el.value, new Set());
}
out.get(el.value).add(TYPES[el.type]);
}
if (el.options) {
for (const option of Object.values(el.options)) {
argTypes(option.value, out);
}
}
if (el.children) {
argTypes(el.children, out);
}
}
return out;
}
const enArgs = new Map();
for (const [key, message] of Object.entries(en)) {
try {
enArgs.set(key, argTypes(parse(message, {ignoreTag: false, requiresOtherClause: true})));
} catch (e) {
errors.push(`${SOURCE}:${key}: source does not parse: ${e.message}`);
}
}
for (const name of localeNames) {
let data;
try {
data = JSON.parse(readCatalog(name));
} catch (e) {
errors.push(`${name}: invalid JSON: ${e.message}`);
continue;
}
for (const key of Object.keys(en)) {
if (!(key in data)) {
(warnMissingKeys ? warnings : errors).push(`${name}:${key}: missing key`);
}
}
for (const [key, message] of Object.entries(data)) {
if (!(key in en)) {
errors.push(`${name}:${key}: extra key not in ${SOURCE}`);
continue;
}
if (typeof message !== 'string') {
errors.push(`${name}:${key}: value is not a string`);
continue;
}
// ignoreTag: false parses <b>...</b> as tags rather than literal text,
// so the tag checks below see them; requiresOtherClause: true rejects a
// plural or select with no "other" arm, which ICU requires as the fallback.
let ast;
try {
ast = parse(message, {ignoreTag: false, requiresOtherClause: true});
} catch (e) {
errors.push(`${name}:${key}: does not parse: ${e.message}`);
continue;
}
// Absent only when en.json's own message failed to parse, which was
// already reported once against en.json. Comparing against a source we
// could not read would just repeat that for every locale.
const sourceArgs = enArgs.get(key);
if (!sourceArgs) {
continue;
}
const targetArgs = argTypes(ast);
for (const arg of targetArgs.keys()) {
if (!sourceArgs.has(arg)) {
errors.push(`${name}:${key}: unknown variable/tag "${arg}" not present in source`);
}
}
for (const arg of sourceArgs.keys()) {
if (!targetArgs.has(arg)) {
errors.push(`${name}:${key}: source variable/tag "${arg}" is missing from the translation`);
}
}
for (const [arg, types] of targetArgs) {
// Not in the source at all, which the unknown-variable check above
// has already reported.
const sourceTypes = sourceArgs.get(arg);
if (!sourceTypes) {
continue;
}
for (const type of types) {
if (sourceTypes.has(type)) {
continue;
}
if ([...sourceTypes].some((sourceType) => PROMOTIONS[sourceType]?.has(type))) {
continue;
}
errors.push(`${name}:${key}: "${arg}" is used as ${type}, but the source only uses it as ${[...sourceTypes].join('/')}`);
}
}
// Only when the source is clean. The same quoting in en.json is the
// author's own, and blaming each translation for it would report the one
// source defect once per locale.
if (UNESCAPED_APOSTROPHE.test(message) && !UNESCAPED_APOSTROPHE.test(en[key])) {
errors.push(`${name}:${key}: ASCII apostrophe before ICU syntax opens a quoted literal and swallows the rest of the message; use ’ or '' instead`);
}
}
}
if (warnings.length) {
console.warn(warnings.join('\n'));
console.warn(`\n${warnings.length} warning(s)`);
}
if (errors.length) {
console.error(errors.join('\n'));
console.error(`\n${errors.length} error(s) across ${localeNames.length} locale file(s)`);
process.exit(1);
}
console.log(`OK: ${localeNames.length} locale files checked against ${path.join(i18nDir, SOURCE)}`);

View File

@@ -0,0 +1,368 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @jest-environment node
*/
// check_icu.mjs is an ESM CLI whose contract is its exit code, so these drive
// the real script in a subprocess rather than importing pieces of it.
const {spawnSync} = require('child_process');
const fs = require('fs');
const {createRequire} = require('module');
const os = require('os');
const path = require('path');
const SCRIPT = path.join(__dirname, 'check_icu.mjs');
/**
* Write a source catalog and one or more locale catalogs to a temp i18n dir, run
* the checker over that directory, and return its exit code and output. A
* catalog may be an object, or a string when the case needs to be malformed.
*/
function check(en, locales, {warnMissingKeys = false, extraFiles = {}} = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-icu-'));
const serialize = (body) => (typeof body === 'string' ? body : JSON.stringify(body));
fs.writeFileSync(path.join(dir, 'en.json'), serialize(en));
for (const [name, body] of Object.entries({...locales, ...extraFiles})) {
fs.writeFileSync(path.join(dir, name), serialize(body));
}
const args = [SCRIPT, dir, ...(warnMissingKeys ? ['--warn-missing-keys'] : [])];
const result = spawnSync(process.execPath, args, {encoding: 'utf8'});
return {code: result.status, stdout: result.stdout, stderr: result.stderr};
}
describe('check_icu', () => {
describe('parser', () => {
// The script's whole premise is that parsing here implies parsing under
// react-intl, which only holds while the two agree on a version. Three
// copies of this package exist in the tree, so a bare import picks up
// whichever npm hoisted -- for a long time, the older one
// babel-plugin-formatjs pulls. The script sidesteps that by resolving
// the parser from react-intl's own node_modules, which works only while
// react-intl keeps depending on the parser directly.
test('is the copy react-intl loads', () => {
const wantedByReactIntl = require('react-intl/package.json').dependencies['@formatjs/icu-messageformat-parser'];
expect(wantedByReactIntl).toBeDefined();
const fromReactIntl = createRequire(require.resolve('react-intl'));
const resolved = fromReactIntl('@formatjs/icu-messageformat-parser/package.json').version;
expect(resolved).toBe(wantedByReactIntl);
});
});
describe('well-formedness', () => {
test('accepts a clean catalog', () => {
const {code, stdout} = check(
{'a.b': 'Hello {name}'},
{'fr.json': {'a.b': 'Bonjour {name}'}},
);
expect(code).toBe(0);
expect(stdout).toContain('OK:');
});
test('rejects invalid JSON', () => {
const {code, stderr} = check({'a.b': 'Hello'}, {'fr.json': '{"a.b":'});
expect(code).toBe(1);
expect(stderr).toContain('invalid JSON');
});
test.each([
['null', null],
['a number', 5],
['an object', {nested: 'x'}],
])('rejects a value that is %s', (_label, value) => {
const {code, stderr} = check({'a.b': 'Hello'}, {'fr.json': {'a.b': value}});
expect(code).toBe(1);
expect(stderr).toContain('value is not a string');
});
test('rejects a translation that does not parse', () => {
const {code, stderr} = check(
{'a.b': 'Hello {name}'},
{'fr.json': {'a.b': 'Bonjour {name'}},
);
expect(code).toBe(1);
expect(stderr).toContain('does not parse');
});
test('reports an unparseable source once, and skips its key', () => {
const {code, stderr} = check(
{'a.b': 'Hello {name'},
{'fr.json': {'a.b': 'Bonjour {name}'}},
);
expect(code).toBe(1);
expect(stderr).toContain('en.json:a.b: source does not parse');
expect(stderr).not.toContain('fr.json:a.b:');
});
});
describe('key parity', () => {
test('an extra key is always an error, since nothing will read it', () => {
const {code, stderr} = check(
{'a.b': 'Hello'},
{'fr.json': {'a.b': 'Bonjour', 'z.z': 'Orphelin'}},
);
expect(code).toBe(1);
expect(stderr).toContain('fr.json:z.z: extra key not in en.json');
});
test('a missing key is an error by default', () => {
const {code, stderr} = check({'a.b': 'Hello'}, {'fr.json': {}});
expect(code).toBe(1);
expect(stderr).toContain('fr.json:a.b: missing key');
});
test('a missing key is a warning under --warn-missing-keys', () => {
const {code, stderr} = check({'a.b': 'Hello'}, {'fr.json': {}}, {warnMissingKeys: true});
expect(code).toBe(0);
expect(stderr).toContain('fr.json:a.b: missing key');
expect(stderr).toContain('1 warning(s)');
});
});
describe('variables and tags', () => {
test('rejects a variable the source does not have', () => {
const {code, stderr} = check(
{'a.b': 'Hello {name}'},
{'fr.json': {'a.b': 'Bonjour {name}, {extra}'}},
);
expect(code).toBe(1);
expect(stderr).toContain('unknown variable/tag "extra" not present in source');
});
test('rejects a tag the source does not have', () => {
const {code, stderr} = check(
{'a.b': 'Read <link>this</link>'},
{'fr.json': {'a.b': 'Lisez <link>ceci</link> et <b>cela</b>'}},
);
expect(code).toBe(1);
expect(stderr).toContain('unknown variable/tag "b" not present in source');
});
test.each([
['variable', 'Hello {name}', 'Bonjour', 'name'],
['tag', 'Read <link>this</link>', 'Lisez ceci', 'link'],
])('rejects dropping a source %s', (_label, en, fr, dropped) => {
const {code, stderr} = check({'a.b': en}, {'fr.json': {'a.b': fr}});
expect(code).toBe(1);
expect(stderr).toContain(`source variable/tag "${dropped}" is missing from the translation`);
});
test('accepts a name used as both a tag and a variable', () => {
const {code} = check(
{'a.b': '<b>{b}</b>'},
{'fr.json': {'a.b': '<b>{b}</b>'}},
);
expect(code).toBe(0);
});
test('finds variables nested inside plural branches', () => {
const {code, stderr} = check(
{'a.b': '{count, plural, one {{name} item} other {{name} items}}'},
{'fr.json': {'a.b': '{count, plural, one {un article} other {des articles}}'}},
);
expect(code).toBe(1);
expect(stderr).toContain('source variable/tag "name" is missing from the translation');
});
});
describe('argument roles', () => {
// Demotion is the defect the role comparison exists for: it still
// parses and still renders, it just silently stops pluralizing.
test.each([
['plural', '{count, plural, one {# item} other {# items}}', '{count} articles'],
['number', 'You have {count, number} points', 'Vous avez {count} points'],
])('rejects demoting %s to a bare argument', (role, en, fr) => {
const {code, stderr} = check({'a.b': en}, {'fr.json': {'a.b': fr}});
expect(code).toBe(1);
expect(stderr).toContain(`the source only uses it as ${role}`);
});
// English needs a plural far less often than the languages it is
// translated into, so widening a bare argument is correct translation,
// not a defect.
test.each([
['a bare argument', '{count} items were deleted'],
['a number', '{count, number} items were deleted'],
])('accepts promoting %s to a plural', (_label, en) => {
const {code} = check(
{'a.b': en},
{'ru.json': {'a.b': '{count, plural, one {# элемент} few {# элемента} many {# элементов} other {# элемента}}'}},
);
expect(code).toBe(0);
});
test('accepts promoting a bare argument to a select', () => {
const {code} = check(
{'a.b': 'Status: {status}'},
{'fr.json': {'a.b': 'Statut : {status, select, active {actif} other {autre}}'}},
);
expect(code).toBe(0);
});
// Formatting types are not reachable from a bare argument: applying them
// to a value that is not a number or a date renders NaN or Invalid Date.
test.each([
['number', '{count, number}'],
['date', '{count, date}'],
])('rejects promoting a bare argument to %s', (role, fr) => {
const {code, stderr} = check({'a.b': '{count} items'}, {'fr.json': {'a.b': `${fr} articles`}});
expect(code).toBe(1);
expect(stderr).toContain(`"count" is used as ${role}`);
});
test('does not compare plural categories, which belong to the locale', () => {
const {code} = check(
{'a.b': '{count, plural, one {# item} other {# items}}'},
{'ru.json': {'a.b': '{count, plural, one {# элемент} few {# элемента} many {# элементов} other {# элемента}}'}},
);
expect(code).toBe(0);
});
test('does not treat # as a variable', () => {
const {code} = check(
{'a.b': '{count, plural, one {# item} other {# items}}'},
{'fr.json': {'a.b': '{count, plural, one {# article} other {# articles}}'}},
);
expect(code).toBe(0);
});
});
describe('apostrophes', () => {
// A lone apostrophe before ICU syntax opens a quoted literal that
// swallows the rest of the message. It still parses, so nothing else
// catches it.
test.each([
['a tag', 'Read <link>this</link>', "Lisez l'<link>article</link>"],
['a variable', 'Today is {day}', "Aujourd'hui c'est l'{day}"],
])('rejects an unescaped apostrophe before %s', (_label, en, fr) => {
const {code, stderr} = check({'a.b': en}, {'fr.json': {'a.b': fr}});
expect(code).toBe(1);
expect(stderr).toContain('opens a quoted literal');
});
test('accepts a doubled apostrophe, the documented elision escape', () => {
const {code} = check(
{'a.b': 'Read <link>this</link>'},
{'fr.json': {'a.b': "Lisez l''<link>article</link>"}},
);
expect(code).toBe(0);
});
test('accepts a typographic apostrophe', () => {
const {code} = check(
{'a.b': 'Read <link>this</link>'},
{'fr.json': {'a.b': 'Lisez l’<link>article</link>'}},
);
expect(code).toBe(0);
});
test('does not flag quoting the source itself uses', () => {
const {code} = check(
{'a.b': "Use '{braces}' literally"},
{'fr.json': {'a.b': "Utilisez '{braces}' littéralement"}},
);
expect(code).toBe(0);
});
});
describe('cli', () => {
test.each([
['no arguments', []],
['more than one directory', ['a', 'b']],
])('exits 2 on %s', (_label, args) => {
const result = spawnSync(process.execPath, [SCRIPT, ...args], {encoding: 'utf8'});
expect(result.status).toBe(2);
expect(result.stderr).toContain('usage:');
});
test('exits 2 on a directory that does not exist', () => {
const missing = path.join(os.tmpdir(), 'check-icu-does-not-exist');
const result = spawnSync(process.execPath, [SCRIPT, missing], {encoding: 'utf8'});
expect(result.status).toBe(2);
expect(result.stderr).toContain('cannot read the i18n directory');
});
test('exits 2 when en.json is missing or malformed', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-icu-'));
fs.writeFileSync(path.join(dir, 'fr.json'), '{}');
const missing = spawnSync(process.execPath, [SCRIPT, dir], {encoding: 'utf8'});
expect(missing.status).toBe(2);
expect(missing.stderr).toContain('cannot read');
fs.writeFileSync(path.join(dir, 'en.json'), '{not json');
const malformed = spawnSync(process.execPath, [SCRIPT, dir], {encoding: 'utf8'});
expect(malformed.status).toBe(2);
expect(malformed.stderr).toContain('cannot read');
});
test('exits 2 when the directory holds no locale catalogs', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-icu-'));
fs.writeFileSync(path.join(dir, 'en.json'), '{}');
const result = spawnSync(process.execPath, [SCRIPT, dir], {encoding: 'utf8'});
expect(result.status).toBe(2);
expect(result.stderr).toContain('no locale catalogs');
});
// src/i18n holds i18n.ts and a CLAUDE.OPTIONAL.md beside the catalogs.
test('walks only the sibling json catalogs, and never en.json', () => {
const {code, stdout} = check(
{'a.b': 'Hello'},
{'fr.json': {'a.b': 'Bonjour'}},
{extraFiles: {'i18n.ts': 'export default 1;', 'notes.md': 'hi'}},
);
expect(code).toBe(0);
expect(stdout).toContain('OK: 1 locale files checked');
});
test('reports every locale file, not just the first that fails', () => {
const {code, stderr} = check(
{'a.b': 'Hello {name}'},
{
'fr.json': {'a.b': 'Bonjour'},
'de.json': {'a.b': 'Hallo'},
},
);
expect(code).toBe(1);
expect(stderr).toContain('fr.json:a.b:');
expect(stderr).toContain('de.json:a.b:');
expect(stderr).toContain('2 error(s) across 2 locale file(s)');
});
});
});

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* Custom formatjs formatter matching mmjstool's behavior
*
* Based on formatjs simple formatter with case-insensitive sorting
* to match mmjstool's sortJson({ignoreCase: true})
*/
/**
* Format function - extract defaultMessage from message objects
* Same as formatjs simple formatter
*/
module.exports.format = (msgs) => {
return Object.keys(msgs).reduce((all, k) => {
all[k] = msgs[k].defaultMessage;
return all;
}, {});
};
/**
* Compile function - pass through (identity)
* Same as formatjs simple formatter
*/
module.exports.compile = (msgs) => msgs;
/**
* Custom comparator for case-insensitive alphabetical sorting
* with underscore before dot (to match existing en.json ordering)
*/
module.exports.compareMessages = (el1, el2) => {
// Normalize keys: replace _ with a character that sorts before .
// Use \x00 (null char) which sorts before all printable characters
const key1 = el1.key.toLowerCase().replace(/_/g, '\x00');
const key2 = el2.key.toLowerCase().replace(/_/g, '\x00');
if (key1 < key2) {
return -1;
}
if (key1 > key2) {
return 1;
}
return 0;
};

View File

@@ -0,0 +1,36 @@
# CLAUDE: `channels/src/`
## Purpose
- React + Redux source for the Channels app. Everything rendered in the browser lives here.
- Split by concern: UI (`components`, `sass`), data (`actions`, `reducers`, `selectors`, `store`), utilities, and feature-specific packages.
## Directory Map
- `components/` – feature folders for UI (see `components/CLAUDE.md`).
- `actions/`, `reducers/`, `selectors/`, `store/` – Redux stack (each has its own CLAUDE).
- `sass/` – theme variables and global styles.
- `i18n/` – locale JSON plus helpers.
- `utils/`, `types/` – shared helpers + local type definitions.
- `packages/mattermost-redux/` – embedded redux package mirroring the standalone repo.
## Layering Rules
- Components never call `Client4` directly; async work flows through `actions` → `mattermost-redux` → API packages.
- Shared state comes from `mattermost-redux/state.entities.*`; UI/persisted state belongs in `state.views.*`.
- Prefer hooks (`useSelector`, `useDispatch`, custom hooks) over legacy HOCs.
- Keep cross-layer imports stable: `components` may import `selectors`, `utils`, `types`, but not `reducers` or `store`.
## State Management Primer
- Redux store configured in `store/index.ts`; persistence handled via redux-persist + localForage.
- Selector factories (`makeGet...`) should be memoized per component instance.
- Use `mattermost-redux` for server-backed data; add new entity fields there first, then expose selectors into this workspace.
## How to Navigate
- Start from route entry (`root.tsx` and `root.html`) to understand bootstrapping and async chunk loading.
- `module_registry.ts` registers dynamically loaded views; ensure new routes/components are wrapped with `makeAsyncComponent` where appropriate.
- Before adding a new folder, check for an existing feature area under `components` or `utils`.
## References
- `webapp/STYLE_GUIDE.md → React Component Structure`, `Redux & Data Fetching`.
- Example: `root.tsx` (bootstrapping), `module_registry.ts` (async component wiring).

View File

@@ -0,0 +1,76 @@
# CLAUDE: `actions/`
## Purpose
- Hosts Redux action creators (sync + thunk) for UI behaviors and server calls specific to the Channels webapp.
- Bridges components to `mattermost-redux` and `@mattermost/client`.
## Directory Structure
```
actions/
├── *.ts # Domain-specific actions (channel_actions.ts, post_actions.ts, etc.)
└── views/ # UI-specific actions (modals, sidebars, etc.)
```
## Action Patterns
### Async Thunks
All async thunks must return `{data: ...}` on success or `{error: ...}` on failure.
```typescript
export function fetchSomething(id: string): ActionFuncAsync {
return async (dispatch, getState) => {
try {
const data = await Client4.getSomething(id);
dispatch({type: ActionTypes.RECEIVED_SOMETHING, data});
return {data};
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {error};
}
};
}
```
### Using bindClientFunc
For simple API calls, use `bindClientFunc` helper for standard error handling:
```typescript
export function fetchUser(userId: string): ActionFuncAsync {
return bindClientFunc({
clientFunc: Client4.getUser,
params: [userId],
onSuccess: ActionTypes.RECEIVED_USER,
});
}
```
## Conventions & Best Practices
- **Response Structure**: Async actions return `{data}` on success or `{error}` on failure (see `webapp/STYLE_GUIDE.md → Redux & Data Fetching`).
- **Actions Only**: Call `Client4` only inside actions; components should dispatch actions, never hit APIs directly.
- **Helpers**: Extract reusable async logic into helpers (`hooks.ts`, `apps.ts`) rather than duplicating inside multiple actions.
- **Entity Data**: When adding new entity data, first wire it through `channels/src/packages/mattermost-redux`, then consume selectors here.
## Error & Logging Requirements
- Catch errors to call `forceLogoutIfNecessary(error)` and dispatch `logError`.
- Use telemetry wrappers (`trackEvent`, `perf`) when adding analytics inside thunks.
- Always dispatch optimistic UI updates with corresponding failure rollback where user experience demands it.
## Batching Network Requests
- Use bulk API endpoints when available.
- Use `DelayedDataLoader` for batching multiple calls.
- Fetch data from parent components, not individual list items.
## views/ Subdirectory
UI state actions that don't involve server data (modals, sidebars, view state) dispatch to `state.views.*` reducers rather than `state.entities.*`.
## Testing
- Favor RTL-style async action tests with mocked store where possible (`channel_actions.test.ts`).
- Use `nock` or request-mocking utilities in `mattermost-redux` tests for complex flows.
## References
- `channel_actions.ts`, `global_actions.tsx` – canonical patterns for async thunks.
- `mattermost-redux/src/actions/*` – shared actions; import instead of duplicating server logic.

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import configureStore from 'store';
import * as Actions from './admin_actions';
describe('Actions.Admin', () => {
// These actions type `component` as a React.Component instance, but every caller
// (including the plugin registry) passes the component class itself.
const componentClass = React.Component as unknown as React.Component;
let store = configureStore();
beforeEach(async () => {
store = await configureStore();
});
test('Register a plugin adds the plugin to the state', async () => {
const func = jest.fn();
expect(store.getState().plugins.adminConsoleReducers).toEqual({});
store.dispatch(Actions.registerAdminConsolePlugin('plugin-id', func));
expect(store.getState().plugins.adminConsoleReducers).toEqual({'plugin-id': func});
});
test('Unregister a plugin removes an existing plugin from the state', async () => {
const func = jest.fn();
expect(store.getState().plugins.adminConsoleReducers).toEqual({});
store.dispatch(Actions.registerAdminConsolePlugin('plugin-id', func));
expect(store.getState().plugins.adminConsoleReducers).toEqual({'plugin-id': func});
store.dispatch(Actions.unregisterAdminConsolePlugin('plugin-id'));
expect(store.getState().plugins.adminConsoleReducers).toEqual({});
});
test('Unregister an unexisting plugin do nothing', async () => {
const func = jest.fn();
expect(store.getState().plugins.adminConsoleReducers).toEqual({});
store.dispatch(Actions.registerAdminConsolePlugin('plugin-id', func));
expect(store.getState().plugins.adminConsoleReducers).toEqual({'plugin-id': func});
store.dispatch(Actions.unregisterAdminConsolePlugin('invalid-plugin-id'));
expect(store.getState().plugins.adminConsoleReducers).toEqual({'plugin-id': func});
});
test('Register a custom plugin setting adds the component to the state', async () => {
expect(store.getState().plugins.adminConsoleCustomComponents).toEqual({});
store.dispatch(Actions.registerAdminConsoleCustomSetting('plugin-id', 'settingA', componentClass, {showTitle: true}));
expect(store.getState().plugins.adminConsoleCustomComponents).toEqual(
{'plugin-id': {
settinga: {
key: 'settingA',
pluginId: 'plugin-id',
component: React.Component,
options: {
showTitle: true,
},
}}});
});
test('Register a custom plugin section adds the component to the state', async () => {
expect(store.getState().plugins.adminConsoleCustomSections).toEqual({});
store.dispatch(Actions.registerAdminConsoleCustomSection('plugin-id', 'sectionA', componentClass));
expect(store.getState().plugins.adminConsoleCustomSections).toEqual(
{'plugin-id': {
sectiona: {
key: 'sectionA',
pluginId: 'plugin-id',
component: React.Component,
}}});
});
});

View File

@@ -0,0 +1,648 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ClusterInfo} from '@mattermost/types/admin';
import type {StatusOK} from '@mattermost/types/client4';
import type {AdminConfig, AllowedIPRange, FetchIPResponse, RequestLicenseBody} from '@mattermost/types/config';
import type {Job, JobTypeBase} from '@mattermost/types/jobs';
import type {SamlCertificateStatus, SamlMetadataResponse} from '@mattermost/types/saml';
import type {AuthChangeResponse, UserProfile} from '@mattermost/types/users';
import * as AdminActions from 'mattermost-redux/actions/admin';
import {bindClientFunc} from 'mattermost-redux/actions/helpers';
import {createJob} from 'mattermost-redux/actions/jobs';
import {getServerLimits as getServerLimitsAction} from 'mattermost-redux/actions/limits';
import * as TeamActions from 'mattermost-redux/actions/teams';
import * as UserActions from 'mattermost-redux/actions/users';
import {Client4} from 'mattermost-redux/client';
import {emitUserLoggedOutEvent} from 'actions/global_actions';
import {getOnNavigationConfirmed} from 'selectors/views/admin';
import store from 'stores/redux_store';
import {ActionTypes, JobTypes} from 'utils/constants';
import type {ThunkActionFunc} from 'types/store';
import type {AdminConsolePluginComponent} from 'types/store/plugins';
const dispatch = store.dispatch;
type SuccessCallback<T = StatusOK> = ((data: T) => void) | null | undefined;
type ErrorCallback = ((error: Error & {id: string; server_error_id: string}) => void) | null | undefined;
export async function reloadConfig(success: SuccessCallback, error: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.reloadConfig());
if (data && success) {
dispatch(AdminActions.getConfig());
dispatch(AdminActions.getEnvironmentConfig());
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function adminResetMfa(userId: string, success: SuccessCallback<boolean>, error?: ErrorCallback) {
const {data, error: err} = await dispatch(UserActions.updateUserMfa(userId, false));
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function getClusterStatus(success: SuccessCallback<ClusterInfo[]>, error?: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.getClusterStatus());
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function ldapTest(success: SuccessCallback, error?: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.testLdap());
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function ldapTestConnection(success: SuccessCallback, error: ErrorCallback, settings: any) {
const {data, error: err} = await dispatch(AdminActions.testLdapConnection(settings));
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function ldapTestFilters(success: SuccessCallback, error: ErrorCallback, settings: any) {
const {data, error: err} = await dispatch(AdminActions.testLdapFilters(settings));
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function ldapTestAttributes(success: SuccessCallback, error: ErrorCallback, settings: any) {
const {data, error: err} = await dispatch(AdminActions.testLdapAttributes(settings));
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function ldapTestGroupAttributes(success: SuccessCallback, error: ErrorCallback, settings: any) {
const {data, error: err} = await dispatch(AdminActions.testLdapGroupAttributes(settings));
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function invalidateAllCaches(success: SuccessCallback, error: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.invalidateCaches());
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function recycleDatabaseConnection(success: SuccessCallback, error: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.recycleDatabase());
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function adminResetEmail(user: UserProfile, success: SuccessCallback<UserProfile>, error: ErrorCallback) {
const {data, error: err} = await dispatch(UserActions.patchUser(user));
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function samlCertificateStatus(success: SuccessCallback<SamlCertificateStatus>, error: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.getSamlCertificateStatus());
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function getIPFilters(success: SuccessCallback<AllowedIPRange[]>, error?: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.getIPFilters());
if (data && success) {
success(data);
} else if (err && error) {
error(err);
}
}
export async function getCurrentIP(success: SuccessCallback<FetchIPResponse>, error?: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.getCurrentIP());
if (data && success) {
success(data);
} else if (err && error) {
error(err);
}
}
export async function applyIPFilters(ipList: AllowedIPRange[], success: SuccessCallback<AllowedIPRange[]>, error?: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.applyIPFilters(ipList));
if (data && success) {
success(data);
} else if (err && error) {
error(err);
}
}
export function getOAuthAppInfo(clientId: string) {
return bindClientFunc({
clientFunc: Client4.getOAuthAppInfo,
params: [clientId],
});
}
export function allowOAuth2({
responseType,
clientId,
redirectUri,
state,
scope,
resource,
codeChallenge,
codeChallengeMethod,
}: {
responseType: string | null;
clientId: string | null;
redirectUri: string | null;
state: string | null;
scope: string | null;
resource?: string | null;
codeChallenge?: string | null;
codeChallengeMethod?: string | null;
}) {
return bindClientFunc({
clientFunc: Client4.authorizeOAuthApp,
params: [responseType, clientId, redirectUri, state, scope, resource, codeChallenge, codeChallengeMethod],
});
}
export async function emailToLdap(
loginId: string,
password: string,
token: string | undefined,
ldapId: string,
ldapPassword: string,
success: SuccessCallback<AuthChangeResponse>,
error: ErrorCallback,
) {
const {data, error: err} = await dispatch(UserActions.switchEmailToLdap(loginId, password, ldapId, ldapPassword, token));
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function emailToOAuth(
loginId: string,
password: string,
token: string | undefined,
newType: string,
success: SuccessCallback<AuthChangeResponse>,
error: ErrorCallback,
) {
const {data, error: err} = await dispatch(UserActions.switchEmailToOAuth(newType, loginId, password, token));
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function oauthToEmail(
currentService: string,
email: string,
password: string,
success: SuccessCallback<AuthChangeResponse>,
error: ErrorCallback,
) {
const {data, error: err} = await dispatch(UserActions.switchOAuthToEmail(currentService, email, password));
if (data) {
if (data.follow_link) {
emitUserLoggedOutEvent(data.follow_link);
}
if (success) {
success(data);
}
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function uploadBrandImage(brandImage: File, success: SuccessCallback<StatusOK>, error: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.uploadBrandImage(brandImage));
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function deleteBrandImage(success: SuccessCallback, error: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.deleteBrandImage());
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function uploadPublicSamlCertificate(file: File, success: SuccessCallback<string>, error: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.uploadPublicSamlCertificate(file));
if (data && success) {
success('saml-public.crt');
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function uploadPrivateSamlCertificate(file: File, success: SuccessCallback<string>, error: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.uploadPrivateSamlCertificate(file));
if (data && success) {
success('saml-private.key');
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function uploadPublicLdapCertificate(file: File, success: SuccessCallback<string>, error: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.uploadPublicLdapCertificate(file));
if (data && success) {
success('ldap-public.crt');
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function uploadPrivateLdapCertificate(file: File, success: SuccessCallback<string>, error: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.uploadPrivateLdapCertificate(file));
if (data && success) {
success('ldap-private.key');
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function uploadIdpSamlCertificate(file: File, success: SuccessCallback<string>, error: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.uploadIdpSamlCertificate(file));
if (data && success) {
success('saml-idp.crt');
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function uploadAuditCertificate(fileData: File, success: SuccessCallback<string>, error: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.uploadAuditCertificate(fileData));
if (data && success) {
success('audit.crt');
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function removeAuditCertificate(success: SuccessCallback, error: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.removeAuditCertificate());
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function removePublicSamlCertificate(success: SuccessCallback, error: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.removePublicSamlCertificate());
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function removePrivateSamlCertificate(success: SuccessCallback, error: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.removePrivateSamlCertificate());
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function removePublicLdapCertificate(success: SuccessCallback, error: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.removePublicLdapCertificate());
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function removePrivateLdapCertificate(success: SuccessCallback, error: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.removePrivateLdapCertificate());
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function removeIdpSamlCertificate(success: SuccessCallback, error: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.removeIdpSamlCertificate());
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function getStandardAnalytics(teamId?: string) {
await dispatch(AdminActions.getStandardAnalytics(teamId));
}
export async function refreshServerLimits() {
await dispatch(getServerLimitsAction());
}
export async function getAdvancedAnalytics(teamId?: string) {
await dispatch(AdminActions.getAdvancedAnalytics(teamId));
}
export async function getBotPostsPerDayAnalytics(teamId?: string) {
await dispatch(AdminActions.getBotPostsPerDayAnalytics(teamId));
}
export async function getPostsPerDayAnalytics(teamId?: string) {
await dispatch(AdminActions.getPostsPerDayAnalytics(teamId));
}
export async function getUsersPerDayAnalytics(teamId?: string) {
await dispatch(AdminActions.getUsersPerDayAnalytics(teamId));
}
export async function elasticsearchTest(config: AdminConfig, success: SuccessCallback, error?: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.testElasticsearch(config));
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function testFileStoreConnection(success: SuccessCallback, error: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.testFileStoreConnection());
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function elasticsearchPurgeIndexes(success: SuccessCallback, error: ErrorCallback, indexes?: string[]) {
const {data, error: err} = await dispatch(AdminActions.purgeElasticsearchIndexes(indexes));
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function jobCreate(success: SuccessCallback<Job>, error: ErrorCallback, job: JobTypeBase & {data?: any}) {
const {data, error: err} = await dispatch(createJob(job));
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function rebuildChannelsIndex(success: SuccessCallback<void>, error: ErrorCallback) {
await elasticsearchPurgeIndexes(undefined, error, ['channels']);
const job = {
type: JobTypes.ELASTICSEARCH_POST_INDEXING,
data: {
index_posts: 'false',
index_users: 'false',
index_files: 'false',
index_channels: 'true',
sub_type: 'channels_index_rebuild',
},
};
await jobCreate(undefined, error, job);
success?.();
}
export function setNavigationBlocked(blocked: boolean) {
return {
type: ActionTypes.SET_NAVIGATION_BLOCKED,
blocked,
};
}
export function deferNavigation(onNavigationConfirmed: () => void) {
return {
type: ActionTypes.DEFER_NAVIGATION,
onNavigationConfirmed,
};
}
export function cancelNavigation() {
return {
type: ActionTypes.CANCEL_NAVIGATION,
};
}
export function confirmNavigation(): ThunkActionFunc<void> {
// have to rename these because of lint no-shadow
return (thunkDispatch, thunkGetState) => {
const callback = getOnNavigationConfirmed(thunkGetState());
if (callback) {
callback();
}
thunkDispatch({
type: ActionTypes.CONFIRM_NAVIGATION,
});
};
}
export async function invalidateAllEmailInvites(success: SuccessCallback, error: ErrorCallback) {
const {data, error: err} = await dispatch(TeamActions.invalidateAllEmailInvites());
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function testSmtp(success: SuccessCallback, error: ErrorCallback) {
const {data, error: err} = await dispatch(AdminActions.testEmail());
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export function registerAdminConsolePlugin(pluginId: string, reducer: unknown): ThunkActionFunc<void> {
return (storeDispatch) => {
storeDispatch({
type: ActionTypes.RECEIVED_ADMIN_CONSOLE_REDUCER,
data: {
pluginId,
reducer,
},
});
};
}
export function unregisterAdminConsolePlugin(pluginId: string): ThunkActionFunc<void> {
return (storeDispatch) => {
storeDispatch({
type: ActionTypes.REMOVED_ADMIN_CONSOLE_REDUCER,
data: {
pluginId,
},
});
};
}
export async function testSiteURL(success: SuccessCallback, error: ErrorCallback, siteURL: string) {
const {data, error: err} = await dispatch(AdminActions.testSiteURL(siteURL));
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export function registerAdminConsoleCustomSetting(
pluginId: string,
key: string,
component: React.Component,
{showTitle}: AdminConsolePluginComponent['options'],
): ThunkActionFunc<void> {
return (storeDispatch) => {
storeDispatch({
type: ActionTypes.RECEIVED_ADMIN_CONSOLE_CUSTOM_COMPONENT,
data: {
pluginId,
key,
component,
options: {showTitle},
},
});
};
}
export function registerAdminConsoleCustomSection(
pluginId: string,
key: string,
component: React.Component,
): ThunkActionFunc<void> {
return (storeDispatch) => {
storeDispatch({
type: ActionTypes.RECEIVED_ADMIN_CONSOLE_CUSTOM_SECTION,
data: {
pluginId,
key,
component,
},
});
};
}
export async function getSamlMetadataFromIdp(
success: SuccessCallback<SamlMetadataResponse>,
error: ErrorCallback,
samlMetadataURL: string,
) {
const {data, error: err} = await dispatch(AdminActions.getSamlMetadataFromIdp(samlMetadataURL));
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export async function setSamlIdpCertificateFromMetadata(success: SuccessCallback<string>, error: ErrorCallback, certData: string) {
const {data, error: err} = await dispatch(AdminActions.setSamlIdpCertificateFromMetadata(certData));
if (data && success) {
success('saml-idp.crt');
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
}
export function upgradeToE0() {
return async () => {
const data = await Client4.upgradeToEnterprise();
return data;
};
}
export function upgradeToE0Status() {
return async () => {
const data = await Client4.upgradeToEnterpriseStatus();
return data;
};
}
export function isAllowedToUpgradeToEnterprise() {
return async () => {
try {
await Client4.isAllowedToUpgradeToEnterprise();
return {data: true};
} catch (error) {
return {error};
}
};
}
export function restartServer() {
return async () => {
const data = await Client4.restartServer();
return data;
};
}
export function ping(getServerStatus?: boolean, deviceId?: string) {
return async () => {
const data = await Client4.ping(getServerStatus, deviceId);
return data;
};
}
export function requestTrialLicense(requestLicenseBody: RequestLicenseBody) {
return async () => {
try {
const response = await Client4.requestTrialLicense(requestLicenseBody);
return {data: response};
} catch (e) {
// In the event that the status code returned is 451, this request has been blocked because it originated from an embargoed country_dropdown
return {error: e.message, data: {status: e.status_code}};
}
};
}

View File

@@ -0,0 +1,271 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {IntlShape} from 'react-intl';
import type {AnyAction} from 'redux';
import type {AppCallResponse, AppForm, AppCallRequest, AppContext, AppBinding} from '@mattermost/types/apps';
import type {CommandArgs} from '@mattermost/types/integrations';
import type {Post} from '@mattermost/types/posts';
import {Client4} from 'mattermost-redux/client';
import {AppCallResponseTypes} from 'mattermost-redux/constants/apps';
import {cleanForm} from 'mattermost-redux/utils/apps';
import {openModal} from 'actions/views/modals';
import AppsForm from 'components/apps_form';
import {createCallRequest, makeCallErrorResponse} from 'utils/apps';
import {getHistory} from 'utils/browser_history';
import {ModalIdentifiers} from 'utils/constants';
import {getSiteURL, shouldOpenInNewTab} from 'utils/url';
import type {DoAppCallResult} from 'types/apps';
import type {ActionFuncAsync, ThunkActionFunc} from 'types/store';
import {sendEphemeralPost} from './global_actions';
export function handleBindingClick<Res=unknown>(binding: AppBinding, context: AppContext, intl: any): ThunkActionFunc<Promise<DoAppCallResult<Res>>> {
return async (dispatch) => {
// Fetch form
let form = binding.form;
if (form?.source) {
const callRequest = createCallRequest(form.source, context);
const res = await dispatch(doAppFetchForm<Res>(callRequest, intl));
if (res.error) {
return res;
}
form = res.data!.form;
}
// Open form
if (form) {
// This should come properly formed, but using preventive checks
if (!form?.submit) {
const errMsg = intl.formatMessage({
id: 'apps.error.malformed_binding',
defaultMessage: 'This binding is not properly formed. Contact the App developer.',
});
return {error: makeCallErrorResponse(errMsg)};
}
const res: AppCallResponse<Res> = {
type: AppCallResponseTypes.FORM,
form,
};
return {data: res};
}
// Submit binding
// This should come properly formed, but using preventive checks
if (!binding.submit) {
const errMsg = intl.formatMessage({
id: 'apps.error.malformed_binding',
defaultMessage: 'This binding is not properly formed. Contact the App developer.',
});
return {error: makeCallErrorResponse(errMsg)};
}
const callRequest = createCallRequest(
binding.submit,
context,
);
const res = await dispatch(doAppSubmit<Res>(callRequest, intl));
return res;
};
}
export function doAppSubmit<Res=unknown>(inCall: AppCallRequest, intl: IntlShape): ThunkActionFunc<Promise<DoAppCallResult<Res>>> {
return async () => {
try {
const call: AppCallRequest = {
...inCall,
context: {
...inCall.context,
track_as_submit: true,
},
};
const res = await Client4.executeAppCall(call, true) as AppCallResponse<Res>;
const responseType = res.type || AppCallResponseTypes.OK;
switch (responseType) {
case AppCallResponseTypes.OK:
return {data: res};
case AppCallResponseTypes.ERROR:
return {error: res};
case AppCallResponseTypes.FORM:
if (!res.form?.submit) {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.form.no_form',
defaultMessage: 'Response type is `form`, but no form was included in response.',
});
return {error: makeCallErrorResponse(errMsg)};
}
cleanForm(res.form);
return {data: res};
case AppCallResponseTypes.NAVIGATE: {
if (!res.navigate_to_url) {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.navigate.no_url',
defaultMessage: 'Response type is `navigate`, but no url was included in response.',
});
return {error: makeCallErrorResponse(errMsg)};
}
if (shouldOpenInNewTab(res.navigate_to_url, getSiteURL())) {
window.open(res.navigate_to_url);
return {data: res};
}
const navigateURL = res.navigate_to_url.startsWith(getSiteURL()) ? res.navigate_to_url.slice(getSiteURL().length) : res.navigate_to_url;
getHistory().push(navigateURL);
return {data: res};
}
default: {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {type: responseType});
return {error: makeCallErrorResponse(errMsg)};
}
}
} catch (error: any) {
const errMsg = error.message || intl.formatMessage({
id: 'apps.error.responses.unexpected_error',
defaultMessage: 'Received an unexpected error.',
});
return {error: makeCallErrorResponse(errMsg)};
}
};
}
export function doAppFetchForm<Res=unknown>(call: AppCallRequest, intl: any): ThunkActionFunc<Promise<DoAppCallResult<Res>>> {
return async () => {
try {
const res = await Client4.executeAppCall(call, false) as AppCallResponse<Res>;
const responseType = res.type || AppCallResponseTypes.OK;
switch (responseType) {
case AppCallResponseTypes.ERROR:
return {error: res};
case AppCallResponseTypes.FORM:
if (!res.form?.submit) {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.form.no_form',
defaultMessage: 'Response type is `form`, but no form was included in response.',
});
return {error: makeCallErrorResponse(errMsg)};
}
cleanForm(res.form);
return {data: res};
default: {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {type: responseType});
return {error: makeCallErrorResponse(errMsg)};
}
}
} catch (error: any) {
const errMsg = error.message || intl.formatMessage({
id: 'apps.error.responses.unexpected_error',
defaultMessage: 'Received an unexpected error.',
});
return {error: makeCallErrorResponse(errMsg)};
}
};
}
export function doAppLookup<Res=unknown>(call: AppCallRequest, intl: any): ThunkActionFunc<Promise<DoAppCallResult<Res>>> {
return async () => {
try {
const res = await Client4.executeAppCall(call, false) as AppCallResponse<Res>;
const responseType = res.type || AppCallResponseTypes.OK;
switch (responseType) {
case AppCallResponseTypes.OK:
return {data: res};
case AppCallResponseTypes.ERROR:
return {error: res};
default: {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {type: responseType});
return {error: makeCallErrorResponse(errMsg)};
}
}
} catch (error: any) {
const errMsg = error.message || intl.formatMessage({
id: 'apps.error.responses.unexpected_error',
defaultMessage: 'Received an unexpected error.',
});
return {error: makeCallErrorResponse(errMsg)};
}
};
}
export function makeFetchBindings(location: string): (channelId: string, teamId: string) => ActionFuncAsync<AppBinding[]> {
return (channelId: string, teamId: string): ActionFuncAsync<AppBinding[]> => {
return async () => {
try {
const allBindings = await Client4.getAppsBindings(channelId, teamId);
const headerBindings = allBindings.filter((b) => b.location === location);
const bindings = headerBindings.reduce((accum: AppBinding[], current: AppBinding) => accum.concat(current.bindings || []), []);
return {data: bindings};
} catch {
return {data: []};
}
};
};
}
export function openAppsModal(form: AppForm, context: AppContext): AnyAction {
return openModal({
modalId: ModalIdentifiers.APPS_MODAL,
dialogType: AppsForm,
dialogProps: {
form,
appContext: context,
},
});
}
export function postEphemeralCallResponseForPost(response: AppCallResponse, message: string, post: Post) {
return sendEphemeralPost(
message,
post.channel_id,
post.root_id || post.id,
response.app_metadata?.bot_user_id,
);
}
export function postEphemeralCallResponseForChannel(response: AppCallResponse, message: string, channelID: string) {
return sendEphemeralPost(
message,
channelID,
'',
response.app_metadata?.bot_user_id,
);
}
export function postEphemeralCallResponseForContext(response: AppCallResponse, message: string, context: AppContext) {
return sendEphemeralPost(
message,
context.channel_id,
context.root_id || context.post_id,
response.app_metadata?.bot_user_id,
);
}
export function postEphemeralCallResponseForCommandArgs(response: AppCallResponse, message: string, args: CommandArgs) {
return sendEphemeralPost(
message,
args.channel_id,
args.root_id,
response.app_metadata?.bot_user_id,
);
}

View File

@@ -0,0 +1,132 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {PostTypes} from 'mattermost-redux/action_types';
import {Client4} from 'mattermost-redux/client';
import * as Actions from './burn_on_read_deletion';
jest.mock('mattermost-redux/client');
describe('burn_on_read_deletion actions', () => {
let mockDispatch: jest.Mock;
beforeEach(() => {
mockDispatch = jest.fn();
});
describe('burnPostNow', () => {
it('should call API and dispatch POST_REMOVED on success', async () => {
(Client4.burnPostNow as jest.Mock).mockResolvedValue({});
const mockPost = {
id: 'post123',
user_id: 'user1',
channel_id: 'channel1',
};
const mockGetState = () => ({
entities: {
users: {
currentUserId: 'user123',
},
posts: {
posts: {
post123: mockPost,
},
},
},
} as any);
const action = Actions.burnPostNow('post123');
const result = await action(mockDispatch, mockGetState, undefined);
expect(Client4.burnPostNow).toHaveBeenCalledWith('post123');
expect(mockDispatch).toHaveBeenCalledWith({
type: PostTypes.POST_REMOVED,
data: mockPost,
});
expect(result.data).toBe(true);
});
it('should return error and not dispatch POST_REMOVED when API call fails', async () => {
const error = new Error('API Error');
(Client4.burnPostNow as jest.Mock).mockRejectedValue(error);
const mockPost = {
id: 'post123',
user_id: 'user1',
channel_id: 'channel1',
};
const mockGetState = () => ({
entities: {
users: {
currentUserId: 'user123',
},
posts: {
posts: {
post123: mockPost,
},
},
},
} as any);
const action = Actions.burnPostNow('post123');
const result = await action(mockDispatch, mockGetState, undefined);
expect(result.error).toBe(error);
expect(mockDispatch).not.toHaveBeenCalledWith({
type: PostTypes.POST_REMOVED,
data: mockPost,
});
});
});
describe('handlePostExpired', () => {
it('should dispatch POST_REMOVED action with full post object', async () => {
const mockPost = {
id: 'post123',
channel_id: 'channel1',
user_id: 'user1',
message: 'test',
create_at: 123456,
};
const mockGetState = () => ({
entities: {
posts: {
posts: {
post123: mockPost,
},
},
},
} as any);
const action = Actions.handlePostExpired('post123');
const result = await action(mockDispatch, mockGetState, undefined);
expect(mockDispatch).toHaveBeenCalledWith({
type: PostTypes.POST_REMOVED,
data: mockPost,
});
expect(result.data).toBe(true);
});
it('should return early if post does not exist', async () => {
const mockGetState = () => ({
entities: {
posts: {
posts: {},
},
},
} as any);
const action = Actions.handlePostExpired('nonexistent');
const result = await action(mockDispatch, mockGetState, undefined);
expect(mockDispatch).not.toHaveBeenCalled();
expect(result.data).toBe(true);
});
});
});

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {PostTypes} from 'mattermost-redux/action_types';
import {forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers';
import {Client4} from 'mattermost-redux/client';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import type {DispatchFunc, GetStateFunc, ActionFuncAsync} from 'types/store';
/**
* Shared helper to remove a post from Redux state
* @param postId - The ID of the post to remove
* @param dispatch - Redux dispatch function
* @param getState - Redux getState function
* @returns true if post was removed or didn't exist, false otherwise
*/
function removePostFromState(postId: string, dispatch: DispatchFunc, getState: GetStateFunc): boolean {
const state = getState();
const post = getPost(state, postId);
if (!post) {
return true;
}
dispatch({
type: PostTypes.POST_REMOVED,
data: post,
});
return true;
}
/**
* Manual "burn now" action - immediately deletes a burn-on-read post
* Triggered when user clicks the timer chip/badge and confirms deletion
* Works for both sender (permanent delete) and recipient (per-user burn)
*
* @param postId - The ID of the post to burn/delete
*/
export function burnPostNow(postId: string): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
try {
// Use burn endpoint for both sender and recipient
await Client4.burnPostNow(postId);
// Remove post from Redux state
const removed = removePostFromState(postId, dispatch, getState);
return {data: removed};
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
return {error};
}
};
}
/**
* Handles automatic post expiration and WebSocket burn events
*
* Called when:
* - Timer reaches zero (automatic expiration)
* - WebSocket receives burn event (syncs across user's devices)
*
* Note: This only removes the post from local Redux state (client-side deletion).
* Backend cleanup job handles authoritative deletion. This is a UX optimization
* to immediately remove expired posts from the UI without waiting for backend.
*/
export function handlePostExpired(postId: string): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
// Remove post from Redux state (client-side only)
const removed = removePostFromState(postId, dispatch, getState);
return {data: removed};
};
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {PostTypes} from 'mattermost-redux/action_types';
import {Client4} from 'mattermost-redux/client';
import {Posts} from 'mattermost-redux/constants';
import {revealBurnOnReadPost} from './burn_on_read_posts';
jest.mock('mattermost-redux/client');
describe('burn_on_read_posts actions', () => {
const mockPost = {
id: 'post123',
type: Posts.POST_TYPES.BURN_ON_READ,
user_id: 'user1',
channel_id: 'channel1',
message: 'revealed content',
create_at: 1234567890,
update_at: 1234567890,
delete_at: 0,
metadata: {
expire_at: 9999999999999,
},
};
const mockResponse = {
...mockPost,
};
describe('revealBurnOnReadPost', () => {
const mockState = {
entities: {
users: {
currentUserId: 'user1',
},
},
};
it('should dispatch success action on successful reveal', async () => {
(Client4.revealBurnOnReadPost as jest.Mock) = jest.fn().mockResolvedValue(mockResponse);
const dispatch = jest.fn();
const getState = jest.fn().mockReturnValue(mockState);
const result = await revealBurnOnReadPost('post123')(dispatch, getState, undefined);
expect(Client4.revealBurnOnReadPost).toHaveBeenCalledWith('post123');
expect(dispatch).toHaveBeenCalledWith({
type: PostTypes.REVEAL_BURN_ON_READ_SUCCESS,
data: {
post: mockResponse,
expireAt: 9999999999999,
},
});
expect(result).toEqual({data: mockResponse});
});
it('should return error on failure', async () => {
const mockError = {
message: 'Not found',
status_code: 404,
};
(Client4.revealBurnOnReadPost as jest.Mock) = jest.fn().mockRejectedValue(mockError);
const dispatch = jest.fn();
const getState = jest.fn().mockReturnValue(mockState);
const result = await revealBurnOnReadPost('post123')(dispatch, getState, undefined);
// Should dispatch logError thunk (which is a function, not a plain action)
expect(dispatch).toHaveBeenCalledWith(expect.any(Function));
expect(result).toEqual({error: mockError});
});
it('should handle network errors', async () => {
const networkError = new Error('Network error');
(Client4.revealBurnOnReadPost as jest.Mock) = jest.fn().mockRejectedValue(networkError);
const dispatch = jest.fn();
const getState = jest.fn().mockReturnValue(mockState);
const result = await revealBurnOnReadPost('post123')(dispatch, getState, undefined);
expect(result.error).toBe(networkError);
});
});
});

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Post} from '@mattermost/types/posts';
import {PostTypes} from 'mattermost-redux/action_types';
import {logError} from 'mattermost-redux/actions/errors';
import {forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers';
import {Client4} from 'mattermost-redux/client';
import type {ActionFuncAsync} from 'types/store';
/**
* Reveals a Burn-on-Read post for the current user.
* This action calls the backend API to mark the post as read and returns the full post content
*
* @param postId - The ID of the Burn-on-Read post to reveal
* @returns Action result with revealed post, or error
*/
export function revealBurnOnReadPost(postId: string): ActionFuncAsync<Post> {
return async (dispatch, getState) => {
let revealedPost: Post;
try {
revealedPost = await Client4.revealBurnOnReadPost(postId);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {error};
}
const expireAt = revealedPost.metadata?.expire_at;
dispatch({
type: PostTypes.REVEAL_BURN_ON_READ_SUCCESS,
data: {
post: revealedPost,
expireAt: expireAt || 0,
},
});
return {data: revealedPost};
};
}

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {WebSocketMessages} from '@mattermost/client';
import {PostTypes} from 'mattermost-redux/action_types';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import type {DispatchFunc, GetStateFunc} from 'types/store';
/**
* Handles the post_revealed websocket event for burn-on-read posts.
* Two scenarios:
* 1. Post author: Updates recipients list for real-time recipient count tracking
* 2. Revealing user: Updates post with revealed content for multi-device sync
*/
export function handleBurnOnReadPostRevealed(data: WebSocketMessages.BurnOnReadPostRevealed['data']) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const state = getState();
const currentUserId = getCurrentUserId(state);
let post;
if (typeof data.post === 'string') {
try {
post = JSON.parse(data.post);
} catch (e) {
// eslint-disable-next-line no-console
console.error('Failed to parse burn-on-read post revealed data:', e);
return {data: false};
}
} else {
post = data.post;
}
if (!post || !post.id) {
return {data: false};
}
const existingPost = state.entities.posts.posts[post.id];
if (!existingPost) {
return {data: false};
}
// Case 1: Current user is the post author - update recipients list
if (existingPost.user_id === currentUserId && data.recipients) {
dispatch({
type: PostTypes.POST_RECIPIENTS_UPDATED,
data: {
postId: post.id,
recipients: data.recipients,
},
});
}
// Case 2: Current user is a recipient - update with revealed content
// This enables multi-device sync when user reveals on one device
if (existingPost.user_id !== currentUserId && post.message) {
const expireAt = post.metadata?.expire_at || 0;
dispatch({
type: PostTypes.REVEAL_BURN_ON_READ_SUCCESS,
data: {
post,
expireAt,
},
});
}
return {data: true};
};
}
/**
* Handles the burn_on_read_all_revealed websocket event.
* Sent to the post author when all recipients have revealed the message.
*/
export function handleBurnOnReadAllRevealed(data: WebSocketMessages.BurnOnReadPostAllRevealed['data']) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const state = getState();
const {post_id: postId, sender_expire_at: senderExpireAt} = data;
if (!postId || !senderExpireAt) {
return {data: false};
}
const post = state.entities.posts.posts[postId];
if (!post) {
return {data: false};
}
// Update the post with the sender's expiration time
dispatch({
type: PostTypes.BURN_ON_READ_ALL_REVEALED,
data: {
postId,
senderExpireAt,
},
});
return {data: true};
};
}

View File

@@ -0,0 +1,255 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
addUsersToChannel,
openDirectChannelToUserId,
openGroupChannelToUserIds,
loadChannelsForCurrentUser,
} from 'actions/channel_actions';
import {loadProfilesForSidebar} from 'actions/user_actions';
import mockStore from 'tests/test_store';
import {TestHelper} from 'utils/test_helper';
const initialState = {
entities: {
channels: {
currentChannelId: 'current_channel_id',
myMembers: {
current_channel_id: {
channel_id: 'current_channel_id',
user_id: 'current_user_id',
roles: 'channel_role',
mention_count: 1,
msg_count: 9,
},
},
channels: {
current_channel_id: TestHelper.getChannelMock({
id: 'current_channel_id',
name: 'default-name',
display_name: 'Default',
delete_at: 0,
type: 'O',
team_id: 'team_id',
}),
current_user_id__existingId: TestHelper.getChannelMock({
id: 'current_user_id__existingId',
name: 'current_user_id__existingId',
display_name: 'Default',
delete_at: 0,
type: 'O',
team_id: 'team_id',
}),
},
channelsInTeam: {
'team-id': new Set(['asdf']),
},
messageCounts: {
current_channel_id: {total: 10},
current_user_id__existingId: {total: 0},
},
},
teams: {
currentTeamId: 'team-id',
teams: {
'team-id': {
id: 'team_id',
name: 'team-1',
display_name: 'Team 1',
},
},
myMembers: {
'team-id': {roles: 'team_role'},
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_role'},
},
},
preferences: {
myPreferences: {
'display_settings--name_format': {
category: 'display_settings',
name: 'name_format',
user_id: 'current_user_id',
value: 'username',
},
},
},
roles: {
roles: {
system_role: {
permissions: [],
},
team_role: {
permissions: [],
},
channel_role: {
permissions: [],
},
},
},
general: {
license: {IsLicensed: 'false'},
serverVersion: '5.4.0',
config: {PostEditTimeLimit: '-1'},
},
},
};
const realDateNow = Date.now;
jest.mock('mattermost-redux/actions/channels', () => ({
fetchChannelsAndMembers: (...args: any) => ({type: 'MOCK_FETCH_CHANNELS_AND_MEMBERS', args}),
searchChannels: () => {
return {
type: 'MOCK_SEARCH_CHANNELS',
data: [{
id: 'channel-id',
name: 'channel-name',
display_name: 'Channel',
delete_at: 0,
type: 'O',
}],
};
},
addChannelMembers: (...args: any) => ({type: 'MOCK_ADD_CHANNEL_MEMBERS', args}),
createDirectChannel: (...args: any) => ({type: 'MOCK_CREATE_DIRECT_CHANNEL', args}),
createGroupChannel: (...args: any) => ({type: 'MOCK_CREATE_GROUP_CHANNEL', args}),
}));
jest.mock('actions/user_actions', () => ({
loadNewDMIfNeeded: jest.fn(),
loadNewGMIfNeeded: jest.fn(),
loadProfilesForSidebar: jest.fn(),
}));
describe('Actions.Channel', () => {
test('loadChannelsForCurrentUser', async () => {
const testStore = await mockStore(initialState);
const expectedActions = [{
type: 'MOCK_FETCH_CHANNELS_AND_MEMBERS',
args: ['team-id'],
}];
await testStore.dispatch(loadChannelsForCurrentUser());
expect(testStore.getActions()).toEqual(expectedActions);
expect(loadProfilesForSidebar).toHaveBeenCalledTimes(1);
});
test('addUsersToChannel', async () => {
const testStore = await mockStore(initialState);
const expectedActions = [{
type: 'MOCK_ADD_CHANNEL_MEMBERS',
args: ['testid', ['testuserid', 'testuserid2']],
}];
const fakeData = {
channel: 'testid',
userIds: ['testuserid', 'testuserid2'],
};
await testStore.dispatch(addUsersToChannel(fakeData.channel, fakeData.userIds));
expect(testStore.getActions()).toEqual(expectedActions);
});
test('openDirectChannelToUserId Not Existing', async () => {
const testStore = await mockStore(initialState);
const expectedActions = [{
type: 'MOCK_CREATE_DIRECT_CHANNEL',
args: ['current_user_id', 'testid'],
}];
const fakeData = {
userId: 'testid',
};
await testStore.dispatch(openDirectChannelToUserId(fakeData.userId));
expect(testStore.getActions()).toEqual(expectedActions);
});
test('openDirectChannelToUserId Existing', async () => {
Date.now = () => new Date(0).getMilliseconds();
const testStore = await mockStore(initialState);
const expectedActions = [
{
meta: {
batch: true,
},
payload: [
{
data: [
{
category: 'direct_channel_show',
name: 'existingId',
value: 'true',
},
],
type: 'RECEIVED_PREFERENCES',
},
{
data: [
{
category: 'channel_open_time',
name: 'current_user_id__existingId',
value: '0',
},
],
type: 'RECEIVED_PREFERENCES',
},
],
type: 'BATCHING_REDUCER.BATCH',
},
{
data: [
{
category: 'direct_channel_show',
name: 'existingId',
user_id: 'current_user_id',
value: 'true',
},
{
category: 'channel_open_time',
name: 'current_user_id__existingId',
user_id: 'current_user_id',
value: '0',
},
],
type: 'RECEIVED_PREFERENCES',
},
];
const fakeData = {
userId: 'existingId',
};
await testStore.dispatch(openDirectChannelToUserId(fakeData.userId));
const doneActions = testStore.getActions();
expect(doneActions).toEqual(expectedActions);
Date.now = realDateNow;
});
test('openGroupChannelToUserIds', async () => {
const testStore = await mockStore(initialState);
const expectedActions = [{
type: 'MOCK_CREATE_GROUP_CHANNEL',
args: [['testuserid1', 'testuserid2']],
}];
const fakeData = {
userIds: ['testuserid1', 'testuserid2'],
};
await testStore.dispatch(openGroupChannelToUserIds(fakeData.userIds));
expect(testStore.getActions()).toEqual(expectedActions);
});
});

View File

@@ -0,0 +1,192 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {batchActions} from 'redux-batched-actions';
import type {Channel} from '@mattermost/types/channels';
import type {ServerError} from '@mattermost/types/errors';
import type {UserProfile} from '@mattermost/types/users';
import {PreferenceTypes} from 'mattermost-redux/action_types';
import * as ChannelActions from 'mattermost-redux/actions/channels';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {getChannelByName, getUnreadChannelIds, getChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeamUrl, getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {loadNewDMIfNeeded, loadNewGMIfNeeded, loadProfilesForSidebar} from 'actions/user_actions';
import {getHistory} from 'utils/browser_history';
import {Constants, Preferences, NotificationLevels} from 'utils/constants';
import {getDirectChannelName} from 'utils/utils';
import type {ActionFuncAsync} from 'types/store';
export function openDirectChannelToUserId(userId: UserProfile['id']): ActionFuncAsync<Channel> {
return async (dispatch, getState) => {
const state = getState();
const currentUserId = getCurrentUserId(state);
const channelName = getDirectChannelName(currentUserId, userId);
const channel = getChannelByName(state, channelName);
if (!channel) {
return dispatch(ChannelActions.createDirectChannel(currentUserId, userId));
}
const now = Date.now();
const prefDirect = {
category: Preferences.CATEGORY_DIRECT_CHANNEL_SHOW,
name: userId,
value: 'true',
};
const prefOpenTime = {
category: Preferences.CATEGORY_CHANNEL_OPEN_TIME,
name: channel.id,
value: now.toString(),
};
const actions = [{
type: PreferenceTypes.RECEIVED_PREFERENCES,
data: [prefDirect],
}, {
type: PreferenceTypes.RECEIVED_PREFERENCES,
data: [prefOpenTime],
}];
dispatch(batchActions(actions));
dispatch(savePreferences(currentUserId, [
{user_id: currentUserId, ...prefDirect},
{user_id: currentUserId, ...prefOpenTime},
]));
return {data: channel};
};
}
export function openGroupChannelToUserIds(userIds: Array<UserProfile['id']>): ActionFuncAsync<Channel> {
return async (dispatch, getState) => {
const result = await dispatch(ChannelActions.createGroupChannel(userIds));
if (result.error) {
getHistory().push(getCurrentTeamUrl(getState()));
}
return result;
};
}
export function loadChannelsForCurrentUser(): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const unreads = getUnreadChannelIds(state);
await dispatch(ChannelActions.fetchChannelsAndMembers(getCurrentTeamId(state)));
for (const id of unreads) {
const channel = getChannel(state, id);
if (channel && channel.type === Constants.DM_CHANNEL) {
dispatch(loadNewDMIfNeeded(channel.id));
} else if (channel && channel.type === Constants.GM_CHANNEL) {
dispatch(loadNewGMIfNeeded(channel.id));
}
}
loadProfilesForSidebar();
return {data: true};
};
}
export function autocompleteChannels(term: string, success: (channels: Channel[]) => void, error?: (err: ServerError) => void): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
const state = getState();
const teamId = getCurrentTeamId(state);
if (!teamId) {
return {data: false};
}
const {data, error: err} = await dispatch(ChannelActions.autocompleteChannels(teamId, term));
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
return {data: true};
};
}
export function autocompleteActiveChannels(term: string, success: (channels: Channel[]) => void, error?: (err: ServerError) => void): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const teamId = getCurrentTeamId(state);
if (!teamId) {
return {data: false};
}
const {data, error: err} = await dispatch(ChannelActions.autocompleteChannels(teamId, term));
if (data && success) {
const activeChannels = data.filter((channel: Channel) => channel.delete_at === 0);
success(activeChannels);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
return {data: true};
};
}
export function autocompleteChannelsForSearch(term: string, success?: (channels: Channel[]) => void, error?: (err: ServerError) => void): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const teamId = getCurrentTeamId(state);
if (!teamId) {
return {data: false};
}
const {data, error: err} = await dispatch(ChannelActions.autocompleteChannelsForSearch(teamId, term));
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
return {data: true};
};
}
export function autocompleteChannelsForSearchInTeam(term: string, teamId: string, success?: (channels: Channel[]) => void, error?: (err: ServerError) => void): ActionFuncAsync {
return async (dispatch) => {
if (!teamId) {
return {data: false};
}
const {data, error: err} = await dispatch(ChannelActions.autocompleteChannelsForSearch(teamId, term));
if (data && success) {
success(data);
} else if (err && error) {
error({id: err.server_error_id, ...err});
}
return {data: true};
};
}
export function addUsersToChannel(channelId: Channel['id'], userIds: Array<UserProfile['id']>): ActionFuncAsync {
return async (dispatch) => {
const error = await dispatch(ChannelActions.addChannelMembers(channelId, userIds));
if (error) {
return error;
}
return {data: true};
};
}
export function unmuteChannel(userId: UserProfile['id'], channelId: Channel['id']) {
return ChannelActions.updateChannelNotifyProps(userId, channelId, {
mark_unread: NotificationLevels.ALL,
});
}
export function muteChannel(userId: UserProfile['id'], channelId: Channel['id']) {
return ChannelActions.updateChannelNotifyProps(userId, channelId, {
mark_unread: NotificationLevels.MENTION,
});
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ChannelBookmark, ChannelBookmarkCreate, ChannelBookmarkPatch} from '@mattermost/types/channel_bookmarks';
import * as Actions from 'mattermost-redux/actions/channel_bookmarks';
import {getConnectionId} from 'selectors/general';
import type {ActionFuncAsync} from 'types/store';
export function deleteBookmark(channelId: string, id: string): ActionFuncAsync<boolean> {
return (dispatch, getState) => {
const state = getState();
const connectionId = getConnectionId(state);
return dispatch(Actions.deleteBookmark(channelId, id, connectionId));
};
}
export function createBookmark(channelId: string, bookmark: ChannelBookmarkCreate): ActionFuncAsync<boolean> {
return (dispatch, getState) => {
const state = getState();
const connectionId = getConnectionId(state);
return dispatch(Actions.createBookmark(channelId, bookmark, connectionId));
};
}
export function editBookmark(channelId: string, id: string, patch: ChannelBookmarkPatch): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
const state = getState();
const connectionId = getConnectionId(state);
return dispatch(Actions.editBookmark(channelId, id, patch, connectionId));
};
}
export function reorderBookmark(channelId: string, id: string, newOrder: number): ActionFuncAsync<boolean> {
return (dispatch, getState) => {
const state = getState();
const connectionId = getConnectionId(state);
return dispatch(Actions.reorderBookmark(channelId, id, newOrder, connectionId));
};
}
export function fetchChannelBookmarks(channelId: string): ActionFuncAsync<ChannelBookmark[]> {
return Actions.fetchChannelBookmarks(channelId);
}

View File

@@ -0,0 +1,166 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ServerError} from '@mattermost/types/errors';
import {CloudTypes} from 'mattermost-redux/action_types';
import {getCloudCustomer, getCloudProducts, getCloudSubscription, getInvoices} from 'mattermost-redux/actions/cloud';
import {Client4} from 'mattermost-redux/client';
import {getCloudErrors} from 'mattermost-redux/selectors/entities/cloud';
import type {ActionFunc, ThunkActionFunc} from 'types/store';
export function getInstallation() {
return async () => {
try {
const installation = await Client4.getInstallation();
return {data: installation};
} catch (e: any) {
return {error: e.message};
}
};
}
export function validateBusinessEmail(email = '') {
return async () => {
try {
const res = await Client4.validateBusinessEmail(email);
return res.data.is_valid;
} catch {
return false;
}
};
}
export function validateWorkspaceBusinessEmail() {
return async () => {
try {
const res = await Client4.validateWorkspaceBusinessEmail();
return res.data.is_valid;
} catch {
return false;
}
};
}
export function getCloudLimits(): ThunkActionFunc<Promise<boolean | ServerError>> {
return async (dispatch) => {
try {
dispatch({
type: CloudTypes.CLOUD_LIMITS_REQUEST,
});
const result = await Client4.getCloudLimits();
if (result) {
dispatch({
type: CloudTypes.RECEIVED_CLOUD_LIMITS,
data: result,
});
}
} catch (error) {
dispatch({
type: CloudTypes.CLOUD_LIMITS_FAILED,
});
return error;
}
return true;
};
}
export function getMessagesUsage(): ThunkActionFunc<Promise<boolean | ServerError>> {
return async (dispatch) => {
try {
const result = await Client4.getPostsUsage();
if (result) {
dispatch({
type: CloudTypes.RECEIVED_MESSAGES_USAGE,
data: result.count,
});
}
} catch (error) {
return error;
}
return true;
};
}
export function getFilesUsage(): ThunkActionFunc<Promise<boolean | ServerError>> {
return async (dispatch) => {
try {
const result = await Client4.getFilesUsage();
if (result) {
// match limit notation in bits
const inBits = result.bytes * 8;
dispatch({
type: CloudTypes.RECEIVED_FILES_USAGE,
data: inBits,
});
}
} catch (error) {
return error;
}
return {data: true};
};
}
export function getTeamsUsage(): ThunkActionFunc<Promise<boolean | ServerError>> {
return async (dispatch) => {
try {
const result = await Client4.getTeamsUsage();
if (result) {
dispatch({
type: CloudTypes.RECEIVED_TEAMS_USAGE,
data: {active: result.active, cloudArchived: result.cloud_archived},
});
}
} catch (error) {
return error;
}
return {data: false};
};
}
export function getCloudPreviewModalData(): ThunkActionFunc<Promise<boolean | ServerError>> {
return async () => {
try {
const result = await Client4.getCloudPreviewModalData();
if (result) {
return {data: result};
}
} catch (error) {
return error;
}
return true;
};
}
export function retryFailedCloudFetches(): ActionFunc<boolean> {
return (dispatch, getState) => {
const errors = getCloudErrors(getState());
if (Object.keys(errors).length === 0) {
return {data: true};
}
if (errors.subscription) {
dispatch(getCloudSubscription());
}
if (errors.products) {
dispatch(getCloudProducts());
}
if (errors.customer) {
dispatch(getCloudCustomer());
}
if (errors.invoices) {
dispatch(getInvoices());
}
if (errors.limits) {
dispatch(getCloudLimits());
}
return {data: true};
};
}

View File

@@ -0,0 +1,447 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as UserAgent from '@mattermost/shared/utils/user_agent';
import type {Channel} from '@mattermost/types/channels';
import type {CommandArgs} from '@mattermost/types/integrations';
import {IntegrationTypes} from 'mattermost-redux/action_types';
import {Client4} from 'mattermost-redux/client';
import {Permissions} from 'mattermost-redux/constants';
import {AppCallResponseTypes} from 'mattermost-redux/constants/apps';
import * as Channels from 'mattermost-redux/selectors/entities/channels';
import * as Teams from 'mattermost-redux/selectors/entities/teams';
import * as GlobalActions from 'actions/global_actions';
import UserSettingsModal from 'components/user_settings/modal';
import mockStore from 'tests/test_store';
import {ActionTypes, Constants, ModalIdentifiers} from 'utils/constants';
import * as Utils from 'utils/utils';
import type {ActionFuncAsync, GlobalState} from 'types/store';
import {executeCommand} from './command';
const currentChannelId = '123';
const currentTeamId = '321';
const currentUserId = 'user123';
const initialState = {
entities: {
admin: {
pluginStatuses: {
'com.mattermost.apps': {
state: 2,
},
},
},
posts: {
posts: {
root_id: {id: 'root_id', channel_id: '123'},
},
},
channels: {
currentChannelId,
channels: {
123: {id: '123', team_id: '456'},
},
},
preferences: {
myPreferences: {},
},
roles: {
roles: {
custom_role: {
permissions: [
Permissions.SYSCONSOLE_WRITE_PLUGINS,
],
},
},
},
teams: {
currentTeamId,
},
users: {
currentUserId,
profiles: {
user123: {
roles: 'custom_role',
timezone: {
useAutomaticTimezone: true,
automaticTimezone: '',
manualTimezone: '',
},
},
},
},
apps: {
main: {
bindings: [
{
location: '/command',
bindings: [
{
location: '/command/appid',
app_id: 'appid',
label: 'appid',
bindings: [
{
location: '/command/appid/custom',
app_id: 'appid',
label: 'custom',
description: 'Run the command.',
form: {
submit: {
path: 'https://someserver.com/command',
},
fields: [
{
name: 'key1',
label: 'key1',
type: 'text',
position: 1,
},
{
name: 'key2',
label: 'key2',
type: 'static_select',
options: [
{
label: 'Value 2',
value: 'value2',
},
],
},
],
},
},
],
},
],
},
],
forms: {},
},
pluginEnabled: true,
},
general: {
config: {},
},
},
views: {
rhs: {
rhsState: null,
searchTerms: '',
searchType: '',
},
},
} as unknown as GlobalState;
const isMobileMock = jest.mocked(UserAgent.isMobile);
jest.mock('@mattermost/shared/utils/user_agent', () => ({
isDesktopApp: jest.fn(),
isMobile: jest.fn(),
}));
jest.mock('actions/global_actions');
describe('executeCommand', () => {
let store: ReturnType<typeof mockStore>;
beforeEach(async () => {
store = await mockStore(initialState);
});
describe('search', () => {
test('should fire the UPDATE_RHS_SEARCH_TERMS with the terms', async () => {
store.dispatch(executeCommand('/search foo bar', [] as unknown as CommandArgs));
expect(store.getActions()).toEqual([
{type: 'UPDATE_RHS_SEARCH_TERMS', terms: 'foo bar'},
{type: 'UPDATE_RHS_STATE', state: 'search'},
{type: 'UPDATE_RHS_SEARCH_RESULTS_TERMS', terms: ''},
{type: 'UPDATE_RHS_SEARCH_RESULTS_TYPE', searchType: ''},
{type: 'SEARCH_POSTS_REQUEST', isGettingMore: false},
{data: {firstInaccessiblePostTime: 0, searchType: 'posts'}, type: 'RECEIVED_SEARCH_TRUNCATION_INFO'},
{type: 'SEARCH_FILES_REQUEST', isGettingMore: false},
{data: {firstInaccessiblePostTime: 0, searchType: 'files'}, type: 'RECEIVED_SEARCH_TRUNCATION_INFO'},
]);
});
});
describe('shortcuts', () => {
test('should return error in case of mobile', async () => {
isMobileMock.mockReturnValueOnce(true);
const result = await store.dispatch(executeCommand('/shortcuts', [] as unknown as CommandArgs));
expect(result).toEqual({
error: {
message: 'Keyboard shortcuts are not supported on your device.',
},
});
});
test('should open shortcut modal in case of no mobile', async () => {
isMobileMock.mockReturnValueOnce(false);
const result = await store.dispatch(executeCommand('/shortcuts', [] as unknown as CommandArgs));
const actionDispatch = store.getActions()[0];
expect(actionDispatch).toMatchObject({
type: ActionTypes.MODAL_OPEN,
modalId: ModalIdentifiers.KEYBOARD_SHORTCUTS_MODAL,
});
expect(result.data).toBeDefined();
});
});
describe('settings', () => {
test('should pass right modal params', async () => {
const result = await store.dispatch(executeCommand('/settings', {} as CommandArgs));
expect(store.getActions()).toEqual([
{
type: ActionTypes.MODAL_OPEN,
dialogProps: {isContentProductSettings: true},
dialogType: UserSettingsModal,
modalId: 'user_settings',
},
]);
expect(result.data).toBeDefined();
});
});
describe('collapse', () => {
test('call executeCommand with right params', async () => {
Client4.executeCommand = jest.fn().mockResolvedValue({});
await store.dispatch(executeCommand('/collapse', [] as unknown as CommandArgs));
expect(Client4.executeCommand).toHaveBeenCalledWith('/collapse ', []);
});
});
// MM-70251: the server mints the dialog's trigger against the channel sent here, so
// this is where a dialog's channel originates — the composer's channel, which is the
// thread's channel in the RHS rather than whichever channel is centre stage.
describe('dialog trigger', () => {
test('sends the command channel to the server', async () => {
Client4.executeCommand = jest.fn().mockResolvedValue({trigger_id: 'trigger123'});
await store.dispatch(executeCommand('/somecommand', {channel_id: 'command_channel_id'}));
expect(Client4.executeCommand).toHaveBeenCalledWith(
'/somecommand ',
expect.objectContaining({channel_id: 'command_channel_id'}),
);
});
test('announces the trigger returned by the server', async () => {
Client4.executeCommand = jest.fn().mockResolvedValue({trigger_id: 'trigger123'});
await store.dispatch(executeCommand('/somecommand', {channel_id: 'command_channel_id'}));
expect(store.getActions()).toContainEqual({
type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID,
data: 'trigger123',
});
});
test('announces nothing when the command returns no trigger_id', async () => {
Client4.executeCommand = jest.fn().mockResolvedValue({});
await store.dispatch(executeCommand('/somecommand', {channel_id: 'command_channel_id'}));
const triggerAction = store.getActions().find((a) => a.type === IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID);
expect(triggerAction).toBeUndefined();
});
});
describe('leave', () => {
test('should send message when command typed in reply threads', async () => {
jest.mocked(GlobalActions.sendEphemeralPost).mockReturnValue({type: 'someaction'} as unknown as ActionFuncAsync<boolean>);
const result = await store.dispatch(executeCommand('/leave', {channel_id: 'channel_id', root_id: 'root_id'}));
expect(GlobalActions.sendEphemeralPost).
toHaveBeenCalledWith('/leave is not supported in reply threads. Use it in the center channel instead.',
'channel_id', 'root_id');
expect(result.data).toBeDefined();
});
test('should show private modal if channel is private', async () => {
jest.spyOn(Channels, 'getCurrentChannel').mockImplementation(() => ({type: Constants.PRIVATE_CHANNEL} as Channel));
const result = await store.dispatch(executeCommand('/leave', {} as CommandArgs));
const actionDispatch = store.getActions()[0];
expect(actionDispatch).toMatchObject({
type: ActionTypes.MODAL_OPEN,
modalId: ModalIdentifiers.LEAVE_PRIVATE_CHANNEL_MODAL,
dialogProps: {channel: {type: Constants.PRIVATE_CHANNEL}},
});
expect(result.data).toBeDefined();
});
test('should use user id as name if channel is dm', async () => {
jest.spyOn(Utils, 'getUserIdFromChannelName').mockImplementation(() => 'userId');
jest.spyOn(Channels, 'getRedirectChannelNameForTeam').mockImplementation(() => 'channel1');
jest.spyOn(Teams, 'getCurrentRelativeTeamUrl').mockImplementation(() => '/team1');
jest.spyOn(Channels, 'getCurrentChannel').mockImplementation(() => ({type: Constants.DM_CHANNEL} as Channel));
const result = await store.dispatch(executeCommand('/leave', {} as CommandArgs));
expect(store.getActions()[0].data).toEqual([{category: 'direct_channel_show', name: 'userId', user_id: 'user123', value: 'false'}]);
expect(result.data).toBeDefined();
});
test('should use channel id as name if channel is gm', async () => {
jest.spyOn(Utils, 'getUserIdFromChannelName').mockImplementation(() => 'userId');
jest.spyOn(Channels, 'getRedirectChannelNameForTeam').mockImplementation(() => 'channel1');
jest.spyOn(Teams, 'getCurrentRelativeTeamUrl').mockImplementation(() => '/team1');
jest.spyOn(Channels, 'getCurrentChannel').mockImplementation(() => ({type: Constants.GM_CHANNEL, id: 'channelId'} as Channel));
const result = await store.dispatch(executeCommand('/leave', {} as CommandArgs));
expect(store.getActions()[0].data).toEqual([{category: 'group_channel_show', name: 'channelId', user_id: 'user123', value: 'false'}]);
expect(result.data).toBeDefined();
});
});
describe('marketplace command', () => {
test('it is a local command, it should not call the server', async () => {
const state = {
...initialState,
entities: {
...initialState.entities,
general: {
...initialState.entities.general,
config: {
...initialState.entities.general.config,
EnableMarketplace: 'true',
PluginsEnabled: 'true',
},
},
},
};
store = await mockStore(state);
Client4.executeCommand = jest.fn().mockResolvedValue({});
const result = await store.dispatch(executeCommand('/marketplace', [] as unknown as CommandArgs));
// Make sure the server was not called
expect(Client4.executeCommand).not.toHaveBeenCalled();
// Make sure we opened the modal
const actionDispatch = store.getActions()[0];
expect(actionDispatch).toMatchObject({
type: ActionTypes.MODAL_OPEN,
modalId: ModalIdentifiers.PLUGIN_MARKETPLACE,
});
expect(result.data).toBeDefined();
});
test('should show error when marketpace is not enabled', async () => {
const state = {
...initialState,
entities: {
...initialState.entities,
general: {
...initialState.entities.general,
config: {
...initialState.entities.general.config,
EnableMarketplace: 'false',
PluginsEnabled: 'false',
},
},
},
};
store = await mockStore(state);
const res = await store.dispatch(executeCommand('/marketplace', [] as unknown as CommandArgs));
expect(res.error).not.toBeUndefined();
});
test('should show error when user does not have permission', async () => {
const state = {
...initialState,
entities: {
...initialState.entities,
general: {
...initialState.entities.general,
config: {
...initialState.entities.general.config,
EnableMarketplace: 'true',
PluginsEnabled: 'true',
},
},
roles: {
...initialState.entities.roles,
roles: {
...initialState.entities.roles.roles,
custom_role: {
permissions: [],
},
},
},
},
};
store = await mockStore(state);
const res = await store.dispatch(executeCommand('/marketplace', [] as unknown as CommandArgs));
expect(res.error).not.toBeUndefined();
});
});
describe('app command', () => {
test('should call executeAppCall', async () => {
const state = {
...initialState,
entities: {
...initialState.entities,
general: {
...initialState.entities.general,
config: {
...initialState.entities.general.config,
FeatureFlagAppsEnabled: 'true',
},
},
},
};
store = await mockStore(state);
const f = Client4.executeAppCall;
const mocked = jest.fn().mockResolvedValue(Promise.resolve({
type: AppCallResponseTypes.OK,
markdown: 'Success',
}));
Client4.executeAppCall = mocked;
const result = await store.dispatch(executeCommand('/appid custom value1 --key2 value2', {channel_id: '123'}));
Client4.executeAppCall = f;
expect(mocked).toHaveBeenCalledWith({
context: {
app_id: 'appid',
channel_id: '123',
location: '/command/appid/custom',
root_id: '',
team_id: '456',
track_as_submit: true,
},
raw_command: '/appid custom value1 --key2 value2',
path: 'https://someserver.com/command',
values: {
key1: 'value1',
key2: {label: 'Value 2', value: 'value2'},
},
expand: {},
query: undefined,
selected_field: undefined,
}, true);
expect(result.data).toBeDefined();
});
});
});

View File

@@ -0,0 +1,221 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as UserAgent from '@mattermost/shared/utils/user_agent';
import type {AppCallResponse} from '@mattermost/types/apps';
import type {CommandArgs, CommandResponse} from '@mattermost/types/integrations';
import {IntegrationTypes} from 'mattermost-redux/action_types';
import {unfavoriteChannel} from 'mattermost-redux/actions/channels';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {Client4} from 'mattermost-redux/client';
import {Permissions} from 'mattermost-redux/constants';
import {AppCallResponseTypes} from 'mattermost-redux/constants/apps';
import {appsEnabled} from 'mattermost-redux/selectors/entities/apps';
import {getCurrentChannel, getRedirectChannelNameForTeam, isFavoriteChannel} from 'mattermost-redux/selectors/entities/channels';
import {isMarketplaceEnabled} from 'mattermost-redux/selectors/entities/general';
import {haveICurrentTeamPermission} from 'mattermost-redux/selectors/entities/roles';
import {getCurrentRelativeTeamUrl, getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import * as GlobalActions from 'actions/global_actions';
import * as PostActions from 'actions/post_actions';
import {openModal} from 'actions/views/modals';
import KeyboardShortcutsModal from 'components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal';
import LeaveChannelModal from 'components/leave_channel_modal';
import MarketplaceModal from 'components/plugin_marketplace/marketplace_modal';
import {AppCommandParser} from 'components/suggestion/command_provider/app_command_parser/app_command_parser';
import UserSettingsModal from 'components/user_settings/modal';
import {getHistory} from 'utils/browser_history';
import {Constants, ModalIdentifiers} from 'utils/constants';
import {getIntl} from 'utils/i18n';
import {applyIntegrationGotoLocation} from 'utils/integration_navigation';
import {isUrlSafe} from 'utils/url';
import {getUserIdFromChannelName} from 'utils/utils';
import type {ActionFuncAsync} from 'types/store';
import {doAppSubmit, openAppsModal, postEphemeralCallResponseForCommandArgs} from './apps';
export type ExecuteCommandReturnType = {
frontendHandled?: boolean;
silentFailureReason?: Error;
commandResponse?: CommandResponse;
appResponse?: AppCallResponse;
};
export function executeCommand(message: string, args: CommandArgs): ActionFuncAsync<ExecuteCommandReturnType> {
return async (dispatch, getState) => {
const intl = getIntl();
const state = getState();
let msg = message;
let cmdLength = msg.indexOf(' ');
if (cmdLength < 0) {
cmdLength = msg.length;
}
const cmd = msg.substring(0, cmdLength).toLowerCase();
msg = cmd + ' ' + msg.substring(cmdLength, msg.length).trim();
switch (cmd) {
case '/search':
dispatch(PostActions.searchForTerm(msg.substring(cmdLength + 1, msg.length)));
return {data: {frontendHandled: true}};
case '/shortcuts':
if (UserAgent.isMobile()) {
const error = {message: intl.formatMessage({id: 'create_post.shortcutsNotSupported', defaultMessage: 'Keyboard shortcuts are not supported on your device.'})};
return {error};
}
dispatch(openModal({modalId: ModalIdentifiers.KEYBOARD_SHORTCUTS_MODAL, dialogType: KeyboardShortcutsModal}));
return {data: {frontendHandled: true}};
case '/leave': {
// /leave command not supported in reply threads.
if (args.channel_id && args.root_id) {
dispatch(GlobalActions.sendEphemeralPost('/leave is not supported in reply threads. Use it in the center channel instead.', args.channel_id, args.root_id));
return {data: {frontendHandled: true}};
}
const channel = getCurrentChannel(state);
if (!channel) {
return {data: {silentFailureReason: new Error('cannot find current channel')}};
}
if (channel.type === Constants.PRIVATE_CHANNEL || channel.policy_enforced) {
dispatch(openModal({modalId: ModalIdentifiers.LEAVE_PRIVATE_CHANNEL_MODAL, dialogType: LeaveChannelModal, dialogProps: {channel}}));
return {data: {frontendHandled: true}};
}
if (
channel.type === Constants.DM_CHANNEL ||
channel.type === Constants.GM_CHANNEL
) {
const currentUserId = getCurrentUserId(state);
let name;
let category;
if (channel.type === Constants.DM_CHANNEL) {
name = getUserIdFromChannelName(channel);
category = Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW;
} else {
name = channel.id;
category = Constants.Preferences.CATEGORY_GROUP_CHANNEL_SHOW;
}
const currentTeamId = getCurrentTeamId(state);
const redirectChannel = getRedirectChannelNameForTeam(state, currentTeamId);
const teamUrl = getCurrentRelativeTeamUrl(state);
getHistory().push(`${teamUrl}/channels/${redirectChannel}`);
dispatch(savePreferences(currentUserId, [{category, name, user_id: currentUserId, value: 'false'}]));
if (isFavoriteChannel(state, channel.id)) {
dispatch(unfavoriteChannel(channel.id));
}
return {data: {frontendHandled: true}};
}
break;
}
case '/settings':
dispatch(openModal({modalId: ModalIdentifiers.USER_SETTINGS, dialogType: UserSettingsModal, dialogProps: {isContentProductSettings: true}}));
return {data: {frontendHandled: true}};
case '/marketplace':
// check if user has permissions to access the read plugins
if (!haveICurrentTeamPermission(state, Permissions.SYSCONSOLE_WRITE_PLUGINS)) {
return {error: {message: intl.formatMessage({id: 'marketplace_command.no_permission', defaultMessage: 'You do not have the appropriate permissions to access the marketplace.'})}};
}
// check config to see if marketplace is enabled
if (!isMarketplaceEnabled(state)) {
return {error: {message: intl.formatMessage({id: 'marketplace_command.disabled', defaultMessage: 'The marketplace is disabled. Please contact your System Administrator for details.'})}};
}
dispatch(openModal({modalId: ModalIdentifiers.PLUGIN_MARKETPLACE, dialogType: MarketplaceModal}));
return {data: {frontendHandled: true}};
case '/collapse':
case '/expand':
dispatch(PostActions.resetEmbedVisibility());
dispatch(PostActions.resetInlineImageVisibility());
}
if (appsEnabled(state)) {
const getGlobalState = () => getState();
const createErrorMessage = (errMessage: string) => {
return {error: {message: errMessage}};
};
const parser = new AppCommandParser({dispatch, getState: getGlobalState} as any, intl, args.channel_id, args.team_id, args.root_id);
if (parser.isAppCommand(msg)) {
try {
const {creq, errorMessage} = await parser.composeCommandSubmitCall(msg);
if (!creq) {
return createErrorMessage(errorMessage!);
}
const res = await dispatch(doAppSubmit(creq, intl));
if (res.error) {
const errorResponse = res.error;
return createErrorMessage(errorResponse.text || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
}));
}
const callResp = res.data!;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.text) {
dispatch(postEphemeralCallResponseForCommandArgs(callResp, callResp.text, args));
}
return {data: {appResponse: callResp}};
case AppCallResponseTypes.FORM:
if (callResp.form) {
dispatch(openAppsModal(callResp.form, creq.context));
}
return {data: {appResponse: callResp}};
case AppCallResponseTypes.NAVIGATE:
return {data: {appResponse: callResp}};
default:
return createErrorMessage(intl.formatMessage(
{
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
},
{
type: callResp.type,
},
));
}
} catch (err: any) {
const message = err.message || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
return createErrorMessage(message);
}
}
}
let data;
try {
data = await Client4.executeCommand(msg, args);
} catch (err) {
return {error: err};
}
const hasGotoLocation = data.goto_location && isUrlSafe(data.goto_location);
if (msg.trim() === '/logout') {
GlobalActions.emitUserLoggedOutEvent(hasGotoLocation ? data.goto_location : '/');
return {data: {response: data}};
}
if (data.trigger_id) {
dispatch({type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID, data: data.trigger_id});
}
if (hasGotoLocation) {
applyIntegrationGotoLocation(data.goto_location);
}
return {data: {response: data}};
};
}

View File

@@ -0,0 +1,293 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {SystemEmoji} from '@mattermost/types/emojis';
import * as PreferenceActions from 'mattermost-redux/actions/preferences';
import * as EmojiActions from 'actions/emoji_actions';
import {getRecentEmojisData, getEmojiMap} from 'selectors/emojis';
import mockStore from 'tests/test_store';
import type EmojiMap from 'utils/emoji_map';
const currentUserId = 'current_user_id';
const initialState = {
entities: {
users: {
currentUserId,
},
},
};
// The actions under test only ever call EmojiMap.get, so a plain Map stands in for one.
function mockEmojiMap(emojis: Array<[string, Partial<SystemEmoji>]>): EmojiMap {
return new Map(emojis) as unknown as EmojiMap;
}
jest.mock('selectors/emojis', () => ({
getRecentEmojisData: jest.fn(),
getEmojiMap: jest.fn(),
}));
jest.mock('mattermost-redux/actions/preferences', () => ({
savePreferences: (...args: unknown[]) => ({type: 'RECEIVED_PREFERENCES', args}),
}));
describe('Actions.Emojis', () => {
let store: ReturnType<typeof mockStore>;
beforeEach(async () => {
store = await mockStore(initialState);
});
test('Emoji alias is stored in recent emojis', async () => {
jest.mocked(getRecentEmojisData).mockImplementation(() => {
return [];
});
jest.mocked(getEmojiMap).mockImplementation(() => {
return mockEmojiMap([['grinning', {short_name: 'grinning'}]]);
});
const expectedActions = [{
type: 'RECEIVED_PREFERENCES',
args: [
'current_user_id',
[
{
category: 'recent_emojis',
name: 'current_user_id',
user_id: 'current_user_id',
value: JSON.stringify([
{
name: 'grinning',
usageCount: 1,
},
]),
},
],
],
}];
await store.dispatch(EmojiActions.addRecentEmoji('grinning'));
expect(store.getActions()).toEqual(expectedActions);
});
test('First alias is stored in recent emojis even if second alias used', async () => {
jest.mocked(getRecentEmojisData).mockImplementation(() => {
return [];
});
jest.mocked(getEmojiMap).mockImplementation(() => {
return mockEmojiMap([['thumbsup', {short_name: '+1'}]]);
});
const expectedActions = [{
type: 'RECEIVED_PREFERENCES',
args: [
'current_user_id',
[
{
category: 'recent_emojis',
name: 'current_user_id',
user_id: 'current_user_id',
value: JSON.stringify([
{
name: '+1',
usageCount: 1,
},
]),
},
],
],
}];
await store.dispatch(EmojiActions.addRecentEmoji('thumbsup'));
expect(store.getActions()).toEqual(expectedActions);
});
test('Invalid emoji are not stored in recents', async () => {
jest.mocked(getRecentEmojisData).mockImplementation(() => {
return [];
});
jest.mocked(getEmojiMap).mockImplementation(() => {
return mockEmojiMap([['smile', {short_name: 'smile'}]]);
});
const savePreferencesSpy = jest.spyOn(PreferenceActions, 'savePreferences').mockImplementation(() => {
return {date: true} as unknown as ReturnType<typeof PreferenceActions.savePreferences>;
});
await store.dispatch(EmojiActions.addRecentEmoji('gamgamstyle'));
expect(savePreferencesSpy).not.toHaveBeenCalled();
});
test('Emoji already present in recent should be bumped on the top', async () => {
jest.mocked(getRecentEmojisData).mockImplementation(() => {
return [
{name: 'smile', usageCount: 1},
{name: 'grinning', usageCount: 1},
{name: 'shell', usageCount: 1},
{name: 'ladder', usageCount: 1},
];
});
jest.mocked(getEmojiMap).mockImplementation(() => {
return mockEmojiMap([['grinning', {short_name: 'grinning'}]]);
});
const expectedActions = [{
type: 'RECEIVED_PREFERENCES',
args: [
'current_user_id',
[
{
category: 'recent_emojis',
name: 'current_user_id',
user_id: 'current_user_id',
value: JSON.stringify([
{name: 'smile', usageCount: 1},
{name: 'shell', usageCount: 1},
{name: 'ladder', usageCount: 1},
{name: 'grinning', usageCount: 2},
]),
},
],
],
}];
await store.dispatch(EmojiActions.addRecentEmoji('grinning'));
expect(store.getActions()).toEqual(expectedActions);
});
test('Recent list lenght should always be of size less than or equal to max_recent_size', async () => {
const recentEmojisList = [
{name: 'smile', usageCount: 1},
{name: 'grinning', usageCount: 1},
{name: 'shell', usageCount: 1},
{name: 'ladder', usageCount: 1},
{name: '1', usageCount: 1},
{name: '2', usageCount: 1},
{name: '3', usageCount: 1},
{name: '4', usageCount: 1},
{name: '5', usageCount: 1},
{name: '6', usageCount: 1},
{name: '7', usageCount: 1},
{name: '8', usageCount: 1},
{name: '9', usageCount: 1},
{name: '10', usageCount: 1},
{name: '11', usageCount: 1},
{name: '12', usageCount: 1},
{name: '13', usageCount: 1},
{name: '14', usageCount: 1},
{name: '15', usageCount: 1},
{name: '16', usageCount: 1},
{name: '17', usageCount: 1},
{name: '18', usageCount: 1},
{name: '19', usageCount: 1},
{name: '20', usageCount: 1},
{name: '21', usageCount: 1},
{name: '22', usageCount: 1},
{name: '23', usageCount: 1},
];
jest.mocked(getRecentEmojisData).mockImplementation(() => {
return recentEmojisList;
});
jest.mocked(getEmojiMap).mockImplementation(() => {
return mockEmojiMap([['accept', {short_name: 'accept'}]]);
});
const expectedActions = [{
type: 'RECEIVED_PREFERENCES',
args: [
'current_user_id',
[
{
category: 'recent_emojis',
name: 'current_user_id',
user_id: 'current_user_id',
value: JSON.stringify([
{name: 'grinning', usageCount: 1},
{name: 'shell', usageCount: 1},
{name: 'ladder', usageCount: 1},
{name: '1', usageCount: 1},
{name: '2', usageCount: 1},
{name: '3', usageCount: 1},
{name: '4', usageCount: 1},
{name: '5', usageCount: 1},
{name: '6', usageCount: 1},
{name: '7', usageCount: 1},
{name: '8', usageCount: 1},
{name: '9', usageCount: 1},
{name: '10', usageCount: 1},
{name: '11', usageCount: 1},
{name: '12', usageCount: 1},
{name: '13', usageCount: 1},
{name: '14', usageCount: 1},
{name: '15', usageCount: 1},
{name: '16', usageCount: 1},
{name: '17', usageCount: 1},
{name: '18', usageCount: 1},
{name: '19', usageCount: 1},
{name: '20', usageCount: 1},
{name: '21', usageCount: 1},
{name: '22', usageCount: 1},
{name: '23', usageCount: 1},
{name: 'accept', usageCount: 1},
]),
},
],
],
}];
await store.dispatch(EmojiActions.addRecentEmoji('accept'));
expect(store.getActions()).toEqual(expectedActions);
});
test('Should add multiple emojis to recent emojis at once', async () => {
const recentEmojisList = [
{name: 'trumpet', usageCount: 1},
{name: 'balloon', usageCount: 3},
{name: 'taco', usageCount: 4},
];
jest.mocked(getRecentEmojisData).mockImplementation(() => {
return recentEmojisList;
});
jest.mocked(getEmojiMap).mockImplementation(() => {
return mockEmojiMap([
['accept', {short_name: 'accept'}],
['balloon', {short_name: 'balloon'}],
['grinning', {short_name: 'grinning'}],
['taco', {short_name: 'taco'}],
['trumpet', {short_name: 'trumpet'}],
]);
});
const expectedActions = [{
type: 'RECEIVED_PREFERENCES',
args: [
'current_user_id',
[
{
category: 'recent_emojis',
name: 'current_user_id',
user_id: 'current_user_id',
value: JSON.stringify([
{name: 'trumpet', usageCount: 1},
{name: 'grinning', usageCount: 1},
{name: 'accept', usageCount: 2},
{name: 'taco', usageCount: 4},
{name: 'balloon', usageCount: 5},
]),
},
],
],
}];
await store.dispatch(EmojiActions.addRecentEmojis(['balloon', 'grinning', 'accept', 'balloon', 'accept']));
expect(store.getActions()).toEqual(expectedActions);
});
});

View File

@@ -0,0 +1,244 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Post} from '@mattermost/types/posts';
import type {IDMappedObjects} from '@mattermost/types/utilities';
import * as EmojiActions from 'mattermost-redux/actions/emojis';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {Preferences as ReduxPreferences} from 'mattermost-redux/constants';
import {getCustomEmojisByName as selectCustomEmojisByName, getCustomEmojisEnabled} from 'mattermost-redux/selectors/entities/emojis';
import {get} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getEmojiName} from 'mattermost-redux/utils/emoji_utils';
import {getEmojiMap, getRecentEmojisData, getRecentEmojisNames, isCustomEmojiEnabled} from 'selectors/emojis';
import {isCustomStatusEnabled, makeGetCustomStatus} from 'selectors/views/custom_status';
import LocalStorageStore from 'stores/local_storage_store';
import Constants, {ActionTypes, Preferences} from 'utils/constants';
import {EmojiIndicesByAlias} from 'utils/emoji';
import type {ActionFunc, ActionFuncAsync} from 'types/store';
export function loadRecentlyUsedCustomEmojis(): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
if (!getCustomEmojisEnabled(state)) {
return {data: true};
}
const recentEmojiNames = getRecentEmojisNames(state);
return dispatch(EmojiActions.getCustomEmojisByName(recentEmojiNames));
};
}
export function incrementEmojiPickerPage() {
return {
type: ActionTypes.INCREMENT_EMOJI_PICKER_PAGE,
};
}
export function setUserSkinTone(skin: string): ActionFuncAsync {
return (dispatch, getState) => {
const state = getState();
const currentUserId = getCurrentUserId(state);
const skinTonePreference = [{
user_id: currentUserId,
name: Preferences.EMOJI_SKINTONE,
category: Preferences.CATEGORY_EMOJI,
value: skin,
}];
return dispatch(savePreferences(currentUserId, skinTonePreference));
};
}
export function addRecentEmoji(alias: string) {
return addRecentEmojis([alias]);
}
export const MAXIMUM_RECENT_EMOJI = 27;
export function addRecentEmojis(aliases: string[]): ActionFunc {
return (dispatch, getState) => {
const state = getState();
const currentUserId = getCurrentUserId(state);
const recentEmojis = getRecentEmojisData(state);
const emojiMap = getEmojiMap(state);
let updatedRecentEmojis = [...recentEmojis];
for (const alias of aliases) {
const emoji = emojiMap.get(alias);
if (!emoji) {
continue;
}
const name = getEmojiName(emoji);
const currentEmojiIndexInRecentList = updatedRecentEmojis.findIndex((recentEmoji) => recentEmoji.name === name);
if (currentEmojiIndexInRecentList > -1) {
const currentEmojiInRecentList = updatedRecentEmojis[currentEmojiIndexInRecentList];
// If the emoji is already in the recent list, remove it and add it to the front with updated usage count
const updatedCurrentEmojiData = {
name,
usageCount: currentEmojiInRecentList.usageCount + 1,
};
updatedRecentEmojis.splice(currentEmojiIndexInRecentList, 1);
updatedRecentEmojis = [...updatedRecentEmojis, updatedCurrentEmojiData].slice(-MAXIMUM_RECENT_EMOJI);
} else {
const currentEmojiData = {
name,
usageCount: 1,
};
updatedRecentEmojis = [...updatedRecentEmojis, currentEmojiData].slice(-MAXIMUM_RECENT_EMOJI);
}
}
// sort emojis by count in the ascending order
updatedRecentEmojis.sort(
(emojiA, emojiB) => emojiA.usageCount - emojiB.usageCount,
);
dispatch(savePreferences(currentUserId, [{category: Constants.Preferences.RECENT_EMOJIS, name: currentUserId, user_id: currentUserId, value: JSON.stringify(updatedRecentEmojis)}]));
return {data: true};
};
}
export function loadCustomEmojisForCustomStatusesByUserIds(userIds: Set<string> | string[]): ActionFuncAsync {
const getCustomStatus = makeGetCustomStatus();
return async (dispatch, getState) => {
const state = getState();
const customEmojiEnabled = isCustomEmojiEnabled(state);
const customStatusEnabled = isCustomStatusEnabled(state);
if (!customEmojiEnabled || !customStatusEnabled) {
return {data: false};
}
const emojisToLoad = new Set<string>();
userIds.forEach((userId) => {
const customStatus = getCustomStatus(state, userId);
if (!customStatus || !customStatus.emoji) {
return;
}
emojisToLoad.add(customStatus.emoji);
});
return dispatch(loadCustomEmojisIfNeeded(Array.from(emojisToLoad)));
};
}
export function loadCustomEmojisForRecentCustomStatuses(): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const customEmojiEnabled = isCustomEmojiEnabled(state);
const customStatusEnabled = isCustomStatusEnabled(state);
if (!customEmojiEnabled || !customStatusEnabled) {
return {data: false};
}
const recentCustomStatusesValue = get(state, ReduxPreferences.CATEGORY_CUSTOM_STATUS, ReduxPreferences.NAME_RECENT_CUSTOM_STATUSES);
if (!recentCustomStatusesValue) {
return {data: false};
}
const recentCustomStatuses = JSON.parse(recentCustomStatusesValue);
const emojisToLoad = new Set<string>();
for (const customStatus of recentCustomStatuses) {
if (!customStatus || !customStatus.emoji) {
continue;
}
emojisToLoad.add(customStatus.emoji);
}
return dispatch(loadCustomEmojisIfNeeded(Array.from(emojisToLoad)));
};
}
export function loadCustomEmojisIfNeeded(emojis: string[]): ActionFuncAsync {
return async (dispatch, getState) => {
if (!emojis || emojis.length === 0) {
return {data: false};
}
const state = getState();
const customEmojiEnabled = isCustomEmojiEnabled(state);
if (!customEmojiEnabled) {
return {data: false};
}
const systemEmojis = EmojiIndicesByAlias;
const customEmojisByName = selectCustomEmojisByName(state);
const nonExistentCustomEmoji = state.entities.emojis.nonExistentEmoji;
const emojisToLoad: string[] = [];
emojis.forEach((emoji) => {
if (!emoji) {
return;
}
if (systemEmojis.has(emoji)) {
// It's a system emoji, no need to fetch
return;
}
if (nonExistentCustomEmoji.has(emoji)) {
// We've previously confirmed this is not a custom emoji
return;
}
if (customEmojisByName.has(emoji)) {
// We have the emoji, no need to fetch
return;
}
emojisToLoad.push(emoji);
});
return dispatch(EmojiActions.getCustomEmojisByName(emojisToLoad));
};
}
export function loadCustomStatusEmojisForPostList(posts: IDMappedObjects<Post>): ActionFuncAsync {
return async (dispatch) => {
if (!posts || Object.keys(posts).length === 0) {
return {data: false};
}
const userIds = new Set<string>();
Object.keys(posts).forEach((postId) => {
const post = posts[postId];
if (post.user_id) {
userIds.add(post.user_id);
}
});
return dispatch(loadCustomEmojisForCustomStatusesByUserIds(userIds));
};
}
export function migrateRecentEmojis(): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const currentUserId = getCurrentUserId(state);
const recentEmojisFromPreference = getRecentEmojisData(state);
if (recentEmojisFromPreference.length === 0) {
const recentEmojisFromLocalStorage = LocalStorageStore.getRecentEmojis(currentUserId);
if (recentEmojisFromLocalStorage) {
const parsedRecentEmojisFromLocalStorage = JSON.parse(recentEmojisFromLocalStorage) as string[];
const toSetRecentEmojiData = parsedRecentEmojisFromLocalStorage.map((emojiName) => ({name: emojiName, usageCount: 1}));
if (toSetRecentEmojiData.length > 0) {
dispatch(savePreferences(currentUserId, [{category: Constants.Preferences.RECENT_EMOJIS, name: currentUserId, user_id: currentUserId, value: JSON.stringify(toSetRecentEmojiData)}]));
}
return {data: parsedRecentEmojisFromLocalStorage};
}
}
return {data: recentEmojisFromPreference};
};
}

View File

@@ -0,0 +1,258 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import nock from 'nock';
import {BATCH} from 'redux-batched-actions';
import type {DeepPartial} from '@mattermost/types/utilities';
import {EmojiTypes} from 'mattermost-redux/action_types';
import {setSystemEmojis} from 'mattermost-redux/actions/emojis';
import {Client4} from 'mattermost-redux/client';
import * as Actions from 'actions/emoji_actions';
import mergeObjects from 'packages/mattermost-redux/test/merge_objects';
import mockStore from 'tests/test_store';
import {Preferences} from 'utils/constants';
import {EmojiIndicesByAlias} from 'utils/emoji';
import {TestHelper} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
Client4.setUrl('http://localhost:8065');
describe('loadRecentlyUsedCustomEmojis', () => {
const currentUserId = 'currentUserId';
const emoji1 = TestHelper.getCustomEmojiMock({name: 'emoji1', id: 'emojiId1'});
const emoji2 = TestHelper.getCustomEmojiMock({name: 'emoji2', id: 'emojiId2'});
const loadedEmoji = TestHelper.getCustomEmojiMock({name: 'loaded', id: 'loadedId'});
const initialState: DeepPartial<GlobalState> = {
entities: {
emojis: {
customEmoji: {
[loadedEmoji.id]: loadedEmoji,
},
nonExistentEmoji: new Set(),
},
general: {
config: {
EnableCustomEmoji: 'true',
},
},
users: {
currentUserId,
},
},
};
setSystemEmojis(new Set(EmojiIndicesByAlias.keys()));
test('should get requested emojis', async () => {
const state = mergeObjects(initialState, {
entities: {
preferences: {
myPreferences: TestHelper.getPreferencesMock([
{
category: Preferences.RECENT_EMOJIS,
name: currentUserId,
value: JSON.stringify([
{name: emoji1.name, usageCount: 3},
{name: emoji2.name, usageCount: 5},
]),
},
]),
},
},
});
const store = mockStore(state);
nock(Client4.getBaseRoute()).
post('/emoji/names', ['emoji1', 'emoji2']).
reply(200, [emoji1, emoji2]);
await store.dispatch(Actions.loadRecentlyUsedCustomEmojis());
expect(store.getActions()).toEqual([
{
type: EmojiTypes.RECEIVED_CUSTOM_EMOJIS,
data: [emoji1, emoji2],
},
]);
});
test('should not request emojis which are already loaded', async () => {
const state = mergeObjects(initialState, {
entities: {
preferences: {
myPreferences: TestHelper.getPreferencesMock([
{
category: Preferences.RECENT_EMOJIS,
name: currentUserId,
value: JSON.stringify([
{name: emoji1.name, usageCount: 3},
{name: loadedEmoji.name, usageCount: 5},
]),
},
]),
},
},
});
const store = mockStore(state);
nock(Client4.getBaseRoute()).
post('/emoji/names', ['emoji1']).
reply(200, [emoji1]);
await store.dispatch(Actions.loadRecentlyUsedCustomEmojis());
expect(store.getActions()).toEqual([
{
type: EmojiTypes.RECEIVED_CUSTOM_EMOJIS,
data: [emoji1],
},
]);
});
test('should not make a request if all recentl emojis are loaded', async () => {
const state = mergeObjects(initialState, {
entities: {
preferences: {
myPreferences: TestHelper.getPreferencesMock([
{
category: Preferences.RECENT_EMOJIS,
name: currentUserId,
value: JSON.stringify([
{name: loadedEmoji.name, usageCount: 5},
]),
},
]),
},
},
});
const store = mockStore(state);
await store.dispatch(Actions.loadRecentlyUsedCustomEmojis());
expect(store.getActions()).toEqual([]);
});
test('should properly store any names of nonexistent emojis', async () => {
const fakeEmojiName = 'fake-emoji-name';
const state = mergeObjects(initialState, {
entities: {
preferences: {
myPreferences: TestHelper.getPreferencesMock([
{
category: Preferences.RECENT_EMOJIS,
name: currentUserId,
value: JSON.stringify([
{name: emoji1.name, usageCount: 3},
{name: fakeEmojiName, usageCount: 5},
]),
},
]),
},
},
});
const store = mockStore(state);
nock(Client4.getBaseRoute()).
post('/emoji/names', ['emoji1', fakeEmojiName]).
reply(200, [emoji1]);
await store.dispatch(Actions.loadRecentlyUsedCustomEmojis());
expect(store.getActions()).toEqual([
{
type: BATCH,
meta: {
batch: true,
},
payload: [
{
type: EmojiTypes.RECEIVED_CUSTOM_EMOJIS,
data: [emoji1],
},
{
type: EmojiTypes.CUSTOM_EMOJI_DOES_NOT_EXIST,
data: fakeEmojiName,
},
],
},
]);
});
test('should not request an emoji that we know does not exist', async () => {
const fakeEmojiName = 'fake-emoji-name';
// Add nonExistentEmoji separately because mergeObjects only works with plain objects
const state = mergeObjects(initialState, {
entities: {
preferences: {
myPreferences: TestHelper.getPreferencesMock([
{
category: Preferences.RECENT_EMOJIS,
name: currentUserId,
value: JSON.stringify([
{name: emoji1.name, usageCount: 3},
{name: fakeEmojiName, usageCount: 5},
]),
},
]),
},
},
});
state.entities.emojis.nonExistentEmoji = new Set([fakeEmojiName]);
const store = mockStore(state);
nock(Client4.getBaseRoute()).
post('/emoji/names', ['emoji1']).
reply(200, [emoji1]);
await store.dispatch(Actions.loadRecentlyUsedCustomEmojis());
expect(store.getActions()).toEqual([
{
type: EmojiTypes.RECEIVED_CUSTOM_EMOJIS,
data: [emoji1],
},
]);
});
test('should not request a system emoji', async () => {
const state = mergeObjects(initialState, {
entities: {
preferences: {
myPreferences: TestHelper.getPreferencesMock([
{
category: Preferences.RECENT_EMOJIS,
name: currentUserId,
value: JSON.stringify([
{name: emoji1.name, usageCount: 3},
{name: 'taco', usageCount: 5},
]),
},
]),
},
},
});
const store = mockStore(state);
nock(Client4.getBaseRoute()).
post('/emoji/names', ['emoji1']).
reply(200, [emoji1]);
await store.dispatch(Actions.loadRecentlyUsedCustomEmojis());
expect(store.getActions()).toEqual([
{
type: EmojiTypes.RECEIVED_CUSTOM_EMOJIS,
data: [emoji1],
},
]);
});
});

View File

@@ -0,0 +1,89 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {uploadFile} from 'actions/file_actions';
import testConfigureStore from 'tests/test_store';
jest.mock('selectors/general', () => ({
...jest.requireActual('selectors/general'),
getConnectionId: jest.fn(() => ''),
}));
jest.mock('utils/utils', () => ({
...jest.requireActual('utils/utils'),
localizeMessage: jest.fn((descriptor: {id: string; defaultMessage: string}) => descriptor.defaultMessage || descriptor.id),
}));
class MockXMLHttpRequest {
status = 0;
readyState = 0;
response = '';
upload: {onprogress?: (event: ProgressEvent) => void} = {};
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
open = jest.fn();
setRequestHeader = jest.fn();
send = jest.fn();
}
describe('actions/file_actions', () => {
describe('uploadFile', () => {
const originalXHR = window.XMLHttpRequest;
let mockXhr: MockXMLHttpRequest;
beforeEach(() => {
mockXhr = new MockXMLHttpRequest();
window.XMLHttpRequest = jest.fn(() => mockXhr) as unknown as typeof XMLHttpRequest;
});
afterEach(() => {
window.XMLHttpRequest = originalXHR;
});
function startUpload(onError: jest.Mock) {
const store = testConfigureStore();
store.dispatch(uploadFile({
file: new File(['data'], 'secret.tdf'),
name: 'secret.tdf',
type: 'application/octet-stream',
rootId: 'root1',
channelId: 'channel1',
clientId: 'client1',
onProgress: jest.fn(),
onSuccess: jest.fn(),
onError,
}));
}
test('suppresses the inline error on plugin rejection by passing an empty message', () => {
const onError = jest.fn();
startUpload(onError);
mockXhr.status = 400;
mockXhr.readyState = 4;
mockXhr.response = JSON.stringify({
id: 'app.upload.run_plugins_hook.rejected',
message: 'Unable to upload the file. File rejected by plugin. blocked by policy',
});
mockXhr.onload!();
expect(onError).toHaveBeenCalledWith('', 'client1', 'channel1', 'root1');
});
test('passes the original error message for non-plugin upload failures', () => {
const onError = jest.fn();
startUpload(onError);
mockXhr.status = 400;
mockXhr.readyState = 4;
mockXhr.response = JSON.stringify({
id: 'api.file.upload_file.too_large.app_error',
message: 'File is too large',
});
mockXhr.onload!();
expect(onError).toHaveBeenCalledWith('File is too large', 'client1', 'channel1', 'root1');
});
});
});

View File

@@ -0,0 +1,169 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {batchActions} from 'redux-batched-actions';
import type {ServerError} from '@mattermost/types/errors';
import type {FileInfo} from '@mattermost/types/files';
import {FileTypes} from 'mattermost-redux/action_types';
import {getLogErrorAction} from 'mattermost-redux/actions/errors';
import {forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers';
import {Client4} from 'mattermost-redux/client';
import {getConnectionId} from 'selectors/general';
import type {FilePreviewInfo} from 'components/file_preview/file_preview';
import {localizeMessage} from 'utils/utils';
import type {ThunkActionFunc} from 'types/store';
export interface UploadFile {
file: File;
name: string;
type: string;
rootId: string;
channelId: string;
clientId: string;
onProgress: (filePreviewInfo: FilePreviewInfo) => void;
onSuccess: (data: any, channelId: string, rootId: string) => void;
onError: (err: string | ServerError, clientId: string, channelId: string, rootId: string) => void;
}
export function uploadFile({file, name, type, rootId, channelId, clientId, onProgress, onSuccess, onError}: UploadFile, isBookmark?: boolean): ThunkActionFunc<XMLHttpRequest> {
return (dispatch, getState) => {
dispatch({type: FileTypes.UPLOAD_FILES_REQUEST});
let url = Client4.getFilesRoute();
if (isBookmark) {
url += '?bookmark=true';
}
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
const client4Headers = Client4.getOptions({method: 'POST'}).headers;
Object.keys(client4Headers).forEach((client4Header) => {
const client4HeaderValue = client4Headers[client4Header];
if (client4HeaderValue) {
xhr.setRequestHeader(client4Header, client4HeaderValue);
}
});
xhr.setRequestHeader('Accept', 'application/json');
const connectionId = getConnectionId(getState());
if (connectionId) {
xhr.setRequestHeader('Connection-Id', connectionId);
}
const formData = new FormData();
formData.append('channel_id', channelId);
formData.append('client_ids', clientId);
formData.append('files', file, name); // appending file in the end for steaming support
if (onProgress && xhr.upload) {
xhr.upload.onprogress = (event) => {
const percent = Math.floor((event.loaded / event.total) * 100);
const filePreviewInfo = {
clientId,
name,
percent,
type,
} as FilePreviewInfo;
onProgress(filePreviewInfo);
};
}
if (onSuccess) {
xhr.onload = () => {
if (xhr.status === 201 && xhr.readyState === 4) {
const response = JSON.parse(xhr.response);
const data = response.file_infos.map((fileInfo: FileInfo, index: number) => {
return {
...fileInfo,
clientId: response.client_ids[index],
};
});
dispatch(batchActions([
{
type: FileTypes.RECEIVED_UPLOAD_FILES,
data,
channelId,
rootId,
},
{
type: FileTypes.UPLOAD_FILES_SUCCESS,
},
]));
onSuccess(response, channelId, rootId);
} else if (xhr.status >= 400 && xhr.readyState === 4) {
let errorMessage = '';
let pluginRejected = false;
try {
const errorResponse = JSON.parse(xhr.response);
// Plugin upload rejections are surfaced as a toast via the
// file_upload_rejected websocket event, so suppress the inline composer error.
pluginRejected = errorResponse?.id === 'app.upload.run_plugins_hook.rejected';
errorMessage =
(errorResponse?.id && errorResponse?.message) ? localizeMessage({id: errorResponse.id, defaultMessage: errorResponse.message}) : localizeMessage({id: 'file_upload.generic_error', defaultMessage: 'There was a problem uploading your files.'});
} catch {
errorMessage = localizeMessage({id: 'file_upload.generic_error', defaultMessage: 'There was a problem uploading your files.'});
}
dispatch({
type: FileTypes.UPLOAD_FILES_FAILURE,
clientIds: [clientId],
channelId,
rootId,
});
// Pass an empty message on plugin rejection so the in-progress upload is cleared
// without showing the inline error; the websocket-driven toast explains the rejection.
onError?.(pluginRejected ? '' : errorMessage, clientId, channelId, rootId);
}
};
}
if (onError) {
xhr.onerror = () => {
if (xhr.readyState === 4 && xhr.responseText.length !== 0) {
const errorResponse = JSON.parse(xhr.response);
forceLogoutIfNecessary(errorResponse, dispatch, getState);
const uploadFailureAction = {
type: FileTypes.UPLOAD_FILES_FAILURE,
clientIds: [clientId],
channelId,
rootId,
error: errorResponse,
};
dispatch(batchActions([uploadFailureAction, getLogErrorAction(errorResponse)]));
onError(errorResponse, clientId, channelId, rootId);
} else {
const errorMessage = xhr.status === 0 || !xhr.status ? localizeMessage({id: 'file_upload.generic_error', defaultMessage: 'There was a problem uploading your files.'}) : localizeMessage({id: 'channel_loader.unknown_error', defaultMessage: 'We received an unexpected status code from the server.'}) + ' (' + xhr.status + ')';
dispatch({
type: FileTypes.UPLOAD_FILES_FAILURE,
clientIds: [clientId],
channelId,
rootId,
});
onError({message: errorMessage}, clientId, channelId, rootId);
}
};
}
xhr.send(formData);
return xhr;
};
}

View File

@@ -0,0 +1,590 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Team} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import {redirectUserToDefaultTeam, toggleSideBarRightMenuAction, getTeamRedirectChannelIfIsAccesible} from 'actions/global_actions';
import {close as closeLhs} from 'actions/views/lhs';
import {closeRightHandSide, closeMenu as closeRhsMenu} from 'actions/views/rhs';
import LocalStorageStore from 'stores/local_storage_store';
import reduxStore from 'stores/redux_store';
import mockStore from 'tests/test_store';
import {getHistory} from 'utils/browser_history';
const getState = jest.mocked(reduxStore.getState);
jest.mock('actions/views/rhs', () => ({
closeMenu: jest.fn(),
closeRightHandSide: jest.fn(),
}));
jest.mock('actions/views/lhs', () => ({
close: jest.fn(),
}));
jest.mock('mattermost-redux/actions/users', () => ({
loadMe: () => ({type: 'MOCK_RECEIVED_ME'}),
}));
jest.mock('stores/redux_store', () => {
return {
dispatch: jest.fn(),
getState: jest.fn(),
};
});
describe('actions/global_actions', () => {
describe('redirectUserToDefaultTeam', () => {
it('should redirect to /select_team when no team is available', async () => {
const store = mockStore({
entities: {
general: {
config: {
DefaultClientLocale: 'en',
},
},
teams: {
teams: {},
myMembers: {},
},
channels: {
myMembers: {},
channels: {},
channelsInTeam: {},
},
users: {
currentUserId: 'user1',
profiles: {
user1: {
id: 'user1',
roles: '',
},
},
},
},
});
getState.mockImplementation(store.getState);
await redirectUserToDefaultTeam();
expect(getHistory().push).toHaveBeenCalledWith('/select_team');
});
it('should redirect to last viewed channel in the last viewed team when the user have access to that team', async () => {
const userId = 'user1';
LocalStorageStore.setPreviousTeamId(userId, 'team2');
LocalStorageStore.setPreviousChannelName(userId, 'team1', 'channel-in-team-1');
LocalStorageStore.setPreviousChannelName(userId, 'team2', 'channel-in-team-2');
const store = mockStore({
entities: {
general: {
config: {
DefaultClientLocale: 'en',
},
serverVersion: '5.16.0',
},
teams: {
teams: {
team1: {id: 'team1', display_name: 'Team 1', name: 'team1', delete_at: 0},
team2: {id: 'team2', display_name: 'Team 2', name: 'team2', delete_at: 0},
},
myMembers: {
team1: {team_id: 'team1'},
team2: {team_id: 'team2'},
},
},
channels: {
myMembers: {
'channel-in-team-1': {},
'channel-in-team-2': {},
},
channels: {
'channel-in-team-1': {
id: 'channel-in-team-1',
team_id: 'team1',
name: 'channel-in-team-1',
},
'channel-in-team-2': {
id: 'channel-in-team-2',
team_id: 'team2',
name: 'channel-in-team-2',
},
},
channelsInTeam: {
team1: new Set(['channel-in-team-1']),
team2: new Set(['channel-in-team-2']),
},
},
users: {
currentUserId: userId,
profiles: {
[userId]: {id: userId, roles: 'system_guest'},
},
},
roles: {
roles: {
system_guest: {
permissions: [],
},
team_guest: {
permissions: [],
},
channel_guest: {
permissions: [],
},
},
},
},
});
getState.mockImplementation(store.getState);
await redirectUserToDefaultTeam();
expect(getHistory().push).toHaveBeenCalledWith('/team2/channels/channel-in-team-2');
});
it('should redirect to last channel on first team with channels when the user have no channels in the current team', async () => {
const userId = 'user1';
LocalStorageStore.setPreviousTeamId(userId, 'team1');
LocalStorageStore.setPreviousChannelName(userId, 'team1', 'channel-in-team-1');
LocalStorageStore.setPreviousChannelName(userId, 'team2', 'channel-in-team-2');
const store = mockStore({
entities: {
general: {
config: {
DefaultClientLocale: 'en',
},
serverVersion: '5.16.0',
},
teams: {
teams: {
team1: {id: 'team1', display_name: 'Team 1', name: 'team1', delete_at: 0},
team2: {id: 'team2', display_name: 'Team 2', name: 'team2', delete_at: 0},
},
myMembers: {
team1: {team_id: 'team1'},
team2: {team_id: 'team2'},
},
},
channels: {
myMembers: {
'channel-in-team-2': {},
},
channels: {
'channel-in-team-1': {
id: 'channel-in-team-1',
team_id: 'team1',
name: 'channel-in-team-1',
},
'channel-in-team-2': {
id: 'channel-in-team-2',
team_id: 'team2',
name: 'channel-in-team-2',
},
},
channelsInTeam: {
team1: new Set(['channel-in-team-1']),
team2: new Set(['channel-in-team-2']),
},
},
users: {
currentUserId: userId,
profiles: {
[userId]: {id: userId, roles: 'system_guest'},
},
},
roles: {
roles: {
system_guest: {
permissions: [],
},
team_guest: {
permissions: [],
},
channel_guest: {
permissions: [],
},
},
},
},
});
getState.mockImplementation(store.getState);
await redirectUserToDefaultTeam();
expect(getHistory().push).toHaveBeenCalledWith('/team2/channels/channel-in-team-2');
});
it('should redirect to /select_team when the user have no channels in the any of his teams', async () => {
const userId = 'user1';
LocalStorageStore.setPreviousTeamId(userId, 'team1');
LocalStorageStore.setPreviousChannelName(userId, 'team1', 'channel-in-team-1');
LocalStorageStore.setPreviousChannelName(userId, 'team2', 'channel-in-team-2');
const store = mockStore({
entities: {
general: {
config: {
DefaultClientLocale: 'en',
},
serverVersion: '5.16.0',
},
teams: {
teams: {
team1: {id: 'team1', display_name: 'Team 1', name: 'team1', delete_at: 0},
team2: {id: 'team2', display_name: 'Team 2', name: 'team2', delete_at: 0},
},
myMembers: {
team1: {team_id: 'team1'},
team2: {team_id: 'team2'},
},
},
channels: {
myMembers: {
},
channels: {
'channel-in-team-1': {
id: 'channel-in-team-1',
team_id: 'team1',
name: 'channel-in-team-1',
},
'channel-in-team-2': {
id: 'channel-in-team-2',
team_id: 'team2',
name: 'channel-in-team-2',
},
},
channelsInTeam: {
team1: new Set(['channel-in-team-1']),
team2: new Set(['channel-in-team-2']),
},
},
users: {
currentUserId: userId,
profiles: {
[userId]: {id: userId, roles: 'system_guest'},
},
},
roles: {
roles: {
system_guest: {
permissions: [],
},
team_guest: {
permissions: [],
},
channel_guest: {
permissions: [],
},
},
},
},
});
getState.mockImplementation(store.getState);
await redirectUserToDefaultTeam();
expect(getHistory().push).toHaveBeenCalledWith('/select_team');
});
it('should do nothing if there is not current user', async () => {
const store = mockStore({
entities: {
general: {
config: {
DefaultClientLocale: 'en',
},
},
teams: {
teams: {
team1: {id: 'team1', display_name: 'Team 1', name: 'team1', delete_at: 0},
team2: {id: 'team2', display_name: 'Team 2', name: 'team2', delete_at: 0},
},
myMembers: {
team1: {team_id: 'team1'},
team2: {team_id: 'team2'},
},
},
users: {
profiles: {
user1: {id: 'user1', roles: 'system_guest'},
},
},
},
});
getState.mockImplementation(store.getState);
await redirectUserToDefaultTeam();
expect(getHistory().push).not.toHaveBeenCalled();
});
it('should redirect to direct message if that\'s the most recently used', async () => {
const userId = 'user1';
const teamId = 'team1';
const user2 = 'user2';
const directChannelId = `${userId}__${user2}`;
const store = mockStore({
entities: {
general: {
config: {
DefaultClientLocale: 'en',
TeammateNameDisplay: 'username',
},
serverVersion: '5.16.0',
},
preferences: {
myPreferences: {},
},
teams: {
teams: {
team1: {id: 'team1', display_name: 'Team 1', name: 'team1', delete_at: 0},
team2: {id: 'team2', display_name: 'Team 2', name: 'team2', delete_at: 0},
},
myMembers: {
team1: {team_id: 'team1'},
team2: {team_id: 'team2'},
},
},
channels: {
myMembers: {
'channel-in-team-1': {},
'channel-in-team-2': {},
[directChannelId]: {},
},
channels: {
'channel-in-team-1': {
id: 'channel-in-team-1',
team_id: 'team1',
name: 'channel-in-team-1',
type: 'O',
},
'channel-in-team-2': {
id: 'channel-in-team-2',
team_id: 'team2',
name: 'channel-in-team-2',
type: 'O',
},
[directChannelId]: {
id: directChannelId,
team_id: '',
name: directChannelId,
type: 'D',
teammate_id: 'user2',
},
'group-channel': {
id: 'group-channel',
name: 'group-channel',
team_id: 'team1',
type: 'G',
},
},
channelsInTeam: {
team1: new Set(['channel-in-team-1', directChannelId]),
team2: new Set(['channel-in-team-2']),
},
},
users: {
currentUserId: userId,
profiles: {
[userId]: {id: userId, username: userId, roles: 'system_guest'},
[user2]: {id: user2, username: user2, roles: 'system_guest'},
},
},
roles: {
roles: {
system_guest: {
permissions: [],
},
team_guest: {
permissions: [],
},
channel_guest: {
permissions: [],
},
},
},
},
});
getState.mockImplementation(store.getState);
LocalStorageStore.setPreviousTeamId(userId, teamId);
LocalStorageStore.setPreviousChannelName(userId, teamId, directChannelId);
const result = await getTeamRedirectChannelIfIsAccesible({id: userId} as UserProfile, {id: teamId} as Team);
expect(result?.id).toBe(directChannelId);
});
it('should redirect to group message if that\'s the most recently used', async () => {
const userId = 'user1';
const teamId = 'team1';
const user2 = 'user2';
const directChannelId = `${userId}__${user2}`;
const groupChannelId = 'group-channel';
const store = mockStore({
entities: {
general: {
config: {
DefaultClientLocale: 'en',
TeammateNameDisplay: 'username',
},
serverVersion: '5.16.0',
},
preferences: {
myPreferences: {},
},
teams: {
teams: {
team1: {id: 'team1', display_name: 'Team 1', name: 'team1', delete_at: 0},
team2: {id: 'team2', display_name: 'Team 2', name: 'team2', delete_at: 0},
},
myMembers: {
team1: {team_id: 'team1'},
team2: {team_id: 'team2'},
},
},
channels: {
myMembers: {
'channel-in-team-1': {},
'channel-in-team-2': {},
[directChannelId]: {},
[groupChannelId]: {},
},
channels: {
'channel-in-team-1': {
id: 'channel-in-team-1',
team_id: 'team1',
name: 'channel-in-team-1',
type: 'O',
},
'channel-in-team-2': {
id: 'channel-in-team-2',
team_id: 'team2',
name: 'channel-in-team-2',
type: 'O',
},
[directChannelId]: {
id: directChannelId,
team_id: '',
name: directChannelId,
type: 'D',
teammate_id: 'user2',
},
[groupChannelId]: {
id: groupChannelId,
name: groupChannelId,
team_id: 'team1',
type: 'G',
},
},
channelsInTeam: {
team1: new Set(['channel-in-team-1', directChannelId, groupChannelId]),
team2: new Set(['channel-in-team-2']),
},
},
users: {
currentUserId: userId,
profiles: {
[userId]: {id: userId, username: userId, roles: 'system_guest'},
[user2]: {id: user2, username: user2, roles: 'system_guest'},
},
},
roles: {
roles: {
system_guest: {
permissions: [],
},
team_guest: {
permissions: [],
},
channel_guest: {
permissions: [],
},
},
},
},
});
getState.mockImplementation(store.getState);
LocalStorageStore.setPreviousTeamId(userId, teamId);
LocalStorageStore.setPreviousChannelName(userId, teamId, groupChannelId);
const result = await getTeamRedirectChannelIfIsAccesible({id: userId} as UserProfile, {id: teamId} as Team);
expect(result?.id).toBe(groupChannelId);
});
it('should redirect to last channel on first team when current team is no longer available', async () => {
const userId = 'user1';
LocalStorageStore.setPreviousTeamId(userId, 'non-existent');
LocalStorageStore.setPreviousChannelName(userId, 'team1', 'channel-in-team-1');
LocalStorageStore.setPreviousChannelName(userId, 'team2', 'channel-in-team-2');
const store = mockStore({
entities: {
general: {
config: {
DefaultClientLocale: 'en',
},
},
teams: {
teams: {
team1: {id: 'team1', display_name: 'Team 1', name: 'team1', delete_at: 0},
team2: {id: 'team2', display_name: 'Team 2', name: 'team2', delete_at: 0},
},
myMembers: {
team1: {team_id: 'team1'},
team2: {team_id: 'team2'},
},
},
channels: {
myMembers: {
'channel-in-team-1': {},
'channel-in-team-2': {},
},
channels: {
'channel-in-team-1': {
id: 'channel-in-team-1',
team_id: 'team1',
name: 'channel-in-team-1',
},
'channel-in-team-2': {
id: 'channel-in-team-2',
team_id: 'team2',
name: 'channel-in-team-2',
},
},
channelsInTeam: {
team1: new Set(['channel-in-team-1']),
team2: new Set(['channel-in-team-2']),
},
},
users: {
currentUserId: userId,
profiles: {
[userId]: {id: userId, roles: ''},
},
},
},
});
getState.mockImplementation(store.getState);
await redirectUserToDefaultTeam();
expect(getHistory().push).toHaveBeenCalledWith('/team1/channels/channel-in-team-1');
});
});
test('toggleSideBarRightMenuAction', () => {
const dispatchMock = (arg: any) => {
if (typeof arg === 'function') {
arg(dispatchMock);
}
};
dispatchMock(toggleSideBarRightMenuAction());
expect(closeRhsMenu).toHaveBeenCalled();
expect(closeRightHandSide).toHaveBeenCalled();
expect(closeLhs).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,437 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {batchActions} from 'redux-batched-actions';
import type {Channel, ChannelMembership} from '@mattermost/types/channels';
import type {Post, PostType} from '@mattermost/types/posts';
import type {Team} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import {ChannelTypes} from 'mattermost-redux/action_types';
import {fetchAppBindings} from 'mattermost-redux/actions/apps';
import {
fetchChannelsAndMembers,
getChannelByNameAndTeamName,
getChannelStats,
selectChannel,
} from 'mattermost-redux/actions/channels';
import {fetchTeamScheduledPosts} from 'mattermost-redux/actions/scheduled_posts';
import {logout, loadMe} from 'mattermost-redux/actions/users';
import {Preferences} from 'mattermost-redux/constants';
import {appsEnabled} from 'mattermost-redux/selectors/entities/apps';
import {getCurrentChannelStats, getCurrentChannelId, getMyChannelMember, getRedirectChannelNameForTeam, getChannelsNameMapInTeam, getAllDirectChannels, getChannelMessageCount} from 'mattermost-redux/selectors/entities/channels';
import {getConfig, isPerformanceDebuggingEnabled} from 'mattermost-redux/selectors/entities/general';
import {getBool, getIsOnboardingFlowEnabled, isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {isScheduledPostsEnabled} from 'mattermost-redux/selectors/entities/scheduled_posts';
import {getCurrentTeamId, getMyTeams, getTeam, getMyTeamMember, getTeamMemberships, getActiveTeamsList} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUser, getCurrentUserId, isFirstAdmin} from 'mattermost-redux/selectors/entities/users';
import {calculateUnreadCount} from 'mattermost-redux/utils/channel_utils';
import {handleNewPost} from 'actions/post_actions';
import {loadProfilesForSidebar} from 'actions/user_actions';
import {clearUserCookie} from 'actions/views/cookie';
import {close as closeLhs} from 'actions/views/lhs';
import {closeRightHandSide, closeMenu as closeRhsMenu, updateRhsState} from 'actions/views/rhs';
import * as WebsocketActions from 'actions/websocket_actions';
import {getCurrentLocale} from 'selectors/i18n';
import {getIsRhsOpen, getPreviousRhsState, getRhsState} from 'selectors/rhs';
import BrowserStore from 'stores/browser_store';
import LocalStorageStore from 'stores/local_storage_store';
import store from 'stores/redux_store';
import SubMenuModal from 'components/widgets/menu/menu_modals/submenu_modal/submenu_modal';
import WebSocketClient from 'client/web_websocket_client';
import {getHistory} from 'utils/browser_history';
import {ActionTypes, PostTypes, RHSStates, ModalIdentifiers, PreviousViewedTypes} from 'utils/constants';
import DesktopApp from 'utils/desktop_api';
import {filterAndSortTeamsByDisplayName} from 'utils/team_utils';
import * as Utils from 'utils/utils';
import type {ActionFuncAsync, ThunkActionFunc, GlobalState} from 'types/store';
import {openModal} from './views/modals';
const dispatch = store.dispatch;
const getState = store.getState;
export function emitChannelClickEvent(channel: Channel) {
function switchToChannel(chan: Channel) {
const state = getState();
const userId = getCurrentUserId(state);
const teamId = chan.team_id || getCurrentTeamId(state);
const isRHSOpened = getIsRhsOpen(state);
const isPinnedPostsShowing = getRhsState(state) === RHSStates.PIN;
const isChannelFilesShowing = getRhsState(state) === RHSStates.CHANNEL_FILES;
const member = getMyChannelMember(state, chan.id);
const currentChannelId = getCurrentChannelId(state);
const previousRhsState = getPreviousRhsState(state);
dispatch(getChannelStats(chan.id));
const penultimate = LocalStorageStore.getPreviousChannelName(userId, teamId);
const penultimateType = LocalStorageStore.getPreviousViewedType(userId, teamId);
if (penultimate !== chan.name) {
LocalStorageStore.setPenultimateChannelName(userId, teamId, penultimate);
LocalStorageStore.setPreviousChannelName(userId, teamId, chan.name);
}
if (penultimateType !== PreviousViewedTypes.CHANNELS || penultimate !== chan.name) {
LocalStorageStore.setPreviousViewedType(userId, teamId, PreviousViewedTypes.CHANNELS);
LocalStorageStore.setPenultimateViewedType(userId, teamId, penultimateType);
}
// When switching to a different channel if the pinned posts is showing
// Update the RHS state to reflect the pinned post of the selected channel
if (isRHSOpened && isPinnedPostsShowing) {
dispatch(updateRhsState(RHSStates.PIN, chan.id, previousRhsState));
}
if (isRHSOpened && isChannelFilesShowing) {
dispatch(updateRhsState(RHSStates.CHANNEL_FILES, chan.id, previousRhsState));
}
if (currentChannelId) {
loadProfilesForSidebar();
}
dispatch(batchActions([
{
type: ChannelTypes.SELECT_CHANNEL,
data: chan.id,
},
{
type: ActionTypes.SELECT_CHANNEL_WITH_MEMBER,
data: chan.id,
channel: chan,
member: member || {},
},
setLastUnreadChannel(state, chan),
]));
if (appsEnabled(state)) {
dispatch(fetchAppBindings(chan.id));
}
}
switchToChannel(channel);
}
function setLastUnreadChannel(state: GlobalState, channel: Channel) {
const member = getMyChannelMember(state, channel.id);
const messageCount = getChannelMessageCount(state, channel.id);
let hadMentions = false;
let hadUnreads = false;
if (member && messageCount) {
const crtEnabled = isCollapsedThreadsEnabled(state);
const unreadCount = calculateUnreadCount(messageCount, member, crtEnabled);
hadMentions = unreadCount.mentions > 0;
hadUnreads = unreadCount.showUnread && unreadCount.messages > 0;
}
return {
type: ActionTypes.SET_LAST_UNREAD_CHANNEL,
channelId: channel.id,
hadMentions,
hadUnreads,
};
}
export const clearLastUnreadChannel = {
type: ActionTypes.SET_LAST_UNREAD_CHANNEL,
channelId: '',
};
export function updateNewMessagesAtInChannel(channelId: string, lastViewedAt = Date.now()) {
return {
type: ActionTypes.UPDATE_CHANNEL_LAST_VIEWED_AT,
channel_id: channelId,
last_viewed_at: lastViewedAt,
};
}
export function emitCloseRightHandSide() {
dispatch(closeRightHandSide());
}
export function showMobileSubMenuModal(elements: any[]) { // TODO Use more specific type
const submenuModalData = {
modalId: ModalIdentifiers.MOBILE_SUBMENU,
dialogType: SubMenuModal,
dialogProps: {
elements,
},
};
dispatch(openModal(submenuModalData));
}
export function sendEphemeralPost(message: string, channelId?: string, parentId?: string, userId?: string): ActionFuncAsync<boolean> {
return (doDispatch, doGetState) => {
const timestamp = Utils.getTimestamp();
const post: Post = {
id: Utils.generateId(),
user_id: userId || '0',
channel_id: channelId || getCurrentChannelId(doGetState()),
message,
type: PostTypes.EPHEMERAL as PostType,
create_at: timestamp,
update_at: timestamp,
root_id: parentId || '',
props: {},
delete_at: 0,
edit_at: 0,
is_pinned: false,
hashtags: '',
metadata: {
embeds: [],
emojis: [],
files: [],
images: {},
},
original_id: '',
pending_post_id: '',
reply_count: 0,
};
return doDispatch(handleNewPost(post));
};
}
export function sendAddToChannelEphemeralPost(user: UserProfile, addedUsername: string, addedUserId: string, channelId: string, postRootId = '', timestamp: number) {
const post: Post = {
id: Utils.generateId(),
user_id: user.id,
channel_id: channelId || getCurrentChannelId(getState()),
message: '',
type: PostTypes.EPHEMERAL_ADD_TO_CHANNEL as PostType,
create_at: timestamp,
update_at: timestamp,
root_id: postRootId,
props: {
username: user.username,
addedUsername,
addedUserId,
},
edit_at: 0,
delete_at: 0,
is_pinned: false,
original_id: '',
pending_post_id: '',
reply_count: 0,
metadata: {
embeds: [],
emojis: [],
files: [],
images: {},
},
hashtags: '',
};
dispatch(handleNewPost(post));
}
let lastTimeTypingSent = 0;
export function emitLocalUserTypingEvent(channelId: string, parentPostId: string) {
const userTyping: ActionFuncAsync = async (actionDispatch, actionGetState) => {
const state = actionGetState();
const config = getConfig(state);
if (
isPerformanceDebuggingEnabled(state) &&
getBool(state, Preferences.CATEGORY_PERFORMANCE_DEBUGGING, Preferences.NAME_DISABLE_TYPING_MESSAGES)
) {
return {data: false};
}
const t = Date.now();
const stats = getCurrentChannelStats(state);
const membersInChannel = stats ? stats.member_count : 0;
const timeBetweenUserTypingUpdatesMilliseconds = Utils.stringToNumber(config.TimeBetweenUserTypingUpdatesMilliseconds);
const maxNotificationsPerChannel = Utils.stringToNumber(config.MaxNotificationsPerChannel);
if (((t - lastTimeTypingSent) > timeBetweenUserTypingUpdatesMilliseconds) &&
(membersInChannel < maxNotificationsPerChannel) && (config.EnableUserTypingMessages === 'true')) {
WebSocketClient.userTyping(channelId, parentPostId);
lastTimeTypingSent = t;
}
return {data: true};
};
return dispatch(userTyping);
}
export function emitUserLoggedOutEvent(redirectTo = '/', shouldSignalLogout = true, userAction = true) {
// If the logout was intentional, discard knowledge about having previously been logged in.
// This bit is otherwise used to detect session expirations on the login page.
if (userAction) {
LocalStorageStore.setWasLoggedIn(false);
}
dispatch(logout()).then(() => {
if (shouldSignalLogout) {
BrowserStore.signalLogout();
DesktopApp.signalLogout();
}
BrowserStore.clearHideNotificationPermissionRequestBanner();
WebsocketActions.close();
clearUserCookie();
getHistory().push(redirectTo);
}).catch(() => {
getHistory().push(redirectTo);
});
}
export function toggleSideBarRightMenuAction(): ThunkActionFunc<void> {
return (doDispatch) => {
doDispatch(closeRightHandSide());
doDispatch(closeLhs());
doDispatch(closeRhsMenu());
};
}
export function emitBrowserFocus(focus: boolean) {
dispatch({
type: ActionTypes.BROWSER_CHANGE_FOCUS,
focus,
});
}
export async function getTeamRedirectChannelIfIsAccesible(user: UserProfile, team: Team) {
let state = getState();
let channel = null;
const myMember = getMyTeamMember(state, team.id);
if (!myMember || Object.keys(myMember).length === 0) {
return null;
}
let teamChannels = getChannelsNameMapInTeam(state, team.id);
if (!teamChannels || Object.keys(teamChannels).length === 0) {
// This should be executed in pretty limited scenarios (empty teams)
await dispatch(fetchChannelsAndMembers(team.id)); // eslint-disable-line no-await-in-loop
state = getState();
teamChannels = getChannelsNameMapInTeam(state, team.id);
}
const channelName = LocalStorageStore.getPreviousChannelName(user.id, team.id);
channel = teamChannels[channelName];
if (typeof channel === 'undefined') {
const dmList = getAllDirectChannels(state);
channel = dmList.find((directChannel) => directChannel.name === channelName);
}
let channelMember: ChannelMembership | undefined;
if (channel) {
channelMember = getMyChannelMember(state, channel.id);
}
if (!channel || !channelMember) {
// This should be executed in pretty limited scenarios (when the last visited channel in the team has been removed)
await dispatch(getChannelByNameAndTeamName(team.name, channelName)); // eslint-disable-line no-await-in-loop
state = getState();
teamChannels = getChannelsNameMapInTeam(state, team.id);
channel = teamChannels[channelName];
channelMember = getMyChannelMember(state, channel && channel.id);
}
if (!channel || !channelMember) {
const redirectedChannelName = getRedirectChannelNameForTeam(state, team.id);
channel = teamChannels[redirectedChannelName];
channelMember = getMyChannelMember(state, channel && channel.id);
}
if (channel && channelMember) {
return channel;
}
return null;
}
function historyPushWithQueryParams(path: string, queryParams?: URLSearchParams) {
if (queryParams) {
getHistory().push({
pathname: path,
search: queryParams.toString(),
});
} else {
getHistory().push(path);
}
}
export async function redirectUserToDefaultTeam(searchParams?: URLSearchParams) {
let state = getState();
// Assume we need to load the user if they don't have any team memberships loaded or the user loaded
let user = getCurrentUser(state);
const shouldLoadUser = Utils.isEmptyObject(getTeamMemberships(state)) || !user;
const onboardingFlowEnabled = getIsOnboardingFlowEnabled(state);
if (shouldLoadUser) {
await dispatch(loadMe());
state = getState();
user = getCurrentUser(state);
}
if (!user) {
return;
}
// if the user is the first admin
const isUserFirstAdmin = isFirstAdmin(state);
const locale = getCurrentLocale(state);
const teamId = LocalStorageStore.getPreviousTeamId(user.id);
let myTeams = getMyTeams(state);
const teams = getActiveTeamsList(state);
if (teams.length === 0) {
if (isUserFirstAdmin && onboardingFlowEnabled) {
historyPushWithQueryParams('/preparing-workspace', searchParams);
return;
}
historyPushWithQueryParams('/select_team', searchParams);
return;
}
let team: Team | undefined;
if (teamId) {
team = getTeam(state, teamId);
}
if (team && team.delete_at === 0) {
const channel = await getTeamRedirectChannelIfIsAccesible(user, team);
if (channel) {
if (isScheduledPostsEnabled(state)) {
dispatch(fetchTeamScheduledPosts(team.id, true));
}
dispatch(selectChannel(channel.id));
historyPushWithQueryParams(`/${team.name}/channels/${channel.name}`, searchParams);
return;
}
}
myTeams = filterAndSortTeamsByDisplayName(myTeams, locale);
for (const myTeam of myTeams) {
// This should execute async behavior in a pretty limited set of situations, so shouldn't be a problem
const channel = await getTeamRedirectChannelIfIsAccesible(user, myTeam); // eslint-disable-line no-await-in-loop
if (channel) {
dispatch(selectChannel(channel.id));
historyPushWithQueryParams(`/${myTeam.name}/channels/${channel.name}`, searchParams);
return;
}
}
historyPushWithQueryParams('/select_team', searchParams);
}

View File

@@ -0,0 +1,771 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Channel} from '@mattermost/types/channels';
import type {CommandArgs} from '@mattermost/types/integrations';
import type {Post} from '@mattermost/types/posts';
import {General} from 'mattermost-redux/constants';
import mockStore from 'tests/test_store';
import {
runDesktopNotificationHooks,
runMessageWillBePostedHooks,
runMessageWillBeUpdatedHooks,
runSlashCommandWillBePostedHooks,
} from './hooks';
import type {NewPostMessageProps} from './new_post';
describe('runMessageWillBePostedHooks', () => {
test('should do nothing when no hooks are registered', async () => {
const store = mockStore({
plugins: {
components: {},
},
});
const post = {message: 'test'} as Post;
const result = await store.dispatch(runMessageWillBePostedHooks(post));
expect(result).toEqual({data: post});
});
test('should pass the post through every hook', async () => {
const hook1 = jest.fn((post) => ({post}));
const hook2 = jest.fn((post) => ({post}));
const hook3 = jest.fn((post) => ({post}));
const store = mockStore({
plugins: {
components: {
MessageWillBePosted: [
{hook: hook1},
{hook: hook2},
{hook: hook3},
],
},
},
});
const post = {message: 'test'} as Post;
const result = await store.dispatch(runMessageWillBePostedHooks(post));
expect(result).toEqual({data: post});
expect(hook1).toHaveBeenCalledWith(post);
expect(hook2).toHaveBeenCalledWith(post);
expect(hook3).toHaveBeenCalledWith(post);
});
test('should return an error when a hook rejects the post', async () => {
const hook1 = jest.fn((post) => ({post}));
const hook2 = jest.fn(() => ({error: {message: 'an error occurred'}}));
const hook3 = jest.fn((post) => ({post}));
const store = mockStore({
plugins: {
components: {
MessageWillBePosted: [
{hook: hook1},
{hook: hook2},
{hook: hook3},
],
},
},
});
const post = {message: 'test'} as Post;
const result = await store.dispatch(runMessageWillBePostedHooks(post));
expect(result).toEqual({error: {message: 'an error occurred'}});
expect(hook1).toHaveBeenCalledWith(post);
expect(hook2).toHaveBeenCalledWith(post);
expect(hook3).not.toHaveBeenCalled();
});
test('should pass the result of each hook to the next', async () => {
const hook1 = jest.fn((post) => ({post: {...post, message: post.message + 'a'}}));
const hook2 = jest.fn((post) => ({post: {...post, message: post.message + 'b'}}));
const hook3 = jest.fn((post) => ({post: {...post, message: post.message + 'c'}}));
const store = mockStore({
plugins: {
components: {
MessageWillBePosted: [
{hook: hook1},
{hook: hook2},
{hook: hook3},
],
},
},
});
const post = {message: 'test'} as Post;
const result = await store.dispatch(runMessageWillBePostedHooks(post));
expect(result).toEqual({data: {message: 'testabc'}});
expect(hook1).toHaveBeenCalledWith(post);
expect(hook2).toHaveBeenCalled();
expect(hook2).not.toHaveBeenCalledWith(post);
expect(hook3).toHaveBeenCalled();
expect(hook3).not.toHaveBeenCalledWith(post);
});
test('should wait for async hooks', async () => {
jest.useFakeTimers();
const hook = jest.fn((post) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({post: {...post, message: post.message + 'async'}});
}, 100);
jest.runOnlyPendingTimers();
});
});
const store = mockStore({
plugins: {
components: {
MessageWillBePosted: [
{hook},
],
},
},
});
const post = {message: 'test'} as Post;
const result = await store.dispatch(runMessageWillBePostedHooks(post));
expect(result).toEqual({data: {message: 'testasync'}});
expect(hook).toHaveBeenCalledWith(post);
});
test('should assume post is unchanged if a hook returns undefined', async () => {
const hook1 = jest.fn();
const hook2 = jest.fn((post) => ({post: {...post, message: post.message + 'b'}}));
const store = mockStore({
plugins: {
components: {
MessageWillBePosted: [
{hook: hook1},
{hook: hook2},
],
},
},
});
const post = {message: 'test'} as Post;
const result = await store.dispatch(runMessageWillBePostedHooks(post));
expect(result).toEqual({data: {message: 'testb'}});
expect(hook1).toHaveBeenCalledWith(post);
expect(hook2).toHaveBeenCalled();
expect(hook2).toHaveBeenCalledWith(post);
});
});
describe('runSlashCommandWillBePostedHooks', () => {
test('should do nothing when no hooks are registered', async () => {
const store = mockStore({
plugins: {
components: {},
},
});
const message = '/test';
const args = {channelId: 'abcdefg'} as unknown as CommandArgs;
const result = await store.dispatch(runSlashCommandWillBePostedHooks(message, args));
expect(result.data).toEqual({message, args});
});
test('should pass the command through every hook', async () => {
const hook1 = jest.fn((message, args) => ({message, args}));
const hook2 = jest.fn((message, args) => ({message, args}));
const hook3 = jest.fn((message, args) => ({message, args}));
const store = mockStore({
plugins: {
components: {
SlashCommandWillBePosted: [
{hook: hook1},
{hook: hook2},
{hook: hook3},
],
},
},
});
const message = '/test';
const args = {channelId: 'abcdefg'} as unknown as CommandArgs;
const result = await store.dispatch(runSlashCommandWillBePostedHooks(message, args));
expect(result.data).toEqual({message, args});
expect(hook1).toHaveBeenCalledWith(message, args);
expect(hook2).toHaveBeenCalledWith(message, args);
expect(hook3).toHaveBeenCalledWith(message, args);
});
test('should return an error when a hook rejects the command', async () => {
const hook1 = jest.fn((message, args) => ({message, args}));
const hook2 = jest.fn(() => ({error: {message: 'an error occurred'}}));
const hook3 = jest.fn((message, args) => ({message, args}));
const store = mockStore({
plugins: {
components: {
SlashCommandWillBePosted: [
{hook: hook1},
{hook: hook2},
{hook: hook3},
],
},
},
});
const message = '/test';
const args = {channelId: 'abcdefg'} as unknown as CommandArgs;
const result = await store.dispatch(runSlashCommandWillBePostedHooks(message, args));
expect(result).toEqual({error: {message: 'an error occurred'}});
expect(hook1).toHaveBeenCalledWith(message, args);
expect(hook2).toHaveBeenCalledWith(message, args);
expect(hook3).not.toHaveBeenCalled();
});
test('should pass the result of each hook to the next', async () => {
const hook1 = jest.fn((message, args) => ({message: message + 'a', args}));
const hook2 = jest.fn((message, args) => ({message: message + 'b', args}));
const hook3 = jest.fn((message, args) => ({message: message + 'c', args}));
const store = mockStore({
plugins: {
components: {
SlashCommandWillBePosted: [
{hook: hook1},
{hook: hook2},
{hook: hook3},
],
},
},
});
const message = '/test';
const args = {channelId: 'abcdefg'} as unknown as CommandArgs;
const result = await store.dispatch(runSlashCommandWillBePostedHooks(message, args));
expect(result.data).toEqual({message: '/testabc', args});
expect(hook1).toHaveBeenCalledWith('/test', args);
expect(hook2).toHaveBeenCalledWith('/testa', args);
expect(hook3).toHaveBeenCalledWith('/testab', args);
});
test('should pass the result of each hook to the next, until one consumes the command by returning an empty object', async () => {
const hook1 = jest.fn((message, args) => ({message: message + 'a', args}));
const hook2 = jest.fn(() => ({}));
const hook3 = jest.fn((message, args) => ({message: message + 'c', args}));
const store = mockStore({
plugins: {
components: {
SlashCommandWillBePosted: [
{hook: hook1},
{hook: hook2},
{hook: hook3},
],
},
},
});
const message = '/test';
const args = {channelId: 'abcdefg'} as unknown as CommandArgs;
const result = await store.dispatch(runSlashCommandWillBePostedHooks(message, args));
expect(result.data).toEqual({});
expect(hook1).toHaveBeenCalledWith('/test', args);
expect(hook2).toHaveBeenCalledWith('/testa', args);
expect(hook3).not.toHaveBeenCalled();
});
test('should wait for async hooks', async () => {
jest.useFakeTimers();
const hook = jest.fn((message, args) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({message: message + 'async', args});
}, 100);
jest.runOnlyPendingTimers();
});
});
const store = mockStore({
plugins: {
components: {
SlashCommandWillBePosted: [
{hook},
],
},
},
});
const message = '/test';
const args = {channelId: 'abcdefg'} as unknown as CommandArgs;
const result = await store.dispatch(runSlashCommandWillBePostedHooks(message, args));
expect(result.data).toEqual({message: '/testasync', args});
expect(hook).toHaveBeenCalledWith(message, args);
});
test('should assume command is unchanged if a hook returns undefined', async () => {
const hook1 = jest.fn();
const hook2 = jest.fn((message, args) => ({message: message + 'b', args}));
const store = mockStore({
plugins: {
components: {
SlashCommandWillBePosted: [
{hook: hook1},
{hook: hook2},
],
},
},
});
const message = '/test';
const args = {channelId: 'abcdefg'} as unknown as CommandArgs;
const result = await store.dispatch(runSlashCommandWillBePostedHooks(message, args));
expect(result.data).toEqual({message: '/testb', args});
expect(hook1).toHaveBeenCalledWith(message, args);
expect(hook2).toHaveBeenCalled();
expect(hook2).toHaveBeenCalledWith(message, args);
});
});
describe('runMessageWillBeUpdatedHooks', () => {
test('should do nothing when no hooks are registered', async () => {
const store = mockStore({
plugins: {
components: {},
},
});
const oldPost = {message: 'test'} as Post;
const newPost = {message: 'edited'};
const result = await store.dispatch(runMessageWillBeUpdatedHooks(newPost, oldPost));
expect(result).toEqual({data: newPost});
});
test('should pass the post through every hook', async () => {
const hook1 = jest.fn((post) => ({post}));
const hook2 = jest.fn((post) => ({post}));
const hook3 = jest.fn((post) => ({post}));
const store = mockStore({
plugins: {
components: {
MessageWillBeUpdated: [
{hook: hook1},
{hook: hook2},
{hook: hook3},
],
},
},
});
const oldPost = {message: 'test'} as Post;
const newPost = {message: 'edited'};
const result = await store.dispatch(runMessageWillBeUpdatedHooks(newPost, oldPost));
expect(result).toEqual({data: newPost});
expect(hook1).toHaveBeenCalledWith(newPost, oldPost);
expect(hook2).toHaveBeenCalledWith(newPost, oldPost);
expect(hook3).toHaveBeenCalledWith(newPost, oldPost);
});
test('should return an error when a hook rejects the post', async () => {
const hook1 = jest.fn((post) => ({post}));
const hook2 = jest.fn(() => ({error: {message: 'an error occurred'}}));
const hook3 = jest.fn((post) => ({post}));
const store = mockStore({
plugins: {
components: {
MessageWillBeUpdated: [
{hook: hook1},
{hook: hook2},
{hook: hook3},
],
},
},
});
const oldPost = {message: 'test'} as Post;
const newPost = {message: 'edited'};
const result = await store.dispatch(runMessageWillBeUpdatedHooks(newPost, oldPost));
expect(result).toEqual({error: {message: 'an error occurred'}});
expect(hook1).toHaveBeenCalledWith(newPost, oldPost);
expect(hook2).toHaveBeenCalledWith(newPost, oldPost);
expect(hook3).not.toHaveBeenCalled();
});
test('should pass the result of each hook to the next', async () => {
const hook1 = jest.fn((post) => ({post: {...post, message: post.message + 'a'}}));
const hook2 = jest.fn((post) => ({post: {...post, message: post.message + 'b'}}));
const hook3 = jest.fn((post) => ({post: {...post, message: post.message + 'c'}}));
const store = mockStore({
plugins: {
components: {
MessageWillBeUpdated: [
{hook: hook1},
{hook: hook2},
{hook: hook3},
],
},
},
});
const oldPost = {message: 'test'} as Post;
const newPost = {message: 'edited'};
const result = await store.dispatch(runMessageWillBeUpdatedHooks(newPost, oldPost));
expect(result).toEqual({data: {message: 'editedabc'}});
expect(hook1).toHaveBeenCalledWith(newPost, oldPost);
expect(hook2).toHaveBeenCalled();
expect(hook2).not.toHaveBeenCalledWith(newPost, oldPost);
expect(hook3).toHaveBeenCalled();
expect(hook3).not.toHaveBeenCalledWith(newPost, oldPost);
});
test('should wait for async hooks', async () => {
jest.useFakeTimers();
const hook = jest.fn((post) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({post: {...post, message: post.message + 'async'}});
}, 100);
jest.runOnlyPendingTimers();
});
});
const store = mockStore({
plugins: {
components: {
MessageWillBeUpdated: [
{hook},
],
},
},
});
const oldPost = {message: 'test'} as Post;
const newPost = {message: 'edited'};
const result = await store.dispatch(runMessageWillBeUpdatedHooks(newPost, oldPost));
expect(result).toEqual({data: {message: 'editedasync'}});
expect(hook).toHaveBeenCalledWith(newPost, oldPost);
});
test('should assume post is unchanged if a hook returns undefined', async () => {
const hook1 = jest.fn();
const hook2 = jest.fn((post) => ({post: {...post, message: post.message + 'b'}}));
const store = mockStore({
plugins: {
components: {
MessageWillBeUpdated: [
{hook: hook1},
{hook: hook2},
],
},
},
});
const oldPost = {message: 'test'} as Post;
const newPost = {message: 'edited'};
const result = await store.dispatch(runMessageWillBeUpdatedHooks(newPost, oldPost));
expect(result).toEqual({data: {message: 'editedb'}});
expect(hook1).toHaveBeenCalledWith(newPost, oldPost);
expect(hook2).toHaveBeenCalled();
expect(hook2).toHaveBeenCalledWith(newPost, oldPost);
});
});
describe('runDesktopNotificationHooks', () => {
test('should do nothing when no hooks are registered', async () => {
const store = mockStore({
plugins: {
components: {},
},
});
const post = {id: 'postid01234567890123456789'} as Post;
const teamId = 'teamid01234567890123456789';
const msgProps = {mentions: ['userid1'], team_id: teamId} as unknown as NewPostMessageProps;
const channel = {type: General.DM_CHANNEL} as Channel;
const args = {
title: 'Notification title',
body: 'Notification body',
silent: false,
soundName: 'Bing',
url: 'http://localhost:8065/ad-1/channels/test',
notify: true,
};
const result = await store.dispatch(runDesktopNotificationHooks(post, msgProps, channel, teamId, args));
expect(result.data).toEqual(args);
});
test('should pass the args through every hook', async () => {
const hook1 = jest.fn((post, msgProps, channel, teamId, args) => ({args}));
const hook2 = jest.fn((post, msgProps, channel, teamId, args) => ({args}));
const hook3 = jest.fn((post, msgProps, channel, teamId, args) => ({args}));
const store = mockStore({
plugins: {
components: {
DesktopNotificationHooks: [
{hook: hook1},
{hook: hook2},
{hook: hook3},
],
},
},
});
const post = {id: 'postid01234567890123456789'} as Post;
const teamId = 'teamid01234567890123456789';
const msgProps = {mentions: ['userid1'], team_id: teamId} as unknown as NewPostMessageProps;
const channel = {type: General.DM_CHANNEL} as Channel;
const args = {
title: 'Notification title',
body: 'Notification body',
silent: false,
soundName: 'Bing',
url: 'http://localhost:8065/ad-1/channels/test',
notify: true,
};
const result = await store.dispatch(runDesktopNotificationHooks(post, msgProps, channel, teamId, args));
expect(result.data).toEqual(args);
expect(hook1).toHaveBeenCalledWith(post, msgProps, channel, teamId, args);
expect(hook2).toHaveBeenCalledWith(post, msgProps, channel, teamId, args);
expect(hook3).toHaveBeenCalledWith(post, msgProps, channel, teamId, args);
});
test('should return an error when a hook returns an error', async () => {
const hook1 = jest.fn((post, msgProps, channel, teamId, args) => ({args}));
const hook2 = jest.fn(() => ({error: 'an error occurred'}));
const hook3 = jest.fn((post, msgProps, channel, teamId, args) => ({args}));
const store = mockStore({
plugins: {
components: {
DesktopNotificationHooks: [
{hook: hook1},
{hook: hook2},
{hook: hook3},
],
},
},
});
const post = {id: 'postid01234567890123456789'} as Post;
const teamId = 'teamid01234567890123456789';
const msgProps = {mentions: ['userid1'], team_id: teamId} as unknown as NewPostMessageProps;
const channel = {type: General.DM_CHANNEL} as Channel;
const args = {
title: 'Notification title',
body: 'Notification body',
silent: false,
soundName: 'Bing',
url: 'http://localhost:8065/ad-1/channels/test',
notify: true,
};
const result = await store.dispatch(runDesktopNotificationHooks(post, msgProps, channel, teamId, args));
expect(result).toEqual({error: 'an error occurred'});
expect(hook1).toHaveBeenCalledWith(post, msgProps, channel, teamId, args);
expect(hook2).toHaveBeenCalledWith(post, msgProps, channel, teamId, args);
expect(hook3).not.toHaveBeenCalled();
});
test('should return an error when a hook returns an empty result', async () => {
const hook1 = jest.fn((post, msgProps, channel, teamId, args) => ({args}));
const hook2 = jest.fn(() => ({}));
const hook3 = jest.fn((post, msgProps, channel, teamId, args) => ({args}));
const store = mockStore({
plugins: {
components: {
DesktopNotificationHooks: [
{hook: hook1},
{hook: hook2},
{hook: hook3},
],
},
},
});
const post = {id: 'postid01234567890123456789'} as Post;
const teamId = 'teamid01234567890123456789';
const msgProps = {mentions: ['userid1'], team_id: teamId} as unknown as NewPostMessageProps;
const channel = {type: General.DM_CHANNEL} as Channel;
const args = {
title: 'Notification title',
body: 'Notification body',
silent: false,
soundName: 'Bing',
url: 'http://localhost:8065/ad-1/channels/test',
notify: true,
};
const result = await store.dispatch(runDesktopNotificationHooks(post, msgProps, channel, teamId, args));
expect(result).toEqual({error: 'returned empty args'});
expect(hook1).toHaveBeenCalledWith(post, msgProps, channel, teamId, args);
expect(hook2).toHaveBeenCalledWith(post, msgProps, channel, teamId, args);
expect(hook3).not.toHaveBeenCalled();
});
test('should continue to call next hooks when a hook returns null or undefined', async () => {
const hook1 = jest.fn(() => (null));
const hook2 = jest.fn(() => (undefined));
const hook3 = jest.fn((post, msgProps, channel, teamId, args) => ({args}));
const store = mockStore({
plugins: {
components: {
DesktopNotificationHooks: [
{hook: hook1},
{hook: hook2},
{hook: hook3},
],
},
},
});
const post = {id: 'postid01234567890123456789'} as Post;
const teamId = 'teamid01234567890123456789';
const msgProps = {mentions: ['userid1'], team_id: teamId} as unknown as NewPostMessageProps;
const channel = {type: General.DM_CHANNEL} as Channel;
const args = {
title: 'Notification title',
body: 'Notification body',
silent: false,
soundName: 'Bing',
url: 'http://localhost:8065/ad-1/channels/test',
notify: true,
};
const result = await store.dispatch(runDesktopNotificationHooks(post, msgProps, channel, teamId, args));
expect(result.data).toEqual(args);
expect(hook1).toHaveBeenCalledWith(post, msgProps, channel, teamId, args);
expect(hook2).toHaveBeenCalledWith(post, msgProps, channel, teamId, args);
expect(hook3).toHaveBeenCalledWith(post, msgProps, channel, teamId, args);
});
test('should pass the result of each hook to the next', async () => {
const hook1 = jest.fn((post, msgProps, channel, teamId, args) => ({args: {...args, title: args.title + 'a'}}));
const hook2 = jest.fn((post, msgProps, channel, teamId, args) => ({
args: {
...args,
title: args.title + 'b',
notify: false,
},
}));
const hook3 = jest.fn((post, msgProps, channel, teamId, args) => ({
args: {
...args,
title: args.title + 'c',
notify: true,
},
}));
const store = mockStore({
plugins: {
components: {
DesktopNotificationHooks: [
{hook: hook1},
{hook: hook2},
{hook: hook3},
],
},
},
});
const post = {id: 'postid01234567890123456789'} as Post;
const teamId = 'teamid01234567890123456789';
const msgProps = {mentions: ['userid1'], team_id: teamId} as unknown as NewPostMessageProps;
const channel = {type: General.DM_CHANNEL} as Channel;
const args = {
title: 'Notification title',
body: 'Notification body',
silent: false,
soundName: 'Bing',
url: 'http://localhost:8065/ad-1/channels/test',
notify: true,
};
const result = await store.dispatch(runDesktopNotificationHooks(post, msgProps, channel, teamId, args));
expect(result.data).toEqual({...args, title: 'Notification titleabc', notify: true});
expect(hook1).toHaveBeenCalledWith(post, msgProps, channel, teamId, args);
expect(hook2).toHaveBeenCalledWith(post, msgProps, channel, teamId, {...args, title: 'Notification titlea'});
expect(hook3).toHaveBeenCalledWith(post, msgProps, channel, teamId, {...args, title: 'Notification titleab', notify: false});
});
test('should wait for async hooks', async () => {
jest.useFakeTimers();
const hook = jest.fn((post, msgProps, channel, teamId, args) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({args: {...args, title: args.title + ' async'}});
}, 100);
jest.runOnlyPendingTimers();
});
});
const store = mockStore({
plugins: {
components: {
DesktopNotificationHooks: [
{hook},
],
},
},
});
const post = {id: 'postid01234567890123456789'} as Post;
const teamId = 'teamid01234567890123456789';
const msgProps = {mentions: ['userid1'], team_id: teamId} as unknown as NewPostMessageProps;
const channel = {type: General.DM_CHANNEL} as Channel;
const args = {
title: 'Notification title',
body: 'Notification body',
silent: false,
soundName: 'Bing',
url: 'http://localhost:8065/ad-1/channels/test',
notify: true,
};
const result = await store.dispatch(runDesktopNotificationHooks(post, msgProps, channel, teamId, args));
expect(result.data).toEqual({...args, title: 'Notification title async'});
expect(hook).toHaveBeenCalledWith(post, msgProps, channel, teamId, args);
});
});

View File

@@ -0,0 +1,132 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Channel} from '@mattermost/types/channels';
import type {CommandArgs} from '@mattermost/types/integrations';
import type {Post} from '@mattermost/types/posts';
import type {ActionFuncAsync} from 'types/store';
import type {DesktopNotificationArgs} from 'types/store/plugins';
import type {NewPostMessageProps} from './new_post';
/**
* @param {Post} originalPost
* @returns {ActionFuncAsync<Post>}
*/
export function runMessageWillBePostedHooks(originalPost: Post): ActionFuncAsync<Post> {
return async (dispatch, getState) => {
const hooks = getState().plugins.components.MessageWillBePosted;
if (!hooks || hooks.length === 0) {
return {data: originalPost};
}
let post = originalPost;
for (const hook of hooks) {
const result = await hook.hook?.(post); // eslint-disable-line no-await-in-loop
if (result) {
if ('error' in result) {
return {
error: result.error,
};
}
post = result.post;
}
}
return {data: post};
};
}
export function runSlashCommandWillBePostedHooks(originalMessage: string, originalArgs: CommandArgs): ActionFuncAsync<{message: string; args: CommandArgs}> {
return async (dispatch, getState) => {
const hooks = getState().plugins.components.SlashCommandWillBePosted;
if (!hooks || hooks.length === 0) {
return {data: {message: originalMessage, args: originalArgs}};
}
let message = originalMessage;
let args = originalArgs;
for (const hook of hooks) {
const result = await hook.hook?.(message, args); // eslint-disable-line no-await-in-loop
if (result) {
if ('error' in result) {
return {
error: result.error,
};
}
message = result.message;
args = result.args;
// The first plugin to consume the slash command by returning an empty object
// should terminate the processing by subsequent plugins.
if (Object.keys(result).length === 0) {
break;
}
}
}
return {data: {message, args}};
};
}
export function runMessageWillBeUpdatedHooks(newPost: Partial<Post>, oldPost: Post): ActionFuncAsync<Partial<Post>> {
return async (dispatch, getState) => {
const hooks = getState().plugins.components.MessageWillBeUpdated;
if (!hooks || hooks.length === 0) {
return {data: newPost};
}
let post = newPost;
for (const hook of hooks) {
const result = await hook.hook?.(post, oldPost); // eslint-disable-line no-await-in-loop
if (result) {
if ('error' in result) {
return {
error: result.error,
};
}
post = result.post;
}
}
return {data: post};
};
}
export function runDesktopNotificationHooks(post: Post, msgProps: NewPostMessageProps, channel: Channel, teamId: string, args: DesktopNotificationArgs): ActionFuncAsync<DesktopNotificationArgs> {
return async (dispatch, getState) => {
const hooks = getState().plugins.components.DesktopNotificationHooks;
if (!hooks || hooks.length === 0) {
return {data: args};
}
let nextArgs = args;
for (const hook of hooks) {
const result = await hook.hook(post, msgProps, channel, teamId, nextArgs); // eslint-disable-line no-await-in-loop
if (result) {
if (result.error) {
return {error: result.error};
}
if (!result.args) {
return {error: 'returned empty args'};
}
nextArgs = result.args;
}
}
return {data: nextArgs};
};
}

View File

@@ -0,0 +1,369 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {IncomingWebhook, OutgoingWebhook, Command, OAuthApp} from '@mattermost/types/integrations';
import {IntegrationTypes} from 'mattermost-redux/action_types';
import * as IntegrationActions from 'mattermost-redux/actions/integrations';
import {getProfilesByIds} from 'mattermost-redux/actions/users';
import {Client4} from 'mattermost-redux/client';
import * as Actions from 'actions/integration_actions';
import mockStore from 'tests/test_store';
jest.mock('mattermost-redux/actions/users', () => ({
getProfilesByIds: jest.fn(() => {
return {type: ''};
}),
}));
jest.mock('mattermost-redux/actions/integrations', () => ({
getIncomingHooks: jest.fn(() => {
return {type: 'MOCK_GET_INCOMING_HOOKS', data: []};
}),
getOutgoingHooks: jest.fn(() => {
return {type: 'MOCK_GET_OUTGOING_HOOKS', data: []};
}),
getCustomTeamCommands: jest.fn(() => {
return {type: 'MOCK_GET_COMMANDS', data: []};
}),
getOAuthApps: jest.fn(() => {
return {type: 'MOCK_GET_OAUTH_APPS', data: []};
}),
getOutgoingOAuthConnections: jest.fn(() => {
return {type: 'MOCK_GET_OUTGOING_OAUTH_CONNECTIONS', data: []};
}),
getAppsOAuthAppIDs: jest.fn(() => {
return {type: 'MOCK_GET_APPS_OAUTH_APP_IDS'};
}),
isIncomingWebhooksWithCount: jest.fn(() => false),
}));
jest.mock('mattermost-redux/selectors/entities/apps', () => ({
appsEnabled: jest.fn(() => true),
}));
jest.mock('mattermost-redux/client');
jest.mock('mattermost-redux/actions/helpers', () => ({
forceLogoutIfNecessary: jest.fn(),
}));
jest.mock('mattermost-redux/actions/errors', () => ({
logError: jest.fn(() => ({type: 'MOCK_LOG_ERROR'})),
}));
interface CustomMatchers<R = unknown> {
arrayContainingExactly(stringArray: string[]): R;
}
type GreatExpectations = typeof expect & CustomMatchers;
describe('actions/integration_actions', () => {
const initialState = {
entities: {
teams: {
currentTeamId: 'team_id1',
teams: {
team_id1: {
id: 'team_id1',
name: 'team1',
},
},
},
users: {
currentUserId: 'current_user_id',
profiles: {current_user_id: {id: 'current_user_id', username: 'current_user'}, user_id3: {id: 'user_id3', username: 'user3'}, user_id4: {id: 'user_id4', username: 'user4'}},
},
channels: {
currentChannelId: 'current_channel_id',
},
integrations: {},
},
};
describe('loadProfilesForIncomingHooks', () => {
test('load profiles for hooks including user we already have', () => {
const testStore = mockStore(initialState);
testStore.dispatch(Actions.loadProfilesForIncomingHooks([{user_id: 'current_user_id'}, {user_id: 'user_id2'}] as IncomingWebhook[]));
expect(getProfilesByIds).toHaveBeenCalledWith((expect as GreatExpectations).arrayContainingExactly(['user_id2']));
});
test('load profiles for hooks including only users we don\'t have', () => {
const testStore = mockStore(initialState);
testStore.dispatch(Actions.loadProfilesForIncomingHooks([{user_id: 'user_id1'}, {user_id: 'user_id2'}] as IncomingWebhook[]));
expect(getProfilesByIds).toHaveBeenCalledWith((expect as GreatExpectations).arrayContainingExactly(['user_id1', 'user_id2']));
});
test('load profiles for empty hooks', () => {
const testStore = mockStore(initialState);
testStore.dispatch(Actions.loadProfilesForIncomingHooks([]));
expect(getProfilesByIds).not.toHaveBeenCalled();
});
});
describe('loadProfilesForOutgoingHooks', () => {
test('load profiles for hooks including user we already have', () => {
const testStore = mockStore(initialState);
testStore.dispatch(Actions.loadProfilesForOutgoingHooks([{creator_id: 'current_user_id'}, {creator_id: 'user_id2'}] as OutgoingWebhook[]));
expect(getProfilesByIds).toHaveBeenCalledWith((expect as GreatExpectations).arrayContainingExactly(['user_id2']));
});
test('load profiles for hooks including only users we don\'t have', () => {
const testStore = mockStore(initialState);
testStore.dispatch(Actions.loadProfilesForOutgoingHooks([{creator_id: 'user_id1'}, {creator_id: 'user_id2'}] as OutgoingWebhook[]));
expect(getProfilesByIds).toHaveBeenCalledWith((expect as GreatExpectations).arrayContainingExactly(['user_id1', 'user_id2']));
});
test('load profiles for empty hooks', () => {
const testStore = mockStore(initialState);
testStore.dispatch(Actions.loadProfilesForOutgoingHooks([]));
expect(getProfilesByIds).not.toHaveBeenCalled();
});
});
describe('loadProfilesForCommands', () => {
test('load profiles for commands including user we already have', () => {
const testStore = mockStore(initialState);
testStore.dispatch(Actions.loadProfilesForCommands([{creator_id: 'current_user_id'}, {creator_id: 'user_id2'}] as Command[]));
expect(getProfilesByIds).toHaveBeenCalledWith((expect as GreatExpectations).arrayContainingExactly(['user_id2']));
});
test('load profiles for commands including only users we don\'t have', () => {
const testStore = mockStore(initialState);
testStore.dispatch(Actions.loadProfilesForCommands([{creator_id: 'user_id1'}, {creator_id: 'user_id2'}] as Command[]));
expect(getProfilesByIds).toHaveBeenCalledWith((expect as GreatExpectations).arrayContainingExactly(['user_id1', 'user_id2']));
});
test('load profiles for empty commands', () => {
const testStore = mockStore(initialState);
testStore.dispatch(Actions.loadProfilesForCommands([]));
expect(getProfilesByIds).not.toHaveBeenCalled();
});
});
describe('loadProfilesForOAuthApps', () => {
test('load profiles for apps including user we already have', () => {
const testStore = mockStore(initialState);
testStore.dispatch(Actions.loadProfilesForOAuthApps([{creator_id: 'current_user_id'}, {creator_id: 'user_id2'}] as OAuthApp[]));
expect(getProfilesByIds).toHaveBeenCalledWith((expect as GreatExpectations).arrayContainingExactly(['user_id2']));
});
test('load profiles for apps including only users we don\'t have', () => {
const testStore = mockStore(initialState);
testStore.dispatch(Actions.loadProfilesForOAuthApps([{creator_id: 'user_id1'}, {creator_id: 'user_id2'}] as OAuthApp[]));
expect(getProfilesByIds).toHaveBeenCalledWith((expect as GreatExpectations).arrayContainingExactly(['user_id1', 'user_id2']));
});
test('load profiles for empty apps', () => {
const testStore = mockStore(initialState);
testStore.dispatch(Actions.loadProfilesForOAuthApps([]));
expect(getProfilesByIds).not.toHaveBeenCalled();
});
});
describe('loadIncomingHooksAndProfilesForTeam', () => {
test('should load hooks and profiles', async () => {
const testStore = mockStore(initialState);
await testStore.dispatch(Actions.loadIncomingHooksAndProfilesForTeam('team_id1', 0, 50, false));
expect(IntegrationActions.getIncomingHooks).toHaveBeenCalledWith('team_id1', 0, 50, false);
});
test('should handle webhooks with count response', async () => {
const testStore = mockStore(initialState);
await testStore.dispatch(Actions.loadIncomingHooksAndProfilesForTeam('team_id1', 0, 50, true));
expect(IntegrationActions.getIncomingHooks).toHaveBeenCalledWith('team_id1', 0, 50, true);
});
test('should handle default parameters', async () => {
const testStore = mockStore(initialState);
await testStore.dispatch(Actions.loadIncomingHooksAndProfilesForTeam('team_id1'));
expect(IntegrationActions.getIncomingHooks).toHaveBeenCalledWith('team_id1', 0, 100, false);
});
});
describe('loadOutgoingHooksAndProfilesForTeam', () => {
test('should load outgoing hooks and profiles', async () => {
const testStore = mockStore(initialState);
await testStore.dispatch(Actions.loadOutgoingHooksAndProfilesForTeam('team_id1', 1, 25));
expect(IntegrationActions.getOutgoingHooks).toHaveBeenCalledWith('', 'team_id1', 1, 25);
});
test('should use default parameters', async () => {
const testStore = mockStore(initialState);
await testStore.dispatch(Actions.loadOutgoingHooksAndProfilesForTeam('team_id1'));
expect(IntegrationActions.getOutgoingHooks).toHaveBeenCalledWith('', 'team_id1', 0, 100);
});
});
describe('loadCommandsAndProfilesForTeam', () => {
test('should load commands and profiles', async () => {
const testStore = mockStore(initialState);
await testStore.dispatch(Actions.loadCommandsAndProfilesForTeam('team_id1'));
expect(IntegrationActions.getCustomTeamCommands).toHaveBeenCalledWith('team_id1');
});
test('should handle team commands', async () => {
const testStore = mockStore(initialState);
await testStore.dispatch(Actions.loadCommandsAndProfilesForTeam('team_id1'));
expect(IntegrationActions.getCustomTeamCommands).toHaveBeenCalledWith('team_id1');
});
});
describe('loadOAuthAppsAndProfiles', () => {
test('should load OAuth apps with custom parameters', async () => {
const testStore = mockStore(initialState);
await testStore.dispatch(Actions.loadOAuthAppsAndProfiles(2, 30));
expect(IntegrationActions.getOAuthApps).toHaveBeenCalledWith(2, 30);
});
test('should load OAuth apps with default parameters', async () => {
const testStore = mockStore(initialState);
await testStore.dispatch(Actions.loadOAuthAppsAndProfiles());
expect(IntegrationActions.getOAuthApps).toHaveBeenCalledWith(0, 100);
});
test('should handle OAuth apps loading', async () => {
const testStore = mockStore(initialState);
await testStore.dispatch(Actions.loadOAuthAppsAndProfiles());
expect(IntegrationActions.getOAuthApps).toHaveBeenCalledWith(0, 100);
});
});
describe('loadOutgoingOAuthConnectionsAndProfiles', () => {
test('should load outgoing OAuth connections', async () => {
const testStore = mockStore(initialState);
await testStore.dispatch(Actions.loadOutgoingOAuthConnectionsAndProfiles('team_id1', 1, 50));
expect(IntegrationActions.getOutgoingOAuthConnections).toHaveBeenCalledWith('team_id1', 1, 50);
});
test('should use default connection parameters', async () => {
const testStore = mockStore(initialState);
await testStore.dispatch(Actions.loadOutgoingOAuthConnectionsAndProfiles('team_id1'));
expect(IntegrationActions.getOutgoingOAuthConnections).toHaveBeenCalledWith('team_id1', 0, 100);
});
});
describe('loadProfilesForOutgoingOAuthConnections', () => {
test('load profiles for connections including user we already have', () => {
const testStore = mockStore(initialState);
testStore.dispatch(Actions.loadProfilesForOutgoingOAuthConnections([{creator_id: 'current_user_id'}, {creator_id: 'user_id2'}] as any[]));
expect(getProfilesByIds).toHaveBeenCalledWith((expect as GreatExpectations).arrayContainingExactly(['user_id2']));
});
test('load profiles for connections including only users we don\'t have', () => {
const testStore = mockStore(initialState);
testStore.dispatch(Actions.loadProfilesForOutgoingOAuthConnections([{creator_id: 'user_id1'}, {creator_id: 'user_id2'}] as any[]));
expect(getProfilesByIds).toHaveBeenCalledWith((expect as GreatExpectations).arrayContainingExactly(['user_id1', 'user_id2']));
});
test('load profiles for empty connections', () => {
const testStore = mockStore(initialState);
testStore.dispatch(Actions.loadProfilesForOutgoingOAuthConnections([]));
expect(getProfilesByIds).not.toHaveBeenCalled();
});
});
describe('executeDialogAction', () => {
const url = 'https://example.com/action';
const context = {key: 'value'};
test('uses the dialog channel when one is supplied', async () => {
(Client4.executeDialogAction as jest.Mock).mockResolvedValue({});
// currentChannelId in state is 'current_channel_id' — proves dialog wins
const testStore = mockStore(initialState);
await testStore.dispatch(Actions.executeDialogAction(url, context, 'dialog_channel_id'));
expect(Client4.executeDialogAction).toHaveBeenCalledWith(
url,
context,
'dialog_channel_id',
'team_id1',
);
});
test('falls back to getCurrentChannelId when no dialog channel is supplied', async () => {
(Client4.executeDialogAction as jest.Mock).mockResolvedValue({});
const testStore = mockStore(initialState);
await testStore.dispatch(Actions.executeDialogAction(url, context));
expect(Client4.executeDialogAction).toHaveBeenCalledWith(
url,
context,
'current_channel_id',
'team_id1',
);
});
test('falls back to getCurrentChannelId when the dialog channel is empty', async () => {
(Client4.executeDialogAction as jest.Mock).mockResolvedValue({});
const testStore = mockStore(initialState);
await testStore.dispatch(Actions.executeDialogAction(url, context, ''));
expect(Client4.executeDialogAction).toHaveBeenCalledWith(
url,
context,
'current_channel_id',
'team_id1',
);
});
test('dispatches RECEIVED_DIALOG_TRIGGER_ID when response includes trigger_id', async () => {
(Client4.executeDialogAction as jest.Mock).mockResolvedValue({trigger_id: 'trigger123'});
const testStore = mockStore(initialState);
await testStore.dispatch(Actions.executeDialogAction(url, context));
const dispatchedActions = testStore.getActions();
expect(dispatchedActions).toContainEqual({
type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID,
data: 'trigger123',
});
});
test('does not dispatch RECEIVED_DIALOG_TRIGGER_ID when response has no trigger_id', async () => {
(Client4.executeDialogAction as jest.Mock).mockResolvedValue({});
const testStore = mockStore(initialState);
await testStore.dispatch(Actions.executeDialogAction(url, context));
const dispatchedActions = testStore.getActions();
const triggerIdAction = dispatchedActions.find(
(a) => a.type === IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID,
);
expect(triggerIdAction).toBeUndefined();
});
test('returns {error} and does not dispatch RECEIVED_DIALOG_TRIGGER_ID on Client4 failure', async () => {
const networkError = new Error('network failure');
(Client4.executeDialogAction as jest.Mock).mockRejectedValue(networkError);
const testStore = mockStore(initialState);
const result = await testStore.dispatch(Actions.executeDialogAction(url, context));
expect(result.error).toBe(networkError);
const dispatchedActions = testStore.getActions();
const triggerIdAction = dispatchedActions.find(
(a) => a.type === IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID,
);
expect(triggerIdAction).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,206 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {IncomingWebhook, IncomingWebhooksWithCount, OutgoingWebhook, Command, OAuthApp, OutgoingOAuthConnection} from '@mattermost/types/integrations';
import {IntegrationTypes} from 'mattermost-redux/action_types';
import {logError} from 'mattermost-redux/actions/errors';
import {forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers';
import * as IntegrationActions from 'mattermost-redux/actions/integrations';
import {getProfilesByIds} from 'mattermost-redux/actions/users';
import {Client4} from 'mattermost-redux/client';
import {appsEnabled} from 'mattermost-redux/selectors/entities/apps';
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import type {ActionFuncAsync} from 'types/store';
const DEFAULT_PAGE_SIZE = 100;
export function loadIncomingHooksAndProfilesForTeam(teamId: string, page = 0, perPage = DEFAULT_PAGE_SIZE, includeTotalCount = false): ActionFuncAsync<IncomingWebhook[] | IncomingWebhooksWithCount> {
return async (dispatch) => {
const {data} = await dispatch(IntegrationActions.getIncomingHooks(teamId, page, perPage, includeTotalCount));
if (data) {
const isWebhooksWithCount = IntegrationActions.isIncomingWebhooksWithCount(data);
const hooks = isWebhooksWithCount ? (data as IncomingWebhooksWithCount).incoming_webhooks : data;
dispatch(loadProfilesForIncomingHooks(hooks as IncomingWebhook[]));
}
return {data};
};
}
export function loadProfilesForIncomingHooks(hooks: IncomingWebhook[]): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const profilesToLoad: {[key: string]: boolean} = {};
for (let i = 0; i < hooks.length; i++) {
const hook = hooks[i];
if (!getUser(state, hook.user_id)) {
profilesToLoad[hook.user_id] = true;
}
}
const list = Object.keys(profilesToLoad);
if (list.length === 0) {
return {data: null};
}
dispatch(getProfilesByIds(list));
return {data: null};
};
}
export function loadOutgoingHooksAndProfilesForTeam(teamId: string, page = 0, perPage = DEFAULT_PAGE_SIZE): ActionFuncAsync<OutgoingWebhook[]> {
return async (dispatch) => {
const {data} = await dispatch(IntegrationActions.getOutgoingHooks('', teamId, page, perPage));
if (data) {
dispatch(loadProfilesForOutgoingHooks(data));
}
return {data};
};
}
export function loadProfilesForOutgoingHooks(hooks: OutgoingWebhook[]): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const profilesToLoad: {[key: string]: boolean} = {};
for (let i = 0; i < hooks.length; i++) {
const hook = hooks[i];
if (!getUser(state, hook.creator_id)) {
profilesToLoad[hook.creator_id] = true;
}
}
const list = Object.keys(profilesToLoad);
if (list.length === 0) {
return {data: null};
}
dispatch(getProfilesByIds(list));
return {data: null};
};
}
export function loadCommandsAndProfilesForTeam(teamId: string): ActionFuncAsync {
return async (dispatch) => {
const {data} = await dispatch(IntegrationActions.getCustomTeamCommands(teamId));
if (data) {
dispatch(loadProfilesForCommands(data));
}
return {data};
};
}
export function loadProfilesForCommands(commands: Command[]): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const profilesToLoad: {[key: string]: boolean} = {};
for (let i = 0; i < commands.length; i++) {
const command = commands[i];
if (!getUser(state, command.creator_id)) {
profilesToLoad[command.creator_id] = true;
}
}
const list = Object.keys(profilesToLoad);
if (list.length === 0) {
return {data: null};
}
dispatch(getProfilesByIds(list));
return {data: null};
};
}
export function loadOAuthAppsAndProfiles(page = 0, perPage = DEFAULT_PAGE_SIZE): ActionFuncAsync {
return async (dispatch, getState) => {
if (appsEnabled(getState())) {
dispatch(IntegrationActions.getAppsOAuthAppIDs());
}
const {data} = await dispatch(IntegrationActions.getOAuthApps(page, perPage));
if (data) {
dispatch(loadProfilesForOAuthApps(data));
}
return {data: null};
};
}
export function loadProfilesForOAuthApps(apps: OAuthApp[]): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const profilesToLoad: {[key: string]: boolean} = {};
for (let i = 0; i < apps.length; i++) {
const app = apps[i];
if (!getUser(state, app.creator_id)) {
profilesToLoad[app.creator_id] = true;
}
}
const list = Object.keys(profilesToLoad);
if (list.length === 0) {
return {data: null};
}
dispatch(getProfilesByIds(list));
return {data: null};
};
}
export function loadOutgoingOAuthConnectionsAndProfiles(teamId: string, page = 0, perPage = DEFAULT_PAGE_SIZE): ActionFuncAsync<null> {
return async (dispatch) => {
const {data} = await dispatch(IntegrationActions.getOutgoingOAuthConnections(teamId, page, perPage));
if (data) {
dispatch(loadProfilesForOutgoingOAuthConnections(data));
}
return {data: null};
};
}
export function loadProfilesForOutgoingOAuthConnections(connections: OutgoingOAuthConnection[]): ActionFuncAsync<null> {
return async (dispatch, getState) => {
const state = getState();
const profilesToLoad: {[key: string]: boolean} = {};
for (let i = 0; i < connections.length; i++) {
const app = connections[i];
if (!getUser(state, app.creator_id)) {
profilesToLoad[app.creator_id] = true;
}
}
const list = Object.keys(profilesToLoad);
if (list.length === 0) {
return {data: null};
}
dispatch(getProfilesByIds(list));
return {data: null};
};
}
export function executeDialogAction(url: string, context?: Record<string, string>, dialogChannelId?: string): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
// Prefer the dialog's own channel over the current channel, so a child dialog
// action targets the dialog's context even if the user has navigated away. The
// server mints the child dialog's trigger against whatever is sent here, which
// is how the child inherits the parent's channel.
const channelId = dialogChannelId || getCurrentChannelId(state);
const teamId = getCurrentTeamId(state);
let data;
try {
data = await Client4.executeDialogAction(url, context, channelId, teamId);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {error};
}
if (data?.trigger_id) {
dispatch({type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID, data: data.trigger_id});
}
return {data};
};
}

View File

@@ -0,0 +1,737 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Channel} from '@mattermost/types/channels';
import type {UserProfile} from '@mattermost/types/users';
import {sendMembersInvites, sendGuestsInvites} from 'actions/invite_actions';
import mockStore from 'tests/test_store';
import {ConsolePages} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
jest.mock('actions/team_actions', () => ({
addUsersToTeam: () => ({ // since we are using addUsersToTeamGracefully, this call will always succeed
type: 'MOCK_RECEIVED_ME',
}),
}));
jest.mock('mattermost-redux/actions/channels', () => ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
joinChannel: (_userId: string, _teamId: string, channelId: string, _channelName: string) => {
if (channelId === 'correct') {
return ({type: 'MOCK_RECEIVED_ME'});
}
if (channelId === 'correct2') {
return ({type: 'MOCK_RECEIVED_ME'});
}
throw new Error('ERROR');
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getChannelMembersByIds: (channelId: string, userIds: string[]) => {
return ({type: 'MOCK_RECEIVED_CHANNEL_MEMBERS'});
},
}));
const mockSendEmailInvitesCalls: Array<{team: string; emails: string[]; profiles?: unknown}> = [];
jest.mock('mattermost-redux/actions/teams', () => ({
getTeamMembersByIds: () => ({type: 'MOCK_RECEIVED_ME'}),
sendEmailInvitesToTeamGracefully: (team: string, emails: string[], profiles?: unknown) => {
mockSendEmailInvitesCalls.push({team, emails, profiles});
if (team === 'incorrect-default-smtp') {
return ({type: 'MOCK_RECEIVED_ME', data: emails.map((email) => ({email, error: {message: '(From server) SMTP is not configured in System Console.', id: 'api.team.invite_members.unable_to_send_email_with_defaults.app_error'}}))});
} else if (emails.length > 21) { // Poor attempt to mock rate limiting.
return ({type: 'MOCK_RECEIVED_ME', data: emails.map((email) => ({email, error: {message: '(From server) Invite emails rate limit exceeded.'}}))});
} else if (team === 'error') {
return ({type: 'MOCK_RECEIVED_ME', data: emails.map((email) => ({email, error: {message: '(From server) Unable to add the user to the team.'}}))});
}
// team === 'correct' i.e no error
return ({type: 'MOCK_RECEIVED_ME', data: emails.map((email) => ({email, error: undefined}))});
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
sendEmailGuestInvitesToChannelsGracefully: (teamId: string, _channelIds: string[], emails: string[], _message: string, _guestMagicLink = false) => {
if (teamId === 'incorrect-default-smtp') {
return ({type: 'MOCK_RECEIVED_ME', data: emails.map((email) => ({email, error: {message: '(From server) SMTP is not configured in System Console.', id: 'api.team.invite_members.unable_to_send_email_with_defaults.app_error'}}))});
} else if (emails.length > 21) { // Poor attempt to mock rate limiting.
return ({type: 'MOCK_RECEIVED_ME', data: emails.map((email) => ({email, error: {message: '(From server) Invite emails rate limit exceeded.'}}))});
} else if (teamId === 'error') {
return ({type: 'MOCK_RECEIVED_ME', data: emails.map((email) => ({email, error: {message: '(From server) Unable to add the guest to the channels.'}}))});
}
// teamId === 'correct' i.e no error
return ({type: 'MOCK_RECEIVED_ME', data: emails.map((email) => ({email, error: undefined}))});
},
}));
describe('actions/invite_actions', () => {
const store = mockStore({
entities: {
general: {
config: {
DefaultClientLocale: 'en',
},
},
teams: {
teams: {
correct: {id: 'correct'},
error: {id: 'error'},
},
membersInTeam: {
correct: {
user1: TestHelper.getTeamMembershipMock({user_id: 'user1', team_id: 'correct'}),
user2: TestHelper.getTeamMembershipMock({user_id: 'user2', team_id: 'correct'}),
guest1: TestHelper.getTeamMembershipMock({user_id: 'guest1', team_id: 'correct'}),
guest2: TestHelper.getTeamMembershipMock({user_id: 'guest2', team_id: 'correct'}),
guest3: TestHelper.getTeamMembershipMock({user_id: 'guest3', team_id: 'correct'}),
},
error: {
user1: TestHelper.getTeamMembershipMock({user_id: 'user1', team_id: 'error'}),
user2: TestHelper.getTeamMembershipMock({user_id: 'user2', team_id: 'error'}),
guest1: TestHelper.getTeamMembershipMock({user_id: 'guest1', team_id: 'error'}),
guest2: TestHelper.getTeamMembershipMock({user_id: 'guest2', team_id: 'error'}),
guest3: TestHelper.getTeamMembershipMock({user_id: 'guest3', team_id: 'error'}),
},
},
myMembers: {},
},
channels: {
myMembers: {},
channels: {},
membersInChannel: {
correct: {
guest2: TestHelper.getChannelMembershipMock({user_id: 'guest2', channel_id: 'correct'}),
guest3: TestHelper.getChannelMembershipMock({user_id: 'guest3', channel_id: 'correct'}),
},
correct2: {
guest2: TestHelper.getChannelMembershipMock({user_id: 'guest2', channel_id: 'correct2'}),
},
error: {
guest2: TestHelper.getChannelMembershipMock({user_id: 'guest2', channel_id: 'error'}),
guest3: TestHelper.getChannelMembershipMock({user_id: 'guest3', channel_id: 'error'}),
},
},
},
users: {
currentUserId: 'user1',
profiles: {
user1: {
roles: 'system_admin',
},
},
},
},
});
describe('sendMembersInvites', () => {
it('should generate and empty list if nothing is passed', async () => {
const response = await store.dispatch(sendMembersInvites('correct', [], []));
expect(response).toEqual({
data: {
sent: [],
notSent: [],
},
});
});
it('should generate list of success for emails', async () => {
const emails = ['email-one@email-one.com', 'email-two@email-two.com', 'email-three@email-three.com'];
const response = await store.dispatch(sendMembersInvites('correct', [], emails));
expect(response).toEqual({
data: {
notSent: [],
sent: [
{
email: 'email-one@email-one.com',
reason: {
id: 'invite.members.invite-sent',
defaultMessage: 'An invitation email has been sent.',
},
},
{
email: 'email-two@email-two.com',
reason: {
id: 'invite.members.invite-sent',
defaultMessage: 'An invitation email has been sent.',
},
},
{
email: 'email-three@email-three.com',
reason: {
id: 'invite.members.invite-sent',
defaultMessage: 'An invitation email has been sent.',
},
},
],
},
});
});
it('should pass only the filled-in profiles of invited emails to the invite request', async () => {
mockSendEmailInvitesCalls.length = 0;
const emails = ['email-one@email-one.com', 'email-two@email-two.com'];
const profiles = {
'email-one@email-one.com': {
email: 'email-one@email-one.com',
username: 'email.one',
first_name: 'Email',
last_name: 'One',
},
'email-two@email-two.com': {
email: 'email-two@email-two.com',
username: '',
first_name: '',
last_name: '',
},
'not-invited@example.com': {
email: 'not-invited@example.com',
username: 'not.invited',
first_name: 'Not',
last_name: 'Invited',
},
};
await store.dispatch(sendMembersInvites('correct', [], emails, profiles));
expect(mockSendEmailInvitesCalls).toHaveLength(1);
expect(mockSendEmailInvitesCalls[0].profiles).toEqual([{
email: 'email-one@email-one.com',
username: 'email.one',
first_name: 'Email',
last_name: 'One',
}]);
});
it('should pass no profiles when none are provided', async () => {
mockSendEmailInvitesCalls.length = 0;
await store.dispatch(sendMembersInvites('correct', [], ['email-one@email-one.com']));
expect(mockSendEmailInvitesCalls).toHaveLength(1);
expect(mockSendEmailInvitesCalls[0].profiles).toEqual([]);
});
it('should generate list of failures for emails on invite fails', async () => {
const emails = ['email-one@email-one.com', 'email-two@email-two.com', 'email-three@email-three.com'];
const response = await store.dispatch(sendMembersInvites('error', [], emails));
expect(response).toEqual({
data: {
sent: [],
notSent: [
{
email: 'email-one@email-one.com',
reason: '(From server) Unable to add the user to the team.',
},
{
email: 'email-two@email-two.com',
reason: '(From server) Unable to add the user to the team.',
},
{
email: 'email-three@email-three.com',
reason: '(From server) Unable to add the user to the team.',
},
],
},
});
});
it('should generate list of failures and success for regular users and guests', async () => {
const users = [
{id: 'user1', roles: 'system_user'},
{id: 'guest1', roles: 'system_guest'},
{id: 'other-user', roles: 'system_user'},
{id: 'other-guest', roles: 'system_guest'},
] as UserProfile[];
const response = await store.dispatch(sendMembersInvites('correct', users, []));
expect(response).toEqual({
data: {
sent: [
{
reason: {
id: 'invite.members.added-to-team',
defaultMessage: 'This member has been added to the team.',
},
user: {
id: 'other-user',
roles: 'system_user',
},
},
],
notSent: [
{
reason: {
id: 'invite.members.already-member',
defaultMessage: 'This person is already a team member.',
},
user: {
id: 'user1',
roles: 'system_user',
},
},
{
reason: {
id: 'invite.members.user-is-guest',
defaultMessage: 'Contact your admin to make this guest a full member.',
},
user: {
id: 'guest1',
roles: 'system_guest',
},
},
{
reason: {
id: 'invite.members.user-is-guest',
defaultMessage: 'Contact your admin to make this guest a full member.',
},
user: {
id: 'other-guest',
roles: 'system_guest',
},
},
],
},
});
});
it('should generate a failure for problems adding a user', async () => {
const users = [
{id: 'user1', roles: 'system_user'},
{id: 'guest1', roles: 'system_guest'},
{id: 'other-user', roles: 'system_user'},
{id: 'other-guest', roles: 'system_guest'},
] as UserProfile[];
const response = await store.dispatch(sendMembersInvites('error', users, []));
expect(response).toEqual({
data: {
sent: [
{
reason: {
id: 'invite.members.added-to-team',
defaultMessage: 'This member has been added to the team.',
},
user: {
id: 'other-user',
roles: 'system_user',
},
},
],
notSent: [
{
reason: {
id: 'invite.members.already-member',
defaultMessage: 'This person is already a team member.',
},
user: {
id: 'user1',
roles: 'system_user',
},
},
{
reason: {
id: 'invite.members.user-is-guest',
defaultMessage: 'Contact your admin to make this guest a full member.',
},
user: {
id: 'guest1',
roles: 'system_guest',
},
},
{
reason: {
id: 'invite.members.user-is-guest',
defaultMessage: 'Contact your admin to make this guest a full member.',
},
user: {
id: 'other-guest',
roles: 'system_guest',
},
},
],
},
});
});
it('should generate a failure for rate limits', async () => {
const emails = [];
const expectedNotSent = [];
for (let i = 0; i < 22; i++) {
emails.push('email-' + i + '@example.com');
expectedNotSent.push({
email: 'email-' + i + '@example.com',
reason: '(From server) Invite emails rate limit exceeded.',
});
}
const response = await store.dispatch(sendMembersInvites('correct', [], emails));
expect(response).toEqual({
data: {
notSent: expectedNotSent,
sent: [],
},
});
});
it('should generate a failure for smtp config', async () => {
const emails = ['email-one@email-one.com'];
const response = await store.dispatch(sendMembersInvites('incorrect-default-smtp', [], emails));
expect(response).toEqual({
data: {
notSent: [
{
email: 'email-one@email-one.com',
reason: {
id: 'admin.environment.smtp.smtpFailure',
defaultMessage: 'SMTP is not configured in System Console. Can be configured <a>here</a>.',
},
path: ConsolePages.SMTP,
}],
sent: [],
},
});
});
});
describe('sendGuestsInvites', () => {
it('should generate and empty list if nothing is passed', async () => {
const response = await store.dispatch(sendGuestsInvites('correct', [], [], [], ''));
expect(response).toEqual({
data: {
sent: [],
notSent: [],
},
});
});
it('should generate list of success for emails', async () => {
const channels = [{id: 'correct'}] as Channel[];
const emails = ['email-one@email-one.com', 'email-two@email-two.com', 'email-three@email-three.com'];
const response = await store.dispatch(sendGuestsInvites('correct', channels, [], emails, 'message'));
expect(response).toEqual({
data: {
notSent: [],
sent: [
{
email: 'email-one@email-one.com',
reason: {
id: 'invite.guests.added-to-channel',
defaultMessage: 'An invitation email has been sent.',
},
},
{
email: 'email-two@email-two.com',
reason: {
id: 'invite.guests.added-to-channel',
defaultMessage: 'An invitation email has been sent.',
},
},
{
email: 'email-three@email-three.com',
reason: {
id: 'invite.guests.added-to-channel',
defaultMessage: 'An invitation email has been sent.',
},
},
],
},
});
});
it('should generate list of failures for emails on invite fails', async () => {
const channels = [{id: 'correct'}] as Channel[];
const emails = ['email-one@email-one.com', 'email-two@email-two.com', 'email-three@email-three.com'];
const response = await store.dispatch(sendGuestsInvites('error', channels, [], emails, 'message'));
expect(response).toEqual({
data: {
sent: [],
notSent: [
{
email: 'email-one@email-one.com',
reason: '(From server) Unable to add the guest to the channels.',
},
{
email: 'email-two@email-two.com',
reason: '(From server) Unable to add the guest to the channels.',
},
{
email: 'email-three@email-three.com',
reason: '(From server) Unable to add the guest to the channels.',
},
],
},
});
});
it('should generate list of failures and success for regular users and guests', async () => {
const channels = [{id: 'correct'}] as Channel[];
const users = [
{id: 'user1', roles: 'system_user'},
{id: 'guest1', roles: 'system_guest'},
{id: 'other-user', roles: 'system_user'},
{id: 'other-guest', roles: 'system_guest'},
] as UserProfile[];
const response = await store.dispatch(sendGuestsInvites('correct', channels, users, [], 'message'));
expect(response).toEqual({
data: {
sent: [
{
reason: {
id: 'invite.guests.new-member',
defaultMessage: 'This guest has been added to the team and {count, plural, one {channel} other {channels}}.',
values: {count: channels.length},
},
user: {
id: 'guest1',
roles: 'system_guest',
},
},
{
reason: {
id: 'invite.guests.new-member',
defaultMessage: 'This guest has been added to the team and {count, plural, one {channel} other {channels}}.',
values: {count: channels.length},
},
user: {
id: 'other-guest',
roles: 'system_guest',
},
},
],
notSent: [
{
reason: {
id: 'invite.members.user-is-not-guest',
defaultMessage: 'This person is already a member of the workspace. Invite them as a member instead of a guest.',
},
user: {
id: 'user1',
roles: 'system_user',
},
},
{
reason: {
id: 'invite.members.user-is-not-guest',
defaultMessage: 'This person is already a member of the workspace. Invite them as a member instead of a guest.',
},
user: {
id: 'other-user',
roles: 'system_user',
},
},
],
},
});
});
it('should generate a failure for users that are part of all or some of the channels', async () => {
const users = [
{id: 'guest2', roles: 'system_guest'},
{id: 'guest3', roles: 'system_guest'},
] as UserProfile[];
const response = await store.dispatch(sendGuestsInvites('correct', [{id: 'correct'}, {id: 'correct2'}] as Channel[], users, [], 'message'));
expect(response).toEqual({
data: {
sent: [],
notSent: [
{
reason: {
id: 'invite.guests.already-all-channels-member',
defaultMessage: 'This person is already a member of all the channels.',
},
user: {
id: 'guest2',
roles: 'system_guest',
},
},
{
reason: {
id: 'invite.guests.already-some-channels-member',
defaultMessage: 'This person is already a member of some of the channels.',
},
user: {
id: 'guest3',
roles: 'system_guest',
},
},
],
},
});
});
it('should generate a failure for problems adding a user to team', async () => {
const users = [
{id: 'user1', roles: 'system_user'},
{id: 'guest1', roles: 'system_guest'},
{id: 'other-user', roles: 'system_user'},
{id: 'other-guest', roles: 'system_guest'},
] as UserProfile[];
const response = await store.dispatch(sendGuestsInvites('error', [{id: 'correct'}] as Channel[], users, [], 'message'));
expect(response).toEqual({
data: {
sent: [
{
user: {
id: 'guest1',
roles: 'system_guest',
},
reason: {
id: 'invite.guests.new-member',
defaultMessage: 'This guest has been added to the team and {count, plural, one {channel} other {channels}}.',
values: {
count: 1,
},
},
},
{
user: {
id: 'other-guest',
roles: 'system_guest',
},
reason: {
id: 'invite.guests.new-member',
defaultMessage: 'This guest has been added to the team and {count, plural, one {channel} other {channels}}.',
values: {
count: 1,
},
},
},
],
notSent: [
{
reason: {
id: 'invite.members.user-is-not-guest',
defaultMessage: 'This person is already a member of the workspace. Invite them as a member instead of a guest.',
},
user: {
id: 'user1',
roles: 'system_user',
},
},
{
reason: {
id: 'invite.members.user-is-not-guest',
defaultMessage: 'This person is already a member of the workspace. Invite them as a member instead of a guest.',
},
user: {
id: 'other-user',
roles: 'system_user',
},
},
],
},
});
});
it('should generate a failure for problems adding a user to channels', async () => {
const users = [
{id: 'user1', roles: 'system_user'},
{id: 'guest1', roles: 'system_guest'},
{id: 'other-user', roles: 'system_user'},
{id: 'other-guest', roles: 'system_guest'},
] as UserProfile[];
const response = await store.dispatch(sendGuestsInvites('correct', [{id: 'error'}] as Channel[], users, [], 'message'));
expect(response).toEqual({
data: {
sent: [],
notSent: [
{
reason: {
id: 'invite.members.user-is-not-guest',
defaultMessage: 'This person is already a member of the workspace. Invite them as a member instead of a guest.',
},
user: {
id: 'user1',
roles: 'system_user',
},
},
{
reason: {
id: 'invite.guests.unable-to-add-the-user-to-the-channels',
defaultMessage: 'Unable to add the guest to the channels.',
},
user: {
id: 'guest1',
roles: 'system_guest',
},
},
{
reason: {
id: 'invite.members.user-is-not-guest',
defaultMessage: 'This person is already a member of the workspace. Invite them as a member instead of a guest.',
},
user: {
id: 'other-user',
roles: 'system_user',
},
},
{
reason: {
id: 'invite.guests.unable-to-add-the-user-to-the-channels',
defaultMessage: 'Unable to add the guest to the channels.',
},
user: {
id: 'other-guest',
roles: 'system_guest',
},
},
],
},
});
});
it('should generate a failure for rate limits', async () => {
const emails = [];
const expectedNotSent = [];
for (let i = 0; i < 22; i++) {
emails.push('email-' + i + '@example.com');
expectedNotSent.push({
email: 'email-' + i + '@example.com',
reason: '(From server) Invite emails rate limit exceeded.',
});
}
const response = await store.dispatch(sendGuestsInvites('correct', [{id: 'correct'}] as Channel[], [], emails, 'message'));
expect(response).toEqual({
data: {
notSent: expectedNotSent,
sent: [],
},
});
});
it('should generate a failure for smtp config', async () => {
const emails = ['email-one@email-one.com'];
const response = await store.dispatch(sendGuestsInvites('incorrect-default-smtp', [{id: 'error'}] as Channel[], [], emails, 'message'));
expect(response).toEqual({
data: {
notSent: [
{
email: 'email-one@email-one.com',
reason: {
id: 'admin.environment.smtp.smtpFailure',
defaultMessage: 'SMTP is not configured in System Console. Can be configured <a>here</a>.',
},
path: ConsolePages.SMTP,
}],
sent: [],
},
});
});
it('should accept guestMagicLink parameter', async () => {
const channels = [{id: 'correct'}] as Channel[];
const emails = ['email-one@email-one.com'];
const response = await store.dispatch(sendGuestsInvites('correct', channels, [], emails, 'message', true));
expect(response).toEqual({
data: {
sent: [
{
email: 'email-one@email-one.com',
reason: {
id: 'invite.guests.added-to-channel',
defaultMessage: 'An invitation email has been sent.',
},
},
],
notSent: [],
},
});
});
});
});

View File

@@ -0,0 +1,484 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {defineMessage} from 'react-intl';
import type {Channel, ChannelMembership} from '@mattermost/types/channels';
import type {TeamMemberWithError, TeamInviteWithError, MemberInviteProfile} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import type {RelationOneToOne} from '@mattermost/types/utilities';
import {joinChannel} from 'mattermost-redux/actions/channels';
import * as TeamActions from 'mattermost-redux/actions/teams';
import {getChannelMembersInChannels} from 'mattermost-redux/selectors/entities/channels';
import {getTeamMember} from 'mattermost-redux/selectors/entities/teams';
import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import {isGuest} from 'mattermost-redux/utils/user_utils';
import {addUsersToTeam} from 'actions/team_actions';
import type {InviteResult} from 'components/invitation_modal/result_table';
import type {InviteResults} from 'components/invitation_modal/result_view';
import {ConsolePages} from 'utils/constants';
import {filterProfilesForEmails} from 'utils/member_invite_profiles';
import type {DispatchFunc, ActionFuncAsync} from 'types/store';
export function sendMembersInvites(teamId: string, users: UserProfile[], emails: string[], profiles?: Record<string, MemberInviteProfile>): ActionFuncAsync<InviteResults> {
return async (dispatch, getState) => {
if (users.length > 0) {
await dispatch(TeamActions.getTeamMembersByIds(teamId, users.map((u) => u.id)));
}
const state = getState();
const sent = [];
const notSent = [];
const usersToAdd = [];
for (const user of users) {
const member = getTeamMember(state, teamId, user.id);
if (isGuest(user.roles)) {
notSent.push({
user,
reason: defineMessage({
id: 'invite.members.user-is-guest',
defaultMessage: 'Contact your admin to make this guest a full member.',
}),
});
} else if (member) {
notSent.push({
user,
reason: defineMessage({
id: 'invite.members.already-member',
defaultMessage: 'This person is already a team member.',
}),
});
} else {
usersToAdd.push(user);
}
}
if (usersToAdd.length > 0) {
const response = await dispatch(addUsersToTeam(teamId, usersToAdd.map((u) => u.id)));
const members = response.data || [];
if (response.error) {
for (const userToAdd of usersToAdd) {
notSent.push({user: userToAdd, reason: response.error.message});
}
} else {
for (const userToAdd of usersToAdd) {
const memberWithError = members.find((m: TeamMemberWithError) => m.user_id === userToAdd.id && m.error);
if (memberWithError) {
notSent.push({
user: userToAdd,
reason: memberWithError.error.message,
});
} else {
sent.push({
user: userToAdd,
reason: defineMessage({
id: 'invite.members.added-to-team',
defaultMessage: 'This member has been added to the team.',
}),
});
}
}
}
}
if (emails.length > 0) {
let response;
try {
response = await dispatch(TeamActions.sendEmailInvitesToTeamGracefully(teamId, emails, filterProfilesForEmails(profiles, emails)));
} catch {
response = {
data: emails.map((email) => ({
email,
error: {
error: defineMessage({
id: 'invite.members.unable-to-add-the-user-to-the-team',
defaultMessage: 'Unable to add the user to the team.',
}),
},
})) as unknown as TeamInviteWithError[],
};
}
const invitesWithErrors = response.data || [];
if (response.error) {
if (response.error.server_error_id === 'app.email.rate_limit_exceeded.app_error') {
response.error.message = defineMessage({
id: 'invite.rate-limit-exceeded',
defaultMessage: 'Invite emails rate limit exceeded.',
});
}
for (const email of emails) {
notSent.push({
email,
reason: response.error.message,
});
}
} else {
for (const email of emails) {
const inviteWithError = invitesWithErrors.find((i: TeamInviteWithError) => email.toLowerCase() === i.email && i.error);
if (inviteWithError && inviteWithError.error.id === 'api.team.invite_members.unable_to_send_email_with_defaults.app_error' && isCurrentUserSystemAdmin(state)) {
notSent.push({
email,
reason: defineMessage({
id: 'admin.environment.smtp.smtpFailure',
// eslint-disable-next-line formatjs/enforce-placeholders -- a placeholder provided via messageWithLink when path is set
defaultMessage: 'SMTP is not configured in System Console. Can be configured <a>here</a>.',
}),
path: ConsolePages.SMTP,
});
} else if (inviteWithError) {
notSent.push({
email,
reason: inviteWithError.error.message,
});
} else {
sent.push({
email,
reason: defineMessage({
id: 'invite.members.invite-sent',
defaultMessage: 'An invitation email has been sent.',
}),
});
}
}
}
}
return {
data: {
sent,
notSent,
},
};
};
}
export async function sendGuestInviteForUser(
dispatch: DispatchFunc,
user: UserProfile,
teamId: string,
channels: Channel[],
members: RelationOneToOne<Channel, Record<string, ChannelMembership>>,
): Promise<({sent: InviteResult} | {notSent: InviteResult})> {
if (!isGuest(user.roles)) {
return {
notSent: {
user,
reason: defineMessage({
id: 'invite.members.user-is-not-guest',
defaultMessage: 'This person is already a member of the workspace. Invite them as a member instead of a guest.',
}),
},
};
}
let memberOfAll = true;
let memberOfAny = false;
for (const channel of channels) {
const member = members && members[channel.id] && members[channel.id][user.id];
if (member) {
memberOfAny = true;
} else {
memberOfAll = false;
}
}
if (memberOfAll) {
return {
notSent: {
user,
reason: defineMessage({
id: 'invite.guests.already-all-channels-member',
defaultMessage: 'This person is already a member of all the channels.',
}),
},
};
}
try {
await dispatch(addUsersToTeam(teamId, [user.id]));
for (const channel of channels) {
const member = members && members[channel.id] && members[channel.id][user.id];
if (!member) {
await dispatch(joinChannel(user.id, teamId, channel.id, channel.name)); // eslint-disable-line no-await-in-loop
}
}
} catch {
return {
notSent: {
user,
reason: defineMessage({
id: 'invite.guests.unable-to-add-the-user-to-the-channels',
defaultMessage: 'Unable to add the guest to the channels.',
}),
},
};
}
if (memberOfAny) {
return {
notSent: {
user,
reason: defineMessage({
id: 'invite.guests.already-some-channels-member',
defaultMessage: 'This person is already a member of some of the channels.',
}),
},
};
}
return {
sent: {
user,
reason: defineMessage({
id: 'invite.guests.new-member',
// eslint-disable-next-line formatjs/enforce-placeholders -- count provided via values property, consumed by FormattedMessage in result_table
defaultMessage: 'This guest has been added to the team and {count, plural, one {channel} other {channels}}.',
values: {
count: channels.length,
},
}),
},
};
}
export function sendGuestsInvites(
teamId: string,
channels: Channel[],
users: UserProfile[],
emails: string[],
message: string,
guestMagicLink = false,
): ActionFuncAsync<InviteResults> {
return async (dispatch, getState) => {
const state = getState();
const sent = [];
const notSent = [];
const members = getChannelMembersInChannels(state);
const results = await Promise.all(users.map((user) => sendGuestInviteForUser(dispatch, user, teamId, channels, members)));
for (const result of results) {
if ('sent' in result && result.sent) {
sent.push(result.sent);
}
if ('notSent' in result && result.notSent) {
notSent.push(result.notSent);
}
}
if (emails.length > 0) {
let response;
try {
response = await dispatch(TeamActions.sendEmailGuestInvitesToChannelsGracefully(teamId, channels.map((x) => x.id), emails, message, guestMagicLink));
} catch {
response = {
data: emails.map((email) => ({
email,
error: {
error: defineMessage({
id: 'invite.guests.unable-to-add-the-user-to-the-channels',
defaultMessage: 'Unable to add the guest to the channels.',
}),
},
})) as unknown as TeamInviteWithError[],
};
}
if (response.error) {
if (response.error.server_error_id === 'app.email.rate_limit_exceeded.app_error') {
response.error.message = defineMessage({
id: 'invite.rate-limit-exceeded',
defaultMessage: 'Invite emails rate limit exceeded.',
});
}
for (const email of emails) {
notSent.push({
email,
reason: response.error.message,
});
}
} else {
for (const res of (response.data || [])) {
if (res.error) {
if (res.error.id === 'api.team.invite_members.unable_to_send_email_with_defaults.app_error' && isCurrentUserSystemAdmin(state)) {
notSent.push({
email: res.email,
reason: defineMessage({
id: 'admin.environment.smtp.smtpFailure',
// eslint-disable-next-line formatjs/enforce-placeholders -- a placeholder provided via messageWithLink when path is set
defaultMessage: 'SMTP is not configured in System Console. Can be configured <a>here</a>.',
}),
path: ConsolePages.SMTP,
});
} else {
notSent.push({
email: res.email,
reason: res.error.message,
});
}
} else {
sent.push({
email: res.email,
reason: defineMessage({
id: 'invite.guests.added-to-channel',
defaultMessage: 'An invitation email has been sent.',
}),
});
}
}
}
}
return {
data: {
sent,
notSent,
},
};
};
}
export function sendMembersInvitesToChannels(
teamId: string,
channels: Channel[],
users: UserProfile[],
emails: string[],
message: string,
profiles?: Record<string, MemberInviteProfile>,
): ActionFuncAsync<InviteResults> {
return async (dispatch, getState) => {
if (users.length > 0) {
// used to preload in the global store the teammembers info, used later to validate
// if one of the invites is already part of the team by getTeamMembers > getMembersInTeam.
await dispatch(TeamActions.getTeamMembersByIds(teamId, users.map((u) => u.id)));
}
const state = getState();
const sent = [];
const notSent = [];
const usersToAdd = [];
for (const user of users) {
const member = getTeamMember(state, teamId, user.id);
if (isGuest(user.roles)) {
notSent.push({
user,
reason: defineMessage({
id: 'invite.members.user-is-guest',
defaultMessage: 'Contact your admin to make this guest a full member.',
}),
});
} else if (member) {
notSent.push({
user,
reason: defineMessage({
id: 'invite.members.already-member',
defaultMessage: 'This person is already a team member.',
}),
});
} else {
usersToAdd.push(user);
}
}
if (usersToAdd.length > 0) {
const response = await dispatch(addUsersToTeam(teamId, usersToAdd.map((u) => u.id)));
const members = response.data || [];
if (response.error) {
for (const userToAdd of usersToAdd) {
notSent.push({
user: userToAdd,
reason: response.error.message,
});
}
} else {
for (const userToAdd of usersToAdd) {
const memberWithError = members.find((m: TeamMemberWithError) => m.user_id === userToAdd.id && m.error);
if (memberWithError) {
notSent.push({
user: userToAdd,
reason: memberWithError.error.message,
});
} else {
sent.push({
user: userToAdd,
reason: defineMessage({
id: 'invite.members.added-to-team',
defaultMessage: 'This member has been added to the team.',
}),
});
}
}
}
}
if (emails.length > 0) {
let response;
try {
response = await dispatch(
TeamActions.sendEmailInvitesToTeamAndChannelsGracefully(
teamId,
channels.map((x) => x.id),
emails,
message,
filterProfilesForEmails(profiles, emails),
),
);
} catch {
response = {
data: emails.map((email) => ({
email,
error: {
error: defineMessage({
id: 'invite.members.unable-to-add-the-user-to-the-team',
defaultMessage: 'Unable to add the user to the team.',
}),
},
})) as unknown as TeamInviteWithError[],
};
}
const invitesWithErrors = response.data || [];
if (response.error) {
if (response.error.server_error_id === 'app.email.rate_limit_exceeded.app_error') {
response.error.message = defineMessage({id: 'invite.rate-limit-exceeded', defaultMessage: 'Invite emails rate limit exceeded.'});
}
for (const email of emails) {
notSent.push({
email,
reason: response.error.message,
});
}
} else {
for (const email of emails) {
const inviteWithError = invitesWithErrors.find((i: TeamInviteWithError) => email.toLowerCase() === i.email && i.error);
if (inviteWithError) {
if (inviteWithError.error.id === 'api.team.invite_members.unable_to_send_email_with_defaults.app_error' && isCurrentUserSystemAdmin(state)) {
notSent.push({
email,
reason: defineMessage({
id: 'admin.environment.smtp.smtpFailure',
// eslint-disable-next-line formatjs/enforce-placeholders -- a placeholder provided via messageWithLink when path is set
defaultMessage: 'SMTP is not configured in System Console. Can be configured <a>here</a>.',
}),
path: ConsolePages.SMTP,
});
} else {
notSent.push({
email,
reason: inviteWithError.error.message,
});
}
} else {
sent.push({
email,
reason: defineMessage({
id: 'invite.members.invite-sent',
defaultMessage: 'An invitation email has been sent.',
}),
});
}
}
}
}
return {
data: {
sent,
notSent,
},
};
};
}

View File

@@ -0,0 +1,187 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {AppCall, AppExpand, AppFormValues} from '@mattermost/types/apps';
import type {MarketplaceApp, MarketplacePlugin} from '@mattermost/types/marketplace';
import {Client4} from 'mattermost-redux/client';
import {AppBindingLocations, AppCallResponseTypes} from 'mattermost-redux/constants/apps';
import {appsEnabled} from 'mattermost-redux/selectors/entities/apps';
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getFilter, getPlugin} from 'selectors/views/marketplace';
import type {DoAppCallResult} from 'components/suggestion/command_provider/app_command_parser/app_command_parser_dependencies';
import {createCallContext, createCallRequest} from 'utils/apps';
import {ActionTypes} from 'utils/constants';
import {getIntl} from 'utils/i18n';
import type {ActionFuncAsync, ThunkActionFunc} from 'types/store';
import {doAppSubmit, openAppsModal, postEphemeralCallResponseForContext} from './apps';
// fetchPlugins fetches the latest marketplace plugins and apps, subject to any existing search filter.
export function fetchListing(localOnly = false): ActionFuncAsync<Array<MarketplacePlugin | MarketplaceApp>> {
return async (dispatch, getState) => {
const state = getState();
const filter = getFilter(state);
let plugins: MarketplacePlugin[];
let apps: MarketplaceApp[] = [];
try {
plugins = await Client4.getMarketplacePlugins(filter, localOnly);
} catch (error: any) {
// If the marketplace server is unreachable, try to get the local plugins only.
if (error.server_error_id === 'app.plugin.marketplace_client.failed_to_fetch' && !localOnly) {
await dispatch(fetchListing(true));
}
return {error};
}
dispatch({
type: ActionTypes.RECEIVED_MARKETPLACE_PLUGINS,
plugins,
});
if (appsEnabled(state)) {
try {
apps = await Client4.getMarketplaceApps(filter);
} catch {
return {data: plugins};
}
dispatch({
type: ActionTypes.RECEIVED_MARKETPLACE_APPS,
apps,
});
}
if (plugins) {
return {data: (plugins as Array<MarketplacePlugin | MarketplaceApp>).concat(apps)};
}
return {data: apps};
};
}
// filterListing sets a search filter for marketplace listing, fetching the latest data.
export function filterListing(filter: string): ReturnType<typeof fetchListing> {
return async (dispatch) => {
dispatch({
type: ActionTypes.FILTER_MARKETPLACE_LISTING,
filter,
});
return dispatch(fetchListing());
};
}
// installPlugin installs the latest version of the given plugin from the marketplace.
//
// On success, it also requests the current state of the plugins to reflect the newly installed plugin.
export function installPlugin(id: string): ThunkActionFunc<void> {
return async (dispatch, getState) => {
dispatch({
type: ActionTypes.INSTALLING_MARKETPLACE_ITEM,
id,
});
const state = getState();
const marketplacePlugin = getPlugin(state, id);
if (!marketplacePlugin) {
dispatch({
type: ActionTypes.INSTALLING_MARKETPLACE_ITEM_FAILED,
id,
error: 'Unknown plugin: ' + id,
});
return;
}
try {
await Client4.installMarketplacePlugin(id);
} catch (error: any) {
dispatch({
type: ActionTypes.INSTALLING_MARKETPLACE_ITEM_FAILED,
id,
error: error.message,
});
return;
}
await dispatch(fetchListing());
dispatch({
type: ActionTypes.INSTALLING_MARKETPLACE_ITEM_SUCCEEDED,
id,
});
};
}
// installApp installed an App using a given URL a call to the `/install-listed` call path.
//
// On success, it also requests the current state of the apps to reflect the newly installed app.
export function installApp(id: string): ThunkActionFunc<Promise<boolean>> {
return async (dispatch, getState) => {
const intl = getIntl();
dispatch({
type: ActionTypes.INSTALLING_MARKETPLACE_ITEM,
id,
});
const callPath = '/install-listed';
const call: AppCall = {
path: callPath,
};
const expand: AppExpand = {
acting_user: '+summary',
locale: 'all',
};
const values: AppFormValues = {
app: {
label: id,
value: id,
},
};
const state = getState();
const channelID = getCurrentChannelId(state);
const teamID = getCurrentTeamId(state);
const location = AppBindingLocations.MARKETPLACE;
const context = createCallContext('apps', location, channelID, teamID);
const creq = createCallRequest(call, context, expand, values);
const res = await dispatch(doAppSubmit(creq, intl)) as DoAppCallResult;
if (res.error) {
const errorResponse = res.error;
dispatch({
type: ActionTypes.INSTALLING_MARKETPLACE_ITEM_FAILED,
id,
error: errorResponse.text,
});
return false;
}
dispatch({
type: ActionTypes.INSTALLING_MARKETPLACE_ITEM_SUCCEEDED,
id,
});
const callResp = res.data!;
if (callResp.type === AppCallResponseTypes.FORM && callResp.form) {
dispatch(openAppsModal(callResp.form, creq.context));
}
if (callResp.text) {
dispatch(postEphemeralCallResponseForContext(callResp, callResp.text, context));
}
return true;
};
}

View File

@@ -0,0 +1,518 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Post} from '@mattermost/types/posts';
import type {GlobalState} from '@mattermost/types/store';
import {ChannelTypes} from 'mattermost-redux/action_types';
import {receivedNewPost} from 'mattermost-redux/actions/posts';
import {Posts} from 'mattermost-redux/constants';
import * as NewPostActions from 'actions/new_post';
import mockStore from 'tests/test_store';
import {Constants} from 'utils/constants';
jest.mock('mattermost-redux/actions/channels', () => ({
...jest.requireActual('mattermost-redux/actions/channels'),
markChannelAsReadOnServer: (...args: any[]) => ({type: 'MOCK_MARK_CHANNEL_AS_READ_ON_SERVER', args}),
markChannelAsViewedOnServer: (...args: any[]) => ({type: 'MOCK_MARK_CHANNEL_AS_VIEWED_ON_SERVER', args}),
}));
const POST_CREATED_TIME = Date.now();
// This mocks the Date.now() function so it returns a constant value
global.Date.now = jest.fn(() => POST_CREATED_TIME);
const newPostMessageProps = {} as NewPostActions.NewPostMessageProps;
const INCREASED_POST_VISIBILITY = {amount: 1, data: 'current_channel_id', type: 'INCREASE_POST_VISIBILITY'};
describe('actions/new_post', () => {
const latestPost = {
id: 'latest_post_id',
user_id: 'current_user_id',
message: 'test msg',
channel_id: 'current_channel_id',
};
const initialState = {
entities: {
posts: {
posts: {
[latestPost.id]: latestPost,
},
postsInChannel: {
current_channel_id: [
{order: [latestPost.id], recent: true},
],
},
postsInThread: {},
messagesHistory: {
index: {
[Posts.MESSAGE_TYPES.COMMENT]: 0,
},
messages: ['test message'],
},
},
channels: {
currentChannelId: 'current_channel_id',
messageCounts: {},
myMembers: {[latestPost.channel_id]: {channel_id: 'current_channel_id', user_id: 'current_user_id'}},
channels: {
current_channel_id: {id: 'current_channel_id'},
},
},
teams: {
currentTeamId: 'team-1',
teams: {
team_id: {
id: 'team_id',
name: 'team-1',
displayName: 'Team 1',
},
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {},
},
},
general: {
license: {IsLicensed: 'false'},
config: {
TeammateNameDisplay: 'username',
},
},
preferences: {
myPreferences: {},
},
},
views: {
posts: {
editingPost: {},
},
},
} as unknown as GlobalState;
test('completePostReceive', async () => {
const testStore = mockStore(initialState);
const newPost = {id: 'new_post_id', channel_id: 'current_channel_id', message: 'new message', type: Constants.PostTypes.ADD_TO_CHANNEL, user_id: 'some_user_id', create_at: POST_CREATED_TIME, props: {addedUserId: 'other_user_id'}} as unknown as Post;
const websocketProps: NewPostActions.NewPostMessageProps = {
team_id: 'team_id',
mentions: JSON.stringify(['current_user_id']),
should_ack: false,
channel_display_name: '',
channel_name: '',
channel_type: 'P',
post: JSON.stringify(newPost),
sender_name: '',
set_online: false,
};
await testStore.dispatch(NewPostActions.completePostReceive(newPost, websocketProps));
expect(testStore.getActions()).toEqual([
{
meta: {batch: true},
payload: [
INCREASED_POST_VISIBILITY,
receivedNewPost(newPost, false),
],
type: 'BATCHING_REDUCER.BATCH',
},
]);
});
describe('setChannelReadAndViewed', () => {
test('should mark channel as read when viewing channel', () => {
const channelId = 'channel';
const currentUserId = 'user';
const post1 = {id: 'post1', channel_id: channelId, create_at: 1000} as Post;
const post2 = {id: 'post2', channel_id: channelId, create_at: 2000} as Post;
const testStore = mockStore({
entities: {
channels: {
currentChannelId: channelId,
manuallyUnread: {},
messageCounts: {
[channelId]: {total: 0},
},
myMembers: {
[channelId]: {channel_id: channelId, last_viewed_at: 0, roles: ''},
},
channels: {
[channelId]: {id: channelId},
},
},
posts: {
posts: {
[post1.id]: post1,
},
},
users: {
currentUserId,
},
general: {
config: {
test: true,
},
},
preferences: {
myPreferences: {},
},
},
} as unknown as GlobalState);
window.isActive = true;
const actions = NewPostActions.setChannelReadAndViewed(testStore.dispatch, testStore.getState, post2, newPostMessageProps, false);
expect(actions).toMatchObject([
{
type: ChannelTypes.DECREMENT_UNREAD_MSG_COUNT,
data: {
channelId,
},
},
{
type: ChannelTypes.DECREMENT_UNREAD_MENTION_COUNT,
data: {
channelId,
},
},
{
type: ChannelTypes.RECEIVED_LAST_VIEWED_AT,
data: {
channel_id: channelId,
},
},
]);
expect(testStore.getActions()).toMatchObject([
{
type: 'MOCK_MARK_CHANNEL_AS_VIEWED_ON_SERVER',
args: [channelId],
},
]);
});
test('should mark channel as unread when not actively viewing channel', () => {
const channelId = 'channel';
const currentUserId = 'user';
const post1 = {id: 'post1', channel_id: channelId, create_at: 1000} as Post;
const post2 = {id: 'post2', channel_id: channelId, create_at: 2000} as Post;
const testStore = mockStore({
entities: {
channels: {
currentChannelId: channelId,
manuallyUnread: {},
messageCounts: {
[channelId]: {total: 0},
},
myMembers: {
[channelId]: {channel_id: channelId, last_viewed_at: 0, roles: ''},
},
channels: {
[channelId]: {id: channelId},
},
},
posts: {
posts: {
[post1.id]: post1,
},
},
users: {
currentUserId,
},
general: {
config: {
test: true,
},
},
preferences: {
myPreferences: {},
},
},
} as unknown as GlobalState);
window.isActive = false;
const actions = NewPostActions.setChannelReadAndViewed(testStore.dispatch, testStore.getState, post2, newPostMessageProps, false);
expect(actions).toMatchObject([
{
type: ChannelTypes.INCREMENT_UNREAD_MSG_COUNT,
data: {
channelId,
},
},
{
type: ChannelTypes.INCREMENT_TOTAL_MSG_COUNT,
data: {
channelId,
},
},
]);
expect(testStore.getActions()).toEqual([]);
});
test('should not mark channel as read when not viewing channel', () => {
const channelId = 'channel1';
const otherChannelId = 'channel2';
const currentUserId = 'user';
const post1 = {id: 'post1', channel_id: channelId, create_at: 1000} as Post;
const post2 = {id: 'post2', channel_id: channelId, create_at: 2000} as Post;
const testStore = mockStore({
entities: {
channels: {
currentChannelId: otherChannelId,
manuallyUnread: {},
messageCounts: {
[channelId]: {total: 0},
[otherChannelId]: {total: 0},
},
myMembers: {
[channelId]: {channel_id: channelId, last_viewed_at: 500, roles: ''},
[otherChannelId]: {channel_id: otherChannelId, last_viewed_at: 500, roles: ''},
},
channels: {
[channelId]: {id: channelId},
[otherChannelId]: {id: otherChannelId},
},
},
posts: {
posts: {
[post1.id]: post1,
},
},
users: {
currentUserId,
},
general: {
config: {
test: true,
},
},
preferences: {
myPreferences: {},
},
},
} as unknown as GlobalState);
window.isActive = true;
const actions = NewPostActions.setChannelReadAndViewed(testStore.dispatch, testStore.getState, post2, newPostMessageProps, false);
expect(actions).toMatchObject([
{
type: ChannelTypes.INCREMENT_UNREAD_MSG_COUNT,
data: {
channelId,
},
},
{
type: ChannelTypes.INCREMENT_TOTAL_MSG_COUNT,
data: {
channelId,
},
},
]);
expect(testStore.getActions()).toEqual([]);
});
test('should mark channel as read when not viewing channel and post is from current user', () => {
const channelId = 'channel1';
const otherChannelId = 'channel2';
const currentUserId = 'user';
const post1 = {id: 'post1', channel_id: channelId, create_at: 1000} as Post;
const post2 = {id: 'post2', channel_id: channelId, create_at: 2000, user_id: currentUserId} as Post;
const testStore = mockStore({
entities: {
channels: {
currentChannelId: otherChannelId,
manuallyUnread: {},
messageCounts: {
[channelId]: {total: 0},
[otherChannelId]: {total: 0},
},
myMembers: {
[channelId]: {channel_id: channelId, last_viewed_at: 500, roles: ''},
[otherChannelId]: {channel_id: otherChannelId, last_viewed_at: 500, roles: ''},
},
channels: {
[channelId]: {id: channelId},
[otherChannelId]: {id: otherChannelId},
},
},
posts: {
posts: {
[post1.id]: post1,
},
},
users: {
currentUserId,
},
general: {
config: {
test: true,
},
},
preferences: {
myPreferences: {},
},
},
} as unknown as GlobalState);
const actions = NewPostActions.setChannelReadAndViewed(testStore.dispatch, testStore.getState, post2, {} as NewPostActions.NewPostMessageProps, false);
expect(actions).toMatchObject([
{
type: ChannelTypes.DECREMENT_UNREAD_MSG_COUNT,
data: {
channelId,
},
},
{
type: ChannelTypes.DECREMENT_UNREAD_MENTION_COUNT,
data: {
channelId,
},
},
{
type: ChannelTypes.RECEIVED_LAST_VIEWED_AT,
data: {
channel_id: channelId,
},
},
]);
// The post is from the current user, so no request should be made to the server to mark it as read
expect(testStore.getActions()).toEqual([]);
});
test('should mark channel as unread when not viewing channel and post is from webhook owned by current user', async () => {
const channelId = 'channel1';
const otherChannelId = 'channel2';
const currentUserId = 'user';
const post1 = {id: 'post1', channel_id: channelId, create_at: 1000} as Post;
const post2 = {id: 'post2', channel_id: channelId, create_at: 2000, props: {from_webhook: 'true'}, user_id: currentUserId} as unknown as Post;
const testStore = mockStore({
entities: {
channels: {
currentChannelId: otherChannelId,
manuallyUnread: {},
messageCounts: {
[channelId]: {total: 0},
[otherChannelId]: {total: 0},
},
myMembers: {
[channelId]: {channel_id: channelId, last_viewed_at: 500, roles: ''},
[otherChannelId]: {channel_id: otherChannelId, last_viewed_at: 500, roles: ''},
},
channels: {
[channelId]: {id: channelId},
[otherChannelId]: {id: otherChannelId},
},
},
posts: {
posts: {
[post1.id]: post1,
},
},
users: {
currentUserId,
},
general: {
config: {
test: true,
},
},
preferences: {
myPreferences: {},
},
},
} as unknown as GlobalState);
const actions = NewPostActions.setChannelReadAndViewed(testStore.dispatch, testStore.getState, post2, newPostMessageProps, false);
expect(actions).toMatchObject([
{
type: ChannelTypes.INCREMENT_UNREAD_MSG_COUNT,
data: {
channelId,
},
},
{
type: ChannelTypes.INCREMENT_TOTAL_MSG_COUNT,
data: {
channelId,
},
},
]);
expect(testStore.getActions()).toEqual([]);
});
test('should not mark channel as read when viewing channel that was marked as unread', () => {
const channelId = 'channel1';
const currentUserId = 'user';
const post1 = {id: 'post1', channel_id: channelId, create_at: 1000} as Post;
const post2 = {id: 'post2', channel_id: channelId, create_at: 2000} as Post;
const testStore = mockStore({
entities: {
channels: {
currentChannelId: channelId,
manuallyUnread: {
[channelId]: true,
},
messageCounts: {
[channelId]: {total: 0},
},
myMembers: {
[channelId]: {channel_id: channelId, last_viewed_at: post1.create_at - 1, roles: ''},
},
channels: {
[channelId]: {id: channelId},
},
},
posts: {
posts: {
[post1.id]: post1,
},
},
users: {
currentUserId,
},
},
} as unknown as GlobalState);
const actions = NewPostActions.setChannelReadAndViewed(testStore.dispatch, testStore.getState, post2, newPostMessageProps, false);
expect(actions).toMatchObject([
{
type: ChannelTypes.INCREMENT_UNREAD_MSG_COUNT,
data: {
channelId,
},
},
{
type: ChannelTypes.INCREMENT_TOTAL_MSG_COUNT,
data: {
channelId,
},
},
]);
expect(testStore.getActions()).toEqual([]);
});
});
});

View File

@@ -0,0 +1,161 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {AnyAction} from 'redux';
import {batchActions} from 'redux-batched-actions';
import type {WebSocketMessages} from '@mattermost/client';
import type {Post} from '@mattermost/types/posts';
import {
actionsToMarkChannelAsRead,
actionsToMarkChannelAsUnread,
markChannelAsViewedOnServer,
} from 'mattermost-redux/actions/channels';
import * as PostActions from 'mattermost-redux/actions/posts';
import {getCurrentChannelId, isManuallyUnread} from 'mattermost-redux/selectors/entities/channels';
import * as PostSelectors from 'mattermost-redux/selectors/entities/posts';
import {isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {getThread} from 'mattermost-redux/selectors/entities/threads';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {
isFromWebhook,
isNotificationSuppressed,
isSystemMessage,
shouldIgnorePost,
} from 'mattermost-redux/utils/post_utils';
import {sendDesktopNotification} from 'actions/notification_actions';
import {updateThreadLastOpened} from 'actions/views/threads';
import {isThreadOpen, makeGetThreadLastViewedAt} from 'selectors/views/threads';
import WebSocketClient from 'client/web_websocket_client';
import {ActionTypes} from 'utils/constants';
import type {DispatchFunc, GetStateFunc, ActionFunc, ActionFuncAsync} from 'types/store';
export type NewPostMessageProps = Partial<WebSocketMessages.Posted['data']>;
export function completePostReceive(post: Post, websocketMessageProps: NewPostMessageProps, fetchedChannelMember?: boolean): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
const state = getState();
const rootPost = PostSelectors.getPost(state, post.root_id);
const isPostFromCurrentChannel = post.channel_id === getCurrentChannelId(state);
if (post.root_id && !rootPost && isPostFromCurrentChannel) {
const result = await dispatch(PostActions.getPostThread(post.root_id));
if ('error' in result) {
if (websocketMessageProps.should_ack) {
WebSocketClient.acknowledgePostedNotification(post.id, 'error', 'missing_root_post', result.error);
}
return {error: result.error};
}
}
const actions: AnyAction[] = [];
const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state);
const isCRTReply = collapsedThreadsEnabled && post.root_id;
if (isPostFromCurrentChannel) {
actions.push({
type: ActionTypes.INCREASE_POST_VISIBILITY,
data: post.channel_id,
amount: 1,
});
}
actions.push(
PostActions.receivedNewPost(post, collapsedThreadsEnabled),
);
const currentUserId = getCurrentUserId(state);
const isCRTReplyByCurrentUser = isCRTReply && post.user_id === currentUserId;
if (!isCRTReplyByCurrentUser) {
actions.push(
...setChannelReadAndViewed(dispatch, getState, post as Post, websocketMessageProps, fetchedChannelMember),
);
}
dispatch(batchActions(actions));
if (isCRTReply && !isNotificationSuppressed(post)) {
dispatch(setThreadRead(post));
}
const {status, reason, data} = (await dispatch(sendDesktopNotification(post, websocketMessageProps))).data!;
// Only ACK for posts that require it
if (websocketMessageProps.should_ack) {
WebSocketClient.acknowledgePostedNotification(post.id, status, reason, data);
}
return {data: true};
};
}
// setChannelReadAndViewed returns an array of actions to mark the channel read and viewed, and it dispatches an action
// to asynchronously mark the channel as read on the server if necessary.
export function setChannelReadAndViewed(dispatch: DispatchFunc, getState: GetStateFunc, post: Post, websocketMessageProps: NewPostMessageProps, fetchedChannelMember?: boolean): AnyAction[] {
const state = getState();
const currentUserId = getCurrentUserId(state);
// ignore system message posts, except when added to a team
if (shouldIgnorePost(post, currentUserId)) {
return [];
}
let markAsRead = false;
let markAsReadOnServer = false;
// Skip marking a channel as read (when the user is viewing a channel)
// if they have manually marked it as unread.
if (!isManuallyUnread(getState(), post.channel_id)) {
if (
post.user_id === getCurrentUserId(state) &&
!isSystemMessage(post) &&
!isFromWebhook(post)
) {
markAsRead = true;
markAsReadOnServer = false;
} else if (
post.channel_id === getCurrentChannelId(state) &&
window.isActive
) {
markAsRead = true;
markAsReadOnServer = true;
}
}
if (markAsRead) {
if (markAsReadOnServer) {
dispatch(markChannelAsViewedOnServer(post.channel_id));
}
return actionsToMarkChannelAsRead(getState, post.channel_id);
}
if (isNotificationSuppressed(post)) {
return [];
}
return actionsToMarkChannelAsUnread(getState, websocketMessageProps.team_id || '', post.channel_id, websocketMessageProps.mentions || '', fetchedChannelMember, post.root_id === '', post?.metadata?.priority?.priority);
}
export function setThreadRead(post: Post): ActionFunc<boolean> {
const getThreadLastViewedAt = makeGetThreadLastViewedAt();
return (dispatch, getState) => {
const state = getState();
const thread = getThread(state, post.root_id);
// mark a thread as read (when the user is viewing the thread)
if (thread && isThreadOpen(state, thread.id) && window.isActive) {
// update the new messages line (when there are no previous unreads)
if (thread.last_reply_at < getThreadLastViewedAt(state, thread.id)) {
dispatch(updateThreadLastOpened(thread.id, post.create_at));
}
}
return {data: true};
};
}

View File

@@ -0,0 +1,817 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ChannelMembership, ChannelNotifyProps, ChannelType} from '@mattermost/types/channels';
import type {Post, PostType} from '@mattermost/types/posts';
import type {PreferenceType} from '@mattermost/types/preferences';
import type {UserNotifyProps, UserProfile} from '@mattermost/types/users';
import type {DeepPartial} from '@mattermost/types/utilities';
import {MarkUnread} from 'mattermost-redux/constants/channels';
import testConfigureStore from 'tests/test_store';
import {getHistory} from 'utils/browser_history';
import Constants, {NotificationLevels, UserStatuses} from 'utils/constants';
import * as NotificationSounds from 'utils/notification_sounds';
import * as utils from 'utils/notifications';
import {getFocusedPopoutInfo} from 'utils/popouts/focus';
import type {GlobalState} from 'types/store';
import type {NewPostMessageProps} from './new_post';
import {sendDesktopNotification, isDesktopSoundEnabled, getDesktopNotificationSound} from './notification_actions';
jest.mock('utils/popouts/focus', () => ({
getFocusedPopoutInfo: jest.fn(() => null),
}));
describe('notification_actions', () => {
describe('sendDesktopNotification', () => {
let baseState: GlobalState;
let channelSettings: Partial<ChannelNotifyProps>;
let crt: Partial<PreferenceType>;
let msgProps: NewPostMessageProps;
let post: Post;
let spy: jest.SpyInstance<ReturnType<typeof utils.showNotification>, Parameters<typeof utils.showNotification>>;
let userSettings: Partial<UserNotifyProps>;
beforeEach(() => {
spy = jest.spyOn(utils, 'showNotification').mockReturnValue((async () => ({status: 'success'})) as unknown as ReturnType<typeof utils.showNotification>);
(NotificationSounds as {ding: typeof NotificationSounds.ding}).ding = jest.fn();
crt = {
user_id: 'current_user_id',
value: 'off',
};
channelSettings = {
desktop: NotificationLevels.ALL,
};
userSettings = {
desktop: NotificationLevels.ALL,
desktop_sound: false,
desktop_threads: NotificationLevels.ALL,
mention_keys: 'mentionkey',
first_name: 'true',
channel: 'true',
} as unknown as Partial<UserNotifyProps>;
post = {
id: 'post_id',
user_id: 'user_id',
root_id: 'root_id',
channel_id: 'channel_id',
props: {from_webhook: false},
message: 'Where is Jessica Hyde?',
} as unknown as Post;
msgProps = {
post: JSON.stringify(post),
channel_display_name: 'Utopia',
team_id: 'team_id',
};
baseState = {
entities: {
general: {
config: {
CollapsedThreads: 'default_off',
},
},
threads: {
threads: {},
},
users: {
statuses: {
current_user_id: 'online',
},
isManualStatus: {
current_user_id: false,
},
currentUserId: 'current_user_id',
profiles: {
user_id: {
id: 'user_id',
username: 'username',
},
current_user_id: {
id: 'current_user_id',
notify_props: userSettings,
username: 'currentusername',
first_name: 'currentuserfirstname',
},
},
profilesInChannel: {
gm_channel: new Set(['current_user_id']),
},
},
teams: {
currentTeamId: 'team_id',
teams: {
team_id: {
id: 'team_id',
name: 'team',
},
},
myMembers: {},
},
channels: {
currentChannelId: 'channel_id',
channels: {
channel_id: {
id: 'channel_id',
team_id: 'team_id',
display_name: 'Utopia',
name: 'utopia',
},
muted_channel_id: {
id: 'muted_channel_id',
display_name: 'Muted Channel',
team_id: 'team_id',
},
another_channel_id: {
id: 'another_channel_id',
team_id: 'team_id',
},
gm_channel: {
id: 'gm_channel',
type: 'G',
},
},
myMembers: {
channel_id: {
id: 'current_user_id',
notify_props: channelSettings,
},
gm_channel: {
id: 'gm_channel',
notify_props: channelSettings,
},
muted_channel_id: {
id: 'muted_channel_id',
team_id: 'team_id',
notify_props: {
mark_unread: MarkUnread.MENTION,
},
},
},
membersInChannel: {
channel_id: {
current_user_id: {
id: 'current_user_id',
notify_props: channelSettings,
},
},
gm_channel: {
current_user_id: {
id: 'gm_channel',
notify_props: channelSettings,
},
},
muted_channel_id: {
current_user_id: {
id: 'current_user_id',
notify_props: {
mark_unread: NotificationLevels.MENTION,
},
},
},
},
},
preferences: {
myPreferences: {
'display_settings--collapsed_reply_threads': crt,
},
},
groups: {
groups: {},
myGroups: [],
},
},
views: {
browser: {
focused: false,
},
threads: {
selectedThreadIdInTeam: {
team_id: 'another_root_id',
},
},
rhs: {
isSidebarOpen: true,
},
},
plugins: {
components: {
DesktopNotificationHooks: [],
},
},
} as unknown as GlobalState;
});
test('should notify user', async () => {
const store = testConfigureStore(baseState);
const focus = window.focus;
window.focus = jest.fn();
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).toHaveBeenCalledWith({
body: '@username: Where is Jessica Hyde?',
requireInteraction: false,
silent: false,
tag: 'post_id',
title: 'Utopia',
onClick: expect.any(Function),
});
(spy.mock.calls[0][0]!.onClick as () => void)();
expect(getHistory().push).toHaveBeenCalledWith('/team/channels/utopia');
expect(window.focus).toHaveBeenCalled();
window.focus = focus;
});
});
test('should not notify user when tab and channel are active', async () => {
const store = testConfigureStore(baseState);
baseState.views.browser.focused = true;
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).not.toHaveBeenCalled();
});
});
test('should notify user when tab is active but the channel is not', async () => {
const store = testConfigureStore(baseState);
baseState.views.browser.focused = true;
baseState.entities.channels.currentChannelId = 'another_channel_id';
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).toHaveBeenCalled();
});
});
test('should not notify user when notify props is set to mention and there are no mentions', async () => {
channelSettings.desktop = NotificationLevels.MENTION;
const store = testConfigureStore(baseState);
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).not.toHaveBeenCalled();
});
});
test('should not notify user when notify props is set to NONE', async () => {
userSettings.desktop = NotificationLevels.ALL;
channelSettings.desktop = NotificationLevels.NONE;
const store = testConfigureStore(baseState);
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).not.toHaveBeenCalled();
});
});
test('should not notify user when notify props is set to NONE', async () => {
userSettings.desktop = NotificationLevels.NONE;
channelSettings.desktop = undefined;
const store = testConfigureStore(baseState);
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).not.toHaveBeenCalled();
});
});
test('should notify user when notify props is set to mention and there are no mentions but it\'s a DM_CHANNEL', () => {
userSettings.desktop = NotificationLevels.MENTION;
msgProps.channel_type = Constants.DM_CHANNEL as ChannelType;
const store = testConfigureStore(baseState);
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).toHaveBeenCalled();
});
});
test('should notify user when notify props is set to mention and there are mentions', async () => {
channelSettings.desktop = NotificationLevels.MENTION;
msgProps.mentions = JSON.stringify(['current_user_id']);
const store = testConfigureStore(baseState);
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).toHaveBeenCalled();
});
});
test('should not notify user on user\'s webhook', async () => {
const store = testConfigureStore(baseState);
post.props.from_webhook = true;
post.user_id = 'current_user_id';
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).not.toHaveBeenCalled();
});
});
test('should not notify user on systemMessage', () => {
const store = testConfigureStore(baseState);
post.type = 'system_message' as PostType;
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).not.toHaveBeenCalled();
});
});
test('should not notify user on silent_notification post', () => {
const store = testConfigureStore(baseState);
post.props.silent_notification = true;
return store.dispatch(sendDesktopNotification(post, msgProps)).then((result) => {
expect(spy).not.toHaveBeenCalled();
expect(result).toEqual({data: {status: 'not_sent', reason: 'silent_notification'}});
});
});
test('should notify for silent_notification post when force_notification overrides', () => {
const store = testConfigureStore(baseState);
post.props.silent_notification = true;
post.props.force_notification = 'abc123';
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).toHaveBeenCalled();
});
});
test('should notify user on add to channel', () => {
const store = testConfigureStore(baseState);
post.type = 'system_add_to_channel';
post.props.addedUserId = 'current_user_id';
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).toHaveBeenCalled();
});
});
test('should not notify user on other user add to channel', () => {
const store = testConfigureStore(baseState);
post.type = 'system_add_to_channel';
post.props.addedUserId = 'not_current_user_id';
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).not.toHaveBeenCalled();
});
});
test('should not notify user on muted channels', () => {
const store = testConfigureStore(baseState);
post.channel_id = 'muted_channel_id';
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).not.toHaveBeenCalled();
});
});
test('should notify for forced notification posts on muted channels', () => {
const store = testConfigureStore(baseState);
const newPost = {
...post,
props: {
...post.props,
force_notification: 'test',
},
};
newPost.channel_id = 'muted_channel_id';
const newMsgProps = {
post: JSON.stringify(newPost),
channel_display_name: 'Muted Channel',
team_id: 'team_id',
};
return store.dispatch(sendDesktopNotification(newPost, newMsgProps)).then((result) => {
expect(result).toEqual({data: {status: 'success'}});
expect(spy).toHaveBeenCalledWith({
body: '@username: Where is Jessica Hyde?',
requireInteraction: false,
silent: false,
tag: 'post_id',
title: 'Muted Channel',
onClick: expect.any(Function),
});
});
});
test.each([
UserStatuses.DND,
UserStatuses.OUT_OF_OFFICE,
])('should not notify user on user status %s', (status) => {
baseState.entities.users.statuses.current_user_id = status;
const store = testConfigureStore(baseState);
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).not.toHaveBeenCalled();
});
});
test.each([
UserStatuses.OFFLINE,
UserStatuses.AWAY,
UserStatuses.ONLINE,
])('should notify user on user status %s', (status) => {
baseState.entities.users.statuses.current_user_id = status;
const store = testConfigureStore(baseState);
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).toHaveBeenCalled();
});
});
test('should default sound when no sound is specified', () => {
const dingSpy = jest.spyOn(NotificationSounds, 'ding');
baseState.entities.users.profiles.current_user_id.notify_props.desktop_sound = 'true';
const store = testConfigureStore(baseState);
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(dingSpy).toHaveBeenCalledWith('Bing');
});
});
test('should use specified sound when specified', () => {
const dingSpy = jest.spyOn(NotificationSounds, 'ding');
baseState.entities.users.profiles.current_user_id.notify_props.desktop_sound = 'true';
baseState.entities.users.profiles.current_user_id.notify_props.desktop_notification_sound = 'Crackle';
const store = testConfigureStore(baseState);
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(dingSpy).toHaveBeenCalledWith('Crackle');
});
});
describe('CollapsedThreads: false', () => {
beforeEach(() => {
crt.value = 'off';
});
test('should notify user on replies regardless of them being followed', () => {
const store = testConfigureStore(baseState);
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).toHaveBeenCalled();
});
});
});
describe('CollapsedThreads: true', () => {
beforeEach(() => {
crt.value = 'on';
});
test('should not notify user on crt reply when the tab is active and the thread is open', () => {
baseState.views.threads.selectedThreadIdInTeam.team_id = 'root_id';
baseState.views.browser.focused = true;
msgProps.mentions = JSON.stringify(['current_user_id']);
const store = testConfigureStore(baseState);
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).not.toHaveBeenCalled();
});
});
test('should not notify user on crt reply when desktop is MENTION and there is no mention', () => {
userSettings.desktop = NotificationLevels.MENTION;
msgProps.mentions = JSON.stringify([]);
const store = testConfigureStore(baseState);
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).not.toHaveBeenCalled();
});
});
test('should redirect to permalink when CRT in on and the post is a thread', () => {
const focus = window.focus;
window.focus = jest.fn();
userSettings.desktop = NotificationLevels.MENTION;
msgProps.followers = JSON.stringify(['current_user_id']);
const store = testConfigureStore(baseState);
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).toHaveBeenCalledWith({
body: '@username: Where is Jessica Hyde?',
requireInteraction: false,
silent: false,
tag: 'post_id',
title: 'Reply in Utopia',
onClick: expect.any(Function),
});
(spy.mock.calls[0][0]!.onClick as () => void)();
expect(getHistory().push).toHaveBeenCalledWith('/team/pl/post_id');
expect(window.focus).toHaveBeenCalled();
window.focus = focus;
});
});
});
describe('popout windows', () => {
afterEach(() => {
jest.mocked(getFocusedPopoutInfo).mockReturnValue(null);
});
test('should not notify when the channel is focused in a popout window', () => {
baseState.views.browser.focused = false;
jest.mocked(getFocusedPopoutInfo).mockReturnValue({channelId: 'channel_id'});
const store = testConfigureStore(baseState);
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).not.toHaveBeenCalled();
});
});
test('should notify when the popout is focused on a different channel', () => {
baseState.views.browser.focused = false;
jest.mocked(getFocusedPopoutInfo).mockReturnValue({channelId: 'other_channel_id'});
const store = testConfigureStore(baseState);
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).toHaveBeenCalled();
});
});
test('should not notify when a CRT thread is focused in a popout window', () => {
crt.value = 'on';
baseState.views.browser.focused = false;
jest.mocked(getFocusedPopoutInfo).mockReturnValue({channelId: 'channel_id', threadId: 'root_id'});
msgProps.mentions = JSON.stringify(['current_user_id']);
msgProps.followers = JSON.stringify(['current_user_id']);
const store = testConfigureStore(baseState);
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).not.toHaveBeenCalled();
});
});
test('should notify when the thread popout is focused on a different thread', () => {
crt.value = 'on';
baseState.views.browser.focused = false;
jest.mocked(getFocusedPopoutInfo).mockReturnValue({channelId: 'channel_id', threadId: 'other_thread_id'});
msgProps.mentions = JSON.stringify(['current_user_id']);
msgProps.followers = JSON.stringify(['current_user_id']);
const store = testConfigureStore(baseState);
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).toHaveBeenCalled();
});
});
test('should not suppress notification when a thread popout is focused but post is a channel message', () => {
baseState.views.browser.focused = false;
jest.mocked(getFocusedPopoutInfo).mockReturnValue({channelId: 'channel_id', threadId: 'some_thread_id'});
const store = testConfigureStore(baseState);
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).toHaveBeenCalled();
});
});
});
describe('GMs', () => {
test('should notify for any message when channel setting is DEFAULT and user setting is MENTION', async () => {
const store = testConfigureStore(baseState);
userSettings.desktop = NotificationLevels.MENTION;
channelSettings.desktop = NotificationLevels.DEFAULT;
post.channel_id = 'gm_channel';
msgProps.team_id = '';
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).toHaveBeenCalled();
});
});
test('should not notify for any message when channel setting is DEFAULT and user setting is NONE', async () => {
const store = testConfigureStore(baseState);
userSettings.desktop = NotificationLevels.NONE;
channelSettings.desktop = NotificationLevels.DEFAULT;
post.message = '@username';
post.channel_id = 'gm_channel';
msgProps.team_id = '';
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).not.toHaveBeenCalled();
});
});
test('should notify when channel setting MENTION and there is a explicit mention', async () => {
const store = testConfigureStore(baseState);
channelSettings.desktop = NotificationLevels.MENTION;
post.message = '@currentusername';
post.channel_id = 'gm_channel';
msgProps.team_id = '';
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).toHaveBeenCalled();
});
});
test('should notify when channel setting MENTION and there is a keyword mention', async () => {
const store = testConfigureStore(baseState);
channelSettings.desktop = NotificationLevels.MENTION;
post.message = 'mentionkey';
post.channel_id = 'gm_channel';
msgProps.team_id = '';
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).toHaveBeenCalled();
});
});
test('should notify when channel setting MENTION and there is the first name', async () => {
const store = testConfigureStore(baseState);
channelSettings.desktop = NotificationLevels.MENTION;
post.message = 'currentuserfirstname';
post.channel_id = 'gm_channel';
msgProps.team_id = '';
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).toHaveBeenCalled();
});
});
test('should notify when channel setting MENTION and there is a channel mention', async () => {
const store = testConfigureStore(baseState);
channelSettings.desktop = NotificationLevels.MENTION;
post.message = '@all';
post.channel_id = 'gm_channel';
msgProps.team_id = '';
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).toHaveBeenCalled();
});
});
test('should not notify when channel setting MENTION and there is no explicit mention', async () => {
const store = testConfigureStore(baseState);
channelSettings.desktop = NotificationLevels.MENTION;
post.channel_id = 'gm_channel';
msgProps.team_id = '';
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
expect(spy).not.toHaveBeenCalled();
});
});
});
});
});
// Identity helpers so each fixture below is checked against the real notify prop unions instead of
// being cast blindly. Fixtures holding values outside those unions (empty strings) are stale and keep
// their unchecked casts.
function makeChannelMember(channelMember: DeepPartial<ChannelMembership>): ChannelMembership {
return channelMember as ChannelMembership;
}
function makeUser(user: DeepPartial<UserProfile>): UserProfile {
return user as UserProfile;
}
describe('isDesktopSoundEnabled', () => {
test('should return channel member sound if it exists', () => {
const channelMember1 = makeChannelMember({
notify_props: {
desktop_sound: 'on',
},
});
const user1 = makeUser({
notify_props: {
desktop_sound: 'false',
},
});
expect(isDesktopSoundEnabled(channelMember1, user1)).toBe(true);
const channelMember2 = makeChannelMember({
notify_props: {
desktop_sound: 'off',
},
});
const user2 = makeUser({
notify_props: {
desktop_sound: 'false',
},
});
expect(isDesktopSoundEnabled(channelMember2, user2)).toBe(false);
const channelMember3 = makeChannelMember({
notify_props: {
desktop_sound: 'default',
},
});
const user3 = makeUser({
notify_props: {
desktop_sound: 'false',
},
});
expect(isDesktopSoundEnabled(channelMember3, user3)).toBe(false);
const channelMember4 = makeChannelMember({
notify_props: {
desktop_sound: 'default',
},
});
const user4 = makeUser({
notify_props: {
desktop_sound: 'true',
},
});
expect(isDesktopSoundEnabled(channelMember4, user4)).toBe(true);
const channelMember5 = makeChannelMember({
notify_props: {
desktop_sound: 'on',
},
});
const user5 = {
notify_props: {
desktop_sound: '',
},
} as unknown as UserProfile;
expect(isDesktopSoundEnabled(channelMember5, user5)).toBe(true);
});
test('should return user sound if channel member sound is not defined', () => {
const channelMember1 = {
notify_props: {
desktop_sound: '',
},
} as unknown as ChannelMembership;
const user1 = makeUser({
notify_props: {
desktop_sound: 'true',
},
});
expect(isDesktopSoundEnabled(channelMember1, user1)).toBe(true);
const channelMember2 = {
notify_props: {
desktop_sound: '',
},
} as unknown as ChannelMembership;
const user2 = makeUser({
notify_props: {
desktop_sound: 'false',
},
});
expect(isDesktopSoundEnabled(channelMember2, user2)).toBe(false);
const channelMember3 = makeChannelMember({
notify_props: {},
});
const user3 = makeUser({
notify_props: {
desktop_sound: 'false',
},
});
expect(isDesktopSoundEnabled(channelMember3, user3)).toBe(false);
});
test('should return default if both channel member and user are not defined', () => {
const channelMember = makeChannelMember({});
const user = makeUser({});
expect(isDesktopSoundEnabled(channelMember, user)).toBe(true);
});
});
describe('getDesktopNotificationSound', () => {
test('should return channel member notification sound if it exists', () => {
const channelMember1 = makeChannelMember({
notify_props: {
desktop_notification_sound: 'default',
},
});
const user1 = makeUser({
notify_props: {
desktop_notification_sound: 'Crackle',
},
});
expect(getDesktopNotificationSound(channelMember1, user1)).toBe('Crackle');
const channelMember2 = makeChannelMember({
notify_props: {
desktop_notification_sound: 'default',
},
});
const user2 = {
notify_props: {
desktop_notification_sound: '',
},
} as unknown as UserProfile;
expect(getDesktopNotificationSound(channelMember2, user2)).toBe('Bing');
const channelMember3 = makeChannelMember({
notify_props: {
desktop_notification_sound: 'Crackle',
},
});
const user3 = makeUser({
notify_props: {
desktop_notification_sound: 'Bing',
},
});
expect(getDesktopNotificationSound(channelMember3, user3)).toBe('Crackle');
});
test('should return user notification sound if channel member sound is not defined', () => {
const channelMember1 = makeChannelMember({});
const user1 = makeUser({
notify_props: {
desktop_notification_sound: 'Crackle',
},
});
expect(getDesktopNotificationSound(channelMember1, user1)).toBe('Crackle');
const channelMember2 = makeChannelMember({});
const user2 = makeUser({});
expect(getDesktopNotificationSound(channelMember2, user2)).toBe('Bing');
});
});

View File

@@ -0,0 +1,465 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {isDesktopApp, isMobile} from '@mattermost/shared/utils/user_agent';
import type {Channel, ChannelMembership} from '@mattermost/types/channels';
import type {ServerError} from '@mattermost/types/errors';
import {isMessageAttachmentArray} from '@mattermost/types/message_attachments';
import type {Post} from '@mattermost/types/posts';
import type {UserProfile} from '@mattermost/types/users';
import {logError} from 'mattermost-redux/actions/errors';
import {Client4} from 'mattermost-redux/client';
import {getCurrentChannel, getMyChannelMember, makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {
getTeammateNameDisplaySetting,
isCollapsedThreadsEnabled,
} from 'mattermost-redux/selectors/entities/preferences';
import {getAllUserMentionKeys} from 'mattermost-redux/selectors/entities/search';
import {getCurrentUserId, getCurrentUser, getStatusForUserId, getUser} from 'mattermost-redux/selectors/entities/users';
import {isChannelMuted} from 'mattermost-redux/utils/channel_utils';
import {ensureString, isNotificationSuppressed, isSystemMessage, isUserAddedInChannel} from 'mattermost-redux/utils/post_utils';
import {displayUsername} from 'mattermost-redux/utils/user_utils';
import {getChannelURL, getPermalinkURL} from 'selectors/urls';
import {isThreadOpen} from 'selectors/views/threads';
import {getHistory} from 'utils/browser_history';
import Constants, {NotificationLevels, UserStatuses, IgnoreChannelMentions, DesktopSound} from 'utils/constants';
import DesktopApp from 'utils/desktop_api';
import {stripMarkdown, formatWithRenderer} from 'utils/markdown';
import MentionableRenderer from 'utils/markdown/mentionable_renderer';
import {DesktopNotificationSounds, ding} from 'utils/notification_sounds';
import {showNotification} from 'utils/notifications';
import {getFocusedPopoutInfo} from 'utils/popouts/focus';
import {cjkrPattern} from 'utils/text_formatting';
import * as Utils from 'utils/utils';
import type {ActionFuncAsync, GlobalState} from 'types/store';
import {runDesktopNotificationHooks} from './hooks';
import type {NewPostMessageProps} from './new_post';
type NotificationResult = {
status: string;
reason?: string;
data?: string;
};
type NotificationHooksArgs = {
title: string;
body: string;
silent: boolean;
soundName: string;
url: string;
notify: boolean;
};
/**
* This function is used to determine if the desktop sound is enabled.
* It checks if desktop sound is defined in the channel member and if not, it checks if it's defined in the user preferences.
*/
export function isDesktopSoundEnabled(channelMember: ChannelMembership | undefined, user: UserProfile | undefined) {
const soundInChannelMemberNotifyProps = channelMember?.notify_props?.desktop_sound;
const soundInUserNotifyProps = user?.notify_props?.desktop_sound;
if (soundInChannelMemberNotifyProps === DesktopSound.ON) {
return true;
}
if (soundInChannelMemberNotifyProps === DesktopSound.OFF) {
return false;
}
if (soundInChannelMemberNotifyProps === DesktopSound.DEFAULT) {
return soundInUserNotifyProps ? soundInUserNotifyProps === 'true' : true;
}
if (soundInUserNotifyProps) {
return soundInUserNotifyProps === 'true';
}
return true;
}
/**
* This function returns the desktop notification sound from the channel member and user.
* It checks if desktop notification sound is defined in the channel member and if not, it checks if it's defined in the user preferences.
* If neither is defined, it returns the default sound 'BING'.
*/
export function getDesktopNotificationSound(channelMember: ChannelMembership | undefined, user: UserProfile | undefined) {
const notificationSoundInChannelMember = channelMember?.notify_props?.desktop_notification_sound;
const notificationSoundInUser = user?.notify_props?.desktop_notification_sound;
if (notificationSoundInChannelMember && notificationSoundInChannelMember !== DesktopNotificationSounds.DEFAULT) {
return notificationSoundInChannelMember;
}
if (notificationSoundInUser && notificationSoundInUser !== DesktopNotificationSounds.DEFAULT) {
return notificationSoundInUser;
}
return DesktopNotificationSounds.BING;
}
export function sendDesktopNotification(post: Post, msgProps: NewPostMessageProps): ActionFuncAsync<NotificationResult> {
return async (dispatch, getState) => {
const state = getState();
const teamId = msgProps.team_id || '';
const channel = makeGetChannel()(state, post.channel_id) || {
id: post.channel_id,
name: msgProps.channel_name,
display_name: msgProps.channel_display_name,
type: msgProps.channel_type,
} as Channel;
const user = getCurrentUser(state);
const member = getMyChannelMember(state, post.channel_id);
const isCrtReply = isCollapsedThreadsEnabled(state) && post.root_id !== '';
const forceNotification = Boolean(post.props?.force_notification);
const skipNotificationReason = shouldSkipNotification(
state,
post,
msgProps,
user,
channel,
member,
forceNotification,
isCrtReply,
);
if (skipNotificationReason) {
return {data: skipNotificationReason};
}
const title = getNotificationTitle(channel, msgProps, isCrtReply);
const body = getNotificationBody(state, post, msgProps);
//Play a sound if explicitly set in settings
const desktopSoundEnabled = isDesktopSoundEnabled(member, user);
const soundName = getDesktopNotificationSound(member, user);
const updatedState = getState();
const url = isCrtReply ? getPermalinkURL(updatedState, teamId, post.id) : getChannelURL(updatedState, channel, teamId);
// Allow plugins to change the notification, or re-enable a notification
const args: NotificationHooksArgs = {title, body, silent: !desktopSoundEnabled, soundName, url, notify: true};
// TODO verify the type of the desktop hook.
// The channel may not be complete at this moment
// and may cause crashes down the line if not
// properly typed.
const hookResult = await dispatch(runDesktopNotificationHooks(post, msgProps, channel as any, teamId, args));
if (hookResult.error) {
dispatch(logError(hookResult.error as ServerError));
return {data: {status: 'error', reason: 'desktop_notification_hook', data: String(hookResult.error)}};
}
const argsAfterHooks = hookResult.data!;
if (!argsAfterHooks.notify && !forceNotification) {
return {data: {status: 'not_sent', reason: 'desktop_notification_hook', data: String(hookResult)}};
}
const result = dispatch(notifyMe(argsAfterHooks.title, argsAfterHooks.body, channel.id, teamId, argsAfterHooks.silent, argsAfterHooks.soundName, argsAfterHooks.url, post.id));
//Don't add extra sounds on native desktop clients
if (desktopSoundEnabled && !isDesktopApp() && !isMobile()) {
ding(soundName);
}
return result;
};
}
const getNotificationTitle = (channel: Pick<Channel, 'type' | 'display_name'>, msgProps: NewPostMessageProps, isCrtReply: boolean) => {
let title = Utils.localizeMessage({id: 'channel_loader.title', defaultMessage: 'Posted'});
if (channel.type === Constants.DM_CHANNEL) {
title = Utils.localizeMessage({id: 'notification.dm', defaultMessage: 'Direct Message'});
} else {
title = channel.display_name;
}
if (title === '') {
if (msgProps.channel_type === Constants.DM_CHANNEL) {
title = Utils.localizeMessage({id: 'notification.dm', defaultMessage: 'Direct Message'});
} else {
title = msgProps.channel_display_name || '';
}
}
if (isCrtReply) {
title = Utils.localizeMessage({id: 'notification.crt', defaultMessage: 'Reply in {title}'}, {title});
}
return title;
};
const getNotificationUsername = (state: GlobalState, post: Post, msgProps: NewPostMessageProps): string => {
const config = getConfig(state);
const userFromPost = getUser(state, post.user_id);
const overrideUsername = ensureString(post.props.override_username);
if (overrideUsername && config.EnablePostUsernameOverride === 'true') {
return overrideUsername;
}
if (userFromPost) {
return displayUsername(userFromPost, getTeammateNameDisplaySetting(state), false);
}
if (msgProps.sender_name) {
return msgProps.sender_name;
}
return Utils.localizeMessage({id: 'channel_loader.someone', defaultMessage: 'Someone'});
};
const getNotificationBody = (state: GlobalState, post: Post, msgProps: NewPostMessageProps) => {
const username = getNotificationUsername(state, post, msgProps);
let notifyText = post.message;
const attachments = isMessageAttachmentArray(post.props?.attachments) ? post.props.attachments : [];
let image = false;
attachments.forEach((attachment) => {
if (notifyText.length === 0) {
notifyText = attachment.fallback ||
attachment.pretext ||
attachment.text || '';
}
image = Boolean(image || (attachment.image_url?.length));
});
const strippedMarkdownNotifyText = stripMarkdown(notifyText);
let body = `@${username}`;
if (strippedMarkdownNotifyText.length === 0) {
if (msgProps.image) {
body += Utils.localizeMessage({id: 'channel_loader.uploadedImage', defaultMessage: ' uploaded an image'});
} else if (msgProps.otherFile) {
body += Utils.localizeMessage({id: 'channel_loader.uploadedFile', defaultMessage: ' uploaded a file'});
} else if (image) {
body += Utils.localizeMessage({id: 'channel_loader.postedImage', defaultMessage: ' posted an image'});
} else {
body += Utils.localizeMessage({id: 'channel_loader.posted', defaultMessage: ' posted a message'});
}
} else {
body += `: ${strippedMarkdownNotifyText}`;
}
return body;
};
function shouldSkipNotification(
state: GlobalState,
post: Post,
msgProps: NewPostMessageProps,
user: UserProfile,
channel: Pick<Channel, 'type' | 'id'>,
member: ChannelMembership | undefined,
skipChecks: boolean,
isCrtReply: boolean,
) {
const currentUserId = getCurrentUserId(state);
if ((currentUserId === post.user_id && post.props.from_webhook !== 'true')) {
return {status: 'not_sent', reason: 'own_post'};
}
if (isSystemMessage(post) && !isUserAddedInChannel(post, currentUserId)) {
return {status: 'not_sent', reason: 'system_message'};
}
if (isNotificationSuppressed(post)) {
return {status: 'not_sent', reason: 'silent_notification'};
}
if (!member) {
return {status: 'error', reason: 'no_member'};
}
if (skipChecks) {
return undefined;
}
if (isChannelMuted(member)) {
return {status: 'not_sent', reason: 'channel_muted'};
}
const userStatus = getStatusForUserId(state, user.id);
if ((userStatus === UserStatuses.DND || userStatus === UserStatuses.OUT_OF_OFFICE)) {
return {status: 'not_sent', reason: 'user_status', data: userStatus};
}
let mentions = [];
if (msgProps.mentions) {
mentions = JSON.parse(msgProps.mentions);
}
let followers = [];
if (msgProps.followers) {
followers = JSON.parse(msgProps.followers);
mentions = [...new Set([...followers, ...mentions])];
}
const channelNotifyProp = member?.notify_props?.desktop || NotificationLevels.DEFAULT;
let notifyLevel = channelNotifyProp;
if (notifyLevel === NotificationLevels.DEFAULT) {
notifyLevel = user?.notify_props?.desktop || NotificationLevels.ALL;
}
if (channel?.type === 'G' && channelNotifyProp === NotificationLevels.DEFAULT && user?.notify_props?.desktop === NotificationLevels.MENTION) {
notifyLevel = NotificationLevels.ALL;
}
if (notifyLevel === NotificationLevels.NONE) {
return {status: 'not_sent', reason: 'notify_level_none'};
} else if (channel?.type === 'G' && notifyLevel === NotificationLevels.MENTION) {
// Compose the whole text in the message, including interactive messages.
let text = post.message;
// We do this on a try catch block to avoid errors from malformed props
try {
if (isMessageAttachmentArray(post.props.attachments)) {
const attachments = post.props.attachments;
function appendText(toAppend?: string) {
if (toAppend) {
text += `\n${toAppend}`;
}
}
for (const attachment of attachments) {
appendText(attachment.pretext);
appendText(attachment.title);
appendText(attachment.text);
appendText(attachment.footer);
if (attachment.fields) {
for (const field of attachment.fields) {
appendText(field.title);
if (typeof field.value === 'string') {
appendText(field.value);
}
}
}
}
}
} catch (e) {
// eslint-disable-next-line no-console
console.log('Could not process the whole attachment for mentions', e);
}
const allMentions = getAllUserMentionKeys(state);
const ignoreChannelMentionProp = member?.notify_props?.ignore_channel_mentions || IgnoreChannelMentions.DEFAULT;
let ignoreChannelMention = ignoreChannelMentionProp === IgnoreChannelMentions.ON;
if (ignoreChannelMentionProp === IgnoreChannelMentions.DEFAULT) {
ignoreChannelMention = user?.notify_props?.channel === 'false';
}
const mentionableText = formatWithRenderer(text, new MentionableRenderer());
let isExplicitlyMentioned = false;
for (const mention of allMentions) {
if (!mention || !mention.key) {
continue;
}
if (ignoreChannelMention && ['@all', '@here', '@channel'].includes(mention.key)) {
continue;
}
let flags = 'g';
if (!mention.caseSensitive) {
flags += 'i';
}
let pattern;
if (cjkrPattern.test(mention.key)) {
// In the case of CJK mention key, even if there's no delimiters (such as spaces) at both ends of a word, it is recognized as a mention key
pattern = new RegExp(`()(${RegExp.escape(mention.key)})()`, flags);
} else {
pattern = new RegExp(
`(^|\\W)(${RegExp.escape(mention.key)})(\\b|_+\\b)`,
flags,
);
}
if (pattern.test(mentionableText)) {
isExplicitlyMentioned = true;
break;
}
}
if (!isExplicitlyMentioned) {
return {status: 'not_sent', reason: 'not_explicitly_mentioned', data: mentionableText};
}
} else if (notifyLevel === NotificationLevels.MENTION && mentions.indexOf(user.id) === -1 && msgProps.channel_type !== Constants.DM_CHANNEL) {
return {status: 'not_sent', reason: 'not_mentioned'};
} else if (isCrtReply && notifyLevel === NotificationLevels.ALL && followers.indexOf(currentUserId) === -1) {
// if user is not following the thread don't notify
return {status: 'not_sent', reason: 'not_following_thread'};
}
// Notify if you're not looking in the right channel or when
// the window itself is not active
const activeChannel = getCurrentChannel(state);
const channelId = channel ? channel.id : null;
const focusedPopout = getFocusedPopoutInfo();
if (state.views.browser.focused) {
if (isCrtReply) {
if (isThreadOpen(state, post.root_id)) {
return {status: 'not_sent', reason: 'thread_is_open', data: post.root_id};
}
} else if (activeChannel && activeChannel.id === channelId) {
return {status: 'not_sent', reason: 'channel_is_open', data: activeChannel?.id};
}
} else if (focusedPopout) {
if (isCrtReply && focusedPopout.threadId === post.root_id) {
return {status: 'not_sent', reason: 'thread_is_open', data: post.root_id};
}
if (!isCrtReply && !focusedPopout.threadId && focusedPopout.channelId === channelId) {
return {status: 'not_sent', reason: 'channel_is_open', data: channelId};
}
}
return undefined;
}
export function notifyMe(title: string, body: string, channelId: string, teamId: string, silent: boolean, soundName: string, url: string, postId: string): ActionFuncAsync<NotificationResult> {
return async (dispatch) => {
// handle notifications in desktop app
if (isDesktopApp()) {
// The notification-tag leak only affects Chromium-based browser notifications,
// so the desktop app path does not need the opaque post id.
const result = await DesktopApp.dispatchNotification(title, body, channelId, teamId, silent, soundName, url);
return {data: result};
}
try {
const result = await dispatch(showNotification({
title,
body,
tag: postId,
requireInteraction: false,
silent,
onClick: () => {
window.focus();
getHistory().push(url);
},
}));
return {data: result};
} catch (error) {
dispatch(logError(error));
return {data: {status: 'error', reason: 'notification_api', data: String(error)}};
}
};
}
export const sendTestNotification = async () => {
try {
const result = await Client4.sendTestNotificaiton();
return result;
} catch (error) {
return error;
}
};

View File

@@ -0,0 +1,785 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {FileInfo, FilesState} from '@mattermost/types/files';
import type {Post} from '@mattermost/types/posts';
import {ChannelTypes, SearchTypes} from 'mattermost-redux/action_types';
import * as PostActions from 'mattermost-redux/actions/posts';
import {Posts} from 'mattermost-redux/constants';
import * as Actions from 'actions/post_actions';
import test_helper from 'packages/mattermost-redux/test/test_helper';
import mockStore from 'tests/test_store';
import {Constants, ActionTypes, RHSStates} from 'utils/constants';
import * as PostUtils from 'utils/post_utils';
import type {GlobalState} from 'types/store';
import {sendDesktopNotification} from './notification_actions';
jest.mock('mattermost-redux/actions/posts', () => ({
removeReaction: (...args: any[]) => ({type: 'MOCK_REMOVE_REACTION', args}),
addReaction: (...args: any[]) => ({type: 'MOCK_ADD_REACTION', args}),
createPost: (...args: any[]) => ({type: 'MOCK_CREATE_POST', args}),
createPostImmediately: (...args: any[]) => ({type: 'MOCK_CREATE_POST_IMMEDIATELY', args}),
flagPost: (...args: any[]) => ({type: 'MOCK_FLAG_POST', args}),
unflagPost: (...args: any[]) => ({type: 'MOCK_UNFLAG_POST', args}),
pinPost: (...args: any[]) => ({type: 'MOCK_PIN_POST', args}),
unpinPost: (...args: any[]) => ({type: 'MOCK_UNPIN_POST', args}),
receivedNewPost: (...args: any[]) => ({type: 'MOCK_RECEIVED_NEW_POST', args}),
setUnreadPost: (...args: any[]) => ({type: 'MOCK_SET_UNREAD_POST', args}),
getPosts: (...args: any[]) => ({type: 'MOCK_GET_POSTS', args}),
}));
jest.mock('actions/emoji_actions', () => ({
addRecentEmoji: (...args: any[]) => ({type: 'MOCK_ADD_RECENT_EMOJI', args}),
addRecentEmojis: (...args: any[]) => ({type: 'MOCK_ADD_RECENT_EMOJIS', args}),
}));
jest.mock('actions/notification_actions', () => ({
sendDesktopNotification: jest.fn().mockReturnValue(() => ({data: {}})),
}));
jest.mock('actions/storage', () => {
const original = jest.requireActual('actions/storage');
return {
...original,
setGlobalItem: (...args: any[]) => ({type: 'MOCK_SET_GLOBAL_ITEM', args}),
};
});
jest.mock('@mattermost/shared/utils/user_agent', () => ({
isDesktopApp: jest.fn().mockReturnValue(false),
}));
jest.mock('utils/post_utils', () => ({
makeGetUniqueEmojiNameReactionsForPost: jest.fn(),
makeGetIsReactionAlreadyAddedToPost: jest.fn(),
}));
const mockMakeGetIsReactionAlreadyAddedToPost = PostUtils.makeGetIsReactionAlreadyAddedToPost as unknown as jest.Mock<() => boolean>;
const mockMakeGetUniqueEmojiNameReactionsForPost = PostUtils.makeGetUniqueEmojiNameReactionsForPost as unknown as jest.Mock<() => string[]>;
const mockedSendDesktopNotification = jest.mocked(sendDesktopNotification);
const POST_CREATED_TIME = Date.now();
// This mocks the Date.now() function so it returns a constant value
global.Date.now = jest.fn(() => POST_CREATED_TIME);
const INCREASED_POST_VISIBILITY = {amount: 1, data: 'current_channel_id', type: 'INCREASE_POST_VISIBILITY'};
describe('Actions.Posts', () => {
const latestPost = {
id: 'latest_post_id',
user_id: 'current_user_id',
message: 'test msg',
channel_id: 'current_channel_id',
type: 'normal',
};
const initialState = {
entities: {
posts: {
posts: {
[latestPost.id]: latestPost,
},
postsInChannel: {
current_channel_id: [
{order: [latestPost.id], recent: true},
],
},
postsInThread: {},
messagesHistory: {
index: {
[Posts.MESSAGE_TYPES.COMMENT]: 0,
},
messages: ['test message'],
},
},
channels: {
currentChannelId: 'current_channel_id',
myMembers: {
[latestPost.channel_id]: {
channel_id: 'current_channel_id',
user_id: 'current_user_id',
roles: 'channel_role',
},
other_channel_id: {
channel_id: 'other_channel_id',
user_id: 'current_user_id',
roles: 'channel_role',
},
},
roles: {
[latestPost.channel_id]: [
'current_channel_id',
'current_user_id',
'channel_role',
],
other_channel_id: [
'other_channel_id',
'current_user_id',
'channel_role',
],
},
channels: {
current_channel_id: {team_a: 'team_a', id: 'current_channel_id'},
},
manuallyUnread: {},
},
preferences: {
myPreferences: {
'display_settings--name_format': {
category: 'display_settings',
name: 'name_format',
user_id: 'current_user_id',
value: 'username',
},
},
},
teams: {
currentTeamId: 'team-a',
teams: {
team_a: {
id: 'team_a',
name: 'team-a',
displayName: 'Team A',
},
team_b: {
id: 'team_b',
name: 'team-a',
displayName: 'Team B',
},
},
myMembers: {
'team-a': {roles: 'team_role'},
'team-b': {roles: 'team_role'},
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {
id: 'current_user_id',
username: 'current_username',
roles: 'system_role',
useAutomaticTimezone: true,
automaticTimezone: '',
manualTimezone: '',
},
},
},
general: {
license: {IsLicensed: 'false'},
serverVersion: '5.4.0',
config: {PostEditTimeLimit: -1},
},
roles: {
roles: {
system_role: {
permissions: ['edit_post'],
},
team_role: {
permissions: [],
},
channel_role: {
permissions: [],
},
},
},
emojis: {customEmoji: {}},
search: {results: []},
},
views: {
posts: {
editingPost: {},
},
rhs: {
searchTerms: '',
searchType: '',
filesSearchExtFilter: [],
},
},
storage: {
storage: {},
},
} as unknown as GlobalState;
test('handleNewPost', async () => {
const testStore = mockStore(initialState);
const newPost = {id: 'new_post_id', channel_id: 'current_channel_id', message: 'new message', type: Constants.PostTypes.ADD_TO_CHANNEL, user_id: 'some_user_id', create_at: POST_CREATED_TIME} as Post;
const msg = {data: {team_a: 'team_a', mentions: ['current_user_id']}};
await testStore.dispatch(Actions.handleNewPost(newPost, msg as any));
expect(testStore.getActions()).toEqual([
{
meta: {batch: true},
payload: [
INCREASED_POST_VISIBILITY,
PostActions.receivedNewPost(newPost, false),
],
type: 'BATCHING_REDUCER.BATCH',
},
]);
expect(mockedSendDesktopNotification).toHaveBeenCalled();
});
test('handleNewPostOtherChannel', async () => {
const testStore = mockStore(initialState);
const newPost = {id: 'other_channel_post_id', channel_id: 'other_channel_id', message: 'new message in other channel', type: '', user_id: 'other_user_id', create_at: POST_CREATED_TIME} as Post;
const msg = {data: {team_b: 'team_b', mentions: ['current_user_id']}};
await testStore.dispatch(Actions.handleNewPost(newPost, msg as any));
expect(testStore.getActions()).toEqual([
{
meta: {batch: true},
payload: [
PostActions.receivedNewPost(newPost, false),
{
type: ChannelTypes.INCREMENT_UNREAD_MSG_COUNT,
data: {
amount: 1,
amountRoot: 0,
channelId: 'other_channel_id',
fetchedChannelMember: false,
onlyMentions: undefined,
teamId: '',
},
},
{
type: ChannelTypes.INCREMENT_TOTAL_MSG_COUNT,
data: {
amount: 1,
amountRoot: 0,
channelId: 'other_channel_id',
},
},
{
type: ChannelTypes.INCREMENT_UNREAD_MENTION_COUNT,
data: {
amount: 1,
amountRoot: 0,
amountUrgent: 0,
channelId: 'other_channel_id',
fetchedChannelMember: false,
teamId: '',
},
},
],
type: 'BATCHING_REDUCER.BATCH',
},
]);
expect(mockedSendDesktopNotification).toHaveBeenCalled();
});
test('unsetEditingPost', async () => {
// should allow to edit and should fire an action
const testStore = mockStore({...initialState});
const {data: dataSet} = await testStore.dispatch((Actions.setEditingPost as any)('latest_post_id', 'test'));
expect(dataSet).toEqual(true);
// matches the action to set editingPost
expect(testStore.getActions()[0].payload[0]).toEqual(
{data: {isRHS: false, postId: 'latest_post_id', refocusId: 'test', show: true}, type: ActionTypes.TOGGLE_EDITING_POST},
);
// clear actions
testStore.clearActions();
// dispatch action to unset the editingPost
const {data: dataUnset} = testStore.dispatch(Actions.unsetEditingPost());
expect(dataUnset).toEqual(true);
// matches the action to unset editingPost
expect(testStore.getActions()[0].payload[0]).toEqual(
{data: {show: false}, type: ActionTypes.TOGGLE_EDITING_POST},
);
// editingPost value is empty object, as it should
expect(testStore.getState().views.posts.editingPost).toEqual({});
});
test('setEditingPost', async () => {
const state = JSON.parse(JSON.stringify(initialState)) as GlobalState;
state.entities.posts.posts[latestPost.id] = {
...latestPost,
file_ids: ['file_id_1', 'file_id_2'],
} as Post;
state.entities.files = {
files: {
file_id_1: test_helper.getFileInfoMock({id: 'file_id_1', post_id: 'latest_post_id'}),
file_id_2: test_helper.getFileInfoMock({id: 'file_id_2', post_id: 'latest_post_id'}),
},
fileIdsByPostId: {
[latestPost.id]: ['file_id_1', 'file_id_2'],
},
} as unknown as FilesState;
// should allow to edit and should fire an action
let testStore = mockStore({...state});
const {data} = await testStore.dispatch(Actions.setEditingPost('latest_post_id', 'test'));
expect(data).toEqual(true);
let actions = testStore.getActions();
expect(actions.length).toEqual(1);
expect(actions[0].payload.length).toEqual(2);
expect(actions[0].payload[0]).toEqual(
{data: {isRHS: false, postId: 'latest_post_id', refocusId: 'test', show: true}, type: ActionTypes.TOGGLE_EDITING_POST},
);
expect(actions[0].payload[1]).toEqual(
{
args: [
'edit_draft_latest_post_id',
{
id: 'latest_post_id',
user_id: 'current_user_id',
message: 'test msg',
channel_id: 'current_channel_id',
type: 'normal',
file_ids: [
'file_id_1',
'file_id_2',
],
metadata: {
files: [
test_helper.getFileInfoMock({id: 'file_id_1', post_id: 'latest_post_id'}),
test_helper.getFileInfoMock({id: 'file_id_2', post_id: 'latest_post_id'}),
],
},
},
],
type: 'MOCK_SET_GLOBAL_ITEM',
},
);
const general = {
license: {IsLicensed: 'true'},
serverVersion: '5.4.0',
config: {PostEditTimeLimit: -1},
} as unknown as GlobalState['entities']['general'];
const withLicenseState = {...state};
withLicenseState.entities.general = {
...withLicenseState.entities.general,
...general,
};
testStore = mockStore(withLicenseState);
const {data: withLicenseData} = await testStore.dispatch(Actions.setEditingPost('latest_post_id', 'test'));
expect(withLicenseData).toEqual(true);
expect(testStore.getActions()[0].payload[0]).toEqual(
{data: {isRHS: false, postId: 'latest_post_id', refocusId: 'test', show: true}, type: ActionTypes.TOGGLE_EDITING_POST},
);
// should not allow edit for pending post
const newLatestPost = {...latestPost, pending_post_id: latestPost.id} as Post;
const withPendingPostState = {...state};
withPendingPostState.entities.posts.posts[latestPost.id] = newLatestPost;
testStore = mockStore(withPendingPostState);
const {data: withPendingPostData} = await testStore.dispatch(Actions.setEditingPost('latest_post_id', 'test'));
expect(withPendingPostData).toEqual(false);
expect(testStore.getActions()).toEqual([]);
// should not save draft when it already exists
const stateWithDraft = {
...state,
storage: {
...state.storage,
storage: {
...state.storage.storage,
edit_draft_latest_post_id: {
timestamp: new Date(),
value: {id: 'latest_post_id', user_id: 'current_user_id', message: 'test msg', channel_id: 'current_channel_id', type: 'normal'},
},
},
},
} as unknown as GlobalState;
stateWithDraft.entities.posts.posts[latestPost.id] = latestPost as Post;
testStore = mockStore(stateWithDraft);
const {data: dataExisting} = await testStore.dispatch(Actions.setEditingPost('latest_post_id', 'test'));
expect(dataExisting).toEqual(true);
actions = testStore.getActions();
expect(actions.length).toEqual(1);
expect(actions[0].payload.length).toEqual(1);
expect(actions[0].payload[0]).toEqual(
{data: {isRHS: false, postId: 'latest_post_id', refocusId: 'test', show: true}, type: ActionTypes.TOGGLE_EDITING_POST},
);
});
test('searchForTerm', async () => {
const testStore = mockStore(initialState);
await testStore.dispatch(Actions.searchForTerm('hello'));
expect(testStore.getActions()).toEqual([
{terms: 'hello', type: 'UPDATE_RHS_SEARCH_TERMS'},
{state: 'search', type: 'UPDATE_RHS_STATE'},
{terms: '', type: 'UPDATE_RHS_SEARCH_RESULTS_TERMS'},
{searchType: '', type: 'UPDATE_RHS_SEARCH_RESULTS_TYPE'},
{isGettingMore: false, type: 'SEARCH_POSTS_REQUEST'},
{data: {firstInaccessiblePostTime: 0, searchType: 'posts'}, type: 'RECEIVED_SEARCH_TRUNCATION_INFO'},
{isGettingMore: false, type: 'SEARCH_FILES_REQUEST'},
{data: {firstInaccessiblePostTime: 0, searchType: 'files'}, type: 'RECEIVED_SEARCH_TRUNCATION_INFO'},
]);
});
describe('markMostRecentPostInChannelAsUnread', () => {
const systemPost = {id: 'system_post_id', channel_id: 'unread_channel_id', type: Posts.POST_TYPES.ADD_TO_CHANNEL, create_at: 3000} as Post;
const regularPost = {id: 'regular_post_id', channel_id: 'unread_channel_id', type: '', create_at: 2000} as Post;
const joinPost = {id: 'join_post_id', channel_id: 'unread_channel_id', type: Posts.POST_TYPES.JOIN_CHANNEL, create_at: 1000} as Post;
const stateWithPosts = {
...initialState,
entities: {
...initialState.entities,
posts: {
...initialState.entities.posts,
posts: {
[systemPost.id]: systemPost,
[regularPost.id]: regularPost,
[joinPost.id]: joinPost,
},
postsInChannel: {
unread_channel_id: [
{order: [systemPost.id, regularPost.id, joinPost.id], recent: true},
],
},
},
},
} as unknown as GlobalState;
test('marks the most recent non-system post as unread when the latest post is a system message', async () => {
const testStore = mockStore(stateWithPosts);
await testStore.dispatch(Actions.markMostRecentPostInChannelAsUnread('unread_channel_id'));
expect(testStore.getActions().some((action) => action.type === 'MOCK_GET_POSTS')).toBe(false);
const setUnreadAction = testStore.getActions().find((action) => action.type === 'MOCK_SET_UNREAD_POST');
expect(setUnreadAction).toBeDefined();
expect(setUnreadAction!.args).toEqual(['current_user_id', regularPost.id]);
});
test('fetches posts when the channel is loaded but contains only system messages', async () => {
const systemOnlyState = {
...initialState,
entities: {
...initialState.entities,
posts: {
...initialState.entities.posts,
posts: {
[systemPost.id]: systemPost,
[joinPost.id]: joinPost,
},
postsInChannel: {
unread_channel_id: [
{order: [systemPost.id, joinPost.id], recent: true},
],
},
},
},
} as unknown as GlobalState;
const testStore = mockStore(systemOnlyState);
await testStore.dispatch(Actions.markMostRecentPostInChannelAsUnread('unread_channel_id'));
const actions = testStore.getActions();
expect(actions.some((action) => action.type === 'MOCK_GET_POSTS')).toBe(true);
expect(actions.some((action) => action.type === 'MOCK_SET_UNREAD_POST')).toBe(false);
});
test('fetches posts when none are loaded and does not mark a system post as unread', async () => {
const emptyState = {
...initialState,
entities: {
...initialState.entities,
posts: {
...initialState.entities.posts,
posts: {},
postsInChannel: {},
},
},
} as unknown as GlobalState;
const testStore = mockStore(emptyState);
await testStore.dispatch(Actions.markMostRecentPostInChannelAsUnread('unread_channel_id'));
const actions = testStore.getActions();
expect(actions.some((action) => action.type === 'MOCK_GET_POSTS')).toBe(true);
expect(actions.some((action) => action.type === 'MOCK_SET_UNREAD_POST')).toBe(false);
});
});
describe('createPost', () => {
test('no emojis', async () => {
const testStore = mockStore(initialState);
const newPost = {id: 'new_post_id', channel_id: 'current_channel_id', message: 'new message'} as Post;
const newReply = {id: 'reply_post_id', channel_id: 'current_channel_id', message: 'new message', root_id: 'new_post_id'} as Post;
const files: FileInfo[] = [];
const immediateExpectedState = [{
args: [newPost, files],
type: 'MOCK_CREATE_POST',
}, {
args: ['draft_current_channel_id', null],
type: 'MOCK_SET_GLOBAL_ITEM',
}];
await testStore.dispatch(Actions.createPost(newPost, files));
expect(testStore.getActions()).toEqual(immediateExpectedState);
const finalExpectedState = [
...immediateExpectedState,
{
args: [newReply, files],
type: 'MOCK_CREATE_POST',
}, {
args: ['comment_draft_new_post_id', null],
type: 'MOCK_SET_GLOBAL_ITEM',
},
];
await testStore.dispatch(Actions.createPost(newReply, files));
expect(testStore.getActions()).toEqual(finalExpectedState);
});
test('with single shorthand emoji', async () => {
const testStore = mockStore(initialState);
const newPost = {id: 'new_post_id', channel_id: 'current_channel_id', message: 'new message :+1:'} as Post;
const files: FileInfo[] = [];
const immediateExpectedState = [{
args: [['+1']],
type: 'MOCK_ADD_RECENT_EMOJIS',
}, {
args: [newPost, files],
type: 'MOCK_CREATE_POST',
}, {
args: ['draft_current_channel_id', null],
type: 'MOCK_SET_GLOBAL_ITEM',
}];
await testStore.dispatch(Actions.createPost(newPost, files));
expect(testStore.getActions()).toEqual(immediateExpectedState);
});
test('with single named emoji', async () => {
const testStore = mockStore(initialState);
const newPost = {id: 'new_post_id', channel_id: 'current_channel_id', message: 'new message :cake:'} as Post;
const files: FileInfo[] = [];
const immediateExpectedState = [{
args: [['cake']],
type: 'MOCK_ADD_RECENT_EMOJIS',
}, {
args: [newPost, files],
type: 'MOCK_CREATE_POST',
}, {
args: ['draft_current_channel_id', null],
type: 'MOCK_SET_GLOBAL_ITEM',
}];
await testStore.dispatch(Actions.createPost(newPost, files));
expect(testStore.getActions()).toEqual(immediateExpectedState);
});
test('with multiple emoji', async () => {
const testStore = mockStore(initialState);
const newPost = {id: 'new_post_id', channel_id: 'current_channel_id', message: 'new message :cake: :+1:'} as Post;
const files: FileInfo[] = [];
const immediateExpectedState = [{
args: [['cake', '+1']],
type: 'MOCK_ADD_RECENT_EMOJIS',
}, {
args: [newPost, files],
type: 'MOCK_CREATE_POST',
}, {
args: ['draft_current_channel_id', null],
type: 'MOCK_SET_GLOBAL_ITEM',
}];
await testStore.dispatch(Actions.createPost(newPost, files));
expect(testStore.getActions()).toEqual(immediateExpectedState);
});
});
describe('submitReaction', () => {
describe('addReaction', () => {
test('should add reaction when the action is + and the reaction is not added', async () => {
const testStore = mockStore(initialState);
mockMakeGetIsReactionAlreadyAddedToPost.mockReturnValueOnce(() => false);
testStore.dispatch(Actions.submitReaction('post_id_1', '+', 'emoji_name_1'));
expect(testStore.getActions()).toEqual([
{args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'},
{args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_ADD_REACTION'},
]);
});
test('should take no action when the action is + and the reaction has already been added', async () => {
const testStore = mockStore(initialState);
mockMakeGetIsReactionAlreadyAddedToPost.mockReturnValueOnce(() => true);
testStore.dispatch(Actions.submitReaction('post_id_1', '+', 'emoji_name_1'));
expect(testStore.getActions()).toEqual([]);
});
});
describe('removeReaction', () => {
test('should remove reaction when the action is - and the reaction has already been added', async () => {
const testStore = mockStore(initialState);
mockMakeGetIsReactionAlreadyAddedToPost.mockReturnValueOnce(() => true);
testStore.dispatch(Actions.submitReaction('post_id_1', '-', 'emoji_name_1'));
expect(testStore.getActions()).toEqual([
{args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_REMOVE_REACTION'},
]);
});
test('should take no action when the action is - and the reaction is not added', async () => {
const testStore = mockStore(initialState);
mockMakeGetIsReactionAlreadyAddedToPost.mockReturnValueOnce(() => false);
testStore.dispatch(Actions.submitReaction('post_id_1', '-', 'emoji_name_1'));
expect(testStore.getActions()).toEqual([]);
});
});
});
describe('toggleReaction', () => {
test('should add reaction when the reaction is not added', async () => {
const testStore = mockStore(initialState);
mockMakeGetIsReactionAlreadyAddedToPost.mockReturnValueOnce(() => false);
testStore.dispatch(Actions.toggleReaction('post_id_1', 'emoji_name_1'));
expect(testStore.getActions()).toEqual([
{args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'},
{args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_ADD_REACTION'},
]);
});
test('should remove reaction when the reaction has already been added', async () => {
const testStore = mockStore(initialState);
mockMakeGetIsReactionAlreadyAddedToPost.mockReturnValueOnce(() => true);
testStore.dispatch(Actions.toggleReaction('post_id_1', 'emoji_name_1'));
expect(testStore.getActions()).toEqual([
{args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_REMOVE_REACTION'},
]);
});
});
describe('addReaction', () => {
mockMakeGetUniqueEmojiNameReactionsForPost.mockReturnValue(() => []);
test('should add reaction', async () => {
const testStore = mockStore(initialState);
await testStore.dispatch(Actions.addReaction('post_id_1', 'emoji_name_1'));
expect(testStore.getActions()).toEqual([
{args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'},
{args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_ADD_REACTION'},
]);
});
test('should not add reaction if we are over the limit', async () => {
mockMakeGetUniqueEmojiNameReactionsForPost.mockReturnValue(() => ['another_emoji']);
const testStore = mockStore({
...initialState,
entities: {
...initialState.entities,
general: {
...initialState.entities.general,
config: {
...initialState.entities.general.config,
UniqueEmojiReactionLimitPerPost: '1',
},
},
},
});
await testStore.dispatch(Actions.addReaction('post_id_1', 'emoji_name_1'));
expect(testStore.getActions()).not.toEqual([
{args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_ADD_REACTION'},
{args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'},
]);
});
});
test('flagPost', async () => {
const rhs = {rhsState: RHSStates.FLAG} as unknown as GlobalState['views']['rhs'];
const views = {rhs} as GlobalState['views'];
const testStore = mockStore({...initialState, views});
const post = testStore.getState().entities.posts.posts[latestPost.id];
await testStore.dispatch(Actions.flagPost(post.id));
expect(testStore.getActions()).toEqual([
{args: [post.id], type: 'MOCK_FLAG_POST'},
{data: {posts: {[post.id]: post}, order: [post.id]}, type: SearchTypes.RECEIVED_SEARCH_POSTS},
]);
});
test('unflagPost', async () => {
const rhs = {rhsState: RHSStates.FLAG} as unknown as GlobalState['views']['rhs'];
const views = {rhs} as GlobalState['views'];
const testStore = mockStore({views, entities: {...initialState.entities, search: {results: [latestPost.id]}}} as GlobalState);
const post = testStore.getState().entities.posts.posts[latestPost.id];
await testStore.dispatch(Actions.unflagPost(post.id));
expect(testStore.getActions()).toEqual([
{args: [post.id], type: 'MOCK_UNFLAG_POST'},
{data: {posts: [], order: []}, type: SearchTypes.RECEIVED_SEARCH_POSTS},
]);
});
test('pinPost', async () => {
const rhs = {rhsState: RHSStates.PIN} as unknown as GlobalState['views']['rhs'];
const views = {rhs} as GlobalState['views'];
const testStore = mockStore({...initialState, views});
const post = testStore.getState().entities.posts.posts[latestPost.id];
await testStore.dispatch(Actions.pinPost(post.id));
expect(testStore.getActions()).toEqual([
{args: [post.id], type: 'MOCK_PIN_POST'},
{data: {posts: {[post.id]: post}, order: [post.id]}, type: SearchTypes.RECEIVED_SEARCH_POSTS},
]);
});
test('unpinPost', async () => {
const testStore = mockStore({views: {rhs: {rhsState: RHSStates.PIN}}, entities: {...initialState.entities, search: {results: [latestPost.id]}}} as GlobalState);
const post = testStore.getState().entities.posts.posts[latestPost.id];
await testStore.dispatch(Actions.unpinPost(post.id));
expect(testStore.getActions()).toEqual([
{args: [post.id], type: 'MOCK_UNPIN_POST'},
{data: {posts: [], order: []}, type: SearchTypes.RECEIVED_SEARCH_POSTS},
]);
});
});

View File

@@ -0,0 +1,518 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {AnyAction} from 'redux';
import {batchActions} from 'redux-batched-actions';
import type {WebSocketMessages} from '@mattermost/client';
import type {FileInfo} from '@mattermost/types/files';
import type {Post} from '@mattermost/types/posts';
import type {ScheduledPost} from '@mattermost/types/schedule_post';
import {SearchTypes} from 'mattermost-redux/action_types';
import {getMyChannelMember} from 'mattermost-redux/actions/channels';
import * as PostActions from 'mattermost-redux/actions/posts';
import {createSchedulePost} from 'mattermost-redux/actions/scheduled_posts';
import * as ThreadActions from 'mattermost-redux/actions/threads';
import {getChannel, getMyChannelMember as getMyChannelMemberSelector} from 'mattermost-redux/selectors/entities/channels';
import {makeGetFilesForPost} from 'mattermost-redux/selectors/entities/files';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import * as PostSelectors from 'mattermost-redux/selectors/entities/posts';
import {isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import {canEditPost, comparePosts} from 'mattermost-redux/utils/post_utils';
import {addRecentEmoji, addRecentEmojis} from 'actions/emoji_actions';
import {setGlobalItem} from 'actions/storage';
import * as StorageActions from 'actions/storage';
import {loadNewDMIfNeeded, loadNewGMIfNeeded} from 'actions/user_actions';
import {removeDraft} from 'actions/views/drafts';
import {closeModal, openModal} from 'actions/views/modals';
import * as RhsActions from 'actions/views/rhs';
import {manuallyMarkThreadAsUnread} from 'actions/views/threads';
import {getConnectionId} from 'selectors/general';
import {isEmbedVisible, isInlineImageVisible} from 'selectors/posts';
import {getSelectedPostId, getSelectedPostCardId, getRhsState} from 'selectors/rhs';
import {getGlobalItem} from 'selectors/storage';
import ReactionLimitReachedModal from 'components/reaction_limit_reached_modal';
import {
ActionTypes,
Constants,
ModalIdentifiers,
RHSStates,
StoragePrefixes,
} from 'utils/constants';
import {matchEmoticons} from 'utils/emoticons';
import {makeGetIsReactionAlreadyAddedToPost, makeGetUniqueEmojiNameReactionsForPost} from 'utils/post_utils';
import type {
GlobalState,
DispatchFunc,
ActionFunc,
ActionFuncAsync,
ThunkActionFunc,
} from 'types/store';
import type {PostDraft} from 'types/store/draft';
import type {StorageItem} from 'types/store/storage';
import {completePostReceive} from './new_post';
import type {OnSubmitOptions, SubmitPostReturnType} from './views/create_comment';
export type CreatePostOptions = {
keepDraft?: boolean;
ignorePostError?: boolean;
};
export function handleNewPost(post: Post, msg?: WebSocketMessages.Posted | WebSocketMessages.EphemeralPost): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
let websocketMessageProps = {};
const state = getState();
if (msg) {
websocketMessageProps = msg.data!;
}
const myChannelMember = getMyChannelMemberSelector(state, post.channel_id);
const myChannelMemberDoesntExist = !myChannelMember || (Object.keys(myChannelMember).length === 0 && myChannelMember.constructor === Object);
if (myChannelMemberDoesntExist) {
await dispatch(getMyChannelMember(post.channel_id));
}
dispatch(completePostReceive(post, websocketMessageProps, myChannelMemberDoesntExist));
if (msg && msg.data && 'channel_type' in msg.data) {
if (msg.data.channel_type === Constants.DM_CHANNEL) {
dispatch(loadNewDMIfNeeded(post.channel_id));
} else if (msg.data.channel_type === Constants.GM_CHANNEL) {
dispatch(loadNewGMIfNeeded(post.channel_id));
}
}
return {data: true};
};
}
const getPostsForIds = PostSelectors.makeGetPostsForIds();
export function flagPost(postId: string): ActionFuncAsync {
return async (dispatch, getState) => {
await dispatch(PostActions.flagPost(postId));
const state = getState();
const rhsState = getRhsState(state);
if (rhsState === RHSStates.FLAG) {
dispatch(addPostToSearchResults(postId));
}
return {data: true};
};
}
export function unflagPost(postId: string): ActionFuncAsync {
return async (dispatch, getState) => {
await dispatch(PostActions.unflagPost(postId));
const state = getState();
const rhsState = getRhsState(state);
if (rhsState === RHSStates.FLAG) {
removePostFromSearchResults(postId, state, dispatch);
}
return {data: true};
};
}
function addRecentEmojisForMessage(message: string): ActionFunc {
return (dispatch) => {
// parse message and emit emoji event
const emojis = matchEmoticons(message);
if (emojis) {
const trimmedEmojis = emojis.map((emoji) => emoji.substring(1, emoji.length - 1));
dispatch(addRecentEmojis(trimmedEmojis));
}
return {data: true};
};
}
export function createPost(
post: Post,
files: FileInfo[],
afterSubmit?: (response: SubmitPostReturnType) => void,
options?: OnSubmitOptions,
): ActionFuncAsync<PostActions.CreatePostReturnType> {
return async (dispatch) => {
dispatch(addRecentEmojisForMessage(post.message));
const result = await dispatch(PostActions.createPost(post, files, afterSubmit));
if (!options?.keepDraft) {
if (post.root_id) {
dispatch(storeCommentDraft(post.root_id, null));
} else {
dispatch(storeDraft(post.channel_id, null));
}
}
options?.afterOptimisticSubmit?.();
return result;
};
}
export function createSchedulePostFromDraft(scheduledPost: ScheduledPost): ActionFuncAsync<PostActions.CreatePostReturnType> {
return async (dispatch, getState) => {
dispatch(addRecentEmojisForMessage(scheduledPost.message));
const state = getState();
const connectionId = getConnectionId(state);
const channel = state.entities.channels.channels[scheduledPost.channel_id];
const result = await dispatch(createSchedulePost(scheduledPost, channel.team_id, connectionId));
return {
created: !result.error && result.data,
error: result.error,
};
};
}
function storeDraft(channelId: string, draft: null): ActionFunc {
return (dispatch) => {
dispatch(StorageActions.setGlobalItem('draft_' + channelId, draft));
return {data: true};
};
}
function storeCommentDraft(rootPostId: string, draft: null): ActionFunc {
return (dispatch) => {
dispatch(StorageActions.setGlobalItem('comment_draft_' + rootPostId, draft));
return {data: true};
};
}
export function submitReaction(postId: string, action: string, emojiName: string): ActionFuncAsync<PostActions.SubmitReactionReturnType> {
return async (dispatch, getState) => {
const state = getState();
const getIsReactionAlreadyAddedToPost = makeGetIsReactionAlreadyAddedToPost();
const isReactionAlreadyAddedToPost = getIsReactionAlreadyAddedToPost(state, postId, emojiName);
if (action === '+' && !isReactionAlreadyAddedToPost) {
return dispatch(addReaction(postId, emojiName));
} else if (action === '-' && isReactionAlreadyAddedToPost) {
return dispatch(PostActions.removeReaction(postId, emojiName));
}
return {error: new Error(`unknown action ${action}`)};
};
}
export function toggleReaction(postId: string, emojiName: string): ActionFuncAsync<PostActions.SubmitReactionReturnType> {
return async (dispatch, getState) => {
const state = getState();
const getIsReactionAlreadyAddedToPost = makeGetIsReactionAlreadyAddedToPost();
const isReactionAlreadyAddedToPost = getIsReactionAlreadyAddedToPost(state, postId, emojiName);
if (isReactionAlreadyAddedToPost) {
return dispatch(PostActions.removeReaction(postId, emojiName));
}
return dispatch(addReaction(postId, emojiName));
};
}
export function addReaction(postId: string, emojiName: string): ActionFuncAsync<PostActions.SubmitReactionReturnType> {
const getUniqueEmojiNameReactionsForPost = makeGetUniqueEmojiNameReactionsForPost();
return async (dispatch, getState) => {
const state = getState();
const config = getConfig(state);
const uniqueEmojiNames = getUniqueEmojiNameReactionsForPost(state, postId) ?? [];
// If we're adding a new reaction but we're already at or over the limit, stop
if (uniqueEmojiNames.length >= Number(config.UniqueEmojiReactionLimitPerPost) && !uniqueEmojiNames.some((name) => name === emojiName)) {
dispatch(openModal({
modalId: ModalIdentifiers.REACTION_LIMIT_REACHED,
dialogType: ReactionLimitReachedModal,
dialogProps: {
isAdmin: isCurrentUserSystemAdmin(state),
onExited: () => closeModal(ModalIdentifiers.REACTION_LIMIT_REACHED),
},
}));
return {error: new Error('reached reaction limit')};
}
dispatch(addRecentEmoji(emojiName));
const result = await dispatch(PostActions.addReaction(postId, emojiName));
return result;
};
}
export function searchForTerm(term: string): ActionFunc<boolean> {
return (dispatch) => {
dispatch(RhsActions.updateSearchTerms(term));
dispatch(RhsActions.showSearchResults());
return {data: true};
};
}
function addPostToSearchResults(postId: string): ActionFunc {
return (dispatch, getState) => {
const state = getState();
const results = state.entities.search.results;
const index = results.indexOf(postId);
if (index === -1) {
const newPost = PostSelectors.getPost(state, postId);
const posts = getPostsForIds(state, results).reduce((acc, post) => {
acc[post.id] = post;
return acc;
}, {} as Record<string, Post>);
posts[newPost.id] = newPost;
const newResults = [...results, postId];
newResults.sort((a, b) => comparePosts(posts[a], posts[b]));
dispatch({
type: SearchTypes.RECEIVED_SEARCH_POSTS,
data: {posts, order: newResults},
});
}
return {data: true};
};
}
function removePostFromSearchResults(postId: string, state: GlobalState, dispatch: DispatchFunc) {
let results = state.entities.search.results;
const index = results.indexOf(postId);
if (index > -1) {
results = [...results];
results.splice(index, 1);
const posts = getPostsForIds(state, results);
dispatch({
type: SearchTypes.RECEIVED_SEARCH_POSTS,
data: {posts, order: results},
});
}
}
export function pinPost(postId: string): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
await dispatch(PostActions.pinPost(postId));
const state = getState();
const rhsState = getRhsState(state);
if (rhsState === RHSStates.PIN) {
dispatch(addPostToSearchResults(postId));
}
return {data: true};
};
}
export function unpinPost(postId: string): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
await dispatch(PostActions.unpinPost(postId));
const state = getState();
const rhsState = getRhsState(state);
if (rhsState === RHSStates.PIN) {
removePostFromSearchResults(postId, state, dispatch);
}
return {data: true};
};
}
export function setEditingPost(postId = '', refocusId = '', isRHS = false): ActionFunc<boolean, GlobalState> {
const getFilesForPost = makeGetFilesForPost();
return (dispatch, getState) => {
const state = getState();
let post = PostSelectors.getPost(state, postId);
// getPost selectors doesn't include post's file metadata, so we need to add it manually
if (post.file_ids?.length) {
// if the post has files, get their metadata and insert it into the post object
const files = getFilesForPost(state, postId);
post = {...post, metadata: {...post.metadata, files}};
}
if (!post || post.pending_post_id === postId) {
return {data: false};
}
const config = state.entities.general.config;
const license = state.entities.general.license;
const userId = getCurrentUserId(state);
const channel = getChannel(state, post.channel_id);
const teamId = channel?.team_id || '';
const canEdit = canEditPost(state, config, license, teamId, post.channel_id, userId, post);
if (!canEdit) {
return {data: false};
}
const storageKey = `${StoragePrefixes.EDIT_DRAFT}${post.id}`;
const actions: AnyAction[] = [{
type: ActionTypes.TOGGLE_EDITING_POST,
data: {postId, refocusId, isRHS, show: true},
}];
// We need to see if post's draft is already in store, if it is, we don't need to set it again
const editDraftInStore = getGlobalItem(state, storageKey, null) as StorageItem<PostDraft>['value'] | null;
if (
!editDraftInStore ||
(editDraftInStore &&
editDraftInStore?.message?.length === 0 &&
editDraftInStore?.fileInfos?.length === 0 &&
editDraftInStore?.uploadsInProgress?.length === 0
)
) {
actions.push(setGlobalItem(storageKey, post));
}
dispatch(batchActions(actions));
return {data: true};
};
}
export function unsetEditingPost(): ActionFunc<boolean, GlobalState> {
return (dispatch, getState) => {
const editingPostId = getState().views.posts.editingPost.postId;
const actions: AnyAction[] = [{
type: ActionTypes.TOGGLE_EDITING_POST,
data: {
show: false,
},
}];
if (editingPostId) {
const storageKey = `${StoragePrefixes.EDIT_DRAFT}${editingPostId}`;
actions.push(StorageActions.removeGlobalItem(storageKey));
}
dispatch(batchActions(actions));
return {data: true};
};
}
export function markPostAsUnread(post: Post, location?: string): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const userId = getCurrentUserId(state);
const currentTeamId = getCurrentTeamId(state);
// if CRT:ON and this is from within ThreadViewer (e.g. post dot-menu), mark the thread as unread and followed
if (isCollapsedThreadsEnabled(state) && (location === 'RHS_ROOT' || location === 'RHS_COMMENT')) {
const threadId = post.root_id || post.id;
ThreadActions.handleFollowChanged(dispatch, threadId, currentTeamId, true);
dispatch(manuallyMarkThreadAsUnread(threadId, post.create_at - 1));
await dispatch(ThreadActions.markThreadAsUnread(userId, currentTeamId, threadId, post.id));
} else {
// use normal channel unread system
await dispatch(PostActions.setUnreadPost(userId, post.id));
}
return {data: true};
};
}
export function markMostRecentPostInChannelAsUnread(channelId: string): ActionFuncAsync {
return async (dispatch, getState) => {
let state = getState();
let postId = PostSelectors.getMostRecentNonSystemPostIdInChannel(state, channelId);
if (!postId) {
await dispatch(PostActions.getPosts(channelId));
state = getState();
postId = PostSelectors.getMostRecentNonSystemPostIdInChannel(state, channelId);
}
if (postId) {
const lastPost = PostSelectors.getPost(state, postId);
dispatch(markPostAsUnread(lastPost, 'CENTER'));
}
return {data: true};
};
}
// Action called by DeletePostModal when the post is deleted
export function deleteAndRemovePost(post: Post): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
const {error} = await dispatch(PostActions.deletePost(post));
if (error) {
return {error};
}
if (post.id === getSelectedPostId(getState())) {
dispatch({
type: ActionTypes.SELECT_POST,
postId: '',
channelId: '',
timestamp: 0,
});
}
if (post.id === getSelectedPostCardId(getState())) {
dispatch({
type: ActionTypes.SELECT_POST_CARD,
postId: '',
channelId: '',
});
}
if (post.root_id === '') {
const key = StoragePrefixes.COMMENT_DRAFT + post.id;
if (getGlobalItem(getState(), key, null)) {
dispatch(removeDraft(key, post.channel_id, post.id));
}
}
dispatch(PostActions.removePost(post));
return {data: true};
};
}
export function toggleEmbedVisibility(postId: string): ThunkActionFunc<void> {
return (dispatch, getState) => {
const state = getState();
const currentUserId = getCurrentUserId(state);
const visible = isEmbedVisible(state, postId);
dispatch(StorageActions.setGlobalItem(StoragePrefixes.EMBED_VISIBLE + currentUserId + '_' + postId, !visible));
};
}
export function resetEmbedVisibility() {
return StorageActions.actionOnGlobalItemsWithPrefix(StoragePrefixes.EMBED_VISIBLE, () => null);
}
export function toggleInlineImageVisibility(postId: string, imageKey: string): ThunkActionFunc<void> {
return (dispatch, getState) => {
const state = getState();
const currentUserId = getCurrentUserId(state);
const visible = isInlineImageVisible(state, postId, imageKey);
dispatch(StorageActions.setGlobalItem(StoragePrefixes.INLINE_IMAGE_VISIBLE + currentUserId + '_' + postId + '_' + imageKey, !visible));
};
}
export function resetInlineImageVisibility() {
return StorageActions.actionOnGlobalItemsWithPrefix(StoragePrefixes.INLINE_IMAGE_VISIBLE, () => null);
}
/*
* It is called from either center or rhs text input when shortcut for react to last message is pressed
*
* @param {string} emittedFrom - It can be either "CENTER", "RHS_ROOT" or "NO_WHERE"
*/
export function emitShortcutReactToLastPostFrom(emittedFrom: keyof typeof Constants.Locations) {
return {
type: ActionTypes.EMITTED_SHORTCUT_REACT_TO_LAST_POST,
payload: emittedFrom,
};
}

View File

@@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {PropertyValue} from '@mattermost/types/properties';
import {PropertyTypes} from 'mattermost-redux/action_types';
import {handlePropertyValuesUpdated} from 'actions/websocket_actions';
import mockStore from 'tests/test_store';
// The server emits four payloads under one property_values_updated event,
// distinguished only by which keys are present. See the Go-side contract in
// TestPropertyValuesUpdatedPayloadShapes; these assert the client half.
const CHANNEL_ID = 'channel_id_1';
const FIELD_ID = 'field_id_1';
const GROUP_ID = 'group_id_1';
function storedValue(overrides: Partial<PropertyValue<unknown>> = {}): PropertyValue<unknown> {
return {
id: 'value_id_1',
target_id: CHANNEL_ID,
target_type: 'channel',
group_id: GROUP_ID,
field_id: FIELD_ID,
value: 'AURORA',
create_at: 1,
update_at: 1,
delete_at: 0,
created_by: '',
updated_by: '',
...overrides,
};
}
function dispatchEvent(data: Record<string, unknown>) {
const store = mockStore({
entities: {
general: {config: {}, license: {}},
properties: {
fields: {byId: {}, byObjectType: {}},
values: {byTargetId: {}, byFieldId: {}},
groups: {byId: {}, byName: {}},
},
channelCategories: {byId: {}, orderByTeam: {}},
},
});
const msg = {
event: 'property_values_updated',
data,
broadcast: {omit_users: null, user_id: '', channel_id: CHANNEL_ID, team_id: ''},
seq: 1,
};
store.dispatch(handlePropertyValuesUpdated(msg as any) as any);
return store.getActions().flatMap((action: any) => (Array.isArray(action) ? action : [action]));
}
function typesOf(actions: any[]): string[] {
return actions.map((action) => action.type);
}
describe('property_values_updated payload shapes', () => {
test('shape 1: upsert stores the values', () => {
const actions = dispatchEvent({
object_type: 'channel',
target_id: CHANNEL_ID,
values: JSON.stringify([storedValue()]),
});
expect(typesOf(actions)).toContain(PropertyTypes.RECEIVED_PROPERTY_VALUES);
const received = actions.find((a) => a.type === PropertyTypes.RECEIVED_PROPERTY_VALUES);
expect(received.data.values[0].value).toBe('AURORA');
});
test('shape 1b: a null-valued row is stored, not deleted', () => {
// The user-initiated clear keeps the row. No delete event ever arrives,
// so removing it here would leave the client waiting for one.
const actions = dispatchEvent({
object_type: 'channel',
target_id: CHANNEL_ID,
values: JSON.stringify([storedValue({value: null})]),
});
expect(typesOf(actions)).toContain(PropertyTypes.RECEIVED_PROPERTY_VALUES);
expect(typesOf(actions)).not.toContain(PropertyTypes.PROPERTY_VALUE_DELETED);
});
test('shape 2: a tombstone deletes the row rather than storing a blank one', () => {
// Identical to shape 1b on the wire apart from the empty id.
const actions = dispatchEvent({
object_type: 'channel',
target_id: CHANNEL_ID,
values: JSON.stringify([storedValue({id: '', value: null})]),
});
expect(typesOf(actions)).toContain(PropertyTypes.PROPERTY_VALUE_DELETED);
expect(typesOf(actions)).not.toContain(PropertyTypes.RECEIVED_PROPERTY_VALUES);
const deleted = actions.find((a) => a.type === PropertyTypes.PROPERTY_VALUE_DELETED);
expect(deleted.data).toEqual({targetId: CHANNEL_ID, fieldId: FIELD_ID});
});
test('shape 3: an empty array clears only that target', () => {
const actions = dispatchEvent({
object_type: 'channel',
target_id: CHANNEL_ID,
values: '[]',
});
expect(typesOf(actions)).toContain(PropertyTypes.PROPERTY_VALUES_DELETED_FOR_TARGET);
const cleared = actions.find((a) => a.type === PropertyTypes.PROPERTY_VALUES_DELETED_FOR_TARGET);
expect(cleared.data).toEqual({targetId: CHANNEL_ID});
});
test('shape 4: field_id with no target clears that field everywhere', () => {
const actions = dispatchEvent({
field_id: FIELD_ID,
values: '[]',
});
expect(typesOf(actions)).toContain(PropertyTypes.PROPERTY_VALUES_DELETED_FOR_FIELD);
const cleared = actions.find((a) => a.type === PropertyTypes.PROPERTY_VALUES_DELETED_FOR_FIELD);
expect(cleared.data).toEqual({fieldId: FIELD_ID});
// Must not be mistaken for a target-scoped clear, which would wipe an
// unrelated channel instead of one attribute.
expect(typesOf(actions)).not.toContain(PropertyTypes.PROPERTY_VALUES_DELETED_FOR_TARGET);
});
test('malformed JSON is ignored', () => {
const actions = dispatchEvent({object_type: 'channel', target_id: CHANNEL_ID, values: 'not json'});
expect(typesOf(actions)).not.toContain(PropertyTypes.RECEIVED_PROPERTY_VALUES);
expect(typesOf(actions)).not.toContain(PropertyTypes.PROPERTY_VALUE_DELETED);
});
});

View File

@@ -0,0 +1,135 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import cloneDeep from 'lodash/cloneDeep';
import type {UserProfile} from '@mattermost/types/users';
import {addUserIdsForStatusFetchingPoll} from 'mattermost-redux/actions/status_profile_polling';
import {getStatusesByIds} from 'mattermost-redux/actions/users';
import {Preferences} from 'mattermost-redux/constants';
import * as Actions from 'actions/status_actions';
import mockStore from 'tests/test_store';
jest.mock('mattermost-redux/actions/users', () => ({
getStatusesByIds: jest.fn(() => {
return {type: ''};
}),
}));
jest.mock('mattermost-redux/actions/status_profile_polling', () => ({
addUserIdsForStatusFetchingPoll: jest.fn(() => {
return {type: ''};
}),
}));
interface CustomMatchers<R = unknown> {
arrayContainingExactly(stringArray: string[]): R;
}
type GreatExpectations = typeof expect & CustomMatchers;
describe('actions/status_actions', () => {
const initialState = {
views: {
channel: {
postVisibility: {channel_id1: 100, channel_id2: 100},
},
},
entities: {
channels: {
currentChannelId: 'channel_id1',
channels: {channel_id1: {id: 'channel_id1', name: 'channel1', team_id: 'team_id1'}, channel_id2: {id: 'channel_id2', name: 'channel2', team_id: 'team_id1'}},
myMembers: {channel_id1: {channel_id: 'channel_id1', user_id: 'current_user_id'}},
channelsInTeam: {team_id1: new Set(['channel_id1'])},
},
general: {
config: {
EnableCustomEmoji: 'true',
EnableCustomUserStatuses: 'true',
EnableUserStatuses: 'true',
},
},
teams: {
currentTeamId: 'team_id1',
teams: {
team_id1: {
id: 'team_id1',
name: 'team1',
},
},
},
users: {
currentUserId: 'current_user_id',
profiles: {user_id2: {id: 'user_id2', username: 'user2'}, user_id3: {id: 'user_id3', username: 'user3'}, user_id4: {id: 'user_id4', username: 'user4'}},
},
posts: {
posts: {post_id1: {id: 'post_id1', user_id: 'current_user_id'}, post_id2: {id: 'post_id2', user_id: 'user_id2'}},
postsInChannel: {
channel_id1: [
{order: ['post_id1', 'post_id2'], recent: true},
],
channel_id2: [],
},
},
preferences: {
myPreferences: {
[Preferences.CATEGORY_DIRECT_CHANNEL_SHOW + '--user_id3']: {category: Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, name: 'user_id3', value: 'true', user_id: 'current_user_id'},
[Preferences.CATEGORY_DIRECT_CHANNEL_SHOW + '--user_id1']: {category: Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, name: 'user_id1', value: 'false', user_id: 'current_user_id'},
},
},
},
};
describe('addUserIdsForStatusAndProfileFetchingPoll', () => {
test('load statuses with posts in channel and user in sidebar', () => {
const state = cloneDeep(initialState);
const testStore = mockStore(state);
testStore.dispatch(Actions.addVisibleUsersInCurrentChannelAndSelfToStatusPoll());
expect(addUserIdsForStatusFetchingPoll).toHaveBeenCalled();
expect(addUserIdsForStatusFetchingPoll).toHaveBeenCalledWith(['user_id2', 'user_id3', 'current_user_id']);
});
test('load statuses with empty channel and user in sidebar', () => {
const state = cloneDeep(initialState);
state.entities.channels.currentChannelId = 'channel_id2';
const testStore = mockStore(state);
testStore.dispatch(Actions.addVisibleUsersInCurrentChannelAndSelfToStatusPoll());
expect(addUserIdsForStatusFetchingPoll).toHaveBeenCalledWith(['user_id3', 'current_user_id']);
});
test('load statuses with empty channel and no users in sidebar, should only fetch current user\'s status', () => {
const state = cloneDeep(initialState);
state.entities.channels.currentChannelId = 'channel_id2';
state.entities.preferences.myPreferences = {};
const testStore = mockStore(state);
testStore.dispatch(Actions.addVisibleUsersInCurrentChannelAndSelfToStatusPoll());
expect(addUserIdsForStatusFetchingPoll).toHaveBeenCalledWith(['current_user_id']);
});
});
describe('loadStatusesForProfilesList', () => {
test('load statuses for users array', () => {
const state = cloneDeep(initialState);
const testStore = mockStore(state);
testStore.dispatch(Actions.loadStatusesForProfilesList([{id: 'user_id2', username: 'user2'} as UserProfile, {id: 'user_id4', username: 'user4'} as UserProfile]));
expect(getStatusesByIds).toHaveBeenCalledWith((expect as GreatExpectations).arrayContainingExactly(['user_id2', 'user_id4']));
});
test('load statuses for empty users array', () => {
const state = cloneDeep(initialState);
const testStore = mockStore(state);
testStore.dispatch(Actions.loadStatusesForProfilesList([]));
expect(getStatusesByIds).not.toHaveBeenCalled();
});
test('load statuses for null users array', () => {
const state = cloneDeep(initialState);
const testStore = mockStore(state);
testStore.dispatch(Actions.loadStatusesForProfilesList(null));
expect(getStatusesByIds).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,117 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {UserProfile} from '@mattermost/types/users';
import {addUserIdsForStatusFetchingPoll} from 'mattermost-redux/actions/status_profile_polling';
import {getStatusesByIds} from 'mattermost-redux/actions/users';
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels';
import {getIsUserStatusesConfigEnabled} from 'mattermost-redux/selectors/entities/common';
import {getPostsInCurrentChannel} from 'mattermost-redux/selectors/entities/posts';
import {getDirectShowPreferences} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {loadCustomEmojisForCustomStatusesByUserIds} from 'actions/emoji_actions';
import type {ActionFunc} from 'types/store';
/**
* Adds the following users to the status pool for fetching their statuses:
* - All users of current channel with recent posts.
* - All users who have DMs open with the current user.
* - The current user.
*/
export function addVisibleUsersInCurrentChannelAndSelfToStatusPoll(): ActionFunc<boolean> {
return (dispatch, getState) => {
const state = getState();
const currentUserId = getCurrentUserId(state);
const currentChannelId = getCurrentChannelId(state);
const postsInChannel = getPostsInCurrentChannel(state);
const numberOfPostsVisibleInCurrentChannel = state.views.channel.postVisibility[currentChannelId] || 0;
const userIdsToFetchStatusFor = new Set<string>();
// We fetch for users who have recently posted in the current channel
if (postsInChannel && numberOfPostsVisibleInCurrentChannel > 0) {
const posts = postsInChannel.slice(0, numberOfPostsVisibleInCurrentChannel);
for (const post of posts) {
if (post.user_id && post.user_id !== currentUserId) {
userIdsToFetchStatusFor.add(post.user_id);
}
}
}
// We also fetch for users who have DMs open with the current user
const directShowPreferences = getDirectShowPreferences(state);
for (const directShowPreference of directShowPreferences) {
if (directShowPreference.value === 'true') {
// This is the other user's id in the DM
userIdsToFetchStatusFor.add(directShowPreference.name);
}
}
// Add current user to the list to fetch status for
userIdsToFetchStatusFor.add(currentUserId);
// Both the users in the DM list and recent posts constitute for all the visible users in the current channel
const userIdsForStatus = Array.from(userIdsToFetchStatusFor);
if (userIdsForStatus.length > 0) {
dispatch(addUserIdsForStatusFetchingPoll(userIdsForStatus));
}
return {data: true};
};
}
export function loadStatusesForProfilesList(users: UserProfile[] | null): ActionFunc<boolean> {
return (dispatch) => {
if (users == null) {
return {data: false};
}
const statusesToLoad = [];
for (let i = 0; i < users.length; i++) {
statusesToLoad.push(users[i].id);
}
dispatch(loadStatusesByIds(statusesToLoad));
return {data: true};
};
}
export function loadStatusesByIds(userIds: string[]): ActionFunc {
return (dispatch, getState) => {
const state = getState();
const enabledUserStatuses = getIsUserStatusesConfigEnabled(state);
if (userIds.length === 0 || !enabledUserStatuses) {
return {data: false};
}
dispatch(getStatusesByIds(userIds));
dispatch(loadCustomEmojisForCustomStatusesByUserIds(userIds));
return {data: true};
};
}
export function loadProfilesMissingStatus(users: UserProfile[]): ActionFunc {
return (dispatch, getState) => {
const state = getState();
const enabledUserStatuses = getIsUserStatusesConfigEnabled(state);
const statuses = state.entities.users.statuses;
const missingStatusByIds = users.
filter((user) => !statuses[user.id]).
map((user) => user.id);
if (missingStatusByIds.length === 0 || !enabledUserStatuses) {
return {data: false};
}
dispatch(getStatusesByIds(missingStatusByIds));
dispatch(loadCustomEmojisForCustomStatusesByUserIds(missingStatusByIds));
return {data: true};
};
}

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as Actions from 'actions/storage';
import configureStore from 'store';
describe('Actions.Storage', () => {
let store = configureStore();
beforeEach(async () => {
store = await configureStore();
});
it('setGlobalItem', async () => {
store.dispatch(Actions.setGlobalItem('test', 'value'));
expect(store.getState().storage.storage.test.value).toBe('value');
expect(typeof store.getState().storage.storage.test.timestamp).not.toBe('undefined');
});
it('removeGlobalItem', async () => {
store.dispatch(Actions.setGlobalItem('test1', 'value1'));
store.dispatch(Actions.setGlobalItem('test2', 'value2'));
expect(store.getState().storage.storage.test1.value).toBe('value1');
expect(store.getState().storage.storage.test2.value).toBe('value2');
store.dispatch(Actions.removeGlobalItem('test1'));
expect(typeof store.getState().storage.storage.test1).toBe('undefined');
expect(store.getState().storage.storage.test2.value).toBe('value2');
});
it('actionOnGlobalItemsWithPrefix', async () => {
const touchedPairs: Array<[string, number]> = [];
store.dispatch(Actions.setGlobalItem('prefix_test1', 1));
store.dispatch(Actions.setGlobalItem('prefix_test2', 2));
store.dispatch(Actions.setGlobalItem('not_prefix_test', 3));
store.dispatch(Actions.actionOnGlobalItemsWithPrefix(
'prefix',
(key, value) => touchedPairs.push([key, value]),
));
expect(touchedPairs).toEqual([['prefix_test1', 1], ['prefix_test2', 2]]);
});
});
describe('cleanLocalStorage', () => {
beforeAll(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
test('should clear keys used for user profile colors in compact mode', () => {
const keys = [
'harrison-#0a111f',
'harrison-#090a0b',
'jira-#0a111f',
'jira-#090a0b',
'github-#090a0b',
];
for (const key of keys) {
localStorage.setItem(key, key);
}
Actions.cleanLocalStorage();
expect(localStorage.length).toBe(0);
});
test('should not clear keys used for other things', () => {
const keys = [
'theme',
'was_logged_in',
'__landingPageSeen__',
'emoji-mart.frequently',
];
for (const key of keys) {
localStorage.setItem(key, key);
}
Actions.cleanLocalStorage();
expect(localStorage.length).toBe(keys.length);
});
});

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {StorageTypes} from 'utils/constants';
export function setGlobalItem(name: string, value: any) {
return {
type: StorageTypes.SET_GLOBAL_ITEM,
data: {name, value, timestamp: new Date()},
};
}
export function removeGlobalItem(name: string) {
return {
type: StorageTypes.REMOVE_GLOBAL_ITEM,
data: {name},
};
}
export function actionOnGlobalItemsWithPrefix(prefix: string, action: (key: string, value: any) => any) {
return {
type: StorageTypes.ACTION_ON_GLOBAL_ITEMS_WITH_PREFIX,
data: {prefix, action},
};
}
export function cleanLocalStorage() {
const userProfileColorPattern = /^[a-z0-9.\-_]+-#[a-z0-9]{6}$/i;
for (const key of Object.keys(localStorage)) {
// Remove all keys added for user profile colours before MM-47782
if (userProfileColorPattern.test(key)) {
localStorage.removeItem(key);
}
}
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as channelActions from 'mattermost-redux/actions/channels';
import * as TeamActions from 'mattermost-redux/actions/teams';
import * as userActions from 'mattermost-redux/actions/users';
import * as Actions from 'actions/team_actions';
import configureStore from 'tests/test_store';
import {getHistory} from 'utils/browser_history';
import {TestHelper} from 'utils/test_helper';
jest.mock('mattermost-redux/actions/teams', () => ({
...jest.requireActual('mattermost-redux/actions/teams'),
addUsersToTeamGracefully: jest.fn(() => {
return {
type: 'ADD_USER',
};
}),
removeUserFromTeam: jest.fn(() => {
return {
type: 'REMOVE_USER_FROM_TEAM',
};
}),
getTeamStats: jest.fn(() => {
return {
type: 'GET_TEAM_STATS',
};
}),
addUserToTeam: jest.fn(() => {
return {
type: 'ADD_USERS_TO_TEAM',
data: {
team_id: 'teamId',
},
};
}),
getTeam: jest.fn(() => {
return {
type: 'GET_TEAM',
};
}),
}));
jest.mock('mattermost-redux/actions/channels', () => ({
viewChannel: jest.fn(() => {
return {
type: 'VIEW_CHANNEL',
};
}),
getChannelStats: jest.fn(() => {
return {
type: 'GET_CHANNEL_STAT',
};
}),
}));
jest.mock('mattermost-redux/actions/users', () => ({
getUser: jest.fn(() => {
return {
type: 'GET_USER',
};
}),
}));
describe('Actions.Team', () => {
const currentChannelId = 'currentChannelId';
const initialState = {
entities: {
channels: {
currentChannelId,
manuallyUnread: {},
},
},
};
describe('switchTeam', () => {
test('should switch to a team by its URL if no team is provided', () => {
const testStore = configureStore(initialState);
testStore.dispatch(Actions.switchTeam('/test'));
expect(getHistory().push).toHaveBeenCalledWith('/test');
expect(testStore.getActions()).toEqual([]);
});
test('should select a team without changing URL if a team is provided', () => {
const testStore = configureStore(initialState);
const team = TestHelper.getTeamMock();
testStore.dispatch(Actions.switchTeam('/test', team));
expect(getHistory().push).not.toHaveBeenCalled();
expect(testStore.getActions()).toContainEqual(TeamActions.selectTeam(team));
});
});
test('addUsersToTeam', () => {
const testStore = configureStore(initialState);
testStore.dispatch(Actions.addUsersToTeam('teamId', ['123', '1234']));
expect(TeamActions.addUsersToTeamGracefully).toHaveBeenCalledWith('teamId', ['123', '1234']);
});
test('removeUserFromTeamAndGetStats', async () => {
const testStore = configureStore(initialState);
await testStore.dispatch(Actions.removeUserFromTeamAndGetStats('teamId', '123'));
expect(userActions.getUser).toHaveBeenCalledWith('123');
expect(TeamActions.getTeamStats).toHaveBeenCalledWith('teamId');
expect(channelActions.getChannelStats).toHaveBeenCalledWith(currentChannelId);
});
test('addUserToTeam', async () => {
const testStore = configureStore(initialState);
await testStore.dispatch(Actions.addUserToTeam('teamId', 'userId'));
expect(TeamActions.addUserToTeam).toHaveBeenCalledWith('teamId', 'userId');
expect(TeamActions.getTeam).toHaveBeenCalledWith('teamId');
});
});

View File

@@ -0,0 +1,135 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ServerError} from '@mattermost/types/errors';
import type {Team, TeamMemberWithError} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import {TeamTypes} from 'mattermost-redux/action_types';
import {getChannelStats} from 'mattermost-redux/actions/channels';
import {logError} from 'mattermost-redux/actions/errors';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import * as TeamActions from 'mattermost-redux/actions/teams';
import {selectTeam} from 'mattermost-redux/actions/teams';
import {getUser} from 'mattermost-redux/actions/users';
import {Client4} from 'mattermost-redux/client';
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getHistory} from 'utils/browser_history';
import {Preferences} from 'utils/constants';
import type {ActionFuncAsync, ThunkActionFunc} from 'types/store';
export function removeUserFromTeamAndGetStats(teamId: Team['id'], userId: UserProfile['id']): ActionFuncAsync {
return async (dispatch, getState) => {
const response = await dispatch(TeamActions.removeUserFromTeam(teamId, userId));
dispatch(getUser(userId));
dispatch(TeamActions.getTeamStats(teamId));
dispatch(getChannelStats(getCurrentChannelId(getState())));
return response;
};
}
export function addUserToTeamFromInvite(token: string, inviteId: string): ActionFuncAsync<Team> {
return async (dispatch) => {
const {data: member, error} = await dispatch(TeamActions.addUserToTeamFromInvite(token, inviteId));
if (member) {
const {data} = await dispatch(TeamActions.getTeam(member.team_id));
dispatch({
type: TeamTypes.RECEIVED_MY_TEAM_MEMBER,
data: {
...member,
delete_at: 0,
msg_count: 0,
mention_count: 0,
},
});
return {data};
}
return {error};
};
}
export function addUserToTeam(teamId: Team['id'], userId: UserProfile['id']): ActionFuncAsync<Team> {
return async (dispatch) => {
const {data: member, error} = await dispatch(TeamActions.addUserToTeam(teamId, userId));
if (member) {
const {data} = await dispatch(TeamActions.getTeam(member.team_id));
dispatch({
type: TeamTypes.RECEIVED_MY_TEAM_MEMBER,
data: {
...member,
delete_at: 0,
msg_count: 0,
mention_count: 0,
},
});
return {data};
}
return {error};
};
}
export function addUsersToTeam(teamId: Team['id'], userIds: Array<UserProfile['id']>): ActionFuncAsync<TeamMemberWithError[]> {
return async (dispatch, getState) => {
const {data, error} = await dispatch(TeamActions.addUsersToTeamGracefully(teamId, userIds));
if (error) {
return {error};
}
dispatch(getChannelStats(getCurrentChannelId(getState())));
return {data};
};
}
export function switchTeam(url: string, team?: Team): ThunkActionFunc<void> {
return (dispatch) => {
// In Channels, the team argument is undefined, and team switching is done by pushing a URL onto history.
// In other products, a team is passed instead of a URL because the current team isn't tied to the page URL.
//
// Note that url may also be a non-team URL since this is called when switching to the Create Team page
// from the team sidebar as well.
if (team) {
dispatch(selectTeam(team));
} else {
getHistory().push(url);
}
};
}
export function updateTeamsOrderForUser(teamIds: Array<Team['id']>): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const currentUserId = getCurrentUserId(state);
const teamOrderPreferences = [{
user_id: currentUserId,
name: '',
category: Preferences.TEAMS_ORDER,
value: teamIds.join(','),
}];
return dispatch(savePreferences(currentUserId, teamOrderPreferences));
};
}
export function getGroupMessageMembersCommonTeams(channelId: string): ActionFuncAsync<Team[]> {
return async (dispatch) => {
let teams: Team[];
try {
const response = await Client4.getGroupMessageMembersCommonTeams(channelId);
teams = response.data;
} catch (error) {
dispatch(logError(error as ServerError));
return {error: error as ServerError};
}
return {data: teams};
};
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from 'mattermost-redux/client';
const HEADER_X_PAGE_LOAD_CONTEXT = 'X-Page-Load-Context';
/**
* Takes an array of string names of performance markers and invokes
* performance.clearMarkers on each. *
*/
export function clearMarks(names: string[]) {
names.forEach((name) => performance.clearMarks(name));
}
export function mark(name: string) {
performance.mark(name);
}
/**
* This allows the server to know that a given HTTP request occurred during page load or reconnect.
* The server then uses this information to store metrics fields based on the request context.
* The setTimeout approach is a "best effort" approach that will produce false positives.
* A more accurate approach will result in more obtrusive code, which would add risk and maintenance cost.
*/
export const temporarilySetPageLoadContext = (pageLoadContext: string) => {
Client4.setHeader(HEADER_X_PAGE_LOAD_CONTEXT, pageLoadContext);
setTimeout(() => {
Client4.removeHeader(HEADER_X_PAGE_LOAD_CONTEXT);
}, 5000);
};

View File

@@ -0,0 +1,706 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Dispatch, AnyAction} from 'redux';
import type {Channel, ChannelMembership, ChannelMessageCount} from '@mattermost/types/channels';
import type {Post} from '@mattermost/types/posts';
import type {Team, TeamMembership} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import {Preferences, General} from 'mattermost-redux/constants';
import {CategoryTypes} from 'mattermost-redux/constants/channel_categories';
import {getPreferenceKey} from 'mattermost-redux/utils/preference_utils';
import * as UserActions from 'actions/user_actions';
import store from 'stores/redux_store';
import TestHelper from 'packages/mattermost-redux/test/test_helper';
import mockStore from 'tests/test_store';
import type {GlobalState} from 'types/store';
jest.mock('mattermost-redux/actions/users', () => {
const original = jest.requireActual('mattermost-redux/actions/users');
return {
...original,
searchProfiles: (...args: any[]) => ({type: 'MOCK_SEARCH_PROFILES', args}),
getProfilesInTeam: (...args: any[]) => ({type: 'MOCK_GET_PROFILES_IN_TEAM', args}),
getProfilesInChannel: (...args: any[]) => ({type: 'MOCK_GET_PROFILES_IN_CHANNEL', args, data: [{id: 'user_1'}]}),
getProfilesInGroupChannels: (...args: any[]) => ({type: 'MOCK_GET_PROFILES_IN_GROUP_CHANNELS', args}),
getStatusesByIds: (...args: any[]) => ({type: 'MOCK_GET_STATUSES_BY_ID', args}),
};
});
jest.mock('mattermost-redux/actions/teams', () => {
const original = jest.requireActual('mattermost-redux/actions/teams');
return {
...original,
getTeamMembersByIds: (...args: any[]) => ({type: 'MOCK_GET_TEAM_MEMBERS_BY_IDS', args}),
};
});
jest.mock('mattermost-redux/actions/channels', () => {
const original = jest.requireActual('mattermost-redux/actions/channels');
return {
...original,
getChannelMembersByIds: (...args: any[]) => ({type: 'MOCK_GET_CHANNEL_MEMBERS_BY_IDS', args}),
};
});
jest.mock('mattermost-redux/actions/preferences', () => {
const original = jest.requireActual('mattermost-redux/actions/preferences');
return {
...original,
deletePreferences: (...args: any[]) => ({type: 'MOCK_DELETE_PREFERENCES', args}),
savePreferences: (...args: any[]) => ({type: 'MOCK_SAVE_PREFERENCES', args}),
};
});
jest.mock('stores/redux_store', () => {
return {
dispatch: jest.fn(),
getState: jest.fn(),
};
});
describe('Actions.User', () => {
const initialState = {
entities: {
channels: {
currentChannelId: 'current_channel_id',
myMembers: {
current_channel_id: {
channel_id: 'current_channel_id',
user_id: 'current_user_id',
roles: 'channel_role',
mention_count: 1,
msg_count: 9,
} as ChannelMembership,
},
channels: {
current_channel_id: {
team_id: 'team_1',
} as Channel,
},
channelsInTeam: {
team_1: new Set(['current_channel_id']),
},
messageCounts: {
current_channel_id: {total: 10} as ChannelMessageCount,
},
membersInChannel: {
current_channel_id: {
current_user_id: {channel_id: 'current_user_id'} as ChannelMembership,
},
},
},
general: {
config: {
EnableUserStatuses: 'true',
},
},
preferences: {
myPreferences: {
'theme--team_1': {
category: 'theme',
name: 'team_1',
user_id: 'current_user_id',
value: JSON.stringify(Preferences.THEMES.indigo),
},
},
},
teams: {
currentTeamId: 'team_1',
teams: {
team_1: {
id: 'team_1',
name: 'team-1',
display_name: 'Team 1',
} as Team,
team_2: {
id: 'team_2',
name: 'team-2',
display_name: 'Team 2',
} as Team,
},
myMembers: {
team_1: {roles: 'team_role'} as TeamMembership,
team_2: {roles: 'team_role'} as TeamMembership,
},
membersInTeam: {
team_1: {
current_user_id: {id: 'current_user_id'} as unknown as TeamMembership,
},
team_2: {
current_user_id: {id: 'current_user_id'} as unknown as TeamMembership,
},
},
},
users: {
currentUserId: 'current_user_id',
profilesInChannel: {
group_channel_2: new Set(['user_1', 'user_2']),
},
},
posts: {
posts: {
sample_post_id: {
id: 'sample_post_id',
} as Post,
},
postsInChannel: {
current_channel_id: [
{
order: ['sample_post_id'],
},
]},
},
},
storage: {
storage: {},
initialized: true,
},
views: {
channel: {
},
channelSidebar: {
unreadFilterEnabled: false,
},
},
};
test('loadProfilesAndTeamMembers', async () => {
const expectedActions = [{type: 'MOCK_GET_PROFILES_IN_TEAM', args: ['team_1', 0, 60, '', {}]}];
let testStore = mockStore({});
await testStore.dispatch(UserActions.loadProfilesAndTeamMembers(0, 60, 'team_1', {}));
let actualActions = testStore.getActions();
expect(actualActions[0].args).toEqual(expectedActions[0].args);
expect(actualActions[0].type).toEqual(expectedActions[0].type);
testStore = mockStore(initialState);
await testStore.dispatch(UserActions.loadProfilesAndTeamMembers(0, 60, '', {}));
actualActions = testStore.getActions();
expect(actualActions[0].args).toEqual(expectedActions[0].args);
expect(actualActions[0].type).toEqual(expectedActions[0].type);
});
test('loadProfilesAndReloadChannelMembers', async () => {
const expectedActions = [{type: 'MOCK_GET_PROFILES_IN_CHANNEL', args: ['current_channel_id', 0, 60, 'sort', {}]}];
const testStore = mockStore(initialState);
await testStore.dispatch(UserActions.loadProfilesAndReloadChannelMembers(0, 60, 'current_channel_id', 'sort', {}));
const actualActions = testStore.getActions();
expect(actualActions[0].args).toEqual(expectedActions[0].args);
expect(actualActions[0].type).toEqual(expectedActions[0].type);
});
describe('loadProfilesAndReloadChannelMembers reconcile', () => {
// The mocked getProfilesInChannel resolves with a single member (user_1), so any
// other member present in the store for the channel is treated as stale (e.g. a
// member removed by an ABAC access-rule change while the user was not viewing the
// channel, whose user_removed websocket event was never applied).
// user_1 is the only member the mocked getProfilesInChannel returns, so it is the
// current membership. user_2 is stale in both stores; user_3 is stale only in the
// member store; user_4 is stale only in the profile store. This covers the union
// pruning across both entities.channels.membersInChannel and
// entities.users.profilesInChannel.
const buildState = (members: Record<string, unknown>, profileIds: string[]) => ({
...initialState,
entities: {
...initialState.entities,
channels: {
...initialState.entities.channels,
membersInChannel: {reconcile_channel: members},
},
users: {
...initialState.entities.users,
profilesInChannel: {reconcile_channel: new Set(profileIds)},
},
},
}) as unknown as GlobalState;
const reconcileState = buildState(
{
user_1: {channel_id: 'reconcile_channel', user_id: 'user_1'} as ChannelMembership,
user_2: {channel_id: 'reconcile_channel', user_id: 'user_2'} as ChannelMembership,
user_3: {channel_id: 'reconcile_channel', user_id: 'user_3'} as ChannelMembership,
},
['user_1', 'user_2', 'user_4'],
);
const prunedUserIds = (actions: AnyAction[]): string[] => {
const ids: string[] = [];
for (const action of actions) {
const candidates = Array.isArray(action.payload) ? action.payload : [action];
for (const candidate of candidates) {
if (candidate.type === 'RECEIVED_PROFILE_NOT_IN_CHANNEL' && candidate.data?.id === 'reconcile_channel') {
ids.push(candidate.data.user_id);
}
}
}
return ids;
};
test('prunes members the server no longer returns from both member and profile stores', async () => {
const testStore = mockStore(reconcileState);
await testStore.dispatch(UserActions.loadProfilesAndReloadChannelMembers(0, 60, 'reconcile_channel', 'sort', {}, true));
const pruned = prunedUserIds(testStore.getActions());
expect([...pruned].sort()).toEqual(['user_2', 'user_3', 'user_4']);
expect(pruned).not.toContain('user_1');
});
test('does not prune members the server still returns', async () => {
// The server response (user_1) matches the only stored member, so nothing is
// stale and a present member must never be pruned.
const testStore = mockStore(buildState(
{user_1: {channel_id: 'reconcile_channel', user_id: 'user_1'} as ChannelMembership},
['user_1'],
));
await testStore.dispatch(UserActions.loadProfilesAndReloadChannelMembers(0, 60, 'reconcile_channel', 'sort', {}, true));
expect(prunedUserIds(testStore.getActions())).toHaveLength(0);
});
test('does not prune when reconcile is disabled', async () => {
const testStore = mockStore(reconcileState);
await testStore.dispatch(UserActions.loadProfilesAndReloadChannelMembers(0, 60, 'reconcile_channel', 'sort', {}, false));
expect(prunedUserIds(testStore.getActions())).toHaveLength(0);
});
test('does not prune on a full page since more pages may follow', async () => {
const testStore = mockStore(reconcileState);
// perPage equals the number of returned profiles (1), so the page is not known
// to hold the full membership and members beyond it must not be pruned.
await testStore.dispatch(UserActions.loadProfilesAndReloadChannelMembers(0, 1, 'reconcile_channel', 'sort', {}, true));
expect(prunedUserIds(testStore.getActions())).toHaveLength(0);
});
test('does not prune on pages other than the first', async () => {
const testStore = mockStore(reconcileState);
await testStore.dispatch(UserActions.loadProfilesAndReloadChannelMembers(1, 60, 'reconcile_channel', 'sort', {}, true));
expect(prunedUserIds(testStore.getActions())).toHaveLength(0);
});
test('does not prune when perPage is not provided', async () => {
// Without a page size the response cannot be known to hold the full membership.
const testStore = mockStore(reconcileState);
await testStore.dispatch(UserActions.loadProfilesAndReloadChannelMembers(0, undefined, 'reconcile_channel', 'sort', {}, true));
expect(prunedUserIds(testStore.getActions())).toHaveLength(0);
});
});
test('loadProfilesAndTeamMembersAndChannelMembers', async () => {
const expectedActions = [{type: 'MOCK_GET_PROFILES_IN_CHANNEL', args: ['current_channel_id', 0, 60, '', undefined]}];
let testStore = mockStore(initialState);
await testStore.dispatch(UserActions.loadProfilesAndTeamMembersAndChannelMembers(0, 60, 'team_1', 'current_channel_id'));
let actualActions = testStore.getActions();
expect(actualActions[0].args).toEqual(expectedActions[0].args);
expect(actualActions[0].type).toEqual(expectedActions[0].type);
testStore = mockStore(initialState);
await testStore.dispatch(UserActions.loadProfilesAndTeamMembersAndChannelMembers(0, 60, '', ''));
actualActions = testStore.getActions();
expect(actualActions[0].args).toEqual(expectedActions[0].args);
expect(actualActions[0].type).toEqual(expectedActions[0].type);
});
test('loadTeamMembersForProfilesList', async () => {
const expectedActions = [{args: ['team_1', ['other_user_id']], type: 'MOCK_GET_TEAM_MEMBERS_BY_IDS'}];
// should call getTeamMembersByIds since 'other_user_id' is not loaded yet
let testStore = mockStore(initialState);
await testStore.dispatch(UserActions.loadTeamMembersForProfilesList(
[{id: 'other_user_id'} as UserProfile],
'team_1',
));
expect(testStore.getActions()).toEqual(expectedActions);
// should not call getTeamMembersByIds since 'current_user_id' is already loaded
testStore = mockStore(initialState);
await testStore.dispatch(UserActions.loadTeamMembersForProfilesList(
[{id: 'current_user_id'} as UserProfile],
'team_1',
));
expect(testStore.getActions()).toEqual([]);
// should call getTeamMembersByIds when reloadAllMembers = true even though 'current_user_id' is already loaded
testStore = mockStore(initialState);
await testStore.dispatch(UserActions.loadTeamMembersForProfilesList(
[{id: 'current_user_id'} as UserProfile],
'team_1',
true,
));
expect(testStore.getActions()).toEqual([{args: ['team_1', ['current_user_id']], type: 'MOCK_GET_TEAM_MEMBERS_BY_IDS'}]);
// should not call getTeamMembersByIds since no or empty profile is passed
testStore = mockStore(initialState);
await testStore.dispatch(UserActions.loadTeamMembersForProfilesList([], 'team_1'));
expect(testStore.getActions()).toEqual([]);
});
test('loadTeamMembersAndChannelMembersForProfilesList', async () => {
const expectedActions = [
{args: ['team_1', ['other_user_id']], type: 'MOCK_GET_TEAM_MEMBERS_BY_IDS'},
{args: ['current_channel_id', ['other_user_id']], type: 'MOCK_GET_CHANNEL_MEMBERS_BY_IDS'},
];
// should call getTeamMembersByIds and getChannelMembersByIds since 'other_user_id' is not loaded yet
let testStore = mockStore(initialState);
await testStore.dispatch(UserActions.loadTeamMembersAndChannelMembersForProfilesList(
[{id: 'other_user_id'} as UserProfile],
'team_1',
'current_channel_id',
));
expect(testStore.getActions()).toEqual(expectedActions);
// should not call getTeamMembersByIds/getChannelMembersByIds since 'current_user_id' is already loaded
testStore = mockStore(initialState);
await testStore.dispatch(UserActions.loadTeamMembersForProfilesList(
[{id: 'current_user_id'} as UserProfile],
'team_1',
));
expect(testStore.getActions()).toEqual([]);
// should not call getTeamMembersByIds/getChannelMembersByIds since no or empty profile is passed
testStore = mockStore(initialState);
await testStore.dispatch(UserActions.loadTeamMembersForProfilesList([], 'team_1'));
expect(testStore.getActions()).toEqual([]);
});
test('loadChannelMembersForProfilesList', async () => {
const expectedActions = [{args: ['current_channel_id', ['other_user_id']], type: 'MOCK_GET_CHANNEL_MEMBERS_BY_IDS'}];
// should call getChannelMembersByIds since 'other_user_id' is not loaded yet
let testStore = mockStore(initialState);
await testStore.dispatch(UserActions.loadChannelMembersForProfilesList(
[{id: 'other_user_id'} as UserProfile],
'current_channel_id',
));
expect(testStore.getActions()).toEqual(expectedActions);
// should not call getChannelMembersByIds since 'current_user_id' is already loaded
testStore = mockStore(initialState);
await testStore.dispatch(UserActions.loadChannelMembersForProfilesList(
[{id: 'current_user_id'} as UserProfile],
'current_channel_id',
));
expect(testStore.getActions()).toEqual([]);
// should not call getChannelMembersByIds since no or empty profile is passed
testStore = mockStore(initialState);
await testStore.dispatch(UserActions.loadChannelMembersForProfilesList([], 'current_channel_id'));
expect(testStore.getActions()).toEqual([]);
});
test('loadProfilesForGroupChannels', async () => {
const mockedGroupChannels = [{id: 'group_channel_1'}, {id: 'group_channel_2'}] as Channel[];
// as users in group_channel_2 are already loaded, it should only try to load group_channel_1
const expectedActions = [{args: [['group_channel_1']], type: 'MOCK_GET_PROFILES_IN_GROUP_CHANNELS'}];
const testStore = mockStore(initialState);
await testStore.dispatch(UserActions.loadProfilesForGroupChannels(mockedGroupChannels));
expect(testStore.getActions()).toEqual(expectedActions);
});
test('searchProfilesAndChannelMembers', async () => {
const expectedActions = [{type: 'MOCK_SEARCH_PROFILES', args: ['term', {}]}];
const testStore = mockStore(initialState);
await testStore.dispatch(UserActions.searchProfilesAndChannelMembers('term'));
const actualActions = testStore.getActions();
expect(actualActions[0].args).toEqual(expectedActions[0].args);
expect(actualActions[0].type).toEqual(expectedActions[0].type);
});
describe('getGMsForLoading', () => {
const gmChannel1 = {id: 'gmChannel1', type: General.GM_CHANNEL, delete_at: 0};
const gmChannel2 = {id: 'gmChannel2', type: General.GM_CHANNEL, delete_at: 0};
const dmsCategory = {id: 'dmsCategory', type: CategoryTypes.DIRECT_MESSAGES, channel_ids: [gmChannel1.id, gmChannel2.id]};
const baseState = {
...initialState,
entities: {
...initialState.entities,
channelCategories: {
byId: {
dmsCategory,
},
orderByTeam: {
[initialState.entities.teams.currentTeamId]: [dmsCategory.id],
},
},
channels: {
...initialState.entities.channels,
channels: {
...initialState.entities.channels,
gmChannel1,
gmChannel2,
},
myMembers: {
[gmChannel1.id]: {last_viewed_at: 1000},
[gmChannel2.id]: {last_viewed_at: 2000},
},
},
preferences: {
...initialState.entities.preferences,
myPreferences: {
...initialState.entities.preferences.myPreferences,
[getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.LIMIT_VISIBLE_DMS_GMS)]: {value: '10'},
[getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, gmChannel1.id)]: {value: 'true'},
[getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, gmChannel2.id)]: {value: 'true'},
},
},
},
};
test('should not return autoclosed GMs', () => {
let state = baseState;
expect(UserActions.getGMsForLoading(state as unknown as GlobalState)).toEqual([gmChannel1, gmChannel2]);
state = {
...state,
entities: {
...state.entities,
preferences: {
...state.entities.preferences,
myPreferences: {
...state.entities.preferences.myPreferences,
[getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.LIMIT_VISIBLE_DMS_GMS)]: {value: '1'},
},
},
},
};
expect(UserActions.getGMsForLoading(state as unknown as GlobalState)).toEqual([gmChannel2]);
state = {
...state,
entities: {
...state.entities,
channels: {
...state.entities.channels,
myMembers: {
...state.entities.channels.myMembers,
[gmChannel1.id]: {last_viewed_at: 3000},
},
},
},
};
expect(UserActions.getGMsForLoading(state as unknown as GlobalState)).toEqual([gmChannel1]);
});
test('should not return manually closed GMs', () => {
let state = baseState;
expect(UserActions.getGMsForLoading(state as unknown as GlobalState)).toEqual([gmChannel1, gmChannel2]);
state = {
...state,
entities: {
...state.entities,
preferences: {
...state.entities.preferences,
myPreferences: {
...state.entities.preferences.myPreferences,
[getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, gmChannel1.id)]: {value: 'false'},
},
},
},
};
expect(UserActions.getGMsForLoading(state as unknown as GlobalState)).toEqual([gmChannel2]);
state = {
...state,
entities: {
...state.entities,
preferences: {
...state.entities.preferences,
myPreferences: {
...state.entities.preferences.myPreferences,
[getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, gmChannel2.id)]: {value: 'false'},
},
},
},
};
expect(UserActions.getGMsForLoading(state as unknown as GlobalState)).toEqual([]);
});
test('should return GMs that are in custom categories, even if they would be automatically hidden in the DMs category', () => {
const gmChannel3 = {id: 'gmChannel3', type: General.GM_CHANNEL, delete_at: 0};
const customCategory = {id: 'customCategory', type: CategoryTypes.CUSTOM, channel_ids: [gmChannel3.id]};
let state = {
...baseState,
entities: {
...baseState.entities,
channelCategories: {
...baseState.entities.channelCategories,
byId: {
...baseState.entities.channelCategories.byId,
customCategory,
},
orderByTeam: {
...baseState.entities.channelCategories.orderByTeam,
[baseState.entities.teams.currentTeamId]: [customCategory.id, dmsCategory.id],
},
},
channels: {
...baseState.entities.channels,
channels: {
...baseState.entities.channels.channels,
gmChannel3,
},
myMembers: {
...baseState.entities.channels.myMembers,
[gmChannel3.id]: {last_viewed_at: 500},
},
},
preferences: {
...baseState.entities.preferences,
myPreferences: {
...baseState.entities.preferences.myPreferences,
[getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, gmChannel3.id)]: {value: 'true'},
},
},
},
};
expect(UserActions.getGMsForLoading(state as unknown as GlobalState)).toEqual([gmChannel3, gmChannel1, gmChannel2]);
state = {
...state,
entities: {
...state.entities,
preferences: {
...state.entities.preferences,
myPreferences: {
...state.entities.preferences.myPreferences,
[getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.LIMIT_VISIBLE_DMS_GMS)]: {value: '1'},
},
},
},
};
expect(UserActions.getGMsForLoading(state as unknown as GlobalState)).toEqual([gmChannel3, gmChannel2]);
state = {
...state,
entities: {
...state.entities,
preferences: {
...state.entities.preferences,
myPreferences: {
...state.entities.preferences.myPreferences,
[getPreferenceKey(Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.LIMIT_VISIBLE_DMS_GMS)]: {value: '0'},
},
},
},
};
expect(UserActions.getGMsForLoading(state as unknown as GlobalState)).toEqual([gmChannel3]);
});
});
test('Should call getProfilesInGroupChannels on loadProfilesForGM', async () => {
const gmChannel = {id: 'gmChannel', type: General.GM_CHANNEL, team_id: '', delete_at: 0};
const user = TestHelper.fakeUser();
const profiles = {
current_user_id: {
...user,
id: 'current_user_id',
},
};
const channels = {
[gmChannel.id]: gmChannel,
};
const channelsInTeam = {
'': new Set([gmChannel.id]),
};
const myMembers = {
[gmChannel.id]: {},
};
const dmsCategory = {id: 'dmsCategory', type: CategoryTypes.DIRECT_MESSAGES, channel_ids: [gmChannel.id]};
const state = {
entities: {
users: {
currentUserId: 'current_user_id',
profiles,
statuses: {},
profilesInChannel: {
[gmChannel.id]: new Set([]),
},
},
teams: {
currentTeamId: 'team_1',
},
posts: {
posts: {
post_id: {id: 'post_id'},
},
postsInChannel: {},
},
channelCategories: {
byId: {
dmsCategory,
},
orderByTeam: {
team_1: [dmsCategory.id],
},
},
channels: {
channels,
channelsInTeam,
messageCounts: {},
myMembers,
},
preferences: {
myPreferences: {
[getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, gmChannel.id)]: {value: 'true'},
},
},
general: {
config: {},
},
},
storage: {
storage: {},
},
views: {
channel: {
lastViewedChannel: null,
},
channelSidebar: {
unreadFilterEnabled: false,
},
},
} as unknown as GlobalState;
const testStore = mockStore(state);
(store.getState as jest.MockedFunction<() => GlobalState>).mockImplementation(testStore.getState);
(store.dispatch as jest.MockedFunction<Dispatch<AnyAction>>).mockImplementation(testStore.dispatch);
const actions = testStore.getActions();
await UserActions.loadProfilesForGM();
expect(actions).toEqual([{args: [['gmChannel']], type: 'MOCK_GET_PROFILES_IN_GROUP_CHANNELS'}]);
});
});

View File

@@ -0,0 +1,501 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {batchActions} from 'redux-batched-actions';
import type {UserAutocomplete} from '@mattermost/types/autocomplete';
import type {Channel} from '@mattermost/types/channels';
import type {UserProfile, UserStatus} from '@mattermost/types/users';
import {UserTypes} from 'mattermost-redux/action_types';
import {getChannelAndMyMember, getChannelMembersByIds} from 'mattermost-redux/actions/channels';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {getTeamMembersByIds} from 'mattermost-redux/actions/teams';
import * as UserActions from 'mattermost-redux/actions/users';
import {Preferences as PreferencesRedux, General} from 'mattermost-redux/constants';
import {
getChannel,
getChannelMembersInChannels,
getChannelMessageCount,
getCurrentChannelId,
getMyChannelMember,
getMyChannels,
} from 'mattermost-redux/selectors/entities/channels';
import {getIsUserStatusesConfigEnabled} from 'mattermost-redux/selectors/entities/common';
import {getBool, isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTeamId, getTeamMember} from 'mattermost-redux/selectors/entities/teams';
import * as Selectors from 'mattermost-redux/selectors/entities/users';
import type {ActionResult} from 'mattermost-redux/types/actions';
import {calculateUnreadCount} from 'mattermost-redux/utils/channel_utils';
import {loadCustomEmojisForCustomStatusesByUserIds} from 'actions/emoji_actions';
import {loadStatusesForProfilesList} from 'actions/status_actions';
import {getDisplayedChannels} from 'selectors/views/channel_sidebar';
import store from 'stores/redux_store';
import {Constants, Preferences, UserStatuses} from 'utils/constants';
import * as Utils from 'utils/utils';
import type {ActionFunc, ActionFuncAsync, ThunkActionFunc, GlobalState} from 'types/store';
const dispatch = store.dispatch;
const getState = store.getState;
export function loadProfilesAndReloadTeamMembers(page: number, perPage: number, teamId: string, options = {}): ActionFuncAsync {
return async (doDispatch, doGetState) => {
const newTeamId = teamId || getCurrentTeamId(doGetState());
const {data} = await doDispatch(UserActions.getProfilesInTeam(newTeamId, page, perPage, '', options));
if (data) {
await Promise.all([
doDispatch(loadTeamMembersForProfilesList(data, newTeamId, true)),
doDispatch(loadStatusesForProfilesList(data)),
]);
}
return {data: true};
};
}
export function loadProfilesAndReloadChannelMembers(page: number, perPage?: number, channelId?: string, sort = '', options = {}, reconcile = false): ActionFuncAsync {
return async (doDispatch, doGetState) => {
const newChannelId = channelId || getCurrentChannelId(doGetState());
const {data} = await doDispatch(UserActions.getProfilesInChannel(newChannelId, page, perPage, sort, options));
if (data) {
// getProfilesInChannel only adds to the channel member stores, so a removal
// missed over the websocket (e.g. an ABAC access-rule change handled while
// another channel was focused) leaves the member list stale. Prune members
// the server no longer returns, but only when this first page holds the full
// membership; a full page means more pages follow and must not be pruned.
if (reconcile && page === 0 && perPage !== undefined && data.length < perPage) {
doDispatch(pruneStaleChannelMembers(newChannelId, data));
}
await Promise.all([
doDispatch(loadChannelMembersForProfilesList(data, newChannelId, true)),
doDispatch(loadStatusesForProfilesList(data)),
]);
}
return {data: true};
};
}
function pruneStaleChannelMembers(channelId: string, currentProfiles: UserProfile[]): ActionFunc {
return (doDispatch, doGetState) => {
const state = doGetState();
const currentIds = new Set(currentProfiles.map((profile) => profile.id));
const staleIds = new Set<string>();
const storedMembers = getChannelMembersInChannels(state)[channelId];
if (storedMembers) {
Object.keys(storedMembers).forEach((userId) => {
if (!currentIds.has(userId)) {
staleIds.add(userId);
}
});
}
const storedProfileIds = Selectors.getUserIdsInChannels(state)[channelId];
if (storedProfileIds) {
storedProfileIds.forEach((userId) => {
if (!currentIds.has(userId)) {
staleIds.add(userId);
}
});
}
if (staleIds.size === 0) {
return {data: false};
}
doDispatch(batchActions(Array.from(staleIds).map((userId) => ({
type: UserTypes.RECEIVED_PROFILE_NOT_IN_CHANNEL,
data: {id: channelId, user_id: userId},
}))));
return {data: true};
};
}
export function loadProfilesAndTeamMembers(page: number, perPage: number, teamId: string, options?: Record<string, any>): ActionFuncAsync {
return async (doDispatch, doGetState) => {
const newTeamId = teamId || getCurrentTeamId(doGetState());
const {data} = await doDispatch(UserActions.getProfilesInTeam(newTeamId, page, perPage, '', options));
if (data) {
doDispatch(loadTeamMembersForProfilesList(data, newTeamId));
doDispatch(loadStatusesForProfilesList(data));
}
return {data: true};
};
}
export function searchProfilesAndTeamMembers(term = '', options: Record<string, any> = {}): ActionFuncAsync {
return async (doDispatch, doGetState) => {
const newTeamId = options.team_id || getCurrentTeamId(doGetState());
const {data} = await doDispatch(UserActions.searchProfiles(term, options));
if (data) {
await Promise.all([
doDispatch(loadTeamMembersForProfilesList(data, newTeamId)),
doDispatch(loadStatusesForProfilesList(data)),
]);
}
return {data: true};
};
}
export function searchProfilesAndChannelMembers(term: string, options: Record<string, any> = {}): ActionFuncAsync {
return async (doDispatch, doGetState) => {
const newChannelId = options.in_channel_id || getCurrentChannelId(doGetState());
const {data} = await doDispatch(UserActions.searchProfiles(term, options));
if (data) {
await Promise.all([
doDispatch(loadChannelMembersForProfilesList(data, newChannelId)),
doDispatch(loadStatusesForProfilesList(data)),
]);
}
return {data: true};
};
}
export function loadProfilesAndTeamMembersAndChannelMembers(page: number, perPage: number, teamId: string, channelId: string, options?: {active?: boolean}): ActionFuncAsync {
return async (doDispatch, doGetState) => {
const state = doGetState();
const teamIdParam = teamId || getCurrentTeamId(state);
const channelIdParam = channelId || getCurrentChannelId(state);
const {data} = await doDispatch(UserActions.getProfilesInChannel(channelIdParam, page, perPage, '', options));
if (data) {
const {data: listData} = await doDispatch(loadTeamMembersForProfilesList(data, teamIdParam));
if (listData) {
doDispatch(loadChannelMembersForProfilesList(data, channelIdParam));
doDispatch(loadStatusesForProfilesList(data));
}
}
return {data: true};
};
}
export function loadTeamMembersForProfilesList(profiles: UserProfile[], teamId: string, reloadAllMembers = false): ActionFuncAsync {
return async (doDispatch, doGetState) => {
const state = doGetState();
const teamIdParam = teamId || getCurrentTeamId(state);
const membersToLoad: Record<string, true> = {};
for (let i = 0; i < profiles.length; i++) {
const pid = profiles[i].id;
if (reloadAllMembers === true || !getTeamMember(state, teamIdParam, pid)) {
membersToLoad[pid] = true;
}
}
const userIdsToLoad = Object.keys(membersToLoad);
if (userIdsToLoad.length === 0) {
return {data: true};
}
await doDispatch(getTeamMembersByIds(teamIdParam, userIdsToLoad));
return {data: true};
};
}
export function loadTeamMembersAndChannelMembersForProfilesList(profiles: UserProfile[], teamId: string, channelId: string): ActionFuncAsync {
return async (doDispatch, doGetState) => {
const state = doGetState();
const teamIdParam = teamId || getCurrentTeamId(state);
const channelIdParam = channelId || getCurrentChannelId(state);
const {data} = await doDispatch(loadTeamMembersForProfilesList(profiles, teamIdParam));
if (data) {
doDispatch(loadChannelMembersForProfilesList(profiles, channelIdParam));
}
return {data: true};
};
}
export function loadChannelMembersForProfilesList(profiles: UserProfile[], channelId: string, reloadAllMembers = false): ActionFuncAsync {
return async (doDispatch, doGetState) => {
const state = doGetState();
const channelIdParam = channelId || getCurrentChannelId(state);
const membersToLoad: Record<string, boolean> = {};
for (let i = 0; i < profiles.length; i++) {
const pid = profiles[i].id;
const members = getChannelMembersInChannels(state)[channelIdParam];
if (reloadAllMembers === true || !members || !members[pid]) {
membersToLoad[pid] = true;
}
}
const list = Object.keys(membersToLoad);
if (list.length === 0) {
return {data: true};
}
await doDispatch(getChannelMembersByIds(channelIdParam, list));
return {data: true};
};
}
export function loadNewDMIfNeeded(channelId: string): ActionFuncAsync {
return async (doDispatch, doGetState) => {
const state = doGetState();
const currentUserId = Selectors.getCurrentUserId(state);
function checkPreference(channel: Channel) {
const userId = Utils.getUserIdFromChannelName(channel);
if (!userId) {
return {data: false};
}
const pref = getBool(state, Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, userId, false);
if (pref === false) {
const now = Utils.getTimestamp();
doDispatch(savePreferences(currentUserId, [
{user_id: currentUserId, category: Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, name: userId, value: 'true'},
{user_id: currentUserId, category: Preferences.CATEGORY_CHANNEL_OPEN_TIME, name: channelId, value: now.toString()},
]));
loadProfilesForDM();
return {data: true};
}
return {data: false};
}
let result = {data: false} as ActionResult;
const channel = getChannel(doGetState(), channelId);
if (channel) {
result = checkPreference(channel);
} else {
result = await doDispatch(getChannelAndMyMember(channelId));
if (result.data) {
result = checkPreference(result.data.channel);
}
}
return result;
};
}
export function loadNewGMIfNeeded(channelId: string): ActionFuncAsync {
return async (doDispatch, doGetState) => {
const state = doGetState();
const currentUserId = Selectors.getCurrentUserId(state);
function checkPreference() {
const pref = getBool(state, Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channelId, false);
if (pref === false) {
dispatch(savePreferences(currentUserId, [{user_id: currentUserId, category: Preferences.CATEGORY_GROUP_CHANNEL_SHOW, name: channelId, value: 'true'}]));
loadProfilesForGM();
return {data: true};
}
return {data: false};
}
const channel = getChannel(state, channelId);
if (!channel) {
await doDispatch(getChannelAndMyMember(channelId));
}
return checkPreference();
};
}
export function loadProfilesForGroupChannels(groupChannels: Channel[]): ActionFunc {
return (doDispatch, doGetState) => {
const state = doGetState();
const userIdsInChannels = Selectors.getUserIdsInChannels(state);
const groupChannelsToFetch = groupChannels.reduce((acc, {id}) => {
const userIdsInGroupChannel = (userIdsInChannels[id] || new Set());
if (userIdsInGroupChannel.size === 0) {
acc.push(id);
}
return acc;
}, ([] as string[]));
if (groupChannelsToFetch.length > 0) {
doDispatch(UserActions.getProfilesInGroupChannels(groupChannelsToFetch));
return {data: true};
}
return {data: false};
};
}
export async function loadProfilesForSidebar() {
await Promise.all([loadProfilesForDM(), loadProfilesForGM()]);
}
export const getGMsForLoading = (state: GlobalState) => {
// Get all channels visible on the current team which doesn't include hidden GMs/DMs
let channels = getDisplayedChannels(state);
// Make sure we only have GMs
channels = channels.filter((channel) => channel.type === General.GM_CHANNEL);
return channels;
};
export async function loadProfilesForGM() {
const state = getState();
const newPreferences = [];
const userIdsInChannels = Selectors.getUserIdsInChannels(state);
const currentUserId = Selectors.getCurrentUserId(state);
const collapsedThreads = isCollapsedThreadsEnabled(state);
const userIdsForLoadingCustomEmojis = new Set<string>();
const channelUsersToLoad: string[] = [];
for (const channel of getGMsForLoading(state)) {
const userIds = userIdsInChannels[channel.id] || new Set();
userIds.forEach((userId) => userIdsForLoadingCustomEmojis.add(userId));
if (userIds.size >= Constants.MIN_USERS_IN_GM) {
continue;
}
const isVisible = getBool(state, Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channel.id);
if (!isVisible) {
const messageCount = getChannelMessageCount(state, channel.id);
const member = getMyChannelMember(state, channel.id);
const unreadCount = calculateUnreadCount(messageCount, member, collapsedThreads);
if (!unreadCount.showUnread) {
continue;
}
newPreferences.push({
user_id: currentUserId,
category: Preferences.CATEGORY_GROUP_CHANNEL_SHOW,
name: channel.id,
value: 'true',
});
}
if (userIds.size === 0) {
channelUsersToLoad.push(channel.id);
}
}
if (channelUsersToLoad.length > 0) {
await dispatch(UserActions.getProfilesInGroupChannels(channelUsersToLoad));
}
if (userIdsForLoadingCustomEmojis.size > 0) {
dispatch(loadCustomEmojisForCustomStatusesByUserIds(userIdsForLoadingCustomEmojis));
}
if (newPreferences.length > 0) {
dispatch(savePreferences(currentUserId, newPreferences));
}
}
export async function loadProfilesForDM() {
const state = getState();
const channels = getMyChannels(state);
const newPreferences = [];
const profilesToLoad = [];
const profileIds = [];
const currentUserId = Selectors.getCurrentUserId(state);
const collapsedThreads = isCollapsedThreadsEnabled(state);
for (let i = 0; i < channels.length; i++) {
const channel = channels[i];
if (channel.type !== Constants.DM_CHANNEL) {
continue;
}
const teammateId = channel.name.replace(currentUserId, '').replace('__', '');
const isVisible = getBool(state, Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, teammateId);
if (!isVisible) {
const member = getMyChannelMember(state, channel.id);
const messageCount = getChannelMessageCount(state, channel.id);
const unreadCount = calculateUnreadCount(messageCount, member, collapsedThreads);
if (!member || !unreadCount.showUnread) {
continue;
}
newPreferences.push({
user_id: currentUserId,
category: Preferences.CATEGORY_DIRECT_CHANNEL_SHOW,
name: teammateId,
value: 'true',
});
}
if (!Selectors.getUser(state, teammateId)) {
profilesToLoad.push(teammateId);
}
profileIds.push(teammateId);
}
if (newPreferences.length > 0) {
dispatch(savePreferences(currentUserId, newPreferences));
}
if (profilesToLoad.length > 0) {
await dispatch(UserActions.getProfilesByIds(profilesToLoad));
}
await dispatch(loadCustomEmojisForCustomStatusesByUserIds(profileIds));
}
export function autocompleteUsersInCurrentTeam(username: string): ThunkActionFunc<Promise<UserAutocomplete>> {
return async (doDispatch, doGetState) => {
const currentTeamId = getCurrentTeamId(doGetState());
const {data} = await doDispatch(UserActions.autocompleteUsers(username, currentTeamId));
return data!;
};
}
export function autocompleteUsersInTeam(username: string, teamId: string): ThunkActionFunc<Promise<UserAutocomplete>> {
return async (doDispatch) => {
const {data} = await doDispatch(UserActions.autocompleteUsers(username, teamId));
return data!;
};
}
export function autocompleteUsers(username: string): ThunkActionFunc<Promise<UserAutocomplete>> {
return async (doDispatch) => {
const {data} = await doDispatch(UserActions.autocompleteUsers(username));
return data!;
};
}
export function canUserDirectMessage(userId: string, otherUserId: string): ActionFuncAsync<{can_dm: boolean}> {
return async (doDispatch) => {
const {data} = await doDispatch(UserActions.canUserDirectMessage(userId, otherUserId));
return {data};
};
}
export function autoResetStatus(): ActionFuncAsync<UserStatus> {
return async (doDispatch) => {
const state = getState();
const enabledUserStatuses = getIsUserStatusesConfigEnabled(state);
if (!enabledUserStatuses) {
return {data: undefined};
}
const {currentUserId} = state.entities.users;
const {data} = await doDispatch(UserActions.getStatusesByIds([currentUserId]));
const userStatus = data?.[0];
if (userStatus?.status === UserStatuses.OUT_OF_OFFICE || !userStatus?.manual) {
return {data: userStatus};
}
const autoReset = getBool(getState(), PreferencesRedux.CATEGORY_AUTO_RESET_MANUAL_STATUS, currentUserId, false);
if (autoReset) {
doDispatch(UserActions.setStatus({user_id: currentUserId, status: 'online'}));
return {data: userStatus};
}
return {data: userStatus};
};
}

View File

@@ -0,0 +1,82 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ServerError} from '@mattermost/types/errors';
import type {UserReportOptions, UserReport, UserReportFilter} from '@mattermost/types/reports';
import {logError} from 'mattermost-redux/actions/errors';
import {forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers';
import {Client4} from 'mattermost-redux/client';
import {ActionTypes} from 'utils/constants';
import type {ActionFuncAsync} from 'types/store';
import type {AdminConsoleUserManagementTableProperties} from 'types/store/views';
export function setNeedsLoggedInLimitReachedCheck(data: boolean) {
return {
type: ActionTypes.NEEDS_LOGGED_IN_LIMIT_REACHED_CHECK,
data,
};
}
/**
* Action to set the properties of the admin console user management table. Only pass the properties you want to set/modify. If you pass no properties, the table properties will be cleared.
*/
export function setAdminConsoleUsersManagementTableProperties(data?: Partial<AdminConsoleUserManagementTableProperties>) {
if (!data) {
return {
type: ActionTypes.CLEAR_ADMIN_CONSOLE_USER_MANAGEMENT_TABLE_PROPERTIES,
data: null,
};
}
return {
type: ActionTypes.SET_ADMIN_CONSOLE_USER_MANAGEMENT_TABLE_PROPERTIES,
data,
};
}
export function getUserReports(options = {} as UserReportOptions): ActionFuncAsync<UserReport[]> {
return async (dispatch, getState) => {
let data;
try {
data = await Client4.getUsersForReporting(options);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {error: error as ServerError};
}
return {data};
};
}
export function getUserCountForReporting(filter = {} as UserReportFilter): ActionFuncAsync<number> {
return async (dispatch, getState) => {
let data;
try {
data = await Client4.getUserCountForReporting(filter);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {error: error as ServerError};
}
return {data};
};
}
export function startUsersBatchExport(tableFilters = {} as UserReportOptions): ActionFuncAsync {
return async (dispatch, getState) => {
try {
await Client4.startUsersBatchExport(tableFilters);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {error: error as ServerError};
}
return {data: true};
};
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ActionTypes} from 'utils/constants';
export function incrementAnnouncementBarCount() {
return {
type: ActionTypes.TRACK_ANNOUNCEMENT_BAR,
};
}
export function decrementAnnouncementBarCount() {
return {
type: ActionTypes.DISMISS_ANNOUNCEMENT_BAR,
};
}

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Constants, ActionTypes, WindowSizes} from 'utils/constants';
export function emitBrowserWindowResized(windowSize?: string) {
let newWindowSize = windowSize;
if (!windowSize) {
const width = window.innerWidth;
switch (true) {
case width > Constants.TABLET_SCREEN_WIDTH && width <= Constants.DESKTOP_SCREEN_WIDTH: {
newWindowSize = WindowSizes.SMALL_DESKTOP_VIEW;
break;
}
case width > Constants.MOBILE_SCREEN_WIDTH && width <= Constants.TABLET_SCREEN_WIDTH: {
newWindowSize = WindowSizes.TABLET_VIEW;
break;
}
case width <= Constants.MOBILE_SCREEN_WIDTH: {
newWindowSize = WindowSizes.MOBILE_VIEW;
break;
}
default: {
newWindowSize = WindowSizes.DESKTOP_VIEW; // width > Constants.DESKTOP_SCREEN_WIDTH
}
}
}
return {
type: ActionTypes.BROWSER_WINDOW_RESIZED,
data: newWindowSize,
};
}

View File

@@ -0,0 +1,849 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {UserAutocomplete} from '@mattermost/types/autocomplete';
import type {Channel} from '@mattermost/types/channels';
import type {PostList} from '@mattermost/types/posts';
import type {DeepPartial} from '@mattermost/types/utilities';
import {leaveChannel, markChannelAsRead, getChannel} from 'mattermost-redux/actions/channels';
import * as PostActions from 'mattermost-redux/actions/posts';
import * as UserActions from 'mattermost-redux/actions/users';
import {General, Posts, RequestStatus} from 'mattermost-redux/constants';
import * as Actions from 'actions/views/channel';
import {closeRightHandSide} from 'actions/views/rhs';
import {markThreadAsRead} from 'actions/views/threads';
import mockStore from 'tests/test_store';
import {getHistory} from 'utils/browser_history';
import {ActionTypes, PostRequestTypes} from 'utils/constants';
import type {GlobalState} from 'types/store';
jest.mock('utils/channel_utils.tsx', () => {
const original = jest.requireActual('utils/channel_utils.tsx');
return {
...original,
getRedirectChannelNameForTeam: () => 'town-square',
};
});
jest.mock('mattermost-redux/actions/users');
jest.mock('mattermost-redux/actions/channels', () => ({
...jest.requireActual('mattermost-redux/actions/channels'),
markChannelAsRead: jest.fn(() => ({type: ''})),
leaveChannel: jest.fn(() => ({type: ''})),
getChannel: jest.fn(() => ({
type: 'RECEIVED_CHANNEL',
data: {team_id: 'teamid1', id: 'non-existing-channelid', name: 'non-existing-channel', display_name: 'Channel 1', type: 'O'},
})),
}));
jest.mock('actions/views/rhs', () => ({
...jest.requireActual('actions/views/rhs'),
closeRightHandSide: jest.fn(() => ({type: ''})),
}));
jest.mock('actions/views/threads', () => ({
...jest.requireActual('actions/views/threads'),
markThreadAsRead: jest.fn(() => ({type: ''})),
}));
jest.mock('mattermost-redux/actions/posts');
jest.mock('selectors/local_storage', () => ({
getLastViewedChannelName: () => 'channel1',
}));
jest.mock('mattermost-redux/selectors/entities/utils', () => ({
makeAddLastViewAtToProfiles: () => jest.fn().mockReturnValue([]),
}));
function makePostList(postList: DeepPartial<PostList>): PostList {
return postList as PostList;
}
describe('channel view actions', () => {
const channel1 = {id: 'channelid1', name: 'channel1', display_name: 'Channel 1', type: 'O', team_id: 'teamid1'} as Channel;
const townsquare = {id: 'channelid2', name: General.DEFAULT_CHANNEL, display_name: 'Town Square', type: 'O', team_id: 'teamid1'} as Channel;
const gmChannel = {id: 'gmchannelid', name: 'gmchannel', display_name: 'GM Channel 1', type: 'G'} as Channel;
const team1 = {id: 'teamid1', name: 'team1'};
const initialState = {
entities: {
users: {
currentUserId: 'userid1',
profiles: {userid1: {id: 'userid1', username: 'username1', roles: 'system_user'}, userid2: {id: 'userid2', username: 'username2', roles: 'system_user'}},
profilesInChannel: {},
},
teams: {
currentTeamId: 'teamid1',
myMembers: {teamId1: {}},
teams: {teamid1: team1},
},
channels: {
currentChannelId: 'channelid1',
channels: {channelid1: channel1, channelid2: townsquare, gmchannelid: gmChannel},
manuallyUnread: {},
myMembers: {
gmchannelid: {channel_id: 'gmchannelid', user_id: 'userid1'},
channelid1: {channel_id: 'channelid1', user_id: 'userid1'},
townsquare: {channel_id: 'townsquare', user_id: 'userid1'},
},
channelsInTeam: {
[team1.id]: [channel1.id, townsquare.id],
},
},
general: {
config: {
EnableJoinLeaveMessageByDefault: 'true',
},
serverVersion: '5.12.0',
},
roles: {
roles: {
system_user: {permissions: ['join_public_channels']},
},
},
preferences: {
myPreferences: {},
},
posts: {
postsInChannel: {},
posts: {},
},
channelCategories: {
byId: {},
},
},
views: {
channel: {
loadingPosts: {},
postVisibility: {current_channel_id: 60},
},
rhs: {
selectedPostId: '',
},
threads: {},
},
} as unknown as GlobalState;
let store = mockStore(initialState);
beforeEach(() => {
store = mockStore(initialState);
});
describe('switchToChannel', () => {
test('switch to public channel', () => {
store.dispatch(Actions.switchToChannel(channel1));
expect(getHistory().push).toHaveBeenCalledWith(`/${team1.name}/channels/${channel1.name}`);
});
test('switch to gm channel', async () => {
await store.dispatch(Actions.switchToChannel(gmChannel));
expect(getHistory().push).toHaveBeenCalledWith(`/${team1.name}/channels/${gmChannel.name}`);
});
});
describe('loadIfNecessaryAndSwitchToChannelById', () => {
test('existing channel', () => {
store.dispatch(Actions.loadIfNecessaryAndSwitchToChannelById(channel1.id));
expect(getHistory().push).toHaveBeenCalledWith(`/${team1.name}/channels/${channel1.name}`);
});
test('non-existing channel', async () => {
await store.dispatch(Actions.loadIfNecessaryAndSwitchToChannelById('non-existing-channelid'));
expect(getChannel).toHaveBeenCalledWith('non-existing-channelid');
expect(getHistory().push).toHaveBeenCalledWith(`/${team1.name}/channels/non-existing-channel`);
});
});
describe('leaveChannel', () => {
test('leave a channel successfully', async () => {
await store.dispatch(Actions.leaveChannel('channelid1'));
expect(getHistory().push).toHaveBeenCalledWith(`/${team1.name}`);
expect(leaveChannel).toHaveBeenCalledWith('channelid1');
expect(closeRightHandSide).not.toHaveBeenCalled();
});
test('leave a channel successfully with a thread open', async () => {
store = mockStore({
...initialState,
views: {
...initialState.views,
rhs: {
selectedPostId: '1',
},
},
});
await store.dispatch(Actions.leaveChannel('channelid1'));
expect(getHistory().push).toHaveBeenCalledWith(`/${team1.name}`);
expect(leaveChannel).toHaveBeenCalledWith('channelid1');
expect(closeRightHandSide).toHaveBeenCalled();
});
test('leave the last channel successfully', async () => {
store = mockStore({
...initialState,
entities: {
...initialState.entities,
channels: {
...initialState.entities,
myMembers: {
channelid1: {channel_id: 'channelid1', user_id: 'userid1'},
},
},
},
} as unknown as GlobalState);
await store.dispatch(Actions.leaveChannel('channelid1'));
expect(getHistory().push).toHaveBeenCalledWith('/');
expect(leaveChannel).toHaveBeenCalledWith('channelid1');
});
});
describe('goToLastViewedChannel', () => {
test('should switch to town square if last viewed channel is current channel', async () => {
await store.dispatch(Actions.goToLastViewedChannel());
expect(getHistory().push).toHaveBeenCalledWith(`/${team1.name}/channels/${General.DEFAULT_CHANNEL}`);
});
});
describe('loadLatestPosts', () => {
test('should call getPosts and return the results', async () => {
const posts = makePostList({posts: {}, order: []});
jest.mocked(PostActions.getPosts).mockReturnValue(async () => ({data: posts}));
const result = await store.dispatch(Actions.loadLatestPosts('channel')) as {data: PostList; atLatestMessage: boolean; atOldestmessage: boolean};
expect(result.data).toBe(posts);
expect(PostActions.getPosts).toHaveBeenCalledWith('channel', 0, Posts.POST_CHUNK_SIZE / 2);
});
test('when oldest posts are recived', async () => {
const posts = makePostList({posts: {}, order: new Array(Posts.POST_CHUNK_SIZE), next_post_id: 'test', prev_post_id: ''});
jest.mocked(PostActions.getPosts).mockReturnValue(async () => ({data: posts}));
const result = await store.dispatch(Actions.loadLatestPosts('channel')) as {data: PostList; atLatestMessage: boolean; atOldestmessage: boolean};
expect(result.atLatestMessage).toBe(false);
expect(result.atOldestmessage).toBe(true);
});
test('when latest posts are received', async () => {
Date.now = jest.fn().mockReturnValue(12344);
const posts = makePostList({posts: {}, order: new Array((Posts.POST_CHUNK_SIZE / 2) - 1), next_post_id: '', prev_post_id: 'test'});
jest.mocked(PostActions.getPosts).mockReturnValue(async () => ({data: posts}));
const result = await store.dispatch(Actions.loadLatestPosts('channel')) as {data: PostList; atLatestMessage: boolean; atOldestmessage: boolean};
expect(result.atLatestMessage).toBe(true);
expect(result.atOldestmessage).toBe(false);
expect(store.getActions()).toEqual([
{
channelId: 'channel',
time: 12344,
type: ActionTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME,
},
]);
});
});
describe('loadUnreads', () => {
test('when there are no posts after and before the response', async () => {
const posts = makePostList({posts: {}, order: [], next_post_id: '', prev_post_id: ''});
jest.mocked(PostActions.getPostsUnread).mockReturnValue(async () => ({data: posts}));
const result = await store.dispatch(Actions.loadUnreads('channel'));
expect(result).toEqual({atLatestMessage: true, atOldestmessage: true});
expect(PostActions.getPostsUnread).toHaveBeenCalledWith('channel');
});
test('when there are posts before and after the response', async () => {
const posts = makePostList({
posts: {},
order: [
...new Array(Posts.POST_CHUNK_SIZE / 2), // after
'post',
...new Array(Posts.POST_CHUNK_SIZE / 2), // before
],
next_post_id: 'test',
prev_post_id: 'test',
});
jest.mocked(PostActions.getPostsUnread).mockReturnValue(async () => ({data: posts}));
const result = await store.dispatch(Actions.loadUnreads('channel'));
expect(result).toEqual({atLatestMessage: false, atOldestmessage: false});
expect(PostActions.getPostsUnread).toHaveBeenCalledWith('channel');
});
test('when there are no posts after RECEIVED_POSTS_FOR_CHANNEL_AT_TIME should be dispatched', async () => {
const posts = makePostList({posts: {}, order: [], next_post_id: '', prev_post_id: ''});
Date.now = jest.fn().mockReturnValue(12344);
jest.mocked(PostActions.getPostsUnread).mockReturnValue(async () => ({data: posts}));
await store.dispatch(Actions.loadUnreads('channel'));
expect(store.getActions()).toEqual([
{
meta: {batch: true},
payload: [
{amount: 0, data: 'channel', type: 'INCREASE_POST_VISIBILITY'},
{
channelId: 'channel',
time: 12344,
type: 'RECEIVED_POSTS_FOR_CHANNEL_AT_TIME',
}],
type: 'BATCHING_REDUCER.BATCH',
},
]);
});
test('should disptach PREFETCH_POSTS_FOR_CHANNEL status when called with prefetch argument and loadUnreads sucess', async () => {
const posts = makePostList({posts: {}, order: [], next_post_id: '', prev_post_id: ''});
jest.mocked(PostActions.getPostsUnread).mockReturnValue(async () => ({data: posts}));
await store.dispatch(Actions.loadUnreads('channel', true));
expect(store.getActions()).toEqual([{
channelId: 'channel',
status: RequestStatus.STARTED,
type: 'PREFETCH_POSTS_FOR_CHANNEL',
},
{
meta: {batch: true},
payload: [
{amount: 0, data: 'channel', type: 'INCREASE_POST_VISIBILITY'},
{
channelId: 'channel',
status: RequestStatus.SUCCESS,
type: 'PREFETCH_POSTS_FOR_CHANNEL',
},
{
channelId: 'channel',
time: 12344,
type: 'RECEIVED_POSTS_FOR_CHANNEL_AT_TIME',
}],
type: 'BATCHING_REDUCER.BATCH',
},
]);
});
test('should disptach PREFETCH_POSTS_FOR_CHANNEL status when called with prefetch argument and loadUnreads error', async () => {
jest.mocked(PostActions.getPostsUnread).mockReturnValue(async () => ({error: {}}));
await store.dispatch(Actions.loadUnreads('channel', true));
expect(store.getActions()).toEqual([{
channelId: 'channel',
status: RequestStatus.STARTED,
type: 'PREFETCH_POSTS_FOR_CHANNEL',
},
{
channelId: 'channel',
status: RequestStatus.FAILURE,
type: 'PREFETCH_POSTS_FOR_CHANNEL',
},
]);
});
});
describe('loadPostsAround', () => {
test('should call getPostsAround and return the results', async () => {
const posts = makePostList({posts: {}, order: [], next_post_id: '', prev_post_id: ''});
jest.mocked(PostActions.getPostsAround).mockReturnValue(async () => ({data: posts}));
const result = await store.dispatch(Actions.loadPostsAround('channel', 'post'));
expect(result).toEqual({atLatestMessage: true, atOldestmessage: true});
expect(PostActions.getPostsAround).toHaveBeenCalledWith('channel', 'post', Posts.POST_CHUNK_SIZE / 2);
});
test('when there are posts before and after reponse posts chunk', async () => {
const posts = makePostList({
posts: {},
order: [
...new Array(Posts.POST_CHUNK_SIZE / 2), // after
'post',
...new Array(Posts.POST_CHUNK_SIZE / 2), // before
],
next_post_id: 'test',
prev_post_id: 'test',
});
jest.mocked(PostActions.getPostsAround).mockReturnValue(async () => ({data: posts}));
const result = await store.dispatch(Actions.loadPostsAround('channel', 'post'));
expect(result).toEqual({atLatestMessage: false, atOldestmessage: false});
});
test('when there are posts before the reponse posts chunk', async () => {
const posts = makePostList({
posts: {},
order: [
...new Array(Posts.POST_CHUNK_SIZE / 2), // after
'post',
...new Array((Posts.POST_CHUNK_SIZE / 2) - 1), // before
],
next_post_id: '',
prev_post_id: 'test',
});
jest.mocked(PostActions.getPostsAround).mockReturnValue(async () => ({data: posts}));
const result = await store.dispatch(Actions.loadPostsAround('channel', 'post'));
expect(result).toEqual({atLatestMessage: true, atOldestmessage: false});
});
test('when there are posts before the reponse posts chunk', async () => {
const posts = makePostList({
posts: {},
order: [
...new Array((Posts.POST_CHUNK_SIZE / 2) - 1), // after
'post',
...new Array(Posts.POST_CHUNK_SIZE / 2), // before
],
next_post_id: 'test',
prev_post_id: '',
});
jest.mocked(PostActions.getPostsAround).mockReturnValue(async () => ({data: posts}));
const result = await store.dispatch(Actions.loadPostsAround('channel', 'post'));
expect(result).toEqual({atLatestMessage: false, atOldestmessage: true});
});
test('when there are no posts before and after the posts chunk', async () => {
const posts = makePostList({
posts: {},
order: [
...new Array((Posts.POST_CHUNK_SIZE / 2) - 1), // after
'post',
...new Array((Posts.POST_CHUNK_SIZE / 2) - 1), // before
],
next_post_id: '',
prev_post_id: '',
});
jest.mocked(PostActions.getPostsAround).mockReturnValue(async () => ({data: posts}));
const result = await store.dispatch(Actions.loadPostsAround('channel', 'post'));
expect(result).toEqual({atLatestMessage: true, atOldestmessage: true});
});
});
describe('increasePostVisibility', () => {
test('should dispatch the correct actions', async () => {
const posts = makePostList({
posts: {},
order: new Array(7),
prev_post_id: '',
next_post_id: '',
});
jest.mocked(PostActions.getPostsBefore).mockReturnValue(async () => ({data: posts}));
await store.dispatch(Actions.loadPosts({channelId: 'current_channel_id', postId: 'oldest_post_id', type: PostRequestTypes.BEFORE_ID} as Actions.LoadPostsParameters));
expect(store.getActions()).toEqual([
{channelId: 'current_channel_id', data: true, type: 'LOADING_POSTS'},
{
meta: {batch: true},
payload: [
{channelId: 'current_channel_id', data: false, type: 'LOADING_POSTS'},
{amount: 7, data: 'current_channel_id', type: 'INCREASE_POST_VISIBILITY'},
],
type: 'BATCHING_REDUCER.BATCH',
},
]);
});
test('should increase post visibility when receiving posts', async () => {
Date.now = jest.fn().mockReturnValue(12344);
const channelId = 'channel1';
const posts = makePostList({
posts: {},
order: new Array(7),
});
jest.mocked(PostActions.getPostsBefore).mockReturnValue(async () => ({data: posts}));
await store.dispatch(Actions.loadPosts({channelId, postId: 'oldest_post_id', type: PostRequestTypes.BEFORE_ID} as Actions.LoadPostsParameters));
expect(store.getActions()).toContainEqual({
meta: {batch: true},
payload: [
{
type: ActionTypes.LOADING_POSTS,
channelId,
data: false,
},
{
type: ActionTypes.INCREASE_POST_VISIBILITY,
amount: posts.order.length,
data: channelId,
},
],
type: 'BATCHING_REDUCER.BATCH',
});
});
test('should return more to load when enough posts are received', async () => {
const channelId = 'channel1';
const posts = makePostList({
posts: {},
order: new Array(Posts.POST_CHUNK_SIZE / 2),
prev_post_id: 'saasdsd',
});
jest.mocked(PostActions.getPostsBefore).mockReturnValue(async () => ({data: posts}));
const result = await store.dispatch(Actions.loadPosts({channelId, postId: 'oldest_post_id', type: PostRequestTypes.BEFORE_ID} as Actions.LoadPostsParameters));
expect(result).toEqual({
moreToLoad: true,
});
});
test('should not return more to load when not enough posts are received', async () => {
const channelId = 'channel1';
const posts = makePostList({
posts: {},
order: new Array((Posts.POST_CHUNK_SIZE / 2) - 1),
prev_post_id: '',
});
jest.mocked(PostActions.getPostsBefore).mockReturnValue(async () => ({data: posts}));
const result = await store.dispatch(Actions.loadPosts({channelId, postId: 'oldest_post_id', type: PostRequestTypes.BEFORE_ID} as Actions.LoadPostsParameters));
expect(result).toEqual({
moreToLoad: false,
});
});
test('should return error from getPostsBefore', async () => {
const channelId = 'channel1';
const error = {message: 'something went wrong'};
jest.mocked(PostActions.getPostsBefore).mockReturnValue(async () => ({error}));
const result = await store.dispatch(Actions.loadPosts({channelId, postId: 'oldest_post_id', type: PostRequestTypes.BEFORE_ID} as Actions.LoadPostsParameters));
expect(result).toEqual({
error,
moreToLoad: true,
});
});
});
describe('syncPostsInChannel', () => {
test('should call getPostsSince with since argument time as last discconet was earlier than lastGetPosts', async () => {
const channelId = 'channel1';
jest.mocked(PostActions.getPostsSince).mockReturnValue(async () => ({data: [] as unknown as PostList}));
store = mockStore({
...initialState,
views: {
...initialState.views,
channel: {
...initialState.views.channel,
lastGetPosts: {
[channelId]: 12345,
},
},
},
websocket: {
lastDisconnectAt: 12344,
},
});
await store.dispatch(Actions.syncPostsInChannel(channelId, 12350));
expect(PostActions.getPostsSince).toHaveBeenCalledWith(channelId, 12350);
});
test('should call getPostsSince with lastDisconnect time as last discconet was later than lastGetPosts', async () => {
const channelId = 'channel1';
jest.mocked(PostActions.getPostsSince).mockReturnValue(async () => ({data: [] as unknown as PostList}));
store = mockStore({
...initialState,
views: {
...initialState.views,
channel: {
...initialState.views.channel,
lastGetPosts: {
[channelId]: 12343,
},
},
},
websocket: {
lastDisconnectAt: 12344,
},
});
await store.dispatch(Actions.syncPostsInChannel(channelId, 12355));
expect(PostActions.getPostsSince).toHaveBeenCalledWith(channelId, 12343);
});
});
describe('markAsReadOnFocus', () => {
test('should mark channel as read when channel is not manually unread', async () => {
store = mockStore(initialState);
await store.dispatch(Actions.markAsReadOnFocus());
expect(markChannelAsRead).toHaveBeenCalledWith(channel1.id);
});
test('should not mark channel as read when channel is manually unread', async () => {
store = mockStore({
...initialState,
entities: {
...initialState.entities,
channels: {
...initialState.entities.channels,
manuallyUnread: {
[channel1.id]: true,
},
},
},
});
await store.dispatch(Actions.markAsReadOnFocus());
expect(markChannelAsRead).not.toHaveBeenCalled();
});
test('should dispatch markThreadAsRead when threads are selected', async () => {
store = mockStore({
...initialState,
views: {
...initialState.views,
rhs: {
...initialState.views.rhs,
selectedPostId: 'post_id',
},
threads: {
...initialState.views.threads,
selectedThreadIdInTeam: {
teamid1: 'thread_id',
},
},
},
});
await store.dispatch(Actions.markAsReadOnFocus());
expect(markThreadAsRead).toHaveBeenCalledTimes(2);
expect(markThreadAsRead).toHaveBeenCalledWith('thread_id');
expect(markThreadAsRead).toHaveBeenCalledWith('post_id');
});
test('should match actions for PREFETCH_POSTS_FOR_CHANNEL when prefetch argument and getPostsSince sucess', async () => {
const channelId = 'channel1';
jest.mocked(PostActions.getPostsSince).mockReturnValue(async () => ({data: [] as unknown as PostList}));
store = mockStore({
...initialState,
views: {
...initialState.views,
channel: {
...initialState.views.channel,
lastGetPosts: {
[channelId]: 12345,
},
},
},
websocket: {
lastDisconnectAt: 12344,
},
});
await store.dispatch(Actions.syncPostsInChannel(channelId, 12350, true));
expect(store.getActions()).toEqual([{
channelId: 'channel1',
status: RequestStatus.STARTED,
type: 'PREFETCH_POSTS_FOR_CHANNEL',
},
{
meta: {batch: true},
payload: [{
channelId: 'channel1',
time: 12344,
type: 'RECEIVED_POSTS_FOR_CHANNEL_AT_TIME',
}, {
channelId: 'channel1',
status: RequestStatus.SUCCESS,
type: 'PREFETCH_POSTS_FOR_CHANNEL',
}],
type: 'BATCHING_REDUCER.BATCH',
},
]);
});
test('should match actions for PREFETCH_POSTS_FOR_CHANNEL when prefetch argument and getPostsSince failure', async () => {
const channelId = 'channel1';
jest.mocked(PostActions.getPostsSince).mockReturnValue(async () => ({error: {}}));
store = mockStore({
...initialState,
views: {
...initialState.views,
channel: {
...initialState.views.channel,
lastGetPosts: {
[channelId]: 12345,
},
},
},
websocket: {
lastDisconnectAt: 12344,
},
});
await store.dispatch(Actions.syncPostsInChannel(channelId, 12350, true));
expect(store.getActions()).toEqual([{
channelId: 'channel1',
status: RequestStatus.STARTED,
type: 'PREFETCH_POSTS_FOR_CHANNEL',
},
{
meta: {batch: true},
payload: [{
channelId: 'channel1',
status: RequestStatus.FAILURE,
type: 'PREFETCH_POSTS_FOR_CHANNEL',
}],
type: 'BATCHING_REDUCER.BATCH',
},
]);
});
});
describe('updateToastStatus', () => {
test('should disptach updateToastStatus action with the true as argument', async () => {
await store.dispatch(Actions.updateToastStatus(true));
expect(store.getActions()).toEqual([{
data: true,
type: 'UPDATE_TOAST_STATUS',
}]);
});
});
describe('prefetchChannelPosts', () => {
test('should call for loadUnreads if there are no posts in channel', async () => {
await store.dispatch(Actions.prefetchChannelPosts('channelid1'));
expect(PostActions.getPostsUnread).toHaveBeenCalledWith('channelid1');
});
test('should call for syncPostsInChannel if there are posts in channel', async () => {
store = mockStore({
...initialState,
entities: {
...initialState.entities,
posts: {
...initialState.entities.posts,
postsInChannel: {
channelid1: [{order: ['postId'], recent: true}],
},
posts: {
postId: {create_at: 1234},
},
},
},
views: {
...initialState.views,
channel: {
...initialState.views.channel,
lastGetPosts: {
channelid1: 12345,
},
},
},
websocket: {
lastDisconnectAt: 12344,
},
});
await store.dispatch(Actions.prefetchChannelPosts('channelid1'));
expect(PostActions.getPostsSince).toHaveBeenCalledWith('channelid1', 1234);
});
test('should not call for getPostsUnread and not syncPostsInChannel if there are posts but not recent chunk', async () => {
store = mockStore({
...initialState,
entities: {
...initialState.entities,
posts: {
...initialState.entities.posts,
postsInChannel: {
channelid1: [{order: ['postId'], recent: false}],
},
posts: {
postId: {create_at: 1234},
},
},
},
views: {
...initialState.views,
channel: {
...initialState.views.channel,
lastGetPosts: {
channelid1: 12345,
},
},
},
websocket: {
lastDisconnectAt: 12344,
},
});
await store.dispatch(Actions.prefetchChannelPosts('channelid1'));
expect(PostActions.getPostsUnread).toHaveBeenCalledWith('channelid1');
expect(PostActions.getPostsSince).not.toHaveBeenCalled();
});
test('should call for loadUnreads after a delay', async () => {
jest.useFakeTimers();
const posts = makePostList({posts: {}, order: [], next_post_id: '', prev_post_id: ''});
jest.mocked(PostActions.getPostsUnread).mockReturnValue(async () => ({data: posts}));
store.dispatch(Actions.prefetchChannelPosts('channelid1', 500));
expect(PostActions.getPostsUnread).not.toHaveBeenCalled();
jest.runOnlyPendingTimers();
await Promise.resolve();
expect(PostActions.getPostsUnread).toHaveBeenCalledWith('channelid1');
});
});
describe('autocompleteUsersInChannel', () => {
test('should return empty arrays if the key is missing in reponse', async () => {
jest.mocked(UserActions.autocompleteUsers).mockReturnValue(async () => ({data: {} as UserAutocomplete}));
const response = await store.dispatch(Actions.autocompleteUsersInChannel('test', 'channelid1'));
expect(response).toStrictEqual({data: {out_of_channel: [], users: []}});
});
});
});

View File

@@ -0,0 +1,566 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {AnyAction} from 'redux';
import {batchActions} from 'redux-batched-actions';
import type {UserAutocomplete} from '@mattermost/types/autocomplete';
import type {Channel} from '@mattermost/types/channels';
import {TeamTypes} from 'mattermost-redux/action_types';
import {
leaveChannel as leaveChannelRedux,
joinChannel,
markChannelAsRead,
unfavoriteChannel,
deleteChannel as deleteChannelRedux,
getChannel as loadChannel,
} from 'mattermost-redux/actions/channels';
import * as PostActions from 'mattermost-redux/actions/posts';
import {selectTeam} from 'mattermost-redux/actions/teams';
import {autocompleteUsers} from 'mattermost-redux/actions/users';
import {Posts, RequestStatus} from 'mattermost-redux/constants';
import {
getChannel,
getChannelsNameMapInCurrentTeam,
getCurrentChannel,
getRedirectChannelNameForTeam,
getMyChannels,
getMyChannelMemberships,
getAllDirectChannelsNameMapInCurrentTeam,
isFavoriteChannel,
isManuallyUnread,
getCurrentChannelId,
} from 'mattermost-redux/selectors/entities/channels';
import {getMostRecentPostIdInChannel, getPost} from 'mattermost-redux/selectors/entities/posts';
import {
getCurrentRelativeTeamUrl,
getCurrentTeam,
getCurrentTeamId,
getRelativeTeamUrl,
getTeamsList,
} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId, getUserByUsername} from 'mattermost-redux/selectors/entities/users';
import {makeAddLastViewAtToProfiles} from 'mattermost-redux/selectors/entities/utils';
import {getChannelByName} from 'mattermost-redux/utils/channel_utils';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import {openDirectChannelToUserId} from 'actions/channel_actions';
import {loadCustomStatusEmojisForPostList} from 'actions/emoji_actions';
import {closeRightHandSide} from 'actions/views/rhs';
import {markThreadAsRead} from 'actions/views/threads';
import {getLastViewedChannelName} from 'selectors/local_storage';
import {getSelectedPost, getSelectedPostId} from 'selectors/rhs';
import {getLastPostsApiTimeForChannel} from 'selectors/views/channel';
import {getSelectedThreadIdInCurrentTeam} from 'selectors/views/threads';
import {getSocketStatus} from 'selectors/views/websocket';
import LocalStorageStore from 'stores/local_storage_store';
import {getHistory} from 'utils/browser_history';
import {isArchivedChannel} from 'utils/channel_utils';
import {Constants, ActionTypes, EventTypes, PostRequestTypes} from 'utils/constants';
import {stopTryNotificationRing} from 'utils/notification_sounds';
import {isChannelPopoutWindow} from 'utils/popouts/popout_windows';
import type {ActionFuncAsync, ThunkActionFunc} from 'types/store';
export function goToLastViewedChannel(): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const currentChannel = getCurrentChannel(state);
const channelsInTeam = getChannelsNameMapInCurrentTeam(state);
const directChannel = getAllDirectChannelsNameMapInCurrentTeam(state);
const channels = Object.assign({}, channelsInTeam, directChannel);
let channelToSwitchTo = getChannelByName(channels, getLastViewedChannelName(state));
if (currentChannel?.id === channelToSwitchTo!.id) {
channelToSwitchTo = getChannelByName(channels, getRedirectChannelNameForTeam(state, getCurrentTeamId(state)));
}
const result = await dispatch(switchToChannel(channelToSwitchTo!));
if (isChannelPopoutWindow()) {
window.close();
}
return result;
};
}
export function switchToChannelById(channelId: string): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const channel = getChannel(state, channelId);
if (channel) {
return dispatch(switchToChannel(channel));
}
return {data: true};
};
}
export function loadIfNecessaryAndSwitchToChannelById(channelId: string): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
let channel = getChannel(state, channelId);
if (!channel) {
const res = await dispatch(loadChannel(channelId));
channel = res.data!;
}
return dispatch(switchToChannel(channel));
};
}
export function switchToChannel(channel: Channel & {userId?: string}): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const selectedTeamId = channel.team_id;
const teamUrl = selectedTeamId ? getRelativeTeamUrl(state, selectedTeamId) : getCurrentRelativeTeamUrl(state);
if (channel.userId) {
const username = channel.userId ? channel.name : channel.display_name;
const user = getUserByUsername(state, username);
if (!user) {
return {error: true};
}
const direct = await dispatch(openDirectChannelToUserId(user.id));
if (direct.error) {
return {error: true};
}
getHistory().push(`${teamUrl}/messages/@${channel.name}`);
} else if (channel.type === Constants.GM_CHANNEL) {
const gmChannel = getChannel(state, channel.id);
if (!gmChannel?.name) {
return {error: true};
}
getHistory().push(`${teamUrl}/channels/${gmChannel.name}`);
} else if (channel.type === Constants.THREADS) {
getHistory().push(`${teamUrl}/${channel.name}`);
} else {
getHistory().push(`${teamUrl}/channels/${channel.name}`);
}
return {data: true};
};
}
export function joinChannelById(channelId: string): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const currentUserId = getCurrentUserId(state);
const currentTeamId = getCurrentTeamId(state);
return dispatch(joinChannel(currentUserId, currentTeamId, channelId));
};
}
export function leaveChannel(channelId: string): ActionFuncAsync {
return async (dispatch, getState) => {
let state = getState();
const currentUserId = getCurrentUserId(state);
const currentTeam = getCurrentTeam(state);
if (!currentTeam) {
return {data: false};
}
const channel = getChannel(state, channelId);
const currentChannelId = getCurrentChannelId(state);
if (isFavoriteChannel(state, channelId)) {
dispatch(unfavoriteChannel(channelId));
}
const teamUrl = getCurrentRelativeTeamUrl(state);
if (!isArchivedChannel(channel)) {
LocalStorageStore.removePreviousChannel(currentUserId, currentTeam.id, state);
}
const {error} = await dispatch(leaveChannelRedux(channelId));
if (error) {
return {error};
}
state = getState();
const prevChannelName = LocalStorageStore.getPreviousChannelName(currentUserId, currentTeam.id, state);
const channelsInTeam = getChannelsNameMapInCurrentTeam(state);
const prevChannel = getChannelByName(channelsInTeam, prevChannelName);
if (!prevChannel || !getMyChannelMemberships(state)[prevChannel.id]) {
LocalStorageStore.removePreviousChannel(currentUserId, currentTeam.id, state);
}
const selectedPost = getSelectedPost(state);
const selectedPostId = getSelectedPostId(state);
if (selectedPostId && selectedPost.exists === false) {
dispatch(closeRightHandSide());
}
if (getMyChannels(getState()).filter((c) => c.type === Constants.OPEN_CHANNEL || c.type === Constants.PRIVATE_CHANNEL).length === 0) {
LocalStorageStore.removePreviousChannel(currentUserId, currentTeam.id, state);
dispatch(selectTeam(''));
dispatch({type: TeamTypes.LEAVE_TEAM, data: currentTeam});
getHistory().push('/');
if (isChannelPopoutWindow()) {
window.close();
}
} else if (channelId === currentChannelId) {
getHistory().push(teamUrl);
if (isChannelPopoutWindow()) {
window.close();
}
}
return {
data: true,
};
};
}
export function leaveDirectChannel(channelName: string): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const currentUserId = getCurrentUserId(state);
const teams = getTeamsList(state); // dms are shared across teams but on local storage are set linked to one, we need to look into all.
teams.forEach((currentTeam) => {
const previousChannel = LocalStorageStore.getPreviousChannelName(currentUserId, currentTeam.id, state);
const penultimateChannel = LocalStorageStore.getPenultimateChannelName(currentUserId, currentTeam.id, state);
if (channelName === previousChannel) {
LocalStorageStore.removePreviousChannel(currentUserId, currentTeam.id, state);
} else if (channelName === penultimateChannel) {
LocalStorageStore.removePenultimateChannelName(currentUserId, currentTeam.id);
}
});
return {
data: true,
};
};
}
export function autocompleteUsersInChannel(prefix: string, channelId: string): ActionFuncAsync<UserAutocomplete> {
const addLastViewAtToProfiles = makeAddLastViewAtToProfiles();
return async (dispatch, getState) => {
const state = getState();
const currentTeamId = getCurrentTeamId(state);
const response = await dispatch(autocompleteUsers(prefix, currentTeamId, channelId));
const data = response.data;
if (data) {
return {
...response,
data: {
...data,
users: addLastViewAtToProfiles(state, data.users || []),
out_of_channel: addLastViewAtToProfiles(state, data.out_of_channel || []),
},
};
}
return response;
};
}
export function loadUnreads(channelId: string, prefetch = false): ActionFuncAsync<{atLatestMessage: boolean; atOldestMessage: boolean}> {
return async (dispatch) => {
const time = Date.now();
if (prefetch) {
dispatch({
type: ActionTypes.PREFETCH_POSTS_FOR_CHANNEL,
channelId,
status: RequestStatus.STARTED,
});
}
const {data, error} = await dispatch(PostActions.getPostsUnread(channelId));
if (error) {
if (prefetch) {
dispatch({
type: ActionTypes.PREFETCH_POSTS_FOR_CHANNEL,
channelId,
status: RequestStatus.FAILURE,
});
}
return {
error,
atLatestMessage: false,
atOldestmessage: false,
};
}
dispatch(loadCustomStatusEmojisForPostList(data!.posts));
const actions = [];
actions.push({
type: ActionTypes.INCREASE_POST_VISIBILITY,
data: channelId,
amount: data!.order.length,
});
if (prefetch) {
actions.push({
type: ActionTypes.PREFETCH_POSTS_FOR_CHANNEL,
channelId,
status: RequestStatus.SUCCESS,
});
}
if (data!.next_post_id === '') {
actions.push({
type: ActionTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME,
channelId,
time,
});
}
dispatch(batchActions(actions));
return {
atLatestMessage: data!.next_post_id === '',
atOldestmessage: data!.prev_post_id === '',
};
};
}
export function loadPostsAround(channelId: string, focusedPostId: string): ActionFuncAsync {
return async (dispatch) => {
const {data, error} = await dispatch(PostActions.getPostsAround(channelId, focusedPostId, Posts.POST_CHUNK_SIZE / 2));
if (error) {
return {
error,
atLatestMessage: false,
atOldestmessage: false,
};
}
dispatch({
type: ActionTypes.INCREASE_POST_VISIBILITY,
data: channelId,
amount: data!.order.length,
});
return {
atLatestMessage: data!.next_post_id === '',
atOldestmessage: data!.prev_post_id === '',
};
};
}
export function loadLatestPosts(channelId: string): ActionFuncAsync {
return async (dispatch) => {
const time = Date.now();
const {data, error} = await dispatch(PostActions.getPosts(channelId, 0, Posts.POST_CHUNK_SIZE / 2));
if (error) {
return {
error,
atLatestMessage: false,
atOldestmessage: false,
};
}
dispatch({
type: ActionTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME,
channelId,
time,
});
return {
data,
atLatestMessage: data!.next_post_id === '',
atOldestmessage: data!.prev_post_id === '',
};
};
}
export interface LoadPostsReturnValue {
error?: string;
moreToLoad: boolean;
}
export type CanLoadMorePosts = typeof PostRequestTypes[keyof typeof PostRequestTypes] | undefined;
export interface LoadPostsParameters {
channelId: string;
postId: string;
type: CanLoadMorePosts;
perPage: number;
}
export function loadPosts({
channelId,
postId,
type,
perPage,
}: LoadPostsParameters): ThunkActionFunc<Promise<LoadPostsReturnValue>> {
//type here can be BEFORE_ID or AFTER_ID
return async (dispatch) => {
dispatch({
type: ActionTypes.LOADING_POSTS,
data: true,
channelId,
});
const page = 0;
let result;
if (type === PostRequestTypes.BEFORE_ID) {
result = await dispatch(PostActions.getPostsBefore(channelId, postId, page, perPage));
} else {
result = await dispatch(PostActions.getPostsAfter(channelId, postId, page, perPage));
}
const {data} = result;
const actions: AnyAction[] = [{
type: ActionTypes.LOADING_POSTS,
data: false,
channelId,
}];
if (result.error) {
return {
error: result.error,
moreToLoad: true,
};
}
dispatch(loadCustomStatusEmojisForPostList(data!.posts));
actions.push({
type: ActionTypes.INCREASE_POST_VISIBILITY,
data: channelId,
amount: data!.order.length,
});
dispatch(batchActions(actions));
return {
moreToLoad: type === PostRequestTypes.BEFORE_ID ? data!.prev_post_id !== '' : data!.next_post_id !== '',
};
};
}
export function syncPostsInChannel(channelId: string, since: number, prefetch = false): ActionFuncAsync {
return async (dispatch, getState) => {
const time = Date.now();
const state = getState();
const socketStatus = getSocketStatus(state);
let sinceTimeToGetPosts = since;
const lastPostsApiCallForChannel = getLastPostsApiTimeForChannel(state, channelId);
const actions = [];
if (lastPostsApiCallForChannel && lastPostsApiCallForChannel < socketStatus.lastDisconnectAt) {
sinceTimeToGetPosts = lastPostsApiCallForChannel;
}
if (prefetch) {
dispatch({
type: ActionTypes.PREFETCH_POSTS_FOR_CHANNEL,
channelId,
status: RequestStatus.STARTED,
});
}
const {data, error} = await dispatch(PostActions.getPostsSince(channelId, sinceTimeToGetPosts));
if (data) {
actions.push({
type: ActionTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME,
channelId,
time,
});
}
if (prefetch) {
if (error) {
actions.push({
type: ActionTypes.PREFETCH_POSTS_FOR_CHANNEL,
channelId,
status: RequestStatus.FAILURE,
});
} else {
actions.push({
type: ActionTypes.PREFETCH_POSTS_FOR_CHANNEL,
channelId,
status: RequestStatus.SUCCESS,
});
}
}
dispatch(batchActions(actions));
return {data, error};
};
}
export function prefetchChannelPosts(channelId: string, jitter?: number): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const recentPostIdInChannel = getMostRecentPostIdInChannel(state, channelId);
if (!state.entities.posts.postsInChannel[channelId] || !recentPostIdInChannel) {
if (jitter) {
await new Promise((resolve) => setTimeout(resolve, jitter));
}
return dispatch(loadUnreads(channelId, true));
}
const recentPost = getPost(state, recentPostIdInChannel);
return dispatch(syncPostsInChannel(channelId, recentPost.create_at, true));
};
}
export function scrollPostListToBottom() {
return () => {
EventEmitter.emit(EventTypes.POST_LIST_SCROLL_TO_BOTTOM);
};
}
export function markAsReadOnFocus(): ThunkActionFunc<void> {
return (dispatch, getState) => {
const state = getState();
const currentChannelId = getCurrentChannelId(state);
const selectedThreadId = getSelectedThreadIdInCurrentTeam(state);
const selectedPostId = getSelectedPostId(state);
if (!isManuallyUnread(getState(), currentChannelId)) {
dispatch(markChannelAsRead(currentChannelId));
}
if (selectedThreadId) {
dispatch(markThreadAsRead(selectedThreadId));
}
if (currentChannelId && selectedPostId) {
dispatch(markThreadAsRead(selectedPostId));
}
};
}
export function updateToastStatus(status: boolean) {
return {
type: ActionTypes.UPDATE_TOAST_STATUS,
data: status,
};
}
export function deleteChannel(channelId: string): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
// Get state before deletion
const state = getState();
const channel = getChannel(state, channelId);
// Validate channel ID
if (!channel || channel.id.length !== Constants.CHANNEL_ID_LENGTH) {
return {data: false};
}
// Call the delete channel action
const res = await dispatch(deleteChannelRedux(channelId));
if (res.error) {
return {data: false};
}
// Handle RHS state
const updatedState = getState();
const selectedPost = getSelectedPost(updatedState);
const selectedPostId = getSelectedPostId(updatedState);
if (selectedPostId && !selectedPost.exists) {
dispatch(closeRightHandSide());
}
// Stop notification sounds
stopTryNotificationRing();
return {data: true};
};
}

View File

@@ -0,0 +1,317 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {CategorySorting} from '@mattermost/types/channel_categories';
import type {Channel, ChannelMembership} from '@mattermost/types/channels';
import {insertWithoutDuplicates} from 'mattermost-redux/utils/array_utils';
import configureStore from 'store';
import * as Actions from './channel_sidebar';
describe('adjustTargetIndexForMove', () => {
const channelIds = ['one', 'twoDeleted', 'three', 'four', 'fiveDeleted', 'six', 'seven'];
const initialState = {
entities: {
channelCategories: {
byId: {
category1: {
id: 'category1',
channel_ids: channelIds,
sorting: CategorySorting.Manual,
},
},
},
channels: {
channels: {
new: {id: 'new', delete_at: 0},
one: {id: 'one', delete_at: 0},
twoDeleted: {id: 'twoDeleted', delete_at: 1},
three: {id: 'three', delete_at: 0},
four: {id: 'four', delete_at: 0},
fiveDeleted: {id: 'fiveDeleted', delete_at: 1},
six: {id: 'six', delete_at: 0},
seven: {id: 'seven', delete_at: 0},
},
},
},
};
describe('should place newly added channels correctly in the category', () => {
const testCases = [
{
inChannelIds: ['new', 'one', 'three', 'four', 'six', 'seven'],
expectedChannelIds: ['new', 'one', 'twoDeleted', 'three', 'four', 'fiveDeleted', 'six', 'seven'],
},
{
inChannelIds: ['one', 'new', 'three', 'four', 'six', 'seven'],
expectedChannelIds: ['one', 'new', 'twoDeleted', 'three', 'four', 'fiveDeleted', 'six', 'seven'],
},
{
inChannelIds: ['one', 'three', 'new', 'four', 'six', 'seven'],
expectedChannelIds: ['one', 'twoDeleted', 'three', 'new', 'four', 'fiveDeleted', 'six', 'seven'],
},
{
inChannelIds: ['one', 'three', 'four', 'new', 'six', 'seven'],
expectedChannelIds: ['one', 'twoDeleted', 'three', 'four', 'new', 'fiveDeleted', 'six', 'seven'],
},
{
inChannelIds: ['one', 'three', 'four', 'six', 'new', 'seven'],
expectedChannelIds: ['one', 'twoDeleted', 'three', 'four', 'fiveDeleted', 'six', 'new', 'seven'],
},
{
inChannelIds: ['one', 'three', 'four', 'six', 'seven', 'new'],
expectedChannelIds: ['one', 'twoDeleted', 'three', 'four', 'fiveDeleted', 'six', 'seven', 'new'],
},
];
for (let i = 0; i < testCases.length; i++) {
const testCase = testCases[i];
test('at ' + i, async () => {
const store = configureStore(initialState);
const targetIndex = testCase.inChannelIds.indexOf('new');
const newIndex = Actions.adjustTargetIndexForMove(store.getState(), 'category1', ['new'], targetIndex, 'new');
const actualChannelIds = insertWithoutDuplicates(channelIds, 'new', newIndex);
expect(actualChannelIds).toEqual(testCase.expectedChannelIds);
});
}
});
describe('should be able to move channels forwards', () => {
const testCases = [
{
inChannelIds: ['one', 'three', 'four', 'six', 'seven'],
expectedChannelIds: ['one', 'twoDeleted', 'three', 'four', 'fiveDeleted', 'six', 'seven'],
},
{
inChannelIds: ['three', 'one', 'four', 'six', 'seven'],
expectedChannelIds: ['twoDeleted', 'three', 'one', 'four', 'fiveDeleted', 'six', 'seven'],
},
{
inChannelIds: ['three', 'four', 'one', 'six', 'seven'],
expectedChannelIds: ['twoDeleted', 'three', 'four', 'one', 'fiveDeleted', 'six', 'seven'],
},
{
inChannelIds: ['three', 'four', 'six', 'one', 'seven'],
expectedChannelIds: ['twoDeleted', 'three', 'four', 'fiveDeleted', 'six', 'one', 'seven'],
},
{
inChannelIds: ['three', 'four', 'six', 'seven', 'one'],
expectedChannelIds: ['twoDeleted', 'three', 'four', 'fiveDeleted', 'six', 'seven', 'one'],
},
];
for (let i = 0; i < testCases.length; i++) {
const testCase = testCases[i];
test('at ' + i, async () => {
const store = configureStore(initialState);
const targetIndex = testCase.inChannelIds.indexOf('one');
const newIndex = Actions.adjustTargetIndexForMove(store.getState(), 'category1', ['one'], targetIndex, 'one');
const actualChannelIds = insertWithoutDuplicates(channelIds, 'one', newIndex);
expect(actualChannelIds).toEqual(testCase.expectedChannelIds);
});
}
});
describe('should be able to move channels backwards', () => {
const testCases = [
{
inChannelIds: ['one', 'three', 'four', 'six', 'seven'],
expectedChannelIds: ['one', 'twoDeleted', 'three', 'four', 'fiveDeleted', 'six', 'seven'],
},
{
inChannelIds: ['one', 'three', 'four', 'seven', 'six'],
expectedChannelIds: ['one', 'twoDeleted', 'three', 'four', 'seven', 'fiveDeleted', 'six'],
},
{
inChannelIds: ['one', 'three', 'seven', 'four', 'six'],
expectedChannelIds: ['one', 'twoDeleted', 'three', 'seven', 'four', 'fiveDeleted', 'six'],
},
{
inChannelIds: ['one', 'seven', 'three', 'four', 'six'],
expectedChannelIds: ['one', 'seven', 'twoDeleted', 'three', 'four', 'fiveDeleted', 'six'],
},
{
inChannelIds: ['seven', 'one', 'three', 'four', 'six'],
expectedChannelIds: ['seven', 'one', 'twoDeleted', 'three', 'four', 'fiveDeleted', 'six'],
},
];
for (let i = 0; i < testCases.length; i++) {
const testCase = testCases[i];
test('at ' + i, async () => {
const store = configureStore(initialState);
const targetIndex = testCase.inChannelIds.indexOf('seven');
const newIndex = Actions.adjustTargetIndexForMove(store.getState(), 'category1', ['seven'], targetIndex, 'seven');
const actualChannelIds = insertWithoutDuplicates(channelIds, 'seven', newIndex);
expect(actualChannelIds).toEqual(testCase.expectedChannelIds);
});
}
});
});
describe('multiSelectChannelTo', () => {
const channelIds = ['one', 'two', 'three', 'four', 'five', 'six', 'seven'];
const initialState = {
entities: {
users: {
currentUserId: 'user',
},
channelCategories: {
byId: {
category1: {
id: 'category1',
channel_ids: channelIds.map((id) => `category1_${id}`),
sorting: CategorySorting.Manual,
},
category2: {
id: 'category2',
channel_ids: channelIds.map((id) => `category2_${id}`),
sorting: CategorySorting.Manual,
},
},
orderByTeam: {
team1: ['category1', 'category2'],
},
},
channels: {
currentChannelId: 'category1_one',
channels: {
...channelIds.reduce((init: {[key: string]: Partial<Channel>}, val) => {
init[`category1_${val}`] = {id: `category1_${val}`, delete_at: 0};
return init;
}, {}),
...channelIds.reduce((init: {[key: string]: Partial<Channel>}, val) => {
init[`category2_${val}`] = {id: `category2_${val}`, delete_at: 0};
return init;
}, {}),
},
myMembers: {
...channelIds.reduce((init: {[key: string]: Partial<ChannelMembership>}, val) => {
init[`category1_${val}`] = {channel_id: `category1_${val}`, user_id: 'user'};
return init;
}, {}),
...channelIds.reduce((init: {[key: string]: Partial<ChannelMembership>}, val) => {
init[`category2_${val}`] = {channel_id: `category2_${val}`, user_id: 'user'};
return init;
}, {}),
},
channelsInTeam: {
team1: new Set(channelIds.map((id) => `category1_${id}`).concat(channelIds.map((id) => `category2_${id}`))),
},
},
teams: {
currentTeamId: 'team1',
},
},
views: {
channelSidebar: {
multiSelectedChannelIds: [],
lastSelectedChannel: '',
},
},
};
test('should only select current channel if is selected ID and none other are selected', () => {
const store = configureStore(initialState);
store.dispatch(Actions.multiSelectChannelTo('category1_one'));
expect(store.getState().views.channelSidebar.multiSelectedChannelIds).toEqual(['category1_one']);
});
test('should select group of channels in ascending order', () => {
const store = configureStore({
...initialState,
views: {
channelSidebar: {
multiSelectedChannelIds: ['category1_two'],
lastSelectedChannel: 'category1_two',
},
},
});
store.dispatch(Actions.multiSelectChannelTo('category1_seven'));
expect(store.getState().views.channelSidebar.multiSelectedChannelIds).toEqual(['category1_two', 'category1_three', 'category1_four', 'category1_five', 'category1_six', 'category1_seven']);
});
test('should select group of channels in descending order and sort by ascending', () => {
const store = configureStore({
...initialState,
views: {
channelSidebar: {
multiSelectedChannelIds: ['category1_five'],
lastSelectedChannel: 'category1_five',
},
},
});
store.dispatch(Actions.multiSelectChannelTo('category1_one'));
expect(store.getState().views.channelSidebar.multiSelectedChannelIds).toEqual(['category1_one', 'category1_two', 'category1_three', 'category1_four', 'category1_five']);
});
test('should select group of channels where some other channels were already selected', () => {
const store = configureStore({
...initialState,
views: {
channelSidebar: {
multiSelectedChannelIds: ['category1_five', 'category2_six', 'category2_three'],
lastSelectedChannel: 'category1_five',
},
},
});
store.dispatch(Actions.multiSelectChannelTo('category1_one'));
expect(store.getState().views.channelSidebar.multiSelectedChannelIds).toEqual(['category1_one', 'category1_two', 'category1_three', 'category1_four', 'category1_five']);
});
test('should select group of channels where some other channels were already selected but in new selection', () => {
const store = configureStore({
...initialState,
views: {
channelSidebar: {
multiSelectedChannelIds: ['category1_five', 'category1_three', 'category1_one'],
lastSelectedChannel: 'category1_five',
},
},
});
store.dispatch(Actions.multiSelectChannelTo('category1_one'));
expect(store.getState().views.channelSidebar.multiSelectedChannelIds).toEqual(['category1_one', 'category1_two', 'category1_three', 'category1_four', 'category1_five']);
});
test('should select group of channels across categories', () => {
const store = configureStore({
...initialState,
views: {
channelSidebar: {
multiSelectedChannelIds: ['category1_five'],
lastSelectedChannel: 'category1_five',
},
},
});
store.dispatch(Actions.multiSelectChannelTo('category2_two'));
expect(store.getState().views.channelSidebar.multiSelectedChannelIds).toEqual(['category1_five', 'category1_six', 'category1_seven', 'category2_one', 'category2_two']);
});
});

View File

@@ -0,0 +1,220 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createCategory as createCategoryRedux, moveChannelsToCategory} from 'mattermost-redux/actions/channel_categories';
import {General} from 'mattermost-redux/constants';
import {CategoryTypes} from 'mattermost-redux/constants/channel_categories';
import {getCategory, makeGetChannelIdsForCategory} from 'mattermost-redux/selectors/entities/channel_categories';
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels';
import {insertMultipleWithoutDuplicates} from 'mattermost-redux/utils/array_utils';
import {getCategoriesForCurrentTeam, getChannelsInCategoryOrder, getDisplayedChannels} from 'selectors/views/channel_sidebar';
import {ActionTypes} from 'utils/constants';
import type {ActionFunc, ActionFuncAsync, DraggingState, GlobalState} from 'types/store';
export function setUnreadFilterEnabled(enabled: boolean) {
return {
type: ActionTypes.SET_UNREAD_FILTER_ENABLED,
enabled,
};
}
export function setDraggingState(data: DraggingState) {
return {
type: ActionTypes.SIDEBAR_DRAGGING_SET_STATE,
data,
};
}
export function stopDragging() {
return {type: ActionTypes.SIDEBAR_DRAGGING_STOP};
}
export function createCategory(teamId: string, displayName: string, channelIds?: string[]): ActionFuncAsync<unknown> {
return async (dispatch, getState) => {
if (channelIds) {
const state = getState();
const multiSelectedChannelIds = state.views.channelSidebar.multiSelectedChannelIds;
channelIds.forEach((channelId) => {
if (multiSelectedChannelIds.indexOf(channelId) >= 0) {
dispatch(multiSelectChannelAdd(channelId));
}
});
}
const result = await dispatch(createCategoryRedux(teamId, displayName, channelIds));
return dispatch({
type: ActionTypes.ADD_NEW_CATEGORY_ID,
data: result.data!.id,
});
};
}
// addChannelsInSidebar moves channels to a given category without specifying the order in the sidebar, so the channels
// will always go to the first position in the category
export function addChannelsInSidebar(categoryId: string, channelId: string) {
return moveChannelsInSidebar(categoryId, 0, channelId, false);
}
// moveChannelsInSidebar moves channels to a given category in the sidebar, but it accounts for when the target index
// may have changed due to archived channels not being shown in the sidebar.
export function moveChannelsInSidebar(categoryId: string, targetIndex: number, draggableChannelId: string, setManualSorting = true): ActionFuncAsync<unknown> {
return (dispatch, getState) => {
const state = getState();
const multiSelectedChannelIds = state.views.channelSidebar.multiSelectedChannelIds;
let channelIds = [];
// Multi channel case
if (multiSelectedChannelIds.length && multiSelectedChannelIds.indexOf(draggableChannelId) !== -1) {
const categories = getCategoriesForCurrentTeam(state);
const displayedChannels = getDisplayedChannels(state);
let channelsToMove = [draggableChannelId];
// Filter out channels that can't go in the category specified
const targetCategory = categories.find((category) => category.id === categoryId);
channelsToMove = multiSelectedChannelIds.filter((channelId) => {
const selectedChannel = displayedChannels.find((channel) => channelId === channel.id);
const isDMGM = selectedChannel?.type === General.DM_CHANNEL || selectedChannel?.type === General.GM_CHANNEL;
return targetCategory?.type === CategoryTypes.CUSTOM || targetCategory?.type === CategoryTypes.FAVORITES || (isDMGM && targetCategory?.type === CategoryTypes.DIRECT_MESSAGES) || (!isDMGM && targetCategory?.type !== CategoryTypes.DIRECT_MESSAGES);
});
// Reorder such that the channels move in the order that they appear in the sidebar
const displayedChannelIds = displayedChannels.map((channel) => channel.id);
channelsToMove.sort((a, b) => displayedChannelIds.indexOf(a) - displayedChannelIds.indexOf(b));
// Remove selection from channels that were moved
channelsToMove.forEach((channelId) => dispatch(multiSelectChannelAdd(channelId)));
channelIds = channelsToMove;
} else {
channelIds = [draggableChannelId];
}
const newIndex = adjustTargetIndexForMove(state, categoryId, channelIds, targetIndex, draggableChannelId);
return dispatch(moveChannelsToCategory(categoryId, channelIds, newIndex, setManualSorting));
};
}
export function adjustTargetIndexForMove(state: GlobalState, categoryId: string, channelIds: string[], targetIndex: number, draggableChannelId: string) {
if (targetIndex === 0) {
// The channel is being placed first, so there's nothing above that could affect the index
return 0;
}
const category = getCategory(state, categoryId);
const filteredChannelIds = makeGetChannelIdsForCategory()(state, category);
// When dragging multiple channels, we don't actually remove all of them from the list as @hello-pangea/dnd doesn't support that
// Account for channels removed above the insert point, except the one currently being dragged which is already accounted for by @hello-pangea/dnd
const removedChannelsAboveInsert = filteredChannelIds.filter((channel, index) => channel !== draggableChannelId && channelIds.indexOf(channel) !== -1 && index <= targetIndex);
const shiftedIndex = targetIndex - removedChannelsAboveInsert.length;
if (category.channel_ids.length === filteredChannelIds.length) {
// There are no archived channels in the category, so the shiftedIndex will be correct
return shiftedIndex;
}
const updatedChannelIds = insertMultipleWithoutDuplicates(filteredChannelIds, channelIds, shiftedIndex);
// After "moving" the channel in the sidebar, find what channel comes above it
const previousChannelId = updatedChannelIds[updatedChannelIds.indexOf(channelIds[0]) - 1];
// We want the channel to still be below that channel, so place the new index below it
let newIndex = category.channel_ids.indexOf(previousChannelId) + 1;
// If the channel is moving downwards, then the target index will need to be reduced by one to account for
// the channel being removed. For example, if we're moving channelA from [channelA, channelB, channelC] to
// [channelB, channelA, channelC], newIndex would currently be 2 (which comes after channelB), but we need
// it to be 1 (which comes after channelB once channelA is removed).
const sourceIndex = category.channel_ids.indexOf(channelIds[0]);
if (sourceIndex !== -1 && sourceIndex < newIndex) {
newIndex -= 1;
}
return Math.max(newIndex - removedChannelsAboveInsert.length, 0);
}
export function clearChannelSelection(): ActionFunc<unknown> {
return (dispatch, getState) => {
const state = getState();
if (state.views.channelSidebar.multiSelectedChannelIds.length === 0) {
// No selection to clear
return {data: false};
}
dispatch({
type: ActionTypes.MULTISELECT_CHANNEL_CLEAR,
});
return {data: true};
};
}
export function multiSelectChannelAdd(channelId: string): ActionFunc<unknown> {
return (dispatch, getState) => {
const state = getState();
const multiSelectedChannelIds = state.views.channelSidebar.multiSelectedChannelIds;
// Nothing already selected, so we include the active channel
if (!multiSelectedChannelIds.length) {
const currentChannel = getCurrentChannelId(state);
dispatch({
type: ActionTypes.MULTISELECT_CHANNEL,
data: currentChannel,
});
}
return dispatch({
type: ActionTypes.MULTISELECT_CHANNEL_ADD,
data: channelId,
});
};
}
// Much of this logic was pulled from the react-beautiful-dnd sample multiselect implementation
// Found here: https://github.com/atlassian/react-beautiful-dnd/tree/master/stories/src/multi-drag
export function multiSelectChannelTo(channelId: string): ActionFunc<unknown> {
return (dispatch, getState) => {
const state = getState();
const multiSelectedChannelIds = state.views.channelSidebar.multiSelectedChannelIds;
let lastSelected = state.views.channelSidebar.lastSelectedChannel;
// Nothing already selected, so start with the active channel
if (!multiSelectedChannelIds.length) {
const currentChannel = getCurrentChannelId(state);
dispatch({
type: ActionTypes.MULTISELECT_CHANNEL,
data: currentChannel,
});
lastSelected = currentChannel;
}
const allChannelsIdsInOrder = getChannelsInCategoryOrder(state).map((channel) => channel.id);
const indexOfNew: number = allChannelsIdsInOrder.indexOf(channelId);
const indexOfLast: number = allChannelsIdsInOrder.indexOf(lastSelected);
// multi selecting in the same column
// need to select everything between the last index and the current index inclusive
// nothing to do here
if (indexOfNew === indexOfLast) {
return {data: false};
}
const start: number = Math.min(indexOfLast, indexOfNew);
const end: number = Math.max(indexOfLast, indexOfNew);
const inBetween = allChannelsIdsInOrder.slice(start, end + 1);
// everything inbetween needs to have it's selection toggled.
// with the exception of the start and end values which will always be selected
return dispatch({
type: ActionTypes.MULTISELECT_CHANNEL_TO,
data: inBetween,
});
};
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export function clearUserCookie() {
// We need to clear the cookie without the domain, with the domain, and with both the domain and path set because we
// can't tell if the server set the cookie with or without the domain.
// The server will have set the domain if ServiceSettings.EnableCookiesForSubdomains is true
// The server will have set a non-default path if Mattermost is also served from a subpath.
document.cookie = 'MMUSERID=;expires=Thu, 01 Jan 1970 00:00:01 GMT;path=/';
document.cookie = `MMUSERID=;expires=Thu, 01 Jan 1970 00:00:01 GMT;path=${window.basename}`;
document.cookie = `MMUSERID=;expires=Thu, 01 Jan 1970 00:00:01 GMT;domain=${window.location.hostname};path=/`;
document.cookie = `MMUSERID=;expires=Thu, 01 Jan 1970 00:00:01 GMT;domain=${window.location.hostname};path=${window.basename}`;
document.cookie = 'MMCSRF=;expires=Thu, 01 Jan 1970 00:00:01 GMT;path=/';
document.cookie = `MMCSRF=;expires=Thu, 01 Jan 1970 00:00:01 GMT;path=${window.basename}`;
document.cookie = `MMCSRF=;expires=Thu, 01 Jan 1970 00:00:01 GMT;domain=${window.location.hostname};path=/`;
document.cookie = `MMCSRF=;expires=Thu, 01 Jan 1970 00:00:01 GMT;domain=${window.location.hostname};path=${window.basename}`;
}

View File

@@ -0,0 +1,351 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {CommandArgs} from '@mattermost/types/integrations';
import type {DeepPartial} from '@mattermost/types/utilities';
import {
addMessageIntoHistory,
} from 'mattermost-redux/actions/posts';
import {Posts} from 'mattermost-redux/constants';
import {executeCommand} from 'actions/command';
import * as HookActions from 'actions/hooks';
import * as PostActions from 'actions/post_actions';
import {
onSubmit,
submitPost,
submitCommand,
} from 'actions/views/create_comment';
import mockStore from 'tests/test_store';
import {StoragePrefixes} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
import type {PostDraft} from 'types/store/draft';
/* eslint-disable global-require */
jest.mock('mattermost-redux/client', () => {
const original = jest.requireActual('mattermost-redux/client');
return {
...original,
Client4: {
...original.Client4,
deleteDraft: jest.fn().mockResolvedValue([]),
upsertDraft: jest.fn().mockResolvedValue([]),
},
};
});
jest.mock('mattermost-redux/actions/posts', () => ({
removeReaction: (...args: any[]) => ({type: 'MOCK_REMOVE_REACTION', args}),
addMessageIntoHistory: (...args: any[]) => ({type: 'MOCK_ADD_MESSAGE_INTO_HISTORY', args}),
moveHistoryIndexBack: (...args: any[]) => ({type: 'MOCK_MOVE_MESSAGE_HISTORY_BACK', args}),
moveHistoryIndexForward: (...args: any[]) => ({type: 'MOCK_MOVE_MESSAGE_HISTORY_FORWARD', args}),
}));
jest.mock('actions/command', () => ({
executeCommand: jest.fn((...args) => ({type: 'MOCK_ACTIONS_COMMAND_EXECUTE', args})),
}));
jest.mock('actions/global_actions', () => ({
emitUserCommentedEvent: jest.fn(),
}));
jest.mock('actions/hooks', () => ({
runMessageWillBePostedHooks: jest.fn((post) => () => ({data: post})),
runSlashCommandWillBePostedHooks: jest.fn((message, args) => () => ({data: {message, args}})),
}));
jest.mock('actions/post_actions', () => ({
submitReaction: (...args: any[]) => ({type: 'MOCK_SUBMIT_REACTION', args}),
addReaction: (...args: any[]) => ({type: 'MOCK_ADD_REACTION', args}),
createPost: jest.fn(() => ({type: 'MOCK_CREATE_POST'})),
setEditingPost: (...args: any[]) => ({type: 'MOCK_SET_EDITING_POST', args}),
}));
jest.mock('actions/storage', () => {
const original = jest.requireActual('actions/storage');
return {
...original,
setGlobalItem: (...args: any[]) => ({type: 'MOCK_SET_GLOBAL_ITEM', args}),
actionOnGlobalItemsWithPrefix: (...args: any[]) => ({type: 'MOCK_ACTION_ON_GLOBAL_ITEMS_WITH_PREFIX', args}),
};
});
function lastCall<T>(calls: T[]): T {
return calls[calls.length - 1];
}
function makeDraft(draft: DeepPartial<PostDraft>): PostDraft {
return draft as PostDraft;
}
const rootId = 'fc234c34c23';
const currentUserId = '34jrnfj43';
const teamId = '4j5nmn4j3';
const channelId = '4j5j4k3k34j4';
const latestPostId = 'latestPostId';
describe('rhs view actions', () => {
const initialState: DeepPartial<GlobalState> = {
entities: {
posts: {
posts: {
[latestPostId]: {
id: latestPostId,
user_id: currentUserId,
message: 'test msg',
channel_id: channelId,
root_id: rootId,
create_at: 42,
},
[rootId]: {
id: rootId,
user_id: currentUserId,
message: 'root msg',
channel_id: channelId,
root_id: '',
create_at: 2,
},
},
postsInChannel: {
[channelId]: [
{order: [latestPostId], recent: true},
],
},
postsInThread: {
[rootId]: [latestPostId],
},
messagesHistory: {
index: {
[Posts.MESSAGE_TYPES.COMMENT]: 0,
},
messages: ['test message'],
},
},
channels: {
channels: {
[channelId]: TestHelper.getChannelMock({id: channelId}),
},
roles: {
[channelId]: new Set(['channel_roles']),
},
},
preferences: {
myPreferences: {},
},
users: {
currentUserId,
profiles: {
[currentUserId]: TestHelper.getUserMock({id: currentUserId}),
},
},
teams: {
currentTeamId: teamId,
},
emojis: {
customEmoji: {},
},
roles: {
roles: {
channel_roles: {
permissions: '' as unknown as string[], // stale fixture: Role.permissions is a string[]
},
},
},
general: {
config: {
EnableCustomEmoji: 'true',
},
},
},
storage: {
storage: {
[`${StoragePrefixes.COMMENT_DRAFT}${rootId}`]: {
value: {
message: '',
fileInfos: [],
uploadsInProgress: [],
channelId,
rootId,
},
timestamp: new Date(),
},
},
},
websocket: {
connectionId: '',
},
};
let store = mockStore(initialState);
beforeEach(() => {
store = mockStore(initialState);
});
describe('submitPost', () => {
const draft = makeDraft({message: '', channelId, rootId, fileInfos: []});
const post = {
file_ids: [],
message: draft.message,
channel_id: channelId,
root_id: rootId,
user_id: currentUserId,
};
test('it call PostActions.createPost with post', async () => {
await store.dispatch(submitPost(channelId, rootId, draft));
expect(HookActions.runMessageWillBePostedHooks).toHaveBeenCalled();
expect(PostActions.createPost).toHaveBeenCalled();
expect(lastCall(jest.mocked(PostActions.createPost).mock.calls)[0]).toEqual(
expect.objectContaining(post),
);
expect(lastCall(jest.mocked(PostActions.createPost).mock.calls)[1]).toBe(draft.fileInfos);
});
test('it does not call PostActions.createPost when hooks fail', async () => {
jest.mocked(HookActions.runMessageWillBePostedHooks).mockImplementation(() => async () => ({error: {message: 'An error occurred'}}));
await store.dispatch(submitPost(channelId, rootId, draft));
expect(HookActions.runMessageWillBePostedHooks).toHaveBeenCalled();
expect(PostActions.createPost).not.toHaveBeenCalled();
});
});
describe('submitCommand', () => {
const args = {
channel_id: channelId,
team_id: teamId,
root_id: rootId,
};
const draft = makeDraft({message: '/test msg', channelId, rootId});
test('it calls executeCommand', async () => {
await store.dispatch(submitCommand(channelId, rootId, draft));
expect(HookActions.runSlashCommandWillBePostedHooks).toHaveBeenCalled();
expect(executeCommand).toHaveBeenCalled();
// First argument
expect(lastCall(jest.mocked(executeCommand).mock.calls)[0]).toEqual(draft.message);
// Second argument
expect(lastCall(jest.mocked(executeCommand).mock.calls)[1]).toEqual(args);
});
test('it does not call executeComaand when hooks fail', async () => {
jest.mocked(HookActions.runSlashCommandWillBePostedHooks).mockImplementation(() => async () => ({error: {message: 'An error occurred'}}));
await store.dispatch(submitCommand(channelId, rootId, draft));
expect(HookActions.runSlashCommandWillBePostedHooks).toHaveBeenCalled();
expect(executeCommand).not.toHaveBeenCalled();
});
test('it should not error in case of an empty response', async () => {
jest.mocked(HookActions.runSlashCommandWillBePostedHooks).mockImplementation(() => async () => ({data: {} as {message: string; args: CommandArgs}}));
const res = await store.dispatch(submitCommand(channelId, rootId, draft));
expect(res).toStrictEqual({});
expect(HookActions.runSlashCommandWillBePostedHooks).toHaveBeenCalled();
expect(executeCommand).not.toHaveBeenCalled();
});
test('it calls submitPost on error.sendMessage', async () => {
jest.mock('actions/channel_actions', () => ({
executeCommand: jest.fn((message, _args, resolve, reject) => reject({sendMessage: 'test'})),
}));
jest.resetModules();
const {submitCommand: remockedSubmitCommand} = require('actions/views/create_comment');
await store.dispatch(remockedSubmitCommand(channelId, rootId, draft));
const expectedActions = [{args: ['/test msg', {channel_id: '4j5j4k3k34j4', root_id: 'fc234c34c23', team_id: '4j5nmn4j3'}], type: 'MOCK_ACTIONS_COMMAND_EXECUTE'}];
expect(store.getActions()).toEqual(expectedActions);
});
});
describe('onSubmit', () => {
const draft = makeDraft({
message: 'test',
fileInfos: [],
uploadsInProgress: [],
rootId,
channelId,
});
test('it adds message into history', () => {
store.dispatch(onSubmit(channelId, rootId, draft, {}));
const testStore = mockStore(initialState);
testStore.dispatch(addMessageIntoHistory('test'));
expect(store.getActions()).toEqual(
expect.arrayContaining(testStore.getActions()),
);
});
test('it submits a command when message is /away', async () => {
// jest.config.js sets clearMocks, which clears calls but not implementations, so the
// empty-response override from the submitCommand suite above would otherwise leak here
// and make submitCommand return before dispatching executeCommand.
jest.mocked(HookActions.runSlashCommandWillBePostedHooks).mockImplementation((message, args) => async () => ({data: {message, args}}));
await store.dispatch(onSubmit(channelId, rootId, makeDraft({
message: '/away',
fileInfos: [],
uploadsInProgress: [],
}), {}));
const testStore = mockStore(initialState);
await testStore.dispatch(submitCommand(channelId, rootId, makeDraft({message: '/away', fileInfos: [], uploadsInProgress: []})));
const commandActions = [{args: ['/away', {channel_id: '4j5j4k3k34j4', root_id: 'fc234c34c23', team_id: '4j5nmn4j3'}], type: 'MOCK_ACTIONS_COMMAND_EXECUTE'}];
expect(store.getActions()).toEqual(expect.arrayContaining(testStore.getActions()));
expect(store.getActions()).toEqual(expect.arrayContaining(commandActions));
});
test('it submits a regular post when options.ignoreSlash is true', () => {
store.dispatch(onSubmit(channelId, rootId, makeDraft({
message: '/fakecommand',
fileInfos: [],
uploadsInProgress: [],
}), {ignoreSlash: true}));
const testStore = mockStore(initialState);
testStore.dispatch(submitPost(channelId, rootId, makeDraft({message: '/fakecommand', fileInfos: [], uploadsInProgress: []})));
expect(store.getActions()).toEqual(expect.arrayContaining(testStore.getActions()));
expect(store.getActions()).toEqual(expect.arrayContaining([{args: ['/fakecommand'], type: 'MOCK_ADD_MESSAGE_INTO_HISTORY'}]));
});
test('it submits a regular post when message is something else', () => {
store.dispatch(onSubmit(channelId, rootId, makeDraft({
message: 'test msg',
fileInfos: [],
uploadsInProgress: [],
}), {}));
const testStore = mockStore(initialState);
testStore.dispatch(submitPost(channelId, rootId, makeDraft({message: 'test msg', fileInfos: [], uploadsInProgress: []})));
expect(store.getActions()).toEqual(expect.arrayContaining(testStore.getActions()));
expect(store.getActions()).toEqual(expect.arrayContaining([{args: ['test msg'], type: 'MOCK_ADD_MESSAGE_INTO_HISTORY'}]));
});
});
});

View File

@@ -0,0 +1,229 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {CommandArgs} from '@mattermost/types/integrations';
import type {Post, PostMetadata} from '@mattermost/types/posts';
import type {SchedulingInfo} from '@mattermost/types/schedule_post';
import {scheduledPostFromPost} from '@mattermost/types/schedule_post';
import type {CreatePostReturnType, SubmitReactionReturnType} from 'mattermost-redux/actions/posts';
import {addMessageIntoHistory} from 'mattermost-redux/actions/posts';
import {Permissions} from 'mattermost-redux/constants';
import {PostTypes} from 'mattermost-redux/constants/posts';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCustomEmojisByName} from 'mattermost-redux/selectors/entities/emojis';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {getAssociatedGroupsForReferenceByMention} from 'mattermost-redux/selectors/entities/groups';
import {
getLatestInteractablePostId,
getLatestPostToEdit,
} from 'mattermost-redux/selectors/entities/posts';
import {isCustomGroupsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import type {ExecuteCommandReturnType} from 'actions/command';
import {executeCommand} from 'actions/command';
import {runMessageWillBePostedHooks, runSlashCommandWillBePostedHooks} from 'actions/hooks';
import * as PostActions from 'actions/post_actions';
import {createSchedulePostFromDraft} from 'actions/post_actions';
import {isBurnOnReadEnabled} from 'selectors/burn_on_read';
import EmojiMap from 'utils/emoji_map';
import {containsAtChannel, groupsMentionedInText} from 'utils/post_utils';
import * as Utils from 'utils/utils';
import type {ActionFunc, ActionFuncAsync, GlobalState} from 'types/store';
import type {PostDraft} from 'types/store/draft';
export function submitPost(
channelId: string,
rootId: string,
draft: PostDraft,
afterSubmit?: (response: SubmitPostReturnType) => void,
schedulingInfo?: SchedulingInfo,
options?: OnSubmitOptions,
): ActionFuncAsync<CreatePostReturnType> {
return async (dispatch, getState) => {
const state = getState();
const userId = getCurrentUserId(state);
const time = Utils.getTimestamp();
const isBorEnabled = isBurnOnReadEnabled(state);
const postType = (isBorEnabled && draft.type === PostTypes.BURN_ON_READ) ? PostTypes.BURN_ON_READ : undefined;
let post = {
file_ids: [],
message: draft.message,
channel_id: channelId,
root_id: rootId,
pending_post_id: `${userId}:${time}`,
user_id: userId,
create_at: time,
type: postType,
metadata: {
...(draft.metadata?.priority && {priority: draft.metadata.priority}),
},
props: {...draft.props},
} as unknown as Post;
const channel = getChannel(state, channelId);
if (!channel) {
return {error: new Error('cannot find channel')};
}
const useChannelMentions = haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_CHANNEL_MENTIONS);
if (!useChannelMentions && containsAtChannel(post.message, {checkAllMentions: true})) {
post.props.mentionHighlightDisabled = true;
}
const license = getLicense(state);
const isLDAPEnabled = license?.IsLicensed === 'true' && license?.LDAPGroups === 'true';
const useLDAPGroupMentions = isLDAPEnabled && haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_GROUP_MENTIONS);
const useCustomGroupMentions = isCustomGroupsEnabled(state) && haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_GROUP_MENTIONS);
const groupsWithAllowReference = useLDAPGroupMentions || useCustomGroupMentions ? getAssociatedGroupsForReferenceByMention(state, channel.team_id, channel.id) : null;
if (!useLDAPGroupMentions && !useCustomGroupMentions && groupsMentionedInText(post.message, groupsWithAllowReference)) {
post.props.disable_group_highlight = true;
}
const hookResult = await dispatch(runMessageWillBePostedHooks(post));
if (hookResult.error) {
return {error: hookResult.error};
}
post = hookResult.data!;
if (schedulingInfo) {
const scheduledPost = scheduledPostFromPost(post, schedulingInfo);
scheduledPost.file_ids = draft.fileInfos.map((fileInfo) => fileInfo.id);
if (draft.fileInfos?.length > 0) {
if (!scheduledPost.metadata) {
scheduledPost.metadata = {} as PostMetadata;
}
scheduledPost.metadata.files = draft.fileInfos;
}
const response = await dispatch(createSchedulePostFromDraft(scheduledPost));
if (afterSubmit) {
const result: CreatePostReturnType = {
error: response.error,
created: !response.error,
};
afterSubmit(result);
}
return response;
}
return dispatch(PostActions.createPost(post, draft.fileInfos, afterSubmit, options));
};
}
type SubmitCommandRerturnType = ExecuteCommandReturnType & CreatePostReturnType;
export function submitCommand(channelId: string, rootId: string, draft: PostDraft): ActionFuncAsync<SubmitCommandRerturnType> {
return async (dispatch, getState) => {
const state = getState();
const teamId = getCurrentTeamId(state);
let args: CommandArgs = {
channel_id: channelId,
team_id: teamId,
root_id: rootId,
};
let {message} = draft;
const hookResult = await dispatch(runSlashCommandWillBePostedHooks(message, args));
if (hookResult.error) {
return {error: hookResult.error};
} else if (!hookResult.data!.message && !hookResult.data!.args) {
// do nothing with an empty return from a hook
// this is allowed by the registerSlashCommandWillBePostedHook API in case
// a plugin intercepts and handles the command on the client side
// but doesn't require it to be sent to the server. (e.g., /call start).
return {};
}
message = hookResult.data!.message;
args = hookResult.data!.args;
const {error, data} = await dispatch(executeCommand(message, args));
if (error) {
if (error.sendMessage) {
return dispatch(submitPost(channelId, rootId, draft));
}
throw (error);
}
return {data: data!};
};
}
export type SubmitPostReturnType = CreatePostReturnType & SubmitCommandRerturnType & SubmitReactionReturnType;
export type OnSubmitOptions = {
ignoreSlash?: boolean;
afterSubmit?: (response: SubmitPostReturnType) => void;
afterOptimisticSubmit?: () => void;
keepDraft?: boolean;
};
export function onSubmit(
channelId: string,
rootId: string,
draft: PostDraft,
options: OnSubmitOptions,
schedulingInfo?: SchedulingInfo,
): ActionFuncAsync<SubmitPostReturnType> {
return async (dispatch, getState) => {
const {message} = draft;
const state = getState();
dispatch(addMessageIntoHistory(message));
if (!schedulingInfo && !options.ignoreSlash) {
const isReaction = Utils.REACTION_PATTERN.exec(message);
const emojis = getCustomEmojisByName(state);
const emojiMap = new EmojiMap(emojis);
if (isReaction && emojiMap.has(isReaction[2])) {
const latestPostId = getLatestInteractablePostId(state, channelId, rootId);
if (latestPostId) {
return dispatch(PostActions.submitReaction(latestPostId, isReaction[1], isReaction[2]));
}
return {error: new Error('no post to react to')};
}
if (message.indexOf('/') === 0 && !options.ignoreSlash) {
return dispatch(submitCommand(channelId, rootId, draft));
}
}
return dispatch(submitPost(channelId, rootId, draft, options.afterSubmit, schedulingInfo, options));
};
}
export function editLatestPost(channelId: string, rootId = ''): ActionFunc<boolean, GlobalState> {
return (dispatch, getState) => {
const state = getState();
const lastPostId = getLatestPostToEdit(state, channelId, rootId);
if (!lastPostId) {
return {data: false};
}
return dispatch(PostActions.setEditingPost(
lastPostId,
rootId ? 'reply_textbox' : 'post_textbox',
Boolean(rootId),
));
};
}

View File

@@ -0,0 +1,189 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from 'mattermost-redux/client';
import {Posts, Preferences} from 'mattermost-redux/constants';
import {getPreferenceKey} from 'mattermost-redux/utils/preference_utils';
import {setGlobalItem} from 'actions/storage';
import mockStore from 'tests/test_store';
import {StoragePrefixes} from 'utils/constants';
import type {PostDraft} from 'types/store/draft';
import {removeDraft, setGlobalDraftSource, updateDraft} from './drafts';
jest.mock('mattermost-redux/client', () => {
const original = jest.requireActual('mattermost-redux/client');
return {
...original,
Client4: {
...original.Client4,
deleteDraft: jest.fn().mockResolvedValue([]),
upsertDraft: jest.fn().mockResolvedValue([]),
},
};
});
jest.mock('actions/storage', () => {
const original = jest.requireActual('actions/storage');
return {
...original,
setGlobalItem: (...args: any) => ({type: 'MOCK_SET_GLOBAL_ITEM', args}),
actionOnGlobalItemsWithPrefix: (...args: any) => ({type: 'MOCK_ACTION_ON_GLOBAL_ITEMS_WITH_PREFIX', args}),
};
});
const rootId = 'fc234c34c23';
const currentUserId = '34jrnfj43';
const teamId = '4j5nmn4j3';
const channelId = '4j5j4k3k34j4';
const latestPostId = 'latestPostId';
const enabledSyncedDraftsKey = getPreferenceKey(Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.ADVANCED_SYNC_DRAFTS);
describe('draft actions', () => {
const initialState = {
entities: {
posts: {
posts: {
[latestPostId]: {
id: latestPostId,
user_id: currentUserId,
message: 'test msg',
channel_id: channelId,
root_id: rootId,
create_at: 42,
},
[rootId]: {
id: rootId,
user_id: currentUserId,
message: 'root msg',
channel_id: channelId,
root_id: '',
create_at: 2,
},
},
postsInChannel: {
[channelId]: [
{order: [latestPostId], recent: true},
],
},
postsInThread: {
[rootId]: [latestPostId],
},
messagesHistory: {
index: {
[Posts.MESSAGE_TYPES.COMMENT]: 0,
},
messages: ['test message'],
},
},
preferences: {
myPreferences: {
[enabledSyncedDraftsKey]: {value: 'true'},
},
},
users: {
currentUserId,
profiles: {
[currentUserId]: {id: currentUserId},
},
},
teams: {
currentTeamId: teamId,
},
emojis: {
customEmoji: {},
},
general: {
config: {
EnableCustomEmoji: 'true',
AllowSyncedDrafts: 'true',
},
},
channels: {
channels: {},
},
},
storage: {
storage: {
[`${StoragePrefixes.COMMENT_DRAFT}${rootId}`]: {
value: {
message: '',
fileInfos: [],
uploadsInProgress: [],
channelId,
rootId,
},
timestamp: new Date(),
},
},
},
websocket: {
connectionId: '',
},
};
let store: any;
const key = StoragePrefixes.DRAFT + channelId;
const upsertDraftSpy = jest.spyOn(Client4, 'upsertDraft');
const deleteDraftSpy = jest.spyOn(Client4, 'deleteDraft');
beforeEach(() => {
store = mockStore(initialState);
});
describe('updateDraft', () => {
const draft = {message: 'test', channelId, fileInfos: [{id: 1}], uploadsInProgress: [2, 3]} as unknown as PostDraft;
it('calls setGlobalItem action correctly', async () => {
jest.useFakeTimers();
jest.setSystemTime(42);
await store.dispatch(updateDraft(key, draft, '', false));
const testStore = mockStore(initialState);
const expectedKey = StoragePrefixes.DRAFT + channelId;
testStore.dispatch(setGlobalItem(expectedKey, {
...draft,
createAt: 42,
updateAt: 42,
}));
testStore.dispatch(setGlobalDraftSource(expectedKey, false));
expect(store.getActions()).toEqual(testStore.getActions());
jest.useRealTimers();
});
it('calls upsertDraft correctly', async () => {
await store.dispatch(updateDraft(key, draft, '', true));
expect(upsertDraftSpy).toHaveBeenCalled();
});
});
describe('removeDraft', () => {
it('calls setGlobalItem action correctly', async () => {
await store.dispatch(removeDraft(key, channelId));
const testStore = mockStore(initialState);
testStore.dispatch(setGlobalItem(StoragePrefixes.DRAFT + channelId, {
message: '',
fileInfos: [],
uploadsInProgress: [],
metadata: {},
}));
expect(store.getActions()).toEqual(testStore.getActions());
});
it('calls upsertDraft correctly', async () => {
await store.dispatch(removeDraft(key, channelId));
expect(deleteDraftSpy).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,190 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {batchActions} from 'redux-batched-actions';
import type {Draft as ServerDraft} from '@mattermost/types/drafts';
import type {FileInfo} from '@mattermost/types/files';
import type {PostMetadata, PostPriorityMetadata} from '@mattermost/types/posts';
import type {UserProfile} from '@mattermost/types/users';
import {Client4} from 'mattermost-redux/client';
import {syncedDraftsAreAllowedAndEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {setGlobalItem} from 'actions/storage';
import {makeGetDrafts} from 'selectors/drafts';
import {getConnectionId} from 'selectors/general';
import {getGlobalItem} from 'selectors/storage';
import {ActionTypes, StoragePrefixes} from 'utils/constants';
import type {ActionFunc, ActionFuncAsync, GlobalState} from 'types/store';
import type {PostDraft} from 'types/store/draft';
type Draft = {
key: keyof GlobalState['storage']['storage'];
value: PostDraft;
timestamp: Date;
};
/**
* Gets drafts stored on the server and reconciles them with any locally stored drafts.
* @param teamId Only drafts for the given teamId will be fetched.
*/
export function getDrafts(teamId: string): ActionFuncAsync<boolean> {
const getLocalDrafts = makeGetDrafts(false);
return async (dispatch, getState) => {
const state = getState();
let serverDrafts: Draft[] = [];
try {
serverDrafts = (await Client4.getUserDrafts(teamId)).map((draft) => transformServerDraft(draft));
} catch (error) {
return {data: false, error};
}
const localDrafts = getLocalDrafts(state);
const drafts = [...serverDrafts, ...localDrafts];
// Reconcile drafts and only keep the latest version of a draft.
const draftsMap = new Map(drafts.map((draft) => [draft.key, draft]));
drafts.forEach((draft) => {
const currentDraft = draftsMap.get(draft.key);
if (currentDraft && draft.timestamp > currentDraft.timestamp) {
draftsMap.set(draft.key, draft);
}
});
const actions = Array.from(draftsMap).map(([key, draft]) => {
return setGlobalItem(key, draft.value);
});
dispatch(batchActions(actions));
return {data: true};
};
}
export function removeDraft(key: string, channelId: string, rootId = ''): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
const state = getState();
dispatch(setGlobalItem(key, {message: '', fileInfos: [], uploadsInProgress: [], metadata: {}}));
if (syncedDraftsAreAllowedAndEnabled(state)) {
const connectionId = getConnectionId(getState());
try {
await Client4.deleteDraft(channelId, rootId, connectionId);
} catch (error) {
return {
data: false,
error,
};
}
}
return {data: true};
};
}
export function updateDraft(key: string, value: PostDraft | null, rootId = '', save = false): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
const state = getState();
let updatedValue: PostDraft | null = null;
if (value) {
const timestamp = new Date().getTime();
const data = getGlobalItem<Partial<PostDraft>>(state, key, {});
updatedValue = {
...value,
createAt: data.createAt || timestamp,
updateAt: timestamp,
};
}
dispatch(setGlobalDraft(key, updatedValue, false));
if (syncedDraftsAreAllowedAndEnabled(state) && save && updatedValue) {
const connectionId = getConnectionId(state);
const userId = getCurrentUserId(state);
try {
await upsertDraft(updatedValue, userId, rootId, connectionId);
} catch (error) {
return {data: false, error};
}
}
return {data: true};
};
}
function upsertDraft(draft: PostDraft, userId: UserProfile['id'], rootId = '', connectionId: string) {
const fileIds = draft.fileInfos.map((file) => file.id);
const newDraft = {
create_at: draft.createAt || 0,
update_at: draft.updateAt || 0,
delete_at: 0,
user_id: userId,
channel_id: draft.channelId,
root_id: draft.rootId || rootId,
message: draft.message,
type: draft.type,
props: draft.props,
file_ids: fileIds,
priority: draft.metadata?.priority as PostPriorityMetadata,
};
return Client4.upsertDraft(newDraft, connectionId);
}
export function setGlobalDraft(key: string, value: PostDraft | null, isRemote: boolean): ActionFunc {
return (dispatch) => {
dispatch(setGlobalItem(key, value));
dispatch(setGlobalDraftSource(key, isRemote));
return {data: true};
};
}
export function setGlobalDraftSource(key: string, isRemote: boolean) {
return {
type: ActionTypes.SET_DRAFT_SOURCE,
data: {
key,
isRemote,
},
};
}
export function transformServerDraft(draft: ServerDraft): Draft {
let key: Draft['key'] = `${StoragePrefixes.DRAFT}${draft.channel_id}`;
if (draft.root_id !== '') {
key = `${StoragePrefixes.COMMENT_DRAFT}${draft.root_id}`;
}
let fileInfos: FileInfo[] = [];
if (draft.metadata?.files) {
fileInfos = draft.metadata.files;
}
const metadata = (draft.metadata || {}) as PostMetadata;
if (draft.priority) {
metadata.priority = draft.priority;
}
return {
key,
timestamp: new Date(draft.update_at),
value: {
message: draft.message,
fileInfos,
props: draft.props,
uploadsInProgress: [],
channelId: draft.channel_id,
rootId: draft.root_id,
createAt: draft.create_at,
updateAt: draft.update_at,
type: draft.type,
metadata,
show: true,
},
};
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {GroupSearchParams} from '@mattermost/types/groups';
import {searchGroups} from 'mattermost-redux/actions/groups';
import Permissions from 'mattermost-redux/constants/permissions';
import {searchAssociatedGroupsForReferenceLocal} from 'mattermost-redux/selectors/entities/groups';
import {isCustomGroupsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
import type {ActionFuncAsync} from 'types/store';
export function searchAssociatedGroupsForReference(
prefix: string,
teamId: string,
channelId: string,
opts: Omit<GroupSearchParams, 'q'> = {},
): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
if (!haveIChannelPermission(state,
teamId,
channelId,
Permissions.USE_GROUP_MENTIONS,
)) {
return {data: []};
}
if (isCustomGroupsEnabled(state)) {
const params = {
q: prefix,
filter_allow_reference: true,
page: 0,
per_page: 60,
include_member_count: true,
include_channel_member_count: channelId,
...opts,
};
await dispatch(searchGroups(params));
}
return {data: searchAssociatedGroupsForReferenceLocal(state, prefix, teamId, channelId)};
};
}

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {MockStoreEnhanced} from 'redux-mock-store';
import {close, open, toggle} from 'actions/views/lhs';
import configureStore from 'store';
import mockStore from 'tests/test_store';
import {ActionTypes} from 'utils/constants';
import type {GlobalState} from 'types/store';
import * as Actions from './lhs';
describe('lhs view actions', () => {
const initialState = {
views: {
lhs: {
isOpen: false,
},
},
};
let store: MockStoreEnhanced<GlobalState>;
beforeEach(() => {
store = mockStore(initialState);
});
it('toggle dispatches the right action', () => {
store.dispatch(toggle());
const compareStore = mockStore(initialState);
compareStore.dispatch({
type: ActionTypes.TOGGLE_LHS,
});
expect(store.getActions()).toEqual(compareStore.getActions());
});
it('open dispatches the right action', () => {
store.dispatch(open());
const compareStore = mockStore(initialState);
compareStore.dispatch({
type: ActionTypes.OPEN_LHS,
});
expect(store.getActions()).toEqual(compareStore.getActions());
});
it('close dispatches the right action', () => {
store.dispatch(close());
const compareStore = mockStore(initialState);
compareStore.dispatch({
type: ActionTypes.CLOSE_LHS,
});
expect(store.getActions()).toEqual(compareStore.getActions());
});
test('selectStaticPage', async () => {
const testStore = configureStore({...initialState});
await testStore.dispatch(Actions.selectStaticPage('test'));
expect(testStore.getState().views.lhs.currentStaticPageId).toEqual('test');
});
});

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {selectChannel} from 'mattermost-redux/actions/channels';
import {getCurrentRelativeTeamUrl} from 'mattermost-redux/selectors/entities/teams';
import {SidebarSize} from 'components/resizable_sidebar/constants';
import {getHistory} from 'utils/browser_history';
import Constants, {ActionTypes} from 'utils/constants';
import type {ActionFunc, ThunkActionFunc} from 'types/store';
import {LhsItemType} from 'types/store/lhs';
export const setLhsSize = (sidebarSize?: SidebarSize) => {
let newSidebarSize = sidebarSize;
if (!sidebarSize) {
const width = window.innerWidth;
switch (true) {
case width <= Constants.SMALL_SIDEBAR_BREAKPOINT: {
newSidebarSize = SidebarSize.SMALL;
break;
}
case width > Constants.SMALL_SIDEBAR_BREAKPOINT && width <= Constants.MEDIUM_SIDEBAR_BREAKPOINT: {
newSidebarSize = SidebarSize.MEDIUM;
break;
}
case width > Constants.MEDIUM_SIDEBAR_BREAKPOINT && width <= Constants.LARGE_SIDEBAR_BREAKPOINT: {
newSidebarSize = SidebarSize.LARGE;
break;
}
default: {
newSidebarSize = SidebarSize.XLARGE;
}
}
}
return {
type: ActionTypes.SET_LHS_SIZE,
size: newSidebarSize,
};
};
export const toggle = () => ({
type: ActionTypes.TOGGLE_LHS,
});
export const open = () => ({
type: ActionTypes.OPEN_LHS,
});
export const close = () => ({
type: ActionTypes.CLOSE_LHS,
});
export const selectStaticPage = (itemId: string) => ({
type: ActionTypes.SELECT_STATIC_PAGE,
data: itemId,
});
export const selectLhsItem = (type: LhsItemType, id?: string): ThunkActionFunc<unknown> => {
return (dispatch) => {
switch (type) {
case LhsItemType.Channel:
dispatch(selectChannel(id || ''));
dispatch(selectStaticPage(''));
break;
case LhsItemType.Page:
dispatch(selectChannel(''));
dispatch(selectStaticPage(id || ''));
break;
case LhsItemType.None:
dispatch(selectChannel(''));
dispatch(selectStaticPage(''));
break;
default:
throw new Error('Unknown LHS item type: ' + type);
}
};
};
export function switchToLhsStaticPage(id: string): ActionFunc<boolean> {
return (dispatch, getState) => {
const state = getState();
const teamUrl = getCurrentRelativeTeamUrl(state);
getHistory().push(`${teamUrl}/${id}`);
return {data: true};
};
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import nock from 'nock';
import {Client4} from 'mattermost-redux/client';
import {
login,
loginById,
} from 'actions/views/login';
import configureStore from 'store';
import TestHelper from 'packages/mattermost-redux/test/test_helper';
describe('actions/views/login', () => {
describe('login', () => {
test('should return successful when login is successful', async () => {
const store = configureStore();
TestHelper.initBasic(Client4);
nock(Client4.getBaseRoute()).
post('/users/login').
reply(200, {...TestHelper.basicUser});
const result = await store.dispatch(login('user', 'password', ''));
expect(result).toEqual({data: true});
});
test('should return error when when login fails', async () => {
const store = configureStore();
TestHelper.initBasic(Client4);
nock(Client4.getBaseRoute()).
post('/users/login').
reply(500, {});
const result = await store.dispatch(login('user', 'password', ''));
expect(Object.keys(result)[0]).toEqual('error');
});
});
describe('loginById', () => {
test('should return successful when login is successful', async () => {
const store = configureStore();
TestHelper.initBasic(Client4);
nock(Client4.getBaseRoute()).
post('/users/login').
reply(200, {...TestHelper.basicUser});
const result = await store.dispatch(loginById('userId', 'password'));
expect(result).toEqual({data: true});
});
test('should return error when when login fails', async () => {
const store = configureStore();
TestHelper.initBasic(Client4);
nock(Client4.getBaseRoute()).
post('/users/login').
reply(500, {});
const result = await store.dispatch(loginById('userId', 'password'));
expect(Object.keys(result)[0]).toEqual('error');
});
});
});

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {batchActions} from 'redux-batched-actions';
import type {ServerError} from '@mattermost/types/errors';
import type {UserProfile} from '@mattermost/types/users';
import {UserTypes} from 'mattermost-redux/action_types';
import {logError} from 'mattermost-redux/actions/errors';
import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles';
import {Client4} from 'mattermost-redux/client';
import type {ActionFuncAsync, DispatchFunc} from 'types/store';
async function handleLoginSuccess(dispatch: DispatchFunc, loggedInUserProfile: UserProfile) {
dispatch(
batchActions([
{
type: UserTypes.LOGIN_SUCCESS,
},
{
type: UserTypes.RECEIVED_ME,
data: loggedInUserProfile,
},
]),
);
dispatch(loadRolesIfNeeded(loggedInUserProfile.roles.split(' ')));
}
async function performLogin(
dispatch: DispatchFunc,
loginFunc: () => Promise<UserProfile>,
) {
dispatch({type: UserTypes.LOGIN_REQUEST, data: null});
try {
// This is partial user profile we received when we login. We still need to make getMe for complete user profile.
const loggedInUserProfile = await loginFunc();
await handleLoginSuccess(dispatch, loggedInUserProfile);
} catch (error) {
dispatch({
type: UserTypes.LOGIN_FAILURE,
error,
});
dispatch(logError(error as ServerError));
return {error};
}
return {data: true};
}
export function login(loginId: string, password: string, mfaToken = ''): ActionFuncAsync {
return async (dispatch) => {
return performLogin(dispatch, () => Client4.login(loginId, password, mfaToken));
};
}
export function loginWithDesktopToken(token: string): ActionFuncAsync {
return async (dispatch) => {
return performLogin(dispatch, () => Client4.loginWithDesktopToken(token));
};
}
export function loginById(id: string, password: string): ActionFuncAsync {
return async (dispatch) => {
return performLogin(dispatch, () => Client4.loginById(id, password, ''));
};
}
export function getUserLoginType(loginId: string): ActionFuncAsync<{auth_service: 'magic_link' | ''; is_deactivated: boolean}> {
return async (dispatch) => {
try {
const response = await Client4.getUserLoginType(loginId);
return {data: {auth_service: response.auth_service ?? '', is_deactivated: response.is_deactivated ?? false}};
} catch (error) {
dispatch(logError(error as ServerError));
return {error};
}
};
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
jest.mock('mattermost-redux/actions/users');
import * as UserActions from 'mattermost-redux/actions/users';
import configureStore from 'tests/test_store';
import {
activateMfa,
deactivateMfa,
generateMfaSecret,
} from './mfa';
describe('actions/views/mfa', () => {
describe('activateMfa', () => {
it('should call updateUserMfa to enable MFA for the current user', async () => {
const currentUserId = 'abcd';
const store = configureStore({
entities: {
users: {
currentUserId,
},
},
});
jest.mocked(UserActions.updateUserMfa).mockImplementation(() => async () => ({data: true}));
const code = 'mfa code';
await store.dispatch(activateMfa(code));
expect(UserActions.updateUserMfa).toHaveBeenCalledWith(currentUserId, true, code);
});
});
describe('deactivateMfa', () => {
it('should call updateUserMfa to disable MFA for the current user', async () => {
const currentUserId = 'abcd';
const store = configureStore({
entities: {
users: {
currentUserId,
},
},
});
jest.mocked(UserActions.updateUserMfa).mockImplementation(() => async () => ({data: true}));
await store.dispatch(deactivateMfa());
expect(UserActions.updateUserMfa).toHaveBeenCalledWith(currentUserId, false);
});
});
describe('generateMfaSecret', () => {
it('should call generateMfaSecret for the current user', async () => {
const currentUserId = 'abcd';
const store = configureStore({
entities: {
users: {
currentUserId,
},
},
});
jest.mocked(UserActions.generateMfaSecret).mockImplementation(() => async () => ({data: {secret: '1234', qr_code: ''}}));
await store.dispatch(generateMfaSecret());
expect(UserActions.generateMfaSecret).toHaveBeenCalledWith(currentUserId);
});
});
});

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {MfaSecret} from '@mattermost/types/mfa';
import * as UserActions from 'mattermost-redux/actions/users';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import type {ActionFuncAsync} from 'types/store';
export function activateMfa(code: string): ActionFuncAsync {
return (dispatch, getState) => {
const currentUserId = getCurrentUserId(getState());
return dispatch(UserActions.updateUserMfa(currentUserId, true, code));
};
}
export function deactivateMfa(): ActionFuncAsync {
return (dispatch, getState) => {
const currentUserId = getCurrentUserId(getState());
return dispatch(UserActions.updateUserMfa(currentUserId, false));
};
}
export function generateMfaSecret(): ActionFuncAsync<MfaSecret> {
return (dispatch, getState) => {
const currentUserId = getCurrentUserId(getState());
return dispatch(UserActions.generateMfaSecret(currentUserId));
};
}

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Modal} from 'react-bootstrap';
import {openModal, closeModal} from 'actions/views/modals';
import mockStore from 'tests/test_store';
import {ActionTypes, ModalIdentifiers} from 'utils/constants';
const TestModal = () => (
<Modal
show={true}
onHide={jest.fn()}
>
<Modal.Header closeButton={true}/>
<Modal.Body/>
</Modal>
);
describe('modals view actions', () => {
let store: ReturnType<typeof mockStore>;
beforeEach(() => {
store = mockStore();
});
test(ActionTypes.MODAL_OPEN, () => {
const dialogType = TestModal;
const dialogProps = {
test: true,
};
const modalData = {
type: ActionTypes.MODAL_OPEN,
modalId: ModalIdentifiers.DELETE_CHANNEL,
dialogType,
dialogProps,
};
store.dispatch(openModal(modalData));
const action = {
type: ActionTypes.MODAL_OPEN,
modalId: ModalIdentifiers.DELETE_CHANNEL,
dialogType,
dialogProps,
};
expect(store.getActions()).toEqual([action]);
});
test(ActionTypes.MODAL_CLOSE, () => {
store.dispatch(closeModal(ModalIdentifiers.DELETE_CHANNEL));
const action = {
type: ActionTypes.MODAL_CLOSE,
modalId: ModalIdentifiers.DELETE_CHANNEL,
};
expect(store.getActions()).toEqual([action]);
});
});

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ActionTypes} from 'utils/constants';
import type {ModalData} from 'types/actions';
export function openModal<P>(modalData: ModalData<P>) {
return {
type: ActionTypes.MODAL_OPEN,
modalId: modalData.modalId,
dialogProps: modalData.dialogProps,
dialogType: modalData.dialogType,
};
}
export type CloseModalType = {
type: string;
modalId: string;
};
export function closeModal(modalId: string) {
return {
type: ActionTypes.MODAL_CLOSE,
modalId,
};
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ActionTypes} from 'utils/constants';
export function dismissNotice(type: string) {
return {
type: ActionTypes.DISMISS_NOTICE,
data: type,
};
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getCurrentUser, getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
import {getCurrentTeamId, getTeam} from 'mattermost-redux/selectors/entities/teams';
import {getTeamRedirectChannelIfIsAccesible} from 'actions/global_actions';
import LocalStorageStore from 'stores/local_storage_store';
import InvitationModal from 'components/invitation_modal';
import {getHistory} from 'utils/browser_history';
import {ActionTypes, Constants, ModalIdentifiers} from 'utils/constants';
import type {ActionFunc, ActionFuncAsync} from 'types/store';
import {openModal} from './modals';
export function switchToChannels(): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
const state = getState();
const currentUserId = getCurrentUserId(state);
const user = getCurrentUser(state);
const teamId = getCurrentTeamId(state) || LocalStorageStore.getPreviousTeamId(currentUserId);
const team = getTeam(state, teamId || '');
if (!team) {
return {data: false};
}
const channel = await getTeamRedirectChannelIfIsAccesible(user, team);
const channelName = channel?.name || Constants.DEFAULT_CHANNEL;
getHistory().push(`/${team.name}/channels/${channelName}`);
return {data: true};
};
}
export function openInvitationsModal(timeout = 1): ActionFunc {
return (dispatch) => {
dispatch(switchToChannels());
setTimeout(() => {
dispatch(openModal({
modalId: ModalIdentifiers.INVITATION,
dialogType: InvitationModal,
}));
}, timeout);
return {data: true};
};
}
export function setShowOnboardingTaskCompletion(open: boolean) {
return {
type: ActionTypes.SHOW_ONBOARDING_TASK_COMPLETION,
open,
};
}
export function setShowOnboardingCompleteProfileTour(open: boolean) {
return {
type: ActionTypes.SHOW_ONBOARDING_COMPLETE_PROFILE_TOUR,
open,
};
}
export function setShowOnboardingVisitConsoleTour(open: boolean) {
return {
type: ActionTypes.SHOW_ONBOARDING_VISIT_CONSOLE_TOUR,
open,
};
}

View File

@@ -0,0 +1,123 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Channel} from '@mattermost/types/channels';
import type {Post, PostMetadata} from '@mattermost/types/posts';
import {logError} from 'mattermost-redux/actions/errors';
import * as PostActions from 'mattermost-redux/actions/posts';
import {Permissions} from 'mattermost-redux/constants';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {getAssociatedGroupsForReferenceByMention} from 'mattermost-redux/selectors/entities/groups';
import {isCustomGroupsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {haveIChannelPermission, haveICurrentChannelPermission} from 'mattermost-redux/selectors/entities/roles';
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getPermalinkURL} from 'selectors/urls';
import {ActionTypes, AnnouncementBarTypes} from 'utils/constants';
import {containsAtChannel, groupsMentionedInText} from 'utils/post_utils';
import {getSiteURL} from 'utils/url';
import {getTimestamp} from 'utils/utils';
import type {ActionFuncAsync} from 'types/store';
import {runMessageWillBePostedHooks} from '../hooks';
export function editPost(post: Post): ActionFuncAsync<Post> {
return async (dispatch) => {
const result = await dispatch(PostActions.editPost(post));
// Send to error bar if it's an edit post error about time limit.
if (result.error && result.error.server_error_id === 'api.post.update_post.permissions_time_limit.app_error') {
dispatch(logError({type: AnnouncementBarTypes.ANNOUNCEMENT, message: result.error.message}));
}
return result;
};
}
export function forwardPost(post: Post, channel: Channel, message = ''): ActionFuncAsync<PostActions.CreatePostReturnType> {
return async (dispatch, getState) => {
const state = getState();
const channelId = channel.id;
const currentUserId = getCurrentUserId(state);
const currentTeam = getCurrentTeam(state);
if (!currentTeam) {
return {};
}
const relativePermaLink = getPermalinkURL(state, currentTeam.id, post.id);
const permaLink = `${getSiteURL()}${relativePermaLink}`;
const license = getLicense(state);
const isLDAPEnabled = license?.IsLicensed === 'true' && license?.LDAPGroups === 'true';
const useLDAPGroupMentions = isLDAPEnabled && haveICurrentChannelPermission(state, Permissions.USE_GROUP_MENTIONS);
const useChannelMentions = haveIChannelPermission(state, channel.team_id, channelId, Permissions.USE_CHANNEL_MENTIONS);
const useCustomGroupMentions = isCustomGroupsEnabled(state) && haveICurrentChannelPermission(state, Permissions.USE_GROUP_MENTIONS);
const groupsWithAllowReference = useLDAPGroupMentions || useCustomGroupMentions ? getAssociatedGroupsForReferenceByMention(state, currentTeam.id, channelId) : null;
let newPost = {} as Post;
newPost.channel_id = channelId;
const time = getTimestamp();
const userId = currentUserId;
newPost.message = message ? `${message}\n${permaLink}` : permaLink;
newPost.pending_post_id = `${userId}:${time}`;
newPost.user_id = userId;
newPost.create_at = time;
newPost.metadata = {} as PostMetadata;
newPost.props = {};
if (!useChannelMentions && containsAtChannel(newPost.message, {checkAllMentions: true})) {
newPost.props.mentionHighlightDisabled = true;
}
if (!useLDAPGroupMentions && !useCustomGroupMentions && groupsMentionedInText(newPost.message, groupsWithAllowReference)) {
newPost.props.disable_group_highlight = true;
}
const hookResult = await dispatch(runMessageWillBePostedHooks(newPost));
if (hookResult.error) {
return hookResult as PostActions.CreatePostReturnType;
}
newPost = hookResult.data!;
return dispatch(PostActions.createPost(newPost, []));
};
}
export function selectAttachmentMenuAction(
postId: string,
actionId: string,
cookie: string,
dataSource: string | undefined,
text: string,
value: string,
): ActionFuncAsync {
return async (dispatch) => {
dispatch({
type: ActionTypes.SELECT_ATTACHMENT_MENU_ACTION,
data: {
postId,
actions: {
[actionId]: {
text,
value,
},
},
},
});
dispatch(PostActions.doPostActionWithCookie(postId, actionId, cookie, value, undefined, ''));
return {data: true};
};
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ActionTypes} from 'utils/constants';
export function setProductMenuSwitcherOpen(open: boolean) {
return {
type: ActionTypes.SET_PRODUCT_SWITCHER_OPEN,
open,
};
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getChannelMember} from 'mattermost-redux/actions/channels';
import {getTeamMember} from 'mattermost-redux/actions/teams';
import testConfigureStore from 'tests/test_store';
import {getMembershipForEntities} from './profile_popover';
jest.mock('mattermost-redux/actions/channels', () => ({
getChannelMember: jest.fn(() => ({type: 'GET_CHANNEL_MEMBER'})),
}));
jest.mock('mattermost-redux/actions/teams', () => ({
getTeamMember: jest.fn(() => ({type: 'GET_TEAM_MEMBER'})),
}));
describe('getMembershipForEntities', () => {
const baseState = {
entities: {
channels: {
membersInChannel: {},
},
teams: {
membersInTeam: {},
},
},
};
const userId = 'userId';
const teamId = 'teamId';
const channelId = 'channelId';
const getChannelMemberMock = getChannelMember as jest.Mock;
const getTeamMemberMock = getTeamMember as jest.Mock;
test('should only fetch team member in a DM/GM', () => {
const store = testConfigureStore(baseState);
store.dispatch(getMembershipForEntities(teamId, userId, ''));
expect(getChannelMemberMock).not.toHaveBeenCalled();
expect(getTeamMemberMock).toHaveBeenCalledWith(teamId, userId);
});
test('should fetch both team and channel member for regular channels', () => {
const store = testConfigureStore(baseState);
store.dispatch(getMembershipForEntities(teamId, userId, channelId));
expect(getChannelMemberMock).toHaveBeenCalledWith(channelId, userId);
expect(getTeamMemberMock).toHaveBeenCalledWith(teamId, userId);
});
});

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getChannelMember} from 'mattermost-redux/actions/channels';
import {getTeamMember} from 'mattermost-redux/actions/teams';
import type {ThunkActionFunc} from 'types/store';
export function getMembershipForEntities(teamId: string, userId: string, channelId?: string): ThunkActionFunc<unknown> {
return (dispatch) => {
return Promise.all([
dispatch(getTeamMember(teamId, userId)),
channelId && dispatch(getChannelMember(channelId, userId)),
]);
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,680 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import debounce from 'lodash/debounce';
import type {AnyAction} from 'redux';
import {batchActions} from 'redux-batched-actions';
import type {Post} from '@mattermost/types/posts';
import {SearchTypes} from 'mattermost-redux/action_types';
import {getChannel} from 'mattermost-redux/actions/channels';
import {getPostsByIds, getPost as fetchPost} from 'mattermost-redux/actions/posts';
import {
clearSearch,
getFlaggedPosts,
getPinnedPosts,
searchPostsWithParams,
searchFilesWithParams,
} from 'mattermost-redux/actions/search';
import {getCurrentChannelId, getCurrentChannelNameForSearchShortcut, getChannel as getChannelSelector} from 'mattermost-redux/selectors/entities/channels';
import {getLatestInteractablePostId, getPost} from 'mattermost-redux/selectors/entities/posts';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone';
import {getCurrentUserMentionKeys} from 'mattermost-redux/selectors/entities/users';
import {
getSearchType,
getSearchTerms,
getRhsState,
getPluggableId,
getFilesSearchExtFilter,
getPreviousRhsState,
getSearchTeam,
} from 'selectors/rhs';
import {SidebarSize} from 'components/resizable_sidebar/constants';
import {ActionTypes, RHSStates, Constants} from 'utils/constants';
import {Mark, Measure, measureAndReport} from 'utils/performance_telemetry';
import {getBrowserUtcOffset, getUtcOffsetForTimeZone} from 'utils/timezone';
import type {ActionFunc, ActionFuncAsync, ThunkActionFunc} from 'types/store';
import type {RhsState} from 'types/store/rhs';
function selectPostWithPreviousState(post: Post, previousRhsState?: RhsState): ActionFunc<boolean> {
return (dispatch, getState) => {
const state = getState();
dispatch(selectPost(post, previousRhsState || getRhsState(state)));
return {data: true};
};
}
function selectPostCardFromRightHandSideSearchWithPreviousState(post: Post, previousRhsState?: RhsState): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
const state = getState();
dispatch({
type: ActionTypes.SELECT_POST_CARD,
postId: post.id,
channelId: post.channel_id,
previousRhsState: previousRhsState || getRhsState(state),
});
return {data: true};
};
}
export function updateRhsState(rhsState: string, channelId?: string, previousRhsState?: RhsState): ActionFunc<boolean> {
return (dispatch, getState) => {
const action: AnyAction = {
type: ActionTypes.UPDATE_RHS_STATE,
state: rhsState,
};
if ([
RHSStates.PIN,
RHSStates.CHANNEL_FILES,
RHSStates.CHANNEL_INFO,
RHSStates.CHANNEL_MEMBERS,
].includes(rhsState)) {
action.channelId = channelId || getCurrentChannelId(getState());
}
if (previousRhsState) {
action.previousRhsState = previousRhsState;
}
dispatch(action);
return {data: true};
};
}
export function openShowEditHistory(post: Post) {
return {
type: ActionTypes.UPDATE_RHS_STATE,
state: RHSStates.EDIT_HISTORY,
postId: post.id,
channelId: post.channel_id,
timestamp: Date.now(),
};
}
export function goBack(): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
const prevState = getPreviousRhsState(getState());
const defaultTab = 'channel-info';
dispatch({
type: ActionTypes.RHS_GO_BACK,
state: prevState || defaultTab,
});
return {data: true};
};
}
export function selectPostFromRightHandSideSearch(post: Post) {
return selectPostWithPreviousState(post);
}
export function selectPostFromRightHandSideSearchByPostId(postId: string): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
const post = getPost(getState(), postId);
return dispatch(selectPostFromRightHandSideSearch(post));
};
}
export function replyToLatestPostInChannel(channelId: string): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
const state = getState();
const postId = getLatestInteractablePostId(state, channelId);
return dispatch(selectPostFromRightHandSideSearchByPostId(postId));
};
}
export function updateSearchTerms(terms: string) {
return {
type: ActionTypes.UPDATE_RHS_SEARCH_TERMS,
terms,
};
}
export function updateSearchTeam(teamId: string | null) {
return {
type: ActionTypes.UPDATE_RHS_SEARCH_TEAM,
teamId,
};
}
export function setRhsSize(rhsSize?: SidebarSize) {
let newSidebarSize = rhsSize;
if (!newSidebarSize) {
const width = window.innerWidth;
switch (true) {
case width <= Constants.SMALL_SIDEBAR_BREAKPOINT: {
newSidebarSize = SidebarSize.SMALL;
break;
}
case width > Constants.SMALL_SIDEBAR_BREAKPOINT && width <= Constants.MEDIUM_SIDEBAR_BREAKPOINT: {
newSidebarSize = SidebarSize.MEDIUM;
break;
}
case width > Constants.MEDIUM_SIDEBAR_BREAKPOINT && width <= Constants.LARGE_SIDEBAR_BREAKPOINT: {
newSidebarSize = SidebarSize.LARGE;
break;
}
default: {
newSidebarSize = SidebarSize.XLARGE;
}
}
}
return {
type: ActionTypes.SET_RHS_SIZE,
size: newSidebarSize,
};
}
export function updateSearchTermsForShortcut(): ThunkActionFunc<unknown> {
return (dispatch, getState) => {
const currentChannelName = getCurrentChannelNameForSearchShortcut(getState());
return dispatch(updateSearchTerms(`in:${currentChannelName} `));
};
}
export function updateSearchType(searchType: string) {
return {
type: ActionTypes.UPDATE_RHS_SEARCH_TYPE,
searchType,
};
}
function updateSearchResultsTerms(terms: string) {
return {
type: ActionTypes.UPDATE_RHS_SEARCH_RESULTS_TERMS,
terms,
};
}
function updateSearchResultsType(searchType: string) {
return {
type: ActionTypes.UPDATE_RHS_SEARCH_RESULTS_TYPE,
searchType,
};
}
export function performSearch(terms: string, teamId: string, isMentionSearch?: boolean): ThunkActionFunc<unknown> {
return (dispatch, getState) => {
let searchTerms = terms;
const extensionsFilters = getFilesSearchExtFilter(getState());
const extensions = extensionsFilters?.map((ext) => `ext:${ext}`).join(' ');
let termsWithExtensionsFilters = searchTerms;
if (extensions?.trim().length > 0) {
termsWithExtensionsFilters += ` ${extensions}`;
}
if (isMentionSearch) {
// For mentions, perform a specific search by quoting all terms.
// This ensures terms split by dashes or other symbols are treated as a single unit.
const termsArr = searchTerms.split(' ').filter((t) => Boolean(t && t.trim()));
for (let i = 0; i < termsArr.length; i++) {
termsArr[i] = `"${termsArr[i]}"`;
}
searchTerms = termsArr.join(' ');
}
// timezone offset in seconds
const userCurrentTimezone = getCurrentTimezone(getState());
const timezoneOffset = ((userCurrentTimezone && (userCurrentTimezone.length > 0)) ? getUtcOffsetForTimeZone(userCurrentTimezone) : getBrowserUtcOffset()) * 60;
const messagesPromise = dispatch(searchPostsWithParams(teamId, {terms: searchTerms, is_or_search: Boolean(isMentionSearch), include_deleted_channels: true, time_zone_offset: timezoneOffset, page: 0, per_page: 20}));
const filesPromise = dispatch(searchFilesWithParams(teamId, {terms: termsWithExtensionsFilters, is_or_search: Boolean(isMentionSearch), include_deleted_channels: true, time_zone_offset: timezoneOffset, page: 0, per_page: 20}));
return Promise.all([filesPromise, messagesPromise]);
};
}
export function filterFilesSearchByExt(extensions: string[]) {
return {
type: ActionTypes.SET_FILES_FILTER_BY_EXT,
data: extensions,
};
}
export function showSearchResults(isMentionSearch = false): ThunkActionFunc<unknown> {
return (dispatch, getState) => {
const state = getState();
const searchTerms = getSearchTerms(state);
let teamId = getSearchTeam(state);
const searchType = getSearchType(state);
if (isMentionSearch) {
dispatch(updateRhsState(RHSStates.MENTION));
teamId = '';
} else {
dispatch(updateRhsState(RHSStates.SEARCH));
}
dispatch(updateSearchResultsTerms(searchTerms));
dispatch(updateSearchResultsType(searchType));
return dispatch(performSearch(searchTerms, teamId));
};
}
export function showRHSPlugin(pluggableId: string) {
return {
type: ActionTypes.UPDATE_RHS_STATE,
state: RHSStates.PLUGIN,
pluggableId,
};
}
export function showChannelMembers(channelId: string, inEditingMode = false): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
const state = getState();
if (inEditingMode) {
await dispatch(setEditChannelMembers(true));
}
let previousRhsState = getRhsState(state);
if (previousRhsState === RHSStates.CHANNEL_MEMBERS) {
previousRhsState = getPreviousRhsState(state);
}
dispatch({
type: ActionTypes.UPDATE_RHS_STATE,
channelId,
state: RHSStates.CHANNEL_MEMBERS,
previousRhsState,
});
return {data: true};
};
}
export function hideRHSPlugin(pluggableId: string): ActionFunc<boolean> {
return (dispatch, getState) => {
const state = getState();
if (getPluggableId(state) === pluggableId) {
dispatch(closeRightHandSide());
}
return {data: true};
};
}
export function toggleRHSPlugin(pluggableId: string): ActionFunc<boolean> {
return (dispatch, getState) => {
const state = getState();
if (getPluggableId(state) === pluggableId) {
dispatch(hideRHSPlugin(pluggableId));
return {data: false};
}
dispatch(showRHSPlugin(pluggableId));
return {data: true};
};
}
export function showFlaggedPosts(): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const teamId = getCurrentTeamId(state);
dispatch({
type: ActionTypes.UPDATE_RHS_STATE,
state: RHSStates.FLAG,
});
const results = await dispatch(getFlaggedPosts());
let data;
if ('data' in results) {
data = results.data;
}
dispatch(batchActions([
{
type: SearchTypes.RECEIVED_SEARCH_POSTS,
data,
},
{
type: SearchTypes.RECEIVED_SEARCH_TERM,
data: {
teamId,
terms: null,
isOrSearch: false,
},
},
]));
return {data: true};
};
}
export function showPinnedPosts(channelId?: string): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
const state = getState();
const currentChannelId = getCurrentChannelId(state);
const teamId = getCurrentTeamId(state);
let previousRhsState = getRhsState(state);
if (previousRhsState === RHSStates.PIN) {
previousRhsState = getPreviousRhsState(state);
}
dispatch({
type: ActionTypes.UPDATE_RHS_STATE,
channelId: channelId || currentChannelId,
state: RHSStates.PIN,
previousRhsState,
});
const results = await dispatch(getPinnedPosts(channelId || currentChannelId));
let data;
if ('data' in results) {
data = results.data;
}
dispatch(batchActions([
{
type: SearchTypes.RECEIVED_SEARCH_POSTS,
data,
},
{
type: SearchTypes.RECEIVED_SEARCH_TERM,
data: {
teamId,
terms: null,
isOrSearch: false,
},
},
]));
return {data: true};
};
}
export function showChannelFiles(channelId: string): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
const state = getState();
const teamId = getSearchTeam(state);
let previousRhsState = getRhsState(state);
if (previousRhsState === RHSStates.CHANNEL_FILES) {
previousRhsState = getPreviousRhsState(state);
}
dispatch({
type: ActionTypes.UPDATE_RHS_STATE,
channelId,
state: RHSStates.CHANNEL_FILES,
previousRhsState,
});
dispatch(updateSearchType('files'));
const results = await dispatch(performSearch('channel:' + channelId, teamId));
const fileData = results instanceof Array ? results[0].data : null;
const missingPostIds: string[] = [];
if (fileData) {
Object.values(fileData.file_infos).forEach((file: any) => {
const postId = file?.post_id;
if (postId && !getPost(state, postId)) {
missingPostIds.push(postId);
}
});
}
if (missingPostIds.length > 0) {
await dispatch(getPostsByIds(missingPostIds));
}
let data: any;
if (results && results instanceof Array && results.length === 2 && 'data' in results[1]) {
data = results[1].data;
}
dispatch(batchActions([
{
type: SearchTypes.RECEIVED_SEARCH_POSTS,
data,
},
{
type: SearchTypes.RECEIVED_SEARCH_TERM,
data: {
teamId,
terms: null,
isOrSearch: false,
},
},
]));
return {data: true};
};
}
export function showMentions(): ActionFunc<boolean> {
return (dispatch, getState) => {
const termKeys = getCurrentUserMentionKeys(getState()).filter(({key}) => {
return key !== '@channel' && key !== '@all' && key !== '@here';
});
const terms = termKeys.map(({key}) => key).join(' ').trim() + ' ';
dispatch(performSearch(terms, '', true));
dispatch(batchActions([
{
type: ActionTypes.UPDATE_RHS_SEARCH_TERMS,
terms,
},
{
type: ActionTypes.UPDATE_RHS_STATE,
state: RHSStates.MENTION,
},
]));
return {data: true};
};
}
export function showChannelInfo(channelId: string) {
return {
type: ActionTypes.UPDATE_RHS_STATE,
channelId,
state: RHSStates.CHANNEL_INFO,
};
}
export function closeRightHandSide(): ActionFunc {
return (dispatch) => {
const actionsBatch: AnyAction[] = [
{
type: ActionTypes.UPDATE_RHS_STATE,
state: null,
},
{
type: ActionTypes.SELECT_POST,
postId: '',
channelId: '',
timestamp: 0,
},
];
dispatch(batchActions(actionsBatch));
return {data: true};
};
}
export const toggleMenu = (): ThunkActionFunc<unknown> => (dispatch) => dispatch({
type: ActionTypes.TOGGLE_RHS_MENU,
});
export const openMenu = (): ThunkActionFunc<unknown> => (dispatch) => dispatch({
type: ActionTypes.OPEN_RHS_MENU,
});
export const closeMenu = (): ThunkActionFunc<unknown> => (dispatch) => dispatch({
type: ActionTypes.CLOSE_RHS_MENU,
});
export function setRhsExpanded(expanded: boolean) {
return {
type: ActionTypes.SET_RHS_EXPANDED,
expanded,
};
}
export function toggleRhsExpanded() {
return {
type: ActionTypes.TOGGLE_RHS_EXPANDED,
};
}
export function selectPost(post: Post, previousRhsState?: RhsState) {
performance.mark(Mark.PostSelected);
return {
type: ActionTypes.SELECT_POST,
postId: post.root_id || post.id,
channelId: post.channel_id,
previousRhsState,
timestamp: Date.now(),
};
}
export function selectPostById(postId: string): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const post: Post | undefined = getPost(state, postId) ?? (await dispatch(fetchPost(postId))).data;
if (post && post.state !== 'DELETED' && post.delete_at === 0) {
const channel = getChannelSelector(state, post.channel_id);
if (!channel) {
await dispatch(getChannel(post.channel_id));
}
dispatch(selectPost(post));
return {data: true};
}
return {data: false};
};
}
export function highlightReply(post: Post) {
return {
type: ActionTypes.HIGHLIGHT_REPLY,
postId: post.id,
};
}
export const clearHighlightReply = {
type: ActionTypes.CLEAR_HIGHLIGHT_REPLY,
};
export const debouncedClearHighlightReply = debounce((dispatch) => {
return dispatch(clearHighlightReply);
}, Constants.PERMALINK_FADEOUT);
export function selectPostAndHighlight(post: Post): ActionFunc {
return (dispatch) => {
dispatch(batchActions([
selectPost(post),
highlightReply(post),
]));
debouncedClearHighlightReply(dispatch);
return {data: true};
};
}
export function selectPostCard(post: Post) {
return {type: ActionTypes.SELECT_POST_CARD, postId: post.id, channelId: post.channel_id};
}
export function openRHSSearch(): ActionFunc {
return (dispatch) => {
dispatch(clearSearch());
dispatch(updateSearchTerms(''));
dispatch(updateSearchTeam(null));
dispatch(updateSearchResultsTerms(''));
dispatch(updateRhsState(RHSStates.SEARCH));
return {data: true};
};
}
export function openAtPrevious(previous: any): ThunkActionFunc<unknown> {
return (dispatch, getState) => {
if (!previous) {
return dispatch(openRHSSearch());
}
if (previous.isChannelInfo) {
const currentChannelId = getCurrentChannelId(getState());
return dispatch(showChannelInfo(currentChannelId));
}
if (previous.isChannelMembers) {
const currentChannelId = getCurrentChannelId(getState());
return dispatch(showChannelMembers(currentChannelId));
}
if (previous.isMentionSearch) {
return dispatch(showMentions());
}
if (previous.isPinnedPosts) {
return dispatch(showPinnedPosts());
}
if (previous.isFlaggedPosts) {
return dispatch(showFlaggedPosts());
}
if (previous.selectedPostId) {
const post = getPost(getState(), previous.selectedPostId);
return post ? dispatch(selectPostWithPreviousState(post, previous.previousRhsState)) : dispatch(openRHSSearch());
}
if (previous.selectedPostCardId) {
const post = getPost(getState(), previous.selectedPostCardId);
return post ? dispatch(selectPostCardFromRightHandSideSearchWithPreviousState(post, previous.previousRhsState)) : dispatch(openRHSSearch());
}
if (previous.searchVisible) {
return dispatch(showSearchResults());
}
return dispatch(openRHSSearch());
};
}
export const suppressRHS = {
type: ActionTypes.SUPPRESS_RHS,
};
export const unsuppressRHS = {
type: ActionTypes.UNSUPPRESS_RHS,
};
export function focusedRHS() {
return {
type: ActionTypes.RHS_FOCUSED,
};
}
export function setEditChannelMembers(active: boolean) {
return {
type: ActionTypes.SET_EDIT_CHANNEL_MEMBERS,
active,
};
}
export function measureRhsOpened() {
return () => {
measureAndReport({
name: Measure.RhsLoad,
startMark: Mark.PostSelected,
canFail: true,
});
performance.clearMarks(Mark.PostSelected);
};
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as Actions from 'actions/views/root';
import * as i18nSelectors from 'selectors/i18n';
import mockStore from 'tests/test_store';
import {ActionTypes} from 'utils/constants';
describe('root view actions', () => {
const origCookies = document.cookie;
const origWasLoggedIn = localStorage.getItem('was_logged_in');
beforeAll(() => {
document.cookie = '';
localStorage.setItem('was_logged_in', '');
});
afterAll(() => {
document.cookie = origCookies;
localStorage.setItem('was_logged_in', origWasLoggedIn || '');
});
describe('registerPluginTranslationsSource', () => {
test('Should not dispatch action when getTranslation is empty', () => {
const testStore = mockStore({});
jest.spyOn(i18nSelectors, 'getTranslations').mockReturnValue(undefined as any);
jest.spyOn(i18nSelectors, 'getCurrentLocale').mockReturnValue('en');
testStore.dispatch(Actions.registerPluginTranslationsSource('plugin_id', jest.fn()));
expect(testStore.getActions()).toEqual([]);
});
test('Should dispatch action when getTranslation is not empty', () => {
const testStore = mockStore({});
jest.spyOn(i18nSelectors, 'getTranslations').mockReturnValue({});
jest.spyOn(i18nSelectors, 'getCurrentLocale').mockReturnValue('en');
testStore.dispatch(Actions.registerPluginTranslationsSource('plugin_id', jest.fn()));
expect(testStore.getActions()).toEqual([{
data: {
locale: 'en',
translations: {},
},
type: ActionTypes.RECEIVED_TRANSLATIONS,
}]);
});
});
});

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from 'mattermost-redux/client';
import {getCurrentLocale, getTranslations} from 'selectors/i18n';
import en from 'i18n/en.json';
import {ActionTypes} from 'utils/constants';
import type {ActionFuncAsync, ThunkActionFunc} from 'types/store';
import type {Translations} from 'types/store/i18n';
const pluginTranslationSources: Record<string, TranslationPluginFunction> = {};
export type TranslationPluginFunction = (locale: string) => Translations;
export function registerPluginTranslationsSource(pluginId: string, sourceFunction: TranslationPluginFunction): ThunkActionFunc<void> {
pluginTranslationSources[pluginId] = sourceFunction;
return (dispatch, getState) => {
const state = getState();
const locale = getCurrentLocale(state);
const immutableTranslations = getTranslations(state, locale);
const translations = {};
Object.assign(translations, immutableTranslations);
if (immutableTranslations) {
Object.assign(translations, sourceFunction(locale));
dispatch({
type: ActionTypes.RECEIVED_TRANSLATIONS,
data: {
locale,
translations,
},
});
}
};
}
export function unregisterPluginTranslationsSource(pluginId: string) {
Reflect.deleteProperty(pluginTranslationSources, pluginId);
}
export function loadTranslations(locale: string, url: string): ActionFuncAsync {
return async (dispatch) => {
const translations = {...en};
Object.values(pluginTranslationSources).forEach((pluginFunc) => {
Object.assign(translations, pluginFunc(locale));
});
// Need to go to the server for languages other than English
if (locale !== 'en') {
try {
const serverTranslations = await Client4.getTranslations(url);
Object.assign(translations, serverTranslations);
} catch (error) {
console.error(error); //eslint-disable-line no-console
}
}
dispatch({
type: ActionTypes.RECEIVED_TRANSLATIONS,
data: {
locale,
translations,
},
});
return {data: true};
};
}
export function setReadout(message: string) {
return {
type: ActionTypes.SET_READOUT,
data: message,
};
}

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