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

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

View File

@@ -0,0 +1,5 @@
{
"extends": "@parcel/config-default",
"bundler": "@parcel/bundler-library",
"namers": ["./build/parcel-namer-shared", "..."]
}

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
# Mattermost Shared Package
[![npm version](https://img.shields.io/npm/v/@mattermost/shared?style=flat)](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

View 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;
},
});

View 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;

View 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']}],
},
},
];

View 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'],
};

View 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
}
}

View 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';

View 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';

View File

@@ -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);
}

View 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';

View File

@@ -0,0 +1 @@
/* The styles for this component are still defined in the web app */

View 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',
});
});
});

View 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,
}}
/>
);
}

View 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';

View File

@@ -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>
`;

View File

@@ -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';

View 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',
}),
};

View File

@@ -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;
}
}

View File

@@ -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();
});
});

View File

@@ -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>
);
}

View File

@@ -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>
`;

View 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,
},
};

View 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';

View File

@@ -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();
});
});

View File

@@ -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);

View File

@@ -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');
});
});

View File

@@ -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);

View File

@@ -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;
}
}
}

View File

@@ -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();
});
});
});

View 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,
};

View 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>;
}

View 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';

View 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);
}

View 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);
}

View 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';

View 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>
);
};

View 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};
}

View 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;
};

View 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;
};
};

View 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;
};

View 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[];
};

View 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);
}

View 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();
}

View 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"}
]
}