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:
5
webapp/platform/shared/.parcelrc
Normal file
5
webapp/platform/shared/.parcelrc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "@parcel/config-default",
|
||||
"bundler": "@parcel/bundler-library",
|
||||
"namers": ["./build/parcel-namer-shared", "..."]
|
||||
}
|
||||
1
webapp/platform/shared/.stylelintignore
Normal file
1
webapp/platform/shared/.stylelintignore
Normal file
@@ -0,0 +1 @@
|
||||
dist/
|
||||
42
webapp/platform/shared/.stylelintrc.json
Normal file
42
webapp/platform/shared/.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
|
||||
}
|
||||
}
|
||||
32
webapp/platform/shared/README.md
Normal file
32
webapp/platform/shared/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Mattermost Shared Package
|
||||
|
||||
[](https://www.npmjs.com/package/@mattermost/shared)
|
||||
|
||||
This package contains shared components and other utilities for use by the Mattermost web app and its plugins.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a pre-release package in active development. It is currently for internal use only, and it may change significantly between now and when it is fully released.
|
||||
|
||||
## Installation
|
||||
|
||||
This package requires a matching version of the `@mattermost/types` package. It also requires [React](https://react.dev/) and [React Intl](https://formatjs.github.io/docs/react-intl/).
|
||||
|
||||
```sh
|
||||
$ npm install @mattermost/shared @mattermost/types
|
||||
```
|
||||
|
||||
Additionally, if you're writing unit tests involving these components, [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) is also required.
|
||||
|
||||
```sh
|
||||
$ npm install @testing-library/react
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
TODO
|
||||
|
||||
## Development
|
||||
|
||||
### Compilation and Packaging
|
||||
|
||||
TODO
|
||||
44
webapp/platform/shared/build/parcel-namer-shared.ts
Normal file
44
webapp/platform/shared/build/parcel-namer-shared.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import * as path from 'node:path';
|
||||
|
||||
import {Namer} from '@parcel/plugin';
|
||||
import type {FilePath} from '@parcel/types';
|
||||
|
||||
/**
|
||||
* This Namer changes how Parcel outputs its files to put them into subfolders based on where they were originally in
|
||||
* the source folder.
|
||||
*
|
||||
* By default, files output by Parcel are not put into subfolders of dist, and they instead rely on hashes to
|
||||
* differentiate between them. We want to be able to import those directly, so
|
||||
*/
|
||||
export default new Namer({
|
||||
async name(opts): Promise<FilePath | null | undefined> {
|
||||
const {bundle} = opts;
|
||||
|
||||
const mainEntry = bundle.getMainEntry();
|
||||
if (!mainEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the relative file path within the source folder
|
||||
const relativeDir = path.posix.relative('./src', path.dirname(mainEntry.filePath));
|
||||
|
||||
let filename;
|
||||
if (bundle.type === 'js') {
|
||||
// Rename generated JS files from FILE.js to FILE.TARGET.js or FILE.TARGET.js to fix naming conflict
|
||||
// between CommonJS and ESM files
|
||||
filename = path.basename(mainEntry.filePath, path.extname(mainEntry.filePath));
|
||||
filename += '.' + bundle.target.name + '.js';
|
||||
} else if (bundle.type === 'ts') {
|
||||
filename = path.basename(mainEntry.filePath, path.extname(mainEntry.filePath)) + '.d.ts';
|
||||
} else {
|
||||
filename = bundle.target.name + path.extname(mainEntry.filePath);
|
||||
}
|
||||
|
||||
const newPath = path.posix.join(relativeDir, filename);
|
||||
|
||||
return newPath;
|
||||
},
|
||||
});
|
||||
31
webapp/platform/shared/build/webpack-web-app-externals.cjs
Normal file
31
webapp/platform/shared/build/webpack-web-app-externals.cjs
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// This script is intended to be used by Mattermost plugins to set up their Webpack externals to share their
|
||||
// dependencies with the web app. It includes both third party dependencies (React, etc) and the MM Shared package.
|
||||
|
||||
const windowExternals = {
|
||||
react: 'React',
|
||||
'react-dom': 'ReactDOM',
|
||||
redux: 'Redux',
|
||||
luxon: 'Luxon',
|
||||
'react-redux': 'ReactRedux',
|
||||
'prop-types': 'PropTypes',
|
||||
'react-bootstrap': 'ReactBootstrap',
|
||||
'react-router-dom': 'ReactRouterDom',
|
||||
'react-intl': 'ReactIntl',
|
||||
};
|
||||
|
||||
function webAppExternals() {
|
||||
return [
|
||||
windowExternals,
|
||||
({request}, callback) => {
|
||||
if ((/^@mattermost\/shared\//).test(request)) {
|
||||
return callback(null, `promise globalThis.loadSharedDependency('${request}')`);
|
||||
}
|
||||
|
||||
return callback();
|
||||
},
|
||||
];
|
||||
}
|
||||
module.exports = webAppExternals;
|
||||
26
webapp/platform/shared/eslint.config.mjs
Normal file
26
webapp/platform/shared/eslint.config.mjs
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import formatjsPlugin from 'eslint-plugin-formatjs';
|
||||
import noOnlyTestsPlugin from 'eslint-plugin-no-only-tests';
|
||||
|
||||
import eslintPlugin from '@mattermost/eslint-plugin';
|
||||
|
||||
export default [
|
||||
...eslintPlugin.configs.react,
|
||||
{
|
||||
files: ['**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx'],
|
||||
plugins: {
|
||||
formatjs: formatjsPlugin,
|
||||
},
|
||||
},
|
||||
{
|
||||
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']}],
|
||||
},
|
||||
},
|
||||
];
|
||||
44
webapp/platform/shared/jest.config.js
Normal file
44
webapp/platform/shared/jest.config.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/** @type {import('jest').Config} */
|
||||
|
||||
export default {
|
||||
moduleDirectories: ['src', 'node_modules'],
|
||||
testEnvironment: 'jsdom',
|
||||
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
|
||||
clearMocks: true,
|
||||
moduleNameMapper: {
|
||||
'^.+\\.css$': 'identity-obj-proxy',
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||
transform: {
|
||||
'^.+\\.(t|j)sx?$': [
|
||||
'@swc/jest',
|
||||
{
|
||||
jsc: {
|
||||
parser: {
|
||||
syntax: 'typescript',
|
||||
tsx: true,
|
||||
importAssertions: true,
|
||||
},
|
||||
|
||||
transform: {
|
||||
react: {
|
||||
runtime: 'automatic',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/setup_jest.ts'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{js,jsx,ts,tsx}',
|
||||
],
|
||||
coveragePathIgnorePatterns: [
|
||||
'/node_modules/',
|
||||
'/dist/',
|
||||
],
|
||||
coverageReporters: ['json', 'lcov', 'text-summary'],
|
||||
};
|
||||
112
webapp/platform/shared/package.json
Normal file
112
webapp/platform/shared/package.json
Normal file
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"name": "@mattermost/shared",
|
||||
"version": "12.0.0",
|
||||
"description": "Shared components and utilities for use by the Mattermost web app and its plugins",
|
||||
"keywords": [
|
||||
"mattermost"
|
||||
],
|
||||
"homepage": "https://github.com/mattermost/mattermost/tree/master/webapp/platform/shared#readme",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"build/webpack-web-app-externals.cjs",
|
||||
"dist"
|
||||
],
|
||||
"main": "dist/main.js",
|
||||
"module": "dist/module.js",
|
||||
"source": "src/**/index.ts",
|
||||
"types": "dist/types.d.ts",
|
||||
"typesVersions": {
|
||||
">=3.1": {
|
||||
"*": [
|
||||
"./dist/*/index.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": [
|
||||
"./src/index.ts",
|
||||
"./dist/types.d.ts"
|
||||
],
|
||||
"source": "./src/index.ts",
|
||||
"import": "./dist/module.js",
|
||||
"require": "./dist/main.js"
|
||||
},
|
||||
"./build/webpack-web-app-externals": {
|
||||
"source": "./build/webpack-web-app-externals.cjs",
|
||||
"require": "./build/webpack-web-app-externals.cjs"
|
||||
},
|
||||
"./*": {
|
||||
"types": [
|
||||
"./src/*/index.ts",
|
||||
"./dist/*/index.d.ts"
|
||||
],
|
||||
"source": "./src/*/index.ts",
|
||||
"import": "./dist/*/index.module.js",
|
||||
"require": "./dist/*/index.main.js"
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/mattermost/mattermost.git",
|
||||
"directory": "webapp/platform/shared"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.20",
|
||||
"classnames": "^2.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mattermost/eslint-plugin": "*",
|
||||
"@parcel/bundler-library": "^2.16.3",
|
||||
"@parcel/packager-ts": "^2.16.3",
|
||||
"@parcel/transformer-typescript-types": "^2.16.3",
|
||||
"@stylistic/stylelint-plugin": "^3.1.2",
|
||||
"@swc/core": "^1.3.36",
|
||||
"@swc/jest": "^0.2.36",
|
||||
"@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/jest": "^30.0.0",
|
||||
"@types/node": "^24.10.4",
|
||||
"jest": "^30.1.3",
|
||||
"jest-environment-jsdom": "^30.1.0",
|
||||
"parcel": "^2.16.3",
|
||||
"react": "^19.2.8",
|
||||
"stylelint": "^16.10.0",
|
||||
"stylelint-config-idiomatic-order": "^10.0.0",
|
||||
"stylelint-config-recommended": "^14.0.1",
|
||||
"stylelint-order": "^6.0.4",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mattermost/types": "12.0.0",
|
||||
"@testing-library/react": "^16",
|
||||
"react": ">=17",
|
||||
"react-intl": ">=7",
|
||||
"typescript": "^4.3.0 || ^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@testing-library/react": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "parcel build --no-optimize",
|
||||
"check": "npm run check:eslint && npm run check:stylelint",
|
||||
"check:eslint": "eslint ./src eslint.config.mjs --quiet",
|
||||
"check:stylelint": "stylelint \"**/*.{css,scss}\"",
|
||||
"check-types": "tsc -b",
|
||||
"fix": "eslint ./src eslint.config.mjs --quiet --fix && stylelint \"**/*.{css,scss}\" --fix",
|
||||
"run": "parcel watch --watch-dir .",
|
||||
"test": "jest",
|
||||
"test-ci": "jest --ci --forceExit --detectOpenHandles --maxWorkers=100% --logHeapUsage",
|
||||
"clean": "rm -rf dist node_modules *.tsbuildinfo .parcel-cache"
|
||||
},
|
||||
"@parcel/resolver-default": {
|
||||
"packageExports": true
|
||||
}
|
||||
}
|
||||
4
webapp/platform/shared/setup_jest.ts
Normal file
4
webapp/platform/shared/setup_jest.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
53
webapp/platform/shared/src/components/button/button.tsx
Normal file
53
webapp/platform/shared/src/components/button/button.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {buttonClassNames, type ButtonEmphasis, type ButtonSize, type ButtonVariant} from './button_classes';
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: React.ReactNode;
|
||||
|
||||
/**
|
||||
* The emphasis level of the button. Controls the colors of the button.
|
||||
*
|
||||
* Defaults to "primary".
|
||||
*/
|
||||
emphasis?: ButtonEmphasis;
|
||||
|
||||
/**
|
||||
* The size of the button as defined in our Compass Design System. Controls the height and font size of the button.
|
||||
*
|
||||
* Defaults to "md".
|
||||
*/
|
||||
size?: ButtonSize;
|
||||
|
||||
/**
|
||||
* The variant of the button. Modifies the color defined by the emphasis level.
|
||||
*
|
||||
* Defaults to "".
|
||||
*/
|
||||
variant?: ButtonVariant;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
children,
|
||||
className,
|
||||
|
||||
emphasis,
|
||||
size,
|
||||
variant,
|
||||
|
||||
...otherProps
|
||||
}, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={buttonClassNames({emphasis, size, variant}, className)}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
Button.displayName = 'Button';
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
export type ButtonEmphasis = 'primary' | 'secondary' | 'tertiary' | 'quaternary';
|
||||
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg';
|
||||
export type ButtonVariant = '' | 'destructive' | 'inverted';
|
||||
|
||||
const emphasisClasses = {
|
||||
primary: 'btn-primary',
|
||||
secondary: 'btn-secondary',
|
||||
tertiary: 'btn-tertiary',
|
||||
quaternary: 'btn-quaternary',
|
||||
};
|
||||
const sizeClasses = {
|
||||
xs: 'btn-xs',
|
||||
sm: 'btn-sm',
|
||||
md: '',
|
||||
lg: 'btn-lg',
|
||||
};
|
||||
const variantClasses = {
|
||||
'': '',
|
||||
destructive: 'btn-danger',
|
||||
inverted: 'btn-inverted',
|
||||
};
|
||||
|
||||
export interface ButtonStyles {
|
||||
emphasis?: ButtonEmphasis;
|
||||
size?: ButtonSize;
|
||||
variant?: ButtonVariant;
|
||||
}
|
||||
|
||||
export function buttonClassNames({emphasis = 'primary', size = 'md', variant = ''}: ButtonStyles, ...other: classNames.ArgumentArray) {
|
||||
let emphasisClass = emphasisClasses[emphasis];
|
||||
const sizeClass = sizeClasses[size];
|
||||
const variantClass = variantClasses[variant];
|
||||
|
||||
if (emphasis === 'primary' && variant === 'destructive') {
|
||||
// TODO in the current CSS, btn-primary overrides btn-danger
|
||||
emphasisClass = '';
|
||||
}
|
||||
|
||||
return classNames('btn', emphasisClass, sizeClass, variantClass, other);
|
||||
}
|
||||
7
webapp/platform/shared/src/components/button/index.ts
Normal file
7
webapp/platform/shared/src/components/button/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {Button} from './button';
|
||||
export type {ButtonProps} from './button';
|
||||
export {buttonClassNames} from './button_classes';
|
||||
export type {ButtonEmphasis, ButtonSize, ButtonVariant} from './button_classes';
|
||||
1
webapp/platform/shared/src/components/emoji/emoji.css
Normal file
1
webapp/platform/shared/src/components/emoji/emoji.css
Normal file
@@ -0,0 +1 @@
|
||||
/* The styles for this component are still defined in the web app */
|
||||
45
webapp/platform/shared/src/components/emoji/emoji.test.tsx
Normal file
45
webapp/platform/shared/src/components/emoji/emoji.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {screen} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import {Emoji} from './emoji';
|
||||
|
||||
import {renderWithContext} from '../../testing';
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
describe('Emoji', () => {
|
||||
test('should render nothing when no emoji name is provided', () => {
|
||||
renderWithContext(
|
||||
<Emoji emojiName=''/>,
|
||||
);
|
||||
|
||||
expect(document.querySelector('.emoticon')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the provided system emoji', () => {
|
||||
renderWithContext(
|
||||
<Emoji emojiName='smiley'/>,
|
||||
);
|
||||
|
||||
expect(document.querySelector('.emoticon')).toBe(screen.getByLabelText(':smiley:'));
|
||||
expect(screen.getByLabelText(':smiley:')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(':smiley:')).toHaveStyle({
|
||||
backgroundImage: '/static/emoji/1F603.png',
|
||||
});
|
||||
});
|
||||
|
||||
test('should render the provided custom emoji', () => {
|
||||
renderWithContext(
|
||||
<Emoji emojiName='custom-emoji-1'/>,
|
||||
);
|
||||
|
||||
expect(document.querySelector('.emoticon')).toBe(screen.getByLabelText(':custom-emoji-1:'));
|
||||
expect(screen.getByLabelText(':custom-emoji-1:')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(':custom-emoji-1:')).toHaveStyle({
|
||||
backgroundImage: '/api/v4/emojis/custom-emoji-id-1/image',
|
||||
});
|
||||
});
|
||||
});
|
||||
56
webapp/platform/shared/src/components/emoji/emoji.tsx
Normal file
56
webapp/platform/shared/src/components/emoji/emoji.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {KeyboardEvent, MouseEvent} from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import {useEmojiByName} from '../../context/useEmojiByName';
|
||||
import {useEmojiUrl} from '../../context/useEmojiUrl';
|
||||
|
||||
import './emoji.css';
|
||||
|
||||
const emptyEmojiStyle = {};
|
||||
|
||||
export interface EmojiProps {
|
||||
emojiName: string;
|
||||
size?: number;
|
||||
emojiStyle?: React.CSSProperties;
|
||||
|
||||
// TODO remove this prop and move the click handler a proper button
|
||||
onClick?: (event: MouseEvent<HTMLSpanElement> | KeyboardEvent<HTMLSpanElement>) => void;
|
||||
}
|
||||
|
||||
export function Emoji({
|
||||
emojiName,
|
||||
emojiStyle = emptyEmojiStyle,
|
||||
size = 16,
|
||||
onClick,
|
||||
}: EmojiProps) {
|
||||
const emoji = useEmojiByName(emojiName);
|
||||
const emojiImageUrl = useEmojiUrl(emoji);
|
||||
|
||||
if (!emoji || !emojiImageUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={onClick}
|
||||
className='emoticon'
|
||||
aria-label={`:${emojiName}:`}
|
||||
data-emoticon={emojiName}
|
||||
style={{
|
||||
backgroundImage: `url(${emojiImageUrl})`,
|
||||
backgroundSize: 'contain',
|
||||
height: size,
|
||||
width: size,
|
||||
maxHeight: size,
|
||||
maxWidth: size,
|
||||
minHeight: size,
|
||||
minWidth: size,
|
||||
overflow: 'hidden',
|
||||
...emojiStyle,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
5
webapp/platform/shared/src/components/emoji/index.ts
Normal file
5
webapp/platform/shared/src/components/emoji/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {Emoji} from './emoji';
|
||||
export type {EmojiProps} from './emoji';
|
||||
@@ -0,0 +1,21 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`components/ShortcutKey should match snapshot for contrast key 1`] = `
|
||||
<div>
|
||||
<mark
|
||||
class="shortcut-key shortcut-key--contrast"
|
||||
>
|
||||
Shift
|
||||
</mark>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/ShortcutKey should match snapshot for regular key 1`] = `
|
||||
<div>
|
||||
<mark
|
||||
class="shortcut-key"
|
||||
>
|
||||
Shift
|
||||
</mark>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,6 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {ShortcutKeys} from './keys';
|
||||
export {ShortcutKey, ShortcutKeyVariant} from './shortcut_key';
|
||||
export type {ShortcutKeyProps} from './shortcut_key';
|
||||
29
webapp/platform/shared/src/components/shortcut_key/keys.ts
Normal file
29
webapp/platform/shared/src/components/shortcut_key/keys.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {defineMessage} from 'react-intl';
|
||||
|
||||
export const ShortcutKeys = {
|
||||
alt: defineMessage({
|
||||
id: 'shortcuts.generic.alt',
|
||||
defaultMessage: 'Alt',
|
||||
}),
|
||||
cmd: '⌘',
|
||||
ctrl: defineMessage({
|
||||
id: 'shortcuts.generic.ctrl',
|
||||
defaultMessage: 'Ctrl',
|
||||
}),
|
||||
enter: defineMessage({
|
||||
id: 'shortcuts.generic.enter',
|
||||
defaultMessage: 'Enter',
|
||||
}),
|
||||
escape: defineMessage({
|
||||
id: 'general_button.esc',
|
||||
defaultMessage: 'Esc',
|
||||
}),
|
||||
option: '⌥',
|
||||
shift: defineMessage({
|
||||
id: 'shortcuts.generic.shift',
|
||||
defaultMessage: 'Shift',
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
.shortcut-key {
|
||||
display: inline-block;
|
||||
padding: 10px 9px 8px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--center-channel-color);
|
||||
color: var(--center-channel-bg);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
|
||||
&.shortcut-key--contrast {
|
||||
background-color: rgba(var(--center-channel-color-rgb), 0.08);
|
||||
color: var(--center-channel-color);
|
||||
}
|
||||
|
||||
&.shortcut-key--tooltip {
|
||||
padding: 2px 5px;
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
&.shortcut-key--tutorial-tip {
|
||||
padding: 2px 5px;
|
||||
background-color: rgba(var(--button-color-rgb), 0.08);
|
||||
color: rgba(var(--button-color-rgb), 0.75);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
&.shortcut-key--shortcut-modal {
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
&.shortcut-key--inline-content {
|
||||
padding: 2px 5px;
|
||||
background: rgba(var(--center-channel-color-rgb), 0.08);
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {ShortcutKey, ShortcutKeyVariant} from './shortcut_key';
|
||||
|
||||
import {renderWithContext} from '../../testing/react_testing_utils';
|
||||
|
||||
describe('components/ShortcutKey', () => {
|
||||
test('should match snapshot for regular key', () => {
|
||||
const {container} = renderWithContext(<ShortcutKey>{'Shift'}</ShortcutKey>);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot for contrast key', () => {
|
||||
const {container} = renderWithContext(<ShortcutKey variant={ShortcutKeyVariant.Contrast}>{'Shift'}</ShortcutKey>);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import './shortcut_key.css';
|
||||
|
||||
export enum ShortcutKeyVariant {
|
||||
Contrast = 'contrast',
|
||||
Tooltip = 'tooltip',
|
||||
TutorialTip = 'tutorialTip',
|
||||
ShortcutModal = 'shortcut',
|
||||
InlineContent = 'inline-content',
|
||||
}
|
||||
|
||||
export interface ShortcutKeyProps {
|
||||
variant?: ShortcutKeyVariant;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ShortcutKey({children, variant}: ShortcutKeyProps) {
|
||||
return (
|
||||
<mark
|
||||
className={classNames('shortcut-key', {
|
||||
'shortcut-key--contrast': variant === ShortcutKeyVariant.Contrast,
|
||||
'shortcut-key--tooltip': variant === ShortcutKeyVariant.Tooltip,
|
||||
'shortcut-key--tutorial-tip': variant === ShortcutKeyVariant.TutorialTip,
|
||||
'shortcut-key--shortcut-modal': variant === ShortcutKeyVariant.ShortcutModal,
|
||||
'shortcut-key--inline-content': variant === ShortcutKeyVariant.InlineContent,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</mark>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`TooltipContent have correct structure with just title 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="tooltipContent"
|
||||
>
|
||||
<span
|
||||
class="tooltipContentTitleContainer"
|
||||
>
|
||||
<span
|
||||
class="tooltipContentTitle"
|
||||
>
|
||||
Title
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`TooltipContent have correct structure with title and emoji 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="tooltipContent"
|
||||
>
|
||||
<span
|
||||
class="tooltipContentTitleContainer"
|
||||
>
|
||||
<span
|
||||
class="tooltipContentEmoji"
|
||||
>
|
||||
<span
|
||||
aria-label=":smile:"
|
||||
class="emoticon"
|
||||
data-emoticon="smile"
|
||||
style="background-image: url("/static/emoji/1F604.png"); background-size: contain; height: 16px; width: 16px; max-height: 16px; max-width: 16px; min-height: 16px; min-width: 16px; overflow: hidden;"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="tooltipContentTitle"
|
||||
>
|
||||
Title with Emoji
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`TooltipContent have correct structure with title and large emoji 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="tooltipContent"
|
||||
>
|
||||
<span
|
||||
class="tooltipContentTitleContainer isEmojiLarge"
|
||||
>
|
||||
<span
|
||||
class="tooltipContentEmoji"
|
||||
>
|
||||
<span
|
||||
aria-label=":smile:"
|
||||
class="emoticon"
|
||||
data-emoticon="smile"
|
||||
style="background-image: url("/static/emoji/1F604.png"); background-size: contain; height: 48px; width: 48px; max-height: 48px; max-width: 48px; min-height: 48px; min-width: 48px; overflow: hidden;"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="tooltipContentTitle"
|
||||
>
|
||||
Title with Large Emoji
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`TooltipContent have correct structure with title and shortcut 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="tooltipContent"
|
||||
>
|
||||
<span
|
||||
class="tooltipContentTitleContainer"
|
||||
>
|
||||
<span
|
||||
class="tooltipContentTitle"
|
||||
>
|
||||
Title with Shortcut
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="tooltipContentShortcut"
|
||||
>
|
||||
<mark
|
||||
class="shortcut-key shortcut-key--tooltip"
|
||||
>
|
||||
Ctrl
|
||||
</mark>
|
||||
<mark
|
||||
class="shortcut-key shortcut-key--tooltip"
|
||||
>
|
||||
K
|
||||
</mark>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`TooltipContent have correct structure with title, emoji and hint 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="tooltipContent"
|
||||
>
|
||||
<span
|
||||
class="tooltipContentTitleContainer"
|
||||
>
|
||||
<span
|
||||
class="tooltipContentEmoji"
|
||||
>
|
||||
<span
|
||||
aria-label=":smile:"
|
||||
class="emoticon"
|
||||
data-emoticon="smile"
|
||||
style="background-image: url("/static/emoji/1F604.png"); background-size: contain; height: 16px; width: 16px; max-height: 16px; max-width: 16px; min-height: 16px; min-width: 16px; overflow: hidden;"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="tooltipContentTitle"
|
||||
>
|
||||
Title with Hint
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="tooltipContentHint"
|
||||
>
|
||||
This is a hint
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
21
webapp/platform/shared/src/components/tooltip/constants.ts
Normal file
21
webapp/platform/shared/src/components/tooltip/constants.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// Some of these constants duplicate ones in utils/constants.tsx in the web app.
|
||||
|
||||
export const RootHtmlPortalId = 'root-portal';
|
||||
|
||||
export const OverlaysTimings = {
|
||||
CURSOR_REST_TIME_BEFORE_OPEN: 400, // in ms
|
||||
CURSOR_MOUSEOVER_TO_OPEN: 400, // in ms
|
||||
CURSOR_MOUSEOUT_TO_CLOSE: 0,
|
||||
CURSOR_MOUSEOUT_TO_CLOSE_WITH_DELAY: 200, // in ms
|
||||
FADE_IN_DURATION: 250, // in ms
|
||||
FADE_OUT_DURATION: 150, // in ms
|
||||
};
|
||||
|
||||
export const OverlayTransitionStyles = {
|
||||
START: {
|
||||
opacity: 0,
|
||||
},
|
||||
};
|
||||
6
webapp/platform/shared/src/components/tooltip/index.ts
Normal file
6
webapp/platform/shared/src/components/tooltip/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export type {ShortcutDefinition} from './tooltip_shortcut';
|
||||
export {WithTooltip} from './with_tooltip';
|
||||
export type {WithTooltipProps} from './with_tooltip';
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import TooltipContent from './tooltip_content';
|
||||
|
||||
import {renderWithContext} from '../../testing';
|
||||
|
||||
describe('TooltipContent', () => {
|
||||
test('have correct structure with just title', () => {
|
||||
const {container} = renderWithContext(
|
||||
<TooltipContent title='Title'/>,
|
||||
);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('have correct structure with title and emoji', () => {
|
||||
const {container} = renderWithContext(
|
||||
<TooltipContent
|
||||
title='Title with Emoji'
|
||||
emoji='smile'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('have correct structure with title and large emoji', () => {
|
||||
const {container} = renderWithContext(
|
||||
<TooltipContent
|
||||
title='Title with Large Emoji'
|
||||
emoji='smile'
|
||||
isEmojiLarge={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('have correct structure with title, emoji and hint', () => {
|
||||
const {container} = renderWithContext(
|
||||
<TooltipContent
|
||||
title='Title with Hint'
|
||||
hint='This is a hint'
|
||||
emoji='smile'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('have correct structure with title and shortcut', () => {
|
||||
const shortcut = {
|
||||
default: ['Ctrl', 'K'],
|
||||
};
|
||||
|
||||
const {container} = renderWithContext(
|
||||
<TooltipContent
|
||||
title='Title with Shortcut'
|
||||
shortcut={shortcut}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import type {ReactNode} from 'react';
|
||||
import React, {memo} from 'react';
|
||||
import type {MessageDescriptor} from 'react-intl';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import TooltipShortcut from './tooltip_shortcut';
|
||||
import {type ShortcutDefinition} from './tooltip_shortcut';
|
||||
|
||||
import {isMessageDescriptor} from '../../utils/i18n';
|
||||
import {Emoji} from '../emoji';
|
||||
|
||||
const TOOLTIP_EMOTICON_SIZE = 16;
|
||||
const TOOLTIP_EMOTICON_LARGE_SIZE = 48;
|
||||
|
||||
interface Props {
|
||||
title: string | ReactNode | MessageDescriptor;
|
||||
emoji?: string;
|
||||
isEmojiLarge?: boolean;
|
||||
hint?: string | ReactNode | MessageDescriptor;
|
||||
shortcut?: ShortcutDefinition;
|
||||
}
|
||||
|
||||
function TooltipContent(props: Props) {
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
let title = props.title;
|
||||
if (isMessageDescriptor(title)) {
|
||||
title = formatMessage(title);
|
||||
}
|
||||
|
||||
let hint = props.hint;
|
||||
if (isMessageDescriptor(hint)) {
|
||||
hint = formatMessage(hint);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='tooltipContent'>
|
||||
<span
|
||||
className={classNames('tooltipContentTitleContainer', {
|
||||
isEmojiLarge: props.isEmojiLarge,
|
||||
})}
|
||||
>
|
||||
{props.emoji && (
|
||||
<span className='tooltipContentEmoji'>
|
||||
<Emoji
|
||||
emojiName={props.emoji}
|
||||
size={props.isEmojiLarge ? TOOLTIP_EMOTICON_LARGE_SIZE : TOOLTIP_EMOTICON_SIZE}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span className='tooltipContentTitle'>{title}</span>
|
||||
</span>
|
||||
{props.hint && (
|
||||
<span className='tooltipContentHint'>{hint}</span>
|
||||
)}
|
||||
{props.shortcut && (
|
||||
<span className='tooltipContentShortcut'>
|
||||
<TooltipShortcut shortcut={props.shortcut}/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(TooltipContent);
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {screen} from '@testing-library/dom';
|
||||
import React from 'react';
|
||||
|
||||
import TooltipShortcut from './tooltip_shortcut';
|
||||
|
||||
import {renderWithContext} from '../../testing';
|
||||
import * as userAgentUtils from '../../utils/user_agent';
|
||||
import {ShortcutKeys} from '../shortcut_key';
|
||||
|
||||
jest.mock('../../utils/user_agent', () => ({
|
||||
isMac: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('TooltipShortcut', () => {
|
||||
const isMacMock = jest.mocked(userAgentUtils.isMac);
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
test('should show non mac shortcut when on non mac', () => {
|
||||
isMacMock.mockReturnValue(false);
|
||||
const shortcut = {
|
||||
default: ['Ctrl', 'K'],
|
||||
mac: ['⌘', 'K'],
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<TooltipShortcut shortcut={shortcut}/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Ctrl')).toBeInTheDocument();
|
||||
expect(screen.getByText('K')).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText('⌘')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show mac shortcut when on mac', () => {
|
||||
isMacMock.mockReturnValue(true);
|
||||
|
||||
const shortcut = {
|
||||
default: ['Ctrl', 'K'],
|
||||
mac: ['⌘', 'K'],
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<TooltipShortcut shortcut={shortcut}/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('⌘')).toBeInTheDocument();
|
||||
expect(screen.getByText('K')).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText('Ctrl')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('show shortcut with message descriptor', () => {
|
||||
const shortcut = {
|
||||
default: [
|
||||
ShortcutKeys.enter,
|
||||
],
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<TooltipShortcut shortcut={shortcut}/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Enter')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render with tooltip variant styling', () => {
|
||||
const shortcut = {
|
||||
default: ['K'],
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<TooltipShortcut shortcut={shortcut}/>,
|
||||
);
|
||||
|
||||
const keyElement = screen.getByText('K');
|
||||
expect(keyElement).toHaveClass('shortcut-key--tooltip');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {memo} from 'react';
|
||||
import type {MessageDescriptor} from 'react-intl';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import {isMessageDescriptor} from '../../utils/i18n';
|
||||
import {isMac} from '../../utils/user_agent';
|
||||
import {ShortcutKey, ShortcutKeyVariant} from '../shortcut_key';
|
||||
|
||||
type ShortcutKeyDescriptor = string | MessageDescriptor;
|
||||
|
||||
export type ShortcutDefinition = {
|
||||
default: ShortcutKeyDescriptor[];
|
||||
mac?: ShortcutKeyDescriptor[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
shortcut: ShortcutDefinition;
|
||||
};
|
||||
|
||||
function TooltipShortcut(props: Props) {
|
||||
let shortcut = props.shortcut.default;
|
||||
if (props.shortcut.mac && isMac()) {
|
||||
shortcut = props.shortcut.mac;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{shortcut.map((shortcutKey) => {
|
||||
let key;
|
||||
let content;
|
||||
if (isMessageDescriptor(shortcutKey)) {
|
||||
key = shortcutKey.id;
|
||||
content = <FormattedMessage {...shortcutKey}/>;
|
||||
} else {
|
||||
key = shortcutKey;
|
||||
content = shortcutKey;
|
||||
}
|
||||
|
||||
return (
|
||||
<ShortcutKey
|
||||
key={key}
|
||||
variant={ShortcutKeyVariant.Tooltip}
|
||||
>
|
||||
{content}
|
||||
</ShortcutKey>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(TooltipShortcut);
|
||||
@@ -0,0 +1,66 @@
|
||||
.tooltipContainer {
|
||||
z-index: var(--z-index-tooltip);
|
||||
max-width: 220px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 1);
|
||||
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.12);
|
||||
line-height: 18px;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
|
||||
> .tooltipContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: "Open Sans", sans-serif;
|
||||
|
||||
> .tooltipContentTitleContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
|
||||
&.isEmojiLarge {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
> .tooltipContentEmoji {
|
||||
padding-top: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
> .tooltipContentEmoji {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
> .tooltipContentTitle {
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
> .tooltipContentShortcut {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
> .tooltipContentHint {
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {screen, waitFor} from '@testing-library/dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import {WithTooltip} from './with_tooltip';
|
||||
|
||||
import {renderWithContext} from '../../testing';
|
||||
|
||||
jest.mock('@mattermost/shared/utils/user_agent', () => ({
|
||||
isMac: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
describe('WithTooltip', () => {
|
||||
test('renders children correctly', async () => {
|
||||
renderWithContext(
|
||||
<WithTooltip title='TooltipOfButton'>
|
||||
<button>{'I am a button surrounded by a tooltip'}</button>
|
||||
</WithTooltip>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('I am a button surrounded by a tooltip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows tooltip on hover', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
renderWithContext(
|
||||
<WithTooltip title='Tooltip will appear on hover'>
|
||||
<div>{'Hover Me'}</div>
|
||||
</WithTooltip>,
|
||||
);
|
||||
|
||||
await userEvent.hover(screen.getByText('Hover Me'), {advanceTimers: jest.advanceTimersByTime});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Tooltip will appear on hover')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('shows tooltip on focus', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
renderWithContext(
|
||||
<WithTooltip title='Tooltip will appear on hover'>
|
||||
<button>{'Hover Me'}</button>
|
||||
</WithTooltip>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByText('Hover Me');
|
||||
|
||||
// Clicking the button will simulate a focus event
|
||||
await userEvent.click(trigger, {advanceTimers: jest.advanceTimersByTime});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(trigger).toHaveFocus();
|
||||
expect(screen.getByText('Tooltip will appear on hover')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('calls onOpen when tooltip appears', async () => {
|
||||
const onOpen = jest.fn();
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
renderWithContext(
|
||||
<WithTooltip
|
||||
title='Tooltip will appear on hover'
|
||||
onOpen={onOpen}
|
||||
>
|
||||
<div>{'Hover Me'}</div>
|
||||
</WithTooltip>,
|
||||
);
|
||||
|
||||
expect(onOpen).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.hover(screen.getByText('Hover Me'), {advanceTimers: jest.advanceTimersByTime});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Tooltip will appear on hover')).toBeInTheDocument();
|
||||
expect(onOpen).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
207
webapp/platform/shared/src/components/tooltip/with_tooltip.tsx
Normal file
207
webapp/platform/shared/src/components/tooltip/with_tooltip.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Placement} from '@floating-ui/react';
|
||||
import {
|
||||
useFloating,
|
||||
autoUpdate,
|
||||
offset,
|
||||
useHover,
|
||||
useFocus,
|
||||
useDismiss,
|
||||
useRole,
|
||||
useInteractions,
|
||||
arrow,
|
||||
FloatingPortal,
|
||||
useTransitionStyles,
|
||||
FloatingArrow,
|
||||
flip,
|
||||
shift,
|
||||
useMergeRefs,
|
||||
} from '@floating-ui/react';
|
||||
import classNames from 'classnames';
|
||||
import React, {useRef, useState, useMemo, cloneElement, isValidElement} from 'react';
|
||||
import type {ReactElement, ReactNode} from 'react';
|
||||
import type {MessageDescriptor} from 'react-intl';
|
||||
|
||||
import {OverlaysTimings, OverlayTransitionStyles, RootHtmlPortalId} from './constants';
|
||||
import TooltipContent from './tooltip_content';
|
||||
import type {ShortcutDefinition} from './tooltip_shortcut';
|
||||
|
||||
import './with_tooltip.css';
|
||||
|
||||
export const OverlayArrow = {
|
||||
WIDTH: 10, // in px
|
||||
HEIGHT: 6, // in px
|
||||
OFFSET: 8, // in px
|
||||
};
|
||||
|
||||
export interface WithTooltipProps {
|
||||
title: string | ReactNode | MessageDescriptor;
|
||||
emoji?: string;
|
||||
isEmojiLarge?: boolean;
|
||||
hint?: string | ReactNode | MessageDescriptor;
|
||||
shortcut?: ShortcutDefinition;
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* Whether the tooltip should be vertical or horizontal, by default it is vertical
|
||||
* This doesn't always guarantee the tooltip will be vertical, it just determines the initial placement and fallback placements
|
||||
*/
|
||||
isVertical?: boolean;
|
||||
|
||||
/**
|
||||
* If closing of the tooltip should be delayed,
|
||||
* Useful if tooltips contains links that need to be clicked
|
||||
*/
|
||||
delayClose?: boolean;
|
||||
|
||||
/**
|
||||
* Additional class name to be added to the tooltip container
|
||||
*/
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* @deprecated Do not use this except for special cases
|
||||
* Callback when the tooltip appears
|
||||
*/
|
||||
onOpen?: () => void;
|
||||
children: ReactElement<any>;
|
||||
|
||||
forcedPlacement?: Placement;
|
||||
}
|
||||
|
||||
export function WithTooltip({
|
||||
children,
|
||||
title,
|
||||
emoji,
|
||||
isEmojiLarge = false,
|
||||
hint,
|
||||
shortcut,
|
||||
isVertical = true,
|
||||
delayClose = false,
|
||||
className,
|
||||
onOpen,
|
||||
disabled,
|
||||
forcedPlacement,
|
||||
id,
|
||||
}: WithTooltipProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const arrowRef = useRef(null);
|
||||
|
||||
function handleChange(open: boolean) {
|
||||
setOpen(open);
|
||||
|
||||
if (onOpen && open) {
|
||||
onOpen();
|
||||
}
|
||||
}
|
||||
|
||||
const placements = useMemo<{initial: Placement; fallback: Placement[]}>(() => {
|
||||
// if an explicit placement is provided, use it exclusively
|
||||
if (forcedPlacement) {
|
||||
return {initial: forcedPlacement, fallback: [forcedPlacement]};
|
||||
}
|
||||
|
||||
let initial: Placement;
|
||||
let fallback: Placement[];
|
||||
if (isVertical) {
|
||||
initial = 'top';
|
||||
fallback = ['bottom', 'right', 'left'];
|
||||
} else {
|
||||
initial = 'right';
|
||||
fallback = ['left', 'top', 'bottom'];
|
||||
}
|
||||
return {initial, fallback};
|
||||
}, [isVertical]);
|
||||
|
||||
const {refs: {setReference, setFloating}, floatingStyles, context: floatingContext} = useFloating({
|
||||
open: disabled ? false : open,
|
||||
onOpenChange: handleChange,
|
||||
whileElementsMounted: autoUpdate,
|
||||
placement: placements.initial,
|
||||
middleware: [
|
||||
offset(OverlayArrow.OFFSET),
|
||||
flip({
|
||||
fallbackPlacements: placements.fallback,
|
||||
}),
|
||||
shift({padding: OverlayArrow.OFFSET}),
|
||||
arrow({
|
||||
element: arrowRef,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const {isMounted, styles: transitionStyles} = useTransitionStyles(floatingContext, TRANSITION_STYLE_PROPS);
|
||||
|
||||
const hover = useHover(floatingContext, {
|
||||
restMs: OverlaysTimings.CURSOR_REST_TIME_BEFORE_OPEN,
|
||||
delay: {
|
||||
open: OverlaysTimings.CURSOR_MOUSEOVER_TO_OPEN,
|
||||
close: delayClose ? OverlaysTimings.CURSOR_MOUSEOUT_TO_CLOSE_WITH_DELAY : OverlaysTimings.CURSOR_MOUSEOUT_TO_CLOSE,
|
||||
},
|
||||
});
|
||||
const focus = useFocus(floatingContext);
|
||||
const dismiss = useDismiss(floatingContext);
|
||||
const role = useRole(floatingContext, {role: 'tooltip'});
|
||||
|
||||
const {getReferenceProps, getFloatingProps} = useInteractions([hover, focus, dismiss, role]);
|
||||
|
||||
if (!isValidElement(children)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Children must be a valid React element for WithTooltip');
|
||||
}
|
||||
|
||||
// Since React 19, a child's ref is part of its props rather than being exposed as `element.ref`
|
||||
const mergedRefs = useMergeRefs([setReference, children.props?.ref]);
|
||||
|
||||
const trigger = cloneElement(
|
||||
children,
|
||||
getReferenceProps({
|
||||
...children.props,
|
||||
ref: mergedRefs,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
|
||||
{isMounted && (
|
||||
<FloatingPortal id={RootHtmlPortalId}>
|
||||
<div
|
||||
ref={setFloating}
|
||||
className={classNames('tooltipContainer', className)}
|
||||
style={{...floatingStyles, ...transitionStyles}}
|
||||
{...getFloatingProps()}
|
||||
id={id}
|
||||
>
|
||||
<TooltipContent
|
||||
title={title}
|
||||
emoji={emoji}
|
||||
isEmojiLarge={isEmojiLarge}
|
||||
hint={hint}
|
||||
shortcut={shortcut}
|
||||
/>
|
||||
<FloatingArrow
|
||||
ref={arrowRef}
|
||||
context={floatingContext}
|
||||
width={OverlayArrow.WIDTH}
|
||||
height={OverlayArrow.HEIGHT}
|
||||
/>
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const TRANSITION_STYLE_PROPS = {
|
||||
duration: {
|
||||
open: OverlaysTimings.FADE_IN_DURATION,
|
||||
close: OverlaysTimings.FADE_OUT_DURATION,
|
||||
},
|
||||
initial: OverlayTransitionStyles.START,
|
||||
};
|
||||
46
webapp/platform/shared/src/context/context.tsx
Normal file
46
webapp/platform/shared/src/context/context.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useMemo} from 'react';
|
||||
|
||||
import type {Emoji} from '@mattermost/types/emojis';
|
||||
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
|
||||
export interface SharedContextValue {
|
||||
useEmojiByName: (name: string) => Emoji | undefined;
|
||||
useEmojiUrl: (emoji?: Emoji) => string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__MATTERMOST_SHARED_CONTEXT__: React.Context<SharedContextValue> | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// If multiple copies of the shared package happen to be loaded, this makes them share the same context. In practice,
|
||||
// // this should never happen because the web app is supposed to provide the only copy of @mattermost/shared,
|
||||
// but I borrowed the idea from React Intl.
|
||||
export const SharedContext = window?.__MATTERMOST_SHARED_CONTEXT__ ?? (
|
||||
window.__MATTERMOST_SHARED_CONTEXT__ = React.createContext<SharedContextValue>(
|
||||
null as unknown as SharedContextValue,
|
||||
)
|
||||
);
|
||||
SharedContext.displayName = 'MattermostSharedContext';
|
||||
|
||||
export interface SharedProviderProps extends SharedContextValue {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SharedProvider({
|
||||
children,
|
||||
useEmojiByName,
|
||||
useEmojiUrl,
|
||||
}: SharedProviderProps) {
|
||||
const contextValue = useMemo(() => ({
|
||||
useEmojiByName,
|
||||
useEmojiUrl,
|
||||
}), [useEmojiByName, useEmojiUrl]);
|
||||
|
||||
return <SharedContext.Provider value={contextValue}>{children}</SharedContext.Provider>;
|
||||
}
|
||||
5
webapp/platform/shared/src/context/index.ts
Normal file
5
webapp/platform/shared/src/context/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {SharedProvider} from './context';
|
||||
export type {SharedProviderProps} from './context';
|
||||
12
webapp/platform/shared/src/context/useEmojiByName.ts
Normal file
12
webapp/platform/shared/src/context/useEmojiByName.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {SharedContext} from './context';
|
||||
|
||||
export function useEmojiByName(name: string) {
|
||||
const context = React.useContext(SharedContext);
|
||||
|
||||
return context.useEmojiByName(name);
|
||||
}
|
||||
14
webapp/platform/shared/src/context/useEmojiUrl.ts
Normal file
14
webapp/platform/shared/src/context/useEmojiUrl.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type {Emoji} from '@mattermost/types/emojis';
|
||||
|
||||
import {SharedContext} from './context';
|
||||
|
||||
export function useEmojiUrl(emoji?: Emoji) {
|
||||
const context = React.useContext(SharedContext);
|
||||
|
||||
return context.useEmojiUrl(emoji);
|
||||
}
|
||||
4
webapp/platform/shared/src/testing/index.ts
Normal file
4
webapp/platform/shared/src/testing/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {renderHookWithContext, renderWithContext} from './react_testing_utils';
|
||||
112
webapp/platform/shared/src/testing/react_testing_utils.tsx
Normal file
112
webapp/platform/shared/src/testing/react_testing_utils.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {render, renderHook} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import {IntlProvider} from 'react-intl';
|
||||
|
||||
import {useMockSharedContext} from './useMockSharedContext';
|
||||
|
||||
import type {SharedProviderProps} from '../context';
|
||||
|
||||
export type FullContextOptions = {
|
||||
intlMessages?: Record<string, string>;
|
||||
locale?: string;
|
||||
sharedContext?: Partial<Omit<SharedProviderProps, 'children'>>;
|
||||
};
|
||||
|
||||
export const renderWithContext = (
|
||||
component: React.ReactElement<any>,
|
||||
|
||||
partialOptions?: FullContextOptions,
|
||||
) => {
|
||||
const options = {
|
||||
intlMessages: partialOptions?.intlMessages,
|
||||
locale: partialOptions?.locale ?? 'en',
|
||||
sharedContext: partialOptions?.sharedContext,
|
||||
};
|
||||
|
||||
// Store these in an object so that they can be maintained through rerenders
|
||||
const renderState = {
|
||||
component,
|
||||
|
||||
options,
|
||||
};
|
||||
|
||||
const results = render(component, {
|
||||
wrapper: ({children}) => {
|
||||
// Every time this is called, these values should be updated from `renderState`
|
||||
return <Providers {...renderState}>{children}</Providers>;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...results,
|
||||
rerender: (newComponent: React.ReactElement<any>) => {
|
||||
renderState.component = newComponent;
|
||||
|
||||
results.rerender(renderState.component);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const renderHookWithContext = <TProps, TResult>(
|
||||
callback: (props: TProps) => TResult,
|
||||
|
||||
partialOptions?: FullContextOptions,
|
||||
) => {
|
||||
const options = {
|
||||
intlMessages: partialOptions?.intlMessages,
|
||||
locale: partialOptions?.locale ?? 'en',
|
||||
sharedContext: partialOptions?.sharedContext,
|
||||
};
|
||||
|
||||
// Store these in an object so that they can be maintained through rerenders
|
||||
const renderState = {
|
||||
callback,
|
||||
options,
|
||||
};
|
||||
|
||||
const results = renderHook(callback, {
|
||||
wrapper: ({children}) => {
|
||||
// Every time this is called, these values should be updated from `renderState`
|
||||
return <Providers {...renderState}>{children}</Providers>;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...results,
|
||||
};
|
||||
};
|
||||
|
||||
type Opts = {
|
||||
intlMessages: Record<string, string> | undefined;
|
||||
locale: string;
|
||||
sharedContext?: Partial<Omit<SharedProviderProps, 'children'>>;
|
||||
};
|
||||
|
||||
type RenderStateProps = {
|
||||
children: React.ReactNode;
|
||||
|
||||
options: Opts;
|
||||
};
|
||||
|
||||
// This should wrap the component in roughly the same providers used in App and RootProvider
|
||||
const Providers = ({
|
||||
children,
|
||||
|
||||
options,
|
||||
}: RenderStateProps) => {
|
||||
const {SharedContextProvider} = useMockSharedContext(options?.sharedContext ?? {});
|
||||
|
||||
return (
|
||||
<SharedContextProvider>
|
||||
<IntlProvider
|
||||
locale={options.locale}
|
||||
messages={options.intlMessages}
|
||||
>
|
||||
{children}
|
||||
</IntlProvider>
|
||||
</SharedContextProvider>
|
||||
);
|
||||
};
|
||||
77
webapp/platform/shared/src/testing/useMockSharedContext.tsx
Normal file
77
webapp/platform/shared/src/testing/useMockSharedContext.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
|
||||
import {isSystemEmoji, type CustomEmoji, type Emoji, type SystemEmoji} from '@mattermost/types/emojis';
|
||||
|
||||
import {SharedProvider, type SharedProviderProps} from '../context/context';
|
||||
|
||||
const mockEmojisByName = {
|
||||
smile: {
|
||||
name: 'SSMILING FACE WITH OPEN MOUTH AND SMILING EYES',
|
||||
unified: '1F604',
|
||||
short_name: 'smile',
|
||||
short_names: [
|
||||
'smile',
|
||||
],
|
||||
category: 'smileys-emotion',
|
||||
} as SystemEmoji,
|
||||
smiley: {
|
||||
name: 'SMILING FACE WITH OPEN MOUTH',
|
||||
unified: '1F603',
|
||||
short_name: 'smiley',
|
||||
short_names: [
|
||||
'smiley',
|
||||
],
|
||||
category: 'smileys-emotion',
|
||||
} as SystemEmoji,
|
||||
'custom-emoji-1': {
|
||||
id: 'custom-emoji-id-1',
|
||||
name: 'custom-emoji-1',
|
||||
category: 'custom',
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
creator_id: 'user-id-1',
|
||||
} as CustomEmoji,
|
||||
};
|
||||
|
||||
export function useMockSharedContext({
|
||||
useEmojiByName,
|
||||
useEmojiUrl,
|
||||
}: Partial<Omit<SharedProviderProps, 'children'>>) {
|
||||
const propsWithOverrides = useMemo(() => {
|
||||
return {
|
||||
useEmojiByName: useEmojiByName ?? ((name: string) => {
|
||||
if (!Object.hasOwn(mockEmojisByName, name)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return mockEmojisByName[name as keyof typeof mockEmojisByName];
|
||||
}),
|
||||
useEmojiUrl: useEmojiUrl ?? ((emoji?: Emoji) => {
|
||||
// This doesn't 100% follow getEmojiImageUrl, but it's close enough for testing
|
||||
if (!emoji) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (isSystemEmoji(emoji)) {
|
||||
return `/static/emoji/${emoji.unified}.png`;
|
||||
}
|
||||
|
||||
return `/api/v4/emojis/${emoji.id}`;
|
||||
}),
|
||||
};
|
||||
}, [useEmojiByName, useEmojiUrl]);
|
||||
|
||||
const SharedContextProvider = useCallback(({children}: Pick<SharedProviderProps, 'children'>) => {
|
||||
return (
|
||||
<SharedProvider {...propsWithOverrides}>
|
||||
{children}
|
||||
</SharedProvider>
|
||||
);
|
||||
}, [propsWithOverrides]);
|
||||
|
||||
return {SharedContextProvider};
|
||||
}
|
||||
163
webapp/platform/shared/src/types/global/editor.ts
Normal file
163
webapp/platform/shared/src/types/global/editor.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {
|
||||
ComponentType,
|
||||
ForwardRefExoticComponent,
|
||||
KeyboardEvent,
|
||||
KeyboardEventHandler,
|
||||
ReactNode,
|
||||
RefAttributes,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
|
||||
import type {Agent} from '@mattermost/types/agents';
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {Group} from '@mattermost/types/groups';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import type {ProviderResults, SuggestionResults} from './suggestions';
|
||||
|
||||
// Plugin-facing contract for window.WebappUtils.editor. The web app asserts each
|
||||
// shape stays assignable to the real component/provider at build time in
|
||||
// published_editor.ts.
|
||||
|
||||
export type ActionResult<Data = unknown, Error = unknown> = {
|
||||
data?: Data;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
export type WysiwygEditorProps = {
|
||||
value: string;
|
||||
onChange: (content: string) => void;
|
||||
onSubmit: () => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
placeholder?: string;
|
||||
channelId: string;
|
||||
rootId?: string;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
id?: string;
|
||||
useCtrlSend?: boolean;
|
||||
sendCodeBlockOnCtrlEnter?: boolean;
|
||||
onKeyDown?: (e: KeyboardEvent<HTMLDivElement>) => void;
|
||||
|
||||
// 'json' reads and emits stringified ProseMirror JSON. Mount-only.
|
||||
contentType?: 'markdown' | 'json';
|
||||
|
||||
// Mount-only. `any[]` so consumers don't need `@tiptap/core` transitively.
|
||||
extensions?: any[];
|
||||
|
||||
// Any content error in 'json' mode, for the editor's lifetime. See
|
||||
// hasContentError() for the autosave-gating contract.
|
||||
onContentError?: (error: Error) => void;
|
||||
};
|
||||
|
||||
export type SuggestionListProps = {
|
||||
inputRef?: RefObject<HTMLDivElement | null>;
|
||||
open: boolean;
|
||||
position?: 'top' | 'bottom';
|
||||
renderNoResults?: boolean;
|
||||
onCompleteWord: (term: string, matchedPretext: string, e?: KeyboardEventHandler<HTMLDivElement>) => boolean;
|
||||
preventClose?: () => void;
|
||||
onItemHover: (term: string) => void;
|
||||
pretext: string;
|
||||
cleared: boolean;
|
||||
results: SuggestionResults;
|
||||
selection: string;
|
||||
suggestionBoxAlgn?: {
|
||||
lineHeight?: number;
|
||||
pixelsToMoveX?: number;
|
||||
pixelsToMoveY?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type PublishedMarkdownMode = 'bold' | 'italic' | 'link' | 'strike' | 'code' | 'heading' | 'quote' | 'ul' | 'ol';
|
||||
|
||||
export type FormattingBarProps = {
|
||||
applyFormatting: (mode: PublishedMarkdownMode) => void;
|
||||
disableControls: boolean;
|
||||
location: string;
|
||||
additionalControls?: readonly ReactNode[];
|
||||
aiActionsMenu?: ReactNode;
|
||||
|
||||
// Returns a Tiptap Editor. Left as `any` so plugins don't need `@tiptap/react`
|
||||
getEditor?: () => any;
|
||||
};
|
||||
|
||||
export type PublishedEditorComponentProps = {
|
||||
wysiwyg_editor: WysiwygEditorProps;
|
||||
suggestion_list: SuggestionListProps;
|
||||
formatting_bar: FormattingBarProps;
|
||||
};
|
||||
|
||||
export type PublishedEditorComponentId = keyof PublishedEditorComponentProps;
|
||||
|
||||
// Providers emit ProviderResults; SuggestionList consumes the normalized SuggestionResults.
|
||||
export type SuggestionProviderInstance = {
|
||||
triggerCharacter?: string;
|
||||
handlePretextChanged: (pretext: string, resultsCallback: (results: ProviderResults) => void) => boolean | void;
|
||||
};
|
||||
|
||||
export type AtMentionProviderOptions = {
|
||||
currentUserId: string;
|
||||
channelId: string;
|
||||
autocompleteUsersInChannel: (prefix: string) => Promise<ActionResult>;
|
||||
useChannelMentions: boolean;
|
||||
autocompleteGroups: Group[] | null;
|
||||
searchAssociatedGroupsForReference: (prefix: string) => Promise<ActionResult<Group[]>>;
|
||||
priorityProfiles: UserProfile[] | undefined;
|
||||
defaultAgent?: Agent;
|
||||
};
|
||||
|
||||
export type CommandProviderOptions = {
|
||||
teamId: string;
|
||||
channelId: string;
|
||||
rootId?: string;
|
||||
};
|
||||
|
||||
export type ChannelMentionProviderArgs = [
|
||||
channelSearchFunc: (
|
||||
term: string,
|
||||
success: (channels: Channel[]) => void,
|
||||
error: () => void,
|
||||
) => Promise<ActionResult>,
|
||||
delayChannelAutocomplete: boolean,
|
||||
];
|
||||
|
||||
export type PublishedSuggestionProviderConstructors = {
|
||||
AtMention: new (options: AtMentionProviderOptions) => SuggestionProviderInstance;
|
||||
ChannelMention: new (...args: ChannelMentionProviderArgs) => SuggestionProviderInstance;
|
||||
Command: new (options: CommandProviderOptions) => SuggestionProviderInstance;
|
||||
Emoticon: new () => SuggestionProviderInstance;
|
||||
};
|
||||
|
||||
export type PublishedSuggestionProviderId = keyof PublishedSuggestionProviderConstructors;
|
||||
|
||||
export type PublishedWysiwygEditorHandle = {
|
||||
insertText: (text: string) => void;
|
||||
focus: () => void;
|
||||
blur: () => void;
|
||||
getInputBox: () => HTMLElement | null;
|
||||
|
||||
// Null until the mount effect runs, so a useLayoutEffect can still miss it.
|
||||
// In 'json' mode use getJSON(); getMarkdown() isn't attached.
|
||||
getEditor: () => any;
|
||||
|
||||
// True when the initial `value` failed to load in 'json' mode. Autosaving
|
||||
// consumers must gate the first onChange on this, or the empty fallback
|
||||
// overwrites the source. Load-only, so it can't stall a healthy session.
|
||||
hasContentError: () => boolean;
|
||||
};
|
||||
|
||||
export type PublishedFormattingBarHandle = {
|
||||
openLinkPopover: () => void;
|
||||
};
|
||||
|
||||
export type PublishedEditorUtils = {
|
||||
WysiwygEditor: ForwardRefExoticComponent<WysiwygEditorProps & RefAttributes<PublishedWysiwygEditorHandle>>;
|
||||
SuggestionList: ComponentType<SuggestionListProps>;
|
||||
FormattingBar: ForwardRefExoticComponent<FormattingBarProps & RefAttributes<PublishedFormattingBarHandle>>;
|
||||
providers: PublishedSuggestionProviderConstructors;
|
||||
};
|
||||
18
webapp/platform/shared/src/types/global/index.ts
Normal file
18
webapp/platform/shared/src/types/global/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {PublishedEditorUtils} from './editor';
|
||||
import type {PublishedModalUtils} from './modals';
|
||||
|
||||
export * from './modals';
|
||||
export * from './editor';
|
||||
export * from './suggestions';
|
||||
|
||||
// Lets plugins type the window global without reaching into channels internals.
|
||||
// Grows as more of channels' WindowWithLibraries surface migrates here.
|
||||
export type WindowShared = {
|
||||
WebappUtils: {
|
||||
modals: PublishedModalUtils;
|
||||
editor: PublishedEditorUtils;
|
||||
};
|
||||
};
|
||||
34
webapp/platform/shared/src/types/global/modals.ts
Normal file
34
webapp/platform/shared/src/types/global/modals.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
|
||||
// Plugin-facing contract for the `window.WebappUtils` API. Hand-authored, not derived:
|
||||
// the web app checks at build time that each stays assignable to the real modal, so
|
||||
// drift breaks the build, not plugins.
|
||||
|
||||
export type UserSettingsModalProps = {isContentProductSettings: boolean; userID?: string; adminMode?: boolean; activeTab?: string};
|
||||
export type InvitationModalProps = {channelToInvite?: Channel; canInviteGuests?: boolean};
|
||||
export type TeamSettingsModalProps = {focusOriginElement?: string};
|
||||
export type TeamMembersModalProps = {onLoad?: () => void; focusOriginElement?: string};
|
||||
export type LeaveTeamModalProps = Record<string, never>;
|
||||
|
||||
export type PublishedModalProps = {
|
||||
user_settings: UserSettingsModalProps;
|
||||
invitation: InvitationModalProps;
|
||||
team_settings: TeamSettingsModalProps;
|
||||
team_members: TeamMembersModalProps;
|
||||
leave_team: LeaveTeamModalProps;
|
||||
};
|
||||
|
||||
export type PublishedModalId = keyof PublishedModalProps;
|
||||
|
||||
// A known modal id, or any string: lets a plugin feature-detect (via canOpenModalId)
|
||||
// ids a running web app may not publish yet, while keeping autocomplete for known ids.
|
||||
export type PublishedModalIdCandidate = PublishedModalId | (string & {});
|
||||
|
||||
// The modal opener utils the web app publishes on window.WebappUtils.modals.
|
||||
export type PublishedModalUtils = {
|
||||
openModalById: <K extends PublishedModalId>(modalId: K, dialogProps?: PublishedModalProps[K]) => void;
|
||||
canOpenModalId: (modalId: PublishedModalIdCandidate) => boolean;
|
||||
};
|
||||
59
webapp/platform/shared/src/types/global/suggestions.ts
Normal file
59
webapp/platform/shared/src/types/global/suggestions.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {ElementType} from 'react';
|
||||
import type {MessageDescriptor} from 'react-intl';
|
||||
|
||||
// Data shapes produced by suggestion providers and consumed by SuggestionList.
|
||||
|
||||
export type SuggestionResults<Item = unknown> = SuggestionResultsGrouped<Item> | SuggestionResultsUngrouped<Item>;
|
||||
|
||||
type SuggestionResultsGrouped<Item = unknown> = {
|
||||
matchedPretext: string;
|
||||
groups: Array<SuggestionResultsGroup<Item>>;
|
||||
};
|
||||
|
||||
type SuggestionResultsGroup<Item = unknown> = {
|
||||
key: string;
|
||||
label?: MessageDescriptor;
|
||||
terms: string[];
|
||||
items: Array<Item | Loading>;
|
||||
components: ElementType[];
|
||||
};
|
||||
|
||||
export type SuggestionResultsUngrouped<Item = unknown> = {
|
||||
matchedPretext: string;
|
||||
terms: string[];
|
||||
items: Array<Item | Loading>;
|
||||
components: ElementType[];
|
||||
};
|
||||
|
||||
export type Loading = {
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export type ProviderResults<Item = unknown> = ProviderResultsGrouped<Item> | ProviderResultsUngrouped<Item>;
|
||||
|
||||
type ProviderResultsGrouped<Item = unknown> = {
|
||||
matchedPretext: string;
|
||||
groups: Array<ProviderResultsGroup<Item>>;
|
||||
};
|
||||
|
||||
export type ProviderResultsGroup<Item = unknown> = {
|
||||
key: string;
|
||||
label?: MessageDescriptor;
|
||||
terms: string[];
|
||||
items: Array<Item | Loading>;
|
||||
} & ComponentOrComponents;
|
||||
|
||||
type ProviderResultsUngrouped<Item = unknown> = {
|
||||
matchedPretext: string;
|
||||
terms: string[];
|
||||
items: Array<Item | Loading>;
|
||||
} & ComponentOrComponents;
|
||||
|
||||
type ComponentOrComponents = {
|
||||
component: ElementType;
|
||||
} | {
|
||||
components: ElementType[];
|
||||
};
|
||||
8
webapp/platform/shared/src/utils/i18n/index.ts
Normal file
8
webapp/platform/shared/src/utils/i18n/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {MessageDescriptor} from 'react-intl';
|
||||
|
||||
export function isMessageDescriptor(descriptor: unknown): descriptor is MessageDescriptor {
|
||||
return Boolean(descriptor && (descriptor as MessageDescriptor).id);
|
||||
}
|
||||
120
webapp/platform/shared/src/utils/user_agent/index.ts
Normal file
120
webapp/platform/shared/src/utils/user_agent/index.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/*
|
||||
Example User Agents
|
||||
--------------------
|
||||
|
||||
Chrome:
|
||||
Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36
|
||||
|
||||
Firefox:
|
||||
Mozilla/5.0 (Windows NT 10.0; WOW64; rv:47.0) Gecko/20100101 Firefox/47.0
|
||||
|
||||
Edge:
|
||||
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Safari/537.36 Edge/13.10586
|
||||
|
||||
Desktop App:
|
||||
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Mattermost/1.2.1 Chrome/49.0.2623.75 Electron/0.37.8 Safari/537.36
|
||||
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Safari/537.36 Edge/13.10586
|
||||
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_0) AppleWebKit/537.36 (KHTML, like Gecko) Mattermost/3.4.1 Chrome/53.0.2785.113 Electron/1.4.2 Safari/537.36
|
||||
Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Mattermost/3.4.1 Chrome/51.0.2704.106 Electron/1.2.8 Safari/537.36
|
||||
|
||||
Android App:
|
||||
Mozilla/5.0 (Linux; U; Android 4.1.1; en-gb; Build/KLP) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Safari/534.30
|
||||
Mozilla/5.0 (Linux; Android 4.4; Nexus 5 Build/_BuildID_) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/30.0.0.0 Mobile Safari/537.36
|
||||
Mozilla/5.0 (Linux; Android 5.1.1; Nexus 5 Build/LMY48B; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/43.0.2357.65 Mobile Safari/537.36
|
||||
|
||||
iOS Safari:
|
||||
Mozilla/5.0 (iPhone; U; CPU like Mac OS X; en) AppleWebKit/420+ (KHTML, like Gecko) Version/3.0 Mobile/1A543 Safari/419.3
|
||||
|
||||
iOS Chrome:
|
||||
Mozilla/5.0 (iPhone; U; CPU iPhone OS 5_1_1 like Mac OS X; en) AppleWebKit/534.46.0 (KHTML, like Gecko) CriOS/19.0.1084.60 Mobile/9B206 Safari/7534.48.3
|
||||
|
||||
iOS App:
|
||||
Mozilla/5.0 (iPhone; CPU iPhone OS 9_3_2 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Mobile/13F69
|
||||
*/
|
||||
|
||||
const userAgent = () => window.navigator.userAgent;
|
||||
|
||||
export function isChrome(): boolean {
|
||||
return userAgent().indexOf('Chrome') > -1 && userAgent().indexOf('Edge') === -1;
|
||||
}
|
||||
|
||||
export function isSafari(): boolean {
|
||||
return userAgent().indexOf('Safari') !== -1 && userAgent().indexOf('Chrome') === -1;
|
||||
}
|
||||
|
||||
export function isIosChrome(): boolean {
|
||||
return userAgent().indexOf('CriOS') !== -1;
|
||||
}
|
||||
|
||||
export function isIosFirefox(): boolean {
|
||||
return userAgent().indexOf('FxiOS') !== -1;
|
||||
}
|
||||
|
||||
export function isIos(): boolean {
|
||||
return userAgent().indexOf('iPhone') !== -1 || userAgent().indexOf('iPad') !== -1;
|
||||
}
|
||||
|
||||
export function isAndroid(): boolean {
|
||||
return userAgent().indexOf('Android') !== -1;
|
||||
}
|
||||
|
||||
// Returns true if and only if the user is using Mattermost from the web browser on a mobile device.
|
||||
export function isMobile(): boolean {
|
||||
return isIos() || isAndroid();
|
||||
}
|
||||
|
||||
export function isFirefox(): boolean {
|
||||
return userAgent().indexOf('Firefox') !== -1;
|
||||
}
|
||||
|
||||
export function isChromebook(): boolean {
|
||||
return userAgent().indexOf('CrOS') !== -1;
|
||||
}
|
||||
|
||||
export function isEdge(): boolean {
|
||||
return userAgent().indexOf('Edg') !== -1 && userAgent().indexOf('Edge') === -1;
|
||||
}
|
||||
|
||||
export function isDesktopApp(): boolean {
|
||||
return userAgent().indexOf('Mattermost') !== -1 && userAgent().indexOf('Electron') !== -1;
|
||||
}
|
||||
|
||||
export function isMacApp(): boolean {
|
||||
return isDesktopApp() && isMac();
|
||||
}
|
||||
|
||||
export function isWindows(): boolean {
|
||||
return userAgent().indexOf('Windows') !== -1;
|
||||
}
|
||||
|
||||
export function isMac(): boolean {
|
||||
return userAgent().indexOf('Macintosh') !== -1;
|
||||
}
|
||||
|
||||
export function isLinux(): boolean {
|
||||
return navigator.platform.toUpperCase().indexOf('LINUX') >= 0;
|
||||
}
|
||||
|
||||
export function getDesktopVersion(): string {
|
||||
// use if the value window.desktop.version is not set yet
|
||||
const regex = /Mattermost\/(\d+\.\d+\.\d+)/gm;
|
||||
const match = regex.exec(window.navigator.appVersion)?.[1] || '';
|
||||
return match;
|
||||
}
|
||||
|
||||
function isTeamsMobile(): boolean {
|
||||
return userAgent().indexOf('TeamsMobile-Android') !== -1 ||
|
||||
userAgent().indexOf('TeamsMobile-iOS') !== -1 ||
|
||||
(isMobile() && userAgent().indexOf('Teams/') !== -1);
|
||||
}
|
||||
|
||||
function isOutlookMobile(): boolean {
|
||||
return userAgent().indexOf('PKeyAuth/1.0') !== -1;
|
||||
}
|
||||
|
||||
export function isM365Mobile(): boolean {
|
||||
return isTeamsMobile() || isOutlookMobile();
|
||||
}
|
||||
23
webapp/platform/shared/tsconfig.json
Normal file
23
webapp/platform/shared/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "es2022",
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react",
|
||||
"rootDir": "./src",
|
||||
"composite": true,
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*"
|
||||
],
|
||||
"references": [
|
||||
{"path": "../types"}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user