PLT-1378 Initial version of emoji reactions (#4520)

* Refactored emoji.json to support multiple aliases and emoji categories

* Added custom category to emoji.jsx and stabilized all fields

* Removed conflicting aliases for :mattermost: and :ca:

* fixup after store changes

* Added emoji reactions

* Removed reactions for an emoji when that emoji is deleted

* Fixed incorrect test case

* Renamed ReactionList to ReactionListView

* Fixed 👍 and 👎 not showing up as possible reactions

* Removed text emoticons from emoji reaction autocomplete

* Changed emoji reactions to be sorted by the order that they were first created

* Set a maximum number of listeners for the ReactionStore

* Removed unused code from Textbox component

* Fixed reaction permissions

* Changed error code when trying to modify reactions for another user

* Fixed merge conflicts

* Properly applied theme colours to reactions

* Fixed ESLint and gofmt errors

* Fixed ReactionListContainer to properly update when its post prop changes

* Removed unnecessary escape characters from reaction regexes

* Shared reaction message pattern between CreatePost and CreateComment

* Removed an unnecessary select query when saving a reaction

* Changed reactions route to be under /reactions

* Fixed copyright dates on newly added files

* Removed debug code that prevented all unit tests from being ran

* Cleaned up unnecessary code for reactions

* Renamed ReactionStore.List to ReactionStore.GetForPost
Этот коммит содержится в:
Harrison Healey
2016-11-30 13:55:49 -05:00
коммит произвёл GitHub
родитель 2bf0342d13
Коммит 165ad0d4f7
47 изменённых файлов: 2154 добавлений и 98 удалений

230
store/sql_reaction_store.go Обычный файл
Просмотреть файл

@@ -0,0 +1,230 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package store
import (
"github.com/mattermost/platform/model"
"github.com/mattermost/platform/utils"
l4g "github.com/alecthomas/log4go"
"github.com/go-gorp/gorp"
)
type SqlReactionStore struct {
*SqlStore
}
func NewSqlReactionStore(sqlStore *SqlStore) ReactionStore {
s := &SqlReactionStore{sqlStore}
for _, db := range sqlStore.GetAllConns() {
table := db.AddTableWithName(model.Reaction{}, "Reactions").SetKeys(false, "UserId", "PostId", "EmojiName")
table.ColMap("UserId").SetMaxSize(26)
table.ColMap("PostId").SetMaxSize(26)
table.ColMap("EmojiName").SetMaxSize(64)
}
return s
}
func (s SqlReactionStore) CreateIndexesIfNotExists() {
s.CreateIndexIfNotExists("idx_reactions_post_id", "Reactions", "PostId")
}
func (s SqlReactionStore) Save(reaction *model.Reaction) StoreChannel {
storeChannel := make(StoreChannel)
go func() {
result := StoreResult{}
reaction.PreSave()
if result.Err = reaction.IsValid(); result.Err != nil {
storeChannel <- result
close(storeChannel)
return
}
if transaction, err := s.GetMaster().Begin(); err != nil {
result.Err = model.NewLocAppError("SqlReactionStore.Save", "store.sql_reaction.save.begin.app_error", nil, err.Error())
} else {
err := saveReactionAndUpdatePost(transaction, reaction)
if err != nil {
transaction.Rollback()
// We don't consider duplicated save calls as an error
if !IsUniqueConstraintError(err.Error(), []string{"reactions_pkey", "PRIMARY"}) {
result.Err = model.NewLocAppError("SqlPreferenceStore.Save", "store.sql_reaction.save.save.app_error", nil, err.Error())
}
} else {
if err := transaction.Commit(); err != nil {
// don't need to rollback here since the transaction is already closed
result.Err = model.NewLocAppError("SqlPreferenceStore.Save", "store.sql_preference.save.commit.app_error", nil, err.Error())
}
}
if result.Err == nil {
result.Data = reaction
}
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}
func (s SqlReactionStore) Delete(reaction *model.Reaction) StoreChannel {
storeChannel := make(StoreChannel)
go func() {
result := StoreResult{}
if transaction, err := s.GetMaster().Begin(); err != nil {
result.Err = model.NewLocAppError("SqlReactionStore.Delete", "store.sql_reaction.delete.begin.app_error", nil, err.Error())
} else {
err := deleteReactionAndUpdatePost(transaction, reaction)
if err != nil {
transaction.Rollback()
result.Err = model.NewLocAppError("SqlPreferenceStore.Delete", "store.sql_reaction.delete.app_error", nil, err.Error())
} else if err := transaction.Commit(); err != nil {
// don't need to rollback here since the transaction is already closed
result.Err = model.NewLocAppError("SqlPreferenceStore.Delete", "store.sql_preference.delete.commit.app_error", nil, err.Error())
} else {
result.Data = reaction
}
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}
func saveReactionAndUpdatePost(transaction *gorp.Transaction, reaction *model.Reaction) error {
if err := transaction.Insert(reaction); err != nil {
return err
}
return updatePostForReactions(transaction, reaction.PostId)
}
func deleteReactionAndUpdatePost(transaction *gorp.Transaction, reaction *model.Reaction) error {
if _, err := transaction.Exec(
`DELETE FROM
Reactions
WHERE
PostId = :PostId AND
UserId = :UserId AND
EmojiName = :EmojiName`,
map[string]interface{}{"PostId": reaction.PostId, "UserId": reaction.UserId, "EmojiName": reaction.EmojiName}); err != nil {
return err
}
return updatePostForReactions(transaction, reaction.PostId)
}
const (
// Set HasReactions = true if and only if the post has reactions, update UpdateAt only if HasReactions changes
UPDATE_POST_HAS_REACTIONS_QUERY = `UPDATE
Posts
SET
UpdateAt = (CASE
WHEN HasReactions != (SELECT count(0) > 0 FROM Reactions WHERE PostId = :PostId) THEN :UpdateAt
ELSE UpdateAt
END),
HasReactions = (SELECT count(0) > 0 FROM Reactions WHERE PostId = :PostId)
WHERE
Id = :PostId`
)
func updatePostForReactions(transaction *gorp.Transaction, postId string) error {
_, err := transaction.Exec(UPDATE_POST_HAS_REACTIONS_QUERY, map[string]interface{}{"PostId": postId, "UpdateAt": model.GetMillis()})
return err
}
func (s SqlReactionStore) GetForPost(postId string) StoreChannel {
storeChannel := make(StoreChannel)
go func() {
result := StoreResult{}
var reactions []*model.Reaction
if _, err := s.GetReplica().Select(&reactions,
`SELECT
*
FROM
Reactions
WHERE
PostId = :PostId
ORDER BY
CreateAt`, map[string]interface{}{"PostId": postId}); err != nil {
result.Err = model.NewLocAppError("SqlReactionStore.GetForPost", "store.sql_reaction.get_for_post.app_error", nil, "")
} else {
result.Data = reactions
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}
func (s SqlReactionStore) DeleteAllWithEmojiName(emojiName string) StoreChannel {
storeChannel := make(StoreChannel)
go func() {
result := StoreResult{}
// doesn't use a transaction since it's better for this to half-finish than to not commit anything
var reactions []*model.Reaction
if _, err := s.GetReplica().Select(&reactions,
`SELECT
*
FROM
Reactions
WHERE
EmojiName = :EmojiName`, map[string]interface{}{"EmojiName": emojiName}); err != nil {
result.Err = model.NewLocAppError("SqlReactionStore.DeleteAllWithEmojiName",
"store.sql_reaction.delete_all_with_emoji_name.get_reactions.app_error", nil,
"emoji_name="+emojiName+", error="+err.Error())
storeChannel <- result
close(storeChannel)
return
}
if _, err := s.GetMaster().Exec(
`DELETE FROM
Reactions
WHERE
EmojiName = :EmojiName`, map[string]interface{}{"EmojiName": emojiName}); err != nil {
result.Err = model.NewLocAppError("SqlReactionStore.DeleteAllWithEmojiName",
"store.sql_reaction.delete_all_with_emoji_name.delete_reactions.app_error", nil,
"emoji_name="+emojiName+", error="+err.Error())
storeChannel <- result
close(storeChannel)
return
}
for _, reaction := range reactions {
if _, err := s.GetMaster().Exec(UPDATE_POST_HAS_REACTIONS_QUERY,
map[string]interface{}{"PostId": reaction.PostId, "UpdateAt": model.GetMillis()}); err != nil {
l4g.Warn(utils.T("store.sql_reaction.delete_all_with_emoji_name.update_post.warn"), reaction.PostId, err.Error())
}
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}

270
store/sql_reaction_store_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,270 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package store
import (
"github.com/mattermost/platform/model"
"testing"
)
func TestReactionSave(t *testing.T) {
Setup()
post := Must(store.Post().Save(&model.Post{
ChannelId: model.NewId(),
UserId: model.NewId(),
})).(*model.Post)
firstUpdateAt := post.UpdateAt
reaction1 := &model.Reaction{
UserId: model.NewId(),
PostId: post.Id,
EmojiName: model.NewId(),
}
if result := <-store.Reaction().Save(reaction1); result.Err != nil {
t.Fatal(result.Err)
} else if saved := result.Data.(*model.Reaction); saved.UserId != reaction1.UserId ||
saved.PostId != reaction1.PostId || saved.EmojiName != reaction1.EmojiName {
t.Fatal("should've saved reaction and returned it")
}
var secondUpdateAt int64
if postList := Must(store.Post().Get(reaction1.PostId)).(*model.PostList); !postList.Posts[post.Id].HasReactions {
t.Fatal("should've set HasReactions = true on post")
} else if postList.Posts[post.Id].UpdateAt == firstUpdateAt {
t.Fatal("should've marked post as updated when HasReactions changed")
} else {
secondUpdateAt = postList.Posts[post.Id].UpdateAt
}
if result := <-store.Reaction().Save(reaction1); result.Err != nil {
t.Log(result.Err)
t.Fatal("should've allowed saving a duplicate reaction")
}
// different user
reaction2 := &model.Reaction{
UserId: model.NewId(),
PostId: reaction1.PostId,
EmojiName: reaction1.EmojiName,
}
if result := <-store.Reaction().Save(reaction2); result.Err != nil {
t.Fatal(result.Err)
}
if postList := Must(store.Post().Get(reaction2.PostId)).(*model.PostList); postList.Posts[post.Id].UpdateAt != secondUpdateAt {
t.Fatal("shouldn't mark as updated when HasReactions hasn't changed")
}
// different post
reaction3 := &model.Reaction{
UserId: reaction1.UserId,
PostId: model.NewId(),
EmojiName: reaction1.EmojiName,
}
if result := <-store.Reaction().Save(reaction3); result.Err != nil {
t.Fatal(result.Err)
}
// different emoji
reaction4 := &model.Reaction{
UserId: reaction1.UserId,
PostId: reaction1.PostId,
EmojiName: model.NewId(),
}
if result := <-store.Reaction().Save(reaction4); result.Err != nil {
t.Fatal(result.Err)
}
// invalid reaction
reaction5 := &model.Reaction{
UserId: reaction1.UserId,
PostId: reaction1.PostId,
}
if result := <-store.Reaction().Save(reaction5); result.Err == nil {
t.Fatal("should've failed for invalid reaction")
}
}
func TestReactionDelete(t *testing.T) {
Setup()
post := Must(store.Post().Save(&model.Post{
ChannelId: model.NewId(),
UserId: model.NewId(),
})).(*model.Post)
reaction := &model.Reaction{
UserId: model.NewId(),
PostId: post.Id,
EmojiName: model.NewId(),
}
Must(store.Reaction().Save(reaction))
firstUpdateAt := Must(store.Post().Get(reaction.PostId)).(*model.PostList).Posts[post.Id].UpdateAt
if result := <-store.Reaction().Delete(reaction); result.Err != nil {
t.Fatal(result.Err)
}
if result := <-store.Reaction().GetForPost(post.Id); result.Err != nil {
t.Fatal(result.Err)
} else if len(result.Data.([]*model.Reaction)) != 0 {
t.Fatal("should've deleted reaction")
}
if postList := Must(store.Post().Get(post.Id)).(*model.PostList); postList.Posts[post.Id].HasReactions {
t.Fatal("should've set HasReactions = false on post")
} else if postList.Posts[post.Id].UpdateAt == firstUpdateAt {
t.Fatal("shouldn't mark as updated when HasReactions has changed after deleting reactions")
}
}
func TestReactionGetForPost(t *testing.T) {
Setup()
postId := model.NewId()
userId := model.NewId()
reactions := []*model.Reaction{
{
UserId: userId,
PostId: postId,
EmojiName: "smile",
},
{
UserId: model.NewId(),
PostId: postId,
EmojiName: "smile",
},
{
UserId: userId,
PostId: postId,
EmojiName: "sad",
},
{
UserId: userId,
PostId: model.NewId(),
EmojiName: "angry",
},
}
for _, reaction := range reactions {
Must(store.Reaction().Save(reaction))
}
if result := <-store.Reaction().GetForPost(postId); result.Err != nil {
t.Fatal(result.Err)
} else if returned := result.Data.([]*model.Reaction); len(returned) != 3 {
t.Fatal("should've returned 3 reactions")
} else {
for _, reaction := range reactions {
found := false
for _, returnedReaction := range returned {
if returnedReaction.UserId == reaction.UserId && returnedReaction.PostId == reaction.PostId &&
returnedReaction.EmojiName == reaction.EmojiName {
found = true
break
}
}
if !found && reaction.PostId == postId {
t.Fatalf("should've returned reaction for post %v", reaction)
} else if found && reaction.PostId != postId {
t.Fatal("shouldn't have returned reaction for another post")
}
}
}
}
func TestReactionDeleteAllWithEmojiName(t *testing.T) {
Setup()
emojiToDelete := model.NewId()
post := Must(store.Post().Save(&model.Post{
ChannelId: model.NewId(),
UserId: model.NewId(),
})).(*model.Post)
post2 := Must(store.Post().Save(&model.Post{
ChannelId: model.NewId(),
UserId: model.NewId(),
})).(*model.Post)
post3 := Must(store.Post().Save(&model.Post{
ChannelId: model.NewId(),
UserId: model.NewId(),
})).(*model.Post)
userId := model.NewId()
reactions := []*model.Reaction{
{
UserId: userId,
PostId: post.Id,
EmojiName: emojiToDelete,
},
{
UserId: model.NewId(),
PostId: post.Id,
EmojiName: emojiToDelete,
},
{
UserId: userId,
PostId: post.Id,
EmojiName: "sad",
},
{
UserId: userId,
PostId: post2.Id,
EmojiName: "angry",
},
{
UserId: userId,
PostId: post3.Id,
EmojiName: emojiToDelete,
},
}
for _, reaction := range reactions {
Must(store.Reaction().Save(reaction))
}
if result := <-store.Reaction().DeleteAllWithEmojiName(emojiToDelete); result.Err != nil {
t.Fatal(result.Err)
}
// check that the reactions were deleted
if returned := Must(store.Reaction().GetForPost(post.Id)).([]*model.Reaction); len(returned) != 1 {
t.Fatal("should've only removed reactions with emoji name")
} else {
for _, reaction := range returned {
if reaction.EmojiName == "smile" {
t.Fatal("should've removed reaction with emoji name")
}
}
}
if returned := Must(store.Reaction().GetForPost(post2.Id)).([]*model.Reaction); len(returned) != 1 {
t.Fatal("should've only removed reactions with emoji name")
}
if returned := Must(store.Reaction().GetForPost(post3.Id)).([]*model.Reaction); len(returned) != 0 {
t.Fatal("should've only removed reactions with emoji name")
}
// check that the posts are updated
if postList := Must(store.Post().Get(post.Id)).(*model.PostList); !postList.Posts[post.Id].HasReactions {
t.Fatal("post should still have reactions")
}
if postList := Must(store.Post().Get(post2.Id)).(*model.PostList); !postList.Posts[post2.Id].HasReactions {
t.Fatal("post should still have reactions")
}
if postList := Must(store.Post().Get(post3.Id)).(*model.PostList); postList.Posts[post3.Id].HasReactions {
t.Fatal("post shouldn't have reactions any more")
}
}

Просмотреть файл

@@ -84,6 +84,7 @@ type SqlStore struct {
emoji EmojiStore
status StatusStore
fileInfo FileInfoStore
reaction ReactionStore
SchemaVersion string
rrCounter int64
}
@@ -134,6 +135,7 @@ func NewSqlStore() Store {
sqlStore.emoji = NewSqlEmojiStore(sqlStore)
sqlStore.status = NewSqlStatusStore(sqlStore)
sqlStore.fileInfo = NewSqlFileInfoStore(sqlStore)
sqlStore.reaction = NewSqlReactionStore(sqlStore)
err := sqlStore.master.CreateTablesIfNotExists()
if err != nil {
@@ -161,6 +163,7 @@ func NewSqlStore() Store {
sqlStore.emoji.(*SqlEmojiStore).CreateIndexesIfNotExists()
sqlStore.status.(*SqlStatusStore).CreateIndexesIfNotExists()
sqlStore.fileInfo.(*SqlFileInfoStore).CreateIndexesIfNotExists()
sqlStore.reaction.(*SqlReactionStore).CreateIndexesIfNotExists()
sqlStore.preference.(*SqlPreferenceStore).DeleteUnusedFeatures()
@@ -676,6 +679,10 @@ func (ss *SqlStore) FileInfo() FileInfoStore {
return ss.fileInfo
}
func (ss *SqlStore) Reaction() ReactionStore {
return ss.reaction
}
func (ss *SqlStore) DropAllTables() {
ss.master.TruncateTables()
}

Просмотреть файл

@@ -15,6 +15,7 @@ import (
)
const (
VERSION_3_6_0 = "3.6.0"
VERSION_3_5_0 = "3.5.0"
VERSION_3_4_0 = "3.4.0"
VERSION_3_3_0 = "3.3.0"
@@ -37,6 +38,7 @@ func UpgradeDatabase(sqlStore *SqlStore) {
UpgradeDatabaseToVersion33(sqlStore)
UpgradeDatabaseToVersion34(sqlStore)
UpgradeDatabaseToVersion35(sqlStore)
UpgradeDatabaseToVersion36(sqlStore)
// If the SchemaVersion is empty this this is the first time it has ran
// so lets set it to the current version.
@@ -210,3 +212,17 @@ func UpgradeDatabaseToVersion35(sqlStore *SqlStore) {
saveSchemaVersion(sqlStore, VERSION_3_5_0)
}
}
func UpgradeDatabaseToVersion36(sqlStore *SqlStore) {
//if shouldPerformUpgrade(sqlStore, VERSION_3_5_0, VERSION_3_6_0) {
sqlStore.CreateColumnIfNotExists("Posts", "HasReactions", "tinyint", "boolean", "0")
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// TODO FIXME UNCOMMENT WHEN WE DO RELEASE
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
//sqlStore.Session().RemoveAllSessions()
//saveSchemaVersion(sqlStore, VERSION_3_6_0)
//}
}

Просмотреть файл

@@ -46,6 +46,7 @@ type Store interface {
Emoji() EmojiStore
Status() StatusStore
FileInfo() FileInfoStore
Reaction() ReactionStore
MarkSystemRanUnitTests()
Close()
DropAllTables()
@@ -310,3 +311,10 @@ type FileInfoStore interface {
AttachToPost(fileId string, postId string) StoreChannel
DeleteForPost(postId string) StoreChannel
}
type ReactionStore interface {
Save(reaction *model.Reaction) StoreChannel
Delete(reaction *model.Reaction) StoreChannel
GetForPost(postId string) StoreChannel
DeleteAllWithEmojiName(emojiName string) StoreChannel
}