Клиентская часть
Клиентская часть плагина — код на React и TypeScript, который выполняется в браузере и в десктоп-приложении Loop. С её помощью плагин добавляет в интерфейс кнопки, панели, пункты меню, страницы и свой вид сообщений.
Мобильное приложение Loop клиентскую часть плагинов не загружает. Всё, что должно работать и на телефоне, делайте на сервере: сообщения, боты, slash-команды, кнопки во вложениях.
Как Loop загружает плагин
- При открытии Loop браузер получает список включённых плагинов и скачивает их файлы
main.js. - Файл плагина вызывает
window.registerPlugin(ID плагина, объект плагина)— в шаблоне это последняя строкаwebapp/src/index.tsx. - Loop вызывает у объекта метод
initialize(registry, store):registry— реестр: через него плагин говорит, что и куда добавить в интерфейс;store— Redux-хранилище Loop, где лежат все данные: текущий пользователь, каналы, сообщения, настройки.
- Когда плагин выключают, Loop вызывает у объекта
uninitialize()(если он есть) и сам убирает всё, что плагин зарегистрировал.
После включения или обновления плагина пользователям не нужно перезагружать страницу: Loop подгрузит новую версию сам.
Какие библиотеки уже есть
Плагин не собирает свою копию React — он берёт библиотеки у Loop. Их можно импортировать как обычно (import React from 'react'), а при сборке импорт заменится на объект из window:
| Импорт | Что это |
|---|---|
react, react-dom | React 18 |
redux, react-redux | Redux и хуки 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) | Убрать зарегистрированное |
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/... |
|---|---|---|
| Текущий пользователь и его ID | getCurrentUser, getCurrentUserId | common |
| Текущий канал и его ID | getCurrentChannel, getCurrentChannelId | channels, common |
| Текущая команда и её ID | getCurrentTeam, getCurrentTeamId | teams |
| Пользователь по ID | getUser(state, id) | users |
| Канал по ID | getChannel(state, id) | channels |
| Сообщение по ID | getPost(state, id) | posts |
| Настройки сервера (без секретов) | getConfig | general |
| Тема оформления пользователя | getTheme | preferences |
| Язык пользователя | getCurrentUserLocale | i18n |
Вне компонентов (например, в 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) };
}
}
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.
Переводы
-
Пишите тексты через
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: 'Поиск' }); -
Добавьте строки в
webapp/i18n/ru.jsonиwebapp/i18n/en.json. Начинайте ID с названия плагина, чтобы не пересечься с Loop и другими плагинами. -
Компоненты, которые вы регистрируете, оборачивайте в
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">.