Compare commits

..

39 Commits

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

68
go.mod
View File

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

40
go.sum
View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

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

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

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

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

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

8
server/manifest.go generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,4 +3,140 @@
flex-flow: column;
height: 100%;
padding: 10px;
&--loading {
justify-content: center;
align-items: center;
.spinner {
width: 48px;
height: 48px;
}
}
&__loadingWrap {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
.spinner {
width: 48px;
height: 48px;
}
}
&__emptyContent {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 20px;
flex: 1;
}
&__emptyTitle {
font-size: 16px;
font-weight: 600;
color: var(--center-channel-color, #3d3c40);
margin-bottom: 8px;
}
&__emptyDescription {
font-size: 13px;
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.72);
margin-bottom: 16px;
line-height: 1.5;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
&__tabs {
display: flex;
gap: 0;
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,90 +1,154 @@
import React from 'react';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {systemEmojis} from 'mattermost-redux/actions/emojis';
import {FormattedMessage} from 'react-intl';
import {Virtuoso} from 'react-virtuoso';
import {useSelector} from 'react-redux';
import {EmojiIndicesByAlias} from 'utils/emoji';
import {BadgeID, AllBadgesBadge} from '../../types/badges';
import Client from '../../client/api';
import {RHSState} from '../../types/general';
import {IMAGE_TYPE_EMOJI, RHS_STATE_DETAIL} from '../../constants';
import {IMAGE_TYPE_EMOJI, RHS_STATE_DETAIL, RHS_STATE_TYPES} from '../../constants';
import {isCreateBadgeModalVisible, getEditBadgeModalData} from '../../selectors';
import BackButton from 'components/back_button/back_button';
import AllBadgesRow from './all_badges_row';
import RHSScrollbars from './rhs_scrollbars';
import './all_badges.scss';
type Props = {
filterTypeId?: number | null;
filterTypeName?: string | null;
actions: {
setRHSView: (view: RHSState) => void;
setRHSBadge: (badge: BadgeID | null) => void;
getCustomEmojisByName: (names: string[]) => void;
openCreateBadgeModal: () => void;
};
}
type State = {
loading: boolean;
badges?: AllBadgesBadge[];
}
const AllBadges: React.FC<Props> = ({filterTypeId, filterTypeName, actions}) => {
const [loading, setLoading] = useState(true);
const [badges, setBadges] = useState<AllBadgesBadge[]>([]);
const isFiltered = filterTypeId != null;
class AllBadges extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
const createBadgeVisible = useSelector(isCreateBadgeModalVisible);
const editBadgeData = useSelector(getEditBadgeModalData);
const isModalOpen = createBadgeVisible || editBadgeData !== null;
const wasModalOpen = useRef(false);
this.state = {
loading: true,
};
}
const fetchBadges = useCallback(() => {
const client = new Client();
client.getAllBadges().then((data) => {
setBadges(data);
setLoading(false);
componentDidMount() {
const c = new Client();
c.getAllBadges().then((badges) => {
this.setState({badges, loading: false});
});
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (this.state.badges !== prevState.badges) {
const names: string[] = [];
this.state.badges?.forEach((badge) => {
data.forEach((badge) => {
if (badge.image_type === IMAGE_TYPE_EMOJI) {
names.push(badge.image);
}
});
const toLoad = names.filter((v) => !systemEmojis.has(v));
this.props.actions.getCustomEmojisByName(toLoad);
}
}
onBadgeClick = (badge: AllBadgesBadge) => {
this.props.actions.setRHSBadge(badge.id);
this.props.actions.setRHSView(RHS_STATE_DETAIL);
}
render() {
if (this.state.loading) {
return (<div className='AllBadges'>{'Loading...'}</div>);
}
if (!this.state.badges || this.state.badges.length === 0) {
return (<div className='AllBadges'>{'No badges yet.'}</div>);
}
const content = this.state.badges.map((badge) => {
return (
<AllBadgesRow
key={badge.id}
badge={badge}
onClick={this.onBadgeClick}
/>
);
const toLoad = names.filter((v) => !EmojiIndicesByAlias.has(v));
actions.getCustomEmojisByName(toLoad);
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
fetchBadges();
}, [fetchBadges]);
useEffect(() => {
if (wasModalOpen.current && !isModalOpen) {
fetchBadges();
}
wasModalOpen.current = isModalOpen;
}, [isModalOpen, fetchBadges]);
const displayBadges = useMemo(() => {
if (!isFiltered) {
return badges;
}
return badges.filter((b) => b.type === filterTypeId);
}, [badges, isFiltered, filterTypeId]);
const onBadgeClick = useCallback((badge: AllBadgesBadge) => {
actions.setRHSBadge(badge.id);
actions.setRHSView(RHS_STATE_DETAIL);
}, [actions]);
if (loading) {
return (
<div className='AllBadges'>
<div><b>{'All badges'}</b></div>
<RHSScrollbars>{content}</RHSScrollbars>
<div className='AllBadges__loadingWrap'>
<div className='spinner'/>
</div>
);
}
}
const isEmpty = !isFiltered && (!badges || badges.length === 0);
return (
<>
{isFiltered && (
<div className='AllBadges__header'>
<div className='AllBadges__backHeader'>
<BackButton
targetView={RHS_STATE_TYPES}
onNavigate={actions.setRHSView}
>
<FormattedMessage
id='badges.rhs.back_to_types'
defaultMessage='Назад к типам'
/>
</BackButton>
<span className='AllBadges__filterTitle'>{filterTypeName}</span>
</div>
</div>
)}
{isEmpty && (
<div className='AllBadges__emptyContent'>
<div className='AllBadges__emptyTitle'>
<FormattedMessage
id='badges.empty.title'
defaultMessage='Достижений пока нет'
/>
</div>
<div className='AllBadges__emptyDescription'>
<FormattedMessage
id='badges.empty.description'
defaultMessage='Создайте первое достижение, чтобы отмечать заслуги участников команды.'
/>
</div>
</div>
)}
{!isEmpty && displayBadges.length === 0 && (
<div className='AllBadges__empty'>
<FormattedMessage
id='badges.types.no_badges'
defaultMessage='В этом типе нет достижений'
/>
</div>
)}
{!isEmpty && displayBadges.length > 0 && (
<Virtuoso
style={{flex: '1 1 auto'}}
data={displayBadges}
increaseViewportBy={300}
overscan={200}
itemContent={(_index, badge) => (
<AllBadgesRow
key={badge.id}
badge={badge}
onClick={onBadgeClick}
/>
)}
/>
)}
</>
);
};
export default AllBadges;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

File diff suppressed because it is too large Load Diff