diff --git a/app/slashcommands/command_custom_status.go b/app/slashcommands/command_custom_status.go index 8851bfbcf5..926812ad47 100644 --- a/app/slashcommands/command_custom_status.go +++ b/app/slashcommands/command_custom_status.go @@ -4,7 +4,9 @@ package slashcommands import ( + "regexp" "strings" + "unicode/utf8" "github.com/mattermost/mattermost-server/v5/app" "github.com/mattermost/mattermost-server/v5/app/request" @@ -49,7 +51,7 @@ func (*CustomStatusProvider) DoCommand(a *app.App, c *request.Context, args *mod message = strings.TrimSpace(message) if message == CmdCustomStatusClear { if err := a.RemoveCustomStatus(args.UserId); err != nil { - mlog.Error(err.Error()) + mlog.Debug(err.Error()) return &model.CommandResponse{Text: args.T("api.command_custom_status.clear.app_error"), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL} } @@ -59,20 +61,9 @@ func (*CustomStatusProvider) DoCommand(a *app.App, c *request.Context, args *mod } } - customStatus := &model.CustomStatus{ - Emoji: DefaultCustomStatusEmoji, - Text: message, - } - firstEmojiLocations := model.ALL_EMOJI_PATTERN.FindIndex([]byte(message)) - if len(firstEmojiLocations) > 0 && firstEmojiLocations[0] == 0 { - // emoji found at starting index - customStatus.Emoji = message[firstEmojiLocations[0]+1 : firstEmojiLocations[1]-1] - customStatus.Text = strings.TrimSpace(message[firstEmojiLocations[1]:]) - } - - customStatus.TrimMessage() + customStatus := GetCustomStatus(message) if err := a.SetCustomStatus(args.UserId, customStatus); err != nil { - mlog.Error(err.Error()) + mlog.Debug(err.Error()) return &model.CommandResponse{Text: args.T("api.command_custom_status.app_error"), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL} } @@ -84,3 +75,69 @@ func (*CustomStatusProvider) DoCommand(a *app.App, c *request.Context, args *mod }), } } + +func GetCustomStatus(message string) *model.CustomStatus { + customStatus := &model.CustomStatus{ + Emoji: DefaultCustomStatusEmoji, + Text: message, + } + + firstEmojiLocations := model.ALL_EMOJI_PATTERN.FindIndex([]byte(message)) + if len(firstEmojiLocations) > 0 && firstEmojiLocations[0] == 0 { + // emoji found at starting index + customStatus.Emoji = message[firstEmojiLocations[0]+1 : firstEmojiLocations[1]-1] + customStatus.Text = strings.TrimSpace(message[firstEmojiLocations[1]:]) + customStatus.TrimMessage() + return customStatus + } + + if message == "" { + return customStatus + } + + spaceSeparatedMessage := strings.Fields(message) + if len(spaceSeparatedMessage) == 0 { + return customStatus + } + + emojiString := spaceSeparatedMessage[0] + var unicode []string + for utf8.RuneCountInString(emojiString) >= 1 { + codepoint, size := utf8.DecodeRuneInString(emojiString) + code := model.RuneToHexadecimalString(codepoint) + unicode = append(unicode, code) + emojiString = emojiString[size:] + } + + unicodeString := removeUnicodeSkinTone(strings.Join(unicode, "-")) + emoji, count := model.GetEmojiNameFromUnicode(unicodeString) + if count > 0 { + customStatus.Emoji = emoji + textString := strings.Join(spaceSeparatedMessage[1:], " ") + customStatus.Text = strings.TrimSpace(textString) + } + + customStatus.TrimMessage() + return customStatus +} + +func removeUnicodeSkinTone(unicodeString string) string { + skinToneDetectorRegex := regexp.MustCompile("-(1f3fb|1f3fc|1f3fd|1f3fe|1f3ff)") + skinToneLocations := skinToneDetectorRegex.FindIndex([]byte(unicodeString)) + + if len(skinToneLocations) == 0 { + return unicodeString + } + if _, count := model.GetEmojiNameFromUnicode(unicodeString); count == 1 { + return unicodeString + } + unicodeWithRemovedSkinTone := unicodeString[:skinToneLocations[0]] + unicodeString[skinToneLocations[1]:] + unicodeWithVariationSelector := unicodeString[:skinToneLocations[0]] + "-fe0f" + unicodeString[skinToneLocations[1]:] + if _, count := model.GetEmojiNameFromUnicode(unicodeWithRemovedSkinTone); count > 0 { + unicodeString = unicodeWithRemovedSkinTone + } else if _, count := model.GetEmojiNameFromUnicode(unicodeWithVariationSelector); count > 0 { + unicodeString = unicodeWithVariationSelector + } + + return unicodeString +} diff --git a/app/slashcommands/command_custom_status_test.go b/app/slashcommands/command_custom_status_test.go new file mode 100644 index 0000000000..4e99dad52e --- /dev/null +++ b/app/slashcommands/command_custom_status_test.go @@ -0,0 +1,34 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package slashcommands + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v5/model" +) + +func TestGetCustomStatus(t *testing.T) { + for msg, expected := range map[string]model.CustomStatus{ + "": {Emoji: DefaultCustomStatusEmoji, Text: ""}, + "Hey": {Emoji: DefaultCustomStatusEmoji, Text: "Hey"}, + ":cactus: Hurt": {Emoji: "cactus", Text: "Hurt"}, + "πŸ‘…": {Emoji: "tongue", Text: ""}, + "πŸ‘… Eating": {Emoji: "tongue", Text: "Eating"}, + "πŸ’ͺ🏻 Working out": {Emoji: "muscle_light_skin_tone", Text: "Working out"}, + "πŸ‘™ Swimming": {Emoji: "bikini", Text: "Swimming"}, + "πŸ‘™Swimming": {Emoji: DefaultCustomStatusEmoji, Text: "πŸ‘™Swimming"}, + "πŸ‘πŸΏ Okay": {Emoji: "+1_dark_skin_tone", Text: "Okay"}, + "🀴🏾 Dark king": {Emoji: "prince_medium_dark_skin_tone", Text: "Dark king"}, + "β›ΉπŸΎβ€β™€οΈ Playing basketball": {Emoji: "basketball_woman", Text: "Playing basketball"}, + "πŸ‹πŸΏβ€β™€οΈ Weightlifting": {Emoji: "weight_lifting_woman", Text: "Weightlifting"}, + "πŸ„ Surfing": {Emoji: "surfer", Text: "Surfing"}, + "πŸ‘¨β€πŸ‘¨β€πŸ‘¦β€πŸ‘¦ Family": {Emoji: "family_man_man_boy_boy", Text: "Family"}, + } { + actual := GetCustomStatus(msg) + if actual.Emoji != expected.Emoji || actual.Text != expected.Text { + t.Errorf("expected `%v`, got `%v`", expected, *actual) + } + } +} diff --git a/model/custom_status.go b/model/custom_status.go index 5479144d26..68e78e4d29 100644 --- a/model/custom_status.go +++ b/model/custom_status.go @@ -5,6 +5,7 @@ package model import ( "encoding/json" + "fmt" "io" ) @@ -39,6 +40,10 @@ func CustomStatusFromJson(data io.Reader) *CustomStatus { return cs } +func RuneToHexadecimalString(r rune) string { + return fmt.Sprintf("%04x", r) +} + type RecentCustomStatuses []CustomStatus func (rcs *RecentCustomStatuses) Contains(cs *CustomStatus) bool { diff --git a/model/emoji.go b/model/emoji.go index bcc35e9ff3..bc9b23b891 100644 --- a/model/emoji.go +++ b/model/emoji.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "regexp" + "sort" ) const ( @@ -23,6 +24,8 @@ var EMOJI_PATTERN = regexp.MustCompile(`:[a-zA-Z0-9_-]+:`) // TODO: Merge ALL_EMOJI_PATTERN with EMOJI_PATTERN after updating custom emoji help texts var ALL_EMOJI_PATTERN = regexp.MustCompile(`:[a-zA-Z0-9_+-]+:`) +var ReverseSystemEmojisMap = makeReverseEmojiMap() + type Emoji struct { Id string `json:"id"` CreateAt int64 `json:"create_at"` @@ -42,6 +45,26 @@ func GetSystemEmojiId(emojiName string) (string, bool) { return id, found } +func makeReverseEmojiMap() map[string][]string { + reverseEmojiMap := make(map[string][]string) + for key, value := range SystemEmojis { + emojiNames := reverseEmojiMap[value] + emojiNames = append(emojiNames, key) + sort.Strings(emojiNames) + reverseEmojiMap[value] = emojiNames + } + + return reverseEmojiMap +} + +func GetEmojiNameFromUnicode(unicode string) (emojiName string, count int) { + if emojiNames, found := ReverseSystemEmojisMap[unicode]; found { + return emojiNames[0], len(emojiNames) + } + + return "", 0 +} + func (emoji *Emoji) IsValid() *AppError { if !IsValidId(emoji.Id) { return NewAppError("Emoji.IsValid", "model.emoji.id.app_error", nil, "", http.StatusBadRequest)