MM-66867/MM-67318 Add initial version of shared package (#35065)

* Change moduleResolution to bundler

This makes TS follow the module resolution of newer versions of Node.js which
makes it use the `imports` and `exports` fields of the package.json while not
requiring file extensions in some cases which it does when set to node16 or
nodenext.

I'm changing this to make it so that VS Code can correctly import things from
our types package without adding `/src/` to the import path erroneously.
Hopefully it doesn't introduce any other issues.

* Change make clean to use package.json script

* Remove missing fields from SystemEmoji type

These were removed from emoji.json in
https://github.com/mattermost/mattermost-webapp/pull/9597, but we forgot to
remove the fields from the type definition. They weren't used anyway.

* MM-66867 Add initial version of shared package

This initial version includes the shared context, React Intl support (although
that's currently untested), linting, and testing support. It builds with
Parcel.

* Move isSystemEmoji into Types package

* MM-67318 Add Emoji component to shared package

To limit the number of changes to the web app, it still uses RenderEmoji which
wraps the new component for the time being. I'll likely replace RenderEmoji
with using it directly in a future PR, but I may leave it as-is if the changes
are too big because the API is different.

* Add postinstall script to build shared package

* Revert changes to moduleResolution and add typesVersions to shared package

I plan to still change moduleResolution to bundler since it's the new default
for TS projects, and since it lets TS use the exports field in package.json,
but it requires other changes to fix some minor issues in this repo which I
don't want to muddy this PR with.

Adding typesVersions lets TS resolve the components in the shared package like
it does with the types package while using the old value for moduleResolution.
Plugins still use the old value for moduleResolution, so this will let them use
the shared package with fewer updates changes as well.

* Fix Webpack not always watching other packages for changes

* Add shared package dependencies and build output to CI cache

* Update @parcel/watcher to fix segfaults

This package seems to be older than the rest of the newly added Parcel
dependencies because it's used by sass.

* Fix build script not doing that

* Go back to manually specifying postinstall order

I just learned that postinstall scripts run in parallel because I was running
into an issue where the client and types packages were building at the same
time, causing one of them to fail. They still run in parallel, so that may
still occasionally happen, but by specifying the order manually, we hopefully
avoid that happening like we seemed to do before.

* Further revert changes to postinstall script

The subpackages were also being built when installed
by a plugin

* Increment cache keys

* Fix typo

* Change the cache busting to look at shared/package.json

* Attempt to debug tests and caching

* Debugging...

* Add shared package to platform code coverage

* Remove caching of package builds and manually run postinstall during web app CI setup

* Debugging...

* Remove CI debugging logic

* Update package-lock.json

* Change Emoji component back to taking an emojiName prop

* Add .parcel-cache to .gitignore
This commit is contained in:
Harrison Healey 2026-02-13 14:53:10 -05:00 committed by GitHub
parent aab258a9f0
commit 1e98250566
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 3880 additions and 213 deletions

View file

@ -17,17 +17,9 @@ runs:
webapp/channels/node_modules
webapp/platform/client/node_modules
webapp/platform/components/node_modules
webapp/platform/shared/node_modules
webapp/platform/types/node_modules
key: node-modules-${{ runner.os }}-${{ hashFiles('webapp/package-lock.json') }}
- name: ci/cache-platform-builds
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
id: cache-platform-builds
with:
path: |
webapp/platform/types/lib
webapp/platform/client/lib
webapp/platform/components/dist
key: platform-builds-${{ runner.os }}-${{ hashFiles('webapp/platform/types/src/**', 'webapp/platform/client/src/**', 'webapp/platform/components/src/**') }}
- name: ci/get-node-modules
if: steps.cache-node-modules.outputs.cache-hit != 'true'
shell: bash
@ -35,8 +27,10 @@ runs:
run: |
make node_modules
- name: ci/build-platform-packages
if: steps.cache-platform-builds.outputs.cache-hit != 'true'
# These are built automatically when depenedencies are installed, but they aren't cached properly, so we need to
# manually build them when the cache is hit. They aren't worth caching because they have too many dependencies.
if: steps.cache-node-modules.outputs.cache-hit == 'true'
shell: bash
working-directory: webapp
run: |
npm run build --workspace=platform/types --workspace=platform/client --workspace=platform/components
npm run postinstall

View file

@ -80,7 +80,7 @@ jobs:
env:
NODE_OPTIONS: --max_old_space_size=5120
run: |
npm run test-ci --workspace=platform/client --workspace=platform/components -- --coverage
npm run test-ci --workspace=platform/client --workspace=platform/components --workspace=platform/shared -- --coverage
- name: ci/upload-coverage-artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
@ -88,6 +88,7 @@ jobs:
path: |
./webapp/platform/client/coverage
./webapp/platform/components/coverage
./webapp/platform/shared/coverage
retention-days: 1
test-mattermost-redux:

1
webapp/.gitignore vendored
View file

@ -1,6 +1,7 @@
.eslintcache
junit.xml
node_modules
.parcel-cache
*.tsbuildinfo
.rollup.cache
*.tar.gz

View file

@ -97,8 +97,7 @@ endif
clean: ## Clears cached; deletes node_modules and dist directories
@echo Cleaning Web App
npm run clean --workspaces --if-present
rm -rf node_modules
npm run clean
.PHONY: package
package: node_modules dist ## Generates ./mattermost-webapp.tar.gz for use by someone customizing the web app

View file

@ -12,6 +12,7 @@
"@guyplusplus/turndown-plugin-gfm": "1.0.7",
"@mattermost/client": "11.4.0",
"@mattermost/desktop-api": "6.0.0-1",
"@mattermost/shared": "11.4.0",
"@mattermost/types": "11.4.0",
"@mui/base": "5.0.0-alpha.127",
"@mui/material": "5.11.16",

View file

@ -1,63 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {MouseEvent, KeyboardEvent} from 'react';
import {useSelector} from 'react-redux';
import {Emoji} from '@mattermost/shared/components/emoji';
import {getEmojiImageUrl} from 'mattermost-redux/utils/emoji_utils';
import {getEmojiMap} from 'selectors/emojis';
import type {GlobalState} from 'types/store';
const emptyEmojiStyle = {};
interface ComponentProps {
emojiName: string;
size?: number;
emojiStyle?: React.CSSProperties;
onClick?: (event: MouseEvent<HTMLSpanElement> | KeyboardEvent<HTMLSpanElement>) => void;
}
const RenderEmoji = ({
emojiName = '',
emojiStyle = emptyEmojiStyle,
size = 16,
onClick,
}: ComponentProps) => {
const emojiMap = useSelector((state: GlobalState) => getEmojiMap(state));
if (!emojiName) {
return null;
}
const emojiFromMap = emojiMap.get(emojiName);
if (!emojiFromMap) {
return null;
}
const emojiImageUrl = getEmojiImageUrl(emojiFromMap);
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,
}}
/>
);
};
export default React.memo(RenderEmoji);
export default Emoji;

View file

@ -9,18 +9,22 @@ import ThemeProvider from 'components/theme_provider';
import WebSocketClient from 'client/web_websocket_client';
import {WebSocketContext} from 'utils/use_websocket';
import SharedPackageProvider from './shared_package_provider';
type Props = {
children: React.ReactNode;
}
export default function RootProvider(props: Props) {
return (
<IntlProvider>
<WebSocketContext.Provider value={WebSocketClient}>
<ThemeProvider>
{props.children}
</ThemeProvider>
</WebSocketContext.Provider>
</IntlProvider>
<SharedPackageProvider>
<IntlProvider>
<WebSocketContext.Provider value={WebSocketClient}>
<ThemeProvider>
{props.children}
</ThemeProvider>
</WebSocketContext.Provider>
</IntlProvider>
</SharedPackageProvider>
);
}

View file

@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useSelector} from 'react-redux';
import {SharedProvider} from '@mattermost/shared/context';
import type {Emoji} from '@mattermost/types/emojis';
import {getEmojiImageUrl} from 'mattermost-redux/utils/emoji_utils';
import {getEmojiMap} from 'selectors/emojis';
import type {GlobalState} from 'types/store';
export interface SharedPackageProviderProps {
children: React.ReactNode;
}
export default function SharedPackageProvider({children}: SharedPackageProviderProps) {
return (
<SharedProvider
useEmojiByName={useEmojiByName}
useEmojiUrl={useEmojiUrl}
>
{children}
</SharedProvider>
);
}
function useEmojiByName(name: string) {
// This isn't defined for use elsewhere because makeUseEntity currently needs additional logic for handling emojis
const emojiMap = useSelector((state: GlobalState) => getEmojiMap(state));
if (!name) {
return undefined;
}
return emojiMap.get(name);
}
function useEmojiUrl(emoji?: Emoji) {
if (!emoji) {
return '';
}
return getEmojiImageUrl(emoji);
}

View file

@ -52,8 +52,6 @@ describe('EmojiUtils', () => {
category: 'activities',
short_names: ['sampleEmoji'],
short_name: 'sampleEmoji',
batch: 2,
image: 'sampleEmoji.png',
});
expect(EmojiUtils.isSystemEmoji(sampleEmoji)).toBe(true);
});
@ -64,8 +62,6 @@ describe('EmojiUtils', () => {
name: 'sampleEmoji',
short_names: ['sampleEmoji'],
short_name: 'sampleEmoji',
batch: 2,
image: 'sampleEmoji.png',
});
expect(EmojiUtils.isSystemEmoji(sampleEmoji)).toBe(true);
});

View file

@ -1,17 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Emoji, SystemEmoji} from '@mattermost/types/emojis';
import {isSystemEmoji, type Emoji} from '@mattermost/types/emojis';
import {Client4} from 'mattermost-redux/client';
export function isSystemEmoji(emoji: Emoji): emoji is SystemEmoji {
if ('category' in emoji) {
return emoji.category !== 'custom';
}
return !('id' in emoji);
}
export {isSystemEmoji};
export function getEmojiImageUrl(emoji: Emoji): string {
// If its the mattermost custom emoji

View file

@ -701,10 +701,8 @@ class TestHelper {
return {
name: '',
category: 'recent',
image: '',
short_name: '',
short_names: [],
batch: 0,
unified: '',
...override,
};

View file

@ -16,6 +16,8 @@ import type {DeepPartial} from '@mattermost/types/utilities';
import configureStore from 'store';
import globalStore from 'stores/redux_store';
import SharedPackageProvider from 'components/root/shared_package_provider';
import WebSocketClient from 'client/web_websocket_client';
import mergeObjects from 'packages/mattermost-redux/test/merge_objects';
import mockStore from 'tests/test_store';
@ -183,14 +185,16 @@ const Providers = ({children, store, history, options}: RenderStateProps) => {
return (
<Provider store={store}>
<Router history={history}>
<IntlProvider
locale={options.locale}
messages={options.intlMessages}
>
<WebSocketContext.Provider value={WebSocketClient}>
{children}
</WebSocketContext.Provider>
</IntlProvider>
<SharedPackageProvider>
<IntlProvider
locale={options.locale}
messages={options.intlMessages}
>
<WebSocketContext.Provider value={WebSocketClient}>
{children}
</WebSocketContext.Provider>
</IntlProvider>
</SharedPackageProvider>
</Router>
</Provider>
);

View file

@ -452,10 +452,8 @@ export class TestHelper {
return {
name: '',
category: 'recent',
image: '',
short_name: '',
short_names: [],
batch: 0,
unified: '',
...override,
};

View file

@ -302,6 +302,11 @@ var config = {
],
}),
],
watchOptions: {
// By default, Webpack doesn't watch node_modules for changes, but we want it to watch packages in the monorepo
ignored: /node_modules([\\]+|\/)(?!@mattermost\/(client|components|types|shared))/,
},
};
function generateCSP() {

3191
webapp/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@
"npm": "^10 || ^11"
},
"scripts": {
"postinstall": "patch-package && npm run build --workspace=platform/types --workspace=platform/client --workspace=platform/components",
"postinstall": "patch-package && npm run build --workspace platform/types --workspace platform/client --workspace platform/components --workspace platform/shared",
"build": "node scripts/build.mjs",
"run": "node scripts/run.mjs",
"dev-server": "node scripts/dev-server.mjs",
@ -18,7 +18,7 @@
"check-types": "npm run check-types --workspaces --if-present",
"i18n-extract": "npm run i18n-extract --workspaces --if-present",
"i18n-extract:check": "npm run i18n-extract:check --workspaces --if-present",
"clean": "npm run clean --workspaces --if-present",
"clean": "npm run clean --workspaces --if-present && rm -rf node_modules .parcel-cache",
"gen-lang-imports": "node scripts/gen_lang_imports.mjs"
},
"dependencies": {
@ -90,6 +90,7 @@
"platform/components",
"platform/eslint-plugin",
"platform/mattermost-redux",
"platform/shared",
"platform/types"
]
}

View file

@ -0,0 +1,20 @@
{
"root": true,
"extends": [
"plugin:@mattermost/react"
],
"plugins": [
"formatjs",
"no-only-tests"
],
"rules": {
},
"overrides": [
{
"files": ["*.test.*", "src/tests/**"],
"rules": {
"no-only-tests/no-only-tests": ["error", {"focus": ["only", "skip"]}]
}
}
]
}

View file

@ -0,0 +1,5 @@
{
"extends": "@parcel/config-default",
"bundler": "@parcel/bundler-library",
"namers": ["./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.
/** @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,110 @@
{
"name": "@mattermost/shared",
"version": "11.4.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": [
"dist",
"src"
],
"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"
},
"./*": {
"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": {
"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",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^4.6.0",
"jest": "^30.1.3",
"jest-environment-jsdom": "^30.1.0",
"parcel": "^2.16.3",
"react": "^18.2.0",
"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": "11.4.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 --ext .js,.jsx,.tsx,.ts ./src --quiet",
"check:stylelint": "stylelint \"**/*.{css,scss}\"",
"check-types": "tsc -b",
"fix": "eslint --ext .js,.jsx,.tsx,.ts ./src --quiet --fix && stylelint \"**/*.{css,scss}\" --fix",
"run": "parcel watch",
"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,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,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 @@
/* 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: 'https://mattermost.example.com/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: 'https://mattermost.example.com/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,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,
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) => {
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,68 @@
// 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 = {
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 `https://mattermost.example.com/static/emoji/${emoji.unified}.png`;
}
return `https://mattermost.example.com/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,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"}
]
}

View file

@ -28,10 +28,8 @@ export type CustomEmoji = {
export type SystemEmoji = {
name: string;
category: EmojiCategory;
image: string;
short_name: string;
short_names: string[];
batch: number;
skins?: string[];
skin_variations?: Record<string, SystemEmojiVariation>;
unified: string;
@ -63,3 +61,11 @@ export type RecentEmojiData = {
name: string;
usageCount: number;
};
export function isSystemEmoji(emoji: Emoji): emoji is SystemEmoji {
if ('category' in emoji) {
return emoji.category !== 'custom';
}
return !('id' in emoji);
}