This commit is contained in:
2026-01-30 11:49:04 +03:00
commit f8bb05a652
48 changed files with 9538 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
{
"settings": {
"import/resolver": {
"typescript": {
"project": "./tsconfig.json"
}
},
"react": {
"version": "17.0.2"
}
},
"extends": [
"eslint:recommended",
"plugin:react-hooks/recommended",
"plugin:import/recommended",
"plugin:import/typescript"
],
"parser": "@typescript-eslint/parser",
"plugins": [
"react",
"import",
"@typescript-eslint",
"unused-imports"
],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"ecmaFeatures": {
"jsx": true,
"impliedStrict": true,
"modules": true,
"experimentalObjectRestSpread": true
}
},
"env": {
"browser": true,
"node": true,
"jquery": false,
"es6": true
},
"rules": {
"no-unused-vars": "warn",
"no-unused-expressions": "warn",
"unused-imports/no-unused-imports": "warn",
"import/no-named-as-default": "off"
}
}
+3
View File
@@ -0,0 +1,3 @@
.eslintcache
junit.xml
node_modules
+1
View File
@@ -0,0 +1 @@
save-exact=true
+1
View File
@@ -0,0 +1 @@
nodeLinker: node-modules
+4
View File
@@ -0,0 +1,4 @@
{
}
+3
View File
@@ -0,0 +1,3 @@
{
}
+63
View File
@@ -0,0 +1,63 @@
{
"private": true,
"engines": {
"node": ">=22",
"npm": ">=10"
},
"scripts": {
"build": "vite build",
"dev": "vite",
"lint": "eslint --ignore-pattern node_modules --ignore-pattern dist --ext .js --ext .jsx --ext tsx --ext ts . --quiet --cache",
"fix": "eslint --ignore-pattern node_modules --ignore-pattern dist --ext .js --ext .jsx --ext tsx --ext ts . --quiet --fix --cache",
"check-types": "tsc"
},
"devDependencies": {
"@babel/plugin-transform-modules-umd": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"@emotion/core": "10.0.28",
"@eslint/js": "^9.25.1",
"@rollup/plugin-replace": "^6.0.2",
"@types/node": "14.0.20",
"@types/react": "^17",
"@types/react-dom": "^17",
"@types/react-intl": "3.0.0",
"@types/react-redux": "7.1.9",
"@types/react-router-dom": "5.1.5",
"@types/react-transition-group": "4.4.0",
"@typescript-eslint/eslint-plugin": "3.6.0",
"@typescript-eslint/parser": "3.6.0",
"@vitejs/plugin-react": "^4.4.1",
"bootstrap": "5.1.3",
"core-js": "3.6.5",
"css-loader": "7.1.2",
"eslint": "^9.26.0",
"eslint-plugin-import": "2.22.0",
"eslint-plugin-react": "7.20.3",
"eslint-plugin-react-hooks": "4.0.6",
"eslint-plugin-unused-imports": "^4.1.4",
"file-loader": "6.0.0",
"identity-obj-proxy": "3.0.0",
"loop-plugin-sdk": "https://artifacts.wilix.dev/repository/npm-public-loop/loop-plugin-sdk/-/loop-plugin-sdk-0.1.6.tgz",
"moment-timezone": "^0.5.48",
"react": "17.0.2",
"react-bootstrap": "^2.10.10",
"react-dom": "17.0.2",
"react-intl": "6.8.9",
"react-redux": "8.1.3",
"redux": "4.2.1",
"sass": "1.86.0",
"sass-loader": "13.0.2",
"style-loader": "1.2.1",
"styled-components": "6.1.16",
"typescript": "4.9.5",
"typescript-eslint": "^8.31.1",
"vite": "^6.3.1",
"vite-plugin-css-injected-by-js": "^3.5.2",
"vite-plugin-externals": "^0.6.2"
},
"dependencies": {
"clsx": "^2.1.1",
"redux-batched-actions": "^0.5.0"
},
"packageManager": "yarn@4.9.1+sha512.f95ce356460e05be48d66401c1ae64ef84d163dd689964962c6888a9810865e39097a5e9de748876c2e0bf89b232d583c33982773e9903ae7a76257270986538"
}
+19
View File
@@ -0,0 +1,19 @@
import React from "react";
import { useDispatch } from 'react-redux';
import { Provider } from 'react-redux';
import manifest from '../manifest';
import usePlugin from '../utils/usePlugin';
const RootComponent: React.FC = () => {
const dispatch = useDispatch();
const plugin = usePlugin();
return (
<div>
{/* Add your root component content here */}
<p>Template Plugin Root Component</p>
</div>
)
}
export default RootComponent
+1
View File
@@ -0,0 +1 @@
// Add your global styles here
+14
View File
@@ -0,0 +1,14 @@
import { getPluginAssetsPath } from '../utils/utils';
export const basePath = getPluginAssetsPath();
// Add your assets configuration here
export type AssetsType = {
// Define your asset types
}
const assets: AssetsType = {
// Add your assets here
} as const
export default assets
+5
View File
@@ -0,0 +1,5 @@
import Svgs from './svgs.js';
export {
Svgs,
};
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+17
View File
@@ -0,0 +1,17 @@
import * as React from 'react';
type Props = {
icon: string,
className?: string,
} & React.HTMLAttributes<HTMLElement>
export default function CompassIcon({icon, className, ...rest}: Props): JSX.Element {
// All compass icon classes start with icon,
// so not expecting that prefix in props.
return (
<i
className={`CompassIcon icon-${icon} ${className}`}
{...rest}
/>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { PluginRegistry } from 'loop-plugin-sdk';
import { Store, Action } from 'redux';
import { GlobalState } from 'loop-plugin-sdk/loop/types/store';
import 'loop-plugin-sdk/window'
import manifest from './manifest';
import registerApp from './registerApp';
export default class Plugin {
// @ts-ignore
public store: Store;
// @ts-ignore
public registry: PluginRegistry
public onStoreChanged: any;
public uninitialize: any;
public async initialize(registry: PluginRegistry, store: Store<GlobalState, Action<Record<string, unknown>>>) {
this.store = store;
this.registry = registry;
await registerApp(this, registry, store)
}
}
window.registerPlugin(manifest.id, new Plugin());
+49
View File
@@ -0,0 +1,49 @@
import { PluginRegistry } from 'loop-plugin-sdk';
import { getCurrentUserLocale } from 'loop-plugin-sdk/loop/redux/selectors/entities/i18n';
import { Action, Store } from 'redux';
import RootComponent from './components/RootComponent';
import Plugin from './index';
import manifest from './manifest';
import reducer from './store/reducers';
import * as React from 'react';
import { getTranslations } from './utils/utils';
import { Provider, useSelector } from 'react-redux';
import { IntlProvider } from 'react-intl';
import { GlobalState } from 'loop-plugin-sdk/loop/types/store';
export const pluginStoreId = `plugins-${manifest.id}`;
export default async function Initialize(plugin: Plugin, registry: PluginRegistry, store: Store<GlobalState, Action<Record<string, unknown>>>) {
const Providers: React.FC<React.PropsWithChildren<any>> = ({ children }) => {
const locale = useSelector((state) => getCurrentUserLocale(state as GlobalState) || 'en');
return (
<IntlProvider locale={locale} key={locale} messages={getTranslations(locale)}>
<Provider store={store}>{children}</Provider>
</IntlProvider>
);
};
const regIds: string[] = [];
registry.registerReducer(reducer);
registry.registerTranslations(getTranslations);
plugin.uninitialize = () => {
// Add cleanup logic here
};
const reinit = () => {
if (regIds.length > 0) {
regIds.forEach(regId => registry.unregisterComponent(regId));
regIds.splice(0, regIds.length);
}
regIds.push(registry.registerRootComponent(() => (
<Providers>
<RootComponent />
</Providers>
)));
};
reinit();
}
+13
View File
@@ -0,0 +1,13 @@
import { DispatchFunc } from '../types/store';
import { ACTIONS } from './reducers';
import { AnyAction } from 'redux';
// Add your action creators here
export function exampleAction(data: string) {
return ((dispatch: DispatchFunc) => {
dispatch({
type: ACTIONS.EXAMPLE_ACTION,
data: { example: data }
});
}) as unknown as AnyAction;
}
+37
View File
@@ -0,0 +1,37 @@
import { combineReducers } from 'redux';
import { PluginStore } from '../types/store';
export type ActionType = {
type: ACTIONS
data: PluginActionData
}
export enum ACTIONS {
// Add your action types here
EXAMPLE_ACTION = 'EXAMPLE_ACTION',
}
export type PluginActionData = {
// Add your action data types here
example?: string;
}
const EmptyState: PluginStore = {
// Add your initial state here
}
function pluginState(state: PluginStore = EmptyState, { type, data }: ActionType): PluginStore {
switch (type) {
case ACTIONS.EXAMPLE_ACTION:
return {
...state,
// Handle action
}
default:
return state;
}
}
export default combineReducers({
pluginState,
});
+15
View File
@@ -0,0 +1,15 @@
import Plugin from '../index';
declare module "*.module.css";
declare module "*.module.scss";
declare global {
const __PLUGIN_DEV__: boolean;
const __PLUGIN_COMPONENTS_HOST__: string | null;
interface Window {
plugins: Record<string, Plugin | unknown>;
basename: string;
desktopAPI?: any;
}
}
+16
View File
@@ -0,0 +1,16 @@
import { GlobalState } from 'loop-plugin-sdk/loop/types/store';
import { ActionType } from '../store/reducers';
export type PluginStore = {
// Add your store state here
}
export type GlobalStatePlugin = GlobalState & {
[name: string]: {
pluginState: PluginStore
}
}
export type GetStateType = () => GlobalStatePlugin
export type DispatchFunc = (action: ActionType) => any;
+1
View File
@@ -0,0 +1 @@
// trackEvent: (event: Telemetry.Event, source: Telemetry.Source, props?: Record<string, string>) => void,
+39
View File
@@ -0,0 +1,39 @@
import Client4 from 'loop-plugin-sdk/loop/client/client4';
import manifest from '../manifest';
export type StdApiResp<T = undefined> = {
status: string
error?: string
data?: T
}
class ApiClient {
client = new Client4();
async exampleRequest(): Promise<StdApiResp<{ message: string }>> {
try {
// @ts-ignore
return await this.client.doFetch<StdApiResp<{ message: string }>>(`/plugins/${manifest.id}/example`, {
method: 'GET',
credentials: 'include',
})
} catch (error) {
console.error(error);
return {status: 'error', error: error as string};
}
}
async apiPingServerStatus(): Promise<boolean> {
try {
const response = await this.client.ping();
return response.status === 'OK';
} catch (err) {
console.error('Ошибка пинга сервера:', err);
return false;
}
}
}
const apiClient = new ApiClient();
export default apiClient
+18
View File
@@ -0,0 +1,18 @@
import { getTheme } from 'loop-plugin-sdk/loop/redux/selectors/entities/preferences';
import { GlobalState } from 'loop-plugin-sdk/loop/types/store';
import React, { useEffect } from "react";
import { useSelector } from 'react-redux';
import { isDarkTheme } from './colorUtils';
const useIsDarkTheme = () => {
const [isDark, setIsDark] = React.useState(false);
const theme = useSelector((state: GlobalState) => getTheme(state))
useEffect(() => {
setIsDark(isDarkTheme());
}, [theme])
return isDark
}
export default useIsDarkTheme
+6
View File
@@ -0,0 +1,6 @@
import Plugin from '../index';
import manifest from '../manifest';
export default function usePlugin() {
return window.plugins[manifest.id] as Plugin
}
+97
View File
@@ -0,0 +1,97 @@
import { WebSocketClient } from 'loop-plugin-sdk/loop/client';
import { DispatchFunc } from 'loop-plugin-sdk/loop/redux/types/actions';
import { GlobalState } from 'loop-plugin-sdk/loop/types/store';
import { AnyAction } from 'redux';
import en from '../../i18n/en.json';
import ru from '../../i18n/ru.json';
import Plugin from '../index';
import manifest from '../manifest';
import { pluginStoreId } from '../registerApp';
import { GlobalStatePlugin } from '../types/store';
export function getPluginAssetsPath() {
return `${window.basename || ''}/plugins/${manifest.id}/assets`;
}
export function getPlugin(): Plugin {
return window.plugins[manifest.id] as Plugin;
}
export function isDesktopApp(): boolean {
return window.navigator.userAgent.indexOf('Electron') !== -1;
}
export function getWebappUtils() {
let utils;
try {
utils = window.opener ? window.opener.WebappUtils : window.WebappUtils;
} catch (err) {
console.error(err);
}
return utils;
}
export type ProductApi = {
selectRhsPost: (postId: string) => (dispatch: DispatchFunc) => AnyAction
closeRhs: () => (dispatch: DispatchFunc) => AnyAction
getIsRhsOpen: (state: GlobalState) => boolean
getRhsSelectedPostId: (state: GlobalState) => string
useWebSocketClient: () => WebSocketClient
}
export function getProductApi(): ProductApi {
let utils: any;
try {
// @ts-ignore
utils = window.opener ? window.opener.ProductApi : window['ProductApi'];
} catch (err) {
console.error(err);
}
return utils;
}
export function getPluginStoreFromState(state: GlobalStatePlugin) {
return state[pluginStoreId].pluginState;
}
export function getTranslations(locale: string) {
switch (locale) {
case "en":
return en;
case "ru":
return ru;
}
return en;
}
export function getRandomInt(min: number, max: number) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
}
export async function waitForServer(
pingFn: () => Promise<any>,
interval = 2000,
maxAttempts = 10
): Promise<void> {
let attempts = 0;
return new Promise((resolve, reject) => {
const check = async () => {
attempts++;
const ok = await pingFn();
if (ok) {
resolve();
return;
}
if (attempts >= maxAttempts) {
reject(new Error("Не удалось подключиться после нескольких попыток"));
return;
}
setTimeout(check, interval);
};
check();
});
}
+35
View File
@@ -0,0 +1,35 @@
{
"compilerOptions": {
"target": "esnext",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"module": "es2020",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"experimentalDecorators": true,
"jsx": "react",
"baseUrl": "./src",
"typeRoots": [ "./src/types", "./node_modules/@types"],
"types": ["vite/client"],
},
"include": [
"src"
],
"exclude": [
"dist",
"node_modules",
"!node_modules/@types"
]
}
+131
View File
@@ -0,0 +1,131 @@
import { resolve } from "path";
import { defineConfig } from 'vite'
import replace from '@rollup/plugin-replace';
import react from '@vitejs/plugin-react';
import { viteExternalsPlugin } from 'vite-plugin-externals';
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
import manifest from './src/manifest';
const isDev = ['development', 'develop'].includes(process.env.NODE_ENV);
console.warn('Vite building in ', isDev ? 'DEVELOPER MODE' : 'PRODUCTION MODE');
const optimizeDepsInclude = [
'**/*.scss',
];
// В dev: подготавливаем react/react-dom/react-redux как ESM-шимы
if (isDev) {
optimizeDepsInclude.push('react-redux')
}
export default defineConfig({
plugins: [
...(isDev ? [
viteExternalsPlugin({
react: 'React',
'react-dom': 'ReactDOM',
redux: 'Redux',
'react-redux': 'ReactRedux',
'prop-types': 'PropTypes',
'react-bootstrap': 'ReactBootstrap',
'react-router-dom': 'ReactRouterDom',
}),
] : [
]),
// 2) В dev включаем плагин React (он генерит preamble + jsx runtime + fast refresh)
...(isDev ? [react()] : []),
cssInjectedByJsPlugin(),
replace({
// Переопределяем NODE_ENV внутри бандла
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'),
preventAssignment: true,
}),
],
// Трансформация JSX через esbuild
esbuild: {
jsx: 'transform',
jsxFactory: 'React.createElement',
jsxFragment: 'React.Fragment',
},
// В dev: подготавливаем react/react-dom/react-redux как ESM-шимы
optimizeDeps: {
include: optimizeDepsInclude,
},
css: {
preprocessorOptions: {
scss: {
modules: true,
},
},
modules: {
localsConvention: 'camelCaseOnly',
},
},
build: {
lib: {
entry: resolve(__dirname, 'src/index.tsx'),
formats: ['umd'],
name: manifest.id,
cssFileName: manifest.id,
fileName: () => 'main.js',
},
rollupOptions: {
// В проде всё это выносится в глобальные переменные Mattermost
external: [
'react',
'react-dom',
'redux',
'react-redux',
'prop-types',
'react-bootstrap',
'react-router-dom',
'core-js',
'react-intl',
],
output: {
inlineDynamicImports: true,
manualChunks: undefined,
globals: {
react: 'React',
'react-dom': 'ReactDOM',
redux: 'Redux',
'react-redux': 'ReactRedux',
'prop-types': 'PropTypes',
'react-bootstrap': 'ReactBootstrap',
'react-router-dom': 'ReactRouterDom',
'react-intl': 'ReactIntl',
},
},
},
},
server: {
port: 3001,
cors: true,
headers: {
'Access-Control-Allow-Origin': '*',
},
// ----------------------------------------------------
// HMR-настройка для плагина, загружаемого в Loop
hmr: {
protocol: 'ws', // WebSocket
host: 'localhost', // где запущен vite dev
port: 3001, // тот же порт
clientPort: 3001, // куда клиент будет подключаться
},
// ----------------------------------------------------
},
define: {
__PLUGIN_DEV__: isDev,
__PLUGIN_COMPONENTS_HOST__: isDev
? JSON.stringify('http://localhost:3001')
: JSON.stringify(null),
},
});
+7063
View File
File diff suppressed because it is too large Load Diff