LP-5613 #2

Open
dmitrii.pichenikin wants to merge 37 commits from LP-5613 into dev
77 changed files with 6406 additions and 1078 deletions

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 != ""
}

File diff suppressed because it is too large Load Diff

View File

@ -271,7 +271,12 @@ func (p *Plugin) runEditBadge(args []string, extra *model.CommandArgs) (bool, *m
return commandError(err.Error())
}
if !canEditBadge(u, p.badgeAdminUserID, 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", "У вас нет прав на редактирование этого значка"))
}
@ -359,7 +364,7 @@ func (p *Plugin) runEditType(args []string, extra *model.CommandArgs) (bool, *mo
}
T := p.getT(u.Locale)
if !canCreateType(u, p.badgeAdminUserID, false) {
if !canCreateType(u, p.badgeAdminUserIDs, false) {
return commandError(T("badges.error.no_permissions_edit_type", "У вас нет прав на редактирование типа значков."))
}
@ -379,7 +384,7 @@ func (p *Plugin) runEditType(args []string, extra *model.CommandArgs) (bool, *mo
return commandError(err.Error())
}
if !canEditType(u, p.badgeAdminUserID, typeDefinition) {
if !canEditType(u, p.badgeAdminUserIDs, typeDefinition) {
return commandError(T("badges.error.cannot_edit_type", "У вас нет прав на редактирование этого типа"))
}
@ -493,7 +498,7 @@ func (p *Plugin) runCreateType(args []string, extra *model.CommandArgs) (bool, *
}
T := p.getT(u.Locale)
if !canCreateType(u, p.badgeAdminUserID, false) {
if !canCreateType(u, p.badgeAdminUserIDs, false) {
return commandError(T("badges.error.no_permissions_create_type", "У вас нет прав на создание типа значков."))
}
@ -582,7 +587,7 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
return commandError(err.Error())
}
if !canGrantBadge(granter, p.badgeAdminUserID, badge, badgeType) {
if !canGrantBadge(granter, p.badgeAdminUserIDs, badge, badgeType) {
return commandError(T("badges.error.no_permissions_grant", "У вас нет прав на выдачу этого значка"))
}
@ -592,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())
}
@ -755,7 +763,7 @@ func (p *Plugin) runCreateSubscription(args []string, extra *model.CommandArgs)
}
T := p.getT(actingUser.Locale)
if !canCreateSubscription(actingUser, p.badgeAdminUserID, extra.ChannelId) {
if !canCreateSubscription(actingUser, p.badgeAdminUserIDs, extra.ChannelId) {
return commandError(T("badges.error.cannot_create_subscription", "Вы не можете создавать подписки"))
}
@ -818,7 +826,7 @@ func (p *Plugin) runDeleteSubscription(args []string, extra *model.CommandArgs)
}
T := p.getT(actingUser.Locale)
if !canCreateSubscription(actingUser, p.badgeAdminUserID, extra.ChannelId) {
if !canCreateSubscription(actingUser, p.badgeAdminUserIDs, extra.ChannelId) {
return commandError(T("badges.error.cannot_create_subscription", "Вы не можете создавать подписки"))
}

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)

View File

@ -1,21 +1,21 @@
[
{"id": "badges.dialog.create_badge.title", "translation": "Create badge"},
{"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 badge"},
{"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 badge"},
{"id": "badges.dialog.grant.title", "translation": "Grant achievement"},
{"id": "badges.dialog.grant.submit", "translation": "Grant"},
{"id": "badges.dialog.grant.intro", "translation": "Grant badge to @%s"},
{"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 badge type you want to subscribe to this channel."},
{"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 badge type you want to unsubscribe from this channel."},
{"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"},
@ -23,45 +23,46 @@
{"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 badge can be granted multiple times"},
{"id": "badges.field.delete_badge", "translation": "Delete badge"},
{"id": "badges.field.delete_badge.help", "translation": "WARNING: checking this will remove this badge permanently."},
{"id": "badges.field.everyone_can_create", "translation": "Everyone can create badge"},
{"id": "badges.field.everyone_can_create.help", "translation": "Whether any user can create a badge of this type"},
{"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 badges of this type."},
{"id": "badges.field.everyone_can_grant", "translation": "Everyone can grant badge"},
{"id": "badges.field.everyone_can_grant.help", "translation": "Whether any user can grant a badge of this type"},
{"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 badges of this type."},
{"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 badges permanently."},
{"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": "Badge"},
{"id": "badges.field.badge", "translation": "Achievement"},
{"id": "badges.field.reason", "translation": "Reason"},
{"id": "badges.field.reason.help", "translation": "Reason why you are granting this badge. This will be seen by the user, and wherever this grant notification is shown (e.g. subscriptions)."},
{"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 badge to this person."},
{"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 badges database."},
{"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 badge or type"},
{"id": "badges.error.no_types_available", "translation": "You cannot create badges from any type."},
{"id": "badges.error.must_set_badge_id", "translation": "You must set the badge ID"},
{"id": "badges.error.cannot_edit_badge", "translation": "You cannot edit this badge"},
{"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 badge or type"},
{"id": "badges.error.no_permissions_edit_type", "translation": "You have no permissions to edit a badge type."},
{"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 badge"},
{"id": "badges.error.cannot_grant_badge", "translation": "You cannot grant that badge"},
{"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 a badge type."},
{"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"},
@ -72,8 +73,8 @@
{"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 badge"},
{"id": "badges.api.badge_created", "translation": "Badge `%s` created."},
{"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"},
@ -83,24 +84,25 @@
{"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 badge"},
{"id": "badges.api.cannot_edit_badge", "translation": "You cannot edit this badge"},
{"id": "badges.api.could_not_get_badge", "translation": "Could not get the badge"},
{"id": "badges.api.no_permissions_edit_badge", "translation": "You have no permissions to edit this badge"},
{"id": "badges.api.badge_updated", "translation": "Badge `%s` updated."},
{"id": "badges.api.badge_not_found", "translation": "Badge not found"},
{"id": "badges.api.no_permissions_grant", "translation": "You have no permissions to grant this badge"},
{"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": "Badge `%s` granted to @%s."},
{"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` badge."},
{"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": "%sbadge granted!"},
{"id": "badges.notify.channel_text", "translation": "@%s granted @%s the %s`%s` badge."},
{"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."}
]
]

View File

@ -18,6 +18,7 @@ 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)
}

View File

@ -1,21 +1,21 @@
[
{"id": "badges.dialog.create_badge.title", "translation": "Создать значок"},
{"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.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.title", "translation": "Выдать достижение"},
{"id": "badges.dialog.grant.submit", "translation": "Выдать"},
{"id": "badges.dialog.grant.intro", "translation": "Выдать значок пользователю @%s"},
{"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.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.dialog.delete_subscription.intro", "translation": "Выберите тип достижения, подписку на который хотите удалить из этого канала."},
{"id": "badges.field.name", "translation": "Название"},
{"id": "badges.field.description", "translation": "Описание"},
@ -23,45 +23,46 @@
{"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.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_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.allowlist_grant.help", "translation": "Укажите имена пользователей через запятую (,), которые могут выдавать достижения этого типа."},
{"id": "badges.field.delete_type", "translation": "Удалить тип"},
{"id": "badges.field.delete_type.help", "translation": "ВНИМАНИЕ: если отметить, этот тип и все связанные значки будут удалены безвозвратно."},
{"id": "badges.field.delete_type.help", "translation": "ВНИМАНИЕ: если отметить, этот тип и все связанные достижения будут удалены безвозвратно."},
{"id": "badges.field.user", "translation": "Пользователь"},
{"id": "badges.field.badge", "translation": "Значок"},
{"id": "badges.field.badge", "translation": "Достижение"},
{"id": "badges.field.reason", "translation": "Причина"},
{"id": "badges.field.reason.help", "translation": "Причина выдачи значка. Будет видна пользователю и в уведомлениях о выдаче (например, в подписках)."},
{"id": "badges.field.reason.help", "translation": "Причина выдачи достижения. Будет видна пользователю и в уведомлениях о выдаче (например, в подписках)."},
{"id": "badges.field.notify_here", "translation": "Уведомить в этом канале"},
{"id": "badges.field.notify_here.help", "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.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.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.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.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.no_permissions_create_type", "translation": "У вас нет прав на создание типа достижений."},
{"id": "badges.error.already_owned", "translation": "Это достижение уже выдано этому пользователю"},
{"id": "badges.success.clean", "translation": "Очищено"},
{"id": "badges.success.granted", "translation": "Выдано"},
@ -72,8 +73,8 @@
{"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_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"},
@ -83,24 +84,25 @@
{"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.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.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_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.title", "translation": "%sдостижение выдано!"},
{"id": "badges.notify.channel_text", "translation": "@%s выдал @%s достижение %s`%s`."},
{"id": "badges.notify.no_permission_channel", "translation": "У вас нет прав на отправку уведомления о выдаче в этот канал."}
]

View File

@ -28,7 +28,7 @@ type Plugin struct {
BotUserID string
store Store
router *mux.Router
badgeAdminUserID string
badgeAdminUserIDs map[string]bool
i18nBundle *i18n.Bundle
}
@ -57,6 +57,9 @@ func (p *Plugin) OnActivate() error {
}
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()

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
}
@ -160,8 +169,9 @@ func (p *Plugin) notifyGrant(badgeID badgesmodel.BadgeID, granter string, grante
dmText += Tdm("badges.notify.dm_reason", "\nПочему? ") + reason
}
dmAttachment := model.SlackAttachment{
Title: Tdm("badges.notify.title", "%sзначок выдан!", 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)
@ -192,7 +202,14 @@ 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) {
Tg := p.getT(granterUser.Locale)
p.mm.Post.SendEphemeralPost(granter, &model.Post{Message: Tg("badges.notify.no_permission_channel", "У вас нет прав на отправку уведомления о выдаче в этот канал."), ChannelId: channelID})
@ -208,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,40 +1,181 @@
{
"badges.loading": "Loading...",
"badges.no_badges_yet": "No badges yet.",
"badges.badge_not_found": "Badge not found.",
"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 badges",
"badges.rhs.my_badges": "My badges",
"badges.rhs.user_badges": "@{username}'s badges",
"badges.rhs.badge_details": "Badge Details",
"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 badge",
"badges.grant_badge": "Grant badge",
"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": "Open the list of all badges.",
"badges.menu.create_badge": "Create badge",
"badges.menu.create_type": "Create badge type",
"badges.menu.add_subscription": "Add badge subscription",
"badges.menu.remove_subscription": "Remove badge subscription",
"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": "Badges",
"badges.popover.title": "Badges",
"badges.sidebar.title": "Achievements",
"badges.popover.title": "Achievements",
"badges.admin.label": "Achievements Admin:",
"badges.admin.placeholder": "username",
"badges.admin.help_text": "This user will be considered the achievements plugin administrator. They can create types, as well as modify and grant any badges."
"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"
}

View File

@ -1,40 +1,181 @@
{
"badges.loading": "Загрузка...",
"badges.no_badges_yet": "Значков пока нет.",
"badges.badge_not_found": "Значок не найден.",
"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.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.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.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.sidebar.title": "Достижения",
"badges.popover.title": "Достижения",
"badges.admin.label": "Администратор достижений:",
"badges.admin.placeholder": "имя пользователя",
"badges.admin.help_text": "Этот пользователь будет считаться администратором плагина достижений. Он может создавать типы, а также изменять и выдавать любые значки."
"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,12 +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": {
@ -73,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} = {}) => {
Review

По сути код в методах для crud дублиркется. Можно было бы сократить

По сути код в методах для crud дублиркется. Можно было бы сократить
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

@ -1,6 +1,7 @@
/* eslint-disable react/prop-types */
import React, {useCallback} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {FormattedMessage} from 'react-intl';
import UserMultiSelect from 'components/user_multi_select';
type Props = {
id: string;
@ -16,10 +17,8 @@ type Props = {
}
const BadgesAdminSetting: React.FC<Props> = ({id, value, disabled, onChange, setSaveNeeded}) => {
const intl = useIntl();
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
onChange(id, e.target.value);
const handleChange = useCallback((newValue: string) => {
onChange(id, newValue);
setSaveNeeded();
}, [id, onChange, setSaveNeeded]);
@ -28,25 +27,19 @@ const BadgesAdminSetting: React.FC<Props> = ({id, value, disabled, onChange, set
<label className='control-label col-sm-4'>
<FormattedMessage
id='badges.admin.label'
defaultMessage='Администратор достижений:'
defaultMessage='Администраторы достижений:'
/>
</label>
<div className='col-sm-8'>
<input
className='form-control'
type='text'
value={value || ''}
disabled={disabled}
<UserMultiSelect
value={value}
onChange={handleChange}
placeholder={intl.formatMessage({
id: 'badges.admin.placeholder',
defaultMessage: 'username',
})}
disabled={disabled}
/>
<div className='help-text'>
<FormattedMessage
id='badges.admin.help_text'
defaultMessage='Этот пользователь будет считаться администратором плагина достижений. Он может создавать типы, а также изменять и выдавать любые значки.'
defaultMessage='Эти пользователи будут считаться администраторами плагина достижений. Они могут создавать типы, а также изменять и выдавать любые достижения.'
/>
</div>
</div>

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();
Review

может тогда лучше сделать client синглтоном прямо в файле api? Чтобы сразу экспортировать и не делать new Client когда нужен запрос?

может тогда лучше сделать client синглтоном прямо в файле api? Чтобы сразу экспортировать и не делать 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();
Review

идентично

идентично
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 ||
Review

мб в мемо?

мб в мемо?
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;
Review

мб в мемо?

мб в мемо?
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) => {
Review

получается что есть api а есть еще какое-то апи, на твое усмотрение можно запихнуть в api плагина

получается что есть api а есть еще какое-то апи, на твое усмотрение можно запихнуть в api плагина
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);
Review

на твое усмотрение - вынести число в константу

на твое усмотрение - вынести число в константу
}, [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;
flex-shrink: 1;
min-width: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 2px;
&::-webkit-scrollbar {
height: 3px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.2);
border-radius: 3px;
}
}
&__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);
}
&__createButton {
background: var(--button-bg, #166de0);
color: var(--button-color, #fff);
border: none;
border-radius: 4px;
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
margin-bottom: 7px;
&:hover {
opacity: 0.88;
}
}
}

View File

@ -1,107 +1,154 @@
import React from 'react';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {FormattedMessage} from 'react-intl';
import {Virtuoso} from 'react-virtuoso';
import {useSelector} from 'react-redux';
import {systemEmojis} from 'mattermost-redux/actions/emojis';
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();
Review

идентично

идентично
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'>
<FormattedMessage
id='badges.loading'
defaultMessage='Загрузка...'
/>
</div>);
}
if (!this.state.badges || this.state.badges.length === 0) {
return (<div className='AllBadges'>
<FormattedMessage
id='badges.no_badges_yet'
defaultMessage='Значков пока нет.'
/>
</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>
<FormattedMessage
id='badges.rhs.all_badges'
defaultMessage='Все значки'
/>
</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

@ -3,7 +3,7 @@ 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';
@ -43,29 +43,46 @@ function getGrantedText(badge: AllBadgesBadge): React.ReactNode {
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}
/>
</span>
</a>
<div>
<div className='badge-name'>{badge.name}</div>
<div className='badge-description'>{markdown(badge.description)}</div>
<div className='badge-type'>
<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}}
/>
{' · '}
{getGrantedText(badge)}
</div>
<div className='granted-by'>{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]);
Review

как будто и не надо в зависимости добавлять

как будто и не надо в зависимости добавлять
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

@ -2,17 +2,19 @@ import React from 'react';
import {FormattedMessage} from 'react-intl';
import {systemEmojis} from 'mattermost-redux/actions/emojis';
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';
@ -21,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;
};
}
@ -54,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) {
@ -92,17 +96,14 @@ class BadgeDetailsComponent extends React.PureComponent<Props, State> {
return (<div>
<FormattedMessage
id='badges.badge_not_found'
defaultMessage='Значок не найден.'
defaultMessage='Достижение не найдено.'
/>
</div>);
}
if (loading) {
return (<div>
<FormattedMessage
id='badges.loading'
defaultMessage='Загрузка...'
/>
return (<div className='BadgeDetails BadgeDetails--loading'>
<div className='spinner'/>
</div>);
}
@ -110,7 +111,7 @@ class BadgeDetailsComponent extends React.PureComponent<Props, State> {
return (<div>
<FormattedMessage
id='badges.badge_not_found'
defaultMessage='Значок не найден.'
defaultMessage='Достижение не найдено.'
/>
</div>);
}
@ -126,30 +127,52 @@ class BadgeDetailsComponent extends React.PureComponent<Props, State> {
});
return (
<div className='BadgeDetails'>
<div><b>
<FormattedMessage
id='badges.rhs.badge_details'
defaultMessage='Детали значка'
/>
</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'>
<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}}
/>
</div>
<div className='created-by'>
{' · '}
<FormattedMessage
id='badges.label.created_by'
defaultMessage='Создал: {username}'
@ -157,14 +180,36 @@ class BadgeDetailsComponent extends React.PureComponent<Props, State> {
/>
</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>
<FormattedMessage
id='badges.granted_to'
defaultMessage='Выдан:'
/>
</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,204 @@ 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 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>
{currentView === RHS_STATE_ALL && canCreateBadge && (
<button
className='AllBadges__createButton'
onClick={handleCreateBadge}
>
<FormattedMessage
id='badges.rhs.create_badge'
defaultMessage='+ Создать достижение'
/>
</button>
)}
{currentView === RHS_STATE_TYPES && canCreateType && (
<button
className='AllBadges__createButton'
onClick={handleCreateType}
>
<FormattedMessage
id='badges.rhs.create_type'
defaultMessage='+ Создать тип'
/>
</button>
)}
</div>
);
case RHS_STATE_DETAIL:
};
const renderContent = () => {
Review

выглядит как отдельный компонент или useMemo

выглядит как отдельный компонент или useMemo
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()}
{renderContent()}
</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,27 +1,52 @@
import React from 'react';
import React, {useState} from 'react';
import {FormattedMessage} from 'react-intl';
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'>
<div className='user-badge-reason'>
<FormattedMessage
id='badges.label.reason'
defaultMessage='Причина: {reason}'
@ -35,7 +60,8 @@ const UserBadgeRow: React.FC<Props> = ({badge, onClick, isCurrentUser}: Props) =
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});
}}
@ -48,42 +74,98 @@ const UserBadgeRow: React.FC<Props> = ({badge, onClick, isCurrentUser}: Props) =
</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>
{reason}
<div className='user-badge-type'>
<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-granted-by'>
<div className='user-badge-meta'>
<FormattedMessage
id='badges.label.granted_by'
defaultMessage='Выдал: {username}'
values={{username: badge.granted_by_name}}
/>
</div>
<div className='user-badge-granted-at'>
<div className='user-badge-meta'>
<FormattedMessage
id='badges.label.granted_at'
defaultMessage='Выдан: {date}'
values={{date: time.toDateString()}}
values={{date: intl.formatDate(time, {day: '2-digit', month: '2-digit', year: 'numeric'})}}
/>
</div>
{reason}
{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

@ -3,7 +3,8 @@ 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';
@ -18,6 +19,7 @@ import './user_badges.scss';
type Props = {
isCurrentUser: boolean;
currentUserID: string;
user: UserProfile | null;
actions: {
setRHSView: (view: RHSState) => void;
@ -58,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) {
@ -84,6 +86,17 @@ class UserBadges extends React.PureComponent<Props, State> {
this.props.actions.setRHSView(RHS_STATE_DETAIL);
}
onRevoke = () => {
if (!this.props.user) {
return;
}
const c = new Client();
Review

пупупуууу

пупупуууу
this.setState({loading: true});
c.getUserBadges(this.props.user.id).then((badges) => {
this.setState({badges, loading: false});
});
}
render() {
if (!this.props.user) {
return (<div>
@ -95,11 +108,8 @@ class UserBadges extends React.PureComponent<Props, State> {
}
if (this.state.loading) {
return (<div>
<FormattedMessage
id='badges.loading'
defaultMessage='Загрузка...'
/>
return (<div className='UserBadges UserBadges--loading'>
<div className='spinner'/>
</div>);
}
@ -107,7 +117,7 @@ class UserBadges extends React.PureComponent<Props, State> {
return (<div>
<FormattedMessage
id='badges.no_badges_yet'
defaultMessage='Значков пока нет.'
defaultMessage='Достижений пока нет.'
/>
</div>);
}
@ -116,9 +126,11 @@ class UserBadges extends React.PureComponent<Props, State> {
return (
<UserBadgeRow
isCurrentUser={this.props.isCurrentUser}
currentUserID={this.props.currentUserID}
key={badge.time}
badge={badge}
onClick={this.onBadgeClick}
onRevoke={this.onRevoke}
/>
);
});
@ -126,18 +138,18 @@ class UserBadges extends React.PureComponent<Props, State> {
const title = this.props.isCurrentUser ? (
<FormattedMessage
id='badges.rhs.my_badges'
defaultMessage='Мои значки'
defaultMessage='Мои достижения'
/>
) : (
<FormattedMessage
id='badges.rhs.user_badges'
defaultMessage='Значки @{username}'
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

@ -10,6 +10,7 @@ import {UserProfile} from 'mattermost-redux/types/users';
import {Ownership} from '../../types/badges';
import './user_row.scss';
type Props = {
ownership: Ownership;
onClick: (user: string) => void;
@ -31,20 +32,24 @@ const UserBadgeRow: React.FC<Props> = ({ownership, onClick}: Props) => {
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'>
<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}}
/>
</div>
<div className='badge-user-granted-at'>
{' · '}
<FormattedMessage
id='badges.label.granted_at'
defaultMessage='Выдан: {date}'
values={{date: time.toDateString()}}
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();
Review

опять клиент

опять клиент
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();
Review

опять он

опять он
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) {
Review

как будто можно инвертировать условие if (loadedValueRef.current !== value && value) {...} или типо того

как будто можно инвертировать условие if (loadedValueRef.current !== value && 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) => (
Review

на твое усмотрение {!profilesLoading && selectedUsers.map...}

на твое усмотрение {!profilesLoading && selectedUsers.map...}
<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,209 +1,175 @@
import {UserProfile} from 'mattermost-redux/types/users';
import React from 'react';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl';
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 TooltipWrapper from '../utils/tooltip_wrapper';
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 {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 = {
intl: IntlShape;
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();
Review

client

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.
Review

как будто filter.map.filter можно заменить на reduce

как будто filter.map.filter можно заменить на reduce
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 {intl} = this.props;
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: string | null = null;
if (badge.reason) {
reason = intl.formatMessage(
{id: 'badges.label.reason', defaultMessage: 'Причина: {reason}'},
{reason: badge.reason},
);
}
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: time.toDateString()},
);
const tooltipLines = [
badge.name,
badge.description,
reason,
grantedBy,
grantedAt,
].filter(Boolean).join('\n');
const badgeComponent = (
<TooltipWrapper tooltipContent={tooltipLines}>
<a onClick={() => this.onBadgeClick(badge)}>
<BadgeImage
badge={badge}
size={BADGE_SIZE}
/>
</a>
</TooltipWrapper>
);
content.push(badgeComponent);
}
let andMore: React.ReactNode = null;
if (nBadges > MAX_BADGES) {
const andMoreText = intl.formatMessage(
{id: 'badges.and_more', defaultMessage: 'и ещё {count}. Нажмите, чтобы увидеть все.'},
{count: nBadges - MAX_BADGES},
);
andMore = (
<TooltipWrapper tooltipContent={andMoreText}>
<button
id='showMoreButton'
onClick={this.onMoreClick}
return (
<div id='badgePlugin'>
<div><b>
Review

немного поплыли стили

немного поплыли стили
<FormattedMessage
id='badges.popover.title'
defaultMessage='Достижения'
/>
</b></div>
<div id='contentContainer'>
{visibleGroups.map(({badge, count}) => (
<TooltipWrapper
key={badge.id}
tooltipContent={
<BadgeTooltip
badge={badge}
count={count}
/>
}
>
<span className={'fa fa-angle-right'}/>
</button>
</TooltipWrapper>
);
}
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}}>
<FormattedMessage
id='badges.loading'
defaultMessage='Загрузка...'
/>
</div>
);
}
return (
<div id='badgePlugin'>
<div><b>
<FormattedMessage
id='badges.popover.title'
defaultMessage='Значки'
/>
</b></div>
<div id='contentContainer' >
{content}
{andMore}
</div>
{loading}
<button
id='grantBadgeButton'
onClick={this.onGrantClick}
>
<span className={'fa fa-plus-circle'}/>
<FormattedMessage
id='badges.grant_badge'
defaultMessage='Выдать значок'
/>
</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 injectIntl(BadgeList);
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'}
Review

такой конструкции я еще не видел, жестка

такой конструкции я еще не видел, жестка
Review

Что тут происходит?) Согласен с Владмиром

Что тут происходит?) Согласен с Владмиром
{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

@ -1,4 +1,3 @@
/* eslint-disable react/prop-types */
import React, {ReactNode, useState, useRef, useEffect} from 'react';
import ReactDOM from 'react-dom';

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

@ -17,6 +17,10 @@ import {useSelector} from 'react-redux';
import {openAddSubscription, openCreateBadge, openCreateType, openRemoveSubscription, setRHSView, setShowRHSAction} from 'actions/actions';
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';
@ -60,6 +64,11 @@ export default class Plugin {
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);

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(
Review

разве первым аргументом не должна идти строка? типо 'getPrevRHSView' или что-то такое?

разве первым аргументом не должна идти строка? типо 'getPrevRHSView' или что-то такое?
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

@ -14,6 +14,7 @@ export interface PluginRegistry {
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 '';
}
}

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/,
@ -108,6 +115,7 @@ module.exports = {
path: path.join(__dirname, '/dist'),
publicPath: '/',
filename: 'main.js',
hashFunction: 'xxhash64',
},
devtool,
mode,

View File

@ -1768,33 +1768,6 @@ __metadata:
languageName: node
linkType: hard
"@giphy/js-fetch-api@npm:^5.1.0":
version: 5.7.0
resolution: "@giphy/js-fetch-api@npm:5.7.0"
dependencies:
"@giphy/js-types": "npm:*"
"@giphy/js-util": "npm:*"
checksum: 10c0/af1990c49ed4d633be04e497f6575e4f798c61348cfca9907f74a8450746bb6ab7336b53eea99e647b902076016d994264979bd09a9aacaa85d40cd610a525ac
languageName: node
linkType: hard
"@giphy/js-types@npm:*":
version: 5.1.0
resolution: "@giphy/js-types@npm:5.1.0"
checksum: 10c0/8a76b9fd72d10d47486f26902a2fdc083712b4e0582bb2b27698b2c9c58fd3ecfa07d14ac30bcb80ec0f2ec35b3de7f7791d9d9751b0e6cb7d6fa5c39d52479f
languageName: node
linkType: hard
"@giphy/js-util@npm:*":
version: 5.2.0
resolution: "@giphy/js-util@npm:5.2.0"
dependencies:
"@giphy/js-types": "npm:*"
uuid: "npm:^9.0.0"
checksum: 10c0/0782a4fa1d7b037b4010f76966f5b8347c5732f57516d191471d746cd733f2d212f7461c4ff613961e51cbd65584a1f5e7202dbe5ae2231978b5c52f51a2e7f7
languageName: node
linkType: hard
"@isaacs/balanced-match@npm:^4.0.1":
version: 4.0.1
resolution: "@isaacs/balanced-match@npm:4.0.1"
@ -2395,7 +2368,7 @@ __metadata:
languageName: node
linkType: hard
"@types/eslint-scope@npm:^3.7.0":
"@types/eslint-scope@npm:^3.7.7":
version: 3.7.7
resolution: "@types/eslint-scope@npm:3.7.7"
dependencies:
@ -2415,20 +2388,13 @@ __metadata:
languageName: node
linkType: hard
"@types/estree@npm:*":
"@types/estree@npm:*, @types/estree@npm:^1.0.8":
version: 1.0.8
resolution: "@types/estree@npm:1.0.8"
checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5
languageName: node
linkType: hard
"@types/estree@npm:^0.0.47":
version: 0.0.47
resolution: "@types/estree@npm:0.0.47"
checksum: 10c0/f4541984097640b8fd594ce5870c7cc4116d0be50caa5f992ab1c6731831cb2083deb8aac4644ef83d638bb0321ac5e42cd315d45c3c62f5a3e54fa10b59c6fe
languageName: node
linkType: hard
"@types/graceful-fs@npm:^4.1.2":
version: 4.1.9
resolution: "@types/graceful-fs@npm:4.1.9"
@ -2491,7 +2457,7 @@ __metadata:
languageName: node
linkType: hard
"@types/json-schema@npm:*, @types/json-schema@npm:^7.0.3, @types/json-schema@npm:^7.0.5, @types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9":
"@types/json-schema@npm:*, @types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.3, @types/json-schema@npm:^7.0.5, @types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9":
version: 7.0.15
resolution: "@types/json-schema@npm:7.0.15"
checksum: 10c0/a996a745e6c5d60292f36731dd41341339d4eeed8180bb09226e5c8d23759067692b1d88e5d91d72ee83dfc00d3aca8e7bd43ea120516c17922cbcb7c3e252db
@ -2639,25 +2605,6 @@ __metadata:
languageName: node
linkType: hard
"@types/react@npm:*":
version: 19.2.14
resolution: "@types/react@npm:19.2.14"
dependencies:
csstype: "npm:^3.2.2"
checksum: 10c0/7d25bf41b57719452d86d2ac0570b659210402707313a36ee612666bf11275a1c69824f8c3ee1fdca077ccfe15452f6da8f1224529b917050eb2d861e52b59b7
languageName: node
linkType: hard
"@types/react@npm:16 || 17 || 18":
version: 18.3.28
resolution: "@types/react@npm:18.3.28"
dependencies:
"@types/prop-types": "npm:*"
csstype: "npm:^3.2.2"
checksum: 10c0/683e19cd12b5c691215529af2e32b5ffbaccae3bf0ba93bfafa0e460e8dfee18423afed568be2b8eadf4b837c3749dd296a4f64e2d79f68fa66962c05f5af661
languageName: node
linkType: hard
"@types/react@npm:17.0.3":
version: 17.0.3
resolution: "@types/react@npm:17.0.3"
@ -2799,154 +2746,154 @@ __metadata:
languageName: node
linkType: hard
"@webassemblyjs/ast@npm:1.11.0":
version: 1.11.0
resolution: "@webassemblyjs/ast@npm:1.11.0"
"@webassemblyjs/ast@npm:1.14.1, @webassemblyjs/ast@npm:^1.14.1":
version: 1.14.1
resolution: "@webassemblyjs/ast@npm:1.14.1"
dependencies:
"@webassemblyjs/helper-numbers": "npm:1.11.0"
"@webassemblyjs/helper-wasm-bytecode": "npm:1.11.0"
checksum: 10c0/8cf4369381f5212fa04e9e42517c1dbfb5e71c8612226ff48924f2816dc48cec025ee753f4ff56258d1be0ee9f90f409f8ccea7ee738411f81677acf9fabe9e5
"@webassemblyjs/helper-numbers": "npm:1.13.2"
"@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2"
checksum: 10c0/67a59be8ed50ddd33fbb2e09daa5193ac215bf7f40a9371be9a0d9797a114d0d1196316d2f3943efdb923a3d809175e1563a3cb80c814fb8edccd1e77494972b
languageName: node
linkType: hard
"@webassemblyjs/floating-point-hex-parser@npm:1.11.0":
version: 1.11.0
resolution: "@webassemblyjs/floating-point-hex-parser@npm:1.11.0"
checksum: 10c0/8e14aa3e0eaecbfe193660af7b9feb0724797a503975fdd8eff10ce8e5da23c4faf3073248f194848eb283d4cf63d57b218d15f2a2203aef7d02dea598e36a54
"@webassemblyjs/floating-point-hex-parser@npm:1.13.2":
version: 1.13.2
resolution: "@webassemblyjs/floating-point-hex-parser@npm:1.13.2"
checksum: 10c0/0e88bdb8b50507d9938be64df0867f00396b55eba9df7d3546eb5dc0ca64d62e06f8d881ec4a6153f2127d0f4c11d102b6e7d17aec2f26bb5ff95a5e60652412
languageName: node
linkType: hard
"@webassemblyjs/helper-api-error@npm:1.11.0":
version: 1.11.0
resolution: "@webassemblyjs/helper-api-error@npm:1.11.0"
checksum: 10c0/6a2cebd7f7846d36d1bbb213ce2135d5fd0d6b16617619108c95a993a39470a2238adcc6c1d56b569212b8a711b9559d758f6a87bc16c80c383b70142961e8d7
"@webassemblyjs/helper-api-error@npm:1.13.2":
version: 1.13.2
resolution: "@webassemblyjs/helper-api-error@npm:1.13.2"
checksum: 10c0/31be497f996ed30aae4c08cac3cce50c8dcd5b29660383c0155fce1753804fc55d47fcba74e10141c7dd2899033164e117b3bcfcda23a6b043e4ded4f1003dfb
languageName: node
linkType: hard
"@webassemblyjs/helper-buffer@npm:1.11.0":
version: 1.11.0
resolution: "@webassemblyjs/helper-buffer@npm:1.11.0"
checksum: 10c0/73037720ffa6a5e12444cf753eff780fcceba0b0f654b531a4d24d93882c9e77cd92455602837dc4cd040f11228ef674ecca55d40e05292d187eed9653ebf8fd
"@webassemblyjs/helper-buffer@npm:1.14.1":
version: 1.14.1
resolution: "@webassemblyjs/helper-buffer@npm:1.14.1"
checksum: 10c0/0d54105dc373c0fe6287f1091e41e3a02e36cdc05e8cf8533cdc16c59ff05a646355415893449d3768cda588af451c274f13263300a251dc11a575bc4c9bd210
languageName: node
linkType: hard
"@webassemblyjs/helper-numbers@npm:1.11.0":
version: 1.11.0
resolution: "@webassemblyjs/helper-numbers@npm:1.11.0"
"@webassemblyjs/helper-numbers@npm:1.13.2":
version: 1.13.2
resolution: "@webassemblyjs/helper-numbers@npm:1.13.2"
dependencies:
"@webassemblyjs/floating-point-hex-parser": "npm:1.11.0"
"@webassemblyjs/helper-api-error": "npm:1.11.0"
"@webassemblyjs/floating-point-hex-parser": "npm:1.13.2"
"@webassemblyjs/helper-api-error": "npm:1.13.2"
"@xtuc/long": "npm:4.2.2"
checksum: 10c0/2dab8e53898a89363e778b54439cf9a8c1c2c31cddb99b54e642b2947e9f9949aed42d3a0f3bb57238a56b8205140de25a4ee07f78c605458f38b7effd9462be
checksum: 10c0/9c46852f31b234a8fb5a5a9d3f027bc542392a0d4de32f1a9c0075d5e8684aa073cb5929b56df565500b3f9cc0a2ab983b650314295b9bf208d1a1651bfc825a
languageName: node
linkType: hard
"@webassemblyjs/helper-wasm-bytecode@npm:1.11.0":
version: 1.11.0
resolution: "@webassemblyjs/helper-wasm-bytecode@npm:1.11.0"
checksum: 10c0/947e2132d1137d51e93d6ccd7380c35ad98e9e76e0bb203d341c00b8d6ad643792c0d584a89f415edca467327ae28305cd8fb20a04df37d804c0438c71e5b1f0
"@webassemblyjs/helper-wasm-bytecode@npm:1.13.2":
version: 1.13.2
resolution: "@webassemblyjs/helper-wasm-bytecode@npm:1.13.2"
checksum: 10c0/c4355d14f369b30cf3cbdd3acfafc7d0488e086be6d578e3c9780bd1b512932352246be96e034e2a7fcfba4f540ec813352f312bfcbbfe5bcfbf694f82ccc682
languageName: node
linkType: hard
"@webassemblyjs/helper-wasm-section@npm:1.11.0":
version: 1.11.0
resolution: "@webassemblyjs/helper-wasm-section@npm:1.11.0"
"@webassemblyjs/helper-wasm-section@npm:1.14.1":
version: 1.14.1
resolution: "@webassemblyjs/helper-wasm-section@npm:1.14.1"
dependencies:
"@webassemblyjs/ast": "npm:1.11.0"
"@webassemblyjs/helper-buffer": "npm:1.11.0"
"@webassemblyjs/helper-wasm-bytecode": "npm:1.11.0"
"@webassemblyjs/wasm-gen": "npm:1.11.0"
checksum: 10c0/94472ea408338be95a5b879e8e360d00ec8f5d1f079e8f18d8d4d11baf7bad4c11a83711605e09b224749e35f54b1d9ba97d8e751c005641979bd6c57a977a8a
"@webassemblyjs/ast": "npm:1.14.1"
"@webassemblyjs/helper-buffer": "npm:1.14.1"
"@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2"
"@webassemblyjs/wasm-gen": "npm:1.14.1"
checksum: 10c0/1f9b33731c3c6dbac3a9c483269562fa00d1b6a4e7133217f40e83e975e636fd0f8736e53abd9a47b06b66082ecc976c7384391ab0a68e12d509ea4e4b948d64
languageName: node
linkType: hard
"@webassemblyjs/ieee754@npm:1.11.0":
version: 1.11.0
resolution: "@webassemblyjs/ieee754@npm:1.11.0"
"@webassemblyjs/ieee754@npm:1.13.2":
version: 1.13.2
resolution: "@webassemblyjs/ieee754@npm:1.13.2"
dependencies:
"@xtuc/ieee754": "npm:^1.2.0"
checksum: 10c0/c036fe4c933e77caaaa0850bfa87e3b9be0416a87c8915e62511fbebe98b0b21425bc2910f94839f80c15fd1d1f7bcf858ac7ea51cbfd28d3e6f93f79a933ae1
checksum: 10c0/2e732ca78c6fbae3c9b112f4915d85caecdab285c0b337954b180460290ccd0fb00d2b1dc4bb69df3504abead5191e0d28d0d17dfd6c9d2f30acac8c4961c8a7
languageName: node
linkType: hard
"@webassemblyjs/leb128@npm:1.11.0":
version: 1.11.0
resolution: "@webassemblyjs/leb128@npm:1.11.0"
"@webassemblyjs/leb128@npm:1.13.2":
version: 1.13.2
resolution: "@webassemblyjs/leb128@npm:1.13.2"
dependencies:
"@xtuc/long": "npm:4.2.2"
checksum: 10c0/efd79fc32923e857907e0ab9cdc24b2694f79f09f820fe031e64c055db0d39450310e39cee220b55a9c7dc899715030c4722dbfa65cdad3ef7b4ac931a1a2f6e
checksum: 10c0/dad5ef9e383c8ab523ce432dfd80098384bf01c45f70eb179d594f85ce5db2f80fa8c9cba03adafd85684e6d6310f0d3969a882538975989919329ac4c984659
languageName: node
linkType: hard
"@webassemblyjs/utf8@npm:1.11.0":
version: 1.11.0
resolution: "@webassemblyjs/utf8@npm:1.11.0"
checksum: 10c0/86cb717f4b174c8ad4a4ee720d85f2e926bd5a7e3a996fc47e2cd5097547be24f2406f46b83775d820c4d6dca68fc4f447a5ce40267c591126967d2087d13773
"@webassemblyjs/utf8@npm:1.13.2":
version: 1.13.2
resolution: "@webassemblyjs/utf8@npm:1.13.2"
checksum: 10c0/d3fac9130b0e3e5a1a7f2886124a278e9323827c87a2b971e6d0da22a2ba1278ac9f66a4f2e363ecd9fac8da42e6941b22df061a119e5c0335f81006de9ee799
languageName: node
linkType: hard
"@webassemblyjs/wasm-edit@npm:1.11.0":
version: 1.11.0
resolution: "@webassemblyjs/wasm-edit@npm:1.11.0"
"@webassemblyjs/wasm-edit@npm:^1.14.1":
version: 1.14.1
resolution: "@webassemblyjs/wasm-edit@npm:1.14.1"
dependencies:
"@webassemblyjs/ast": "npm:1.11.0"
"@webassemblyjs/helper-buffer": "npm:1.11.0"
"@webassemblyjs/helper-wasm-bytecode": "npm:1.11.0"
"@webassemblyjs/helper-wasm-section": "npm:1.11.0"
"@webassemblyjs/wasm-gen": "npm:1.11.0"
"@webassemblyjs/wasm-opt": "npm:1.11.0"
"@webassemblyjs/wasm-parser": "npm:1.11.0"
"@webassemblyjs/wast-printer": "npm:1.11.0"
checksum: 10c0/123b8368ec13b7645f190eba24ac0eb0c1a69878c4d6fee3be5f640cf124a1889244e55a28b07196a16a5dff43a3c2d6afebf819794d7c00f29d74309adea77e
"@webassemblyjs/ast": "npm:1.14.1"
"@webassemblyjs/helper-buffer": "npm:1.14.1"
"@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2"
"@webassemblyjs/helper-wasm-section": "npm:1.14.1"
"@webassemblyjs/wasm-gen": "npm:1.14.1"
"@webassemblyjs/wasm-opt": "npm:1.14.1"
"@webassemblyjs/wasm-parser": "npm:1.14.1"
"@webassemblyjs/wast-printer": "npm:1.14.1"
checksum: 10c0/5ac4781086a2ca4b320bdbfd965a209655fe8a208ca38d89197148f8597e587c9a2c94fb6bd6f1a7dbd4527c49c6844fcdc2af981f8d793a97bf63a016aa86d2
languageName: node
linkType: hard
"@webassemblyjs/wasm-gen@npm:1.11.0":
version: 1.11.0
resolution: "@webassemblyjs/wasm-gen@npm:1.11.0"
"@webassemblyjs/wasm-gen@npm:1.14.1":
version: 1.14.1
resolution: "@webassemblyjs/wasm-gen@npm:1.14.1"
dependencies:
"@webassemblyjs/ast": "npm:1.11.0"
"@webassemblyjs/helper-wasm-bytecode": "npm:1.11.0"
"@webassemblyjs/ieee754": "npm:1.11.0"
"@webassemblyjs/leb128": "npm:1.11.0"
"@webassemblyjs/utf8": "npm:1.11.0"
checksum: 10c0/4783c98b2852ee9b2477c48a61861943b25a4e1fc9e93677919ede593790d85026ac7d8e92470cb6b864acede89a28f8395118bdaa60946faec8deec22e92c4f
"@webassemblyjs/ast": "npm:1.14.1"
"@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2"
"@webassemblyjs/ieee754": "npm:1.13.2"
"@webassemblyjs/leb128": "npm:1.13.2"
"@webassemblyjs/utf8": "npm:1.13.2"
checksum: 10c0/d678810d7f3f8fecb2e2bdadfb9afad2ec1d2bc79f59e4711ab49c81cec578371e22732d4966f59067abe5fba8e9c54923b57060a729d28d408e608beef67b10
languageName: node
linkType: hard
"@webassemblyjs/wasm-opt@npm:1.11.0":
version: 1.11.0
resolution: "@webassemblyjs/wasm-opt@npm:1.11.0"
"@webassemblyjs/wasm-opt@npm:1.14.1":
version: 1.14.1
resolution: "@webassemblyjs/wasm-opt@npm:1.14.1"
dependencies:
"@webassemblyjs/ast": "npm:1.11.0"
"@webassemblyjs/helper-buffer": "npm:1.11.0"
"@webassemblyjs/wasm-gen": "npm:1.11.0"
"@webassemblyjs/wasm-parser": "npm:1.11.0"
checksum: 10c0/25c83c7a3cd923d413e0bae8df01e0a43d381314936a8473bdcff05a968d9398c4b7e42b8d86c1aaefef52fbfac5f52acab72822208bb7cff31c2d9d6c53de8c
"@webassemblyjs/ast": "npm:1.14.1"
"@webassemblyjs/helper-buffer": "npm:1.14.1"
"@webassemblyjs/wasm-gen": "npm:1.14.1"
"@webassemblyjs/wasm-parser": "npm:1.14.1"
checksum: 10c0/515bfb15277ee99ba6b11d2232ddbf22aed32aad6d0956fe8a0a0a004a1b5a3a277a71d9a3a38365d0538ac40d1b7b7243b1a244ad6cd6dece1c1bb2eb5de7ee
languageName: node
linkType: hard
"@webassemblyjs/wasm-parser@npm:1.11.0":
version: 1.11.0
resolution: "@webassemblyjs/wasm-parser@npm:1.11.0"
"@webassemblyjs/wasm-parser@npm:1.14.1, @webassemblyjs/wasm-parser@npm:^1.14.1":
version: 1.14.1
resolution: "@webassemblyjs/wasm-parser@npm:1.14.1"
dependencies:
"@webassemblyjs/ast": "npm:1.11.0"
"@webassemblyjs/helper-api-error": "npm:1.11.0"
"@webassemblyjs/helper-wasm-bytecode": "npm:1.11.0"
"@webassemblyjs/ieee754": "npm:1.11.0"
"@webassemblyjs/leb128": "npm:1.11.0"
"@webassemblyjs/utf8": "npm:1.11.0"
checksum: 10c0/b63ae587d841c5545a232e4acd57f61e1f7e5e24ce842b3948d841ce4d38c2a5f51918a71448e4c55454721eaab5dce28a75c74e2229ada1c115e30ee65896cc
"@webassemblyjs/ast": "npm:1.14.1"
"@webassemblyjs/helper-api-error": "npm:1.13.2"
"@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2"
"@webassemblyjs/ieee754": "npm:1.13.2"
"@webassemblyjs/leb128": "npm:1.13.2"
"@webassemblyjs/utf8": "npm:1.13.2"
checksum: 10c0/95427b9e5addbd0f647939bd28e3e06b8deefdbdadcf892385b5edc70091bf9b92fa5faac3fce8333554437c5d85835afef8c8a7d9d27ab6ba01ffab954db8c6
languageName: node
linkType: hard
"@webassemblyjs/wast-printer@npm:1.11.0":
version: 1.11.0
resolution: "@webassemblyjs/wast-printer@npm:1.11.0"
"@webassemblyjs/wast-printer@npm:1.14.1":
version: 1.14.1
resolution: "@webassemblyjs/wast-printer@npm:1.14.1"
dependencies:
"@webassemblyjs/ast": "npm:1.11.0"
"@webassemblyjs/ast": "npm:1.14.1"
"@xtuc/long": "npm:4.2.2"
checksum: 10c0/05fc5cc9a167d36df50a825be77da845d0834f0f819ae8d36cb2fd2aa1f524012c01ee9f463bd251e39b6bcf1f79e327fc444775e74f4af578fdd5794c9d97f2
checksum: 10c0/8d7768608996a052545251e896eac079c98e0401842af8dd4de78fba8d90bd505efb6c537e909cd6dae96e09db3fa2e765a6f26492553a675da56e2db51f9d24
languageName: node
linkType: hard
@ -3021,6 +2968,15 @@ __metadata:
languageName: node
linkType: hard
"acorn-import-phases@npm:^1.0.3":
version: 1.0.4
resolution: "acorn-import-phases@npm:1.0.4"
peerDependencies:
acorn: ^8.14.0
checksum: 10c0/338eb46fc1aed5544f628344cb9af189450b401d152ceadbf1f5746901a5d923016cd0e7740d5606062d374fdf6941c29bb515d2bd133c4f4242d5d4cd73a3c7
languageName: node
linkType: hard
"acorn-jsx@npm:^5.3.1":
version: 5.3.2
resolution: "acorn-jsx@npm:5.3.2"
@ -3046,7 +3002,7 @@ __metadata:
languageName: node
linkType: hard
"acorn@npm:^8.0.4, acorn@npm:^8.15.0, acorn@npm:^8.2.4":
"acorn@npm:^8.15.0, acorn@npm:^8.2.4":
version: 8.15.0
resolution: "acorn@npm:8.15.0"
bin:
@ -3055,6 +3011,15 @@ __metadata:
languageName: node
linkType: hard
"acorn@npm:^8.16.0":
version: 8.16.0
resolution: "acorn@npm:8.16.0"
bin:
acorn: bin/acorn
checksum: 10c0/c9c52697227661b68d0debaf972222d4f622aa06b185824164e153438afa7b08273432ca43ea792cadb24dada1d46f6f6bb1ef8de9956979288cc1b96bf9914e
languageName: node
linkType: hard
"add-px-to-style@npm:1.0.0":
version: 1.0.0
resolution: "add-px-to-style@npm:1.0.0"
@ -3481,18 +3446,18 @@ __metadata:
languageName: node
linkType: hard
"babel-loader@npm:8.2.2":
version: 8.2.2
resolution: "babel-loader@npm:8.2.2"
"babel-loader@npm:^8.3.0":
version: 8.4.1
resolution: "babel-loader@npm:8.4.1"
dependencies:
find-cache-dir: "npm:^3.3.1"
loader-utils: "npm:^1.4.0"
loader-utils: "npm:^2.0.4"
make-dir: "npm:^3.1.0"
schema-utils: "npm:^2.6.5"
peerDependencies:
"@babel/core": ^7.0.0
webpack: ">=2"
checksum: 10c0/c4e1e042af99a0c1ed83d4a9e3436c333b66a10459561eff921ebe274d75425b649cc317e1389a149931f29c5b03a5a46acc1daeb849d10582ce2bb65f697a5f
checksum: 10c0/efdca9c3ef502af58b923a32123d660c54fd0be125b7b64562c8a43bda0a3a55dac0db32331674104e7e5184061b75c3a0e395b2c5ccdc7cb2125dd9ec7108d2
languageName: node
linkType: hard
@ -3835,7 +3800,7 @@ __metadata:
languageName: node
linkType: hard
"browserslist@npm:^4.14.5, browserslist@npm:^4.24.0, browserslist@npm:^4.28.1":
"browserslist@npm:^4.24.0, browserslist@npm:^4.28.1":
version: 4.28.1
resolution: "browserslist@npm:4.28.1"
dependencies:
@ -4549,7 +4514,7 @@ __metadata:
languageName: node
linkType: hard
"csstype@npm:^3.0.2, csstype@npm:^3.2.2":
"csstype@npm:^3.0.2":
version: 3.2.3
resolution: "csstype@npm:3.2.3"
checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce
@ -4966,13 +4931,13 @@ __metadata:
languageName: node
linkType: hard
"enhanced-resolve@npm:^5.8.0":
version: 5.19.0
resolution: "enhanced-resolve@npm:5.19.0"
"enhanced-resolve@npm:^5.20.0":
version: 5.20.0
resolution: "enhanced-resolve@npm:5.20.0"
dependencies:
graceful-fs: "npm:^4.2.4"
tapable: "npm:^2.3.0"
checksum: 10c0/966b1dffb82d5f6a4d6a86e904e812104a999066aa29f9223040aaa751e7c453b462a3f5ef91f8bd4408131ff6f7f90651dd1c804bdcb7944e2099a9c2e45ee2
checksum: 10c0/4ed5f38406fc9ad74c58a3d63b8215862243ab0ed6b0efc51ccdb72cdcedd3ac8638abe298680b279d7a83c3cb140e5eea7a5f8bd99696c74588f07ad89a95a7
languageName: node
linkType: hard
@ -5213,10 +5178,10 @@ __metadata:
languageName: node
linkType: hard
"es-module-lexer@npm:^0.4.0":
version: 0.4.1
resolution: "es-module-lexer@npm:0.4.1"
checksum: 10c0/6463778f04367979d7770cefb1969b6bfc277319e8437a39718b3516df16b1b496b725ceec96a2d24975837a15cf4d56838f16d9c8c7640ad13ad9c8f93ad6fc
"es-module-lexer@npm:^2.0.0":
version: 2.0.0
resolution: "es-module-lexer@npm:2.0.0"
checksum: 10c0/ae78dbbd43035a4b972c46cfb6877e374ea290adfc62bc2f5a083fea242c0b2baaab25c5886af86be55f092f4a326741cb94334cd3c478c383fdc8a9ec5ff817
languageName: node
linkType: hard
@ -5405,7 +5370,7 @@ __metadata:
languageName: node
linkType: hard
"eslint-scope@npm:^5.0.0, eslint-scope@npm:^5.1.1":
"eslint-scope@npm:5.1.1, eslint-scope@npm:^5.0.0, eslint-scope@npm:^5.1.1":
version: 5.1.1
resolution: "eslint-scope@npm:5.1.1"
dependencies:
@ -5921,19 +5886,6 @@ __metadata:
languageName: node
linkType: hard
"form-data@npm:^4.0.0":
version: 4.0.5
resolution: "form-data@npm:4.0.5"
dependencies:
asynckit: "npm:^0.4.0"
combined-stream: "npm:^1.0.8"
es-set-tostringtag: "npm:^2.1.0"
hasown: "npm:^2.0.2"
mime-types: "npm:^2.1.12"
checksum: 10c0/dd6b767ee0bbd6d84039db12a0fa5a2028160ffbfaba1800695713b46ae974a5f6e08b3356c3195137f8530dcd9dfcb5d5ae1eeff53d0db1e5aad863b619ce3b
languageName: node
linkType: hard
"fragment-cache@npm:^0.2.1":
version: 0.2.1
resolution: "fragment-cache@npm:0.2.1"
@ -6229,7 +6181,7 @@ __metadata:
languageName: node
linkType: hard
"graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6":
"graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6":
version: 4.2.11
resolution: "graceful-fs@npm:4.2.11"
checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2
@ -7800,14 +7752,7 @@ __metadata:
languageName: node
linkType: hard
"json-parse-better-errors@npm:^1.0.2":
version: 1.0.2
resolution: "json-parse-better-errors@npm:1.0.2"
checksum: 10c0/2f1287a7c833e397c9ddd361a78638e828fc523038bb3441fd4fc144cfd2c6cd4963ffb9e207e648cf7b692600f1e1e524e965c32df5152120910e4903a47dcb
languageName: node
linkType: hard
"json-parse-even-better-errors@npm:^2.3.0":
"json-parse-even-better-errors@npm:^2.3.0, json-parse-even-better-errors@npm:^2.3.1":
version: 2.3.1
resolution: "json-parse-even-better-errors@npm:2.3.1"
checksum: 10c0/140932564c8f0b88455432e0f33c4cb4086b8868e37524e07e723f4eaedb9425bdc2bafd71bd1d9765bd15fd1e2d126972bc83990f55c467168c228c24d665f3
@ -7842,7 +7787,7 @@ __metadata:
languageName: node
linkType: hard
"json5@npm:^1.0.1, json5@npm:^1.0.2":
"json5@npm:^1.0.2":
version: 1.0.2
resolution: "json5@npm:1.0.2"
dependencies:
@ -7965,25 +7910,14 @@ __metadata:
languageName: node
linkType: hard
"loader-runner@npm:^4.2.0":
"loader-runner@npm:^4.3.1":
version: 4.3.1
resolution: "loader-runner@npm:4.3.1"
checksum: 10c0/a523b6329f114e0a98317158e30a7dfce044b731521be5399464010472a93a15ece44757d1eaed1d8845019869c5390218bc1c7c3110f4eeaef5157394486eac
languageName: node
linkType: hard
"loader-utils@npm:^1.4.0":
version: 1.4.2
resolution: "loader-utils@npm:1.4.2"
dependencies:
big.js: "npm:^5.2.2"
emojis-list: "npm:^3.0.0"
json5: "npm:^1.0.1"
checksum: 10c0/2b726088b5526f7605615e3e28043ae9bbd2453f4a85898e1151f3c39dbf7a2b65d09f3996bc588d92ac7e717ded529d3e1ea3ea42c433393be84a58234a2f53
languageName: node
linkType: hard
"loader-utils@npm:^2.0.0":
"loader-utils@npm:^2.0.0, loader-utils@npm:^2.0.4":
version: 2.0.4
resolution: "loader-utils@npm:2.0.4"
dependencies:
@ -8062,20 +7996,6 @@ __metadata:
languageName: node
linkType: hard
"loop-plugin-sdk@https://artifacts.wilix.dev/repository/npm-public-loop/loop-plugin-sdk/-/loop-plugin-sdk-0.1.6.tgz":
version: 0.1.6
resolution: "loop-plugin-sdk@https://artifacts.wilix.dev/repository/npm-public-loop/loop-plugin-sdk/-/loop-plugin-sdk-0.1.6.tgz"
dependencies:
"@giphy/js-fetch-api": "npm:^5.1.0"
form-data: "npm:^4.0.0"
rudder-sdk-js: "npm:^2.41.0"
serialize-error: "npm:^11.0.2"
shallow-equals: "npm:^1.0.0"
timezones.json: "npm:^1.7.1"
checksum: 10c0/661ed3b99bb666a5fe024dadbbb2f1cf6d7d43bed3e94e87171e76985b31f82b8b4042358f30b60bd097e452185c957ec55fc03b15f45ac1ca70a6239fac1b71
languageName: node
linkType: hard
"loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0":
version: 1.4.0
resolution: "loose-envify@npm:1.4.0"
@ -9648,6 +9568,16 @@ __metadata:
languageName: node
linkType: hard
"react-virtuoso@npm:^4.18.1":
version: 4.18.1
resolution: "react-virtuoso@npm:4.18.1"
peerDependencies:
react: ">=16 || >=17 || >= 18 || >= 19"
react-dom: ">=16 || >=17 || >= 18 || >=19"
checksum: 10c0/ed17f580ad8d625ef9e0278ed12190bbadbacf7e39434047b7994e4967ad9d868b66eaee8a66a2890d2964d99d9b266a4657375488c19b1e58de252fb2e8d3e5
languageName: node
linkType: hard
"react@npm:17.0.2":
version: 17.0.2
resolution: "react@npm:17.0.2"
@ -10206,7 +10136,7 @@ __metadata:
"@typescript-eslint/parser": "npm:4.22.0"
babel-eslint: "npm:10.1.0"
babel-jest: "npm:26.6.3"
babel-loader: "npm:8.2.2"
babel-loader: "npm:^8.3.0"
babel-plugin-typescript-to-proptypes: "npm:1.4.2"
core-js: "npm:3.10.2"
css-loader: "npm:5.2.4"
@ -10223,19 +10153,19 @@ __metadata:
jest: "npm:26.6.3"
jest-canvas-mock: "npm:2.3.1"
jest-junit: "npm:12.0.0"
loop-plugin-sdk: "https://artifacts.wilix.dev/repository/npm-public-loop/loop-plugin-sdk/-/loop-plugin-sdk-0.1.6.tgz"
mattermost-redux: "npm:5.33.1"
memoize-one: "npm:^5.2.1"
react: "npm:17.0.2"
react-custom-scrollbars: "npm:^4.2.1"
react-intl: "npm:6.8.9"
react-redux: "npm:7.2.3"
react-virtuoso: "npm:^4.18.1"
redux: "npm:4.0.5"
sass: "npm:1.86.0"
sass-loader: "npm:11.0.1"
style-loader: "npm:2.0.0"
typescript: "npm:4.2.4"
webpack: "npm:5.34.0"
webpack: "npm:^5.54.0"
webpack-cli: "npm:4.6.0"
languageName: unknown
linkType: soft
@ -10264,13 +10194,6 @@ __metadata:
languageName: node
linkType: hard
"rudder-sdk-js@npm:^2.41.0":
version: 2.52.8
resolution: "rudder-sdk-js@npm:2.52.8"
checksum: 10c0/62732c3402bf1858c1100b287bd72d7e54c293b308f2e96c633aa29dfd8758bbee50b1aa93011ea98d4960b8fb98d2ab88a69536b5fe35bd794e85d5cd4ae861
languageName: node
linkType: hard
"run-parallel@npm:^1.1.9":
version: 1.2.0
resolution: "run-parallel@npm:1.2.0"
@ -10466,7 +10389,7 @@ __metadata:
languageName: node
linkType: hard
"schema-utils@npm:^4.3.0":
"schema-utils@npm:^4.3.0, schema-utils@npm:^4.3.3":
version: 4.3.3
resolution: "schema-utils@npm:4.3.3"
dependencies:
@ -10514,24 +10437,6 @@ __metadata:
languageName: node
linkType: hard
"serialize-error@npm:^11.0.2":
version: 11.0.3
resolution: "serialize-error@npm:11.0.3"
dependencies:
type-fest: "npm:^2.12.2"
checksum: 10c0/7263603883b8936650819f0fd5150d41427b317432678b21722c54b85367ae15b8552865eb7f3f39ba71a32a003730a2e2e971e6909431eb54db70a3ef8eca17
languageName: node
linkType: hard
"serialize-javascript@npm:^6.0.2":
version: 6.0.2
resolution: "serialize-javascript@npm:6.0.2"
dependencies:
randombytes: "npm:^2.1.0"
checksum: 10c0/2dd09ef4b65a1289ba24a788b1423a035581bef60817bea1f01eda8e3bda623f86357665fe7ac1b50f6d4f583f97db9615b3f07b2a2e8cbcb75033965f771dd2
languageName: node
linkType: hard
"set-blocking@npm:^2.0.0":
version: 2.0.0
resolution: "set-blocking@npm:2.0.0"
@ -10617,7 +10522,7 @@ __metadata:
languageName: node
linkType: hard
"shallow-equals@npm:1.0.0, shallow-equals@npm:^1.0.0":
"shallow-equals@npm:1.0.0":
version: 1.0.0
resolution: "shallow-equals@npm:1.0.0"
checksum: 10c0/ba7c87947126fcfdd31d6c473c5785c235c385dcc045dd6b1543366b1e86aa8e8f69289346ffee0b13a845365fc6f3d21badd8c00e2b6c2b1d0e84d69bcf4487
@ -10839,13 +10744,6 @@ __metadata:
languageName: node
linkType: hard
"source-list-map@npm:^2.0.1":
version: 2.0.1
resolution: "source-list-map@npm:2.0.1"
checksum: 10c0/2e5e421b185dcd857f46c3c70e2e711a65d717b78c5f795e2e248c9d67757882ea989b80ebc08cf164eeeda5f4be8aa95d3b990225070b2daaaf3257c5958149
languageName: node
linkType: hard
"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.2.1":
version: 1.2.1
resolution: "source-map-js@npm:1.2.1"
@ -11263,7 +11161,7 @@ __metadata:
languageName: node
linkType: hard
"tapable@npm:^2.1.1, tapable@npm:^2.3.0":
"tapable@npm:^2.3.0":
version: 2.3.0
resolution: "tapable@npm:2.3.0"
checksum: 10c0/cb9d67cc2c6a74dedc812ef3085d9d681edd2c1fa18e4aef57a3c0605fdbe44e6b8ea00bd9ef21bc74dd45314e39d31227aa031ebf2f5e38164df514136f2681
@ -11293,14 +11191,13 @@ __metadata:
languageName: node
linkType: hard
"terser-webpack-plugin@npm:^5.1.1":
version: 5.3.16
resolution: "terser-webpack-plugin@npm:5.3.16"
"terser-webpack-plugin@npm:^5.3.17":
version: 5.4.0
resolution: "terser-webpack-plugin@npm:5.4.0"
dependencies:
"@jridgewell/trace-mapping": "npm:^0.3.25"
jest-worker: "npm:^27.4.5"
schema-utils: "npm:^4.3.0"
serialize-javascript: "npm:^6.0.2"
terser: "npm:^5.31.1"
peerDependencies:
webpack: ^5.1.0
@ -11311,7 +11208,7 @@ __metadata:
optional: true
uglify-js:
optional: true
checksum: 10c0/39e37c5b3015c1a5354a3633f77235677bfa06eac2608ce26d258b1d1a74070a99910319a6f2f2c437eb61dc321f66434febe01d78e73fa96b4d4393b813f4cf
checksum: 10c0/1feed4b9575af795dae6af0c8f0d76d6e1fb7b357b8628d90e834c23a651b918a58cdc48d0ae6c1f0581f74bc8169b33c3b8d049f2d2190bac4e310964e59fde
languageName: node
linkType: hard
@ -11363,13 +11260,6 @@ __metadata:
languageName: node
linkType: hard
"timezones.json@npm:^1.7.1":
version: 1.7.2
resolution: "timezones.json@npm:1.7.2"
checksum: 10c0/209da3d2334118790f57ad060de68ba799adde9df051a6428c348079ed0df20a753e190f85cceadab336f6b24a68302bcca84c895c70270126b15ea0bcde77cd
languageName: node
linkType: hard
"tinyglobby@npm:^0.2.12":
version: 0.2.15
resolution: "tinyglobby@npm:0.2.15"
@ -11586,13 +11476,6 @@ __metadata:
languageName: node
linkType: hard
"type-fest@npm:^2.12.2":
version: 2.19.0
resolution: "type-fest@npm:2.19.0"
checksum: 10c0/a5a7ecf2e654251613218c215c7493574594951c08e52ab9881c9df6a6da0aeca7528c213c622bc374b4e0cb5c443aa3ab758da4e3c959783ce884c3194e12cb
languageName: node
linkType: hard
"typed-array-buffer@npm:^1.0.3":
version: 1.0.3
resolution: "typed-array-buffer@npm:1.0.3"
@ -11895,15 +11778,6 @@ __metadata:
languageName: node
linkType: hard
"uuid@npm:^9.0.0":
version: 9.0.1
resolution: "uuid@npm:9.0.1"
bin:
uuid: dist/bin/uuid
checksum: 10c0/1607dd32ac7fc22f2d8f77051e6a64845c9bce5cd3dd8aa0070c074ec73e666a1f63c7b4e0f4bf2bc8b9d59dc85a15e17807446d9d2b17c8485fbc2147b27f9b
languageName: node
linkType: hard
"v8-compile-cache@npm:^2.0.3, v8-compile-cache@npm:^2.2.0":
version: 2.4.0
resolution: "v8-compile-cache@npm:2.4.0"
@ -11966,7 +11840,7 @@ __metadata:
languageName: node
linkType: hard
"watchpack@npm:^2.0.0":
"watchpack@npm:^2.5.1":
version: 2.5.1
resolution: "watchpack@npm:2.5.1"
dependencies:
@ -12036,49 +11910,48 @@ __metadata:
languageName: node
linkType: hard
"webpack-sources@npm:^2.1.1":
version: 2.3.1
resolution: "webpack-sources@npm:2.3.1"
dependencies:
source-list-map: "npm:^2.0.1"
source-map: "npm:^0.6.1"
checksum: 10c0/caf56a9a478eca7e77feca2b6ddc7673f1384eb870280014b300c40cf42abca656f639ff58a8d55a889a92a810ae3c22e71e578aa38fde416e8c2e6827a6ddfd
"webpack-sources@npm:^3.3.4":
version: 3.3.4
resolution: "webpack-sources@npm:3.3.4"
checksum: 10c0/94a42508531338eb41939cf1d48a4a8a6db97f3a47e5453cff2133a68d3169ca779d4bcbe9dfed072ce16611959eba1e16f085bc2dc56714e1a1c1783fd661a3
languageName: node
linkType: hard
"webpack@npm:5.34.0":
version: 5.34.0
resolution: "webpack@npm:5.34.0"
"webpack@npm:^5.54.0":
version: 5.105.4
resolution: "webpack@npm:5.105.4"
dependencies:
"@types/eslint-scope": "npm:^3.7.0"
"@types/estree": "npm:^0.0.47"
"@webassemblyjs/ast": "npm:1.11.0"
"@webassemblyjs/wasm-edit": "npm:1.11.0"
"@webassemblyjs/wasm-parser": "npm:1.11.0"
acorn: "npm:^8.0.4"
browserslist: "npm:^4.14.5"
"@types/eslint-scope": "npm:^3.7.7"
"@types/estree": "npm:^1.0.8"
"@types/json-schema": "npm:^7.0.15"
"@webassemblyjs/ast": "npm:^1.14.1"
"@webassemblyjs/wasm-edit": "npm:^1.14.1"
"@webassemblyjs/wasm-parser": "npm:^1.14.1"
acorn: "npm:^8.16.0"
acorn-import-phases: "npm:^1.0.3"
browserslist: "npm:^4.28.1"
chrome-trace-event: "npm:^1.0.2"
enhanced-resolve: "npm:^5.8.0"
es-module-lexer: "npm:^0.4.0"
eslint-scope: "npm:^5.1.1"
enhanced-resolve: "npm:^5.20.0"
es-module-lexer: "npm:^2.0.0"
eslint-scope: "npm:5.1.1"
events: "npm:^3.2.0"
glob-to-regexp: "npm:^0.4.1"
graceful-fs: "npm:^4.2.4"
json-parse-better-errors: "npm:^1.0.2"
loader-runner: "npm:^4.2.0"
graceful-fs: "npm:^4.2.11"
json-parse-even-better-errors: "npm:^2.3.1"
loader-runner: "npm:^4.3.1"
mime-types: "npm:^2.1.27"
neo-async: "npm:^2.6.2"
schema-utils: "npm:^3.0.0"
tapable: "npm:^2.1.1"
terser-webpack-plugin: "npm:^5.1.1"
watchpack: "npm:^2.0.0"
webpack-sources: "npm:^2.1.1"
schema-utils: "npm:^4.3.3"
tapable: "npm:^2.3.0"
terser-webpack-plugin: "npm:^5.3.17"
watchpack: "npm:^2.5.1"
webpack-sources: "npm:^3.3.4"
peerDependenciesMeta:
webpack-cli:
optional: true
bin:
webpack: bin/webpack.js
checksum: 10c0/10eb0d3eb666661398974518b26e3682b280f73e8d5720562d6e1f46169b4cfca627543adc1a449f574081cced47da2c3f570c59062a3b336a3e057bde67ba10
checksum: 10c0/e9896d20bac351b119d59942b7efae5b117056ecf203acc0d1a84ecbf0a5a9a80ca733735f96bd163e3530be6ab7f615cd67e5320bd3c47d709c9bfe376c3280
languageName: node
linkType: hard