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
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:
71
webapp/channels/.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
71
webapp/channels/.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
|
||||
|
||||
```
|
||||
99
webapp/channels/.github/workflows/performance-benchmarks.yml
vendored
Normal file
99
webapp/channels/.github/workflows/performance-benchmarks.yml
vendored
Normal 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
29
webapp/channels/.gitignore
vendored
Normal 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
|
||||
104
webapp/channels/.npm-upgrade.json
Normal file
104
webapp/channels/.npm-upgrade.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
webapp/channels/.stylelintignore
Normal file
1
webapp/channels/.stylelintignore
Normal file
@@ -0,0 +1 @@
|
||||
dist/
|
||||
42
webapp/channels/.stylelintrc.json
Normal file
42
webapp/channels/.stylelintrc.json
Normal 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
|
||||
}
|
||||
}
|
||||
73
webapp/channels/CLAUDE.OPTIONAL.md
Normal file
73
webapp/channels/CLAUDE.OPTIONAL.md
Normal 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
202
webapp/channels/LICENSE.txt
Normal 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.
|
||||
40
webapp/channels/babel.config.js
Normal file
40
webapp/channels/babel.config.js
Normal 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
1
webapp/channels/build/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
test-results.xml
|
||||
3
webapp/channels/build/Dockerfile
Normal file
3
webapp/channels/build/Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
||||
FROM node:14.16
|
||||
RUN apt-get update && apt-get install -y make gcc nasm libpng-dev
|
||||
CMD [ "node" ]
|
||||
2865
webapp/channels/build/emoji/additional_shortnames.json
Normal file
2865
webapp/channels/build/emoji/additional_shortnames.json
Normal file
File diff suppressed because it is too large
Load Diff
502
webapp/channels/build/emoji/make_emojis.mjs
Executable file
502
webapp/channels/build/emoji/make_emojis.mjs
Executable 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}`);
|
||||
});
|
||||
35
webapp/channels/build/notice-file/README.md
Normal file
35
webapp/channels/build/notice-file/README.md
Normal 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`. |
|
||||
|
||||
16
webapp/channels/build/notice-file/config.yaml
Normal file
16
webapp/channels/build/notice-file/config.yaml
Normal 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"
|
||||
|
||||
...
|
||||
88
webapp/channels/eslint.config.mjs
Normal file
88
webapp/channels/eslint.config.mjs
Normal 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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
25
webapp/channels/jest.config.channels.js
Normal file
25
webapp/channels/jest.config.channels.js
Normal 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;
|
||||
59
webapp/channels/jest.config.js
Normal file
59
webapp/channels/jest.config.js
Normal 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;
|
||||
23
webapp/channels/jest.config.mattermost-redux.js
Normal file
23
webapp/channels/jest.config.mattermost-redux.js
Normal 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;
|
||||
203
webapp/channels/package.json
Normal file
203
webapp/channels/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
265
webapp/channels/scripts/check_icu.mjs
Normal file
265
webapp/channels/scripts/check_icu.mjs
Normal 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)}`);
|
||||
368
webapp/channels/scripts/check_icu.test.js
Normal file
368
webapp/channels/scripts/check_icu.test.js
Normal 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)');
|
||||
});
|
||||
});
|
||||
});
|
||||
45
webapp/channels/scripts/formatter.js
Normal file
45
webapp/channels/scripts/formatter.js
Normal 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;
|
||||
};
|
||||
36
webapp/channels/src/CLAUDE.OPTIONAL.md
Normal file
36
webapp/channels/src/CLAUDE.OPTIONAL.md
Normal 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).
|
||||
|
||||
|
||||
|
||||
76
webapp/channels/src/actions/CLAUDE.OPTIONAL.md
Normal file
76
webapp/channels/src/actions/CLAUDE.OPTIONAL.md
Normal 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.
|
||||
75
webapp/channels/src/actions/admin_actions.test.ts
Normal file
75
webapp/channels/src/actions/admin_actions.test.ts
Normal 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,
|
||||
}}});
|
||||
});
|
||||
});
|
||||
648
webapp/channels/src/actions/admin_actions.ts
Normal file
648
webapp/channels/src/actions/admin_actions.ts
Normal 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}};
|
||||
}
|
||||
};
|
||||
}
|
||||
271
webapp/channels/src/actions/apps.ts
Normal file
271
webapp/channels/src/actions/apps.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
132
webapp/channels/src/actions/burn_on_read_deletion.test.ts
Normal file
132
webapp/channels/src/actions/burn_on_read_deletion.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
74
webapp/channels/src/actions/burn_on_read_deletion.ts
Normal file
74
webapp/channels/src/actions/burn_on_read_deletion.ts
Normal 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};
|
||||
};
|
||||
}
|
||||
93
webapp/channels/src/actions/burn_on_read_posts.test.ts
Normal file
93
webapp/channels/src/actions/burn_on_read_posts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
44
webapp/channels/src/actions/burn_on_read_posts.ts
Normal file
44
webapp/channels/src/actions/burn_on_read_posts.ts
Normal 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};
|
||||
};
|
||||
}
|
||||
101
webapp/channels/src/actions/burn_on_read_websocket.ts
Normal file
101
webapp/channels/src/actions/burn_on_read_websocket.ts
Normal 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};
|
||||
};
|
||||
}
|
||||
255
webapp/channels/src/actions/channel_actions.test.ts
Normal file
255
webapp/channels/src/actions/channel_actions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
192
webapp/channels/src/actions/channel_actions.ts
Normal file
192
webapp/channels/src/actions/channel_actions.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
||||
46
webapp/channels/src/actions/channel_bookmarks.ts
Normal file
46
webapp/channels/src/actions/channel_bookmarks.ts
Normal 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);
|
||||
}
|
||||
166
webapp/channels/src/actions/cloud.tsx
Normal file
166
webapp/channels/src/actions/cloud.tsx
Normal 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};
|
||||
};
|
||||
}
|
||||
447
webapp/channels/src/actions/command.test.ts
Normal file
447
webapp/channels/src/actions/command.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
221
webapp/channels/src/actions/command.ts
Normal file
221
webapp/channels/src/actions/command.ts
Normal 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}};
|
||||
};
|
||||
}
|
||||
293
webapp/channels/src/actions/emoji_actions.test.ts
Normal file
293
webapp/channels/src/actions/emoji_actions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
244
webapp/channels/src/actions/emoji_actions.ts
Normal file
244
webapp/channels/src/actions/emoji_actions.ts
Normal 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};
|
||||
};
|
||||
}
|
||||
258
webapp/channels/src/actions/emoji_actions_load_recent.test.ts
Normal file
258
webapp/channels/src/actions/emoji_actions_load_recent.test.ts
Normal 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],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
89
webapp/channels/src/actions/file_actions.test.ts
Normal file
89
webapp/channels/src/actions/file_actions.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
169
webapp/channels/src/actions/file_actions.ts
Normal file
169
webapp/channels/src/actions/file_actions.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
590
webapp/channels/src/actions/global_actions.test.ts
Normal file
590
webapp/channels/src/actions/global_actions.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
437
webapp/channels/src/actions/global_actions.tsx
Normal file
437
webapp/channels/src/actions/global_actions.tsx
Normal 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);
|
||||
}
|
||||
771
webapp/channels/src/actions/hooks.test.ts
Normal file
771
webapp/channels/src/actions/hooks.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
132
webapp/channels/src/actions/hooks.ts
Normal file
132
webapp/channels/src/actions/hooks.ts
Normal 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};
|
||||
};
|
||||
}
|
||||
369
webapp/channels/src/actions/integration_actions.test.ts
Normal file
369
webapp/channels/src/actions/integration_actions.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
206
webapp/channels/src/actions/integration_actions.tsx
Normal file
206
webapp/channels/src/actions/integration_actions.tsx
Normal 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};
|
||||
};
|
||||
}
|
||||
737
webapp/channels/src/actions/invite_actions.test.ts
Normal file
737
webapp/channels/src/actions/invite_actions.test.ts
Normal 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: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
484
webapp/channels/src/actions/invite_actions.ts
Normal file
484
webapp/channels/src/actions/invite_actions.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
187
webapp/channels/src/actions/marketplace.ts
Normal file
187
webapp/channels/src/actions/marketplace.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
518
webapp/channels/src/actions/new_post.test.ts
Normal file
518
webapp/channels/src/actions/new_post.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
161
webapp/channels/src/actions/new_post.ts
Normal file
161
webapp/channels/src/actions/new_post.ts
Normal 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};
|
||||
};
|
||||
}
|
||||
817
webapp/channels/src/actions/notification_actions.test.ts
Normal file
817
webapp/channels/src/actions/notification_actions.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
465
webapp/channels/src/actions/notification_actions.tsx
Normal file
465
webapp/channels/src/actions/notification_actions.tsx
Normal 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;
|
||||
}
|
||||
};
|
||||
785
webapp/channels/src/actions/post_actions.test.ts
Normal file
785
webapp/channels/src/actions/post_actions.test.ts
Normal 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},
|
||||
]);
|
||||
});
|
||||
});
|
||||
518
webapp/channels/src/actions/post_actions.ts
Normal file
518
webapp/channels/src/actions/post_actions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
139
webapp/channels/src/actions/property_values_websocket.test.ts
Normal file
139
webapp/channels/src/actions/property_values_websocket.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
135
webapp/channels/src/actions/status_actions.test.ts
Normal file
135
webapp/channels/src/actions/status_actions.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
117
webapp/channels/src/actions/status_actions.ts
Normal file
117
webapp/channels/src/actions/status_actions.ts
Normal 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};
|
||||
};
|
||||
}
|
||||
92
webapp/channels/src/actions/storage.test.ts
Normal file
92
webapp/channels/src/actions/storage.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
36
webapp/channels/src/actions/storage.ts
Normal file
36
webapp/channels/src/actions/storage.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
124
webapp/channels/src/actions/team_actions.test.ts
Normal file
124
webapp/channels/src/actions/team_actions.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
135
webapp/channels/src/actions/team_actions.ts
Normal file
135
webapp/channels/src/actions/team_actions.ts
Normal 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};
|
||||
};
|
||||
}
|
||||
31
webapp/channels/src/actions/telemetry_actions.tsx
Normal file
31
webapp/channels/src/actions/telemetry_actions.tsx
Normal 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);
|
||||
};
|
||||
706
webapp/channels/src/actions/user_actions.test.ts
Normal file
706
webapp/channels/src/actions/user_actions.test.ts
Normal 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'}]);
|
||||
});
|
||||
});
|
||||
501
webapp/channels/src/actions/user_actions.ts
Normal file
501
webapp/channels/src/actions/user_actions.ts
Normal 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};
|
||||
};
|
||||
}
|
||||
82
webapp/channels/src/actions/views/admin.ts
Normal file
82
webapp/channels/src/actions/views/admin.ts
Normal 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};
|
||||
};
|
||||
}
|
||||
16
webapp/channels/src/actions/views/announcement_bar.ts
Normal file
16
webapp/channels/src/actions/views/announcement_bar.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
33
webapp/channels/src/actions/views/browser.ts
Normal file
33
webapp/channels/src/actions/views/browser.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
849
webapp/channels/src/actions/views/channel.test.ts
Normal file
849
webapp/channels/src/actions/views/channel.test.ts
Normal 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: []}});
|
||||
});
|
||||
});
|
||||
});
|
||||
566
webapp/channels/src/actions/views/channel.ts
Normal file
566
webapp/channels/src/actions/views/channel.ts
Normal 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};
|
||||
};
|
||||
}
|
||||
317
webapp/channels/src/actions/views/channel_sidebar.test.ts
Normal file
317
webapp/channels/src/actions/views/channel_sidebar.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
220
webapp/channels/src/actions/views/channel_sidebar.ts
Normal file
220
webapp/channels/src/actions/views/channel_sidebar.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
17
webapp/channels/src/actions/views/cookie.ts
Normal file
17
webapp/channels/src/actions/views/cookie.ts
Normal 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}`;
|
||||
}
|
||||
351
webapp/channels/src/actions/views/create_comment.test.ts
Normal file
351
webapp/channels/src/actions/views/create_comment.test.ts
Normal 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'}]));
|
||||
});
|
||||
});
|
||||
});
|
||||
229
webapp/channels/src/actions/views/create_comment.tsx
Normal file
229
webapp/channels/src/actions/views/create_comment.tsx
Normal 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),
|
||||
));
|
||||
};
|
||||
}
|
||||
189
webapp/channels/src/actions/views/drafts.test.ts
Normal file
189
webapp/channels/src/actions/views/drafts.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
190
webapp/channels/src/actions/views/drafts.ts
Normal file
190
webapp/channels/src/actions/views/drafts.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
44
webapp/channels/src/actions/views/group.ts
Normal file
44
webapp/channels/src/actions/views/group.ts
Normal 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)};
|
||||
};
|
||||
}
|
||||
69
webapp/channels/src/actions/views/lhs.test.ts
Normal file
69
webapp/channels/src/actions/views/lhs.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
90
webapp/channels/src/actions/views/lhs.ts
Normal file
90
webapp/channels/src/actions/views/lhs.ts
Normal 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};
|
||||
};
|
||||
}
|
||||
72
webapp/channels/src/actions/views/login.test.ts
Normal file
72
webapp/channels/src/actions/views/login.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
83
webapp/channels/src/actions/views/login.ts
Normal file
83
webapp/channels/src/actions/views/login.ts
Normal 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};
|
||||
}
|
||||
};
|
||||
}
|
||||
74
webapp/channels/src/actions/views/mfa.test.ts
Normal file
74
webapp/channels/src/actions/views/mfa.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
34
webapp/channels/src/actions/views/mfa.ts
Normal file
34
webapp/channels/src/actions/views/mfa.ts
Normal 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));
|
||||
};
|
||||
}
|
||||
|
||||
63
webapp/channels/src/actions/views/modals.test.tsx
Normal file
63
webapp/channels/src/actions/views/modals.test.tsx
Normal 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]);
|
||||
});
|
||||
});
|
||||
27
webapp/channels/src/actions/views/modals.ts
Normal file
27
webapp/channels/src/actions/views/modals.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
11
webapp/channels/src/actions/views/notice.ts
Normal file
11
webapp/channels/src/actions/views/notice.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
72
webapp/channels/src/actions/views/onboarding_tasks.ts
Normal file
72
webapp/channels/src/actions/views/onboarding_tasks.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
123
webapp/channels/src/actions/views/posts.ts
Normal file
123
webapp/channels/src/actions/views/posts.ts
Normal 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};
|
||||
};
|
||||
}
|
||||
11
webapp/channels/src/actions/views/product_menu.ts
Normal file
11
webapp/channels/src/actions/views/product_menu.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
54
webapp/channels/src/actions/views/profile_popover.test.ts
Normal file
54
webapp/channels/src/actions/views/profile_popover.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
16
webapp/channels/src/actions/views/profile_popover.ts
Normal file
16
webapp/channels/src/actions/views/profile_popover.ts
Normal 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)),
|
||||
]);
|
||||
};
|
||||
}
|
||||
1002
webapp/channels/src/actions/views/rhs.test.ts
Normal file
1002
webapp/channels/src/actions/views/rhs.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
680
webapp/channels/src/actions/views/rhs.ts
Normal file
680
webapp/channels/src/actions/views/rhs.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
51
webapp/channels/src/actions/views/root.test.ts
Normal file
51
webapp/channels/src/actions/views/root.test.ts
Normal 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,
|
||||
}]);
|
||||
});
|
||||
});
|
||||
});
|
||||
75
webapp/channels/src/actions/views/root.ts
Normal file
75
webapp/channels/src/actions/views/root.ts
Normal 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
Reference in New Issue
Block a user