diff options
author | Harrison Healey <harrisonmhealey@gmail.com> | 2016-11-30 13:55:49 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2016-11-30 13:55:49 -0500 |
commit | 165ad0d4f791f8ae2109472d8a626d911fa368e0 (patch) | |
tree | 29001baf676d7d4ef4cd9462e9f2c6766ed6333a /store | |
parent | 2bf0342d130b3a77c5ed02e98e0857f28a5787f0 (diff) | |
download | chat-165ad0d4f791f8ae2109472d8a626d911fa368e0.tar.gz chat-165ad0d4f791f8ae2109472d8a626d911fa368e0.tar.bz2 chat-165ad0d4f791f8ae2109472d8a626d911fa368e0.zip |
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 :+1: and :-1: 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
Diffstat (limited to 'store')
-rw-r--r-- | store/sql_reaction_store.go | 230 | ||||
-rw-r--r-- | store/sql_reaction_store_test.go | 270 | ||||
-rw-r--r-- | store/sql_store.go | 7 | ||||
-rw-r--r-- | store/sql_upgrade.go | 16 | ||||
-rw-r--r-- | store/store.go | 8 |
5 files changed, 531 insertions, 0 deletions
diff --git a/store/sql_reaction_store.go b/store/sql_reaction_store.go new file mode 100644 index 000000000..7bd063a15 --- /dev/null +++ b/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 +} diff --git a/store/sql_reaction_store_test.go b/store/sql_reaction_store_test.go new file mode 100644 index 000000000..5a1cb2d67 --- /dev/null +++ b/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") + } +} diff --git a/store/sql_store.go b/store/sql_store.go index 215e0f894..6a852430c 100644 --- a/store/sql_store.go +++ b/store/sql_store.go @@ -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() } diff --git a/store/sql_upgrade.go b/store/sql_upgrade.go index 992fac189..38aac4299 100644 --- a/store/sql_upgrade.go +++ b/store/sql_upgrade.go @@ -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) + //} +} diff --git a/store/store.go b/store/store.go index ae938a797..7602be8f4 100644 --- a/store/store.go +++ b/store/store.go @@ -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 +} |