Перейти к основному содержимому

Клиентская часть

Клиентская часть плагина — код на React и TypeScript, который выполняется в браузере и в десктоп-приложении Loop. С её помощью плагин добавляет в интерфейс кнопки, панели, пункты меню, страницы и свой вид сообщений.

примечание

Мобильное приложение Loop клиентскую часть плагинов не загружает. Всё, что должно работать и на телефоне, делайте на сервере: сообщения, боты, slash-команды, кнопки во вложениях.

Как Loop загружает плагин​

  1. При открытии Loop браузер получает список включённых плагинов и скачивает их файлы main.js.
  2. Файл плагина вызывает window.registerPlugin(ID плагина, объект плагина) — в шаблоне это последняя строка webapp/src/index.tsx.
  3. Loop вызывает у объекта метод initialize(registry, store):
    • registry — реестр: через него плагин говорит, что и куда добавить в интерфейс;
    • store — Redux-хранилище Loop, где лежат все данные: текущий пользователь, каналы, сообщения, настройки.
  4. Когда плагин выключают, Loop вызывает у объекта uninitialize() (если он есть) и сам убирает всё, что плагин зарегистрировал.

После включения или обновления плагина пользователям не нужно перезагружать страницу: Loop подгрузит новую версию сам.

Какие библиотеки уже есть​

Плагин не собирает свою копию React — он берёт библиотеки у Loop. Их можно импортировать как обычно (import React from 'react'), а при сборке импорт заменится на объект из window:

ИмпортЧто это
react, react-domReact 18
redux, react-reduxRedux и хуки useSelector, useDispatch
react-intlПереводы: FormattedMessage, useIntl
react-bootstrapКомпоненты Bootstrap: Modal, Button, Tooltip и др.
react-router-domМаршрутизация (версия 5)
prop-typesПроверка свойств компонентов

Остальные библиотеки, которые вы добавите в webapp/package.json, попадут в main.js плагина. Следите за размером: всё, что в main.js, скачивается каждым пользователем.

Реестр​

Все места интерфейса, куда может встроиться плагин. Каждый метод возвращает ID регистрации — по нему можно убрать элемент методом registry.unregisterComponent(id).

Полные описания параметров — в справочнике веб-плагинов Mattermost (сверяйтесь с таблицей совместимости: там могут быть методы, которых нет в Loop) и в типах пакета loop-plugin-sdk (PluginRegistry).

Кнопки и панели​

МетодЧто добавляет
registerChannelHeaderButtonAction(icon, action, dropdownText, tooltipText)Кнопку плагина. Обычно она появляется на вертикальной панели приложений справа, а если панель выключена — в шапке канала
registerAppBarComponent(iconUrl, action, tooltipText, supportedProductIds, rhsComponent, rhsTitle)Кнопку на панели приложений справа с картинкой по адресу iconUrl. Вместо action можно передать rhsComponent и rhsTitle — тогда кнопка сама будет открывать правую панель
registerRightHandSidebarComponent(component, title)Правую панель. Возвращает {id, showRHSPlugin, hideRHSPlugin, toggleRHSPlugin} — действия, которые нужно передать в store.dispatch, чтобы открыть, закрыть или переключить панель
registerCallButtonAction(button, dropdownButton, action)Кнопку звонка в шапке канала
registerChannelIntroButtonAction(icon, action, text)Кнопку в приветствии пустого канала
registerLeftSidebarHeaderComponent(component)Компонент вверху левой панели со списком каналов
registerBottomTeamSidebarComponent(component)Компонент внизу боковой панели команд
registerChannelToastComponent(component)Всплывающее уведомление над лентой сообщений
registerSidebarChannelLinkLabelComponent(component)Компонент рядом с названием канала в левой панели

Меню​

МетодКуда добавляет пункт
registerMainMenuAction(text, action, mobileIcon)В главное меню (меню команды)
registerChannelHeaderMenuAction(text, action)В меню канала (по нажатию на название канала). action получает ID канала
registerPostDropdownMenuAction(text, action, filter)В меню «Ещё» (...) у сообщения, в подменю Действия. action получает ID сообщения, filter(postId) решает, показывать ли пункт
registerPostDropdownSubMenuAction(text, action, filter)То же, но с вложенным подменю
registerPostDropdownMenuComponent(component)Свой компонент в меню сообщения
registerFileDropdownMenuAction(match, text, action)В меню у вложенного файла. match(fileInfo) решает, для каких файлов показывать
registerUserGuideDropdownMenuAction(text, action)В меню справки (?)
registerFileUploadMethod(icon, action, text)В меню прикрепления файла — свой способ загрузки

Сообщения​

МетодЧто делает
registerPostTypeComponent(type, component)Рисует сообщения с type = custom_... вашим компонентом. См. Свой вид сообщений
registerPostCardTypeComponent(type, component)Свой вид карточки сообщения в правой панели
registerPostMessageAttachmentComponent(component)Компонент под текстом каждого сообщения
registerPostWillRenderEmbedComponent(match, component, toggleable)Свой предпросмотр ссылок
registerLinkTooltipComponent(component)Всплывающая подсказка при наведении на ссылку
registerCodeBlockActionComponent(component)Кнопка у блока кода
registerNewMessagesSeparatorActionComponent(component)Компонент у разделителя «Новые сообщения»
registerPostActionComponent(component), registerPostEditorActionComponent(component)Компонент в строке действий сообщения / у поля ввода. Нет в типах SDK — см. примечание ниже
registerFilePreviewComponent(override, component)Свой просмотр файлов

Перехватчики​

МетодКогда вызывается функция
registerMessageWillBePostedHook(fn)Перед отправкой сообщения. Можно изменить или отменить
registerMessageWillBeUpdatedHook(fn)Перед сохранением отредактированного сообщения
registerSlashCommandWillBePostedHook(fn)Перед отправкой slash-команды. Можно обработать команду прямо в браузере
registerMessageWillFormatHook(fn)Перед отрисовкой текста сообщения — можно изменить Markdown
registerFilesWillUploadHook(fn)Перед загрузкой файлов
registerDesktopNotificationHook(fn)Перед показом уведомления на рабочем столе. Нет в типах SDK

Страницы, окна и прочее​

МетодЧто делает
registerRootComponent(component)Компонент, который всегда есть на странице. Удобен для модальных окон и фоновой логики
registerGlobalComponent(component)То же, но сохраняется при переключении между разделами Loop
registerNeedsTeamRoute(route, component)Своя страница внутри команды: https://your-loop-server.ru/<команда>/<ID плагина>/<route>
registerCustomRoute(route, component)Своя страница на весь экран: https://your-loop-server.ru/plug/<ID плагина>/<route>
registerProduct(...)Целый раздел Loop, как «Сценарии» или «Доски». Сложный низкоуровневый метод
registerPopoverUserAttributesComponent(component), registerPopoverUserActionsComponent(component)Компоненты в карточке пользователя (по нажатию на аватар)
registerActionAfterChannelCreation(component, action)Компонент в окне создания канала и действие после создания
registerAdminConsoleCustomSetting(key, component, options)Свой компонент для настройки типа custom. См. ниже
registerAdminConsolePlugin(fn)Изменение Системной консоли. Низкоуровневый метод
registerUserSettings(setting)Свой раздел в Настройках пользователя. Нет в типах SDK
registerSiteStatisticsHandler(fn)Свои показатели в Системная консоль > Отчеты > Статистика системы

Служебные​

МетодЧто делает
registerReducer(reducer)Подключает Redux-хранилище плагина (данные будут в state['plugins-<ID плагина>'])
registerTranslations(getTranslationsForLocale)Подключает переводы
registerWebSocketEventHandler(event, handler) / unregisterWebSocketEventHandler(event)Обработчик событий от сервера. См. ниже
registerReconnectHandler(handler) / unregisterReconnectHandler()Функция, которая вызывается, когда пропавшая связь с сервером восстановилась
unregisterComponent(id), unregisterPostTypeComponent(id)Убрать зарегистрированное
Методы, которых нет в типах SDK

registerPostActionComponent, registerPostEditorActionComponent, registerDesktopNotificationHook и registerUserSettings в Loop работают, но в типах loop-plugin-sdk их пока нет, и TypeScript выдаст ошибку. Вызывайте их через приведение типа: (registry as any).registerUserSettings(...).

Методы, которых нет в этом списке

В типах loop-plugin-sdk встречаются методы, которых нет в таблицах выше: registerCreatePostLabelComponent, registerCreatePostActionComponent и другие. Это внутренние методы собственных плагинов Loop — они могут измениться или исчезнуть без предупреждения. Не используйте их в своих плагинах.

Рецепты​

Все примеры пишутся внутри функции из webapp/src/registerApp.tsx, где доступны registry, store и обёртка Providers (о ней — в Устройство проекта).

Кнопка и правая панель​

const { toggleRHSPlugin } = registry.registerRightHandSidebarComponent(
() => (
<Providers>
<MyPanel />
</Providers>
),
'Мой плагин',
);

registry.registerChannelHeaderButtonAction(
() => <i className='icon icon-lightbulb-outline' />,
() => store.dispatch(toggleRHSPlugin),
'Мой плагин', // подпись, если кнопки собраны в выпадающее меню
'Открыть панель плагина', // подсказка при наведении
);

Иконки — из набора Compass Icons, который уже подключён в Loop: класс icon плюс icon-<имя>. В шаблоне есть готовый компонент webapp/src/icons/CompassIcons.tsx: <CompassIcon icon='lightbulb-outline' />.

Пункт в меню сообщения​

Пункт появится в меню ... у сообщения, в подменю Действия.

import { getPost } from 'loop-plugin-sdk/loop/redux/selectors/entities/posts';

registry.registerPostDropdownMenuAction(
'Создать задачу',
(postId: string) => {
const post = getPost(store.getState(), postId);
apiClient.createTask(post.message); // ваш запрос к серверной части
},
// Показывать пункт только у обычных сообщений, не у системных
(postId: string) => !getPost(store.getState(), postId)?.type,
);

Свой вид сообщений​

Серверная часть публикует сообщение с Type: "custom_hello_poll" и данными в Props (см. Серверная часть › Свой тип сообщения). Клиентская часть рисует его:

type PollProps = {
post: { id: string; message: string; props: { options?: string[] } };
};

const PollPost: React.FC<PollProps> = ({ post }) => (
<div>
<strong>{post.message}</strong>
<ul>
{(post.props.options ?? []).map((option) => (
<li key={option}>{option}</li>
))}
</ul>
</div>
);

registry.registerPostTypeComponent('custom_hello_poll', PollPost);

Компонент получает в свойстве post всё сообщение. Там, где клиентской части нет (мобильное приложение, уведомления, поиск), пользователь увидит обычный текст из message, поэтому пишите в него понятное описание.

Проверка сообщения перед отправкой​

registry.registerMessageWillBePostedHook((post: any) => {
if (post.message.includes('пароль:')) {
return { error: { message: 'Не публикуйте пароли в каналах' } };
}
return { post }; // вернуть сообщение как есть или изменённым
});

Такая проверка работает только в браузере и десктоп-приложении. Если правило важно, продублируйте его на сервере в хуке MessageWillBePosted — его не обойти ни из мобильного приложения, ни через API.

Своя страница​

registry.registerNeedsTeamRoute('/stats', () => (
<Providers>
<StatsPage />
</Providers>
));

Страница откроется по адресу https://your-loop-server.ru/<имя команды>/ru.mycompany.hello/stats: сверху останется панель Loop с поиском, а всё остальное место займёт ваш компонент. Перейти на неё из кода можно через window.WebappUtils.browserHistory.push(...).

Модальное окно​

Модальное окно удобно держать в registerRootComponent, а открывать и закрывать — через хранилище плагина:

import { Modal, Button } from 'react-bootstrap';

const MyModal: React.FC = () => {
const dispatch = useDispatch();
const isOpen = useSelector((state: GlobalStatePlugin) => getPluginStoreFromState(state).modalOpen);

return (
<Modal show={isOpen} onHide={() => dispatch(closeModal())}>
<Modal.Header closeButton>
<Modal.Title>Мой плагин</Modal.Title>
</Modal.Header>
<Modal.Body>Содержимое окна</Modal.Body>
<Modal.Footer>
<Button onClick={() => dispatch(closeModal())}>Закрыть</Button>
</Modal.Footer>
</Modal>
);
};

registry.registerRootComponent(() => (
<Providers>
<MyModal />
</Providers>
));

registry.registerMainMenuAction(
<span>Открыть окно плагина</span>, // в типах SDK текст должен быть React-элементом
() => store.dispatch(openModal()),
<i className='icon icon-open-in-new' />, // иконка для мобильной вёрстки веб-версии
);

Здесь modalOpen — поле в PluginStore, а openModal и closeModal — ваши действия, которые его меняют (как counterReceived в пошаговом примере).

Свой компонент настройки​

Если обычных типов настроек не хватает, задайте в манифесте "type": "custom" и зарегистрируйте компонент с тем же key:

type CustomSettingProps = {
id: string;
value: any;
disabled: boolean;
onChange: (id: string, value: any) => void;
setSaveNeeded: () => void;
};

const ChannelsListSetting: React.FC<CustomSettingProps> = ({ id, value, disabled, onChange, setSaveNeeded }) => (
<textarea
className='form-control'
disabled={disabled}
value={(value ?? []).join('\n')}
onChange={(e) => {
onChange(id, e.target.value.split('\n').filter(Boolean));
setSaveNeeded();
}}
/>
);

registry.registerAdminConsoleCustomSetting('WatchedChannels', ChannelsListSetting, { showTitle: true });

onChange(id, value) передаёт значение в форму, setSaveNeeded() включает кнопку Сохранить. С showTitle: true слева будет подпись из display_name, как у обычных настроек.

Данные Loop​

Всё, что знает интерфейс, лежит в store. Доставайте данные готовыми функциями-селекторами из loop-plugin-sdk:

import { useSelector } from 'react-redux';
import { getCurrentUser } from 'loop-plugin-sdk/loop/redux/selectors/entities/common';
import { getCurrentChannel } from 'loop-plugin-sdk/loop/redux/selectors/entities/channels';
import { getCurrentTeam } from 'loop-plugin-sdk/loop/redux/selectors/entities/teams';
import { getConfig } from 'loop-plugin-sdk/loop/redux/selectors/entities/general';
import { GlobalState } from 'loop-plugin-sdk/loop/types/store';

const Info: React.FC = () => {
const user = useSelector(getCurrentUser);
const channel = useSelector((state: GlobalState) => getCurrentChannel(state));
const team = useSelector(getCurrentTeam);
const siteURL = useSelector((state: GlobalState) => getConfig(state).SiteURL);

return <div>{user.username} в канале {channel?.display_name} команды {team?.display_name}</div>;
};
Что нужноСелекторМодуль loop-plugin-sdk/loop/redux/selectors/entities/...
Текущий пользователь и его IDgetCurrentUser, getCurrentUserIdcommon
Текущий канал и его IDgetCurrentChannel, getCurrentChannelIdchannels, common
Текущая команда и её IDgetCurrentTeam, getCurrentTeamIdteams
Пользователь по IDgetUser(state, id)users
Канал по IDgetChannel(state, id)channels
Сообщение по IDgetPost(state, id)posts
Настройки сервера (без секретов)getConfiggeneral
Тема оформления пользователяgetThemepreferences
Язык пользователяgetCurrentUserLocalei18n

Вне компонентов (например, в action кнопки) используйте store.getState(): getCurrentChannelId(store.getState()).

Запросы к серверной части​

Для запросов к своему HTTP API используйте Client4 из loop-plugin-sdk — он сам добавляет авторизацию текущего пользователя и защиту от CSRF. В шаблоне для этого есть класс ApiClient в webapp/src/utils/api.ts; добавляйте в него методы по образцу:

async createTask(title: string): Promise<StdApiResp<{ id: string }>> {
try {
// @ts-ignore — doFetch помечен в типах как protected
return await this.client.doFetch<StdApiResp<{ id: string }>>(`/plugins/${manifest.id}/api/tasks`, {
method: 'POST',
body: JSON.stringify({ title }),
});
} catch (error) {
console.error(error);
return { status: 'error', error: String(error) };
}
}
warning
Не используйте голый fetch для запросов, которые что-то меняют

У POST, PUT и DELETE Loop проверяет CSRF-токен. Client4 добавляет его сам, а обычный fetch — нет: такой запрос сервер выполнит без пользователя, и шаблонная проверка вернёт 401. Если очень нужен fetch, берите заголовки у клиента: fetch(url, client.getOptions({ method: 'POST', body })).

Для стандартных действий Loop (получить пользователей, создать сообщение) есть готовые методы Client4: getUser, getProfilesByIds, createPost, getChannel и другие — они повторяют REST API.

События от сервера​

Когда серверная часть вызывает p.API.PublishWebSocketEvent("item_created", ...) (см. Серверная часть), в браузер приходит событие custom_<ID плагина>_item_created:

registry.registerWebSocketEventHandler(
`custom_${manifest.id}_item_created`,
(msg) => {
// msg.data — словарь, который отправил сервер
store.dispatch(itemReceived(msg.data.id, msg.data.title));
},
);

Если связь с сервером прерывалась, события за это время потеряются. Зарегистрируйте registerReconnectHandler и заново запросите данные у сервера.

Подписываться можно и на стандартные события Loop, например posted (новое сообщение) или user_updated.

Переводы​

  1. Пишите тексты через FormattedMessage или хук useIntl:

    import { FormattedMessage, useIntl } from 'react-intl';

    <FormattedMessage id='myplugin.panel.title' defaultMessage='Задачи' />

    const intl = useIntl();
    const placeholder = intl.formatMessage({ id: 'myplugin.search.placeholder', defaultMessage: 'Поиск' });
  2. Добавьте строки в webapp/i18n/ru.json и webapp/i18n/en.json. Начинайте ID с названия плагина, чтобы не пересечься с Loop и другими плагинами.

  3. Компоненты, которые вы регистрируете, оборачивайте в Providers из шаблона — в нём уже есть IntlProvider с языком пользователя.

Функция getTranslations в webapp/src/utils/utils.ts выбирает файл по языку. Чтобы добавить язык, положите файл в webapp/i18n/ и допишите его в switch.

Оформление и тёмная тема​

У пользователей Loop разные темы, в том числе тёмные. Не задавайте цвета жёстко — используйте CSS-переменные темы:

.my-plugin-card {
background: var(--center-channel-bg);
color: var(--center-channel-color);
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
}

.my-plugin-button {
background: var(--button-bg);
color: var(--button-color);
}

.my-plugin-error {
color: var(--error-text);
}

Основные переменные: --center-channel-bg, --center-channel-color, --sidebar-bg, --sidebar-text, --button-bg, --button-color, --link-color, --error-text, --online-indicator, --away-indicator, --dnd-indicator. У большинства есть вариант с суффиксом -rgb для полупрозрачных цветов: rgba(var(--button-bg-rgb), 0.08).

Стили подключаются обычным импортом .scss в компоненте. Начинайте имена классов с названия плагина, чтобы случайно не переопределить стили Loop.

Десктоп-приложение​

Клиентская часть работает в десктоп-приложении Loop так же, как в браузере. Проверить, где запущен плагин, можно функцией isDesktopApp() из webapp/src/utils/utils.ts. Ссылки на внешние сайты в десктоп-приложении открываются в браузере — используйте обычные <a href="..." target="_blank" rel="noopener noreferrer">.