summaryrefslogtreecommitdiffstats
path: root/store
diff options
context:
space:
mode:
authorHarrison Healey <harrisonmhealey@gmail.com>2016-11-30 13:55:49 -0500
committerGitHub <noreply@github.com>2016-11-30 13:55:49 -0500
commit165ad0d4f791f8ae2109472d8a626d911fa368e0 (patch)
tree29001baf676d7d4ef4cd9462e9f2c6766ed6333a /store
parent2bf0342d130b3a77c5ed02e98e0857f28a5787f0 (diff)
downloadchat-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.go230
-rw-r--r--store/sql_reaction_store_test.go270
-rw-r--r--store/sql_store.go7
-rw-r--r--store/sql_upgrade.go16
-rw-r--r--store/store.go8
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
+}