mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-18 18:18:23 -05:00
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:
parent
aab258a9f0
commit
1e98250566
38 changed files with 3880 additions and 213 deletions
16
.github/actions/webapp-setup/action.yml
vendored
16
.github/actions/webapp-setup/action.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
3
.github/workflows/webapp-ci.yml
vendored
3
.github/workflows/webapp-ci.yml
vendored
|
|
@ -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
1
webapp/.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
|||
.eslintcache
|
||||
junit.xml
|
||||
node_modules
|
||||
.parcel-cache
|
||||
*.tsbuildinfo
|
||||
.rollup.cache
|
||||
*.tar.gz
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -701,10 +701,8 @@ class TestHelper {
|
|||
return {
|
||||
name: '',
|
||||
category: 'recent',
|
||||
image: '',
|
||||
short_name: '',
|
||||
short_names: [],
|
||||
batch: 0,
|
||||
unified: '',
|
||||
...override,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -452,10 +452,8 @@ export class TestHelper {
|
|||
return {
|
||||
name: '',
|
||||
category: 'recent',
|
||||
image: '',
|
||||
short_name: '',
|
||||
short_names: [],
|
||||
batch: 0,
|
||||
unified: '',
|
||||
...override,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
3191
webapp/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
20
webapp/platform/shared/.eslintrc.json
Normal file
20
webapp/platform/shared/.eslintrc.json
Normal 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"]}]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
5
webapp/platform/shared/.parcelrc
Normal file
5
webapp/platform/shared/.parcelrc
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "@parcel/config-default",
|
||||
"bundler": "@parcel/bundler-library",
|
||||
"namers": ["./parcel-namer-shared", "..."]
|
||||
}
|
||||
1
webapp/platform/shared/.stylelintignore
Normal file
1
webapp/platform/shared/.stylelintignore
Normal file
|
|
@ -0,0 +1 @@
|
|||
dist/
|
||||
42
webapp/platform/shared/.stylelintrc.json
Normal file
42
webapp/platform/shared/.stylelintrc.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"extends": [
|
||||
"stylelint-config-idiomatic-order",
|
||||
"stylelint-config-recommended-scss"
|
||||
],
|
||||
"plugins": [
|
||||
"@stylistic/stylelint-plugin"
|
||||
],
|
||||
"rules": {
|
||||
"@stylistic/indentation": 4,
|
||||
"@stylistic/no-missing-end-of-source-newline": true,
|
||||
"no-descending-specificity": null,
|
||||
"font-family-no-missing-generic-family-keyword": null,
|
||||
"property-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignoreProperties": ["scrollbar-3dlight-color"]
|
||||
}
|
||||
],
|
||||
"block-no-empty": [
|
||||
true,
|
||||
{
|
||||
"ignore": ["comments"]
|
||||
}
|
||||
],
|
||||
"declaration-property-value-disallowed-list": [
|
||||
{
|
||||
"color": ["/--denim-button-bg/"],
|
||||
"background-color": ["/--denim-button-bg/"],
|
||||
"border-color": ["/--denim-button-bg/"],
|
||||
"background": ["/--denim-button-bg/"],
|
||||
"border": ["/--denim-button-bg/"],
|
||||
"fill": ["/--denim-button-bg/"]
|
||||
},
|
||||
{
|
||||
"message": "The --denim-button-bg and --denim-button-bg-rgb variables are deprecated. Please use --button-bg or --button-bg-rgb instead."
|
||||
}
|
||||
],
|
||||
"scss/load-no-partial-leading-underscore": null,
|
||||
"scss/at-extend-no-missing-placeholder": null
|
||||
}
|
||||
}
|
||||
32
webapp/platform/shared/README.md
Normal file
32
webapp/platform/shared/README.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Mattermost Shared Package
|
||||
|
||||
[](https://www.npmjs.com/package/@mattermost/shared)
|
||||
|
||||
This package contains shared components and other utilities for use by the Mattermost web app and its plugins.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a pre-release package in active development. It is currently for internal use only, and it may change significantly between now and when it is fully released.
|
||||
|
||||
## Installation
|
||||
|
||||
This package requires a matching version of the `@mattermost/types` package. It also requires [React](https://react.dev/) and [React Intl](https://formatjs.github.io/docs/react-intl/).
|
||||
|
||||
```sh
|
||||
$ npm install @mattermost/shared @mattermost/types
|
||||
```
|
||||
|
||||
Additionally, if you're writing unit tests involving these components, [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) is also required.
|
||||
|
||||
```sh
|
||||
$ npm install @testing-library/react
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
TODO
|
||||
|
||||
## Development
|
||||
|
||||
### Compilation and Packaging
|
||||
|
||||
TODO
|
||||
44
webapp/platform/shared/jest.config.js
Normal file
44
webapp/platform/shared/jest.config.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/** @type {import('jest').Config} */
|
||||
|
||||
export default {
|
||||
moduleDirectories: ['src', 'node_modules'],
|
||||
testEnvironment: 'jsdom',
|
||||
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
|
||||
clearMocks: true,
|
||||
moduleNameMapper: {
|
||||
'^.+\\.css$': 'identity-obj-proxy',
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||
transform: {
|
||||
'^.+\\.(t|j)sx?$': [
|
||||
'@swc/jest',
|
||||
{
|
||||
jsc: {
|
||||
parser: {
|
||||
syntax: 'typescript',
|
||||
tsx: true,
|
||||
importAssertions: true,
|
||||
},
|
||||
|
||||
transform: {
|
||||
react: {
|
||||
runtime: 'automatic',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/setup_jest.ts'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{js,jsx,ts,tsx}',
|
||||
],
|
||||
coveragePathIgnorePatterns: [
|
||||
'/node_modules/',
|
||||
'/dist/',
|
||||
],
|
||||
coverageReporters: ['json', 'lcov', 'text-summary'],
|
||||
};
|
||||
110
webapp/platform/shared/package.json
Normal file
110
webapp/platform/shared/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
44
webapp/platform/shared/parcel-namer-shared.ts
Normal file
44
webapp/platform/shared/parcel-namer-shared.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import * as path from 'node:path';
|
||||
|
||||
import {Namer} from '@parcel/plugin';
|
||||
import type {FilePath} from '@parcel/types';
|
||||
|
||||
/**
|
||||
* This Namer changes how Parcel outputs its files to put them into subfolders based on where they were originally in
|
||||
* the source folder.
|
||||
*
|
||||
* By default, files output by Parcel are not put into subfolders of dist, and they instead rely on hashes to
|
||||
* differentiate between them. We want to be able to import those directly, so
|
||||
*/
|
||||
export default new Namer({
|
||||
async name(opts): Promise<FilePath | null | undefined> {
|
||||
const {bundle} = opts;
|
||||
|
||||
const mainEntry = bundle.getMainEntry();
|
||||
if (!mainEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the relative file path within the source folder
|
||||
const relativeDir = path.posix.relative('./src', path.dirname(mainEntry.filePath));
|
||||
|
||||
let filename;
|
||||
if (bundle.type === 'js') {
|
||||
// Rename generated JS files from FILE.js to FILE.TARGET.js or FILE.TARGET.js to fix naming conflict
|
||||
// between CommonJS and ESM files
|
||||
filename = path.basename(mainEntry.filePath, path.extname(mainEntry.filePath));
|
||||
filename += '.' + bundle.target.name + '.js';
|
||||
} else if (bundle.type === 'ts') {
|
||||
filename = path.basename(mainEntry.filePath, path.extname(mainEntry.filePath)) + '.d.ts';
|
||||
} else {
|
||||
filename = bundle.target.name + path.extname(mainEntry.filePath);
|
||||
}
|
||||
|
||||
const newPath = path.posix.join(relativeDir, filename);
|
||||
|
||||
return newPath;
|
||||
},
|
||||
});
|
||||
4
webapp/platform/shared/setup_jest.ts
Normal file
4
webapp/platform/shared/setup_jest.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
1
webapp/platform/shared/src/components/emoji/emoji.css
Normal file
1
webapp/platform/shared/src/components/emoji/emoji.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
/* The styles for this component are still defined in the web app */
|
||||
45
webapp/platform/shared/src/components/emoji/emoji.test.tsx
Normal file
45
webapp/platform/shared/src/components/emoji/emoji.test.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {screen} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import {Emoji} from './emoji';
|
||||
|
||||
import {renderWithContext} from '../../testing';
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
describe('Emoji', () => {
|
||||
test('should render nothing when no emoji name is provided', () => {
|
||||
renderWithContext(
|
||||
<Emoji emojiName=''/>,
|
||||
);
|
||||
|
||||
expect(document.querySelector('.emoticon')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the provided system emoji', () => {
|
||||
renderWithContext(
|
||||
<Emoji emojiName='smiley'/>,
|
||||
);
|
||||
|
||||
expect(document.querySelector('.emoticon')).toBe(screen.getByLabelText(':smiley:'));
|
||||
expect(screen.getByLabelText(':smiley:')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(':smiley:')).toHaveStyle({
|
||||
backgroundImage: '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',
|
||||
});
|
||||
});
|
||||
});
|
||||
56
webapp/platform/shared/src/components/emoji/emoji.tsx
Normal file
56
webapp/platform/shared/src/components/emoji/emoji.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {KeyboardEvent, MouseEvent} from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import {useEmojiByName} from '../../context/useEmojiByName';
|
||||
import {useEmojiUrl} from '../../context/useEmojiUrl';
|
||||
|
||||
import './emoji.css';
|
||||
|
||||
const emptyEmojiStyle = {};
|
||||
|
||||
export interface EmojiProps {
|
||||
emojiName: string;
|
||||
size?: number;
|
||||
emojiStyle?: React.CSSProperties;
|
||||
|
||||
// TODO remove this prop and move the click handler a proper button
|
||||
onClick?: (event: MouseEvent<HTMLSpanElement> | KeyboardEvent<HTMLSpanElement>) => void;
|
||||
}
|
||||
|
||||
export function Emoji({
|
||||
emojiName,
|
||||
emojiStyle = emptyEmojiStyle,
|
||||
size = 16,
|
||||
onClick,
|
||||
}: EmojiProps) {
|
||||
const emoji = useEmojiByName(emojiName);
|
||||
const emojiImageUrl = useEmojiUrl(emoji);
|
||||
|
||||
if (!emoji || !emojiImageUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={onClick}
|
||||
className='emoticon'
|
||||
aria-label={`:${emojiName}:`}
|
||||
data-emoticon={emojiName}
|
||||
style={{
|
||||
backgroundImage: `url(${emojiImageUrl})`,
|
||||
backgroundSize: 'contain',
|
||||
height: size,
|
||||
width: size,
|
||||
maxHeight: size,
|
||||
maxWidth: size,
|
||||
minHeight: size,
|
||||
minWidth: size,
|
||||
overflow: 'hidden',
|
||||
...emojiStyle,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
5
webapp/platform/shared/src/components/emoji/index.ts
Normal file
5
webapp/platform/shared/src/components/emoji/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {Emoji} from './emoji';
|
||||
export type {EmojiProps} from './emoji';
|
||||
46
webapp/platform/shared/src/context/context.tsx
Normal file
46
webapp/platform/shared/src/context/context.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useMemo} from 'react';
|
||||
|
||||
import type {Emoji} from '@mattermost/types/emojis';
|
||||
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
|
||||
export interface SharedContextValue {
|
||||
useEmojiByName: (name: string) => Emoji | undefined;
|
||||
useEmojiUrl: (emoji?: Emoji) => string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__MATTERMOST_SHARED_CONTEXT__: React.Context<SharedContextValue> | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// If multiple copies of the shared package happen to be loaded, this makes them share the same context. In practice,
|
||||
// // this should never happen because the web app is supposed to provide the only copy of @mattermost/shared,
|
||||
// but I borrowed the idea from React Intl.
|
||||
export const SharedContext = window?.__MATTERMOST_SHARED_CONTEXT__ ?? (
|
||||
window.__MATTERMOST_SHARED_CONTEXT__ = React.createContext<SharedContextValue>(
|
||||
null as unknown as SharedContextValue,
|
||||
)
|
||||
);
|
||||
SharedContext.displayName = 'MattermostSharedContext';
|
||||
|
||||
export interface SharedProviderProps extends SharedContextValue {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SharedProvider({
|
||||
children,
|
||||
useEmojiByName,
|
||||
useEmojiUrl,
|
||||
}: SharedProviderProps) {
|
||||
const contextValue = useMemo(() => ({
|
||||
useEmojiByName,
|
||||
useEmojiUrl,
|
||||
}), [useEmojiByName, useEmojiUrl]);
|
||||
|
||||
return <SharedContext.Provider value={contextValue}>{children}</SharedContext.Provider>;
|
||||
}
|
||||
5
webapp/platform/shared/src/context/index.ts
Normal file
5
webapp/platform/shared/src/context/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {SharedProvider} from './context';
|
||||
export type {SharedProviderProps} from './context';
|
||||
12
webapp/platform/shared/src/context/useEmojiByName.ts
Normal file
12
webapp/platform/shared/src/context/useEmojiByName.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {SharedContext} from './context';
|
||||
|
||||
export function useEmojiByName(name: string) {
|
||||
const context = React.useContext(SharedContext);
|
||||
|
||||
return context.useEmojiByName(name);
|
||||
}
|
||||
14
webapp/platform/shared/src/context/useEmojiUrl.ts
Normal file
14
webapp/platform/shared/src/context/useEmojiUrl.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type {Emoji} from '@mattermost/types/emojis';
|
||||
|
||||
import {SharedContext} from './context';
|
||||
|
||||
export function useEmojiUrl(emoji?: Emoji) {
|
||||
const context = React.useContext(SharedContext);
|
||||
|
||||
return context.useEmojiUrl(emoji);
|
||||
}
|
||||
4
webapp/platform/shared/src/testing/index.ts
Normal file
4
webapp/platform/shared/src/testing/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {renderHookWithContext, renderWithContext} from './react_testing_utils';
|
||||
112
webapp/platform/shared/src/testing/react_testing_utils.tsx
Normal file
112
webapp/platform/shared/src/testing/react_testing_utils.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {render, renderHook} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import {IntlProvider} from 'react-intl';
|
||||
|
||||
import {useMockSharedContext} from './useMockSharedContext';
|
||||
|
||||
import type {SharedProviderProps} from '../context';
|
||||
|
||||
export type FullContextOptions = {
|
||||
intlMessages?: Record<string, string>;
|
||||
locale?: string;
|
||||
sharedContext?: Partial<Omit<SharedProviderProps, 'children'>>;
|
||||
}
|
||||
|
||||
export const renderWithContext = (
|
||||
component: React.ReactElement,
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
68
webapp/platform/shared/src/testing/useMockSharedContext.tsx
Normal file
68
webapp/platform/shared/src/testing/useMockSharedContext.tsx
Normal 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};
|
||||
}
|
||||
23
webapp/platform/shared/tsconfig.json
Normal file
23
webapp/platform/shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "es2022",
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react",
|
||||
"rootDir": "./src",
|
||||
"composite": true,
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*"
|
||||
],
|
||||
"references": [
|
||||
{"path": "../types"}
|
||||
]
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue