Compare commits

..

42 Commits

Author SHA1 Message Date
3bb960a83f update README 2026-04-06 14:02:07 +03:00
981f2aebd6 destroy dot 2026-04-03 17:13:42 +03:00
6f7c963d03 replace header create button with floating action button 2026-03-30 15:41:24 +03:00
08d7638a89 fix height dropdown in GrantModal 2026-03-17 15:00:50 +03:00
779630ebd5 LP-5723: back button logic fix 2026-03-16 15:01:12 +03:00
45e50f0467 LP-5722: fix trims spaces in imaeg input 2026-03-16 14:09:14 +03:00
f8b9565cc1 LP-5681: fix description length 2026-03-16 13:56:41 +03:00
eaf1f305f4 LP-5677: fix error text 2026-03-16 11:44:50 +03:00
da8ee3897a LP-5723: fix bug with BackButton 2026-03-16 11:31:25 +03:00
02305aae92 fix push on mobile 2026-03-13 17:37:21 +03:00
f7ccbb849a fix for plugin tooltip title 2026-03-13 16:28:33 +03:00
5fe054333a LP-5673: fixed a problem with duplicate notifications in a channel 2026-03-13 16:24:25 +03:00
0942562085 LP-5675: fixed the ability to grant an achievement to a user of a shared channel 2026-03-13 16:09:43 +03:00
3e8879245b LP-5676: fixed the ability to add a user to a shared channel 2026-03-13 15:28:48 +03:00
47fbaef659 LP-5674: fixed a bug with creating an achievement with a non-existent emoji 2026-03-13 10:48:09 +03:00
eabb1603bd LP-5677: fixed the possibility of creating an achievement with a duplicate name in one type 2026-03-12 16:42:57 +03:00
d06422a711 fixed a bug with false emoji requests 2026-03-12 15:53:14 +03:00
157967c555 fixed the length of the achievement name 2026-03-12 14:57:32 +03:00
7f7b0cba58 fix ts and webpack errors 2026-03-12 14:55:52 +03:00
ec89c1f115 some refactoring, the BadgeList component has been remade into a functional one 2026-03-12 14:01:34 +03:00
b6c2a3b6f8 LP-5687: add a dash to the achievement card if the description is empty 2026-03-12 10:49:03 +03:00
23bffe57f7 LP-5681: fixed achievement description length 2026-03-11 14:38:52 +03:00
9079737523 update webpack and babel-loader to support Node.js 17+ 2026-03-11 13:45:57 +03:00
f53b8f3df1 defaultProps removed from component 2026-03-11 11:37:35 +03:00
e7d0560bd3 fixed a bug with system emojis that contained aliases 2026-03-11 11:33:51 +03:00
d99ed95cd5 added fallback display for the removed achievement emoji 2026-03-10 15:56:02 +03:00
3d84d75669 added the ability to edit achievements for users allowed to create achievements in this type 2026-03-10 15:33:49 +03:00
2006501a74 added display of the type creator in the list of types 2026-03-10 15:15:23 +03:00
8596b0e7dc added a navigation button to exit BadgeDetails 2026-03-10 14:39:48 +03:00
14186baadb added my achievements tab 2026-03-10 10:40:30 +03:00
df25e1f6fc added the ability to remove achievements and changed the process of deleting achievements and types 2026-03-05 11:36:27 +03:00
0d582ec803 replace interactive dialogs with custom modals for grant and subscription flows 2026-03-03 11:07:43 +03:00
9f4b2218b0 add types management tab with CRUD operations to RHS panel 2026-03-02 11:46:53 +03:00
a6b5bcd503 fix lint rules and add webpack resolve rule for mjs files 2026-03-02 11:31:00 +03:00
edc20a252f added the ability to select emojis via the Emoji Picker Overlay 2026-02-25 13:39:05 +03:00
a88ce39a48 added badge stacking in user popover 2026-02-24 15:57:02 +03:00
dffe0685bb replaced admin text input with a searchable multi-select 2026-02-24 14:04:16 +03:00
7c976233a7 added the ability to assign multiple plugin administrators 2026-02-19 16:21:52 +03:00
754304e4ca remove install-state.gz from git tracking (already in .gitignore) 2026-02-19 15:01:38 +03:00
e47a63f1d5 added a common badge type when initializing the plugin, moved the ability to create types/badges to the UI, and redesigned components (AllBadgesRow, UserBadgeRow, UserRow, BadgeDetails) 2026-02-19 14:48:09 +03:00
04a001bc94 Merge pull request 'added full internationalization for the plugin' (#1) from internationalization into dev
Reviewed-on: #1
2026-02-16 12:49:40 +00:00
b975b5f5f1 added full internationalization for the plugin 2026-02-16 13:50:43 +03:00
83 changed files with 7590 additions and 1208 deletions

260
README.md
View File

@ -1,175 +1,193 @@
# Badges plugin
Let your users show appreciation for their fellow colleagues by granting badges.
# Плагин значков для Loop
## Install
Get the latest release from [GitHub](https://github.com/larkox/mattermost-plugin-badges/releases) and [install it manually](https://developers.mattermost.com/integrate/plugins/server/hello-world/#installing-the-plugin) on your server.
Позволяет пользователям выражать признательность коллегам, выдавая им значки.
## Configuration
## Установка
![Screenshot from 2022-03-16 11-02-13](https://user-images.githubusercontent.com/1933730/158565396-9d637c4c-6772-449f-81cb-2b73f8f6670e.png)
Скачайте последний релиз и установите его вручную через системную консоль Loop.
- **Badges admin**: Every System Admin is considered a badges admin. System Admins can assign the badges admin role to a single person by specifying their username. Only a single badge admin assignment is permitted.
## Настройка
## Usage
### Creating a type
Badge admins can create different types of badges, and each type of badge can have its own permissions. You must be a badge admin to create a badge type.
Run the slash command `/badges create type` to open the creation dialog.
- **Администраторы значков**: все системные администраторы автоматически являются администраторами значков. Дополнительно можно назначить любое количество администраторов значков через мультиселект в настройках плагина.
![Screenshot from 2022-03-16 11-14-31](https://user-images.githubusercontent.com/1933730/158567578-1241cc93-6964-4dc7-a56b-a5b3729229b7.png)
## Использование
- **Name**: The type of badge that's visible in the badges description.
- **Everyone can create badge**: If you mark this checkbox, every user in your Mattermost instance can create badges of this type.
- **Can create allowlist**: This list contains the usernames (comma separated) of all the people allowed to create badges of this type.
- **Everyone can grant badge**: If you mark this checkbox, every user in your Mattermost instance can grant any badge of this type.
- **Can grant allowlist**: This list contains the usernames (comma separated) of all the people allowed to grant badges of this type.
### Создание типа значка
### Permissions details
Badge admins can always create types, create badges for any type, and grant badges from any type, regardless of the permissions in place for a given badge type.
A badge creator can always grant the badge they created.
Any other user is subject to the permissions defined as part of the badge type.
Администраторы значков могут создавать разные типы значков, каждый со своими правами доступа. Для создания типа необходимы права администратора значков.
Some examples of badge permissions by type are included below. Remember that badge admins have full control over badges, and badge creators can always grant badges. The examples below are intended to demonstrate how badge permissions can be configured for non-admin users to get the most out of badges.
(ECC: Everyone Can Create, CC: Can Create Allowlist, ECG: Everyone Can Grant, CG: Can Grant Allowlist)
| Permissions | Example | ECC | ECG | CC | CG |
|----------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|-------|-------|--------------|--------------|
| Only badge admins can create and grant badges | | false | false | empty | empty |
| Only user1 can create badges, but everyone can grant them | for peer appreciation badges, like "Thank you" badge | false | true | user1 | empty |
| Only user1 can create badges, and only user2 and user3 can grant them | lead appreciation badges, like "MVP" badge, where the management create the badges, and the team leads are the ones granting them to their team members | false | false | user1 | user2, user3 |
| Only user1 and user2 can create badges, but they can only grant the badges they have created | can be used to have team specific badges without creating a new type for every team | false | false | user1, user2 | empty |
| Everyone can create badges, but can only grant the badges they have created | | true | false | empty | empty |
| Everyone can create and grant any badge | | true | true | empty | empty |
Выполните слэш-команду `/badges create type`, чтобы открыть диалог создания.
### Creating a badge
Run the slash command `/badges create badge` to open the creation dialog.
- **Название**: название типа, отображаемое в описании значков.
- **Все могут создавать значки**: если отмечено, любой пользователь Loop может создавать значки этого типа.
- **Список допущенных к созданию**: username'ы через запятую — кто может создавать значки этого типа.
- **Все могут выдавать значки**: если отмечено, любой пользователь Loop может выдавать значки этого типа.
- **Список допущенных к выдаче**: username'ы через запятую — кто может выдавать значки этого типа.
![Screenshot from 2022-03-16 11-37-32](https://user-images.githubusercontent.com/1933730/158571687-4983f7e4-1cf9-4fa1-a3f1-6f80918e28e3.png)
### Права доступа
- **Name**: Name of the badge.
- **Description**: Description of the badge.
- **Image**: Only emojis are allowed. You must input the emoji name as you would to add it to a message (e.g. `:+1:` or `:smile:`). Custom emojis are also allowed.
- **Type**: The type of badge. This list will show only types you have permissions to create.
- **Multiple**: Whether this badge can be granted more than once to the same person.
Администраторы значков всегда могут создавать типы, создавать значки любого типа и выдавать любые значки — независимо от настроек прав типа. Создатель значка всегда может выдавать созданный им значок. Остальные пользователи подчиняются правам, настроенным для типа.
### Details about Multiple
All badges can be assigned to any number of people. What the **Multiple** setting controls is whether this badge can be granted more than once to the same person. For example, a "Thank you" badge should be grantable many times (many people can be thankful to you on more than one occasion), and therefore, a Thank You badge should have the **Multiple** option selected. However, a "First year in the company" badge should be granted only once since a user won't celebrate this milestone multiple times at the same company. This type of badge should have the **Multiple** option unselected.
Примеры конфигураций (ECC: все могут создавать, CC: список допущенных к созданию, ECG: все могут выдавать, CG: список допущенных к выдаче):
### Granting a badge
There are two ways to open the grant dialog:
- Run the `/badges grant` command.
- Click on the **Grant badge** link available in the Profile Popover, visible when you click on someone's username.
| Права | Пример использования | ECC | ECG | CC | CG |
|-------|---------------------|-----|-----|----|----|
| Только администраторы могут создавать и выдавать | | false | false | пусто | пусто |
| Только user1 создаёт, но выдавать может каждый | Значок "Спасибо" от коллег | false | true | user1 | пусто |
| Только user1 создаёт, выдают только user2 и user3 | Значок "MVP" — руководство создаёт, тимлиды выдают | false | false | user1 | user2, user3 |
| user1 и user2 создают, выдают только свои значки | Командные значки без создания нового типа на каждую команду | false | false | user1, user2 | пусто |
| Все создают и выдают только свои значки | | true | false | пусто | пусто |
| Все создают и выдают любые значки | | true | true | пусто | пусто |
![Screenshot from 2022-03-16 11-47-14](https://user-images.githubusercontent.com/1933730/158573673-723e77a2-6d58-4aa5-8a89-6adcbce50e13.png)
### Создание значка
The dialog looks like this:
Выполните слэш-команду `/badges create badge`, чтобы открыть диалог создания.
![Screenshot from 2022-03-16 11-51-05](https://user-images.githubusercontent.com/1933730/158573834-70ea72b0-4a03-4b09-a694-751c0ca1ba04.png)
- **Название**: название значка.
- **Описание**: описание значка.
- **Изображение**: только эмодзи. Укажите имя эмодзи как в сообщении (например `:+1:` или `:smile:`). Кастомные эмодзи тоже поддерживаются.
- **Тип**: тип значка. В списке отображаются только типы, для которых у вас есть права на создание.
- **Множественный**: можно ли выдавать этот значок одному человеку несколько раз.
- **User**: The user you want to grant the badge to (may be prepopulated if you clicked the grant button from the profile popover, or added the username in the command).
- **Badge**: The badge you want to grant (may be prepopulated if you added the badge id in the command).
- **Reason**: An optional reason why you are awarding this badge. (Specially useful for badges like "Thank you").
- **Notify on this channel**: If you select this option, a message from the badges bot will be posted in the current channel, letting everyone in that channel know that you granted this badge to that person.
The user that received the badge will always receive a DM from the badges bot letting them know they have been awarded a badge. In addition, the following may happen:
- If **Notify on this channel** was marked, the badges bot will post a message on the current channel letting everyone know that the user has been awarded a badge.
- If a subscription for this badge type is set, the badges bot will post a message on all subscribed channels letting everyone know that the user has been awarded a badge.
### Подробнее о параметре "Множественный"
![Screenshot from 2022-03-16 12-28-45](https://user-images.githubusercontent.com/1933730/158580318-592bb139-6c43-48f0-99c3-79d868aa8024.png)
Любой значок можно выдать любому количеству людей. Параметр **Множественный** определяет, можно ли выдать значок одному и тому же человеку более одного раза. Например, значок "Спасибо" стоит сделать множественным — один человек может получить благодарность многократно. А значок "Первый год в компании" должен быть невозможно выдать дважды.
If you try to award a badge that can't be awarded more than once to a single recipient, the badge won't be granted.
### Выдача значка
### Subscriptions
In order to create a subscription, you must be a badges admin.
Subscriptions will create posts into a channel every time a badge is granted. There is no limit to the number of subscriptions per channel or per type.
There are two ways to open the subscription creation dialog:
- Run the `/badges subscription create` command.
- Click on the **Add badge subscription** menu from the channel menu.
Открыть диалог выдачи можно двумя способами:
- Выполнить команду `/badges grant`.
- Нажать **Выдать значок** в поповере профиля пользователя.
![Screenshot from 2022-03-16 12-16-16](https://user-images.githubusercontent.com/1933730/158578166-1ae6f5de-a53b-4e46-95ba-4fd57f50a315.png)
- **Пользователь**: кому выдаётся значок.
- **Значок**: какой значок выдаётся.
- **Причина**: необязательное пояснение (особенно полезно для значков типа "Спасибо").
- **Уведомить в этом канале**: если отмечено, бот опубликует сообщение в текущем канале о выдаче значка.
The dialog looks like this:
Получатель значка всегда получает личное сообщение от бота. Дополнительно:
- Если отмечено **Уведомить в этом канале** — бот публикует сообщение в текущем канале.
- Если настроена подписка для этого типа — бот публикует сообщение во всех подписанных каналах.
![Screenshot from 2022-03-16 12-16-55](https://user-images.githubusercontent.com/1933730/158578272-dc6644a1-3a8b-4f54-8c83-d192d8fab273.png)
### Подписки
- **Type**: The type of badges you want to subscribe to this channel.
Подписки позволяют автоматически публиковать сообщение в канале каждый раз, когда выдаётся значок определённого типа. Количество подписок не ограничено.
In order to remove subscriptions, a similar dialog can be opened by using the `/badges subscription remove` and the **Remove badge subscription** option from the channel menu.
Для создания подписки необходимы права администратора значков. Открыть диалог создания подписки можно двумя способами:
- Выполнить команду `/badges subscription create`.
- Нажать **Добавить подписку на значки** в меню канала.
### Editing a deleting badges and types
In order to edit or delete types you must be a badge admin. In order to edit or delete a badge, you must be a badge admin or the creator.
Run `/badges edit type --type typeID` or `/badges edit badge --id badgeID` to open a dialog pretty similar to the creation dialog. IDs are not human readable, but Autocomplete will help you select the right badge.
- **Тип**: тип значков, на который подписывается канал.
![Screenshot from 2022-03-16 12-22-49](https://user-images.githubusercontent.com/1933730/158579272-7a7164da-0b90-412f-94f5-7a10fe5f1a1a.png)
![Screenshot from 2022-03-16 12-21-21](https://user-images.githubusercontent.com/1933730/158579256-58b3ad7b-f0c2-44f9-9d33-4679a87cd034.png)
Для удаления подписки используйте `/badges subscription remove` или **Удалить подписку на значки** в меню канала.
The only difference to the creation is one extra checkbox to remove the current type or badge. If you mark this checkbox and click **Edit**, the badge or type will be removed.
When you remove a badge, the badge is deleted permanently, along with any information about who that badge was granted to. When you remove a type, the type and all the associated badges are removed completely.
### Редактирование и удаление значков и типов
### Badge list
Badges show on several places. On the profile popover of the users, they show up to the last 20 badges granted to that user. Hovering over the badges will give you more information, and cliking on them will open the Right Hand Sidebar (RHS) with the badge details.
Для редактирования и удаления типов необходимы права администратора значков. Для редактирования и удаления значка — права администратора или создателя значка.
![Screenshot from 2022-03-16 12-29-39](https://user-images.githubusercontent.com/1933730/158580433-ca57a911-1397-432d-a739-0f06ac474845.png)
Выполните `/badges edit type --type typeID` или `/badges edit badge --id badgeID`, чтобы открыть диалог редактирования. Автодополнение поможет выбрать нужный значок или тип.
The channel header button will open the RHS with the list of all badges.
Диалог редактирования аналогичен диалогу создания, но с дополнительным чекбоксом удаления. Если отметить его и нажать **Изменить** — значок или тип будет удалён.
![Screenshot from 2022-03-16 12-31-18](https://user-images.githubusercontent.com/1933730/158580823-997df585-c775-43ff-9475-7a5900b151e6.png)
![Screenshot from 2022-03-16 12-32-31](https://user-images.githubusercontent.com/1933730/158580924-e24e4884-d321-465c-bd92-8c41c286612e.png)
При удалении значка безвозвратно удаляется вся история его выдачи. При удалении типа удаляются тип и все связанные с ним значки.
Clicking on any badge will lead you to the badge details. Here you can check all the users that have been granted this badge.
### Список значков
![Screenshot from 2022-03-16 12-33-17](https://user-images.githubusercontent.com/1933730/158581085-454ff9b8-1614-4625-a4e3-16f2b0356ac8.png)
Значки отображаются в нескольких местах. В поповере профиля — до 20 последних полученных значков. При наведении на значок появляется подсказка, при клике открывается панель с деталями значка.
Clicking on any username on the badge details screen will lead you to the badges granted to that user.
Кнопка в шапке канала открывает панель со списком всех значков. Клик на любой значок показывает его детали и список пользователей, которым он был выдан. Клик на имя пользователя показывает все его значки.
![Screenshot from 2022-03-16 12-34-31](https://user-images.githubusercontent.com/1933730/158581257-ca614b71-3093-48fe-909d-c706c348891e.png)
---
## Using the Plugin API to create and grant badges
This plugin can be integrated with any other plugin in your system, to automatize the creation and granting of badges.
## REST API
Using the [PluginHTTP](https://developers.mattermost.com/integrate/plugins/server/reference/#API.PluginHTTP) API method, you can create a request to the badges plugin to "Ensure" and to "Grant" the badges needed.
Базовый URL: `https://<сервер>/plugins/ru.loop.plugin.achievements`
The badges plugin exposes the `badgesmodel` package to simplify handling several parts of this process. Some important exposed objects:
- badgesmodel.PluginPath (`/com.mattermost.badges`): The base URL for the plugin (the plugin id).
- badgesmodel.PluginAPIPath (`/papi/v1`): The plugin api route.
- badgesmodel.PluginAPIPathEnsure (`/ensure`): The ensure endpoint route.
- badgesmodel.PluginAPIPathGrant (`/grant`): The grant endpoint route.
- badgesmodel.Badge: The data model for badges.
- badgesmodel.EnsureBadgesRequest: The data model of the body of a Ensure Badges Request.
- badgesmodel.GrantBadgeRequest: The data model of the body of a Grant Badge Request.
- badgesmodel.ImageTypeEmoj (`emoji`): The emoji image type. Other image types are considered, but we recommend using emojis.
Все запросы требуют токен в заголовке:
```
Authorization: Bearer <токен>
```
### Ensure badges
URL: `/com.mattermost.badges/papi/v1/ensure`
Токен можно получить в настройках аккаунта Loop: **Профиль → Безопасность → Персональные токены доступа**.
Method: `POST`
### Типы значков
| Метод | Эндпоинт | Описание |
|-------|----------|----------|
| `GET` | `/api/v1/getTypes` | Список типов, доступных текущему пользователю |
| `POST` | `/api/v1/createType` | Создать тип (только администраторы) |
| `PUT` | `/api/v1/updateType` | Обновить тип (только администраторы) |
| `DELETE` | `/api/v1/deleteType/{typeID}` | Удалить тип и все его значки (только администраторы) |
### Значки
| Метод | Эндпоинт | Описание |
|-------|----------|----------|
| `GET` | `/api/v1/getAllBadges` | Полный список значков |
| `GET` | `/api/v1/getUserBadges/{userID}` | Значки конкретного пользователя |
| `GET` | `/api/v1/getBadgeDetails/{badgeID}` | Детали значка и список получателей |
| `POST` | `/api/v1/createBadge` | Создать значок |
| `PUT` | `/api/v1/updateBadge` | Обновить значок |
| `DELETE` | `/api/v1/deleteBadge/{badgeID}` | Удалить значок и историю его выдачи |
### Выдача значков
| Метод | Эндпоинт | Описание |
|-------|----------|----------|
| `POST` | `/api/v1/grantBadge` | Выдать значок пользователю |
| `POST` | `/api/v1/revokeOwnership` | Отозвать выдачу значка |
### Подписки
| Метод | Эндпоинт | Описание |
|-------|----------|----------|
| `GET` | `/api/v1/getChannelSubscriptions/{channelID}` | Список подписок канала |
| `POST` | `/api/v1/createSubscription` | Создать подписку (только администраторы) |
| `POST` | `/api/v1/deleteSubscription` | Удалить подписку (только администраторы) |
---
## Интеграция с другими плагинами (Plugin API)
Плагин поддерживает интеграцию с другими плагинами Loop через механизм `PluginHTTP`. Авторизация выполняется автоматически ядром Loop — токен не нужен.
Доступны два эндпоинта:
### Ensure — создать значки если не существуют
URL: `/ru.loop.plugin.achievements/papi/v1/ensure`
Метод: `POST`
Body example:
```json
{
"Badges":[
{
"name":"My badge",
"description":"Awesome badge",
"image":"smile",
"image_type":"emoji",
"multiple":true
}
],
"BotId":"myBotId"
"Badges": [
{
"name": "My badge",
"description": "Awesome badge",
"image": "smile",
"image_type": "emoji",
"multiple": true
}
],
"BotId": "botUserId"
}
```
Ensure badges will create badges if they already do not exist, and return the list of badges including the ids. In order to check whether a badge exist or not, it will only check the name of the badge.
### Grant badges
URL: `/com.mattermost.badges/papi/v1/grant`
Создаёт значки, если они ещё не существуют (проверка по имени), и возвращает их с заполненными `id`.
Method: `POST`
### Grant — выдать значок
URL: `/ru.loop.plugin.achievements/papi/v1/grant`
Метод: `POST`
Body example:
```json
{
"BadgeID":"badgeID",
"BotId":"myBotId",
"UserID":"userID",
"Reason":""
"BadgeID": "badgeID",
"BotId": "botUserId",
"UserID": "userID",
"Reason": "За крутую работу"
}
```
Grant badges will grant the badge with the badge id provided from the bot to the user defined. Reason is optional.
Выдаёт значок от имени указанного бота пользователю. `Reason` — необязательное поле.

View File

@ -3,6 +3,7 @@ package badgesmodel
const (
NameMaxLength = 20
DescriptionMaxLength = 120
DefaultTypeName = "Общий"
ImageTypeEmoji ImageType = "emoji"
ImageTypeRelativeURL ImageType = "rel_url"

View File

@ -2,6 +2,7 @@ package badgesmodel
import (
"time"
"unicode/utf8"
)
type BadgeType string
@ -56,6 +57,7 @@ type BadgeTypeDefinition struct {
CreatedBy string `json:"created_by"`
CanGrant PermissionScheme `json:"can_grant"`
CanCreate PermissionScheme `json:"can_create"`
IsDefault bool `json:"is_default"`
}
type PermissionScheme struct {
@ -87,8 +89,8 @@ type Subscription struct {
}
func (b Badge) IsValid() bool {
return len(b.Name) <= NameMaxLength &&
len(b.Description) <= DescriptionMaxLength &&
return utf8.RuneCountInString(b.Name) <= NameMaxLength &&
utf8.RuneCountInString(b.Description) <= DescriptionMaxLength &&
b.Image != ""
}

68
go.mod
View File

@ -1,13 +1,77 @@
module github.com/larkox/mattermost-plugin-badges
go 1.12
go 1.24.0
toolchain go1.24.3
require (
github.com/gorilla/mux v1.8.0
github.com/mattermost/mattermost-plugin-api v0.0.14
github.com/mattermost/mattermost-server/v5 v5.3.2-0.20210422214809-ff657bfdef24
github.com/nicksnyder/go-i18n/v2 v2.6.1
github.com/pkg/errors v0.9.1
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.7.0
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
golang.org/x/text v0.34.0
)
require (
github.com/blang/semver v3.5.1+incompatible // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/dyatlov/go-opengraph v0.0.0-20210112100619-dae8665a5b09 // indirect
github.com/fatih/color v1.10.0 // indirect
github.com/francoispqt/gojay v1.2.13 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.3 // indirect
github.com/go-sql-driver/mysql v1.5.0 // indirect
github.com/golang/protobuf v1.5.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/uuid v1.2.0 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-hclog v0.15.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-plugin v1.4.0 // indirect
github.com/hashicorp/yamux v0.0.0-20210316155119-a95892c5f864 // indirect
github.com/json-iterator/go v1.1.10 // indirect
github.com/klauspost/cpuid/v2 v2.0.5 // indirect
github.com/lib/pq v1.10.0 // indirect
github.com/mattermost/go-i18n v1.11.0 // indirect
github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d // indirect
github.com/mattermost/logr v1.0.13 // indirect
github.com/mattn/go-colorable v0.1.8 // indirect
github.com/mattn/go-isatty v0.0.12 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.10 // indirect
github.com/minio/sha256-simd v1.0.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.1 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/pborman/uuid v1.2.1 // indirect
github.com/pelletier/go-toml v1.8.1 // indirect
github.com/philhofer/fwd v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rs/xid v1.2.1 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/tinylib/msgp v1.1.5 // indirect
github.com/wiggin77/cfg v1.0.2 // indirect
github.com/wiggin77/merror v1.0.3 // indirect
github.com/wiggin77/srslog v1.0.1 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.16.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
google.golang.org/genproto v0.0.0-20210322173543-5f0e89347f5a // indirect
google.golang.org/grpc v1.36.0 // indirect
google.golang.org/protobuf v1.26.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/ini.v1 v1.62.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

40
go.sum
View File

@ -50,8 +50,9 @@ github.com/Azure/azure-sdk-for-go v26.5.0+incompatible/go.mod h1:9XXNKU+eRnpl9mo
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
github.com/Azure/go-autorest v11.5.2+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
github.com/BurntSushi/toml v0.3.0/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/ClickHouse/clickhouse-go v1.3.12/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHgv5+JMS9NSr2smCJI=
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
@ -279,7 +280,6 @@ github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gorp/gorp v2.0.0+incompatible/go.mod h1:7IfkAQnO7jfT/9IQ3R9wL1dFhukN6aQxzKTHnkxzA/E=
github.com/go-gorp/gorp v2.2.0+incompatible h1:xAUh4QgEeqPPhK3vxZN+bzrim1z5Av6q837gtjUlshc=
github.com/go-gorp/gorp v2.2.0+incompatible/go.mod h1:7IfkAQnO7jfT/9IQ3R9wL1dFhukN6aQxzKTHnkxzA/E=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
@ -362,8 +362,9 @@ github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@ -550,7 +551,6 @@ github.com/klauspost/compress v1.11.12/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdY
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/cpuid v1.2.3/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/cpuid v1.3.1 h1:5JNjFYYQrZeKRJ0734q51WCEEn2huer72Dc7K+R/b6s=
github.com/klauspost/cpuid v1.3.1/go.mod h1:bYW4mA6ZgKPob1/Dlai2LviZJO7KGI3uoWLd42rAQw4=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@ -639,7 +639,6 @@ github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRC
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
@ -700,12 +699,13 @@ github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxzi
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
github.com/nelsam/hel/v2 v2.3.2 h1:tXRsJBqRxj4ISSPCrXhbqF8sT+BXA/UaIvjhYjP5Bhk=
github.com/nelsam/hel/v2 v2.3.2/go.mod h1:1ZTGfU2PFTOd5mx22i5O0Lc2GY933lQ2wb/ggy+rL3w=
github.com/neo4j/neo4j-go-driver v1.8.1-0.20200803113522-b626aa943eba/go.mod h1:ncO5VaFWh0Nrt+4KT4mOZboaczBZcLuHrG+/sUeP8gI=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/ngdinhtoan/glide-cleanup v0.2.0/go.mod h1:UQzsmiDOb8YV3nOsCxK/c9zPpCZVNoHScRE3EO9pVMM=
github.com/nicksnyder/go-i18n/v2 v2.0.3/go.mod h1:oDab7q8XCYMRlcrBnaY/7B1eOectbvj6B1UPBT+p5jo=
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
@ -785,7 +785,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/poy/onpar v0.0.0-20200406201722-06f95a1c68e8/go.mod h1:nSbFQvMj97ZyhFRSJYtut+msi4sOY6zJDGCdSc+/rZU=
github.com/poy/onpar v1.0.0 h1:MfdQ9bnas+J1si8vUHAABXKxqOqDVaH4T3LRDYYv5Lo=
github.com/poy/onpar v1.0.0/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg=
github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
@ -1013,7 +1012,6 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE=
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
@ -1058,6 +1056,8 @@ go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
go.uber.org/zap v1.16.0 h1:uFRZXykJGK9lLY4HtgSw44DnIcAM+kRBP7x5m+NpAOM=
go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE=
golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw=
golang.org/x/build v0.0.0-20190314133821-5284462c4bec/go.mod h1:atTaCNAy0f16Ah5aV1gMSwgiKVHwu/JncqDpuRr7lS4=
@ -1081,8 +1081,9 @@ golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@ -1125,8 +1126,9 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180530234432-1e491301e022/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -1182,8 +1184,9 @@ golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4 h1:b0LrWgu8+q7z4J+0Y3Umo5q1dL7NXBkKBWkaVkAq17E=
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.0.0-20180227000427-d7d64896b5ff/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@ -1206,6 +1209,8 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180224232135-f6cff0780e54/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -1276,8 +1281,9 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4 h1:EZ2mChiOa8udjfp6rRmswTbtZN/QzUQp4ptM4rnjHvc=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -1286,8 +1292,9 @@ golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@ -1357,14 +1364,14 @@ golang.org/x/tools v0.0.0-20200818005847-188abfa75333/go.mod h1:njjCfa9FT2d7l9Bc
golang.org/x/tools v0.0.0-20200928182047-19e03678916f/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
@ -1521,8 +1528,9 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@ -1,7 +1,7 @@
{
"id": "ru.loop.plugin.achievements",
"name": "Badges for Mattermost",
"description": "This plugin add badges support to Mattermost.",
"name": "Achievements",
"description": "Плагин достижений и значков для Loop.",
"homepage_url": "https://github.com/larkox/mattermost-plugin-badges",
"support_url": "https://github.com/larkox/mattermost-plugin-badges/issues",
"release_notes_url": "https://github.com/larkox/mattermost-plugin-badges/releases/tag/v0.2.1",
@ -24,9 +24,8 @@
"settings": [
{
"key": "BadgesAdmin",
"display_name": "Badges admin:",
"type": "text",
"help_text": "This user will be considered as an admin for the badges plugin. They can create types, and modify and grant any badge."
"display_name": "Achievements Admin:",
"type": "custom"
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -19,8 +19,8 @@ func getHelp() string {
func (p *Plugin) getCommand() *model.Command {
return &model.Command{
Trigger: "badges",
DisplayName: "Badges Bot",
Description: "Badges",
DisplayName: "Achievements Bot",
Description: "Achievements",
AutoComplete: true,
AutoCompleteDesc: "Available commands:",
AutoCompleteHint: "[command]",
@ -77,7 +77,13 @@ func (p *Plugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*mo
p.postCommandResponse(args, fmt.Sprintf("__Error: %s__", err.Error()))
} else {
p.mm.Log.Error(err.Error())
p.postCommandResponse(args, "An unknown error occurred. Please talk to your system administrator for help.")
u, _ := p.mm.User.Get(args.UserId)
locale := "ru"
if u != nil {
locale = u.Locale
}
T := p.getT(locale)
p.postCommandResponse(args, T("badges.error.unknown", "Произошла неизвестная ошибка. Обратитесь к системному администратору."))
}
}
@ -93,11 +99,12 @@ func (p *Plugin) runClean(args []string, extra *model.CommandArgs) (bool, *model
if err != nil {
return false, &model.CommandResponse{Text: "Cannot get user."}, nil
}
T := p.getT(user.Locale)
if !user.IsSystemAdmin() {
return false, &model.CommandResponse{Text: "Only a system admin can clean the badges database."}, nil
return false, &model.CommandResponse{Text: T("badges.error.only_sysadmin_clean", "Только системный администратор может очистить базу значков.")}, nil
}
_ = p.mm.KV.DeleteAll()
return false, &model.CommandResponse{Text: "Clean"}, nil
return false, &model.CommandResponse{Text: T("badges.success.clean", "Очищено")}, nil
}
func (p *Plugin) runCreate(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
@ -105,7 +112,13 @@ func (p *Plugin) runCreate(args []string, extra *model.CommandArgs) (bool, *mode
restOfArgs := []string{}
var handler func([]string, *model.CommandArgs) (bool, *model.CommandResponse, error)
if lengthOfArgs == 0 {
return false, &model.CommandResponse{Text: "Specify what you want to create."}, nil
u, _ := p.mm.User.Get(extra.UserId)
locale := "ru"
if u != nil {
locale = u.Locale
}
T := p.getT(locale)
return false, &model.CommandResponse{Text: T("badges.error.specify_create", "Укажите, что вы хотите создать.")}, nil
}
command := args[0]
if lengthOfArgs > 1 {
@ -117,7 +130,13 @@ func (p *Plugin) runCreate(args []string, extra *model.CommandArgs) (bool, *mode
case "type":
handler = p.runCreateType
default:
return false, &model.CommandResponse{Text: "You can create either badge or type"}, nil
u, _ := p.mm.User.Get(extra.UserId)
locale := "ru"
if u != nil {
locale = u.Locale
}
T := p.getT(locale)
return false, &model.CommandResponse{Text: T("badges.error.create_badge_or_type", "Можно создать badge или type")}, nil
}
return handler(restOfArgs, extra)
@ -128,6 +147,7 @@ func (p *Plugin) runCreateBadge(args []string, extra *model.CommandArgs) (bool,
if err != nil {
return commandError(err.Error())
}
T := p.getT(u.Locale)
typeSuggestions, err := p.filterCreateBadgeTypes(u)
if err != nil {
@ -141,45 +161,45 @@ func (p *Plugin) runCreateBadge(args []string, extra *model.CommandArgs) (bool,
}
if len(typeOptions) == 0 {
return commandError("You cannot create badges from any type.")
return commandError(T("badges.error.no_types_available", "Вы не можете создать значки ни одного типа."))
}
err = p.mm.Frontend.OpenInteractiveDialog(model.OpenDialogRequest{
TriggerId: extra.TriggerId,
URL: p.getDialogURL() + DialogPathCreateBadge,
Dialog: model.Dialog{
Title: "Create badge",
SubmitLabel: "Create",
Title: T("badges.dialog.create_badge.title", "Создать значок"),
SubmitLabel: T("badges.dialog.create_badge.submit", "Создать"),
Elements: []model.DialogElement{
{
DisplayName: "Name",
DisplayName: T("badges.field.name", "Название"),
Type: "text",
Name: DialogFieldBadgeName,
MaxLength: badgesmodel.NameMaxLength,
},
{
DisplayName: "Description",
DisplayName: T("badges.field.description", "Описание"),
Type: "text",
Name: DialogFieldBadgeDescription,
MaxLength: badgesmodel.DescriptionMaxLength,
},
{
DisplayName: "Image",
DisplayName: T("badges.field.image", "Изображение"),
Type: "text",
Name: DialogFieldBadgeImage,
HelpText: "Insert a emoticon name",
HelpText: T("badges.field.image.help", "Введите название эмодзи"),
},
{
DisplayName: "Type",
DisplayName: T("badges.field.type", "Тип"),
Type: "select",
Name: DialogFieldBadgeType,
Options: typeOptions,
},
{
DisplayName: "Multiple",
DisplayName: T("badges.field.multiple", "Многократный"),
Type: "bool",
Name: DialogFieldBadgeMultiple,
HelpText: "Whether the badge can be granted multiple times",
HelpText: T("badges.field.multiple.help", "Можно ли выдавать этот значок несколько раз"),
Optional: true,
},
},
@ -198,7 +218,13 @@ func (p *Plugin) runEdit(args []string, extra *model.CommandArgs) (bool, *model.
restOfArgs := []string{}
var handler func([]string, *model.CommandArgs) (bool, *model.CommandResponse, error)
if lengthOfArgs == 0 {
return false, &model.CommandResponse{Text: "Specify what you want to create."}, nil
u, _ := p.mm.User.Get(extra.UserId)
locale := "ru"
if u != nil {
locale = u.Locale
}
T := p.getT(locale)
return false, &model.CommandResponse{Text: T("badges.error.specify_edit", "Укажите, что вы хотите отредактировать.")}, nil
}
command := args[0]
if lengthOfArgs > 1 {
@ -210,7 +236,13 @@ func (p *Plugin) runEdit(args []string, extra *model.CommandArgs) (bool, *model.
case "type":
handler = p.runEditType
default:
return false, &model.CommandResponse{Text: "You can create either badge or type"}, nil
u, _ := p.mm.User.Get(extra.UserId)
locale := "ru"
if u != nil {
locale = u.Locale
}
T := p.getT(locale)
return false, &model.CommandResponse{Text: T("badges.error.edit_badge_or_type", "Можно редактировать badge или type")}, nil
}
return handler(restOfArgs, extra)
@ -221,6 +253,7 @@ func (p *Plugin) runEditBadge(args []string, extra *model.CommandArgs) (bool, *m
if err != nil {
return commandError(err.Error())
}
T := p.getT(u.Locale)
var badgeIDStr string
fs := pflag.NewFlagSet("", pflag.ContinueOnError)
@ -230,7 +263,7 @@ func (p *Plugin) runEditBadge(args []string, extra *model.CommandArgs) (bool, *m
}
if badgeIDStr == "" {
return commandError("You must set the badge ID")
return commandError(T("badges.error.must_set_badge_id", "Необходимо указать ID значка"))
}
badge, err := p.store.GetBadge(badgesmodel.BadgeID(badgeIDStr))
@ -238,8 +271,13 @@ func (p *Plugin) runEditBadge(args []string, extra *model.CommandArgs) (bool, *m
return commandError(err.Error())
}
if !canEditBadge(u, p.badgeAdminUserID, badge) {
return commandError("you cannot edit this badge")
badgeType, err := p.store.GetType(badge.Type)
if err != nil {
return commandError(err.Error())
}
if !canEditBadge(u, p.badgeAdminUserIDs, badge, badgeType) {
return commandError(T("badges.error.cannot_edit_badge", "У вас нет прав на редактирование этого значка"))
}
typeSuggestions, err := p.filterCreateBadgeTypes(u)
@ -254,58 +292,58 @@ func (p *Plugin) runEditBadge(args []string, extra *model.CommandArgs) (bool, *m
}
if len(typeOptions) == 0 {
return commandError("You cannot create badges from any type.")
return commandError(T("badges.error.no_types_available", "Вы не можете создать значки ни одного типа."))
}
err = p.mm.Frontend.OpenInteractiveDialog(model.OpenDialogRequest{
TriggerId: extra.TriggerId,
URL: p.getDialogURL() + DialogPathEditBadge,
Dialog: model.Dialog{
Title: "Create badge",
SubmitLabel: "Edit",
Title: T("badges.dialog.edit_badge.title", "Редактировать значок"),
SubmitLabel: T("badges.dialog.edit_badge.submit", "Сохранить"),
State: string(badge.ID),
Elements: []model.DialogElement{
{
DisplayName: "Name",
DisplayName: T("badges.field.name", "Название"),
Type: "text",
Name: DialogFieldBadgeName,
MaxLength: badgesmodel.NameMaxLength,
Default: badge.Name,
},
{
DisplayName: "Description",
DisplayName: T("badges.field.description", "Описание"),
Type: "text",
Name: DialogFieldBadgeDescription,
MaxLength: badgesmodel.DescriptionMaxLength,
Default: badge.Description,
},
{
DisplayName: "Image",
DisplayName: T("badges.field.image", "Изображение"),
Type: "text",
Name: DialogFieldBadgeImage,
HelpText: "Insert a emoticon name",
HelpText: T("badges.field.image.help", "Введите название эмодзи"),
Default: badge.Image,
},
{
DisplayName: "Type",
DisplayName: T("badges.field.type", "Тип"),
Type: "select",
Name: DialogFieldBadgeType,
Options: typeOptions,
Default: string(badge.Type),
},
{
DisplayName: "Multiple",
DisplayName: T("badges.field.multiple", "Многократный"),
Type: "bool",
Name: DialogFieldBadgeMultiple,
HelpText: "Whether the badge can be granted multiple times",
HelpText: T("badges.field.multiple.help", "Можно ли выдавать этот значок несколько раз"),
Optional: true,
Default: getBooleanString(badge.Multiple),
},
{
DisplayName: "Delete badge",
DisplayName: T("badges.field.delete_badge", "Удалить значок"),
Type: "bool",
Name: DialogFieldBadgeDelete,
HelpText: "WARNING: Checking this will remove this badge permanently.",
HelpText: T("badges.field.delete_badge.help", "ВНИМАНИЕ: если отметить, значок будет удалён безвозвратно."),
Optional: true,
},
},
@ -324,9 +362,10 @@ func (p *Plugin) runEditType(args []string, extra *model.CommandArgs) (bool, *mo
if err != nil {
return commandError(err.Error())
}
T := p.getT(u.Locale)
if !canCreateType(u, p.badgeAdminUserID, false) {
return commandError("You have no permissions to edit a badge type.")
if !canCreateType(u, p.badgeAdminUserIDs, false) {
return commandError(T("badges.error.no_permissions_edit_type", "У вас нет прав на редактирование типа значков."))
}
var badgeTypeStr string
@ -337,7 +376,7 @@ func (p *Plugin) runEditType(args []string, extra *model.CommandArgs) (bool, *mo
}
if badgeTypeStr == "" {
return commandError("You must provide a type id")
return commandError(T("badges.error.must_provide_type_id", "Необходимо указать ID типа"))
}
typeDefinition, err := p.store.GetType(badgesmodel.BadgeType(badgeTypeStr))
@ -345,8 +384,8 @@ func (p *Plugin) runEditType(args []string, extra *model.CommandArgs) (bool, *mo
return commandError(err.Error())
}
if !canEditType(u, p.badgeAdminUserID, typeDefinition) {
return commandError("you cannot edit this type")
if !canEditType(u, p.badgeAdminUserIDs, typeDefinition) {
return commandError(T("badges.error.cannot_edit_type", "У вас нет прав на редактирование этого типа"))
}
canGrantAllowList := ""
@ -389,56 +428,56 @@ func (p *Plugin) runEditType(args []string, extra *model.CommandArgs) (bool, *mo
TriggerId: extra.TriggerId,
URL: p.getDialogURL() + DialogPathEditType,
Dialog: model.Dialog{
Title: "Edit type",
SubmitLabel: "Edit",
Title: T("badges.dialog.edit_type.title", "Редактировать тип"),
SubmitLabel: T("badges.dialog.edit_type.submit", "Сохранить"),
State: badgeTypeStr,
Elements: []model.DialogElement{
{
DisplayName: "Name",
DisplayName: T("badges.field.name", "Название"),
Type: "text",
Name: DialogFieldTypeName,
MaxLength: badgesmodel.NameMaxLength,
Default: typeDefinition.Name,
},
{
DisplayName: "Everyone can create badge",
DisplayName: T("badges.field.everyone_can_create", "Все могут создавать значки"),
Type: "bool",
Name: DialogFieldTypeEveryoneCanCreate,
HelpText: "Whether any user can create a badge of this type",
HelpText: T("badges.field.everyone_can_create.help", "Любой пользователь может создать значок этого типа"),
Optional: true,
Default: getBooleanString(typeDefinition.CanCreate.Everyone),
},
{
DisplayName: "Can create allowlist",
DisplayName: T("badges.field.allowlist_create", "Список допущенных к созданию"),
Type: "text",
Name: DialogFieldTypeAllowlistCanCreate,
HelpText: "Fill the usernames separated by comma (,) of the people that can create badges of this type.",
HelpText: T("badges.field.allowlist_create.help", "Укажите имена пользователей через запятую (,), которые могут создавать значки этого типа."),
Placeholder: "user-1, user-2, user-3",
Optional: true,
Default: canCreateAllowList,
},
{
DisplayName: "Everyone can grant badge",
DisplayName: T("badges.field.everyone_can_grant", "Все могут выдавать значки"),
Type: "bool",
Name: DialogFieldTypeEveryoneCanGrant,
HelpText: "Whether any user can grant a badge of this type",
HelpText: T("badges.field.everyone_can_grant.help", "Любой пользователь может выдать значок этого типа"),
Optional: true,
Default: getBooleanString(typeDefinition.CanGrant.Everyone),
},
{
DisplayName: "Can grant allowlist",
DisplayName: T("badges.field.allowlist_grant", "Список допущенных к выдаче"),
Type: "text",
Name: DialogFieldTypeAllowlistCanGrant,
HelpText: "Fill the usernames separated by comma (,) of the people that can grant badges of this type.",
HelpText: T("badges.field.allowlist_grant.help", "Укажите имена пользователей через запятую (,), которые могут выдавать значки этого типа."),
Placeholder: "user-1, user-2, user-3",
Optional: true,
Default: canGrantAllowList,
},
{
DisplayName: "Remove type",
DisplayName: T("badges.field.delete_type", "Удалить тип"),
Type: "bool",
Name: DialogFieldTypeDelete,
HelpText: "WARNING: checking this will remove this type and all associated badges permanently.",
HelpText: T("badges.field.delete_type.help", "ВНИМАНИЕ: если отметить, этот тип и все связанные значки будут удалены безвозвратно."),
Optional: true,
},
},
@ -457,51 +496,52 @@ func (p *Plugin) runCreateType(args []string, extra *model.CommandArgs) (bool, *
if err != nil {
return commandError(err.Error())
}
T := p.getT(u.Locale)
if !canCreateType(u, p.badgeAdminUserID, false) {
return commandError("You have no permissions to create a badge type.")
if !canCreateType(u, p.badgeAdminUserIDs, false) {
return commandError(T("badges.error.no_permissions_create_type", "У вас нет прав на создание типа значков."))
}
err = p.mm.Frontend.OpenInteractiveDialog(model.OpenDialogRequest{
TriggerId: extra.TriggerId,
URL: p.getDialogURL() + DialogPathCreateType,
Dialog: model.Dialog{
Title: "Create type",
SubmitLabel: "Create",
Title: T("badges.dialog.create_type.title", "Создать тип"),
SubmitLabel: T("badges.dialog.create_type.submit", "Создать"),
Elements: []model.DialogElement{
{
DisplayName: "Name",
DisplayName: T("badges.field.name", "Название"),
Type: "text",
Name: DialogFieldTypeName,
MaxLength: badgesmodel.NameMaxLength,
},
{
DisplayName: "Everyone can create badge",
DisplayName: T("badges.field.everyone_can_create", "Все могут создавать значки"),
Type: "bool",
Name: DialogFieldTypeEveryoneCanCreate,
HelpText: "Whether any user can create a badge of this type",
HelpText: T("badges.field.everyone_can_create.help", "Любой пользователь может создать значок этого типа"),
Optional: true,
},
{
DisplayName: "Can create allowlist",
DisplayName: T("badges.field.allowlist_create", "Список допущенных к созданию"),
Type: "text",
Name: DialogFieldTypeAllowlistCanCreate,
HelpText: "Fill the usernames separated by comma (,) of the people that can create badges of this type.",
HelpText: T("badges.field.allowlist_create.help", "Укажите имена пользователей через запятую (,), которые могут создавать значки этого типа."),
Placeholder: "user-1, user-2, user-3",
Optional: true,
},
{
DisplayName: "Everyone can grant badge",
DisplayName: T("badges.field.everyone_can_grant", "Все могут выдавать значки"),
Type: "bool",
Name: DialogFieldTypeEveryoneCanGrant,
HelpText: "Whether any user can grant a badge of this type",
HelpText: T("badges.field.everyone_can_grant.help", "Любой пользователь может выдать значок этого типа"),
Optional: true,
},
{
DisplayName: "Can grant allowlist",
DisplayName: T("badges.field.allowlist_grant", "Список допущенных к выдаче"),
Type: "text",
Name: DialogFieldTypeAllowlistCanGrant,
HelpText: "Fill the usernames separated by comma (,) of the people that can grant badges of this type.",
HelpText: T("badges.field.allowlist_grant.help", "Укажите имена пользователей через запятую (,), которые могут выдавать значки этого типа."),
Placeholder: "user-1, user-2, user-3",
Optional: true,
},
@ -535,6 +575,7 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
if err != nil {
return commandError(err.Error())
}
T := p.getT(granter.Locale)
badge, err := p.store.GetBadge(badgesmodel.BadgeID(badgeStr))
if err != nil {
@ -546,8 +587,8 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
return commandError(err.Error())
}
if !canGrantBadge(granter, p.badgeAdminUserID, badge, badgeType) {
return commandError("you have no permissions to grant this badge")
if !canGrantBadge(granter, p.badgeAdminUserIDs, badge, badgeType) {
return commandError(T("badges.error.no_permissions_grant", "У вас нет прав на выдачу этого значка"))
}
user, err := p.mm.User.GetByUsername(username)
@ -556,6 +597,9 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
}
shouldNotify, err := p.store.GrantBadge(badgesmodel.BadgeID(badgeStr), user.Id, extra.UserId, "")
if err == errAlreadyOwned {
return commandError(T("badges.error.already_owned", "Это достижение уже выдано этому пользователю"))
}
if err != nil {
return commandError(err.Error())
}
@ -564,10 +608,16 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
p.notifyGrant(badgesmodel.BadgeID(badgeStr), extra.UserId, user, false, "", "")
}
p.postCommandResponse(extra, "Granted")
p.postCommandResponse(extra, T("badges.success.granted", "Выдано"))
return false, &model.CommandResponse{}, nil
}
actingUser, err := p.mm.User.Get(extra.UserId)
if err != nil {
return commandError(err.Error())
}
T := p.getT(actingUser.Locale)
elements := []model.DialogElement{}
stateText := ""
@ -582,24 +632,19 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
return commandError(err.Error())
}
introductionText = "Grant badge to @" + username
introductionText = T("badges.dialog.grant.intro", "Выдать значок пользователю @%s", username)
stateText = user.Id
}
if stateText == "" {
elements = append(elements, model.DialogElement{
DisplayName: "User",
DisplayName: T("badges.field.user", "Пользователь"),
Type: "select",
Name: DialogFieldUser,
DataSource: "users",
})
}
actingUser, err := p.mm.User.Get(extra.UserId)
if err != nil {
return commandError(err.Error())
}
options := []*model.PostActionOptions{}
grantableBadges, err := p.filterGrantBadges(actingUser)
if err != nil {
@ -610,7 +655,7 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
}
badgeElement := model.DialogElement{
DisplayName: "Badge",
DisplayName: T("badges.field.badge", "Значок"),
Type: "select",
Name: DialogFieldBadge,
Options: options,
@ -626,7 +671,7 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
}
if !found {
return commandError("You cannot grant that badge")
return commandError(T("badges.error.cannot_grant_badge", "Вы не можете выдать этот значок"))
}
badgeElement.Default = badgeStr
@ -635,18 +680,18 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
elements = append(elements, badgeElement)
elements = append(elements, model.DialogElement{
DisplayName: "Reason",
DisplayName: T("badges.field.reason", "Причина"),
Name: DialogFieldGrantReason,
Optional: true,
HelpText: "Reason why you are granting this badge. This will be seen by the user, and wherever this grant notification is shown (e.g. subscriptions).",
HelpText: T("badges.field.reason.help", "Причина выдачи значка. Будет видна пользователю и в уведомлениях о выдаче (например, в подписках)."),
Type: "text",
})
elements = append(elements, model.DialogElement{
DisplayName: "Notify on this channel",
DisplayName: T("badges.field.notify_here", "Уведомить в этом канале"),
Name: DialogFieldNotifyHere,
Type: "bool",
HelpText: "If you mark this, the bot will send a message to this channel notifying that you granted this badge to this person.",
HelpText: T("badges.field.notify_here.help", "Если отметить, бот отправит сообщение в этот канал о том, что вы выдали значок этому пользователю."),
Optional: true,
})
@ -654,9 +699,9 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
TriggerId: extra.TriggerId,
URL: p.getDialogURL() + DialogPathGrant,
Dialog: model.Dialog{
Title: "Grant badge",
Title: T("badges.dialog.grant.title", "Выдать значок"),
IntroductionText: introductionText,
SubmitLabel: "Grant",
SubmitLabel: T("badges.dialog.grant.submit", "Выдать"),
Elements: elements,
State: stateText,
},
@ -674,7 +719,13 @@ func (p *Plugin) runSubscription(args []string, extra *model.CommandArgs) (bool,
restOfArgs := []string{}
var handler func([]string, *model.CommandArgs) (bool, *model.CommandResponse, error)
if lengthOfArgs == 0 {
return false, &model.CommandResponse{Text: "Specify what you want to do."}, nil
u, _ := p.mm.User.Get(extra.UserId)
locale := "ru"
if u != nil {
locale = u.Locale
}
T := p.getT(locale)
return false, &model.CommandResponse{Text: T("badges.error.specify_subscription", "Укажите, что вы хотите сделать.")}, nil
}
command := args[0]
if lengthOfArgs > 1 {
@ -686,7 +737,13 @@ func (p *Plugin) runSubscription(args []string, extra *model.CommandArgs) (bool,
case "remove":
handler = p.runDeleteSubscription
default:
return false, &model.CommandResponse{Text: "You can either create or delete subscriptions"}, nil
u, _ := p.mm.User.Get(extra.UserId)
locale := "ru"
if u != nil {
locale = u.Locale
}
T := p.getT(locale)
return false, &model.CommandResponse{Text: T("badges.error.create_or_delete_subscription", "Можно создать или удалить подписку")}, nil
}
return handler(restOfArgs, extra)
@ -704,9 +761,10 @@ func (p *Plugin) runCreateSubscription(args []string, extra *model.CommandArgs)
if err != nil {
return commandError(err.Error())
}
T := p.getT(actingUser.Locale)
if !canCreateSubscription(actingUser, p.badgeAdminUserID, extra.ChannelId) {
return commandError("You cannot create subscriptions")
if !canCreateSubscription(actingUser, p.badgeAdminUserIDs, extra.ChannelId) {
return commandError(T("badges.error.cannot_create_subscription", "Вы не можете создавать подписки"))
}
if typeStr != "" {
@ -716,7 +774,7 @@ func (p *Plugin) runCreateSubscription(args []string, extra *model.CommandArgs)
return commandError(err.Error())
}
p.postCommandResponse(extra, "Granted")
p.postCommandResponse(extra, T("badges.success.granted", "Выдано"))
return false, &model.CommandResponse{}, nil
}
@ -733,12 +791,12 @@ func (p *Plugin) runCreateSubscription(args []string, extra *model.CommandArgs)
TriggerId: extra.TriggerId,
URL: p.getDialogURL() + DialogPathCreateSubscription,
Dialog: model.Dialog{
Title: "Create subscription",
IntroductionText: "Introduce the badge type you want to subscribe to this channel.",
SubmitLabel: "Add",
Title: T("badges.dialog.create_subscription.title", "Создать подписку"),
IntroductionText: T("badges.dialog.create_subscription.intro", "Выберите тип значка, на который хотите подписать этот канал."),
SubmitLabel: T("badges.dialog.create_subscription.submit", "Добавить"),
Elements: []model.DialogElement{
{
DisplayName: "Type",
DisplayName: T("badges.field.type", "Тип"),
Type: "select",
Name: DialogFieldBadgeType,
Options: options,
@ -766,9 +824,10 @@ func (p *Plugin) runDeleteSubscription(args []string, extra *model.CommandArgs)
if err != nil {
return commandError(err.Error())
}
T := p.getT(actingUser.Locale)
if !canCreateSubscription(actingUser, p.badgeAdminUserID, extra.ChannelId) {
return commandError("You cannot create subscriptions")
if !canCreateSubscription(actingUser, p.badgeAdminUserIDs, extra.ChannelId) {
return commandError(T("badges.error.cannot_create_subscription", "Вы не можете создавать подписки"))
}
if typeStr != "" {
@ -777,7 +836,7 @@ func (p *Plugin) runDeleteSubscription(args []string, extra *model.CommandArgs)
return commandError(err.Error())
}
p.postCommandResponse(extra, "Removed")
p.postCommandResponse(extra, T("badges.success.removed", "Удалено"))
return false, &model.CommandResponse{}, nil
}
@ -794,12 +853,12 @@ func (p *Plugin) runDeleteSubscription(args []string, extra *model.CommandArgs)
TriggerId: extra.TriggerId,
URL: p.getDialogURL() + DialogPathDeleteSubscription,
Dialog: model.Dialog{
Title: "Delete subscription",
IntroductionText: "Introduce the badge type you want to remove from this channel.",
SubmitLabel: "Remove",
Title: T("badges.dialog.delete_subscription.title", "Удалить подписку"),
IntroductionText: T("badges.dialog.delete_subscription.intro", "Выберите тип значка, подписку на который хотите удалить из этого канала."),
SubmitLabel: T("badges.dialog.delete_subscription.submit", "Удалить"),
Elements: []model.DialogElement{
{
DisplayName: "Type",
DisplayName: T("badges.field.type", "Тип"),
Type: "select",
Name: DialogFieldBadgeType,
Options: options,

View File

@ -2,6 +2,7 @@ package main
import (
"reflect"
"strings"
"github.com/pkg/errors"
)
@ -78,13 +79,20 @@ func (p *Plugin) OnConfigurationChange() error {
return errors.Wrap(err, "failed to load plugin configuration")
}
p.badgeAdminUserID = ""
p.badgeAdminUserIDs = make(map[string]bool)
if configuration.BadgesAdmin != "" {
u, err := p.API.GetUserByUsername(configuration.BadgesAdmin)
if err != nil {
return errors.Wrap(err, "cannot get badge admin user")
for username := range strings.SplitSeq(configuration.BadgesAdmin, ",") {
username = strings.TrimSpace(username)
if username == "" {
continue
}
u, err := p.API.GetUserByUsername(username)
if err != nil {
p.API.LogWarn("Cannot find badge admin user", "username", username, "error", err.Error())
continue
}
p.badgeAdminUserIDs[u.Id] = true
}
p.badgeAdminUserID = u.Id
}
p.setConfiguration(configuration)

108
server/i18n/en.json Normal file
View File

@ -0,0 +1,108 @@
[
{"id": "badges.dialog.create_badge.title", "translation": "Create achievement"},
{"id": "badges.dialog.create_badge.submit", "translation": "Create"},
{"id": "badges.dialog.edit_badge.title", "translation": "Edit achievement"},
{"id": "badges.dialog.edit_badge.submit", "translation": "Save"},
{"id": "badges.dialog.create_type.title", "translation": "Create type"},
{"id": "badges.dialog.create_type.submit", "translation": "Create"},
{"id": "badges.dialog.edit_type.title", "translation": "Edit type"},
{"id": "badges.dialog.edit_type.submit", "translation": "Save"},
{"id": "badges.dialog.grant.title", "translation": "Grant achievement"},
{"id": "badges.dialog.grant.submit", "translation": "Grant"},
{"id": "badges.dialog.grant.intro", "translation": "Grant achievement to @%s"},
{"id": "badges.dialog.create_subscription.title", "translation": "Create subscription"},
{"id": "badges.dialog.create_subscription.submit", "translation": "Add"},
{"id": "badges.dialog.create_subscription.intro", "translation": "Select the achievement type you want to subscribe to this channel."},
{"id": "badges.dialog.delete_subscription.title", "translation": "Delete subscription"},
{"id": "badges.dialog.delete_subscription.submit", "translation": "Remove"},
{"id": "badges.dialog.delete_subscription.intro", "translation": "Select the achievement type you want to unsubscribe from this channel."},
{"id": "badges.field.name", "translation": "Name"},
{"id": "badges.field.description", "translation": "Description"},
{"id": "badges.field.image", "translation": "Image"},
{"id": "badges.field.image.help", "translation": "Enter an emoticon name"},
{"id": "badges.field.type", "translation": "Type"},
{"id": "badges.field.multiple", "translation": "Multiple"},
{"id": "badges.field.multiple.help", "translation": "Whether the achievement can be granted multiple times"},
{"id": "badges.field.delete_badge", "translation": "Delete achievement"},
{"id": "badges.field.delete_badge.help", "translation": "WARNING: checking this will remove this achievement permanently."},
{"id": "badges.field.everyone_can_create", "translation": "Everyone can create achievement"},
{"id": "badges.field.everyone_can_create.help", "translation": "Whether any user can create an achievement of this type"},
{"id": "badges.field.allowlist_create", "translation": "Can create allowlist"},
{"id": "badges.field.allowlist_create.help", "translation": "Fill the usernames separated by comma (,) of the people that can create achievements of this type."},
{"id": "badges.field.everyone_can_grant", "translation": "Everyone can grant achievement"},
{"id": "badges.field.everyone_can_grant.help", "translation": "Whether any user can grant an achievement of this type"},
{"id": "badges.field.allowlist_grant", "translation": "Can grant allowlist"},
{"id": "badges.field.allowlist_grant.help", "translation": "Fill the usernames separated by comma (,) of the people that can grant achievements of this type."},
{"id": "badges.field.delete_type", "translation": "Remove type"},
{"id": "badges.field.delete_type.help", "translation": "WARNING: checking this will remove this type and all associated achievements permanently."},
{"id": "badges.field.user", "translation": "User"},
{"id": "badges.field.badge", "translation": "Achievement"},
{"id": "badges.field.reason", "translation": "Reason"},
{"id": "badges.field.reason.help", "translation": "Reason why you are granting this achievement. This will be seen by the user, and wherever this grant notification is shown (e.g. subscriptions)."},
{"id": "badges.field.notify_here", "translation": "Notify on this channel"},
{"id": "badges.field.notify_here.help", "translation": "If you mark this, the bot will send a message to this channel notifying that you granted this achievement to this person."},
{"id": "badges.error.unknown", "translation": "An unknown error occurred. Please talk to your system administrator for help."},
{"id": "badges.error.cannot_get_user", "translation": "Cannot get user."},
{"id": "badges.error.only_sysadmin_clean", "translation": "Only a system admin can clean the achievements database."},
{"id": "badges.error.specify_create", "translation": "Specify what you want to create."},
{"id": "badges.error.create_badge_or_type", "translation": "You can create either achievement or type"},
{"id": "badges.error.no_types_available", "translation": "You cannot create achievements from any type."},
{"id": "badges.error.must_set_badge_id", "translation": "You must set the achievement ID"},
{"id": "badges.error.cannot_edit_badge", "translation": "You cannot edit this achievement"},
{"id": "badges.error.specify_edit", "translation": "Specify what you want to edit."},
{"id": "badges.error.edit_badge_or_type", "translation": "You can edit either achievement or type"},
{"id": "badges.error.no_permissions_edit_type", "translation": "You have no permissions to edit an achievement type."},
{"id": "badges.error.must_provide_type_id", "translation": "You must provide a type id"},
{"id": "badges.error.cannot_edit_type", "translation": "You cannot edit this type"},
{"id": "badges.error.no_permissions_grant", "translation": "You have no permissions to grant this achievement"},
{"id": "badges.error.cannot_grant_badge", "translation": "You cannot grant that achievement"},
{"id": "badges.error.specify_subscription", "translation": "Specify what you want to do."},
{"id": "badges.error.create_or_delete_subscription", "translation": "You can either create or delete subscriptions"},
{"id": "badges.error.cannot_create_subscription", "translation": "You cannot create subscriptions"},
{"id": "badges.error.no_permissions_create_type", "translation": "You have no permissions to create an achievement type."},
{"id": "badges.error.already_owned", "translation": "This achievement is already owned by this user"},
{"id": "badges.success.clean", "translation": "Clean"},
{"id": "badges.success.granted", "translation": "Granted"},
{"id": "badges.success.removed", "translation": "Removed"},
{"id": "badges.api.dialog_parse_error", "translation": "Could not get the dialog request"},
{"id": "badges.api.cannot_get_user", "translation": "Cannot get user"},
{"id": "badges.api.empty_emoji", "translation": "Empty emoji"},
{"id": "badges.api.invalid_field", "translation": "Invalid field"},
{"id": "badges.api.type_not_exist", "translation": "This type does not exist"},
{"id": "badges.api.no_permissions_create_badge", "translation": "You have no permissions to create this achievement"},
{"id": "badges.api.badge_created", "translation": "Achievement `%s` created."},
{"id": "badges.api.no_permissions_create_type", "translation": "You have no permissions to create a type"},
{"id": "badges.api.cannot_find_user", "translation": "Cannot find user"},
{"id": "badges.api.error_getting_user", "translation": "Error getting user %s: %v"},
{"id": "badges.api.type_created", "translation": "Type `%s` created."},
{"id": "badges.api.cannot_get_type", "translation": "Cannot get type"},
{"id": "badges.api.cannot_edit_type", "translation": "You cannot edit this type"},
{"id": "badges.api.could_not_get_type", "translation": "Could not get the type"},
{"id": "badges.api.no_permissions_edit_type", "translation": "You have no permissions to edit this type"},
{"id": "badges.api.type_updated", "translation": "Type `%s` updated."},
{"id": "badges.api.cannot_get_badge", "translation": "Cannot get achievement"},
{"id": "badges.api.cannot_edit_badge", "translation": "You cannot edit this achievement"},
{"id": "badges.api.could_not_get_badge", "translation": "Could not get the achievement"},
{"id": "badges.api.no_permissions_edit_badge", "translation": "You have no permissions to edit this achievement"},
{"id": "badges.api.badge_updated", "translation": "Achievement `%s` updated."},
{"id": "badges.api.badge_not_found", "translation": "Achievement not found"},
{"id": "badges.api.no_permissions_grant", "translation": "You have no permissions to grant this achievement"},
{"id": "badges.api.user_not_found", "translation": "User not found"},
{"id": "badges.api.badge_granted", "translation": "Achievement `%s` granted to @%s."},
{"id": "badges.api.cannot_create_subscription", "translation": "You cannot create a subscription"},
{"id": "badges.api.subscription_added", "translation": "Subscription added"},
{"id": "badges.api.cannot_delete_subscription", "translation": "You cannot delete a subscription"},
{"id": "badges.api.subscription_removed", "translation": "Subscription removed"},
{"id": "badges.api.cannot_delete_default_type", "translation": "Cannot delete the default type"},
{"id": "badges.api.not_authorized", "translation": "Not authorized"},
{"id": "badges.notify.dm_text", "translation": "@%s granted you the %s`%s` achievement."},
{"id": "badges.notify.dm_reason", "translation": "\nWhy? "},
{"id": "badges.notify.title", "translation": "%sachievement granted!"},
{"id": "badges.notify.channel_text", "translation": "@%s granted @%s the %s`%s` achievement."},
{"id": "badges.notify.no_permission_channel", "translation": "You don't have permissions to notify the grant on this channel."}
]

45
server/i18n/i18n.go Normal file
View File

@ -0,0 +1,45 @@
package i18n
import (
"embed"
"fmt"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
)
//go:embed *.json
var i18nFiles embed.FS
type TranslationFunc func(translationId string, defaultMessage string, params ...any) string
type Bundle i18n.Bundle
func Init() *Bundle {
bundle := i18n.NewBundle(language.Russian)
_, _ = bundle.LoadMessageFileFS(i18nFiles, "en.json")
_, _ = bundle.LoadMessageFileFS(i18nFiles, "ru.json")
return (*Bundle)(bundle)
}
func LocalizerFunc(bundle *Bundle, lang string) TranslationFunc {
localizer := i18n.NewLocalizer((*i18n.Bundle)(bundle), lang)
return func(translationId string, defaultMessage string, params ...any) string {
if len(params) > 0 {
return fmt.Sprintf(localizer.MustLocalize(&i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: translationId,
Other: defaultMessage,
},
}), params...)
}
return localizer.MustLocalize(&i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: translationId,
Other: defaultMessage,
},
})
}
}

108
server/i18n/ru.json Normal file
View File

@ -0,0 +1,108 @@
[
{"id": "badges.dialog.create_badge.title", "translation": "Создать достижение"},
{"id": "badges.dialog.create_badge.submit", "translation": "Создать"},
{"id": "badges.dialog.edit_badge.title", "translation": "Редактировать достижение"},
{"id": "badges.dialog.edit_badge.submit", "translation": "Сохранить"},
{"id": "badges.dialog.create_type.title", "translation": "Создать тип"},
{"id": "badges.dialog.create_type.submit", "translation": "Создать"},
{"id": "badges.dialog.edit_type.title", "translation": "Редактировать тип"},
{"id": "badges.dialog.edit_type.submit", "translation": "Сохранить"},
{"id": "badges.dialog.grant.title", "translation": "Выдать достижение"},
{"id": "badges.dialog.grant.submit", "translation": "Выдать"},
{"id": "badges.dialog.grant.intro", "translation": "Выдать достижение пользователю @%s"},
{"id": "badges.dialog.create_subscription.title", "translation": "Создать подписку"},
{"id": "badges.dialog.create_subscription.submit", "translation": "Добавить"},
{"id": "badges.dialog.create_subscription.intro", "translation": "Выберите тип достижения, на который хотите подписать этот канал."},
{"id": "badges.dialog.delete_subscription.title", "translation": "Удалить подписку"},
{"id": "badges.dialog.delete_subscription.submit", "translation": "Удалить"},
{"id": "badges.dialog.delete_subscription.intro", "translation": "Выберите тип достижения, подписку на который хотите удалить из этого канала."},
{"id": "badges.field.name", "translation": "Название"},
{"id": "badges.field.description", "translation": "Описание"},
{"id": "badges.field.image", "translation": "Изображение"},
{"id": "badges.field.image.help", "translation": "Введите название эмодзи"},
{"id": "badges.field.type", "translation": "Тип"},
{"id": "badges.field.multiple", "translation": "Многократный"},
{"id": "badges.field.multiple.help", "translation": "Можно ли выдавать это достижение несколько раз"},
{"id": "badges.field.delete_badge", "translation": "Удалить достижение"},
{"id": "badges.field.delete_badge.help", "translation": "ВНИМАНИЕ: если отметить, достижение будет удалён безвозвратно."},
{"id": "badges.field.everyone_can_create", "translation": "Все могут создавать достижения"},
{"id": "badges.field.everyone_can_create.help", "translation": "Любой пользователь может создать достижение этого типа"},
{"id": "badges.field.allowlist_create", "translation": "Список допущенных к созданию"},
{"id": "badges.field.allowlist_create.help", "translation": "Укажите имена пользователей через запятую (,), которые могут создавать достижения этого типа."},
{"id": "badges.field.everyone_can_grant", "translation": "Все могут выдавать достижения"},
{"id": "badges.field.everyone_can_grant.help", "translation": "Любой пользователь может выдать достижение этого типа"},
{"id": "badges.field.allowlist_grant", "translation": "Список допущенных к выдаче"},
{"id": "badges.field.allowlist_grant.help", "translation": "Укажите имена пользователей через запятую (,), которые могут выдавать достижения этого типа."},
{"id": "badges.field.delete_type", "translation": "Удалить тип"},
{"id": "badges.field.delete_type.help", "translation": "ВНИМАНИЕ: если отметить, этот тип и все связанные достижения будут удалены безвозвратно."},
{"id": "badges.field.user", "translation": "Пользователь"},
{"id": "badges.field.badge", "translation": "Достижение"},
{"id": "badges.field.reason", "translation": "Причина"},
{"id": "badges.field.reason.help", "translation": "Причина выдачи достижения. Будет видна пользователю и в уведомлениях о выдаче (например, в подписках)."},
{"id": "badges.field.notify_here", "translation": "Уведомить в этом канале"},
{"id": "badges.field.notify_here.help", "translation": "Если отметить, бот отправит сообщение в этот канал о том, что вы выдали достижение этому пользователю."},
{"id": "badges.error.unknown", "translation": "Произошла неизвестная ошибка. Обратитесь к системному администратору."},
{"id": "badges.error.cannot_get_user", "translation": "Не удалось получить пользователя."},
{"id": "badges.error.only_sysadmin_clean", "translation": "Только системный администратор может очистить базу достижений."},
{"id": "badges.error.specify_create", "translation": "Укажите, что вы хотите создать."},
{"id": "badges.error.create_badge_or_type", "translation": "Можно создать badge или type"},
{"id": "badges.error.no_types_available", "translation": "Вы не можете создать достижения ни одного типа."},
{"id": "badges.error.must_set_badge_id", "translation": "Необходимо указать ID достижения"},
{"id": "badges.error.cannot_edit_badge", "translation": "У вас нет прав на редактирование этого достижения"},
{"id": "badges.error.specify_edit", "translation": "Укажите, что вы хотите отредактировать."},
{"id": "badges.error.edit_badge_or_type", "translation": "Можно редактировать badge или type"},
{"id": "badges.error.no_permissions_edit_type", "translation": "У вас нет прав на редактирование типа достижений."},
{"id": "badges.error.must_provide_type_id", "translation": "Необходимо указать ID типа"},
{"id": "badges.error.cannot_edit_type", "translation": "У вас нет прав на редактирование этого типа"},
{"id": "badges.error.no_permissions_grant", "translation": "У вас нет прав на выдачу этого достижения"},
{"id": "badges.error.cannot_grant_badge", "translation": "Вы не можете выдать это достижение"},
{"id": "badges.error.specify_subscription", "translation": "Укажите, что вы хотите сделать."},
{"id": "badges.error.create_or_delete_subscription", "translation": "Можно создать или удалить подписку"},
{"id": "badges.error.cannot_create_subscription", "translation": "Вы не можете создавать подписки"},
{"id": "badges.error.no_permissions_create_type", "translation": "У вас нет прав на создание типа достижений."},
{"id": "badges.error.already_owned", "translation": "Это достижение уже выдано этому пользователю"},
{"id": "badges.success.clean", "translation": "Очищено"},
{"id": "badges.success.granted", "translation": "Выдано"},
{"id": "badges.success.removed", "translation": "Удалено"},
{"id": "badges.api.dialog_parse_error", "translation": "Не удалось получить данные диалога"},
{"id": "badges.api.cannot_get_user", "translation": "Не удалось найти пользователя"},
{"id": "badges.api.empty_emoji", "translation": "Пустой эмодзи"},
{"id": "badges.api.invalid_field", "translation": "Некорректное поле"},
{"id": "badges.api.type_not_exist", "translation": "Этот тип не существует"},
{"id": "badges.api.no_permissions_create_badge", "translation": "У вас нет прав на создание этого достижения"},
{"id": "badges.api.badge_created", "translation": "Достижение `%s` создано."},
{"id": "badges.api.no_permissions_create_type", "translation": "У вас нет прав на создание типа"},
{"id": "badges.api.cannot_find_user", "translation": "Не удалось найти пользователя"},
{"id": "badges.api.error_getting_user", "translation": "Ошибка получения пользователя %s: %v"},
{"id": "badges.api.type_created", "translation": "Тип `%s` создан."},
{"id": "badges.api.cannot_get_type", "translation": "Не удалось получить тип"},
{"id": "badges.api.cannot_edit_type", "translation": "Вы не можете редактировать этот тип"},
{"id": "badges.api.could_not_get_type", "translation": "Не удалось получить тип"},
{"id": "badges.api.no_permissions_edit_type", "translation": "У вас нет прав на редактирование этого типа"},
{"id": "badges.api.type_updated", "translation": "Тип `%s` обновлён."},
{"id": "badges.api.cannot_get_badge", "translation": "Не удалось получить достижение"},
{"id": "badges.api.cannot_edit_badge", "translation": "Вы не можете редактировать это достижение"},
{"id": "badges.api.could_not_get_badge", "translation": "Не удалось получить достижение"},
{"id": "badges.api.no_permissions_edit_badge", "translation": "У вас нет прав на редактирование этого достижения"},
{"id": "badges.api.badge_updated", "translation": "Достижение `%s` обновлёно."},
{"id": "badges.api.badge_not_found", "translation": "Достижение не найдено"},
{"id": "badges.api.no_permissions_grant", "translation": "У вас нет прав на выдачу этого достижения"},
{"id": "badges.api.user_not_found", "translation": "Пользователь не найден"},
{"id": "badges.api.badge_granted", "translation": "Достижение `%s` выдан @%s."},
{"id": "badges.api.cannot_create_subscription", "translation": "Вы не можете создать подписку"},
{"id": "badges.api.subscription_added", "translation": "Подписка добавлена"},
{"id": "badges.api.cannot_delete_subscription", "translation": "Вы не можете удалить подписку"},
{"id": "badges.api.subscription_removed", "translation": "Подписка удалена"},
{"id": "badges.api.cannot_delete_default_type", "translation": "Нельзя удалить тип по умолчанию"},
{"id": "badges.api.not_authorized", "translation": "Не авторизован"},
{"id": "badges.notify.dm_text", "translation": "@%s выдал вам достижение %s`%s`."},
{"id": "badges.notify.dm_reason", "translation": "\nПочему? "},
{"id": "badges.notify.title", "translation": "%sдостижение выдано!"},
{"id": "badges.notify.channel_text", "translation": "@%s выдал @%s достижение %s`%s`."},
{"id": "badges.notify.no_permission_channel", "translation": "У вас нет прав на отправку уведомления о выдаче в этот канал."}
]

8
server/manifest.go generated
View File

@ -13,8 +13,8 @@ var manifest *model.Manifest
const manifestStr = `
{
"id": "ru.loop.plugin.achievements",
"name": "Badges for Mattermost",
"description": "This plugin add badges support to Mattermost.",
"name": "Achievements",
"description": "Плагин достижений и значков для Loop.",
"homepage_url": "https://github.com/larkox/mattermost-plugin-badges",
"support_url": "https://github.com/larkox/mattermost-plugin-badges/issues",
"release_notes_url": "https://github.com/larkox/mattermost-plugin-badges/releases/tag/v0.2.1",
@ -38,9 +38,9 @@ const manifestStr = `
"settings": [
{
"key": "BadgesAdmin",
"display_name": "Badges admin:",
"display_name": "Администратор достижений:",
"type": "text",
"help_text": "This user will be considered as an admin for the badges plugin. They can create types, and modify and grant any badge.",
"help_text": "Этот пользователь будет считаться администратором плагина достижений. Он может создавать типы, а также изменять и выдавать любые значки.",
"placeholder": "",
"default": null
}

View File

@ -9,6 +9,8 @@ import (
"github.com/mattermost/mattermost-server/v5/model"
"github.com/mattermost/mattermost-server/v5/plugin"
"github.com/pkg/errors"
i18n "github.com/larkox/mattermost-plugin-badges/server/i18n"
)
// Plugin implements the interface expected by the Mattermost server to communicate between the server and plugin processes.
@ -26,7 +28,12 @@ type Plugin struct {
BotUserID string
store Store
router *mux.Router
badgeAdminUserID string
badgeAdminUserIDs map[string]bool
i18nBundle *i18n.Bundle
}
func (p *Plugin) getT(locale string) i18n.TranslationFunc {
return i18n.LocalizerFunc(p.i18nBundle, locale)
}
// ServeHTTP demonstrates a plugin that handles HTTP requests by greeting the world.
@ -41,15 +48,19 @@ func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Req
func (p *Plugin) OnActivate() error {
p.mm = pluginapi.NewClient(p.API)
botID, err := p.Helpers.EnsureBot(&model.Bot{
Username: "badges",
DisplayName: "Badges Bot",
Description: "Created by the Badges plugin.",
Username: "achievements",
DisplayName: "Achievements Bot",
Description: "Created by the Achievements plugin.",
})
if err != nil {
return errors.Wrap(err, "failed to ensure badges bot")
}
p.BotUserID = botID
p.store = NewStore(p.API)
if err := p.store.EnsureDefaultType(p.BotUserID); err != nil {
p.mm.Log.Warn("Failed to ensure default type", "error", err.Error())
}
p.i18nBundle = i18n.Init()
p.initializeAPI()
return p.mm.SlashCommand.Register(p.getCommand())

View File

@ -12,6 +12,7 @@ import (
var errInvalidBadge = errors.New("invalid badge")
var errBadgeNotFound = errors.New("badge not found")
var errAlreadyOwned = errors.New("already owned")
type Store interface {
// Interface
@ -33,12 +34,17 @@ type Store interface {
UpdateBadge(b *badgesmodel.Badge) error
DeleteType(tID badgesmodel.BadgeType) error
DeleteBadge(bID badgesmodel.BadgeID) error
RevokeOwnership(badgeID badgesmodel.BadgeID, userID string, grantTime string) error
FindOwnership(badgeID badgesmodel.BadgeID, userID string, grantTime string) (*badgesmodel.Ownership, error)
AddSubscription(tID badgesmodel.BadgeType, cID string) error
RemoveSubscriptions(tID badgesmodel.BadgeType, cID string) error
GetTypeSubscriptions(tID badgesmodel.BadgeType) ([]string, error)
GetChannelSubscriptions(cID string) ([]*badgesmodel.BadgeTypeDefinition, error)
// Default type
EnsureDefaultType(botID string) error
// PAPI
EnsureBadges(badges []*badgesmodel.Badge, pluginID, botID string) ([]*badgesmodel.Badge, error)
}
@ -144,6 +150,28 @@ func (s *store) addType(t *badgesmodel.BadgeTypeDefinition, isPlugin bool) (*bad
return t, nil
}
func (s *store) EnsureDefaultType(botID string) error {
types, _, err := s.getAllTypes()
if err != nil {
return err
}
for _, t := range types {
if t.IsDefault {
return nil
}
}
_, err = s.addType(&badgesmodel.BadgeTypeDefinition{
Name: badgesmodel.DefaultTypeName,
IsDefault: true,
CreatedBy: botID,
CanCreate: badgesmodel.PermissionScheme{Everyone: true},
CanGrant: badgesmodel.PermissionScheme{Everyone: true},
}, false)
return err
}
func (s *store) GetAllBadges() ([]*badgesmodel.AllBadgesBadge, error) {
badges, _, err := s.getAllBadges()
if err != nil {
@ -417,6 +445,11 @@ func (s *store) atomicDeleteType(tID badgesmodel.BadgeType) (bool, error) {
}
func (s *store) DeleteType(tID badgesmodel.BadgeType) error {
t, err := s.GetType(tID)
if err == nil && t.IsDefault {
return errors.New("cannot delete default type")
}
s.doAtomic(func() (bool, error) { return s.atomicDeleteType(tID) })
bb, _, err := s.getAllBadges()
@ -473,6 +506,25 @@ func (s *store) AddSubscription(tID badgesmodel.BadgeType, cID string) error {
return s.doAtomic(func() (bool, error) { return s.atomicAddSubscription(toAdd) })
}
func (s *store) FindOwnership(badgeID badgesmodel.BadgeID, userID string, grantTime string) (*badgesmodel.Ownership, error) {
ownership, _, err := s.getOwnershipList()
if err != nil {
return nil, err
}
for _, o := range ownership {
if o.Badge == badgeID && o.User == userID && o.Time.Format(time.RFC3339Nano) == grantTime {
return &o, nil
}
}
return nil, errors.New("ownership not found")
}
func (s *store) RevokeOwnership(badgeID badgesmodel.BadgeID, userID string, grantTime string) error {
return s.doAtomic(func() (bool, error) { return s.atomicRevokeOwnership(badgeID, userID, grantTime) })
}
func (s *store) RemoveSubscriptions(tID badgesmodel.BadgeType, cID string) error {
toRemove := badgesmodel.Subscription{ChannelID: cID, TypeID: tID}
return s.doAtomic(func() (bool, error) { return s.atomicRemoveSubscription(toRemove) })

View File

@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"errors"
"time"
"github.com/larkox/mattermost-plugin-badges/badgesmodel"
)
@ -107,7 +108,7 @@ func (s *store) atomicAddBadgeToOwnership(o badgesmodel.Ownership, isMultiple bo
}
if !isMultiple && ownership.IsOwned(o.User, o.Badge) {
return false, true, nil
return false, true, errAlreadyOwned
}
ownership = append(ownership, o)
@ -159,6 +160,28 @@ func (s *store) atomicUpdateBadge(b *badgesmodel.Badge) (bool, error) {
return s.compareAndSet(KVKeyBadges, data, bb)
}
func (s *store) atomicRevokeOwnership(badgeID badgesmodel.BadgeID, userID string, grantTime string) (bool, error) {
ownership, data, err := s.getOwnershipList()
if err != nil {
return false, err
}
found := false
for i, o := range ownership {
if o.Badge == badgeID && o.User == userID && o.Time.Format(time.RFC3339Nano) == grantTime {
ownership = append(ownership[:i], ownership[i+1:]...)
found = true
break
}
}
if !found {
return true, nil
}
return s.compareAndSet(KVKeyOwnership, data, ownership)
}
func (s *store) atomicAddSubscription(toAdd badgesmodel.Subscription) (bool, error) {
subs, data, err := s.getAllSubscriptions()
if err != nil {

View File

@ -23,7 +23,7 @@ func (p *Plugin) filterGrantBadges(user *model.User) ([]*badgesmodel.Badge, erro
p.mm.Log.Debug("Badge with missing type", "badge", b)
continue
}
if canGrantBadge(user, p.badgeAdminUserID, b, badgeType) {
if canGrantBadge(user, p.badgeAdminUserIDs, b, badgeType) {
out = append(out, b)
}
}
@ -39,7 +39,7 @@ func (p *Plugin) filterCreateBadgeTypes(user *model.User) (badgesmodel.BadgeType
out := badgesmodel.BadgeTypeList{}
for _, t := range types {
if canCreateBadge(user, p.badgeAdminUserID, t) {
if canCreateBadge(user, p.badgeAdminUserIDs, t) {
out = append(out, t)
}
}
@ -55,7 +55,7 @@ func (p *Plugin) filterEditTypes(user *model.User) (badgesmodel.BadgeTypeList, e
out := badgesmodel.BadgeTypeList{}
for _, t := range types {
if canEditType(user, p.badgeAdminUserID, t) {
if canEditType(user, p.badgeAdminUserIDs, t) {
out = append(out, t)
}
}
@ -69,9 +69,18 @@ func (p *Plugin) filterEditBadges(user *model.User) ([]*badgesmodel.Badge, error
return nil, err
}
typeCache := map[badgesmodel.BadgeType]*badgesmodel.BadgeTypeDefinition{}
out := []*badgesmodel.Badge{}
for _, b := range bb {
if canEditBadge(user, p.badgeAdminUserID, b) {
bt, ok := typeCache[b.Type]
if !ok {
bt, err = p.store.GetType(b.Type)
if err != nil {
continue
}
typeCache[b.Type] = bt
}
if canEditBadge(user, p.badgeAdminUserIDs, b, bt) {
out = append(out, b)
}
}

View File

@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"fmt"
"strings"
"github.com/larkox/mattermost-plugin-badges/badgesmodel"
"github.com/mattermost/mattermost-server/v5/model"
@ -23,8 +24,8 @@ func areRolesAllowed(userRoles []string, allowedRoles map[string]bool) bool {
return false
}
func canGrantBadge(user *model.User, badgeAdminID string, badge *badgesmodel.Badge, badgeType *badgesmodel.BadgeTypeDefinition) bool {
if badgeAdminID != "" && user.Id == badgeAdminID {
func canGrantBadge(user *model.User, badgeAdminIDs map[string]bool, badge *badgesmodel.Badge, badgeType *badgesmodel.BadgeTypeDefinition) bool {
if badgeAdminIDs[user.Id] {
return true
}
@ -57,8 +58,8 @@ func canGrantBadge(user *model.User, badgeAdminID string, badge *badgesmodel.Bad
return badgeType.CanGrant.Everyone
}
func canCreateBadge(user *model.User, badgeAdminID string, badgeType *badgesmodel.BadgeTypeDefinition) bool {
if badgeAdminID != "" && user.Id == badgeAdminID {
func canCreateBadge(user *model.User, badgeAdminIDs map[string]bool, badgeType *badgesmodel.BadgeTypeDefinition) bool {
if badgeAdminIDs[user.Id] {
return true
}
@ -87,36 +88,44 @@ func canCreateBadge(user *model.User, badgeAdminID string, badgeType *badgesmode
return badgeType.CanCreate.Everyone
}
func canEditType(user *model.User, badgeAdminID string, badgeType *badgesmodel.BadgeTypeDefinition) bool {
if badgeAdminID != "" && user.Id == badgeAdminID {
func canEditType(user *model.User, badgeAdminIDs map[string]bool, badgeType *badgesmodel.BadgeTypeDefinition) bool {
if badgeAdminIDs[user.Id] {
return true
}
return user.IsSystemAdmin()
}
func canEditBadge(user *model.User, badgeAdminID string, badge *badgesmodel.Badge) bool {
if badgeAdminID != "" && user.Id == badgeAdminID {
func canEditBadge(user *model.User, badgeAdminIDs map[string]bool, badge *badgesmodel.Badge, badgeType *badgesmodel.BadgeTypeDefinition) bool {
if badgeAdminIDs[user.Id] {
return true
}
return user.IsSystemAdmin() || user.Id == badge.CreatedBy
if user.IsSystemAdmin() {
return true
}
if badgeType != nil && canCreateBadge(user, badgeAdminIDs, badgeType) {
return true
}
return false
}
func canCreateType(user *model.User, badgeAdminID string, isPlugin bool) bool {
func canCreateType(user *model.User, badgeAdminIDs map[string]bool, isPlugin bool) bool {
if isPlugin {
return true
}
if badgeAdminID != "" && user.Id == badgeAdminID {
if badgeAdminIDs[user.Id] {
return true
}
return user.IsSystemAdmin()
}
func canCreateSubscription(user *model.User, badgeAdminID string, channelID string) bool {
if badgeAdminID != "" && user.Id == badgeAdminID {
func canCreateSubscription(user *model.User, badgeAdminIDs map[string]bool, channelID string) bool {
if badgeAdminIDs[user.Id] {
return true
}
@ -152,14 +161,17 @@ func (p *Plugin) notifyGrant(badgeID badgesmodel.BadgeID, granter string, grante
image = fmt.Sprintf("![icon](%s) ", b.Image)
}
// DM to the granted user — use their locale
Tdm := p.getT(granted.Locale)
dmPost := &model.Post{}
dmText := fmt.Sprintf("@%s granted you the %s`%s` badge.", granterUser.Username, image, b.Name)
dmText := Tdm("badges.notify.dm_text", "@%s выдал вам значок %s`%s`.", granterUser.Username, image, b.Name)
if reason != "" {
dmText += "\nWhy? " + reason
dmText += Tdm("badges.notify.dm_reason", "\nПочему? ") + reason
}
dmAttachment := model.SlackAttachment{
Title: fmt.Sprintf("%sbadge granted!", image),
Text: dmText,
Fallback: dmText,
Title: Tdm("badges.notify.title", "%sзначок выдан!", image),
Text: dmText,
}
model.ParseSlackAttachment(dmPost, []*model.SlackAttachment{&dmAttachment})
err := p.mm.Post.DM(p.BotUserID, granted.Id, dmPost)
@ -167,16 +179,18 @@ func (p *Plugin) notifyGrant(badgeID badgesmodel.BadgeID, granter string, grante
p.mm.Log.Debug("dm error", "err", err)
}
// Channel/subscription notifications — use granter's locale
Tch := p.getT(granterUser.Locale)
basePost := model.Post{
UserId: p.BotUserID,
ChannelId: channelID,
}
text := fmt.Sprintf("@%s granted @%s the %s`%s` badge.", granterUser.Username, granted.Username, image, b.Name)
text := Tch("badges.notify.channel_text", "@%s выдал @%s значок %s`%s`.", granterUser.Username, granted.Username, image, b.Name)
if reason != "" {
text += "\nWhy? " + reason
text += Tch("badges.notify.dm_reason", "\nПочему? ") + reason
}
attachment := model.SlackAttachment{
Title: fmt.Sprintf("%sbadge granted!", image),
Title: Tch("badges.notify.title", "%sзначок выдан!", image),
Text: text,
}
model.ParseSlackAttachment(&basePost, []*model.SlackAttachment{&attachment})
@ -188,9 +202,17 @@ func (p *Plugin) notifyGrant(badgeID badgesmodel.BadgeID, granter string, grante
p.mm.Log.Debug("notify subscription error", "err", err)
}
}
if inChannel {
alreadyNotified := false
for _, sub := range subs {
if sub == channelID {
alreadyNotified = true
break
}
}
if inChannel && !alreadyNotified {
if !p.API.HasPermissionToChannel(granter, channelID, model.PERMISSION_CREATE_POST) {
p.mm.Post.SendEphemeralPost(granter, &model.Post{Message: "You don't have permissions to notify the grant on this channel.", ChannelId: channelID})
Tg := p.getT(granterUser.Locale)
p.mm.Post.SendEphemeralPost(granter, &model.Post{Message: Tg("badges.notify.no_permission_channel", "У вас нет прав на отправку уведомления о выдаче в этот канал."), ChannelId: channelID})
} else {
post := basePost.Clone()
post.ChannelId = channelID
@ -203,6 +225,40 @@ func (p *Plugin) notifyGrant(badgeID badgesmodel.BadgeID, granter string, grante
}
}
// resolveUsernameList parses a comma-separated list of usernames and returns a map of user IDs.
func (p *Plugin) resolveUsernameList(csv string) (map[string]bool, error) {
result := map[string]bool{}
usernames := strings.Split(csv, ",")
for _, username := range usernames {
username = strings.TrimSpace(username)
if username == "" {
continue
}
user, err := p.mm.User.GetByUsername(username)
if err != nil {
return nil, fmt.Errorf("user not found: %s", username)
}
result[user.Id] = true
}
return result, nil
}
// resolveUserIDList converts a map of user IDs to a comma-separated list of usernames.
func (p *Plugin) resolveUserIDList(ids map[string]bool) string {
var names []string
for id, allowed := range ids {
if !allowed {
continue
}
user, err := p.mm.User.Get(id)
if err != nil {
continue
}
names = append(names, user.Username)
}
return strings.Join(names, ", ")
}
func getBooleanString(in bool) string {
if in {
return TrueString

View File

@ -194,22 +194,14 @@
"skipComments": false
}
],
"max-nested-callbacks": [
2,
{
"max": 2
}
],
"max-nested-callbacks": 0,
"max-statements-per-line": [
2,
{
"max": 1
}
],
"multiline-ternary": [
1,
"never"
],
"multiline-ternary": 0,
"new-cap": 2,
"new-parens": 2,
"newline-before-return": 0,
@ -415,10 +407,7 @@
2,
"always"
],
"operator-linebreak": [
2,
"after"
],
"operator-linebreak": 0,
"padded-blocks": [
2,
"never"
@ -697,7 +686,8 @@
{
"extensions": [".jsx", ".tsx"]
}
]
],
"react/prop-types": 0
}
}
]

Binary file not shown.

View File

@ -1 +1,181 @@
{}
{
"badges.loading": "Loading...",
"badges.no_badges_yet": "No achievements yet.",
"badges.empty.title": "No achievements yet",
"badges.empty.description": "Create your first achievement to recognize contributions of your team members.",
"badges.badge_not_found": "Achievement not found.",
"badges.user_not_found": "User not found.",
"badges.unknown": "unknown",
"badges.rhs.all_badges": "All achievements",
"badges.rhs.my_badges": "My achievements",
"badges.rhs.user_badges": "@{username}'s achievements",
"badges.rhs.badge_details": "Achievement Details",
"badges.label.name": "Name:",
"badges.label.description": "Description:",
"badges.label.type": "Type: {typeName}",
"badges.label.created_by": "Created by: {username}",
"badges.label.granted_by": "Granted by: {username}",
"badges.label.granted_at": "Granted at: {date}",
"badges.label.reason": "Why? {reason}",
"badges.label.count": "Count: {count}",
"badges.granted.not_yet": "Not yet granted.",
"badges.granted.multiple": "Granted {times, plural, one {# time} other {# times}} to {users, plural, one {# user} other {# users}}.",
"badges.granted.single": "Granted to {users, plural, one {# user} other {# users}}.",
"badges.granted_to": "Granted to:",
"badges.not_granted_yet": "Not granted to anyone yet",
"badges.set_status": "Set status to this achievement",
"badges.grant_badge": "Grant achievement",
"badges.and_more": "and {count} more. Click to see all.",
"badges.menu.open_list": "Achievements",
"badges.menu.create_badge": "Create achievement",
"badges.menu.create_type": "Create achievement type",
"badges.menu.add_subscription": "Add achievement subscription",
"badges.menu.remove_subscription": "Remove achievement subscription",
"badges.sidebar.title": "Achievements",
"badges.popover.title": "Achievements",
"badges.admin.label": "Achievements Administrators:",
"badges.admin.placeholder": "Start typing a name...",
"badges.admin.help_text": "These users will be considered achievements plugin administrators. They can create types, as well as modify and grant any achievements.",
"badges.admin.no_results": "No users found",
"badges.rhs.create_badge": "+ Create achievement",
"badges.rhs.edit_badge": "Edit",
"badges.rhs.types": "Types",
"badges.rhs.create_type": "+ Create type",
"badges.modal.create_badge_title": "Create Achievement",
"badges.modal.edit_badge_title": "Edit Achievement",
"badges.modal.field_name": "Name",
"badges.modal.field_name_placeholder": "Achievement name (max 20 chars)",
"badges.modal.field_description": "Description",
"badges.modal.field_description_placeholder": "Achievement description (max 120 chars)",
"badges.modal.field_image": "Emoji",
"badges.modal.field_image_placeholder": "Emoji name (e.g. star)",
"badges.modal.field_type": "Type",
"badges.modal.field_type_placeholder": "Select achievement type",
"badges.modal.field_multiple": "Can be granted multiple times",
"badges.modal.create_new_type": "+ Create new type",
"badges.modal.new_type_name": "Type name",
"badges.modal.new_type_name_placeholder": "Type name (max 20 chars)",
"badges.modal.new_type_everyone_create": "Everyone can create achievements",
"badges.modal.new_type_everyone_grant": "Everyone can grant achievements",
"badges.modal.btn_cancel": "Cancel",
"badges.modal.btn_create": "Create",
"badges.modal.btn_save": "Save",
"badges.modal.btn_creating": "Saving...",
"badges.modal.btn_delete": "Delete achievement",
"badges.modal.btn_confirm_delete": "Yes, delete",
"badges.modal.confirm_delete": "Are you sure?",
"badges.modal.confirm_delete_badge": "Delete achievement \"{name}\"?",
"badges.modal.error_generic": "An error occurred",
"badges.modal.error_type_name_required": "Enter type name",
"badges.modal.error_type_required": "Select achievement type",
"badges.modal.error_duplicate_name": "An achievement with this name already exists in this type",
"badges.modal.error_not_found_emoji": "This emoji was not found",
"badges.modal.create_type_title": "Create Type",
"badges.modal.edit_type_title": "Edit Type",
"badges.modal.btn_delete_type": "Delete type",
"badges.modal.delete_type": "Delete type",
"badges.modal.confirm_delete_type": "Delete type \"{name}\"?",
"badges.modal.btn_confirm_delete_type": "Yes, delete",
"badges.types.badge_count": "{count, plural, one {# achievement} other {# achievements}}",
"badges.types.everyone_can_create": "Everyone creates",
"badges.types.everyone_can_grant": "Everyone grants",
"badges.types.is_default": "Default",
"badges.types.confirm_delete": "Delete type \"{name}\" and all its achievements?",
"badges.types.empty": "No types yet",
"badges.types.no_badges": "No achievements in this type",
"badges.rhs.back_to_types": "Back to types",
"badges.rhs.back_to_achievements": "Back to achievements",
"badges.modal.allowlist_create": "Allowlist for creation",
"badges.modal.allowlist_create_help": "Users who can create achievements of this type.",
"badges.modal.allowlist_grant": "Allowlist for granting",
"badges.modal.allowlist_grant_help": "Users who can grant achievements of this type.",
"badges.modal.allowlist_placeholder": "user-1, user-2, user-3",
"badges.grant.title": "Grant Achievement",
"badges.grant.intro": "Grant achievement to @{username}",
"badges.grant.field_badge": "Achievement",
"badges.grant.field_badge_placeholder": "Select an achievement",
"badges.grant.no_badges": "No achievements available",
"badges.grant.field_reason": "Reason",
"badges.grant.field_reason_placeholder": "Why is this achievement being granted? (optional)",
"badges.grant.notify_here": "Notify in channel",
"badges.grant.btn_grant": "Grant",
"badges.revoke.btn": "Revoke",
"badges.revoke.confirm": "Revoke achievement?",
"badges.revoke.confirm_yes": "Yes",
"badges.subscription.title_create": "Add Subscription",
"badges.subscription.title_delete": "Remove Subscription",
"badges.subscription.field_type": "Achievement Type",
"badges.subscription.field_type_placeholder": "Select achievement type",
"badges.subscription.no_types": "No types available",
"badges.subscription.btn_create": "Add",
"badges.subscription.btn_delete": "Remove",
"badges.error.invalid_badge_id": "Achievement not specified",
"badges.error.invalid_user_id": "User not specified",
"badges.error.no_permission_grant": "Insufficient permissions to grant this achievement",
"badges.error.cannot_grant_badge": "Failed to grant achievement",
"badges.error.user_not_found": "User not found",
"badges.error.invalid_type_id": "Achievement type not specified",
"badges.error.no_permission_subscription": "Insufficient permissions to manage subscriptions",
"badges.error.cannot_create_subscription": "Failed to create subscription",
"badges.error.cannot_delete_subscription": "Failed to delete subscription",
"badges.error.ownership_not_found": "Ownership not found",
"badges.error.no_permission_revoke": "Insufficient permissions to revoke",
"badges.error.cannot_revoke": "Failed to revoke",
"badges.error.already_owned": "This achievement is already owned by this user",
"badges.error.unknown": "An error occurred",
"badges.error.cannot_get_user": "Failed to get user data",
"badges.error.cannot_get_types": "Failed to load types",
"badges.error.cannot_get_badges": "Failed to load achievements",
"badges.error.invalid_request": "Invalid request format",
"badges.error.invalid_name": "Name is required",
"badges.error.invalid_image": "Emoji is required",
"badges.error.type_not_found": "Achievement type not found",
"badges.error.badge_not_found": "Achievement not found",
"badges.error.no_permission": "Insufficient permissions",
"badges.error.missing_badge_id": "Achievement ID is missing",
"badges.error.missing_type_id": "Type ID is missing",
"badges.error.cannot_create_badge": "Failed to create achievement",
"badges.error.cannot_create_type": "Failed to create type",
"badges.error.cannot_update_badge": "Failed to update achievement",
"badges.error.cannot_delete_badge": "Failed to delete achievement",
"badges.error.cannot_update_type": "Failed to update type",
"badges.error.cannot_delete_type": "Failed to delete type",
"emoji_picker.activities": "Activities",
"emoji_picker.animals-nature": "Animals & Nature",
"emoji_picker.close": "Close",
"emoji_picker.custom": "Custom",
"emoji_picker.custom_emoji": "Custom Emoji",
"emoji_picker.emojiPicker.button.ariaLabel": "select an emoji",
"emoji_picker.emojiPicker.previewPlaceholder": "Select an Emoji",
"emoji_picker.flags": "Flags",
"emoji_picker.food-drink": "Food & Drink",
"emoji_picker.header": "Emoji Picker",
"emoji_picker.objects": "Objects",
"emoji_picker.people-body": "People & Body",
"emoji_picker.recent": "Recently Used",
"emoji_picker.search": "Search emojis",
"emoji_picker.searchResults": "Search Results",
"emoji_picker.search_emoji": "Search for an emoji",
"emoji_picker.skin_tone": "Skin tone",
"emoji_picker.smileys-emotion": "Smileys & Emotion",
"emoji_picker.symbols": "Symbols",
"emoji_picker.travel-places": "Travel Places",
"emoji_picker_item.emoji_aria_label": "{emojiName} emoji"
}

181
webapp/i18n/ru.json Normal file
View File

@ -0,0 +1,181 @@
{
"badges.loading": "Загрузка...",
"badges.no_badges_yet": "Достижений пока нет.",
"badges.empty.title": "Достижений пока нет",
"badges.empty.description": "Создайте первое достижение, чтобы отмечать заслуги участников команды.",
"badges.badge_not_found": "Достижение не найдено.",
"badges.user_not_found": "Пользователь не найден.",
"badges.unknown": "неизвестно",
"badges.rhs.all_badges": "Все достижения",
"badges.rhs.my_badges": "Мои достижения",
"badges.rhs.user_badges": "Достижения @{username}",
"badges.rhs.badge_details": "Детали достижения",
"badges.label.name": "Название:",
"badges.label.description": "Описание:",
"badges.label.type": "Тип: {typeName}",
"badges.label.created_by": "Создал: {username}",
"badges.label.granted_by": "Выдал: {username}",
"badges.label.granted_at": "Выдан: {date}",
"badges.label.reason": "Причина: {reason}",
"badges.label.count": "Количество: {count}",
"badges.granted.not_yet": "Ещё не выдан.",
"badges.granted.multiple": "Выдан {times, plural, one {# раз} few {# раза} many {# раз} other {# раз}} {users, plural, one {# пользователю} few {# пользователям} many {# пользователям} other {# пользователям}}.",
"badges.granted.single": "Выдан {users, plural, one {# пользователю} few {# пользователям} many {# пользователям} other {# пользователям}}.",
"badges.granted_to": "Выдан:",
"badges.not_granted_yet": "Ещё никому не выдан",
"badges.set_status": "Установить как статус",
"badges.grant_badge": "Выдать достижение",
"badges.and_more": "и ещё {count}. Нажмите, чтобы увидеть все.",
"badges.menu.open_list": "Достижения",
"badges.menu.create_badge": "Создать достижение",
"badges.menu.create_type": "Создать тип достижений",
"badges.menu.add_subscription": "Добавить подписку на достижения",
"badges.menu.remove_subscription": "Удалить подписку на достижения",
"badges.sidebar.title": "Достижения",
"badges.popover.title": "Достижения",
"badges.admin.label": "Администраторы достижений:",
"badges.admin.placeholder": "Начните вводить имя...",
"badges.admin.help_text": "Эти пользователи будут считаться администраторами плагина достижений. Они могут создавать типы, а также изменять и выдавать любые достижения.",
"badges.admin.no_results": "Пользователь не найден",
"badges.rhs.create_badge": "+ Создать достижение",
"badges.rhs.edit_badge": "Редактировать",
"badges.rhs.types": "Типы",
"badges.rhs.create_type": "+ Создать тип",
"badges.modal.create_badge_title": "Создать достижение",
"badges.modal.edit_badge_title": "Редактировать достижение",
"badges.modal.field_name": "Название",
"badges.modal.field_name_placeholder": "Название достижения (макс. 20 символов)",
"badges.modal.field_description": "Описание",
"badges.modal.field_description_placeholder": "Описание достижения (макс. 120 символов)",
"badges.modal.field_image": "Эмодзи",
"badges.modal.field_image_placeholder": "Название эмодзи (напр. star)",
"badges.modal.field_type": "Тип",
"badges.modal.field_type_placeholder": "Выберите тип достижения",
"badges.modal.field_multiple": "Можно выдавать несколько раз",
"badges.modal.create_new_type": "+ Создать новый тип",
"badges.modal.new_type_name": "Название типа",
"badges.modal.new_type_name_placeholder": "Название типа (макс. 20 символов)",
"badges.modal.new_type_everyone_create": "Все могут создавать достижения",
"badges.modal.new_type_everyone_grant": "Все могут выдавать достижения",
"badges.modal.btn_cancel": "Отмена",
"badges.modal.btn_create": "Создать",
"badges.modal.btn_save": "Сохранить",
"badges.modal.btn_creating": "Сохранение...",
"badges.modal.btn_delete": "Удалить достижение",
"badges.modal.btn_confirm_delete": "Да, удалить",
"badges.modal.confirm_delete": "Вы уверены?",
"badges.modal.confirm_delete_badge": "Удалить достижение «{name}»?",
"badges.modal.error_generic": "Произошла ошибка",
"badges.modal.error_type_name_required": "Введите название типа",
"badges.modal.error_type_required": "Выберите тип достижения",
"badges.modal.error_duplicate_name": "Достижение в данном типе с таким названием уже существует",
"badges.modal.error_not_found_emoji": "Этот эмодзи не найден",
"badges.modal.create_type_title": "Создать тип",
"badges.modal.edit_type_title": "Редактировать тип",
"badges.modal.btn_delete_type": "Удалить тип",
"badges.modal.delete_type": "Удалить тип",
"badges.modal.confirm_delete_type": "Удалить тип «{name}»?",
"badges.modal.btn_confirm_delete_type": "Да, удалить",
"badges.types.badge_count": "{count, plural, one {# достижение} few {# достижения} many {# достижений} other {# достижений}}",
"badges.types.everyone_can_create": "Все создают",
"badges.types.everyone_can_grant": "Все выдают",
"badges.types.is_default": "По умолчанию",
"badges.types.confirm_delete": "Удалить тип «{name}» и все его достижения?",
"badges.types.empty": "Типов пока нет",
"badges.types.no_badges": "В этом типе нет достижений",
"badges.rhs.back_to_types": "Назад к типам",
"badges.rhs.back_to_achievements": "Назад к достижениям",
"badges.modal.allowlist_create": "Список допущенных к созданию",
"badges.modal.allowlist_create_help": "Пользователи, которые могут создавать достижения этого типа.",
"badges.modal.allowlist_grant": "Список допущенных к выдаче",
"badges.modal.allowlist_grant_help": "Пользователи, которые могут выдавать достижения этого типа.",
"badges.modal.allowlist_placeholder": "user-1, user-2, user-3",
"badges.grant.title": "Выдать достижение",
"badges.grant.intro": "Выдать достижение пользователю @{username}",
"badges.grant.field_badge": "Достижение",
"badges.grant.field_badge_placeholder": "Выберите достижение",
"badges.grant.no_badges": "Нет доступных достижений",
"badges.grant.field_reason": "Причина",
"badges.grant.field_reason_placeholder": "За что выдаётся достижение? (необязательно)",
"badges.grant.notify_here": "Уведомить в канале",
"badges.grant.btn_grant": "Выдать",
"badges.revoke.btn": "Снять достижение",
"badges.revoke.confirm": "Снять достижение?",
"badges.revoke.confirm_yes": "Да",
"badges.subscription.title_create": "Добавить подписку",
"badges.subscription.title_delete": "Удалить подписку",
"badges.subscription.field_type": "Тип достижений",
"badges.subscription.field_type_placeholder": "Выберите тип достижений",
"badges.subscription.no_types": "Нет доступных типов",
"badges.subscription.btn_create": "Добавить",
"badges.subscription.btn_delete": "Удалить",
"badges.error.invalid_badge_id": "Не указано достижение",
"badges.error.invalid_user_id": "Не указан пользователь",
"badges.error.no_permission_grant": "Недостаточно прав для выдачи этого достижения",
"badges.error.cannot_grant_badge": "Не удалось выдать достижение",
"badges.error.user_not_found": "Пользователь не найден",
"badges.error.invalid_type_id": "Не указан тип достижений",
"badges.error.no_permission_subscription": "Недостаточно прав для управления подписками",
"badges.error.cannot_create_subscription": "Не удалось создать подписку",
"badges.error.cannot_delete_subscription": "Не удалось удалить подписку",
"badges.error.ownership_not_found": "Выдача не найдена",
"badges.error.no_permission_revoke": "Недостаточно прав для снятия этого достижения",
"badges.error.cannot_revoke": "Не удалось снять достижение",
"badges.error.already_owned": "Это достижение уже выдано этому пользователю",
"badges.error.unknown": "Произошла ошибка",
"badges.error.cannot_get_user": "Не удалось получить данные пользователя",
"badges.error.cannot_get_types": "Не удалось загрузить типы",
"badges.error.cannot_get_badges": "Не удалось загрузить достижения",
"badges.error.invalid_request": "Неверный формат запроса",
"badges.error.invalid_name": "Необходимо указать название",
"badges.error.invalid_image": "Необходимо указать эмодзи",
"badges.error.type_not_found": "Тип достижения не найден",
"badges.error.badge_not_found": "Достижение не найдено",
"badges.error.no_permission": "Недостаточно прав для выполнения действия",
"badges.error.missing_badge_id": "Не указан ID достижения",
"badges.error.missing_type_id": "Не указан ID типа",
"badges.error.cannot_create_badge": "Не удалось создать достижение",
"badges.error.cannot_create_type": "Не удалось создать тип",
"badges.error.cannot_update_badge": "Не удалось обновить достижение",
"badges.error.cannot_delete_badge": "Не удалось удалить достижение",
"badges.error.cannot_update_type": "Не удалось обновить тип",
"badges.error.cannot_delete_type": "Не удалось удалить тип",
"emoji_picker.activities": "Мероприятия",
"emoji_picker.animals-nature": "Животные и природа",
"emoji_picker.close": "Закрыть",
"emoji_picker.custom": "Настраиваемое",
"emoji_picker.custom_emoji": "Пользовательские смайлики",
"emoji_picker.emojiPicker.button.ariaLabel": "выберите смайлик",
"emoji_picker.emojiPicker.previewPlaceholder": "Выберите смайлик",
"emoji_picker.flags": "Флаги",
"emoji_picker.food-drink": "Еда и напитки",
"emoji_picker.header": "Выбор смайликов",
"emoji_picker.objects": "Объекты",
"emoji_picker.people-body": "Люди и тело",
"emoji_picker.recent": "Недавно использованные",
"emoji_picker.search": "Поиск смайликов",
"emoji_picker.searchResults": "Результаты поиска",
"emoji_picker.search_emoji": "Поиск смайлика",
"emoji_picker.skin_tone": "Цвет кожи",
"emoji_picker.smileys-emotion": "Смайлы и эмоции",
"emoji_picker.symbols": "Символы",
"emoji_picker.travel-places": "Места путешествий",
"emoji_picker_item.emoji_aria_label": "смайлик {emojiName}"
}

View File

@ -41,7 +41,7 @@
"@typescript-eslint/parser": "4.22.0",
"babel-eslint": "10.1.0",
"babel-jest": "26.6.3",
"babel-loader": "8.2.2",
"babel-loader": "^8.3.0",
"babel-plugin-typescript-to-proptypes": "1.4.2",
"css-loader": "5.2.4",
"enzyme": "3.11.0",
@ -57,11 +57,11 @@
"jest": "26.6.3",
"jest-canvas-mock": "2.3.1",
"jest-junit": "12.0.0",
"loop-plugin-sdk": "https://artifacts.wilix.dev/repository/npm-public-loop/loop-plugin-sdk/-/loop-plugin-sdk-0.1.6.tgz",
"react-intl": "6.8.9",
"sass": "1.86.0",
"sass-loader": "11.0.1",
"style-loader": "2.0.0",
"webpack": "5.34.0",
"webpack": "^5.54.0",
"webpack-cli": "4.6.0"
},
"dependencies": {
@ -72,9 +72,13 @@
"react": "17.0.2",
"react-custom-scrollbars": "^4.2.1",
"react-redux": "7.2.3",
"react-virtuoso": "^4.18.1",
"redux": "4.0.5",
"typescript": "4.2.4"
},
"resolutions": {
"@types/react": "17.0.3"
},
"jest": {
"snapshotSerializers": [
"<rootDir>/node_modules/enzyme-to-json/serializer"

View File

@ -8,4 +8,17 @@ export default {
RECEIVED_RHS_VIEW: pluginId + '_received_rhs_view',
RECEIVED_RHS_USER: pluginId + '_received_rhs_user',
RECEIVED_RHS_BADGE: pluginId + '_received_rhs_badge',
RECEIVED_RHS_TYPE: pluginId + '_received_rhs_type',
OPEN_CREATE_BADGE_MODAL: pluginId + '_open_create_badge_modal',
CLOSE_CREATE_BADGE_MODAL: pluginId + '_close_create_badge_modal',
OPEN_EDIT_BADGE_MODAL: pluginId + '_open_edit_badge_modal',
CLOSE_EDIT_BADGE_MODAL: pluginId + '_close_edit_badge_modal',
OPEN_CREATE_TYPE_MODAL: pluginId + '_open_create_type_modal',
CLOSE_CREATE_TYPE_MODAL: pluginId + '_close_create_type_modal',
OPEN_EDIT_TYPE_MODAL: pluginId + '_open_edit_type_modal',
CLOSE_EDIT_TYPE_MODAL: pluginId + '_close_edit_type_modal',
OPEN_GRANT_MODAL: pluginId + '_open_grant_modal',
CLOSE_GRANT_MODAL: pluginId + '_close_grant_modal',
OPEN_SUBSCRIPTION_MODAL: pluginId + '_open_subscription_modal',
CLOSE_SUBSCRIPTION_MODAL: pluginId + '_close_subscription_modal',
};

View File

@ -1,14 +1,9 @@
import {AnyAction, Dispatch} from 'redux';
import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {GetStateFunc} from 'mattermost-redux/types/actions';
import {Client4} from 'mattermost-redux/client';
import {IntegrationTypes} from 'mattermost-redux/action_types';
import ActionTypes from 'action_types/';
import {BadgeID} from 'types/badges';
import {RHSState} from 'types/general';
import {BadgeDetails, BadgeID, BadgeTypeDefinition} from 'types/badges';
import {GrantModalData, RHSState, SubscriptionModalData} from 'types/general';
import {id as pluginId} from '../manifest';
/**
* Stores`showRHSPlugin` action returned by
@ -36,91 +31,106 @@ export function setRHSBadge(badgeID: BadgeID | null) {
}
export function setRHSView(view: RHSState) {
return {
type: ActionTypes.RECEIVED_RHS_VIEW,
data: view,
return (dispatch: Dispatch<AnyAction>, getState: () => any) => {
const state = getState();
const pluginState = state['plugins-' + pluginId];
const currentView = pluginState?.rhsView;
dispatch({
type: ActionTypes.RECEIVED_RHS_VIEW,
data: view,
prevView: currentView,
});
return {data: true};
};
}
export function setTriggerId(triggerId: string) {
export function setRHSType(typeId: number | null, typeName: string | null) {
return {
type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID,
data: triggerId,
type: ActionTypes.RECEIVED_RHS_TYPE,
data: {typeId, typeName},
};
}
export function openGrant(user?: string, badge?: string) {
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
let command = '/badges grant';
if (user) {
command += ` --user ${user}`;
}
if (badge) {
command += ` --badge ${badge}`;
}
clientExecuteCommand(dispatch, getState, command);
return (dispatch: Dispatch<AnyAction>) => {
dispatch(openGrantModal({prefillUser: user, prefillBadgeId: badge}));
return {data: true};
};
}
export function openCreateType() {
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
const command = '/badges create type';
clientExecuteCommand(dispatch, getState, command);
return (dispatch: Dispatch<AnyAction>) => {
dispatch(openCreateTypeModal());
return {data: true};
};
}
export function openCreateBadge() {
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
const command = '/badges create badge';
clientExecuteCommand(dispatch, getState, command);
return (dispatch: Dispatch<AnyAction>) => {
dispatch(openCreateBadgeModal());
return {data: true};
};
}
export function openAddSubscription() {
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
const command = '/badges subscription create';
clientExecuteCommand(dispatch, getState, command);
export function openCreateBadgeModal() {
return {type: ActionTypes.OPEN_CREATE_BADGE_MODAL};
}
export function closeCreateBadgeModal() {
return {type: ActionTypes.CLOSE_CREATE_BADGE_MODAL};
}
export function openEditBadgeModal(badge: BadgeDetails) {
return {type: ActionTypes.OPEN_EDIT_BADGE_MODAL, data: badge};
}
export function closeEditBadgeModal() {
return {type: ActionTypes.CLOSE_EDIT_BADGE_MODAL};
}
export function openCreateTypeModal() {
return {type: ActionTypes.OPEN_CREATE_TYPE_MODAL};
}
export function closeCreateTypeModal() {
return {type: ActionTypes.CLOSE_CREATE_TYPE_MODAL};
}
export function openEditTypeModal(badgeType: BadgeTypeDefinition) {
return {type: ActionTypes.OPEN_EDIT_TYPE_MODAL, data: badgeType};
}
export function closeEditTypeModal() {
return {type: ActionTypes.CLOSE_EDIT_TYPE_MODAL};
}
export function openGrantModal(data?: GrantModalData) {
return {type: ActionTypes.OPEN_GRANT_MODAL, data: data || {}};
}
export function closeGrantModal() {
return {type: ActionTypes.CLOSE_GRANT_MODAL};
}
export function openSubscriptionModal(data: SubscriptionModalData) {
return {type: ActionTypes.OPEN_SUBSCRIPTION_MODAL, data};
}
export function closeSubscriptionModal() {
return {type: ActionTypes.CLOSE_SUBSCRIPTION_MODAL};
}
export function openAddSubscription() {
return (dispatch: Dispatch<AnyAction>) => {
dispatch(openSubscriptionModal({mode: 'create'}));
return {data: true};
};
}
export function openRemoveSubscription() {
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
const command = '/badges subscription remove';
clientExecuteCommand(dispatch, getState, command);
return (dispatch: Dispatch<AnyAction>) => {
dispatch(openSubscriptionModal({mode: 'delete'}));
return {data: true};
};
}
export async function clientExecuteCommand(dispatch: Dispatch<AnyAction>, getState: GetStateFunc, command: string) {
let currentChannel = getCurrentChannel(getState());
const currentTeamId = getCurrentTeamId(getState());
// Default to town square if there is no current channel (i.e., if Mattermost has not yet loaded)
if (!currentChannel) {
currentChannel = await Client4.getChannelByName(currentTeamId, 'town-square');
}
const args = {
channel_id: currentChannel?.id,
team_id: currentTeamId,
};
try {
//@ts-ignore Typing in mattermost-redux is wrong
const data = await Client4.executeCommand(command, args);
dispatch(setTriggerId(data?.trigger_id));
} catch (error) {
console.error(error); //eslint-disable-line no-console
}
}

View File

@ -5,7 +5,7 @@ import {Client4} from 'mattermost-redux/client';
import {ClientError} from 'mattermost-redux/client/client4';
import manifest from 'manifest';
import {AllBadgesBadge, BadgeDetails, BadgeID, UserBadge} from 'types/badges';
import {AllBadgesBadge, Badge, BadgeDetails, BadgeID, BadgeTypeDefinition, CreateBadgeRequest, CreateTypeRequest, GetTypesResponse, GrantBadgeRequest, RevokeOwnershipRequest, SubscriptionRequest, UpdateBadgeRequest, UpdateTypeRequest, UserBadge} from 'types/badges';
export default class Client {
private url: string;
@ -41,6 +41,64 @@ export default class Client {
}
}
async getTypes(): Promise<GetTypesResponse> {
try {
const res = await this.doGet(`${this.url}/getTypes`);
return res as GetTypesResponse;
} catch {
return {types: [], can_create_type: false, can_edit_type: false};
}
}
async createBadge(req: CreateBadgeRequest): Promise<Badge> {
return await this.doPost(`${this.url}/createBadge`, req) as Badge;
}
async createType(req: CreateTypeRequest): Promise<BadgeTypeDefinition> {
return await this.doPost(`${this.url}/createType`, req) as BadgeTypeDefinition;
}
async updateBadge(req: UpdateBadgeRequest): Promise<Badge> {
return await this.doPut(`${this.url}/updateBadge`, req) as Badge;
}
async deleteBadge(badgeID: BadgeID): Promise<void> {
await this.doDelete(`${this.url}/deleteBadge/${badgeID}`);
}
async updateType(req: UpdateTypeRequest): Promise<BadgeTypeDefinition> {
return await this.doPut(`${this.url}/updateType`, req) as BadgeTypeDefinition;
}
async deleteType(typeID: string): Promise<void> {
await this.doDelete(`${this.url}/deleteType/${typeID}`);
}
async grantBadge(req: GrantBadgeRequest): Promise<void> {
await this.doPost(`${this.url}/grantBadge`, req);
}
async createSubscription(req: SubscriptionRequest): Promise<void> {
await this.doPost(`${this.url}/createSubscription`, req);
}
async deleteSubscription(req: SubscriptionRequest): Promise<void> {
await this.doPost(`${this.url}/deleteSubscription`, req);
}
async revokeOwnership(req: RevokeOwnershipRequest): Promise<void> {
await this.doPost(`${this.url}/revokeOwnership`, req);
}
async getChannelSubscriptions(channelID: string): Promise<BadgeTypeDefinition[]> {
try {
const res = await this.doGet(`${this.url}/getChannelSubscriptions/${channelID}`);
return res as BadgeTypeDefinition[];
} catch {
return [];
}
}
private doGet = async (url: string, headers: {[x:string]: string} = {}) => {
headers['X-Timezone-Offset'] = String(new Date().getTimezoneOffset());
@ -63,4 +121,75 @@ export default class Client {
url,
});
}
private doPost = async (url: string, body: any, headers: {[x:string]: string} = {}) => {
headers['X-Timezone-Offset'] = String(new Date().getTimezoneOffset());
const options = {
method: 'post',
body: JSON.stringify(body),
headers,
};
const response = await fetch(url, Client4.getOptions(options));
if (response.ok) {
return response.json();
}
const text = await response.text();
throw new ClientError(Client4.url, {
message: text || '',
status_code: response.status,
url,
});
}
private doPut = async (url: string, body: any, headers: {[x:string]: string} = {}) => {
headers['X-Timezone-Offset'] = String(new Date().getTimezoneOffset());
const options = {
method: 'put',
body: JSON.stringify(body),
headers,
};
const response = await fetch(url, Client4.getOptions(options));
if (response.ok) {
return response.json();
}
const text = await response.text();
throw new ClientError(Client4.url, {
message: text || '',
status_code: response.status,
url,
});
}
private doDelete = async (url: string, headers: {[x:string]: string} = {}) => {
headers['X-Timezone-Offset'] = String(new Date().getTimezoneOffset());
const options = {
method: 'delete',
headers,
};
const response = await fetch(url, Client4.getOptions(options));
if (response.ok) {
return response.json();
}
const text = await response.text();
throw new ClientError(Client4.url, {
message: text || '',
status_code: response.status,
url,
});
}
}

View File

@ -0,0 +1,50 @@
import React, {useCallback} from 'react';
import {FormattedMessage} from 'react-intl';
import UserMultiSelect from 'components/user_multi_select';
type Props = {
id: string;
value: string;
disabled: boolean;
onChange: (id: string, value: any) => void;
setSaveNeeded: () => void;
config: any;
license: any;
setByEnv: boolean;
registerSaveAction: (action: () => Promise<{error?: {message?: string}}>) => void;
unRegisterSaveAction: (action: () => Promise<{error?: {message?: string}}>) => void;
}
const BadgesAdminSetting: React.FC<Props> = ({id, value, disabled, onChange, setSaveNeeded}) => {
const handleChange = useCallback((newValue: string) => {
onChange(id, newValue);
setSaveNeeded();
}, [id, onChange, setSaveNeeded]);
return (
<div className='form-group'>
<label className='control-label col-sm-4'>
<FormattedMessage
id='badges.admin.label'
defaultMessage='Администраторы достижений:'
/>
</label>
<div className='col-sm-8'>
<UserMultiSelect
value={value}
onChange={handleChange}
disabled={disabled}
/>
<div className='help-text'>
<FormattedMessage
id='badges.admin.help_text'
defaultMessage='Эти пользователи будут считаться администраторами плагина достижений. Они могут создавать типы, а также изменять и выдавать любые достижения.'
/>
</div>
</div>
</div>
);
};
export default BadgesAdminSetting;

View File

@ -0,0 +1,13 @@
.BackButton {
background: none;
border: none;
padding: 0;
font-size: 12px;
color: var(--button-bg, #166de0);
cursor: pointer;
text-align: left;
&:hover {
text-decoration: underline;
}
}

View File

@ -0,0 +1,29 @@
import React from 'react';
import {RHSState} from '../../types/general';
import './back_button.scss';
type Props = {
targetView: RHSState;
onNavigate: (view: RHSState) => void;
children: React.ReactNode;
}
const BackButton: React.FC<Props> = ({
targetView,
onNavigate,
children,
}) => {
return (
<button
className='BackButton'
onClick={() => onNavigate(targetView)}
>
{'← '}
{children}
</button>
);
};
export default BackButton;

View File

@ -1,7 +1,7 @@
import React from 'react';
import {Badge} from '../../types/badges';
import RenderEmoji from '../utils/emoji';
import RenderEmoji from '../emoji/emoji';
import {IMAGE_TYPE_ABSOLUTE_URL, IMAGE_TYPE_EMOJI} from '../../constants';
type Props = {

View File

@ -0,0 +1,422 @@
@keyframes badgeModalBackdropIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes badgeModalBackdropOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes badgeModalDialogIn {
from {
opacity: 0;
transform: translateY(-40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes badgeModalDialogOut {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-40px);
}
}
.BadgeModal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
&__backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
animation: badgeModalBackdropIn 0.2s ease-out;
}
&__dialog {
position: relative;
z-index: 1;
background: var(--center-channel-bg, #fff);
color: var(--center-channel-color, #3d3c40);
border-radius: 8px;
box-shadow: 0 20px 32px rgba(0, 0, 0, 0.12);
width: 480px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
animation: badgeModalDialogIn 0.2s ease-out;
}
&--closing {
.BadgeModal__backdrop {
animation: badgeModalBackdropOut 0.15s ease-in forwards;
}
.BadgeModal__dialog {
animation: badgeModalDialogOut 0.15s ease-in forwards;
}
}
&--compact {
.BadgeModal__body {
overflow: visible;
}
.BadgeModal__dialog {
overflow: visible;
}
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px 0;
h4 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--center-channel-color, #3d3c40);
opacity: 0.56;
padding: 0;
line-height: 1;
&:hover {
opacity: 1;
}
}
}
&__body {
padding: 20px 24px;
overflow-y: auto;
flex: 1;
}
.grant-intro {
font-size: 14px;
margin: 0 0 16px;
opacity: 0.72;
}
&__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 24px;
border-top: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
}
.form-group {
margin-bottom: 16px;
label {
display: block;
font-size: 12px;
font-weight: 600;
margin-bottom: 4px;
text-transform: uppercase;
opacity: 0.64;
.required {
color: var(--error-text, #d24b4e);
margin-left: 2px;
}
}
> input[type='text'],
> select,
> textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
border-radius: 4px;
font-size: 14px;
background: var(--center-channel-bg, #fff);
color: var(--center-channel-color, #3d3c40);
&:focus {
border-color: var(--button-bg, #166de0);
outline: none;
}
}
> textarea {
resize: vertical;
min-height: 60px;
}
.emoji-input {
display: flex;
align-items: center;
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
border-radius: 4px;
background: var(--center-channel-bg, #fff);
&:focus-within {
border-color: var(--button-bg, #166de0);
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 36px;
height: 36px;
padding: 0;
border: none;
background: none;
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56);
cursor: pointer;
&:hover {
color: var(--center-channel-color, #3d3c40);
}
.emoticon {
display: block;
}
}
.emojisprite,
.emoticon {
margin-right: 4px;
}
input[type='text'] {
flex: 1;
border: none;
background: transparent;
padding: 8px 12px 8px 0;
&:focus {
outline: none;
border-color: transparent;
box-shadow: none;
}
}
}
}
.checkbox-group {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
label {
font-size: 14px;
font-weight: normal;
margin: 0;
text-transform: none;
opacity: 1;
cursor: pointer;
}
}
.inline-type-section {
padding: 12px;
margin-top: 8px;
border: 1px dashed rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.24);
border-radius: 4px;
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.04);
}
.btn {
padding: 8px 20px;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: none;
&--primary {
background: var(--button-bg, #166de0);
color: var(--button-color, #fff);
&:hover {
opacity: 0.88;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&--cancel {
background: transparent;
color: var(--center-channel-color, #3d3c40);
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
&:hover {
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
}
}
&--danger {
background: var(--error-text, #d24b4e);
color: #fff;
&:hover {
opacity: 0.88;
}
}
}
.error-message {
color: var(--error-text, #d24b4e);
font-size: 13px;
margin-top: 8px;
}
.delete-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
display: flex;
justify-content: space-between;
align-items: center;
}
.confirm-delete {
display: flex;
align-items: center;
gap: 8px;
span {
font-size: 13px;
color: var(--error-text, #d24b4e);
}
}
.type-select {
position: relative;
&__trigger {
width: 100%;
padding: 8px 12px;
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
border-radius: 4px;
font-size: 14px;
background: var(--center-channel-bg, #fff);
color: var(--center-channel-color, #3d3c40);
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
text-align: left;
&:hover {
border-color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.32);
}
}
&__value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__arrow {
font-size: 12px;
opacity: 0.56;
margin-left: 8px;
flex-shrink: 0;
}
&__dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background: var(--center-channel-bg, #fff);
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
z-index: 10;
max-height: 160px;
overflow-y: auto;
}
&__option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
&:hover {
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
}
&--selected {
background: rgba(var(--button-bg-rgb, 22, 109, 224), 0.08);
font-weight: 600;
}
&--create {
border-top: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
color: var(--button-bg, #166de0);
font-weight: 600;
}
}
&__option-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__delete-btn {
background: none;
border: none;
color: var(--center-channel-color, #3d3c40);
opacity: 0.4;
cursor: pointer;
padding: 0 4px;
font-size: 12px;
line-height: 1;
flex-shrink: 0;
&:hover {
opacity: 1;
color: var(--error-text, #d24b4e);
}
}
}
}

View File

@ -0,0 +1,25 @@
import React from 'react';
interface EmojiPickerOverlayProps {
target: () => HTMLElement | null;
container?: () => HTMLElement | null;
show: boolean;
onHide: () => void;
onEmojiClick: (emoji: any) => void;
rightOffset?: number;
defaultHorizontalPosition?: 'left' | 'right';
onExited?: () => void;
hideCustomEmojiButton?: boolean;
}
const EmojiPickerOverlay: React.FC<EmojiPickerOverlayProps> = (props) => {
const Overlay = (window as any).Components?.EmojiPickerOverlay;
if (!Overlay) {
return null;
}
return <Overlay {...props}/>;
};
export default EmojiPickerOverlay;

View File

@ -0,0 +1,470 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {FormattedMessage, useIntl} from 'react-intl';
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/common';
import {GlobalState} from 'mattermost-redux/types/store';
import RenderEmoji from 'components/emoji/emoji';
import {isCreateBadgeModalVisible, getEditBadgeModalData, getEmojiMap} from 'selectors';
import {closeCreateBadgeModal, closeEditBadgeModal, setRHSView} from 'actions/actions';
import {RHS_STATE_ALL} from '../../constants';
import {BadgeFormData, BadgeTypeDefinition, TypeFormData} from 'types/badges';
import Client from 'client/api';
import {getServerErrorId} from 'utils/helpers';
import CloseIcon from 'components/icons/close_icon';
import EmojiIcon from 'components/icons/emoji_icon';
import ConfirmDialog from 'components/confirm_dialog/confirm_dialog';
import EmojiPickerOverlay from './emoji_picker';
import InlineTypeForm from './inline_type_form';
import TypeSelect from './type_select';
import './badge_modal.scss';
const NEW_TYPE_VALUE = '__new__';
const emptyBadgeForm: BadgeFormData = {
name: '',
description: '',
image: '',
badgeType: '',
multiple: false,
};
const emptyTypeForm: TypeFormData = {
name: '',
everyoneCanCreate: false,
everyoneCanGrant: false,
allowlistCanCreate: '',
allowlistCanGrant: '',
};
const BadgeModal: React.FC = () => {
const dispatch = useDispatch();
const intl = useIntl();
const createVisible = useSelector(isCreateBadgeModalVisible);
const editData = useSelector(getEditBadgeModalData);
const channelId = useSelector((state: GlobalState) => getCurrentChannelId(state));
const emojiMap = useSelector((state: GlobalState) => getEmojiMap(state));
const isOpen = createVisible || editData !== null;
const isEditMode = editData !== null;
const [form, setForm] = useState<BadgeFormData>(emptyBadgeForm);
const [newTypeForm, setNewTypeForm] = useState<TypeFormData>(emptyTypeForm);
const [types, setTypes] = useState<BadgeTypeDefinition[]>([]);
const [showCreateType, setShowCreateType] = useState(false);
const [canCreateType, setCanCreateType] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmDeleteTypeId, setConfirmDeleteTypeId] = useState<string | null>(null);
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [closing, setClosing] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const updateForm = useCallback((updates: Partial<BadgeFormData>) => {
setForm((prev) => ({...prev, ...updates}));
}, []);
const updateTypeForm = useCallback((updates: Partial<TypeFormData>) => {
setNewTypeForm((prev) => ({...prev, ...updates}));
}, []);
const emojiData = (window as any)?.useGetEmojiSelectorData?.();
const {
emojiButtonRef,
calculateRightOffSet,
} = emojiData || {};
useEffect(() => {
if (!isOpen) {
return;
}
const fetchTypes = async () => {
const client = new Client();
const resp = await client.getTypes();
setTypes(resp.types);
setCanCreateType(resp.can_create_type);
if (!isEditMode && resp.types.length > 0) {
const defaultType = resp.types.find((t) => t.is_default);
setForm((prev) => ({...prev, badgeType: String(defaultType ? defaultType.id : resp.types[0].id)}));
}
};
fetchTypes();
if (isEditMode && editData) {
setForm({
name: editData.name,
description: editData.description,
image: editData.image,
badgeType: String(editData.type),
multiple: editData.multiple,
});
} else {
setForm(emptyBadgeForm);
}
setShowCreateType(false);
setNewTypeForm(emptyTypeForm);
setError(null);
setConfirmDelete(false);
setConfirmDeleteTypeId(null);
setTypeDropdownOpen(false);
setShowEmojiPicker(false);
setLoading(false);
}, [isOpen, isEditMode]); // eslint-disable-line react-hooks/exhaustive-deps
const doClose = useCallback(() => {
if (createVisible) {
dispatch(closeCreateBadgeModal());
}
if (editData) {
dispatch(closeEditBadgeModal());
}
setClosing(false);
}, [dispatch, createVisible, editData]);
const handleClose = useCallback(() => {
setClosing(true);
setTimeout(doClose, 150);
}, [doClose]);
const handleTypeSelect = useCallback((val: string) => {
if (val === NEW_TYPE_VALUE) {
setShowCreateType(true);
updateForm({badgeType: ''});
} else {
setShowCreateType(false);
updateForm({badgeType: val});
}
setTypeDropdownOpen(false);
setConfirmDeleteTypeId(null);
}, [updateForm]);
const handleEmojiSelect = (emoji: any) => {
if (emoji.short_name) {
updateForm({image: emoji.short_name});
} else if (emoji.name) {
updateForm({image: emoji.name});
}
setShowEmojiPicker(false);
};
const handleDeleteType = useCallback(async (typeId: string) => {
if (confirmDeleteTypeId !== typeId) {
setConfirmDeleteTypeId(typeId);
return;
}
try {
const client = new Client();
await client.deleteType(typeId);
const removeById = (t: BadgeTypeDefinition) => String(t.id) !== typeId;
setTypes((prev) => prev.filter(removeById));
if (form.badgeType === typeId) {
updateForm({badgeType: ''});
}
} catch (err) {
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
}
setConfirmDeleteTypeId(null);
}, [confirmDeleteTypeId, form.badgeType, updateForm, intl]);
const handleSubmit = useCallback(async () => {
setLoading(true);
setError(null);
try {
const client = new Client();
let typeID = form.badgeType;
if (showCreateType) {
if (!newTypeForm.name.trim()) {
setError(intl.formatMessage({id: 'badges.modal.error_type_name_required', defaultMessage: 'Введите название типа'}));
setLoading(false);
return;
}
const createdType = await client.createType({
name: newTypeForm.name.trim(),
everyone_can_create: newTypeForm.everyoneCanCreate,
everyone_can_grant: newTypeForm.everyoneCanGrant,
allowlist_can_create: newTypeForm.allowlistCanCreate.trim(),
allowlist_can_grant: newTypeForm.allowlistCanGrant.trim(),
channel_id: channelId,
});
typeID = String(createdType.id);
}
if (!typeID) {
setError(intl.formatMessage({id: 'badges.modal.error_type_required', defaultMessage: 'Выберите тип достижения'}));
setLoading(false);
return;
}
const allBadges = await client.getAllBadges();
const trimmedName = form.name.trim().toLowerCase();
const duplicate = allBadges.find(
(b) => b.name.toLowerCase() === trimmedName &&
String(b.type) === typeID &&
(!isEditMode || !editData || b.id !== editData.id),
);
if (!emojiMap.has(form.image)) {
setError(intl.formatMessage({id: 'badges.modal.error_not_found_emoji', defaultMessage: 'Этот эмодзи не найден'}));
setLoading(false);
return;
}
if (duplicate) {
setError(intl.formatMessage({id: 'badges.modal.error_duplicate_name', defaultMessage: 'Достижение в данном типе с таким названием уже существует'}));
setLoading(false);
return;
}
if (isEditMode && editData) {
await client.updateBadge({
id: String(editData.id),
name: form.name.trim(),
description: form.description.trim(),
image: form.image.trim(),
type: typeID,
multiple: form.multiple,
});
} else {
await client.createBadge({
name: form.name.trim(),
description: form.description.trim(),
image: form.image.trim(),
type: typeID,
multiple: form.multiple,
channel_id: channelId,
});
}
handleClose();
dispatch(setRHSView(RHS_STATE_ALL));
} catch (err) {
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
} finally {
setLoading(false);
}
}, [form, showCreateType, newTypeForm, isEditMode, editData, handleClose, intl, channelId, dispatch, emojiMap]);
const handleDelete = useCallback(async () => {
if (!editData) {
return;
}
if (!confirmDelete) {
setConfirmDelete(true);
return;
}
setLoading(true);
setError(null);
try {
const client = new Client();
await client.deleteBadge(editData.id);
handleClose();
dispatch(setRHSView(RHS_STATE_ALL));
} catch (err) {
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
} finally {
setLoading(false);
}
}, [editData, confirmDelete, handleClose, intl, dispatch]);
if (!isOpen && !closing) {
return null;
}
const title = isEditMode
? intl.formatMessage({id: 'badges.modal.edit_badge_title', defaultMessage: 'Редактировать достижение'})
: intl.formatMessage({id: 'badges.modal.create_badge_title', defaultMessage: 'Создать достижение'});
const submitLabel = isEditMode
? intl.formatMessage({id: 'badges.modal.btn_save', defaultMessage: 'Сохранить'})
: intl.formatMessage({id: 'badges.modal.btn_create', defaultMessage: 'Создать'});
return (
<div
className={'BadgeModal' + (closing ? ' BadgeModal--closing' : '')}
ref={modalRef}
>
<div
className='BadgeModal__backdrop'
onClick={handleClose}
/>
<div
className='BadgeModal__dialog'
ref={dialogRef}
>
<div className='BadgeModal__header'>
<h4>{title}</h4>
<button
className='close-btn'
onClick={handleClose}
>
<CloseIcon/>
</button>
</div>
<div className='BadgeModal__body'>
<div className='form-group'>
<label>
<FormattedMessage
id='badges.modal.field_name'
defaultMessage='Название'
/>
<span className='required'>{'*'}</span>
</label>
<input
type='text'
value={form.name}
onChange={(e) => updateForm({name: e.target.value})}
maxLength={20}
placeholder={intl.formatMessage({id: 'badges.modal.field_name_placeholder', defaultMessage: 'Название достижения (макс. 20 символов)'})}
/>
</div>
<div className='form-group'>
<label>
<FormattedMessage
id='badges.modal.field_description'
defaultMessage='Описание'
/>
</label>
<textarea
value={form.description}
onChange={(e) => updateForm({description: e.target.value})}
maxLength={120}
placeholder={intl.formatMessage({id: 'badges.modal.field_description_placeholder', defaultMessage: 'Описание достижения (макс. 120 символов)'})}
/>
</div>
<div className='form-group'>
<label>
<FormattedMessage
id='badges.modal.field_image'
defaultMessage='Эмодзи'
/>
<span className='required'>{'*'}</span>
</label>
<div className='emoji-input'>
<button
type='button'
className='emoji-input__icon'
onClick={() => setShowEmojiPicker((prev) => !prev)}
ref={emojiButtonRef}
>
<EmojiIcon/>
</button>
{form.image && (
<RenderEmoji
emojiName={form.image}
size={20}
/>
)}
<input
type='text'
value={form.image}
onChange={(e) => updateForm({image: e.target.value.trim()})}
placeholder={intl.formatMessage({id: 'badges.modal.field_image_placeholder', defaultMessage: 'Название эмодзи (напр. star)'})}
/>
</div>
{showEmojiPicker && (
<EmojiPickerOverlay
target={() => emojiButtonRef?.current}
container={() => modalRef.current}
show={showEmojiPicker}
onHide={() => setShowEmojiPicker(false)}
onEmojiClick={handleEmojiSelect}
rightOffset={calculateRightOffSet?.(emojiButtonRef?.current)}
defaultHorizontalPosition='right'
hideCustomEmojiButton={true}
/>
)}
</div>
<div className='form-group'>
<label>
<FormattedMessage
id='badges.modal.field_type'
defaultMessage='Тип'
/>
<span className='required'>{'*'}</span>
</label>
<TypeSelect
types={types}
badgeType={form.badgeType}
showCreateType={showCreateType}
canCreateType={canCreateType}
typeDropdownOpen={typeDropdownOpen}
confirmDeleteTypeId={confirmDeleteTypeId}
onToggleDropdown={() => setTypeDropdownOpen(!typeDropdownOpen)}
onSelect={handleTypeSelect}
onDeleteType={handleDeleteType}
onCancelDeleteType={() => setConfirmDeleteTypeId(null)}
/>
{showCreateType && (
<InlineTypeForm
form={newTypeForm}
onChange={updateTypeForm}
/>
)}
</div>
<div className='checkbox-group'>
<input
type='checkbox'
id='badgeMultiple'
checked={form.multiple}
onChange={(e) => updateForm({multiple: e.target.checked})}
/>
<label htmlFor='badgeMultiple'>
<FormattedMessage
id='badges.modal.field_multiple'
defaultMessage='Можно выдавать несколько раз'
/>
</label>
</div>
{error && <div className='error-message'>{error}</div>}
{isEditMode && (
<div className='delete-section'>
<button
className='btn btn--danger'
onClick={handleDelete}
disabled={loading}
>
<FormattedMessage
id='badges.modal.btn_delete'
defaultMessage='Удалить достижение'
/>
</button>
{confirmDelete && (
<ConfirmDialog
onConfirm={handleDelete}
onCancel={() => setConfirmDelete(false)}
>
<FormattedMessage
id='badges.modal.confirm_delete_badge'
defaultMessage='Удалить достижение «{name}»?'
values={{name: form.name || editData?.name}}
/>
</ConfirmDialog>
)}
</div>
)}
</div>
<div className='BadgeModal__footer'>
<button
className='btn btn--cancel'
onClick={handleClose}
>
<FormattedMessage
id='badges.modal.btn_cancel'
defaultMessage='Отмена'
/>
</button>
<button
className='btn btn--primary'
onClick={handleSubmit}
disabled={loading || !form.name.trim() || !form.image.trim()}
>
{loading ? intl.formatMessage({id: 'badges.modal.btn_creating', defaultMessage: 'Сохранение...'}) : submitLabel}
</button>
</div>
</div>
</div>
);
};
export default BadgeModal;

View File

@ -0,0 +1,94 @@
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {TypeFormData} from 'types/badges';
import UserMultiSelect from 'components/user_multi_select';
type Props = {
form: TypeFormData;
onChange: (updates: Partial<TypeFormData>) => void;
}
const InlineTypeForm: React.FC<Props> = ({form, onChange}) => {
const intl = useIntl();
return (
<div className='inline-type-section'>
<div className='form-group'>
<label>
<FormattedMessage
id='badges.modal.new_type_name'
defaultMessage='Название типа'
/>
<span className='required'>{'*'}</span>
</label>
<input
type='text'
value={form.name}
onChange={(e) => onChange({name: e.target.value})}
maxLength={20}
placeholder={intl.formatMessage({id: 'badges.modal.new_type_name_placeholder', defaultMessage: 'Название типа (макс. 20 символов)'})}
/>
</div>
<div className='checkbox-group'>
<input
type='checkbox'
id='newTypeEveryoneCanCreate'
checked={form.everyoneCanCreate}
onChange={(e) => onChange({everyoneCanCreate: e.target.checked})}
/>
<label htmlFor='newTypeEveryoneCanCreate'>
<FormattedMessage
id='badges.modal.new_type_everyone_create'
defaultMessage='Все могут создавать достижения'
/>
</label>
</div>
{!form.everyoneCanCreate && (
<div className='form-group'>
<label>
<FormattedMessage
id='badges.modal.allowlist_create'
defaultMessage='Список допущенных к созданию'
/>
</label>
<UserMultiSelect
value={form.allowlistCanCreate}
onChange={(v) => onChange({allowlistCanCreate: v})}
/>
</div>
)}
<div className='checkbox-group'>
<input
type='checkbox'
id='newTypeEveryoneCanGrant'
checked={form.everyoneCanGrant}
onChange={(e) => onChange({everyoneCanGrant: e.target.checked})}
/>
<label htmlFor='newTypeEveryoneCanGrant'>
<FormattedMessage
id='badges.modal.new_type_everyone_grant'
defaultMessage='Все могут выдавать достижения'
/>
</label>
</div>
{!form.everyoneCanGrant && (
<div className='form-group'>
<label>
<FormattedMessage
id='badges.modal.allowlist_grant'
defaultMessage='Список допущенных к выдаче'
/>
</label>
<UserMultiSelect
value={form.allowlistCanGrant}
onChange={(v) => onChange({allowlistCanGrant: v})}
/>
</div>
)}
</div>
);
};
export default InlineTypeForm;

View File

@ -0,0 +1,110 @@
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {BadgeTypeDefinition} from 'types/badges';
import TrashIcon from 'components/icons/trash_icon';
import ConfirmDialog from 'components/confirm_dialog/confirm_dialog';
const NEW_TYPE_VALUE = '__new__';
type Props = {
types: BadgeTypeDefinition[];
badgeType: string;
showCreateType: boolean;
canCreateType: boolean;
typeDropdownOpen: boolean;
confirmDeleteTypeId: string | null;
onToggleDropdown: () => void;
onSelect: (val: string) => void;
onDeleteType: (typeId: string) => void;
onCancelDeleteType: () => void;
}
const TypeSelect: React.FC<Props> = ({
types,
badgeType,
showCreateType,
canCreateType,
typeDropdownOpen,
confirmDeleteTypeId,
onToggleDropdown,
onSelect,
onDeleteType,
onCancelDeleteType,
}) => {
const intl = useIntl();
const selectedTypeName = types.find((t) => String(t.id) === badgeType)?.name ||
intl.formatMessage({id: 'badges.modal.field_type_placeholder', defaultMessage: 'Выберите тип достижения'});
const triggerLabel = showCreateType ? intl.formatMessage({id: 'badges.modal.create_new_type', defaultMessage: '+ Создать новый тип'}) : selectedTypeName;
const confirmType = confirmDeleteTypeId ? types.find((t) => String(t.id) === confirmDeleteTypeId) : null;
return (
<div className='type-select'>
<button
type='button'
className='type-select__trigger'
onClick={onToggleDropdown}
>
<span className='type-select__value'>{triggerLabel}</span>
<span className='type-select__arrow'>{'\u25BE'}</span>
</button>
{typeDropdownOpen && (
<div className='type-select__dropdown'>
{types.map((t) => {
const tid = String(t.id);
const isEmpty = t.badge_count === 0;
return (
<div
key={tid}
className={'type-select__option' + (tid === badgeType ? ' type-select__option--selected' : '')}
>
<span
className='type-select__option-name'
onClick={() => onSelect(tid)}
>
{t.name}
</span>
{isEmpty && !t.is_default && (
<button
type='button'
className='type-select__delete-btn'
onClick={() => onDeleteType(tid)}
title={intl.formatMessage({id: 'badges.modal.delete_type', defaultMessage: 'Удалить тип'})}
>
<TrashIcon/>
</button>
)}
</div>
);
})}
{canCreateType && (
<div
className='type-select__option type-select__option--create'
onClick={() => onSelect(NEW_TYPE_VALUE)}
>
<span className='type-select__option-name'>
{intl.formatMessage({id: 'badges.modal.create_new_type', defaultMessage: '+ Создать новый тип'})}
</span>
</div>
)}
</div>
)}
{confirmType && (
<ConfirmDialog
onConfirm={() => onDeleteType(String(confirmDeleteTypeId))}
onCancel={onCancelDeleteType}
>
<FormattedMessage
id='badges.modal.confirm_delete_type'
defaultMessage='Удалить тип «{name}»?'
values={{name: confirmType.name}}
/>
</ConfirmDialog>
)}
</div>
);
};
export default TypeSelect;

View File

@ -0,0 +1,60 @@
.ConfirmDialog {
background: var(--center-channel-bg, #fff);
border-radius: 8px;
padding: 20px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16);
min-width: 240px;
text-align: center;
&__overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 11;
border-radius: 4px;
}
&__text {
margin: 0 0 16px;
font-size: 14px;
color: var(--center-channel-color, #3d3c40);
}
&__actions {
display: flex;
justify-content: center;
gap: 8px;
.btn--cancel {
background: var(--center-channel-bg, #fff);
color: var(--center-channel-color, #3d3c40);
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
&:hover {
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
}
}
.btn--danger {
background: var(--error-text, #d24b4e);
color: #fff;
border: none;
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
&:hover {
background: color-mix(in srgb, var(--error-text, #d24b4e) 85%, #000);
}
}
}
}

View File

@ -0,0 +1,48 @@
import React from 'react';
import {FormattedMessage} from 'react-intl';
import './confirm_dialog.scss';
type Props = {
children: React.ReactNode;
onConfirm: () => void;
onCancel: () => void;
}
const ConfirmDialog: React.FC<Props> = ({children, onConfirm, onCancel}) => (
<div
className='ConfirmDialog__overlay'
onClick={(e) => e.stopPropagation()}
>
<div className='ConfirmDialog'>
<p className='ConfirmDialog__text'>
{children}
</p>
<div className='ConfirmDialog__actions'>
<button
type='button'
className='btn btn--cancel'
onClick={onCancel}
>
<FormattedMessage
id='badges.modal.btn_cancel'
defaultMessage='Отмена'
/>
</button>
<button
type='button'
className='btn btn--danger'
onClick={onConfirm}
>
<FormattedMessage
id='badges.modal.btn_confirm_delete'
defaultMessage='Да, удалить'
/>
</button>
</div>
</div>
</div>
);
export default ConfirmDialog;

View File

@ -1,6 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {memo} from 'react';
import {useSelector} from 'react-redux';
import {GlobalState} from 'mattermost-redux/types/store';
@ -14,16 +14,21 @@ interface ComponentProps {
emojiStyle?: React.CSSProperties;
}
const RenderEmoji = ({emojiName, emojiStyle, size}: ComponentProps) => {
const FALLBACK_EMOJI = 'question';
const RenderEmoji = ({emojiName, emojiStyle, size = 16}: ComponentProps) => {
const emojiMap = useSelector((state: GlobalState) => getEmojiMap(state));
if (!emojiName) {
return null;
}
const emojiFromMap = emojiMap.get(emojiName);
let emojiFromMap = emojiMap.get(emojiName);
if (!emojiFromMap) {
return null;
emojiFromMap = emojiMap.get(FALLBACK_EMOJI);
if (!emojiFromMap) {
return null;
}
}
const emojiImageUrl = getEmojiImageUrl(emojiFromMap);
@ -46,10 +51,4 @@ const RenderEmoji = ({emojiName, emojiStyle, size}: ComponentProps) => {
);
};
RenderEmoji.defaultProps = {
emoji: '',
emojiStyle: {},
size: 16,
};
export default React.memo(RenderEmoji);
export default memo(RenderEmoji);

View File

@ -0,0 +1,292 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {FormattedMessage, useIntl} from 'react-intl';
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/common';
import {GlobalState} from 'mattermost-redux/types/store';
import {Client4} from 'mattermost-redux/client';
import {closeGrantModal} from 'actions/actions';
import {getGrantModalData} from 'selectors';
import {AllBadgesBadge} from 'types/badges';
import Client from 'client/api';
import {getServerErrorId, getUserDisplayName} from 'utils/helpers';
import CloseIcon from 'components/icons/close_icon';
import RenderEmoji from 'components/emoji/emoji';
type GrantFormData = {
badgeId: string;
userId: string;
userDisplayName: string;
reason: string;
notifyHere: boolean;
}
const emptyForm: GrantFormData = {
badgeId: '',
userId: '',
userDisplayName: '',
reason: '',
notifyHere: false,
};
const GrantModal: React.FC = () => {
const dispatch = useDispatch();
const intl = useIntl();
const modalData = useSelector(getGrantModalData);
const channelId = useSelector((state: GlobalState) => getCurrentChannelId(state));
const isOpen = modalData !== null;
const hasFixedUser = Boolean(modalData?.prefillUser);
const [form, setForm] = useState<GrantFormData>(emptyForm);
const [badges, setBadges] = useState<AllBadgesBadge[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [closing, setClosing] = useState(false);
// Выбор достижения
const [badgeDropdownOpen, setBadgeDropdownOpen] = useState(false);
const badgeDropdownRef = useRef<HTMLDivElement>(null);
const updateForm = useCallback((updates: Partial<GrantFormData>) => {
setForm((prev) => ({...prev, ...updates}));
}, []);
useEffect(() => {
if (!isOpen) {
return;
}
// Всегда очищаем форму при открытии
setForm(emptyForm);
setError(null);
setLoading(false);
setBadgeDropdownOpen(false);
const fetchBadges = async () => {
const client = new Client();
const allBadges = await client.getAllBadges();
setBadges(allBadges);
};
fetchBadges();
// Prefill достижения, если передан
if (modalData?.prefillBadgeId) {
setForm((prev) => ({...prev, badgeId: modalData.prefillBadgeId || ''}));
}
// Prefill пользователя, если передан
if (modalData?.prefillUser) {
Client4.getUserByUsername(modalData.prefillUser).then((user) => {
setForm((prev) => ({
...prev,
userId: user.id,
userDisplayName: getUserDisplayName(user) || user.username,
}));
}).catch(() => {
// Если пользователь не найден — игнорируем
});
}
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
// Закрытие выпадающих списков при клике снаружи
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (badgeDropdownRef.current && !badgeDropdownRef.current.contains(e.target as Node)) {
setBadgeDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const doClose = useCallback(() => {
dispatch(closeGrantModal());
setClosing(false);
}, [dispatch]);
const handleClose = useCallback(() => {
setClosing(true);
setTimeout(doClose, 150);
}, [doClose]);
const handleBadgeSelect = (badgeId: string) => {
updateForm({badgeId});
setBadgeDropdownOpen(false);
};
const handleSubmit = useCallback(async () => {
setLoading(true);
setError(null);
try {
const client = new Client();
await client.grantBadge({
badge_id: form.badgeId,
user_id: form.userId,
reason: form.reason.trim(),
notify_here: form.notifyHere,
channel_id: channelId,
});
handleClose();
} catch (err) {
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
} finally {
setLoading(false);
}
}, [form, channelId, handleClose, intl]);
if (!isOpen && !closing) {
return null;
}
const selectedBadge = badges.find((b) => String(b.id) === form.badgeId);
return (
<div className={'BadgeModal' + (closing ? ' BadgeModal--closing' : '')}>
<div
className='BadgeModal__backdrop'
onClick={handleClose}
/>
<div className='BadgeModal__dialog'>
<div className='BadgeModal__header'>
<h4>
<FormattedMessage
id='badges.grant.title'
defaultMessage='Выдать достижение'
/>
</h4>
<button
className='close-btn'
onClick={handleClose}
>
<CloseIcon/>
</button>
</div>
<div className='BadgeModal__body'>
{hasFixedUser && form.userDisplayName && (
<p className='grant-intro'>
<FormattedMessage
id='badges.grant.intro'
defaultMessage='Выдать достижение пользователю @{username}'
values={{username: modalData?.prefillUser || ''}}
/>
</p>
)}
<div className='form-group'>
<label>
<FormattedMessage
id='badges.grant.field_badge'
defaultMessage='Достижение'
/>
<span className='required'>{'*'}</span>
</label>
<div
className='type-select'
ref={badgeDropdownRef}
>
<button
type='button'
className='type-select__trigger'
onClick={() => setBadgeDropdownOpen(!badgeDropdownOpen)}
>
<span className='type-select__value'>
{selectedBadge ? (
<>
<RenderEmoji
emojiName={selectedBadge.image}
size={16}
/>
{' '}{selectedBadge.name}
</>
) : intl.formatMessage({id: 'badges.grant.field_badge_placeholder', defaultMessage: 'Выберите достижение'})}
</span>
<span className='type-select__arrow'>{'▾'}</span>
</button>
{badgeDropdownOpen && (
<div className='type-select__dropdown'>
{badges.length === 0 && (
<div className='type-select__option'>
<FormattedMessage
id='badges.grant.no_badges'
defaultMessage='Нет доступных достижений'
/>
</div>
)}
{badges.map((badge) => (
<div
key={badge.id}
className={'type-select__option' + (String(badge.id) === form.badgeId ? ' type-select__option--selected' : '')}
onClick={() => handleBadgeSelect(String(badge.id))}
>
<span className='type-select__option-name'>
<RenderEmoji
emojiName={badge.image}
size={16}
/>
{' '}{badge.name}
</span>
<span style={{opacity: 0.56, fontSize: '12px'}}>{badge.type_name}</span>
</div>
))}
</div>
)}
</div>
</div>
<div className='form-group'>
<label>
<FormattedMessage
id='badges.grant.field_reason'
defaultMessage='Причина'
/>
</label>
<textarea
value={form.reason}
onChange={(e) => updateForm({reason: e.target.value})}
maxLength={200}
placeholder={intl.formatMessage({id: 'badges.grant.field_reason_placeholder', defaultMessage: 'За что выдаётся достижение? (необязательно)'})}
/>
</div>
<div className='checkbox-group'>
<input
type='checkbox'
id='grantNotifyHere'
checked={form.notifyHere}
onChange={(e) => updateForm({notifyHere: e.target.checked})}
/>
<label htmlFor='grantNotifyHere'>
<FormattedMessage
id='badges.grant.notify_here'
defaultMessage='Уведомить в канале'
/>
</label>
</div>
{error && <div className='error-message'>{error}</div>}
</div>
<div className='BadgeModal__footer'>
<button
className='btn btn--cancel'
onClick={handleClose}
>
<FormattedMessage
id='badges.modal.btn_cancel'
defaultMessage='Отмена'
/>
</button>
<button
className='btn btn--primary'
onClick={handleSubmit}
disabled={loading || !form.badgeId || !form.userId}
>
{loading
? intl.formatMessage({id: 'badges.modal.btn_creating', defaultMessage: 'Сохранение...'})
: intl.formatMessage({id: 'badges.grant.btn_grant', defaultMessage: 'Выдать'})
}
</button>
</div>
</div>
</div>
);
};
export default GrantModal;

View File

@ -0,0 +1,29 @@
import React from 'react';
type Props = {
size?: number;
}
const CloseIcon: React.FC<Props> = ({size = 16}) => (
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path
stroke='none'
d='M0 0h24v24H0z'
fill='none'
/>
<path d='M18 6l-12 12'/>
<path d='M6 6l12 12'/>
</svg>
);
export default CloseIcon;

View File

@ -0,0 +1,31 @@
import React from 'react';
type Props = {
size?: number;
}
const EmojiIcon: React.FC<Props> = ({size = 20}) => (
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path
stroke='none'
d='M0 0h24v24H0z'
fill='none'
/>
<path d='M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0'/>
<path d='M9 10l.01 0'/>
<path d='M15 10l.01 0'/>
<path d='M9.5 15a3.5 3.5 0 0 0 5 0'/>
</svg>
);
export default EmojiIcon;

View File

@ -0,0 +1,29 @@
import React from 'react';
type Props = {
size?: number;
}
const SearchIcon: React.FC<Props> = ({size = 18}) => (
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path
stroke='none'
d='M0 0h24v24H0z'
fill='none'
/>
<path d='M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0'/>
<path d='M21 21l-6 -6'/>
</svg>
);
export default SearchIcon;

View File

@ -0,0 +1,27 @@
import React from 'react';
type Props = {
size?: number;
}
const TrashIcon: React.FC<Props> = ({size = 16}) => (
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='M4 7l16 0'/>
<path d='M10 11l0 6'/>
<path d='M14 11l0 6'/>
<path d='M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12'/>
<path d='M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3'/>
</svg>
);
export default TrashIcon;

View File

@ -3,4 +3,140 @@
flex-flow: column;
height: 100%;
padding: 10px;
&--loading {
justify-content: center;
align-items: center;
.spinner {
width: 48px;
height: 48px;
}
}
&__loadingWrap {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
.spinner {
width: 48px;
height: 48px;
}
}
&__emptyContent {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 20px;
flex: 1;
}
&__emptyTitle {
font-size: 16px;
font-weight: 600;
color: var(--center-channel-color, #3d3c40);
margin-bottom: 8px;
}
&__emptyDescription {
font-size: 13px;
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.72);
margin-bottom: 16px;
line-height: 1.5;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
&__tabs {
display: flex;
gap: 0;
}
&__tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
padding: 4px 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
&:hover {
color: var(--center-channel-color, #3d3c40);
}
&--active {
color: var(--button-bg, #166de0);
border-bottom-color: var(--button-bg, #166de0);
}
}
&__empty {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
font-size: 14px;
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
}
&__backHeader {
display: flex;
flex-direction: column;
gap: 4px;
}
&__filterTitle {
font-size: 15px;
font-weight: 600;
color: var(--center-channel-color, #3d3c40);
}
&__content {
position: relative;
display: flex;
flex-flow: column;
flex: 1;
min-height: 0;
> .badge-tooltip-wrapper {
position: absolute;
bottom: 12px;
right: 14px;
z-index: 10;
}
}
&__fab {
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--button-bg, #166de0);
color: var(--button-color, #fff);
border: none;
font-size: 16px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
transition: transform 0.15s, box-shadow 0.15s;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
}
}
}

View File

@ -1,90 +1,154 @@
import React from 'react';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {systemEmojis} from 'mattermost-redux/actions/emojis';
import {FormattedMessage} from 'react-intl';
import {Virtuoso} from 'react-virtuoso';
import {useSelector} from 'react-redux';
import {EmojiIndicesByAlias} from 'utils/emoji';
import {BadgeID, AllBadgesBadge} from '../../types/badges';
import Client from '../../client/api';
import {RHSState} from '../../types/general';
import {IMAGE_TYPE_EMOJI, RHS_STATE_DETAIL} from '../../constants';
import {IMAGE_TYPE_EMOJI, RHS_STATE_DETAIL, RHS_STATE_TYPES} from '../../constants';
import {isCreateBadgeModalVisible, getEditBadgeModalData} from '../../selectors';
import BackButton from 'components/back_button/back_button';
import AllBadgesRow from './all_badges_row';
import RHSScrollbars from './rhs_scrollbars';
import './all_badges.scss';
type Props = {
filterTypeId?: number | null;
filterTypeName?: string | null;
actions: {
setRHSView: (view: RHSState) => void;
setRHSBadge: (badge: BadgeID | null) => void;
getCustomEmojisByName: (names: string[]) => void;
openCreateBadgeModal: () => void;
};
}
type State = {
loading: boolean;
badges?: AllBadgesBadge[];
}
const AllBadges: React.FC<Props> = ({filterTypeId, filterTypeName, actions}) => {
const [loading, setLoading] = useState(true);
const [badges, setBadges] = useState<AllBadgesBadge[]>([]);
const isFiltered = filterTypeId != null;
class AllBadges extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
const createBadgeVisible = useSelector(isCreateBadgeModalVisible);
const editBadgeData = useSelector(getEditBadgeModalData);
const isModalOpen = createBadgeVisible || editBadgeData !== null;
const wasModalOpen = useRef(false);
this.state = {
loading: true,
};
}
const fetchBadges = useCallback(() => {
const client = new Client();
client.getAllBadges().then((data) => {
setBadges(data);
setLoading(false);
componentDidMount() {
const c = new Client();
c.getAllBadges().then((badges) => {
this.setState({badges, loading: false});
});
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (this.state.badges !== prevState.badges) {
const names: string[] = [];
this.state.badges?.forEach((badge) => {
data.forEach((badge) => {
if (badge.image_type === IMAGE_TYPE_EMOJI) {
names.push(badge.image);
}
});
const toLoad = names.filter((v) => !systemEmojis.has(v));
this.props.actions.getCustomEmojisByName(toLoad);
}
}
onBadgeClick = (badge: AllBadgesBadge) => {
this.props.actions.setRHSBadge(badge.id);
this.props.actions.setRHSView(RHS_STATE_DETAIL);
}
render() {
if (this.state.loading) {
return (<div className='AllBadges'>{'Loading...'}</div>);
}
if (!this.state.badges || this.state.badges.length === 0) {
return (<div className='AllBadges'>{'No badges yet.'}</div>);
}
const content = this.state.badges.map((badge) => {
return (
<AllBadgesRow
key={badge.id}
badge={badge}
onClick={this.onBadgeClick}
/>
);
const toLoad = names.filter((v) => !EmojiIndicesByAlias.has(v));
actions.getCustomEmojisByName(toLoad);
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
fetchBadges();
}, [fetchBadges]);
useEffect(() => {
if (wasModalOpen.current && !isModalOpen) {
fetchBadges();
}
wasModalOpen.current = isModalOpen;
}, [isModalOpen, fetchBadges]);
const displayBadges = useMemo(() => {
if (!isFiltered) {
return badges;
}
return badges.filter((b) => b.type === filterTypeId);
}, [badges, isFiltered, filterTypeId]);
const onBadgeClick = useCallback((badge: AllBadgesBadge) => {
actions.setRHSBadge(badge.id);
actions.setRHSView(RHS_STATE_DETAIL);
}, [actions]);
if (loading) {
return (
<div className='AllBadges'>
<div><b>{'All badges'}</b></div>
<RHSScrollbars>{content}</RHSScrollbars>
<div className='AllBadges__loadingWrap'>
<div className='spinner'/>
</div>
);
}
}
const isEmpty = !isFiltered && (!badges || badges.length === 0);
return (
<>
{isFiltered && (
<div className='AllBadges__header'>
<div className='AllBadges__backHeader'>
<BackButton
targetView={RHS_STATE_TYPES}
onNavigate={actions.setRHSView}
>
<FormattedMessage
id='badges.rhs.back_to_types'
defaultMessage='Назад к типам'
/>
</BackButton>
<span className='AllBadges__filterTitle'>{filterTypeName}</span>
</div>
</div>
)}
{isEmpty && (
<div className='AllBadges__emptyContent'>
<div className='AllBadges__emptyTitle'>
<FormattedMessage
id='badges.empty.title'
defaultMessage='Достижений пока нет'
/>
</div>
<div className='AllBadges__emptyDescription'>
<FormattedMessage
id='badges.empty.description'
defaultMessage='Создайте первое достижение, чтобы отмечать заслуги участников команды.'
/>
</div>
</div>
)}
{!isEmpty && displayBadges.length === 0 && (
<div className='AllBadges__empty'>
<FormattedMessage
id='badges.types.no_badges'
defaultMessage='В этом типе нет достижений'
/>
</div>
)}
{!isEmpty && displayBadges.length > 0 && (
<Virtuoso
style={{flex: '1 1 auto'}}
data={displayBadges}
increaseViewportBy={300}
overscan={200}
itemContent={(_index, badge) => (
<AllBadgesRow
key={badge.id}
badge={badge}
onClick={onBadgeClick}
/>
)}
/>
)}
</>
);
};
export default AllBadges;

View File

@ -1,25 +1,59 @@
.AllBadgesRow {
display: flex;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
align-items: center;
padding: 5px;
margin-bottom: 3px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 6px;
padding: 12px 16px;
margin-bottom: 10px;
cursor: pointer;
gap: 12px;
transition: background 0.15s;
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.04);
}
.badge-icon {
padding: 10px;
flex-shrink: 0;
}
.badge-text {
flex: 1;
min-width: 0;
}
.badge-name {
font-size: 14px;
font-weight: 600;
color: var(--center-channel-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.granted-by {
font-size: 10px;
}
.badge-type {
font-size: 10px;
}
.badge-descrition {
.badge-description {
font-size: 13px;
color: rgba(var(--center-channel-color-rgb), 0.72);
margin-top: 2px;
word-break: break-word;
p {
margin: 0px
margin: 0;
}
}
.badge-label {
font-weight: 400;
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 12px;
}
.badge-meta {
font-size: 12px;
color: rgba(var(--center-channel-color-rgb), 0.64);
margin-top: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}

View File

@ -1,7 +1,9 @@
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {AllBadgesBadge} from '../../types/badges';
import BadgeImage from '../utils/badge_image';
import BadgeImage from '../badge_image/badge_image';
import {markdown} from 'utils/markdown';
import './all_badges_row.scss';
@ -11,36 +13,76 @@ type Props = {
onClick: (badge: AllBadgesBadge) => void;
}
function getGrantedText(badge: AllBadgesBadge): string {
function getGrantedText(badge: AllBadgesBadge): React.ReactNode {
if (badge.granted === 0) {
return 'Not yet granted.';
return (
<FormattedMessage
id='badges.granted.not_yet'
defaultMessage='Ещё не выдан.'
/>
);
}
if (badge.multiple) {
return `Granted ${badge.granted_times} to ${badge.granted} users.`;
return (
<FormattedMessage
id='badges.granted.multiple'
defaultMessage='Выдан {times, plural, one {# раз} few {# раза} many {# раз} other {# раз}} {users, plural, one {# пользователю} few {# пользователям} many {# пользователям} other {# пользователям}}.'
values={{times: badge.granted_times, users: badge.granted}}
/>
);
}
return `Granted to ${badge.granted} users.`;
return (
<FormattedMessage
id='badges.granted.single'
defaultMessage='Выдан {users, plural, one {# пользователю} few {# пользователям} many {# пользователям} other {# пользователям}}.'
values={{users: badge.granted}}
/>
);
}
const AllBadgesRow: React.FC<Props> = ({badge, onClick}: Props) => {
return (
<div className='AllBadgesRow'>
<a
className='badge-icon'
onClick={() => onClick(badge)}
>
<span>
<BadgeImage
badge={badge}
size={32}
<div
className='AllBadgesRow'
onClick={() => onClick(badge)}
>
<span className='badge-icon'>
<BadgeImage
badge={badge}
size={36}
/>
</span>
<div className='badge-text'>
<div className='badge-name'>
<span className='badge-label'>
<FormattedMessage
id='badges.label.name'
defaultMessage='Название:'
/>
</span>
{' '}
{badge.name}
</div>
<div className='badge-description'>
<span className='badge-label'>
<FormattedMessage
id='badges.label.description'
defaultMessage='Описание:'
/>
</span>
{' '}
{badge.description ? markdown(badge.description) : '-'}
</div>
<div className='badge-meta'>
<FormattedMessage
id='badges.label.type'
defaultMessage='Тип: {typeName}'
values={{typeName: badge.type_name}}
/>
</span>
</a>
<div>
<div className='badge-name'>{badge.name}</div>
<div className='badge-description'>{markdown(badge.description)}</div>
<div className='badge-type'>{'Type: ' + badge.type_name}</div>
<div className='granted-by'>{getGrantedText(badge)}</div>
{' · '}
{getGrantedText(badge)}
</div>
</div>
</div>
);

View File

@ -0,0 +1,73 @@
.AllTypes {
display: flex;
flex-flow: column;
height: 100%;
padding: 10px;
&--loading {
justify-content: center;
align-items: center;
.spinner {
width: 48px;
height: 48px;
}
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
&__tabs {
display: flex;
gap: 0;
}
&__tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
padding: 4px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
&:hover {
color: var(--center-channel-color, #3d3c40);
}
&--active {
color: var(--button-bg, #166de0);
border-bottom-color: var(--button-bg, #166de0);
}
}
&__createButton {
background: var(--button-bg, #166de0);
color: var(--button-color, #fff);
border: none;
border-radius: 4px;
padding: 4px 12px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
margin-bottom: 7px;
&:hover {
opacity: 0.88;
}
}
&__empty {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
font-size: 14px;
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
}
}

View File

@ -0,0 +1,99 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {FormattedMessage} from 'react-intl';
import {Virtuoso} from 'react-virtuoso';
import {BadgeTypeDefinition} from '../../types/badges';
import Client from '../../client/api';
import {RHS_STATE_TYPE_BADGES} from '../../constants';
import {isCreateTypeModalVisible, getEditTypeModalData} from '../../selectors';
import {setRHSView, setRHSType, openEditTypeModal} from '../../actions/actions';
import AllTypesRow from './all_types_row';
import './all_types.scss';
const AllTypes: React.FC = () => {
const dispatch = useDispatch();
const [loading, setLoading] = useState(true);
const [types, setTypes] = useState<BadgeTypeDefinition[]>([]);
const createTypeVisible = useSelector(isCreateTypeModalVisible);
const editTypeData = useSelector(getEditTypeModalData);
const isModalOpen = createTypeVisible || editTypeData !== null;
const wasModalOpen = useRef(false);
const fetchTypes = useCallback(async () => {
const client = new Client();
const resp = await client.getTypes();
setTypes(resp.types);
setLoading(false);
}, []);
useEffect(() => {
fetchTypes();
}, [fetchTypes]);
// Refetch types when type modal closes (after save/delete)
useEffect(() => {
if (wasModalOpen.current && !isModalOpen) {
fetchTypes();
}
wasModalOpen.current = isModalOpen;
}, [isModalOpen, fetchTypes]);
const handleEdit = useCallback((badgeType: BadgeTypeDefinition) => {
dispatch(openEditTypeModal(badgeType));
}, [dispatch]);
const handleDelete = useCallback(async (badgeType: BadgeTypeDefinition) => {
const client = new Client();
await client.deleteType(String(badgeType.id));
setTypes((prev) => prev.filter((t) => t.id !== badgeType.id));
}, []);
const handleClick = useCallback((badgeType: BadgeTypeDefinition) => {
dispatch(setRHSType(badgeType.id, badgeType.name));
dispatch(setRHSView(RHS_STATE_TYPE_BADGES));
}, [dispatch]);
if (loading) {
return (
<div className='AllTypes AllTypes--loading'>
<div className='spinner'/>
</div>
);
}
if (types.length === 0) {
return (
<div className='AllTypes__empty'>
<FormattedMessage
id='badges.types.empty'
defaultMessage='Типов пока нет'
/>
</div>
);
}
return (
<Virtuoso
style={{flex: '1 1 auto'}}
data={types}
increaseViewportBy={300}
overscan={200}
itemContent={(_index, t) => (
<AllTypesRow
key={t.id}
badgeType={t}
onClick={handleClick}
onEdit={handleEdit}
onDelete={handleDelete}
/>
)}
/>
);
};
export default AllTypes;

View File

@ -0,0 +1,93 @@
.AllTypesRow {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
border-radius: 4px;
cursor: default;
&:hover {
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
}
&__info {
flex: 1;
min-width: 0;
}
&__name {
font-size: 14px;
font-weight: 600;
color: var(--center-channel-color, #3d3c40);
display: flex;
align-items: center;
gap: 6px;
}
&__default {
font-size: 10px;
font-weight: 600;
color: var(--button-bg, #166de0);
background: rgba(var(--button-bg-rgb, 22, 109, 224), 0.08);
padding: 1px 6px;
border-radius: 10px;
}
&__meta {
font-size: 12px;
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
margin-top: 2px;
}
&__actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
&__btn {
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
font-size: 12px;
font-weight: 600;
border-radius: 4px;
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
&:hover {
color: var(--center-channel-color, #3d3c40);
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
}
&--edit:hover {
color: var(--button-bg, #166de0);
}
&--danger {
color: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.72);
&:hover {
color: var(--error-text, #d24b4e);
background: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.08);
}
}
&--cancel:hover {
color: var(--center-channel-color, #3d3c40);
}
}
&__confirmDelete {
display: flex;
align-items: center;
gap: 4px;
}
&__confirmText {
font-size: 12px;
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
white-space: nowrap;
}
}

View File

@ -0,0 +1,118 @@
import React, {useState} from 'react';
import {FormattedMessage} from 'react-intl';
import {BadgeTypeDefinition} from '../../types/badges';
import ConfirmDialog from '../confirm_dialog/confirm_dialog';
import './all_types_row.scss';
type Props = {
badgeType: BadgeTypeDefinition;
onEdit: (badgeType: BadgeTypeDefinition) => void;
onDelete: (badgeType: BadgeTypeDefinition) => void;
onClick: (badgeType: BadgeTypeDefinition) => void;
}
const AllTypesRow: React.FC<Props> = ({badgeType, onEdit, onDelete, onClick}: Props) => {
const [confirmDelete, setConfirmDelete] = useState(false);
const handleDelete = () => {
if (!confirmDelete) {
setConfirmDelete(true);
return;
}
onDelete(badgeType);
};
return (
<div
className='AllTypesRow'
onClick={() => onClick(badgeType)}
>
<div className='AllTypesRow__info'>
<div className='AllTypesRow__name'>
{badgeType.name}
{badgeType.is_default && (
<span className='AllTypesRow__default'>
<FormattedMessage
id='badges.types.is_default'
defaultMessage='По умолчанию'
/>
</span>
)}
</div>
<div className='AllTypesRow__meta'>
<FormattedMessage
id='badges.label.created_by'
defaultMessage='Создал: {username}'
values={{username: badgeType.created_by_username || badgeType.created_by}}
/>
{' · '}
<FormattedMessage
id='badges.types.badge_count'
defaultMessage='{count, plural, one {# достижение} few {# достижения} many {# достижений} other {# достижений}}'
values={{count: badgeType.badge_count}}
/>
{badgeType.can_create?.everyone && (
<>
{' · '}
<FormattedMessage
id='badges.types.everyone_can_create'
defaultMessage='Все создают'
/>
</>
)}
{badgeType.can_grant?.everyone && (
<>
{' · '}
<FormattedMessage
id='badges.types.everyone_can_grant'
defaultMessage='Все выдают'
/>
</>
)}
</div>
</div>
<div
className='AllTypesRow__actions'
onClick={(e) => e.stopPropagation()}
>
<button
className='AllTypesRow__btn AllTypesRow__btn--edit'
onClick={() => onEdit(badgeType)}
>
<FormattedMessage
id='badges.rhs.edit_badge'
defaultMessage='Редактировать'
/>
</button>
{!badgeType.is_default && (
<button
className='AllTypesRow__btn AllTypesRow__btn--danger'
onClick={handleDelete}
>
<FormattedMessage
id='badges.modal.delete_type'
defaultMessage='Удалить'
/>
</button>
)}
{confirmDelete && (
<ConfirmDialog
onConfirm={() => onDelete(badgeType)}
onCancel={() => setConfirmDelete(false)}
>
<FormattedMessage
id='badges.modal.confirm_delete_type'
defaultMessage='Удалить тип «{name}»?'
values={{name: badgeType.name}}
/>
</ConfirmDialog>
)}
</div>
</div>
);
};
export default AllTypesRow;

View File

@ -3,26 +3,101 @@
flex-flow: column;
height: 100%;
padding: 10px;
.badge-info {
display: flex;
&--loading {
justify-content: center;
align-items: center;
padding: 5px;
.badge-icon {
padding: 10px;
}
.badge-name {
font-weight: 600;
}
.created-by {
font-size: 10px;
}
.badge-descrition {
p {
margin: 0px
}
}
.badge-type {
font-size: 10px;
.spinner {
width: 48px;
height: 48px;
}
}
.badge-info {
display: flex;
align-items: flex-start;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 6px;
padding: 16px;
margin-bottom: 16px;
gap: 12px;
position: relative;
.badge-icon {
flex-shrink: 0;
}
.badge-text {
flex: 1;
min-width: 0;
padding-right: 100px;
}
.badge-label {
font-weight: 400;
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 12px;
}
.badge-name {
font-size: 16px;
font-weight: 600;
padding-right: 7px;
color: var(--center-channel-text);
}
.badge-description {
font-size: 14px;
color: rgba(var(--center-channel-color-rgb), 0.72);
margin-top: 4px;
word-break: break-word;
p {
margin: 0;
}
}
.badge-meta {
font-size: 12px;
color: rgba(var(--center-channel-color-rgb), 0.64);
margin-top: 8px;
}
}
&__backHeader {
padding: 4px;
}
&__editButton {
position: absolute;
top: 12px;
right: 12px;
background: none;
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
border-radius: 4px;
padding: 4px 12px;
font-size: 12px;
font-weight: 600;
color: var(--button-bg, #166de0);
cursor: pointer;
&:hover {
background: rgba(var(--button-bg-rgb, 22, 109, 224), 0.08);
}
}
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--center-channel-text);
margin-bottom: 8px;
}
.empty-owners {
font-size: 13px;
color: rgba(var(--center-channel-color-rgb), 0.64);
padding: 16px 0;
text-align: center;
}
}

View File

@ -1,16 +1,20 @@
import React from 'react';
import {systemEmojis} from 'mattermost-redux/actions/emojis';
import {FormattedMessage} from 'react-intl';
import {EmojiIndicesByAlias} from 'utils/emoji';
import {BadgeDetails, BadgeID} from '../../types/badges';
import Client from '../../client/api';
import {RHSState} from '../../types/general';
import {RHS_STATE_MY, RHS_STATE_OTHER} from '../../constants';
import BadgeImage from '../utils/badge_image';
import {IMAGE_TYPE_EMOJI, RHS_STATE_MY, RHS_STATE_OTHER} from '../../constants';
import BadgeImage from '../badge_image/badge_image';
import {markdown} from 'utils/markdown';
import BackButton from '../../components/back_button/back_button';
import RHSScrollbars from './rhs_scrollbars';
import UserRow from './user_row';
@ -19,10 +23,12 @@ import './badge_details.scss';
type Props = {
badgeID: BadgeID | null;
currentUserID: string;
prevView: RHSState;
actions: {
setRHSView: (view: RHSState) => void;
setRHSUser: (user: string | null) => void;
getCustomEmojiByName: (names: string) => void;
openEditBadgeModal: (badge: BadgeDetails) => void;
};
}
@ -52,8 +58,8 @@ class BadgeDetailsComponent extends React.PureComponent<Props, State> {
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (this.state.badge !== prevState.badge && this.state.badge && !systemEmojis.has(this.state.badge.name)) {
this.props.actions.getCustomEmojiByName(this.state.badge.name);
if (this.state.badge !== prevState.badge && this.state.badge && this.state.badge.image_type === IMAGE_TYPE_EMOJI && !EmojiIndicesByAlias.has(this.state.badge.image)) {
this.props.actions.getCustomEmojiByName(this.state.badge.image);
}
if (this.props.badgeID === prevProps.badgeID) {
@ -87,15 +93,27 @@ class BadgeDetailsComponent extends React.PureComponent<Props, State> {
render() {
const {badge, loading} = this.state;
if (this.props.badgeID == null) {
return (<div>{'Badge not found.'}</div>);
return (<div>
<FormattedMessage
id='badges.badge_not_found'
defaultMessage='Достижение не найдено.'
/>
</div>);
}
if (loading) {
return (<div>{'Loading...'}</div>);
return (<div className='BadgeDetails BadgeDetails--loading'>
<div className='spinner'/>
</div>);
}
if (!badge) {
return (<div>{'Badge not found.'}</div>);
return (<div>
<FormattedMessage
id='badges.badge_not_found'
defaultMessage='Достижение не найдено.'
/>
</div>);
}
const content = badge.owners.map((ownership) => {
@ -109,23 +127,89 @@ class BadgeDetailsComponent extends React.PureComponent<Props, State> {
});
return (
<div className='BadgeDetails'>
<div><b>{'Badge Details'}</b></div>
<div className='BadgeDetails__backHeader'>
<BackButton
targetView={this.props.prevView}
onNavigate={this.props.actions.setRHSView}
>
<FormattedMessage
id='badges.rhs.back_to_achievements'
defaultMessage='Назад к достижениям'
/>
</BackButton>
</div>
<div className='badge-info'>
<span className='badge-icon'>
<BadgeImage
badge={badge}
size={32}
size={48}
/>
</span>
<div className='badge-text'>
<div className='badge-name'>{badge.name}</div>
<div className='badge-description'>{markdown(badge.description)}</div>
<div className='badge-type'>{'Type: ' + badge.type_name}</div>
<div className='created-by'>{`Created by: ${badge.created_by_username}`}</div>
<div className='badge-name'>
<span className='badge-label'>
<FormattedMessage
id='badges.label.name'
defaultMessage='Название:'
/>
</span>
{' '}
{badge.name}
</div>
<div className='badge-description'>
<span className='badge-label'>
<FormattedMessage
id='badges.label.description'
defaultMessage='Описание:'
/>
</span>
{' '}
{badge.description ? markdown(badge.description) : '—'}
</div>
<div className='badge-meta'>
<FormattedMessage
id='badges.label.type'
defaultMessage='Тип: {typeName}'
values={{typeName: badge.type_name}}
/>
{' · '}
<FormattedMessage
id='badges.label.created_by'
defaultMessage='Создал: {username}'
values={{username: badge.created_by_username}}
/>
</div>
</div>
{badge.can_edit && (
<button
className='BadgeDetails__editButton'
onClick={() => this.props.actions.openEditBadgeModal(badge)}
>
<FormattedMessage
id='badges.rhs.edit_badge'
defaultMessage='Редактировать'
/>
</button>
)}
</div>
<div><b>{'Granted to:'}</b></div>
<RHSScrollbars>{content}</RHSScrollbars>
{badge.owners.length > 0 ? (
<>
<div className='section-title'>
<FormattedMessage
id='badges.granted_to'
defaultMessage='Выдан:'
/>
</div>
<RHSScrollbars>{content}</RHSScrollbars>
</>
) : (
<div className='empty-owners'>
<FormattedMessage
id='badges.not_granted_yet'
defaultMessage='Ещё никому не выдан'
/>
</div>
)}
</div>
);
}

View File

@ -5,7 +5,7 @@ import {useDispatch, useSelector} from 'react-redux';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
import React from 'react';
import React, {useCallback, useEffect, useState} from 'react';
import {getUser} from 'mattermost-redux/selectors/entities/users';
@ -13,73 +13,229 @@ import {GlobalState} from 'mattermost-redux/types/store';
import {getCustomEmojiByName, getCustomEmojisByName} from 'mattermost-redux/actions/emojis';
import {getRHSBadge, getRHSUser, getRHSView} from 'selectors';
import {RHS_STATE_ALL, RHS_STATE_DETAIL, RHS_STATE_OTHER, RHS_STATE_MY} from '../../constants';
import {FormattedMessage} from 'react-intl';
import {getRHSBadge, getRHSUser, getRHSView, getPrevRHSView, getRHSTypeId, getRHSTypeName} from 'selectors';
import {RHS_STATE_ALL, RHS_STATE_DETAIL, RHS_STATE_OTHER, RHS_STATE_MY, RHS_STATE_TYPES, RHS_STATE_TYPE_BADGES} from '../../constants';
import {RHSState} from 'types/general';
import {setRHSBadge, setRHSUser, setRHSView} from 'actions/actions';
import {BadgeID} from 'types/badges';
import {openCreateBadgeModal, openCreateTypeModal, openEditBadgeModal, setRHSBadge, setRHSUser, setRHSView} from 'actions/actions';
import {BadgeDetails, BadgeID} from 'types/badges';
import Client from '../../client/api';
import TooltipWrapper from '../user_popover/tooltip_wrapper';
import UserBadges from './user_badges';
import BadgeDetailsComponent from './badge_details';
import AllBadges from './all_badges';
import AllTypes from './all_types';
import './all_badges.scss';
const RHS: React.FC = () => {
const dispatch = useDispatch();
const currentView = useSelector(getRHSView);
const prevView = useSelector(getPrevRHSView);
const currentBadge = useSelector(getRHSBadge);
const currentUserID = useSelector(getRHSUser);
const filterTypeId = useSelector(getRHSTypeId);
const filterTypeName = useSelector(getRHSTypeName);
const currentUser = useSelector((state: GlobalState) => getUser(state, (currentUserID as string)));
const myUser = useSelector(getCurrentUser);
switch (currentView) {
case RHS_STATE_ALL:
const [canEditType, setCanEditType] = useState(false);
const [canCreateType, setCanCreateType] = useState(false);
const [canCreateBadge, setCanCreateBadge] = useState(false);
useEffect(() => {
const client = new Client();
client.getTypes().then((resp) => {
setCanEditType(resp.can_edit_type);
setCanCreateType(resp.can_create_type);
setCanCreateBadge(resp.types.length > 0 || resp.can_create_type);
});
}, []);
const showTabs = currentView === RHS_STATE_MY || currentView === RHS_STATE_ALL || currentView === RHS_STATE_TYPES;
const handleCreateBadge = useCallback(() => {
dispatch(openCreateBadgeModal());
}, [dispatch]);
const handleCreateType = useCallback(() => {
dispatch(openCreateTypeModal());
}, [dispatch]);
const renderTabs = () => {
if (!showTabs) {
return null;
}
return (
<AllBadges
actions={{
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
}}
/>
<div className='AllBadges__header'>
<div className='AllBadges__tabs'>
<button
className={'AllBadges__tab' + (currentView === RHS_STATE_MY ? ' AllBadges__tab--active' : '')}
onClick={() => dispatch(setRHSView(RHS_STATE_MY))}
>
<FormattedMessage
id='badges.rhs.my_badges'
defaultMessage='Мои'
/>
</button>
<button
className={'AllBadges__tab' + (currentView === RHS_STATE_ALL ? ' AllBadges__tab--active' : '')}
onClick={() => dispatch(setRHSView(RHS_STATE_ALL))}
>
<FormattedMessage
id='badges.rhs.all_badges'
defaultMessage='Все достижения'
/>
</button>
{canEditType && (
<button
className={'AllBadges__tab' + (currentView === RHS_STATE_TYPES ? ' AllBadges__tab--active' : '')}
onClick={() => dispatch(setRHSView(RHS_STATE_TYPES))}
>
<FormattedMessage
id='badges.rhs.types'
defaultMessage='Типы'
/>
</button>
)}
</div>
</div>
);
case RHS_STATE_DETAIL:
};
const renderFab = () => {
if (currentView === RHS_STATE_ALL && canCreateBadge) {
return (
<TooltipWrapper
tooltipContent={
<FormattedMessage
id='badges.rhs.create_badge'
defaultMessage='Создать достижение'
/>
}
>
<button
className='AllBadges__fab'
onClick={handleCreateBadge}
>
{'+'}
</button>
</TooltipWrapper>
);
}
if (currentView === RHS_STATE_TYPES && canCreateType) {
return (
<TooltipWrapper
tooltipContent={
<FormattedMessage
id='badges.rhs.create_type'
defaultMessage='Создать тип'
/>
}
>
<button
className='AllBadges__fab'
onClick={handleCreateType}
>
{'+'}
</button>
</TooltipWrapper>
);
}
return null;
};
const renderContent = () => {
switch (currentView) {
case RHS_STATE_TYPES:
return <AllTypes/>;
case RHS_STATE_TYPE_BADGES:
return (
<AllBadges
filterTypeId={filterTypeId}
filterTypeName={filterTypeName}
actions={{
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
openCreateBadgeModal: () => dispatch(openCreateBadgeModal()),
}}
/>
);
case RHS_STATE_ALL:
return (
<AllBadges
actions={{
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
openCreateBadgeModal: () => dispatch(openCreateBadgeModal()),
}}
/>
);
case RHS_STATE_DETAIL:
return (
<BadgeDetailsComponent
badgeID={currentBadge}
currentUserID={myUser.id}
prevView={prevView}
actions={{
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
setRHSUser: (user: string | null) => dispatch(setRHSUser(user)),
getCustomEmojiByName: (names: string) => dispatch(getCustomEmojiByName(names)),
openEditBadgeModal: (badge: BadgeDetails) => dispatch(openEditBadgeModal(badge)),
}}
/>
);
case RHS_STATE_OTHER:
return (
<UserBadges
user={currentUser}
isCurrentUser={false}
currentUserID={myUser.id}
actions={{
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
}}
/>
);
case RHS_STATE_MY:
default:
return (
<UserBadges
user={myUser}
isCurrentUser={true}
currentUserID={myUser.id}
actions={{
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
}}
/>
);
}
};
const needsWrapper = showTabs || currentView === RHS_STATE_TYPE_BADGES;
if (needsWrapper) {
return (
<BadgeDetailsComponent
badgeID={currentBadge}
currentUserID={myUser.id}
actions={{
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
setRHSUser: (user: string | null) => dispatch(setRHSUser(user)),
getCustomEmojiByName: (names: string) => dispatch(getCustomEmojiByName(names)),
}}
/>
);
case RHS_STATE_OTHER:
return (
<UserBadges
user={currentUser}
isCurrentUser={false}
actions={{
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
}}
/>
);
case RHS_STATE_MY:
default:
return (
<UserBadges
user={myUser}
isCurrentUser={true}
actions={{
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
}}
/>
<div className='AllBadges'>
{renderTabs()}
<div className='AllBadges__content'>
{renderContent()}
{renderFab()}
</div>
</div>
);
}
return renderContent();
};
export default RHS;

View File

@ -26,7 +26,7 @@ function renderThumbVertical(props: any) {
/>);
}
const RHSScrollbars = ({children}: {children: React.ReactNode[]}) => {
const RHSScrollbars = ({children}: {children: React.ReactNode}) => {
return (
<Scrollbars
autoHide={true}

View File

@ -1,28 +1,110 @@
.UserBadgesRow {
display: flex;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
align-items: center;
padding: 5px;
margin-bottom: 3px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 6px;
padding: 12px 16px;
margin-bottom: 10px;
cursor: pointer;
gap: 12px;
transition: background 0.15s;
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.04);
}
.user-badge-icon {
padding: 10px;
flex-shrink: 0;
}
.user-badge-text {
flex: 1;
min-width: 0;
}
.user-badge-name {
font-size: 14px;
font-weight: 600;
color: var(--center-channel-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-badge-granted-by {
font-size: 10px;
}
.user-badge-granted-at {
font-size: 10px;
}
.user-badge-descrition {
.user-badge-description {
font-size: 13px;
color: rgba(var(--center-channel-color-rgb), 0.72);
margin-top: 2px;
word-break: break-word;
p {
margin: 0px
margin: 0;
}
}
.user-badge-type {
font-size: 10px;
.user-badge-label {
font-weight: 400;
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 12px;
}
.user-badge-meta {
font-size: 12px;
color: rgba(var(--center-channel-color-rgb), 0.64);
margin-top: 2px;
}
.user-badge-reason {
font-size: 12px;
color: rgba(var(--center-channel-color-rgb), 0.64);
margin-top: 2px;
}
.user-badge-set-status {
margin-top: 4px;
a {
font-size: 12px;
color: var(--button-bg, #166de0);
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
.user-badge-revoke {
margin-top: 4px;
a {
font-size: 12px;
color: var(--error-text, #d24b4e);
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
&--confirm {
display: flex;
align-items: center;
gap: 8px;
}
&__text {
font-size: 12px;
color: var(--error-text, #d24b4e);
font-weight: 600;
}
&__yes {
font-weight: 600;
}
&__no {
color: rgba(var(--center-channel-color-rgb), 0.56) !important;
}
}
}

View File

@ -1,58 +1,171 @@
import React from 'react';
import React, {useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import Client4 from 'mattermost-redux/client/client4';
import {UserBadge} from '../../types/badges';
import BadgeImage from '../utils/badge_image';
import BadgeImage from '../badge_image/badge_image';
import {markdown} from 'utils/markdown';
import Client from '../../client/api';
import ConfirmDialog from '../confirm_dialog/confirm_dialog';
import './user_badge_row.scss';
type Props = {
badge: UserBadge;
isCurrentUser: boolean;
currentUserID: string;
onClick: (badge: UserBadge) => void;
onRevoke?: (badge: UserBadge) => void;
}
const UserBadgeRow: React.FC<Props> = ({badge, onClick, isCurrentUser}: Props) => {
const UserBadgeRow: React.FC<Props> = ({badge, onClick, isCurrentUser, currentUserID, onRevoke}: Props) => {
const intl = useIntl();
const time = new Date(badge.time);
const [confirmingRevoke, setConfirmingRevoke] = useState(false);
const canRevoke = badge.granted_by === currentUserID;
const handleRevoke = async () => {
try {
const client = new Client();
await client.revokeOwnership({
badge_id: String(badge.id),
user_id: badge.user,
time: String(badge.time),
});
onRevoke?.(badge);
} catch {
// ignore
} finally {
setConfirmingRevoke(false);
}
};
let reason = null;
if (badge.reason) {
reason = (<div className='badge-user-reason'>{'Why? ' + badge.reason}</div>);
reason = (
<div className='user-badge-reason'>
<FormattedMessage
id='badges.label.reason'
defaultMessage='Причина: {reason}'
values={{reason: badge.reason}}
/>
</div>
);
}
let setStatus = null;
if (isCurrentUser && badge.image_type === 'emoji') {
setStatus = (
<div className='user-badge-set-status'>
<a
onClick={() => {
onClick={(e) => {
e.stopPropagation();
const c = new Client4();
c.updateCustomStatus({emoji: badge.image, text: badge.name});
}}
>
{'Set status to this badge'}
<FormattedMessage
id='badges.set_status'
defaultMessage='Установить как статус'
/>
</a>
</div>
);
}
return (
<div className='UserBadgesRow'>
<a onClick={() => onClick(badge)}>
<span className='user-badge-icon'>
<BadgeImage
badge={badge}
size={32}
let revokeAction = null;
if (canRevoke && onRevoke) {
revokeAction = (
<div className='user-badge-revoke'>
<a
onClick={(e) => {
e.stopPropagation();
setConfirmingRevoke(true);
}}
>
<FormattedMessage
id='badges.revoke.btn'
defaultMessage='Снять достижение'
/>
</span>
</a>
</a>
</div>
);
if (confirmingRevoke) {
revokeAction = (
<>
{revokeAction}
<ConfirmDialog
onConfirm={handleRevoke}
onCancel={() => setConfirmingRevoke(false)}
>
<FormattedMessage
id='badges.revoke.confirm'
defaultMessage='Снять достижение?'
/>
</ConfirmDialog>
</>
);
}
}
return (
<div
className='UserBadgesRow'
onClick={() => onClick(badge)}
>
<span className='user-badge-icon'>
<BadgeImage
badge={badge}
size={36}
/>
</span>
<div className='user-badge-text'>
<div className='user-badge-name'>{badge.name}</div>
<div className='user-badge-description'>{markdown(badge.description)}</div>
<div className='user-badge-name'>
<span className='user-badge-label'>
<FormattedMessage
id='badges.label.name'
defaultMessage='Название:'
/>
</span>
{' '}
{badge.name}
</div>
<div className='user-badge-description'>
<span className='user-badge-label'>
<FormattedMessage
id='badges.label.description'
defaultMessage='Описание:'
/>
</span>
{' '}
{badge.description ? markdown(badge.description) : '—'}
</div>
<div className='user-badge-meta'>
<FormattedMessage
id='badges.label.type'
defaultMessage='Тип: {typeName}'
values={{typeName: badge.type_name}}
/>
</div>
<div className='user-badge-meta'>
<FormattedMessage
id='badges.label.granted_by'
defaultMessage='Выдал: {username}'
values={{username: badge.granted_by_name}}
/>
</div>
<div className='user-badge-meta'>
<FormattedMessage
id='badges.label.granted_at'
defaultMessage='Выдан: {date}'
values={{date: intl.formatDate(time, {day: '2-digit', month: '2-digit', year: 'numeric'})}}
/>
</div>
{reason}
<div className='user-badge-type'>{'Type: ' + badge.type_name}</div>
<div className='user-badge-granted-by'>{`Granted by: ${badge.granted_by_name}`}</div>
<div className='user-badge-granted-at'>{`Granted at: ${time.toDateString()}`}</div>
{setStatus}
{revokeAction}
</div>
</div>
);

View File

@ -3,4 +3,20 @@
flex-flow: column;
height: 100%;
padding: 10px;
&--loading {
justify-content: center;
align-items: center;
.spinner {
width: 48px;
height: 48px;
}
}
&__title {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
}
}

View File

@ -1,7 +1,10 @@
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {UserProfile} from 'mattermost-redux/types/users';
import {systemEmojis} from 'mattermost-redux/actions/emojis';
import {EmojiIndicesByAlias} from 'utils/emoji';
import {BadgeID, UserBadge} from '../../types/badges';
import Client from '../../client/api';
@ -16,6 +19,7 @@ import './user_badges.scss';
type Props = {
isCurrentUser: boolean;
currentUserID: string;
user: UserProfile | null;
actions: {
setRHSView: (view: RHSState) => void;
@ -56,7 +60,7 @@ class UserBadges extends React.PureComponent<Props, State> {
names.push(badge.image);
}
});
const toLoad = names.filter((v) => !systemEmojis.has(v));
const toLoad = names.filter((v) => !EmojiIndicesByAlias.has(v));
this.props.actions.getCustomEmojisByName(toLoad);
}
if (this.props.user?.id === prevProps.user?.id) {
@ -82,37 +86,70 @@ class UserBadges extends React.PureComponent<Props, State> {
this.props.actions.setRHSView(RHS_STATE_DETAIL);
}
onRevoke = () => {
if (!this.props.user) {
return;
}
const c = new Client();
this.setState({loading: true});
c.getUserBadges(this.props.user.id).then((badges) => {
this.setState({badges, loading: false});
});
}
render() {
if (!this.props.user) {
return (<div>{'User not found.'}</div>);
return (<div>
<FormattedMessage
id='badges.user_not_found'
defaultMessage='Пользователь не найден.'
/>
</div>);
}
if (this.state.loading) {
return (<div>{'Loading...'}</div>);
return (<div className='UserBadges UserBadges--loading'>
<div className='spinner'/>
</div>);
}
if (!this.state.badges || this.state.badges.length === 0) {
return (<div>{'No badges yet.'}</div>);
return (<div>
<FormattedMessage
id='badges.no_badges_yet'
defaultMessage='Достижений пока нет.'
/>
</div>);
}
const content = this.state.badges.map((badge) => {
return (
<UserBadgeRow
isCurrentUser={this.props.isCurrentUser}
currentUserID={this.props.currentUserID}
key={badge.time}
badge={badge}
onClick={this.onBadgeClick}
onRevoke={this.onRevoke}
/>
);
});
let title = 'My badges';
if (!this.props.isCurrentUser) {
title = `@${this.props.user.username}'s badges`;
}
const title = this.props.isCurrentUser ? (
<FormattedMessage
id='badges.rhs.my_badges'
defaultMessage='Мои достижения'
/>
) : (
<FormattedMessage
id='badges.rhs.user_badges'
defaultMessage='Достижения @{username}'
values={{username: this.props.user.username}}
/>
);
return (
<div className='UserBadges'>
<div><b>{title}</b></div>
<div className='UserBadges__title'>{title}</div>
<RHSScrollbars>{content}</RHSScrollbars>
</div>
);

View File

@ -1,13 +1,32 @@
.UserRow {
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
align-items: center;
padding: 5px;
margin-bottom: 3px;
.badge-user-username {
font-weight: 600;
border-radius: 6px;
padding: 10px 16px;
margin-bottom: 8px;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.04);
}
.badge-user-granted-at {
font-size: 10px;
.badge-user-username {
font-size: 14px;
font-weight: 600;
a {
color: var(--button-bg, #166de0);
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
.badge-user-meta {
font-size: 12px;
color: rgba(var(--center-channel-color-rgb), 0.64);
margin-top: 4px;
}
}

View File

@ -1,5 +1,7 @@
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import {GlobalState} from 'mattermost-redux/types/store';
@ -8,12 +10,14 @@ import {UserProfile} from 'mattermost-redux/types/users';
import {Ownership} from '../../types/badges';
import './user_row.scss';
type Props = {
ownership: Ownership;
onClick: (user: string) => void;
}
const UserBadgeRow: React.FC<Props> = ({ownership, onClick}: Props) => {
const intl = useIntl();
const user = useSelector<GlobalState, UserProfile>((state) => getUser(state, ownership.user));
const grantedBy = useSelector<GlobalState, UserProfile>((state) => getUser(state, ownership.granted_by));
@ -21,17 +25,33 @@ const UserBadgeRow: React.FC<Props> = ({ownership, onClick}: Props) => {
return null;
}
let grantedByName = 'unknown';
let grantedByName = intl.formatMessage({id: 'badges.unknown', defaultMessage: 'неизвестно'});
if (grantedBy) {
grantedByName = '@' + grantedBy.username;
}
const time = new Date(ownership.time);
return (
<div className='UserRow'>
<div className='badge-user-username'><a onClick={() => onClick(ownership.user)}>{`@${user.username}`}</a></div>
<div className='badge-user-granted-by'>{`Granted by: ${grantedByName}`}</div>
<div className='badge-user-granted-at'>{`Granted at: ${time.toDateString()}`}</div>
<div
className='UserRow'
onClick={() => onClick(ownership.user)}
>
<div className='badge-user-username'>
<a>{`@${user.username}`}</a>
</div>
<div className='badge-user-meta'>
<FormattedMessage
id='badges.label.granted_by'
defaultMessage='Выдал: {username}'
values={{username: grantedByName}}
/>
{' · '}
<FormattedMessage
id='badges.label.granted_at'
defaultMessage='Выдан: {date}'
values={{date: intl.formatDate(time, {day: '2-digit', month: '2-digit', year: 'numeric'})}}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,206 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {FormattedMessage, useIntl} from 'react-intl';
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/common';
import {GlobalState} from 'mattermost-redux/types/store';
import {closeSubscriptionModal} from 'actions/actions';
import {getSubscriptionModalData} from 'selectors';
import {BadgeTypeDefinition} from 'types/badges';
import Client from 'client/api';
import {getServerErrorId} from 'utils/helpers';
import CloseIcon from 'components/icons/close_icon';
const SubscriptionModal: React.FC = () => {
const dispatch = useDispatch();
const intl = useIntl();
const modalData = useSelector(getSubscriptionModalData);
const channelId = useSelector((state: GlobalState) => getCurrentChannelId(state));
const isOpen = modalData !== null;
const isDeleteMode = modalData?.mode === 'delete';
const [selectedTypeId, setSelectedTypeId] = useState('');
const [types, setTypes] = useState<BadgeTypeDefinition[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [closing, setClosing] = useState(false);
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false);
const typeDropdownRef = useRef<HTMLDivElement>(null);
// Закрытие дропдауна при клике снаружи
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (typeDropdownRef.current && !typeDropdownRef.current.contains(e.target as Node)) {
setTypeDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
useEffect(() => {
if (!isOpen) {
return;
}
const fetchTypes = async () => {
const client = new Client();
const subs = await client.getChannelSubscriptions(channelId);
if (isDeleteMode) {
setTypes(subs);
} else {
const resp = await client.getTypes();
const subscribedIds = new Set(subs.map((s) => String(s.id)));
setTypes(resp.types.filter((t) => !subscribedIds.has(String(t.id))));
}
};
fetchTypes();
setSelectedTypeId('');
setError(null);
setLoading(false);
setTypeDropdownOpen(false);
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
const doClose = useCallback(() => {
dispatch(closeSubscriptionModal());
setClosing(false);
}, [dispatch]);
const handleClose = useCallback(() => {
setClosing(true);
setTimeout(doClose, 150);
}, [doClose]);
const handleSubmit = useCallback(async () => {
if (!selectedTypeId) {
return;
}
setLoading(true);
setError(null);
try {
const client = new Client();
const req = {type_id: selectedTypeId, channel_id: channelId};
if (isDeleteMode) {
await client.deleteSubscription(req);
} else {
await client.createSubscription(req);
}
handleClose();
} catch (err) {
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
} finally {
setLoading(false);
}
}, [selectedTypeId, channelId, isDeleteMode, handleClose, intl]);
if (!isOpen && !closing) {
return null;
}
const title = isDeleteMode
? intl.formatMessage({id: 'badges.subscription.title_delete', defaultMessage: 'Удалить подписку'})
: intl.formatMessage({id: 'badges.subscription.title_create', defaultMessage: 'Добавить подписку'});
const submitLabel = isDeleteMode
? intl.formatMessage({id: 'badges.subscription.btn_delete', defaultMessage: 'Удалить'})
: intl.formatMessage({id: 'badges.subscription.btn_create', defaultMessage: 'Добавить'});
const selectedType = types.find((t) => String(t.id) === selectedTypeId);
return (
<div className={'BadgeModal BadgeModal--compact' + (closing ? ' BadgeModal--closing' : '')}>
<div
className='BadgeModal__backdrop'
onClick={handleClose}
/>
<div className='BadgeModal__dialog'>
<div className='BadgeModal__header'>
<h4>{title}</h4>
<button
className='close-btn'
onClick={handleClose}
>
<CloseIcon/>
</button>
</div>
<div className='BadgeModal__body'>
<div className='form-group'>
<label>
<FormattedMessage
id='badges.subscription.field_type'
defaultMessage='Тип достижений'
/>
<span className='required'>{'*'}</span>
</label>
<div
className='type-select'
ref={typeDropdownRef}
>
<button
type='button'
className='type-select__trigger'
onClick={() => setTypeDropdownOpen(!typeDropdownOpen)}
>
<span className='type-select__value'>
{selectedType
? selectedType.name
: intl.formatMessage({id: 'badges.subscription.field_type_placeholder', defaultMessage: 'Выберите тип достижений'})
}
</span>
<span className='type-select__arrow'>{'▾'}</span>
</button>
{typeDropdownOpen && (
<div className='type-select__dropdown'>
{types.length === 0 && (
<div className='type-select__option'>
<FormattedMessage
id='badges.subscription.no_types'
defaultMessage='Нет доступных типов'
/>
</div>
)}
{types.map((t) => (
<div
key={t.id}
className={'type-select__option' + (String(t.id) === selectedTypeId ? ' type-select__option--selected' : '')}
onClick={() => {
setSelectedTypeId(String(t.id));
setTypeDropdownOpen(false);
}}
>
<span className='type-select__option-name'>{t.name}</span>
</div>
))}
</div>
)}
</div>
</div>
{error && <div className='error-message'>{error}</div>}
</div>
<div className='BadgeModal__footer'>
<button
className='btn btn--cancel'
onClick={handleClose}
>
<FormattedMessage
id='badges.modal.btn_cancel'
defaultMessage='Отмена'
/>
</button>
<button
className={isDeleteMode ? 'btn btn--danger' : 'btn btn--primary'}
onClick={handleSubmit}
disabled={loading || !selectedTypeId}
>
{loading
? intl.formatMessage({id: 'badges.modal.btn_creating', defaultMessage: 'Сохранение...'})
: submitLabel
}
</button>
</div>
</div>
</div>
);
};
export default SubscriptionModal;

View File

@ -0,0 +1,286 @@
import React, {useCallback, useEffect, useState} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {FormattedMessage, useIntl} from 'react-intl';
import {TypeFormData} from 'types/badges';
import {isCreateTypeModalVisible, getEditTypeModalData} from 'selectors';
import {closeCreateTypeModal, closeEditTypeModal} from 'actions/actions';
import Client from 'client/api';
import {getServerErrorId} from 'utils/helpers';
import CloseIcon from 'components/icons/close_icon';
import UserMultiSelect from 'components/user_multi_select';
import ConfirmDialog from 'components/confirm_dialog/confirm_dialog';
const emptyTypeForm: TypeFormData = {
name: '',
everyoneCanCreate: false,
everyoneCanGrant: false,
allowlistCanCreate: '',
allowlistCanGrant: '',
};
const TypeModal: React.FC = () => {
const dispatch = useDispatch();
const intl = useIntl();
const createVisible = useSelector(isCreateTypeModalVisible);
const editData = useSelector(getEditTypeModalData);
const isOpen = createVisible || editData !== null;
const isEditMode = editData !== null;
const [form, setForm] = useState<TypeFormData>(emptyTypeForm);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [closing, setClosing] = useState(false);
const updateForm = useCallback((updates: Partial<TypeFormData>) => {
setForm((prev) => ({...prev, ...updates}));
}, []);
useEffect(() => {
if (!isOpen) {
return;
}
if (isEditMode && editData) {
setForm({
name: editData.name,
everyoneCanCreate: editData.can_create?.everyone || false,
everyoneCanGrant: editData.can_grant?.everyone || false,
allowlistCanCreate: editData.allowlist_can_create || '',
allowlistCanGrant: editData.allowlist_can_grant || '',
});
} else {
setForm(emptyTypeForm);
}
setError(null);
setConfirmDelete(false);
setLoading(false);
}, [isOpen, isEditMode]); // eslint-disable-line react-hooks/exhaustive-deps
const doClose = useCallback(() => {
if (createVisible) {
dispatch(closeCreateTypeModal());
}
if (editData) {
dispatch(closeEditTypeModal());
}
setClosing(false);
}, [dispatch, createVisible, editData]);
const handleClose = useCallback(() => {
setClosing(true);
setTimeout(doClose, 150);
}, [doClose]);
const handleSubmit = useCallback(async () => {
setLoading(true);
setError(null);
try {
const client = new Client();
const payload = {
name: form.name.trim(),
everyone_can_create: form.everyoneCanCreate,
everyone_can_grant: form.everyoneCanGrant,
allowlist_can_create: form.allowlistCanCreate.trim(),
allowlist_can_grant: form.allowlistCanGrant.trim(),
};
if (isEditMode && editData) {
await client.updateType({id: String(editData.id), ...payload});
} else {
await client.createType(payload);
}
handleClose();
} catch (err) {
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
} finally {
setLoading(false);
}
}, [isEditMode, editData, form, handleClose, intl]);
const handleDelete = useCallback(async () => {
if (!editData) {
return;
}
if (!confirmDelete) {
setConfirmDelete(true);
return;
}
setLoading(true);
setError(null);
try {
const client = new Client();
await client.deleteType(String(editData.id));
handleClose();
} catch (err) {
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
} finally {
setLoading(false);
}
}, [editData, confirmDelete, handleClose, intl]);
if (!isOpen && !closing) {
return null;
}
const title = isEditMode
? intl.formatMessage({id: 'badges.modal.edit_type_title', defaultMessage: 'Редактировать тип'})
: intl.formatMessage({id: 'badges.modal.create_type_title', defaultMessage: 'Создать тип'});
const submitLabel = isEditMode
? intl.formatMessage({id: 'badges.modal.btn_save', defaultMessage: 'Сохранить'})
: intl.formatMessage({id: 'badges.modal.btn_create', defaultMessage: 'Создать'});
return (
<div className={'BadgeModal' + (closing ? ' BadgeModal--closing' : '')}>
<div
className='BadgeModal__backdrop'
onClick={handleClose}
/>
<div className='BadgeModal__dialog'>
<div className='BadgeModal__header'>
<h4>{title}</h4>
<button
className='close-btn'
onClick={handleClose}
>
<CloseIcon/>
</button>
</div>
<div className='BadgeModal__body'>
<div className='form-group'>
<label>
<FormattedMessage
id='badges.modal.field_name'
defaultMessage='Название'
/>
<span className='required'>{'*'}</span>
</label>
<input
type='text'
value={form.name}
onChange={(e) => updateForm({name: e.target.value})}
maxLength={20}
placeholder={intl.formatMessage({id: 'badges.modal.new_type_name_placeholder', defaultMessage: 'Название типа (макс. 20 символов)'})}
/>
</div>
<div className='checkbox-group'>
<input
type='checkbox'
id='typeEveryoneCanCreate'
checked={form.everyoneCanCreate}
onChange={(e) => updateForm({everyoneCanCreate: e.target.checked})}
/>
<label htmlFor='typeEveryoneCanCreate'>
<FormattedMessage
id='badges.modal.new_type_everyone_create'
defaultMessage='Все могут создавать достижения'
/>
</label>
</div>
{!form.everyoneCanCreate && (
<div className='form-group'>
<label>
<FormattedMessage
id='badges.modal.allowlist_create'
defaultMessage='Список допущенных к созданию'
/>
</label>
<UserMultiSelect
value={form.allowlistCanCreate}
onChange={(v) => updateForm({allowlistCanCreate: v})}
/>
<span className='form-group__help'>
<FormattedMessage
id='badges.modal.allowlist_create_help'
defaultMessage='Пользователи, которые могут создавать достижения этого типа.'
/>
</span>
</div>
)}
<div className='checkbox-group'>
<input
type='checkbox'
id='typeEveryoneCanGrant'
checked={form.everyoneCanGrant}
onChange={(e) => updateForm({everyoneCanGrant: e.target.checked})}
/>
<label htmlFor='typeEveryoneCanGrant'>
<FormattedMessage
id='badges.modal.new_type_everyone_grant'
defaultMessage='Все могут выдавать достижения'
/>
</label>
</div>
{!form.everyoneCanGrant && (
<div className='form-group'>
<label>
<FormattedMessage
id='badges.modal.allowlist_grant'
defaultMessage='Список допущенных к выдаче'
/>
</label>
<UserMultiSelect
value={form.allowlistCanGrant}
onChange={(v) => updateForm({allowlistCanGrant: v})}
/>
<span className='form-group__help'>
<FormattedMessage
id='badges.modal.allowlist_grant_help'
defaultMessage='Пользователи, которые могут выдавать достижения этого типа.'
/>
</span>
</div>
)}
{error && <div className='error-message'>{error}</div>}
{isEditMode && !editData?.is_default && (
<div className='delete-section'>
<button
className='btn btn--danger'
onClick={handleDelete}
disabled={loading}
>
<FormattedMessage
id='badges.modal.btn_delete_type'
defaultMessage='Удалить тип'
/>
</button>
{confirmDelete && (
<ConfirmDialog
onConfirm={handleDelete}
onCancel={() => setConfirmDelete(false)}
>
<FormattedMessage
id='badges.types.confirm_delete'
defaultMessage='Удалить тип «{name}» и все его достижения?'
values={{name: editData?.name}}
/>
</ConfirmDialog>
)}
</div>
)}
</div>
<div className='BadgeModal__footer'>
<button
className='btn btn--cancel'
onClick={handleClose}
>
<FormattedMessage
id='badges.modal.btn_cancel'
defaultMessage='Отмена'
/>
</button>
<button
className='btn btn--primary'
onClick={handleSubmit}
disabled={loading || !form.name.trim()}
>
{loading ? intl.formatMessage({id: 'badges.modal.btn_creating', defaultMessage: 'Сохранение...'}) : submitLabel}
</button>
</div>
</div>
</div>
);
};
export default TypeModal;

View File

@ -0,0 +1,244 @@
import React, {useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Client4} from 'mattermost-redux/client';
import {UserProfile} from 'mattermost-redux/types/users';
import {debounce, getUserDisplayName} from 'utils/helpers';
import CloseIcon from 'components/icons/close_icon';
import SearchIcon from 'components/icons/search_icon';
import './user_multi_select.scss';
type SelectedUser = {
id: string;
username: string;
fullName: string;
avatarUrl: string;
}
type Props = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
}
const UserMultiSelect: React.FC<Props> = ({value, onChange, placeholder, disabled}) => {
const intl = useIntl();
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [searchTerm, setSearchTerm] = useState('');
const [results, setResults] = useState<UserProfile[]>([]);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [profilesLoading, setProfilesLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
const loadedValueRef = useRef<string | null>(null);
useEffect(() => {
let cancelled = false;
if (loadedValueRef.current === value) {
// Already synced — nothing to do
} else if (value) {
const usernames = value.split(',').map((u) => u.trim()).filter(Boolean);
if (usernames.length === 0) {
setSelectedUsers([]);
loadedValueRef.current = value;
} else {
setProfilesLoading(true);
Promise.all(usernames.map(async (username) => {
try {
const user = await Client4.getUserByUsername(username);
return {
id: user.id,
username: user.username,
fullName: getUserDisplayName(user),
avatarUrl: Client4.getProfilePictureUrl(user.id, user.last_picture_update),
};
} catch {
return {id: '', username, fullName: '', avatarUrl: ''};
}
})).then((users) => {
if (!cancelled) {
setSelectedUsers(users);
loadedValueRef.current = value;
setProfilesLoading(false);
}
});
}
} else {
setSelectedUsers([]);
setProfilesLoading(false);
loadedValueRef.current = '';
}
return () => {
cancelled = true;
};
}, [value]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
setSearchTerm('');
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const performSearch = async (term: string, excluded: Set<string>) => {
if (!term) {
setResults([]);
setDropdownOpen(false);
setLoading(false);
return;
}
setLoading(true);
try {
const data = await Client4.autocompleteUsers(term, '', '', {limit: 20});
setResults(data.users.filter((u) => !excluded.has(u.username) && !(u as UserProfile & {remote_id?: string}).remote_id));
} catch {
setResults([]);
} finally {
setLoading(false);
}
};
const doSearch = useMemo(() => debounce(performSearch, 400), []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const term = e.target.value;
setSearchTerm(term);
if (term) {
setDropdownOpen(true);
}
doSearch(term, new Set(selectedUsers.map((u) => u.username)));
};
const handleSelect = (user: UserProfile) => {
const next = [...selectedUsers, {
id: user.id,
username: user.username,
fullName: getUserDisplayName(user),
avatarUrl: Client4.getProfilePictureUrl(user.id, user.last_picture_update),
}];
setSelectedUsers(next);
const newValue = next.map((u) => u.username).join(', ');
loadedValueRef.current = newValue;
onChange(newValue);
setSearchTerm('');
setResults([]);
setDropdownOpen(false);
inputRef.current?.focus();
};
const handleRemove = (username: string) => {
const next = selectedUsers.filter((u) => u.username !== username);
setSelectedUsers(next);
const newValue = next.map((u) => u.username).join(', ');
loadedValueRef.current = newValue;
onChange(newValue);
};
const placeholderText = placeholder || intl.formatMessage({
id: 'badges.admin.placeholder',
defaultMessage: 'Начните вводить имя...',
});
return (
<div
className='user-multi-select'
ref={containerRef}
>
<div
className='user-multi-select__container'
onClick={() => inputRef.current?.focus()}
>
{(loading || profilesLoading) ? (
<div className='user-multi-select__spinner'/>
) : (
<SearchIcon/>
)}
{profilesLoading ? null : selectedUsers.map((user) => (
<span
key={user.username}
className='user-multi-select__chip'
>
{user.avatarUrl && (
<img
className='user-multi-select__chip-avatar'
src={user.avatarUrl}
alt={user.username}
/>
)}
<span className='user-multi-select__chip-name'>
{user.fullName || user.username}
</span>
{!disabled && (
<button
type='button'
className='user-multi-select__chip-remove'
onClick={(e) => {
e.stopPropagation();
handleRemove(user.username);
}}
>
<CloseIcon size={12}/>
</button>
)}
</span>
))}
<input
ref={inputRef}
className='user-multi-select__input'
type='text'
value={searchTerm}
disabled={disabled}
onChange={handleInputChange}
placeholder={selectedUsers.length === 0 ? placeholderText : ''}
/>
</div>
{dropdownOpen && (
<div className='user-multi-select__dropdown'>
{results.length === 0 && searchTerm && (
<div className={`user-multi-select__no-results${loading ? ' user-multi-select__no-results--loading' : ''}`}>
{intl.formatMessage({
id: 'badges.admin.no_results',
defaultMessage: 'Пользователь не найден',
})}
</div>
)}
{results.map((user) => (
<div
key={user.id}
className='user-multi-select__option'
onClick={() => handleSelect(user)}
>
<img
className='user-multi-select__avatar'
src={Client4.getProfilePictureUrl(user.id, user.last_picture_update)}
alt={user.username}
/>
<span className='user-multi-select__option-name'>
{user.username}
</span>
{(user.first_name || user.last_name) && (
<span className='user-multi-select__option-fullname'>
{'— '}{`${user.first_name} ${user.last_name}`.trim()}
</span>
)}
</div>
))}
</div>
)}
</div>
);
};
export default UserMultiSelect;

View File

@ -0,0 +1,156 @@
.user-multi-select {
position: relative;
&__container {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
padding: 4px 8px;
min-height: 34px;
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
border-radius: 4px;
background: var(--center-channel-bg, #fff);
cursor: text;
&:focus-within {
border-color: var(--button-bg, #166de0);
box-shadow: 0 0 0 1px var(--button-bg, #166de0);
}
}
&__spinner {
flex-shrink: 0;
width: 18px;
height: 18px;
border: 2px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
border-top-color: var(--button-bg, #166de0);
border-radius: 50%;
animation: user-multi-select-spin 0.6s linear infinite;
}
&__chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 4px;
border-radius: 12px;
background: rgba(var(--button-bg-rgb, 22, 109, 224), 0.1);
color: var(--center-channel-color, #3d3c40);
font-size: 13px;
line-height: 20px;
min-width: 0;
}
&__chip-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
flex-shrink: 0;
}
&__chip-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: none;
border-radius: 50%;
background: none;
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56);
font-size: 14px;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
&:hover {
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
color: var(--center-channel-color, #3d3c40);
}
}
&__input {
flex: 1 1 60px;
min-width: 60px;
padding: 2px 0;
border: none;
outline: none;
background: transparent;
color: var(--center-channel-color, #3d3c40);
font-size: 14px;
line-height: 24px;
&::placeholder {
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56);
}
}
&__dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
max-height: 200px;
overflow-y: auto;
background: var(--center-channel-bg, #fff);
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
z-index: 100;
}
&__option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
color: var(--center-channel-color, #3d3c40);
&:hover {
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
}
}
&__avatar {
width: 24px;
height: 24px;
border-radius: 50%;
flex-shrink: 0;
}
&__option-name {
font-weight: 600;
}
&__option-fullname {
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56);
}
&__no-results {
padding: 8px 12px;
font-size: 14px;
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56);
font-style: italic;
&--loading {
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.32);
}
}
}
@keyframes user-multi-select-spin {
to {
transform: rotate(360deg);
}
}

View File

@ -3,6 +3,7 @@
display: flex;
align-content: flex-end;
align-items: center;
gap: 6px;
}
#showMoreButton {
@ -23,6 +24,27 @@
}
}
.badge-stacked {
position: relative;
display: inline-block;
}
.badge-stack-count {
position: absolute;
bottom: -2px;
right: -4px;
background: var(--button-bg, #166de0);
color: #fff;
font-size: 9px;
font-weight: 700;
line-height: 1;
padding: 1px 3px;
border-radius: 6px;
min-width: 14px;
text-align: center;
pointer-events: none;
}
#grantBadgeButton {
margin-top: 4px;
padding-left: 0;

View File

@ -1,189 +1,175 @@
import {UserProfile} from 'mattermost-redux/types/users';
import React from 'react';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {Tooltip, OverlayTrigger} from 'react-bootstrap';
import {FormattedMessage, useIntl} from 'react-intl';
import {GlobalState} from 'mattermost-redux/types/store';
import {useDispatch, useSelector} from 'react-redux';
import {systemEmojis} from 'mattermost-redux/actions/emojis';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
import {BadgeID, UserBadge} from 'types/badges';
import {getCustomEmojisByName} from 'mattermost-redux/actions/emojis';
import {EmojiIndicesByAlias} from 'utils/emoji';
import {UserBadge} from 'types/badges';
import Client from 'client/api';
import BadgeImage from '../utils/badge_image';
import {RHSState} from 'types/general';
import BadgeImage from '../badge_image/badge_image';
import {IMAGE_TYPE_EMOJI, RHS_STATE_DETAIL, RHS_STATE_MY, RHS_STATE_OTHER} from '../../constants';
import {markdown} from 'utils/markdown';
import {setRHSView, setRHSBadge, setRHSUser, openGrant} from '../../actions/actions';
import {getShowRHS} from 'selectors';
import {groupBadges} from 'components/utils/badge_list_utils';
import BadgeTooltip from './badge_tooltip';
import TooltipWrapper from './tooltip_wrapper';
import './badge_list.scss';
type Props = {
debug: GlobalState;
user: UserProfile;
currentUserID: string;
openRHS: (() => void) | null;
hide: () => void;
status?: string;
actions: {
setRHSView: (view: RHSState) => Promise<void>;
setRHSBadge: (id: BadgeID | null) => Promise<void>;
setRHSUser: (id: string | null) => Promise<void>;
openGrant: (user?: string, badge?: string) => Promise<void>;
getCustomEmojisByName: (names: string[]) => Promise<unknown>;
};
}
type State = {
badges?: UserBadge[];
loaded?: boolean;
}
const MAX_BADGES = 7;
const MAX_BADGES = 6;
const BADGE_SIZE = 24;
class BadgeList extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
const BadgeList: React.FC<Props> = ({user, hide}) => {
const intl = useIntl();
const dispatch = useDispatch();
const currentUserID = useSelector(getCurrentUserId);
const openRHS = useSelector(getShowRHS);
const [badges, setBadges] = useState<UserBadge[]>();
const [loaded, setLoaded] = useState(false);
this.state = {};
}
componentDidMount() {
useEffect(() => {
const c = new Client();
c.getUserBadges(this.props.user.id).then((badges) => {
this.setState({badges, loaded: true});
c.getUserBadges(user.id).then((result) => {
setBadges(result);
setLoaded(true);
});
}
}, [user.id]);
componentDidUpdate(prevProps: Props, prevState: State) {
if (this.state.badges !== prevState.badges) {
const nBadges = this.state.badges?.length || 0;
const toShow = nBadges < MAX_BADGES ? nBadges : MAX_BADGES;
const names: string[] = [];
for (let i = 0; i < toShow; i++) {
const badge = this.state.badges![i];
if (badge.image_type === IMAGE_TYPE_EMOJI) {
names.push(badge.image);
}
}
const toLoad = names.filter((v) => !systemEmojis.has(v));
this.props.actions.getCustomEmojisByName(toLoad);
}
}
const groups = useMemo(
() => (badges ? groupBadges(badges) : []),
[badges],
);
onMoreClick = () => {
if (!this.props.openRHS) {
useEffect(() => {
if (!badges) {
return;
}
const toShow = groups.slice(0, MAX_BADGES);
const names = toShow.
filter(({badge}) => badge.image_type === IMAGE_TYPE_EMOJI).
map(({badge}) => badge.image).
filter((v) => !EmojiIndicesByAlias.has(v));
if (names.length > 0) {
dispatch(getCustomEmojisByName(names));
}
}, [badges, groups, dispatch]);
if (this.props.currentUserID === this.props.user.id) {
this.props.actions.setRHSView(RHS_STATE_MY);
this.props.openRHS();
const handleMoreClick = useCallback(() => {
if (!openRHS) {
return;
}
if (currentUserID === user.id) {
dispatch(setRHSView(RHS_STATE_MY));
} else {
dispatch(setRHSUser(user.id));
dispatch(setRHSView(RHS_STATE_OTHER));
}
openRHS();
hide();
}, [openRHS, currentUserID, user.id, dispatch, hide]);
this.props.actions.setRHSUser(this.props.user.id);
this.props.actions.setRHSView(RHS_STATE_OTHER);
this.props.openRHS();
this.props.hide();
}
onBadgeClick = (badge: UserBadge) => {
if (!this.props.openRHS) {
const handleBadgeClick = useCallback((badge: UserBadge) => {
if (!openRHS) {
return;
}
dispatch(setRHSBadge(badge.id));
dispatch(setRHSView(RHS_STATE_DETAIL));
openRHS();
hide();
}, [openRHS, dispatch, hide]);
this.props.actions.setRHSBadge(badge.id);
this.props.actions.setRHSView(RHS_STATE_DETAIL);
this.props.openRHS();
this.props.hide();
const handleGrantClick = useCallback(() => {
dispatch(openGrant(user.username));
hide();
}, [dispatch, user.username, hide]);
if ((user as UserProfile & {remote_id?: string}).remote_id) {
return null;
}
onGrantClick = () => {
this.props.actions.openGrant(this.props.user.username);
this.props.hide();
}
const visibleGroups = groups.slice(0, MAX_BADGES);
const maxWidth = (MAX_BADGES * BADGE_SIZE) + 30;
render() {
const nBadges = this.state.badges?.length || 0;
const toShow = nBadges < MAX_BADGES ? nBadges : MAX_BADGES;
const content: React.ReactNode[] = [];
for (let i = 0; i < toShow; i++) {
const badge = this.state.badges![i];
const time = new Date(badge.time);
let reason = null;
if (badge.reason) {
reason = (<div>{'Why? ' + badge.reason}</div>);
}
const badgeComponent = (
<OverlayTrigger
overlay={<Tooltip id='badgeTooltip'>
<div>{badge.name}</div>
<div>{markdown(badge.description)}</div>
{reason}
<div>{`Granted by: ${badge.granted_by_name}`}</div>
<div>{`Granted at: ${time.toDateString()}`}</div>
</Tooltip>}
>
<span>
<a onClick={() => this.onBadgeClick(badge)}>
<BadgeImage
return (
<div id='badgePlugin'>
<div><b>
<FormattedMessage
id='badges.popover.title'
defaultMessage='Достижения'
/>
</b></div>
<div id='contentContainer'>
{visibleGroups.map(({badge, count}) => (
<TooltipWrapper
key={badge.id}
tooltipContent={
<BadgeTooltip
badge={badge}
size={BADGE_SIZE}
count={count}
/>
</a>
</span>
</OverlayTrigger>
);
content.push(badgeComponent);
}
let andMore: React.ReactNode = null;
if (nBadges > MAX_BADGES) {
andMore = (
<OverlayTrigger
overlay={<Tooltip id='badgeMoreTooltip'>
{`and ${nBadges - MAX_BADGES} more. Click to see all.`}
</Tooltip>}
>
<button
id='showMoreButton'
onClick={this.onMoreClick}
}
>
<span className={'fa fa-angle-right'}/>
</button>
</OverlayTrigger>
);
}
const maxWidth = (MAX_BADGES * BADGE_SIZE) + 30;
let loading: React.ReactNode = null;
if (!this.state.loaded) {
loading = (
// Reserve enough height one row of badges and the "and more" button
<div style={{height: BADGE_SIZE, minWidth: 66, maxWidth}}>
{'Loading...'}
</div>
);
}
return (
<div id='badgePlugin'>
<div><b>{'Badges'}</b></div>
<div id='contentContainer' >
{content}
{andMore}
</div>
{loading}
<button
id='grantBadgeButton'
onClick={this.onGrantClick}
>
<span className={'fa fa-plus-circle'}/>
{'Grant badge'}
</button>
<hr className='divider divider--expanded'/>
<a onClick={() => handleBadgeClick(badge)}>
<span className='badge-stacked'>
<BadgeImage
badge={badge}
size={BADGE_SIZE}
/>
{count > 1 && (
<span className='badge-stack-count'>
{'×'}{count}
</span>
)}
</span>
</a>
</TooltipWrapper>
))}
{groups.length > MAX_BADGES && (
<TooltipWrapper
tooltipContent={intl.formatMessage(
{id: 'badges.and_more', defaultMessage: 'и ещё {count}. Нажмите, чтобы увидеть все.'},
{count: groups.length - MAX_BADGES},
)}
>
<button
id='showMoreButton'
onClick={handleMoreClick}
>
<span className={'fa fa-angle-right'}/>
</button>
</TooltipWrapper>
)}
</div>
);
}
}
{!loaded && (
<div style={{height: BADGE_SIZE, minWidth: 66, maxWidth}}>
<div className='spinner'/>
</div>
)}
<button
id='grantBadgeButton'
onClick={handleGrantClick}
>
<span className={'fa fa-plus-circle'}/>
<FormattedMessage
id='badges.grant_badge'
defaultMessage='Выдать достижение'
/>
</button>
<hr className='divider divider--expanded'/>
</div>
);
};
export default BadgeList;

View File

@ -0,0 +1,62 @@
import React from 'react';
import {useIntl} from 'react-intl';
import {UserBadge} from 'types/badges';
import {truncateText} from 'components/utils/badge_list_utils';
type Props = {
badge: UserBadge;
count: number;
}
const BadgeTooltip: React.FC<Props> = ({badge, count}) => {
const intl = useIntl();
const desc = badge.description ? truncateText(badge.description) : '—';
const nameRow = intl.formatMessage(
{id: 'badges.label.name', defaultMessage: 'Название:'},
) + ' ' + badge.name;
const descRow = intl.formatMessage(
{id: 'badges.label.description', defaultMessage: 'Описание:'},
) + ' ' + desc;
if (count > 1) {
const countRow = intl.formatMessage(
{id: 'badges.label.count', defaultMessage: 'Количество: {count}'},
{count},
);
return <>{nameRow}{'\n'}{descRow}{'\n'}{countRow}</>;
}
const time = new Date(badge.time);
const grantedBy = intl.formatMessage(
{id: 'badges.label.granted_by', defaultMessage: 'Выдал: {username}'},
{username: badge.granted_by_name},
);
const grantedAt = intl.formatMessage(
{id: 'badges.label.granted_at', defaultMessage: 'Выдан: {date}'},
{date: intl.formatDate(time, {day: '2-digit', month: '2-digit', year: 'numeric'})},
);
return (
<>
{nameRow}{'\n'}
{descRow}{'\n'}
{badge.reason && (
<>
{intl.formatMessage(
{id: 'badges.label.reason', defaultMessage: 'Причина: {reason}'},
{reason: badge.reason},
)}{'\n'}
</>
)}
{grantedBy}{'\n'}
{grantedAt}
</>
);
};
export default BadgeTooltip;

View File

@ -1,49 +1 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License for license information.
import {connect} from 'react-redux';
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {GlobalState} from 'mattermost-redux/types/store';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
import {getCustomEmojisByName} from 'mattermost-redux/actions/emojis';
import {setRHSView, setRHSBadge, setRHSUser, openGrant} from '../../actions/actions';
import {getShowRHS} from 'selectors';
import {RHSState} from 'types/general';
import {BadgeID} from 'types/badges';
import BadgeList from './badge_list';
function mapStateToProps(state: GlobalState) {
return {
openRHS: getShowRHS(state),
currentUserID: getCurrentUserId(state),
debug: state,
};
}
type Actions = {
setRHSView: (view: RHSState) => Promise<void>;
setRHSBadge: (id: BadgeID | null) => Promise<void>;
setRHSUser: (id: string | null) => Promise<void>;
openGrant: (user?: string, badge?: string) => Promise<void>;
getCustomEmojisByName: (names: string[]) => Promise<unknown>;
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators<ActionCreatorsMapObject, Actions>({
setRHSView,
setRHSBadge,
setRHSUser,
openGrant,
getCustomEmojisByName,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(BadgeList);
export {default} from './badge_list';

View File

@ -0,0 +1,31 @@
.badge-tooltip-wrapper {
position: relative;
display: inline-block;
}
.badge-tooltip {
position: absolute;
background-color: #000;
width: max-content;
color: #fff;
font-size: 12px;
font-weight: 600;
text-align: center;
border-radius: 4px;
padding: 4px 8px;
z-index: 10000;
max-width: 220px;
overflow-wrap: break-word;
pointer-events: none;
transition: opacity 0.2s ease;
white-space: pre-line;
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.12);
}
.badge-tooltip-arrow {
position: absolute;
width: 8px;
height: 8px;
background: #000;
transform: rotate(45deg);
}

View File

@ -0,0 +1,68 @@
import React, {ReactNode, useState, useRef, useEffect} from 'react';
import ReactDOM from 'react-dom';
import './tooltip_wrapper.scss';
type TooltipWrapperProps = {
children: ReactNode;
tooltipContent: ReactNode;
}
const TooltipWrapper: React.FC<TooltipWrapperProps> = ({children, tooltipContent}) => {
const [isVisible, setIsVisible] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({bottom: 0, left: 0});
const handleMouseEnter = () => setIsVisible(true);
const handleMouseLeave = () => setIsVisible(false);
useEffect(() => {
if (wrapperRef.current && isVisible) {
const wrapperRect = wrapperRef.current.getBoundingClientRect();
setPosition({
bottom: window.innerHeight - (wrapperRect.top + window.scrollY - 5),
left: wrapperRect.left + window.scrollX + wrapperRect.width / 2,
});
}
}, [isVisible]);
const tooltip = (
<div
ref={tooltipRef}
className='badge-tooltip'
style={{
position: 'absolute',
bottom: `${position.bottom}px`,
left: `${position.left}px`,
transform: 'translateX(-50%)',
visibility: isVisible ? 'visible' : 'hidden',
opacity: isVisible ? 1 : 0,
}}
>
{tooltipContent}
<div
className='badge-tooltip-arrow'
style={{
bottom: '-4px',
left: '50%',
marginLeft: '-4px',
}}
/>
</div>
);
return (
<div
ref={wrapperRef}
className='badge-tooltip-wrapper'
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children}
{isVisible && ReactDOM.createPortal(tooltip as any, document.body)}
</div>
);
};
export default TooltipWrapper;

View File

@ -0,0 +1,25 @@
import {BadgeID, UserBadge} from 'types/badges';
export type BadgeGroup = {
badge: UserBadge;
count: number;
}
const MAX_DESC_LENGTH = 40;
export function groupBadges(badges: UserBadge[]): BadgeGroup[] {
const map = new Map<BadgeID, BadgeGroup>();
for (const badge of badges) {
const existing = map.get(badge.id);
if (existing) {
existing.count++;
} else {
map.set(badge.id, {badge, count: 1});
}
}
return Array.from(map.values());
}
export function truncateText(text: string): string {
return text.length > MAX_DESC_LENGTH ? text.slice(0, MAX_DESC_LENGTH) + '...' : text;
}

View File

@ -8,10 +8,21 @@ export const RHS_STATE_MY: RHSState = 'my';
export const RHS_STATE_OTHER: RHSState = 'other';
export const RHS_STATE_ALL: RHSState = 'all';
export const RHS_STATE_DETAIL: RHSState = 'detail';
export const RHS_STATE_TYPES: RHSState = 'types';
export const RHS_STATE_TYPE_BADGES: RHSState = 'type_badges';
export const initialState: PluginState = {
showRHS: null,
rhsView: RHS_STATE_MY,
prevRhsView: RHS_STATE_MY,
rhsBadge: null,
rhsUser: null,
rhsTypeId: null,
rhsTypeName: null,
createBadgeModalVisible: false,
editBadgeModalData: null,
createTypeModalVisible: false,
editTypeModalData: null,
grantModalData: null,
subscriptionModalData: null,
};

View File

@ -6,11 +6,21 @@ import {GenericAction} from 'mattermost-redux/types/actions';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
import React from 'react';
import {IntlProvider} from 'react-intl';
import {useSelector} from 'react-redux';
import {openAddSubscription, openCreateBadge, openCreateType, openRemoveSubscription, setRHSView, setShowRHSAction} from 'actions/actions';
import UserBadges from 'components/rhs';
import RHSComponent from 'components/rhs';
import BadgeModal from 'components/badge_modal';
import TypeModal from 'components/type_modal';
import GrantModal from 'components/grant_modal';
import SubscriptionModal from 'components/subscription_modal';
import ChannelHeaderButton from 'components/channel_header_button';
@ -20,28 +30,60 @@ import manifest from './manifest';
// eslint-disable-next-line import/no-unresolved
import {PluginRegistry} from './types/mattermost-webapp';
import BadgeList from './components/user_popover/';
import BadgeListConnected from './components/user_popover/';
import {RHS_STATE_ALL} from './constants';
import {getTranslations} from './utils/i18n';
import BadgesAdminSetting from './components/admin/badges_admin_setting';
function withIntl(Component: React.ElementType): React.ElementType {
const Wrapped: React.FC<any> = (props) => {
const currentUser = useSelector(getCurrentUser);
const locale = currentUser?.locale || 'ru';
return (
<IntlProvider
locale={locale}
messages={getTranslations(locale)}
>
<Component {...props}/>
</IntlProvider>
);
};
return Wrapped;
}
const WrappedRHS = withIntl(RHSComponent);
const WrappedBadgeList = withIntl(BadgeListConnected as unknown as React.ElementType);
export default class Plugin {
public async initialize(registry: PluginRegistry, store: Store<GlobalState, GenericAction>) {
registry.registerReducer(Reducer);
registry.registerPopoverUserAttributesComponent(BadgeList);
registry.registerTranslations(getTranslations);
const {showRHSPlugin, toggleRHSPlugin} = registry.registerRightHandSidebarComponent(UserBadges, 'Badges');
registry.registerAdminConsoleCustomSetting('BadgesAdmin', withIntl(BadgesAdminSetting));
registry.registerPopoverUserAttributesComponent(WrappedBadgeList);
registry.registerRootComponent(withIntl(BadgeModal));
registry.registerRootComponent(withIntl(TypeModal));
registry.registerRootComponent(withIntl(GrantModal));
registry.registerRootComponent(withIntl(SubscriptionModal));
const locale = getCurrentUser(store.getState())?.locale || 'ru';
const messages = getTranslations(locale);
const {showRHSPlugin, toggleRHSPlugin} = registry.registerRightHandSidebarComponent(WrappedRHS, messages['badges.sidebar.title']);
store.dispatch(setShowRHSAction(() => store.dispatch(showRHSPlugin)));
const toggleRHS = () => {
store.dispatch(setRHSView(RHS_STATE_ALL));
store.dispatch(toggleRHSPlugin);
}
};
registry.registerChannelHeaderButtonAction(
<ChannelHeaderButton/>,
toggleRHS,
'Badges',
'Open the list of all badges.',
messages['badges.sidebar.title'],
messages['badges.menu.open_list'],
);
if (registry.registerAppBarComponent) {
@ -50,19 +92,19 @@ export default class Plugin {
registry.registerAppBarComponent(
iconURL,
toggleRHS,
'Open the list of all badges.',
messages['badges.menu.open_list'],
);
}
registry.registerMainMenuAction(
'Create badge',
messages['badges.menu.create_badge'],
() => {
store.dispatch(openCreateBadge() as any);
},
null,
);
registry.registerMainMenuAction(
'Create badge type',
messages['badges.menu.create_type'],
() => {
store.dispatch(openCreateType() as any);
},
@ -70,13 +112,13 @@ export default class Plugin {
);
registry.registerChannelHeaderMenuAction(
'Add badge subscription',
messages['badges.menu.add_subscription'],
() => {
store.dispatch(openAddSubscription() as any);
},
);
registry.registerChannelHeaderMenuAction(
'Remove badge subscription',
messages['badges.menu.remove_subscription'],
() => {
store.dispatch(openRemoveSubscription() as any);
},

View File

@ -23,6 +23,15 @@ function rhsView(state = RHS_STATE_MY, action: GenericAction) {
}
}
function prevRhsView(state = RHS_STATE_MY, action: GenericAction) {
switch (action.type) {
case ActionTypes.RECEIVED_RHS_VIEW:
return action.prevView || state;
default:
return state;
}
}
function rhsUser(state = null, action: GenericAction) {
switch (action.type) {
case ActionTypes.RECEIVED_RHS_USER:
@ -41,9 +50,102 @@ function rhsBadge(state = null, action: GenericAction) {
}
}
function rhsTypeId(state = null, action: GenericAction) {
switch (action.type) {
case ActionTypes.RECEIVED_RHS_TYPE:
return action.data.typeId;
default:
return state;
}
}
function rhsTypeName(state = null, action: GenericAction) {
switch (action.type) {
case ActionTypes.RECEIVED_RHS_TYPE:
return action.data.typeName;
default:
return state;
}
}
function createBadgeModalVisible(state = false, action: GenericAction) {
switch (action.type) {
case ActionTypes.OPEN_CREATE_BADGE_MODAL:
return true;
case ActionTypes.CLOSE_CREATE_BADGE_MODAL:
return false;
default:
return state;
}
}
function editBadgeModalData(state = null, action: GenericAction) {
switch (action.type) {
case ActionTypes.OPEN_EDIT_BADGE_MODAL:
return action.data;
case ActionTypes.CLOSE_EDIT_BADGE_MODAL:
return null;
default:
return state;
}
}
function createTypeModalVisible(state = false, action: GenericAction) {
switch (action.type) {
case ActionTypes.OPEN_CREATE_TYPE_MODAL:
return true;
case ActionTypes.CLOSE_CREATE_TYPE_MODAL:
return false;
default:
return state;
}
}
function editTypeModalData(state = null, action: GenericAction) {
switch (action.type) {
case ActionTypes.OPEN_EDIT_TYPE_MODAL:
return action.data;
case ActionTypes.CLOSE_EDIT_TYPE_MODAL:
return null;
default:
return state;
}
}
function grantModalData(state = null, action: GenericAction) {
switch (action.type) {
case ActionTypes.OPEN_GRANT_MODAL:
return action.data || {};
case ActionTypes.CLOSE_GRANT_MODAL:
return null;
default:
return state;
}
}
function subscriptionModalData(state = null, action: GenericAction) {
switch (action.type) {
case ActionTypes.OPEN_SUBSCRIPTION_MODAL:
return action.data;
case ActionTypes.CLOSE_SUBSCRIPTION_MODAL:
return null;
default:
return state;
}
}
export default combineReducers({
showRHS,
rhsView,
prevRhsView,
rhsUser,
rhsBadge,
rhsTypeId,
rhsTypeName,
createBadgeModalVisible,
editBadgeModalData,
createTypeModalVisible,
editTypeModalData,
grantModalData,
subscriptionModalData,
});

View File

@ -30,6 +30,13 @@ export const getRHSView = createSelector(
},
);
export const getPrevRHSView = createSelector(
getPluginState,
(state) => {
return state.prevRhsView;
},
);
export const getRHSUser = createSelector(
getPluginState,
(state) => {
@ -43,3 +50,59 @@ export const getRHSBadge = createSelector(
return state.rhsBadge;
},
);
export const getRHSTypeId = createSelector(
getPluginState,
(state) => {
return state.rhsTypeId;
},
);
export const getRHSTypeName = createSelector(
getPluginState,
(state) => {
return state.rhsTypeName;
},
);
export const isCreateBadgeModalVisible = createSelector(
getPluginState,
(state) => {
return state.createBadgeModalVisible;
},
);
export const getEditBadgeModalData = createSelector(
getPluginState,
(state) => {
return state.editBadgeModalData;
},
);
export const isCreateTypeModalVisible = createSelector(
getPluginState,
(state) => {
return state.createTypeModalVisible;
},
);
export const getEditTypeModalData = createSelector(
getPluginState,
(state) => {
return state.editTypeModalData;
},
);
export const getGrantModalData = createSelector(
getPluginState,
(state) => {
return state.grantModalData;
},
);
export const getSubscriptionModalData = createSelector(
getPluginState,
(state) => {
return state.subscriptionModalData;
},
);

View File

@ -29,6 +29,7 @@ export type BadgeDetails = Badge & {
owners: OwnershipList;
created_by_username: string;
type_name: string;
can_edit: boolean;
}
export type AllBadgesBadge = Badge & {
granted: number;
@ -42,4 +43,96 @@ export type BadgeTypeDefinition = {
id: BadgeType;
name: string;
frame: string;
created_by: string;
created_by_username: string;
can_grant: PermissionScheme;
can_create: PermissionScheme;
badge_count: number;
is_default: boolean;
allowlist_can_create: string;
allowlist_can_grant: string;
}
export type PermissionScheme = {
everyone: boolean;
roles: Record<string, boolean>;
allow_list: Record<string, boolean>;
block_list: Record<string, boolean>;
}
export type GetTypesResponse = {
types: BadgeTypeDefinition[];
can_create_type: boolean;
can_edit_type: boolean;
}
export type TypeFormData = {
name: string;
everyoneCanCreate: boolean;
everyoneCanGrant: boolean;
allowlistCanCreate: string;
allowlistCanGrant: string;
}
export type BadgeFormData = {
name: string;
description: string;
image: string;
badgeType: string;
multiple: boolean;
}
export type CreateBadgeRequest = {
name: string;
description: string;
image: string;
type: string;
multiple: boolean;
channel_id?: string;
}
export type UpdateBadgeRequest = {
id: string;
name: string;
description: string;
image: string;
type: string;
multiple: boolean;
}
export type CreateTypeRequest = {
name: string;
everyone_can_create: boolean;
everyone_can_grant: boolean;
allowlist_can_create: string;
allowlist_can_grant: string;
channel_id?: string;
}
export type UpdateTypeRequest = {
id: string;
name: string;
everyone_can_create: boolean;
everyone_can_grant: boolean;
allowlist_can_create: string;
allowlist_can_grant: string;
}
export type GrantBadgeRequest = {
badge_id: string;
user_id: string;
reason: string;
notify_here: boolean;
channel_id: string;
}
export type SubscriptionRequest = {
type_id: string;
channel_id: string;
}
export type RevokeOwnershipRequest = {
badge_id: string;
user_id: string;
time: string;
}

View File

@ -1,10 +1,28 @@
import {BadgeID} from './badges';
import {BadgeDetails, BadgeID, BadgeTypeDefinition} from './badges';
export type RHSState = string;
export type GrantModalData = {
prefillUser?: string;
prefillBadgeId?: string;
}
export type SubscriptionModalData = {
mode: 'create' | 'delete';
}
export type PluginState = {
showRHS: (() => void)| null;
rhsView: RHSState;
prevRhsView: RHSState;
rhsUser: string | null;
rhsBadge: BadgeID | null;
rhsTypeId: number | null;
rhsTypeName: string | null;
createBadgeModalVisible: boolean;
editBadgeModalData: BadgeDetails | null;
createTypeModalVisible: boolean;
editTypeModalData: BadgeTypeDefinition | null;
grantModalData: GrantModalData | null;
subscriptionModalData: SubscriptionModalData | null;
}

View File

@ -11,7 +11,10 @@ export interface PluginRegistry {
registerChannelHeaderButtonAction(icon: React.ReactNode, action: () => void, dropdownText: string, tooltip: string);
registerMainMenuAction(text: React.ReactNode, action: () => void, mobileIcon: React.ReactNode);
registerChannelHeaderMenuAction(text: string, action: (channelID: string) => void);
registerAppBarComponent(iconURL: string, action: (channel: Channel, member: ChannelMembership) => void, tooltipText: React.ReactNode)
registerAppBarComponent(iconURL: string, action: (channel: Channel, member: ChannelMembership) => void, tooltipText: React.ReactNode);
registerTranslations(getTranslationsForLocale: (locale: string) => Record<string, string>): void;
registerAdminConsoleCustomSetting(key: string, component: React.ElementType, options?: {showTitle: boolean}): void;
registerRootComponent(component: React.ElementType): void;
// Add more if needed from https://developers.mattermost.com/extend/plugins/webapp/reference
}

View File

@ -0,0 +1,29 @@
import {UserProfile} from 'mattermost-redux/types/users';
export function getUserDisplayName(user: UserProfile): string {
if (user.nickname) {
return user.nickname;
}
if (user.first_name || user.last_name) {
return `${user.first_name} ${user.last_name}`.trim();
}
return user.username;
}
export function debounce<T extends(...args: any[]) => void>(fn: T, delay: number): T {
let timer: ReturnType<typeof setTimeout>;
return ((...args: any[]) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
}) as unknown as T;
}
export function getServerErrorId(err: unknown): string {
const msg = (err as {message?: string})?.message || '';
try {
const parsed = JSON.parse(msg);
return parsed.id || '';
} catch {
return '';
}
}

8
webapp/src/utils/i18n.ts Normal file
View File

@ -0,0 +1,8 @@
import en from '../../i18n/en.json';
import ru from '../../i18n/ru.json';
const translations: Record<string, Record<string, string>> = {en, ru};
export function getTranslations(locale: string): Record<string, string> {
return translations[locale] || translations.ru;
}

View File

@ -48,6 +48,13 @@ module.exports = {
},
module: {
rules: [
{
test: /\.mjs$/,
include: /node_modules/,
resolve: {
fullySpecified: false,
},
},
{
test: /\.(js|jsx|ts|tsx)$/,
exclude: /node_modules/,
@ -96,6 +103,7 @@ module.exports = {
},
externals: {
react: 'React',
'react-dom': 'ReactDOM',
redux: 'Redux',
'react-redux': 'ReactRedux',
'prop-types': 'PropTypes',
@ -107,6 +115,7 @@ module.exports = {
path: path.join(__dirname, '/dist'),
publicPath: '/',
filename: 'main.js',
hashFunction: 'xxhash64',
},
devtool,
mode,

File diff suppressed because it is too large Load Diff