diff options
47 files changed, 1422 insertions, 801 deletions
diff --git a/api/team.go b/api/team.go index 862970887..2cc7106dc 100644 --- a/api/team.go +++ b/api/team.go @@ -582,6 +582,39 @@ func updateTeam(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(oldTeam.ToJson())) } +func PermanentDeleteTeam(c *Context, team *model.Team) *model.AppError { + l4g.Warn("Attempting to permanently delete team %v id=%v", team.Name, team.Id) + c.Path = "/teams/permanent_delete" + c.LogAuditWithUserId("", fmt.Sprintf("attempt teamId=%v", team.Id)) + + team.DeleteAt = model.GetMillis() + if result := <-Srv.Store.Team().Update(team); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.User().GetForExport(team.Id); result.Err != nil { + return result.Err + } else { + users := result.Data.([]*model.User) + for _, user := range users { + PermanentDeleteUser(c, user) + } + } + + if result := <-Srv.Store.Channel().PermanentDeleteByTeam(team.Id); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.Team().PermanentDelete(team.Id); result.Err != nil { + return result.Err + } + + l4g.Warn("Permanently deleted team %v id=%v", team.Name, team.Id) + c.LogAuditWithUserId("", fmt.Sprintf("success teamId=%v", team.Id)) + + return nil +} + func getMyTeam(c *Context, w http.ResponseWriter, r *http.Request) { if len(c.Session.TeamId) == 0 { diff --git a/api/team_test.go b/api/team_test.go index 7a3b092ce..0b7d2ed9c 100644 --- a/api/team_test.go +++ b/api/team_test.go @@ -168,6 +168,45 @@ func TestGetAllTeams(t *testing.T) { } } +func TestTeamPermDelete(t *testing.T) { + Setup() + + team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} + team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team) + + user1 := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"} + user1 = Client.Must(Client.CreateUser(user1, "")).Data.(*model.User) + store.Must(Srv.Store.User().VerifyEmail(user1.Id)) + + Client.LoginByEmail(team.Name, user1.Email, "pwd") + + channel1 := &model.Channel{DisplayName: "TestGetPosts", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} + channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) + + post1 := &model.Post{ChannelId: channel1.Id, Message: "search for post1"} + post1 = Client.Must(Client.CreatePost(post1)).Data.(*model.Post) + + post2 := &model.Post{ChannelId: channel1.Id, Message: "search for post2"} + post2 = Client.Must(Client.CreatePost(post2)).Data.(*model.Post) + + post3 := &model.Post{ChannelId: channel1.Id, Message: "#hashtag search for post3"} + post3 = Client.Must(Client.CreatePost(post3)).Data.(*model.Post) + + post4 := &model.Post{ChannelId: channel1.Id, Message: "hashtag for post4"} + post4 = Client.Must(Client.CreatePost(post4)).Data.(*model.Post) + + c := &Context{} + c.RequestId = model.NewId() + c.IpAddress = "test" + + err := PermanentDeleteTeam(c, team) + if err != nil { + t.Fatal(err) + } + + Client.ClearOAuthToken() +} + /* XXXXXX investigate and fix failing test @@ -221,6 +260,7 @@ func TestFindTeamByEmailSend(t *testing.T) { user := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"} user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User) store.Must(Srv.Store.User().VerifyEmail(user.Id)) + Client.LoginByEmail(team.Name, user.Email, "pwd") if _, err := Client.FindTeamsSendEmail(user.Email); err != nil { t.Fatal(err) diff --git a/api/user.go b/api/user.go index 0f868a678..3281e83e2 100644 --- a/api/user.go +++ b/api/user.go @@ -1196,6 +1196,14 @@ func updateActive(c *Context, w http.ResponseWriter, r *http.Request) { } } + ruser := UpdateActive(c, user, active) + + if c.Err == nil { + w.Write([]byte(ruser.ToJson())) + } +} + +func UpdateActive(c *Context, user *model.User, active bool) *model.User { if active { user.DeleteAt = 0 } else { @@ -1204,7 +1212,7 @@ func updateActive(c *Context, w http.ResponseWriter, r *http.Request) { if result := <-Srv.Store.User().Update(user, true); result.Err != nil { c.Err = result.Err - return + return nil } else { c.LogAuditWithUserId(user.Id, fmt.Sprintf("active=%v", active)) @@ -1216,8 +1224,61 @@ func updateActive(c *Context, w http.ResponseWriter, r *http.Request) { options := utils.SanitizeOptions options["passwordupdate"] = false ruser.Sanitize(options) - w.Write([]byte(ruser.ToJson())) + return ruser + } +} + +func PermanentDeleteUser(c *Context, user *model.User) *model.AppError { + l4g.Warn("Attempting to permanently delete account %v id=%v", user.Email, user.Id) + c.Path = "/users/permanent_delete" + c.LogAuditWithUserId(user.Id, fmt.Sprintf("attempt userId=%v", user.Id)) + c.LogAuditWithUserId("", fmt.Sprintf("attempt userId=%v", user.Id)) + if user.IsInRole(model.ROLE_SYSTEM_ADMIN) { + l4g.Warn("You are deleting %v that is a system administrator. You may need to set another account as the system administrator using the command line tools.", user.Email) + } + + UpdateActive(c, user, false) + + if result := <-Srv.Store.Session().PermanentDeleteSessionsByUser(user.Id); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.OAuth().PermanentDeleteAuthDataByUser(user.Id); result.Err != nil { + return result.Err } + + if result := <-Srv.Store.Webhook().PermanentDeleteIncomingByUser(user.Id); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.Webhook().PermanentDeleteOutgoingByUser(user.Id); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.Preference().PermanentDeleteByUser(user.Id); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.Channel().PermanentDeleteMembersByUser(user.Id); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.Post().PermanentDeleteByUser(user.Id); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.User().PermanentDelete(user.Id); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.Audit().PermanentDeleteByUser(user.Id); result.Err != nil { + return result.Err + } + + l4g.Warn("Permanently deleted account %v id=%v", user.Email, user.Id) + c.LogAuditWithUserId("", fmt.Sprintf("success userId=%v", user.Id)) + + return nil } func sendPasswordReset(c *Context, w http.ResponseWriter, r *http.Request) { diff --git a/api/user_test.go b/api/user_test.go index f067182cb..63a1e337b 100644 --- a/api/user_test.go +++ b/api/user_test.go @@ -767,6 +767,45 @@ func TestUserUpdateActive(t *testing.T) { } } +func TestUserPermDelete(t *testing.T) { + Setup() + + team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} + team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team) + + user1 := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"} + user1 = Client.Must(Client.CreateUser(user1, "")).Data.(*model.User) + store.Must(Srv.Store.User().VerifyEmail(user1.Id)) + + Client.LoginByEmail(team.Name, user1.Email, "pwd") + + channel1 := &model.Channel{DisplayName: "TestGetPosts", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} + channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) + + post1 := &model.Post{ChannelId: channel1.Id, Message: "search for post1"} + post1 = Client.Must(Client.CreatePost(post1)).Data.(*model.Post) + + post2 := &model.Post{ChannelId: channel1.Id, Message: "search for post2"} + post2 = Client.Must(Client.CreatePost(post2)).Data.(*model.Post) + + post3 := &model.Post{ChannelId: channel1.Id, Message: "#hashtag search for post3"} + post3 = Client.Must(Client.CreatePost(post3)).Data.(*model.Post) + + post4 := &model.Post{ChannelId: channel1.Id, Message: "hashtag for post4"} + post4 = Client.Must(Client.CreatePost(post4)).Data.(*model.Post) + + c := &Context{} + c.RequestId = model.NewId() + c.IpAddress = "test" + + err := PermanentDeleteUser(c, user1) + if err != nil { + t.Fatal(err) + } + + Client.ClearOAuthToken() +} + func TestSendPasswordReset(t *testing.T) { Setup() diff --git a/doc/help/Messaging.md b/doc/help/Messaging.md index 03adc1ce8..09d35f3b9 100644 --- a/doc/help/Messaging.md +++ b/doc/help/Messaging.md @@ -4,7 +4,7 @@ You can write messages using the input box with the text "Write a message..." at the bottom of Mattermost. -Press **ENTER** to send a message. Use **Ctrl+ENTER** to create a new line without sending a message. +Press **ENTER** to send a message. Use **Shift+ENTER** to create a new line without sending a message. ## Formatting Messages diff --git a/mattermost.go b/mattermost.go index e1ae58904..2d5727400 100644 --- a/mattermost.go +++ b/mattermost.go @@ -30,6 +30,8 @@ var flagCmdCreateUser bool var flagCmdAssignRole bool var flagCmdVersion bool var flagCmdResetPassword bool +var flagCmdPermanentDeleteUser bool +var flagCmdPermanentDeleteTeam bool var flagConfigFile string var flagEmail string var flagPassword string @@ -191,10 +193,18 @@ func parseCmds() { flag.BoolVar(&flagCmdAssignRole, "assign_role", false, "") flag.BoolVar(&flagCmdVersion, "version", false, "") flag.BoolVar(&flagCmdResetPassword, "reset_password", false, "") + flag.BoolVar(&flagCmdPermanentDeleteUser, "permanent_delete_user", false, "") + flag.BoolVar(&flagCmdPermanentDeleteTeam, "permanent_delete_team", false, "") flag.Parse() - flagRunCmds = flagCmdCreateTeam || flagCmdCreateUser || flagCmdAssignRole || flagCmdResetPassword || flagCmdVersion + flagRunCmds = (flagCmdCreateTeam || + flagCmdCreateUser || + flagCmdAssignRole || + flagCmdResetPassword || + flagCmdVersion || + flagCmdPermanentDeleteUser || + flagCmdPermanentDeleteTeam) } func runCmds() { @@ -203,6 +213,8 @@ func runCmds() { cmdCreateUser() cmdAssignRole() cmdResetPassword() + cmdPermDeleteUser() + cmdPermDeleteTeam() } func cmdCreateTeam() { @@ -406,6 +418,106 @@ func cmdResetPassword() { } } +func cmdPermDeleteUser() { + if flagCmdPermanentDeleteUser { + if len(flagTeamName) == 0 { + fmt.Fprintln(os.Stderr, "flag needs an argument: -team_name") + flag.Usage() + os.Exit(1) + } + + if len(flagEmail) == 0 { + fmt.Fprintln(os.Stderr, "flag needs an argument: -email") + flag.Usage() + os.Exit(1) + } + + c := &api.Context{} + c.RequestId = model.NewId() + c.IpAddress = "cmd_line" + + var team *model.Team + if result := <-api.Srv.Store.Team().GetByName(flagTeamName); result.Err != nil { + l4g.Error("%v", result.Err) + flushLogAndExit(1) + } else { + team = result.Data.(*model.Team) + } + + var user *model.User + if result := <-api.Srv.Store.User().GetByEmail(team.Id, flagEmail); result.Err != nil { + l4g.Error("%v", result.Err) + flushLogAndExit(1) + } else { + user = result.Data.(*model.User) + } + + var confirmBackup string + fmt.Print("Have you performed a database backup? (YES/NO): ") + fmt.Scanln(&confirmBackup) + if confirmBackup != "YES" { + flushLogAndExit(1) + } + + var confirm string + fmt.Printf("Are you sure you want to delete the user %v? All data will be permanently deleted? (YES/NO): ", user.Email) + fmt.Scanln(&confirm) + if confirm != "YES" { + flushLogAndExit(1) + } + + if err := api.PermanentDeleteUser(c, user); err != nil { + l4g.Error("%v", err) + flushLogAndExit(1) + } else { + flushLogAndExit(0) + } + } +} + +func cmdPermDeleteTeam() { + if flagCmdPermanentDeleteTeam { + if len(flagTeamName) == 0 { + fmt.Fprintln(os.Stderr, "flag needs an argument: -team_name") + flag.Usage() + os.Exit(1) + } + + c := &api.Context{} + c.RequestId = model.NewId() + c.IpAddress = "cmd_line" + + var team *model.Team + if result := <-api.Srv.Store.Team().GetByName(flagTeamName); result.Err != nil { + l4g.Error("%v", result.Err) + flushLogAndExit(1) + } else { + team = result.Data.(*model.Team) + } + + var confirmBackup string + fmt.Print("Have you performed a database backup? (YES/NO): ") + fmt.Scanln(&confirmBackup) + if confirmBackup != "YES" { + flushLogAndExit(1) + } + + var confirm string + fmt.Printf("Are you sure you want to delete the team %v? All data will be permanently deleted? (YES/NO): ", team.Name) + fmt.Scanln(&confirm) + if confirm != "YES" { + flushLogAndExit(1) + } + + if err := api.PermanentDeleteTeam(c, team); err != nil { + l4g.Error("%v", err) + flushLogAndExit(1) + } else { + flushLogAndExit(0) + } + } +} + func flushLogAndExit(code int) { l4g.Close() time.Sleep(time.Second) @@ -461,5 +573,19 @@ Usage: Example: platform -reset_password -team_name="name" -email="user@example.com" -password="newpassword" + -permanent_delete_user Permanently deletes a user and all related information + include posts from the database. It requires the + -team_name, and -email flag. You may need to restart the + server to invlidate the cache + Example: + platform -permanent_delete_user -team_name="name" -email="user@example.com" + + -permanent_delete_team Permanently deletes a team and all users along with + all related information including posts from the database. + It requires the -team_name flag. You may need to restart + the server to invalidate the cache. + Example: + platform -permanent_delete_team -team_name="name" + ` diff --git a/store/sql_audit_store.go b/store/sql_audit_store.go index b3e2daea0..f4fd29aab 100644 --- a/store/sql_audit_store.go +++ b/store/sql_audit_store.go @@ -86,3 +86,22 @@ func (s SqlAuditStore) Get(user_id string, limit int) StoreChannel { return storeChannel } + +func (s SqlAuditStore) PermanentDeleteByUser(userId string) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if _, err := s.GetMaster().Exec("DELETE FROM Audits WHERE UserId = :userId", + map[string]interface{}{"userId": userId}); err != nil { + result.Err = model.NewAppError("SqlAuditStore.Delete", "We encountered an error deleting the audits", "user_id="+userId) + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_audit_store_test.go b/store/sql_audit_store_test.go index e265db837..b395631f1 100644 --- a/store/sql_audit_store_test.go +++ b/store/sql_audit_store_test.go @@ -44,4 +44,8 @@ func TestSqlAuditStore(t *testing.T) { if len(audits) != 0 { t.Fatal("Should have returned empty because user_id is missing") } + + if r2 := <-store.Audit().PermanentDeleteByUser(audit.UserId); r2.Err != nil { + t.Fatal(r2.Err) + } } diff --git a/store/sql_channel_store.go b/store/sql_channel_store.go index a9f99bd67..badaa4d13 100644 --- a/store/sql_channel_store.go +++ b/store/sql_channel_store.go @@ -279,6 +279,23 @@ func (s SqlChannelStore) Delete(channelId string, time int64) StoreChannel { return storeChannel } +func (s SqlChannelStore) PermanentDeleteByTeam(teamId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if _, err := s.GetMaster().Exec("DELETE FROM Channels WHERE TeamId = :TeamId", map[string]interface{}{"TeamId": teamId}); err != nil { + result.Err = model.NewAppError("SqlChannelStore.PermanentDeleteByTeam", "We couldn't delete the channels", "teamId="+teamId+", "+err.Error()) + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + type channelWithMember struct { model.Channel model.ChannelMember @@ -616,6 +633,23 @@ func (s SqlChannelStore) RemoveMember(channelId string, userId string) StoreChan return storeChannel } +func (s SqlChannelStore) PermanentDeleteMembersByUser(userId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if _, err := s.GetMaster().Exec("DELETE FROM ChannelMembers WHERE UserId = :UserId", map[string]interface{}{"UserId": userId}); err != nil { + result.Err = model.NewAppError("SqlChannelStore.RemoveMember", "We couldn't remove the channel member", "user_id="+userId+", "+err.Error()) + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + func (s SqlChannelStore) CheckPermissionsTo(teamId string, channelId string, userId string) StoreChannel { storeChannel := make(StoreChannel) diff --git a/store/sql_channel_store_test.go b/store/sql_channel_store_test.go index 8662fcbd3..695991bf7 100644 --- a/store/sql_channel_store_test.go +++ b/store/sql_channel_store_test.go @@ -379,6 +379,63 @@ func TestChannelMemberStore(t *testing.T) { } } +func TestChannelDeleteMemberStore(t *testing.T) { + Setup() + + c1 := model.Channel{} + c1.TeamId = model.NewId() + c1.DisplayName = "NameName" + c1.Name = "a" + model.NewId() + "b" + c1.Type = model.CHANNEL_OPEN + c1 = *Must(store.Channel().Save(&c1)).(*model.Channel) + + c1t1 := (<-store.Channel().Get(c1.Id)).Data.(*model.Channel) + t1 := c1t1.ExtraUpdateAt + + u1 := model.User{} + u1.TeamId = model.NewId() + u1.Email = model.NewId() + u1.Nickname = model.NewId() + Must(store.User().Save(&u1)) + + u2 := model.User{} + u2.TeamId = model.NewId() + u2.Email = model.NewId() + u2.Nickname = model.NewId() + Must(store.User().Save(&u2)) + + o1 := model.ChannelMember{} + o1.ChannelId = c1.Id + o1.UserId = u1.Id + o1.NotifyProps = model.GetDefaultChannelNotifyProps() + Must(store.Channel().SaveMember(&o1)) + + o2 := model.ChannelMember{} + o2.ChannelId = c1.Id + o2.UserId = u2.Id + o2.NotifyProps = model.GetDefaultChannelNotifyProps() + Must(store.Channel().SaveMember(&o2)) + + c1t2 := (<-store.Channel().Get(c1.Id)).Data.(*model.Channel) + t2 := c1t2.ExtraUpdateAt + + if t2 <= t1 { + t.Fatal("Member update time incorrect") + } + + count := (<-store.Channel().GetMemberCount(o1.ChannelId)).Data.(int64) + if count != 2 { + t.Fatal("should have saved 2 members") + } + + Must(store.Channel().PermanentDeleteMembersByUser(o2.UserId)) + + count = (<-store.Channel().GetMemberCount(o1.ChannelId)).Data.(int64) + if count != 1 { + t.Fatal("should have removed 1 member") + } +} + func TestChannelStorePermissionsTo(t *testing.T) { Setup() diff --git a/store/sql_oauth_store.go b/store/sql_oauth_store.go index 751207b85..43a5bee31 100644 --- a/store/sql_oauth_store.go +++ b/store/sql_oauth_store.go @@ -332,3 +332,21 @@ func (as SqlOAuthStore) RemoveAuthData(code string) StoreChannel { return storeChannel } + +func (as SqlOAuthStore) PermanentDeleteAuthDataByUser(userId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + _, err := as.GetMaster().Exec("DELETE FROM OAuthAuthData WHERE UserId = :UserId", map[string]interface{}{"UserId": userId}) + if err != nil { + result.Err = model.NewAppError("SqlOAuthStore.RemoveAuthDataByUserId", "We couldn't remove the authorization code", "err="+err.Error()) + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_oauth_store_test.go b/store/sql_oauth_store_test.go index 3f05f1c92..c3f6ea7ac 100644 --- a/store/sql_oauth_store_test.go +++ b/store/sql_oauth_store_test.go @@ -180,3 +180,17 @@ func TestOAuthStoreRemoveAuthData(t *testing.T) { t.Fatal("should have errored - auth code removed") } } + +func TestOAuthStoreRemoveAuthDataByUser(t *testing.T) { + Setup() + + a1 := model.AuthData{} + a1.ClientId = model.NewId() + a1.UserId = model.NewId() + a1.Code = model.NewId() + Must(store.OAuth().SaveAuthData(&a1)) + + if err := (<-store.OAuth().PermanentDeleteAuthDataByUser(a1.UserId)).Err; err != nil { + t.Fatal(err) + } +} diff --git a/store/sql_post_store.go b/store/sql_post_store.go index f800367cb..cc596074f 100644 --- a/store/sql_post_store.go +++ b/store/sql_post_store.go @@ -228,6 +228,99 @@ func (s SqlPostStore) Delete(postId string, time int64) StoreChannel { return storeChannel } +func (s SqlPostStore) permanentDelete(postId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + _, err := s.GetMaster().Exec("DELETE FROM Posts WHERE Id = :Id OR ParentId = :ParentId OR RootId = :RootId", map[string]interface{}{"Id": postId, "ParentId": postId, "RootId": postId}) + if err != nil { + result.Err = model.NewAppError("SqlPostStore.Delete", "We couldn't delete the post", "id="+postId+", err="+err.Error()) + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlPostStore) permanentDeleteAllCommentByUser(userId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + _, err := s.GetMaster().Exec("DELETE FROM Posts WHERE UserId = :UserId AND RootId != ''", map[string]interface{}{"UserId": userId}) + if err != nil { + result.Err = model.NewAppError("SqlPostStore.permanentDeleteAllCommentByUser", "We couldn't delete the comments for user", "userId="+userId+", err="+err.Error()) + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlPostStore) PermanentDeleteByUser(userId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + // First attempt to delete all the comments for a user + if r := <-s.permanentDeleteAllCommentByUser(userId); r.Err != nil { + result.Err = r.Err + storeChannel <- result + close(storeChannel) + return + } + + // Now attempt to delete all the root posts for a user. This will also + // delete all the comments for each post. + found := true + count := 0 + + for found { + var ids []string + _, err := s.GetMaster().Select(&ids, "SELECT Id FROM Posts WHERE UserId = :UserId LIMIT 1000", map[string]interface{}{"UserId": userId}) + if err != nil { + result.Err = model.NewAppError("SqlPostStore.PermanentDeleteByUser.select", "We couldn't select the posts to delete for the user", "userId="+userId+", err="+err.Error()) + storeChannel <- result + close(storeChannel) + return + } else { + found = false + for _, id := range ids { + found = true + if r := <-s.permanentDelete(id); r.Err != nil { + result.Err = r.Err + storeChannel <- result + close(storeChannel) + return + } + } + } + + // This is a fail safe, give up if more than 10K messages + count = count + 1 + if count >= 10 { + result.Err = model.NewAppError("SqlPostStore.PermanentDeleteByUser.toolarge", "We couldn't select the posts to delete for the user (too many), please re-run", "userId="+userId) + storeChannel <- result + close(storeChannel) + return + } + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + func (s SqlPostStore) GetPosts(channelId string, offset int, limit int) StoreChannel { storeChannel := make(StoreChannel) diff --git a/store/sql_post_store_test.go b/store/sql_post_store_test.go index fe7195a54..d9b087ea7 100644 --- a/store/sql_post_store_test.go +++ b/store/sql_post_store_test.go @@ -247,6 +247,76 @@ func TestPostStoreDelete2Level(t *testing.T) { } } +func TestPostStorePermDelete1Level(t *testing.T) { + Setup() + + o1 := &model.Post{} + o1.ChannelId = model.NewId() + o1.UserId = model.NewId() + o1.Message = "a" + model.NewId() + "b" + o1 = (<-store.Post().Save(o1)).Data.(*model.Post) + + o2 := &model.Post{} + o2.ChannelId = o1.ChannelId + o2.UserId = model.NewId() + o2.Message = "a" + model.NewId() + "b" + o2.ParentId = o1.Id + o2.RootId = o1.Id + o2 = (<-store.Post().Save(o2)).Data.(*model.Post) + + if r2 := <-store.Post().PermanentDeleteByUser(o2.UserId); r2.Err != nil { + t.Fatal(r2.Err) + } + + if r3 := (<-store.Post().Get(o1.Id)); r3.Err != nil { + t.Fatal("Deleted id shouldn't have failed") + } + + if r4 := (<-store.Post().Get(o2.Id)); r4.Err == nil { + t.Fatal("Deleted id should have failed") + } +} + +func TestPostStorePermDelete1Level2(t *testing.T) { + Setup() + + o1 := &model.Post{} + o1.ChannelId = model.NewId() + o1.UserId = model.NewId() + o1.Message = "a" + model.NewId() + "b" + o1 = (<-store.Post().Save(o1)).Data.(*model.Post) + + o2 := &model.Post{} + o2.ChannelId = o1.ChannelId + o2.UserId = model.NewId() + o2.Message = "a" + model.NewId() + "b" + o2.ParentId = o1.Id + o2.RootId = o1.Id + o2 = (<-store.Post().Save(o2)).Data.(*model.Post) + + o3 := &model.Post{} + o3.ChannelId = model.NewId() + o3.UserId = model.NewId() + o3.Message = "a" + model.NewId() + "b" + o3 = (<-store.Post().Save(o3)).Data.(*model.Post) + + if r2 := <-store.Post().PermanentDeleteByUser(o1.UserId); r2.Err != nil { + t.Fatal(r2.Err) + } + + if r3 := (<-store.Post().Get(o1.Id)); r3.Err == nil { + t.Fatal("Deleted id should have failed") + } + + if r4 := (<-store.Post().Get(o2.Id)); r4.Err == nil { + t.Fatal("Deleted id should have failed") + } + + if r5 := (<-store.Post().Get(o3.Id)); r5.Err != nil { + t.Fatal("Deleted id shouldn't have failed") + } +} + func TestPostStoreGetWithChildren(t *testing.T) { Setup() diff --git a/store/sql_preference_store.go b/store/sql_preference_store.go index f9f38b747..8454abcbd 100644 --- a/store/sql_preference_store.go +++ b/store/sql_preference_store.go @@ -239,3 +239,21 @@ func (s SqlPreferenceStore) GetAll(userId string) StoreChannel { return storeChannel } + +func (s SqlPreferenceStore) PermanentDeleteByUser(userId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if _, err := s.GetMaster().Exec( + `DELETE FROM Preferences WHERE UserId = :UserId`, map[string]interface{}{"UserId": userId}); err != nil { + result.Err = model.NewAppError("SqlPreferenceStore.Delete", "We encountered an error while deleteing preferences", err.Error()) + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_preference_store_test.go b/store/sql_preference_store_test.go index e68203cc3..77da71fd6 100644 --- a/store/sql_preference_store_test.go +++ b/store/sql_preference_store_test.go @@ -192,3 +192,43 @@ func TestPreferenceGetAll(t *testing.T) { } } } + +func TestPreferenceDelete(t *testing.T) { + Setup() + + userId := model.NewId() + category := model.PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW + name := model.NewId() + + preferences := model.Preferences{ + { + UserId: userId, + Category: category, + Name: name, + }, + // same user/category, different name + { + UserId: userId, + Category: category, + Name: model.NewId(), + }, + // same user/name, different category + { + UserId: userId, + Category: model.NewId(), + Name: name, + }, + // same name/category, different user + { + UserId: model.NewId(), + Category: category, + Name: name, + }, + } + + Must(store.Preference().Save(&preferences)) + + if result := <-store.Preference().PermanentDeleteByUser(userId); result.Err != nil { + t.Fatal(result.Err) + } +} diff --git a/store/sql_session_store.go b/store/sql_session_store.go index 27b34ee39..86604b4fe 100644 --- a/store/sql_session_store.go +++ b/store/sql_session_store.go @@ -158,6 +158,24 @@ func (me SqlSessionStore) RemoveAllSessionsForTeam(teamId string) StoreChannel { return storeChannel } +func (me SqlSessionStore) PermanentDeleteSessionsByUser(userId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + _, err := me.GetMaster().Exec("DELETE FROM Sessions WHERE UserId = :UserId", map[string]interface{}{"UserId": userId}) + if err != nil { + result.Err = model.NewAppError("SqlSessionStore.RemoveAllSessionsForUser", "We couldn't remove all the sessions for the user", "id="+userId+", err="+err.Error()) + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + func (me SqlSessionStore) CleanUpExpiredSessions(userId string) StoreChannel { storeChannel := make(StoreChannel) diff --git a/store/sql_session_store_test.go b/store/sql_session_store_test.go index 068e5fc19..cec8e93b0 100644 --- a/store/sql_session_store_test.go +++ b/store/sql_session_store_test.go @@ -103,6 +103,29 @@ func TestSessionRemoveAll(t *testing.T) { } } +func TestSessionRemoveByUser(t *testing.T) { + Setup() + + s1 := model.Session{} + s1.UserId = model.NewId() + s1.TeamId = model.NewId() + Must(store.Session().Save(&s1)) + + if rs1 := (<-store.Session().Get(s1.Id)); rs1.Err != nil { + t.Fatal(rs1.Err) + } else { + if rs1.Data.(*model.Session).Id != s1.Id { + t.Fatal("should match") + } + } + + Must(store.Session().PermanentDeleteSessionsByUser(s1.UserId)) + + if rs2 := (<-store.Session().Get(s1.Id)); rs2.Err == nil { + t.Fatal("should have been removed") + } +} + func TestSessionRemoveToken(t *testing.T) { Setup() diff --git a/store/sql_team_store.go b/store/sql_team_store.go index 1a0aeabde..9578549ca 100644 --- a/store/sql_team_store.go +++ b/store/sql_team_store.go @@ -300,3 +300,20 @@ func (s SqlTeamStore) GetAllTeamListing() StoreChannel { return storeChannel } + +func (s SqlTeamStore) PermanentDelete(teamId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if _, err := s.GetMaster().Exec("DELETE FROM Teams WHERE Id = :TeamId", map[string]interface{}{"TeamId": teamId}); err != nil { + result.Err = model.NewAppError("SqlTeamStore.Delete", "We couldn't delete the existing team", "teamId="+teamId+", "+err.Error()) + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_team_store_test.go b/store/sql_team_store_test.go index 71740f7e7..7dc31cbe2 100644 --- a/store/sql_team_store_test.go +++ b/store/sql_team_store_test.go @@ -238,3 +238,26 @@ func TestAllTeamListing(t *testing.T) { } } } + +func TestDelete(t *testing.T) { + Setup() + + o1 := model.Team{} + o1.DisplayName = "DisplayName" + o1.Name = "a" + model.NewId() + "b" + o1.Email = model.NewId() + "@nowhere.com" + o1.Type = model.TEAM_OPEN + o1.AllowTeamListing = true + Must(store.Team().Save(&o1)) + + o2 := model.Team{} + o2.DisplayName = "DisplayName" + o2.Name = "a" + model.NewId() + "b" + o2.Email = model.NewId() + "@nowhere.com" + o2.Type = model.TEAM_OPEN + Must(store.Team().Save(&o2)) + + if r1 := <-store.Team().PermanentDelete(o1.Id); r1.Err != nil { + t.Fatal(r1.Err) + } +} diff --git a/store/sql_user_store.go b/store/sql_user_store.go index 77ff5bfab..d19135b64 100644 --- a/store/sql_user_store.go +++ b/store/sql_user_store.go @@ -554,3 +554,21 @@ func (us SqlUserStore) GetTotalActiveUsersCount() StoreChannel { return storeChannel } + +func (us SqlUserStore) PermanentDelete(userId string) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if _, err := us.GetMaster().Exec("DELETE FROM Users WHERE Id = :UserId", map[string]interface{}{"UserId": userId}); err != nil { + result.Err = model.NewAppError("SqlUserStore.GetByEmail", "We couldn't delete the existing account", "userId="+userId+", "+err.Error()) + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_user_store_test.go b/store/sql_user_store_test.go index 874baf634..dd08438f1 100644 --- a/store/sql_user_store_test.go +++ b/store/sql_user_store_test.go @@ -377,3 +377,16 @@ func TestUserStoreUpdatePassword(t *testing.T) { } } } + +func TestUserStoreDelete(t *testing.T) { + Setup() + + u1 := model.User{} + u1.TeamId = model.NewId() + u1.Email = model.NewId() + Must(store.User().Save(&u1)) + + if err := (<-store.User().PermanentDelete(u1.Id)).Err; err != nil { + t.Fatal(err) + } +} diff --git a/store/sql_webhook_store.go b/store/sql_webhook_store.go index c758e2339..b7bf0615f 100644 --- a/store/sql_webhook_store.go +++ b/store/sql_webhook_store.go @@ -116,6 +116,24 @@ func (s SqlWebhookStore) DeleteIncoming(webhookId string, time int64) StoreChann return storeChannel } +func (s SqlWebhookStore) PermanentDeleteIncomingByUser(userId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + _, err := s.GetMaster().Exec("DELETE FROM IncomingWebhooks WHERE UserId = :UserId", map[string]interface{}{"UserId": userId}) + if err != nil { + result.Err = model.NewAppError("SqlWebhookStore.DeleteIncomingByUser", "We couldn't delete the webhook", "id="+userId+", err="+err.Error()) + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + func (s SqlWebhookStore) GetIncomingByUser(userId string) StoreChannel { storeChannel := make(StoreChannel) @@ -294,6 +312,24 @@ func (s SqlWebhookStore) DeleteOutgoing(webhookId string, time int64) StoreChann return storeChannel } +func (s SqlWebhookStore) PermanentDeleteOutgoingByUser(userId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + _, err := s.GetMaster().Exec("DELETE FROM OutgoingWebhooks WHERE CreatorId = :UserId", map[string]interface{}{"UserId": userId}) + if err != nil { + result.Err = model.NewAppError("SqlWebhookStore.DeleteOutgoingByUser", "We couldn't delete the webhook", "id="+userId+", err="+err.Error()) + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + func (s SqlWebhookStore) UpdateOutgoing(hook *model.OutgoingWebhook) StoreChannel { storeChannel := make(StoreChannel) diff --git a/store/sql_webhook_store_test.go b/store/sql_webhook_store_test.go index 1fb990f3e..1a9d5be3b 100644 --- a/store/sql_webhook_store_test.go +++ b/store/sql_webhook_store_test.go @@ -103,6 +103,34 @@ func TestWebhookStoreDeleteIncoming(t *testing.T) { } } +func TestWebhookStoreDeleteIncomingByUser(t *testing.T) { + Setup() + + o1 := &model.IncomingWebhook{} + o1.ChannelId = model.NewId() + o1.UserId = model.NewId() + o1.TeamId = model.NewId() + + o1 = (<-store.Webhook().SaveIncoming(o1)).Data.(*model.IncomingWebhook) + + if r1 := <-store.Webhook().GetIncoming(o1.Id); r1.Err != nil { + t.Fatal(r1.Err) + } else { + if r1.Data.(*model.IncomingWebhook).CreateAt != o1.CreateAt { + t.Fatal("invalid returned webhook") + } + } + + if r2 := <-store.Webhook().PermanentDeleteIncomingByUser(o1.UserId); r2.Err != nil { + t.Fatal(r2.Err) + } + + if r3 := (<-store.Webhook().GetIncoming(o1.Id)); r3.Err == nil { + t.Log(r3.Data) + t.Fatal("Missing id should have failed") + } +} + func TestWebhookStoreSaveOutgoing(t *testing.T) { Setup() @@ -258,6 +286,35 @@ func TestWebhookStoreDeleteOutgoing(t *testing.T) { } } +func TestWebhookStoreDeleteOutgoingByUser(t *testing.T) { + Setup() + + o1 := &model.OutgoingWebhook{} + o1.ChannelId = model.NewId() + o1.CreatorId = model.NewId() + o1.TeamId = model.NewId() + o1.CallbackURLs = []string{"http://nowhere.com/"} + + o1 = (<-store.Webhook().SaveOutgoing(o1)).Data.(*model.OutgoingWebhook) + + if r1 := <-store.Webhook().GetOutgoing(o1.Id); r1.Err != nil { + t.Fatal(r1.Err) + } else { + if r1.Data.(*model.OutgoingWebhook).CreateAt != o1.CreateAt { + t.Fatal("invalid returned webhook") + } + } + + if r2 := <-store.Webhook().PermanentDeleteOutgoingByUser(o1.CreatorId); r2.Err != nil { + t.Fatal(r2.Err) + } + + if r3 := (<-store.Webhook().GetOutgoing(o1.Id)); r3.Err == nil { + t.Log(r3.Data) + t.Fatal("Missing id should have failed") + } +} + func TestWebhookStoreUpdateOutgoing(t *testing.T) { Setup() diff --git a/store/store.go b/store/store.go index 13b59b582..338ae186f 100644 --- a/store/store.go +++ b/store/store.go @@ -52,6 +52,7 @@ type TeamStore interface { GetAll() StoreChannel GetAllTeamListing() StoreChannel GetByInviteId(inviteId string) StoreChannel + PermanentDelete(teamId string) StoreChannel } type ChannelStore interface { @@ -60,6 +61,7 @@ type ChannelStore interface { Update(channel *model.Channel) StoreChannel Get(id string) StoreChannel Delete(channelId string, time int64) StoreChannel + PermanentDeleteByTeam(teamId string) StoreChannel GetByName(team_id string, domain string) StoreChannel GetChannels(teamId string, userId string) StoreChannel GetMoreChannels(teamId string, userId string) StoreChannel @@ -72,6 +74,7 @@ type ChannelStore interface { GetMember(channelId string, userId string) StoreChannel GetMemberCount(channelId string) StoreChannel RemoveMember(channelId string, userId string) StoreChannel + PermanentDeleteMembersByUser(userId string) StoreChannel GetExtraMembers(channelId string, limit int) StoreChannel CheckPermissionsTo(teamId string, channelId string, userId string) StoreChannel CheckOpenChannelPermissions(teamId string, channelId string) StoreChannel @@ -86,6 +89,7 @@ type PostStore interface { Update(post *model.Post, newMessage string, newHashtags string) StoreChannel Get(id string) StoreChannel Delete(postId string, time int64) StoreChannel + PermanentDeleteByUser(userId string) StoreChannel GetPosts(channelId string, offset int, limit int) StoreChannel GetPostsBefore(channelId string, postId string, numPosts int, offset int) StoreChannel GetPostsAfter(channelId string, postId string, numPosts int, offset int) StoreChannel @@ -118,6 +122,7 @@ type UserStore interface { GetTotalUsersCount() StoreChannel GetTotalActiveUsersCount() StoreChannel GetSystemAdminProfiles() StoreChannel + PermanentDelete(userId string) StoreChannel } type SessionStore interface { @@ -126,6 +131,7 @@ type SessionStore interface { GetSessions(userId string) StoreChannel Remove(sessionIdOrToken string) StoreChannel RemoveAllSessionsForTeam(teamId string) StoreChannel + PermanentDeleteSessionsByUser(teamId string) StoreChannel UpdateLastActivityAt(sessionId string, time int64) StoreChannel UpdateRoles(userId string, roles string) StoreChannel } @@ -133,6 +139,7 @@ type SessionStore interface { type AuditStore interface { Save(audit *model.Audit) StoreChannel Get(user_id string, limit int) StoreChannel + PermanentDeleteByUser(userId string) StoreChannel } type OAuthStore interface { @@ -143,6 +150,7 @@ type OAuthStore interface { SaveAuthData(authData *model.AuthData) StoreChannel GetAuthData(code string) StoreChannel RemoveAuthData(code string) StoreChannel + PermanentDeleteAuthDataByUser(userId string) StoreChannel SaveAccessData(accessData *model.AccessData) StoreChannel GetAccessData(token string) StoreChannel GetAccessDataByAuthCode(authCode string) StoreChannel @@ -161,12 +169,14 @@ type WebhookStore interface { GetIncomingByUser(userId string) StoreChannel GetIncomingByChannel(channelId string) StoreChannel DeleteIncoming(webhookId string, time int64) StoreChannel + PermanentDeleteIncomingByUser(userId string) StoreChannel SaveOutgoing(webhook *model.OutgoingWebhook) StoreChannel GetOutgoing(id string) StoreChannel GetOutgoingByCreator(userId string) StoreChannel GetOutgoingByChannel(channelId string) StoreChannel GetOutgoingByTeam(teamId string) StoreChannel DeleteOutgoing(webhookId string, time int64) StoreChannel + PermanentDeleteOutgoingByUser(userId string) StoreChannel UpdateOutgoing(hook *model.OutgoingWebhook) StoreChannel } @@ -175,4 +185,5 @@ type PreferenceStore interface { Get(userId string, category string, name string) StoreChannel GetCategory(userId string, category string) StoreChannel GetAll(userId string) StoreChannel + PermanentDeleteByUser(userId string) StoreChannel } diff --git a/utils/config.go b/utils/config.go index 13b7b6b64..2fd799cd1 100644 --- a/utils/config.go +++ b/utils/config.go @@ -59,8 +59,7 @@ func FindDir(dir string) string { func ConfigureCmdLineLog() { ls := model.LogSettings{} ls.EnableConsole = true - ls.ConsoleLevel = "ERROR" - ls.EnableFile = false + ls.ConsoleLevel = "WARN" configureLog(&ls) } @@ -72,6 +71,8 @@ func configureLog(s *model.LogSettings) { level := l4g.DEBUG if s.ConsoleLevel == "INFO" { level = l4g.INFO + } else if s.ConsoleLevel == "WARN" { + level = l4g.WARNING } else if s.ConsoleLevel == "ERROR" { level = l4g.ERROR } @@ -90,6 +91,8 @@ func configureLog(s *model.LogSettings) { level := l4g.DEBUG if s.FileLevel == "INFO" { level = l4g.INFO + } else if s.FileLevel == "WARN" { + level = l4g.WARNING } else if s.FileLevel == "ERROR" { level = l4g.ERROR } diff --git a/web/react/components/channel_header.jsx b/web/react/components/channel_header.jsx index ffe7cbb5d..e46b2ccd7 100644 --- a/web/react/components/channel_header.jsx +++ b/web/react/components/channel_header.jsx @@ -27,6 +27,7 @@ const ActionTypes = Constants.ActionTypes; const Popover = ReactBootstrap.Popover; const OverlayTrigger = ReactBootstrap.OverlayTrigger; +const Tooltip = ReactBootstrap.Tooltip; export default class ChannelHeader extends React.Component { constructor(props) { @@ -121,6 +122,7 @@ export default class ChannelHeader extends React.Component { } const channel = this.state.channel; + const recentMentionsTooltip = <Tooltip id='recentMentionsTooltip'>{'Recent Mentions'}</Tooltip>; const popoverContent = ( <Popover id='hader-popover' @@ -382,31 +384,19 @@ export default class ChannelHeader extends React.Component { <th className='search-bar__container'><NavbarSearchBox /></th> <th> <div className='dropdown channel-header__links'> - <a - href='#' - className='dropdown-toggle theme' - type='button' - id='channel_header_right_dropdown' - data-toggle='dropdown' - aria-expanded='true' - > - <span dangerouslySetInnerHTML={{__html: Constants.MENU_ICON}} /> - </a> - <ul - className='dropdown-menu dropdown-menu-right' - role='menu' - aria-labelledby='channel_header_right_dropdown' + <OverlayTrigger + delayShow={400} + placement='bottom' + overlay={recentMentionsTooltip} > - <li role='presentation'> - <a - role='menuitem' - href='#' - onClick={this.searchMentions} - > - {'Recent Mentions'} - </a> - </li> - </ul> + <a + href='#' + type='button' + onClick={this.searchMentions} + > + {'@'} + </a> + </OverlayTrigger> </div> </th> </tr> diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index 6f25ef608..d823a54f1 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -383,8 +383,8 @@ export default class CreatePost extends React.Component { screens.push( <div> <h4>{'Sending Messages'}</h4> - <p>{'Type here to write a message.'}</p> - <p>{'Click the attachment button to upload an image or a file.'}</p> + <p>{'Type here to write a message and press '}<strong>{'Enter'}</strong>{' to post it.'}</p> + <p>{'Click the '}<strong>{'Attachment'}</strong>{' button to upload an image or a file.'}</p> </div> ); diff --git a/web/react/components/post.jsx b/web/react/components/post.jsx index 2b9586345..7e4af07c4 100644 --- a/web/react/components/post.jsx +++ b/web/react/components/post.jsx @@ -13,8 +13,6 @@ var AsyncClient = require('../utils/async_client.jsx'); var ActionTypes = Constants.ActionTypes; var utils = require('../utils/utils.jsx'); -var PostInfo = require('./post_info.jsx'); - export default class Post extends React.Component { constructor(props) { super(props); @@ -141,6 +139,8 @@ export default class Post extends React.Component { var postType = ''; if (type !== 'Post') { postType = 'post--comment'; + } else if (commentCount > 0) { + postType = 'post--root'; } var currentUserCss = ''; @@ -170,14 +170,11 @@ export default class Post extends React.Component { } profilePic = ( - <div className='post-profile-img__container'> - <img - className='post-profile-img' - src={src} - height='36' - width='36' - /> - </div> + <img + src={src} + height='36' + width='36' + /> ); } @@ -187,32 +184,26 @@ export default class Post extends React.Component { id={'post_' + post.id} className={'post ' + sameUserClass + ' ' + rootUser + ' ' + postType + ' ' + currentUserCss} > - {profilePic} <div className='post__content'> - <PostHeader - ref='header' - post={post} - sameRoot={this.props.sameRoot} - commentCount={commentCount} - handleCommentClick={this.handleCommentClick} - isLastComment={this.props.isLastComment} - /> - <PostBody - post={post} - sameRoot={this.props.sameRoot} - parentPost={parentPost} - posts={posts} - handleCommentClick={this.handleCommentClick} - retryPost={this.retryPost} - /> - <PostInfo - ref='info' - post={post} - sameRoot={this.props.sameRoot} - commentCount={commentCount} - handleCommentClick={this.handleCommentClick} - allowReply='true' - /> + <div className='post__img'>{profilePic}</div> + <div> + <PostHeader + ref='header' + post={post} + sameRoot={this.props.sameRoot} + commentCount={commentCount} + handleCommentClick={this.handleCommentClick} + isLastComment={this.props.isLastComment} + /> + <PostBody + post={post} + sameRoot={this.props.sameRoot} + parentPost={parentPost} + posts={posts} + handleCommentClick={this.handleCommentClick} + retryPost={this.retryPost} + /> + </div> </div> </div> </div> diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx index 975ac64dc..e1c057775 100644 --- a/web/react/components/post_body.jsx +++ b/web/react/components/post_body.jsx @@ -257,7 +257,7 @@ export default class PostBody extends React.Component { } return ( - <div className='post-comment'> + <div> <h4> <span className='video-type'>{header}</span> <span className='video-title'><a href={link}>{this.state.youtubeTitle}</a></span> @@ -329,7 +329,7 @@ export default class PostBody extends React.Component { } comment = ( - <p className='post-link'> + <div className='post__link'> <span> {'Commented on '}{name}{apostrophe}{' message: '} <a @@ -339,15 +339,13 @@ export default class PostBody extends React.Component { {message} </a> </span> - </p> + </div> ); - - postClass += ' post-comment'; } let loading; if (post.state === Constants.POST_FAILED) { - postClass += ' post-fail'; + postClass += ' post--fail'; loading = ( <a className='theme post-retry pull-right' @@ -379,25 +377,27 @@ export default class PostBody extends React.Component { } return ( - <div className='post-body'> + <div> {comment} - <div - key={`${post.id}_message`} - id={`${post.id}_message`} - className={postClass} - > - {loading} - <span - ref='message_span' - onClick={TextFormatting.handleClick} - dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.state.message)}} + <div className='post__body'> + <div + key={`${post.id}_message`} + id={`${post.id}_message`} + className={postClass} + > + {loading} + <span + ref='message_span' + onClick={TextFormatting.handleClick} + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.state.message)}} + /> + </div> + <PostBodyAdditionalContent + post={this.state.post} /> + {fileAttachmentHolder} + {this.embed} </div> - <PostBodyAdditionalContent - post={this.state.post} - /> - {fileAttachmentHolder} - {this.embed} </div> ); } diff --git a/web/react/components/post_header.jsx b/web/react/components/post_header.jsx index 45e60c767..e8b162fc2 100644 --- a/web/react/components/post_header.jsx +++ b/web/react/components/post_header.jsx @@ -26,14 +26,14 @@ export default class PostHeader extends React.Component { ); } - botIndicator = <li className='post-header-col post-header__name bot-indicator'>{'BOT'}</li>; + botIndicator = <li className='col col__name bot-indicator'>{'BOT'}</li>; } return ( - <ul className='post-header post-header-post'> - <li className='post-header-col post-header__name'><strong>{userProfile}</strong></li> + <ul className='post__header'> + <li className='col col__name'>{userProfile}</li> {botIndicator} - <li className='post-info--hidden'> + <li className='col'> <PostInfo post={post} commentCount={this.props.commentCount} diff --git a/web/react/components/post_info.jsx b/web/react/components/post_info.jsx index fffa5b19a..5446fca7a 100644 --- a/web/react/components/post_info.jsx +++ b/web/react/components/post_info.jsx @@ -74,23 +74,6 @@ export default class PostInfo extends React.Component { ); } - if (this.props.allowReply === 'true') { - dropdownContents.push( - <li - key='replyLink' - role='presentation' - > - <a - className='reply-link theme' - href='#' - onClick={this.props.handleCommentClick} - > - {'Reply'} - </a> - </li> - ); - } - if (dropdownContents.length === 0) { return ''; } @@ -99,7 +82,7 @@ export default class PostInfo extends React.Component { <div> <a href='#' - className='dropdown-toggle theme' + className='dropdown-toggle post__dropdown theme' type='button' data-toggle='dropdown' aria-expanded='false' @@ -116,23 +99,27 @@ export default class PostInfo extends React.Component { render() { var post = this.props.post; var comments = ''; - var lastCommentClass = ' comment-icon__container__hide'; - if (this.props.isLastComment) { - lastCommentClass = ' comment-icon__container__show'; + var showCommentClass = ''; + var commentCountText = this.props.commentCount; + + if (this.props.commentCount >= 1) { + showCommentClass = ' icon--show'; + } else { + commentCountText = ''; } - if (this.props.commentCount >= 1 && post.state !== Constants.POST_FAILED && post.state !== Constants.POST_LOADING && post.state !== Constants.POST_DELETED) { + if (post.state !== Constants.POST_FAILED && post.state !== Constants.POST_LOADING && post.state !== Constants.POST_DELETED) { comments = ( <a href='#' - className={'comment-icon__container theme' + lastCommentClass} + className={'comment-icon__container' + showCommentClass} onClick={this.props.handleCommentClick} > <span className='comment-icon' dangerouslySetInnerHTML={{__html: Constants.COMMENT_ICON}} /> - {this.props.commentCount} + {commentCountText} </a> ); } @@ -140,17 +127,17 @@ export default class PostInfo extends React.Component { var dropdown = this.createDropdown(); return ( - <ul className='post-header post-info'> - <li className='post-header-col'> + <ul className='post__header post__header--info'> + <li className='col'> <TimeSince eventTime={post.create_at} /> </li> - <li className='post-header-col post-header__reply'> + <li className='col col__reply'> + {comments} <div className='dropdown'> {dropdown} </div> - {comments} </li> </ul> ); diff --git a/web/react/components/rhs_comment.jsx b/web/react/components/rhs_comment.jsx index c16f9ff0e..a55bf0039 100644 --- a/web/react/components/rhs_comment.jsx +++ b/web/react/components/rhs_comment.jsx @@ -131,7 +131,7 @@ export default class RhsComment extends React.Component { <div className='dropdown'> <a href='#' - className='dropdown-toggle theme' + className='post__dropdown dropdown-toggle' type='button' data-toggle='dropdown' aria-expanded='false' @@ -193,38 +193,39 @@ export default class RhsComment extends React.Component { return ( <div className={'post ' + currentUserCss}> - <div className='post-profile-img__container'> - <img - className='post-profile-img' - src={'/api/v1/users/' + post.user_id + '/image?time=' + timestamp + '&' + Utils.getSessionIndex()} - height='36' - width='36' - /> - </div> <div className='post__content'> - <ul className='post-header'> - <li className='post-header-col'> - <strong><UserProfile userId={post.user_id} /></strong> - </li> - <li className='post-header-col'> - <time className='post-profile-time'> - {Utils.displayCommentDateTime(post.create_at)} - </time> - </li> - <li className='post-header-col post-header__reply'> - {dropdown} - </li> - </ul> - <div className='post-body'> - <div className={postClass}> - {loading} - <div - ref='message_holder' - onClick={TextFormatting.handleClick} - dangerouslySetInnerHTML={{__html: TextFormatting.formatText(post.message)}} - /> + <div className='post__img'> + <img + src={'/api/v1/users/' + post.user_id + '/image?time=' + timestamp + '&' + Utils.getSessionIndex()} + height='36' + width='36' + /> + </div> + <div> + <ul className='post__header'> + <li className='col__name'> + <strong><UserProfile userId={post.user_id} /></strong> + </li> + <li className='col'> + <time className='post__time'> + {Utils.displayCommentDateTime(post.create_at)} + </time> + </li> + <li className='col col__reply'> + {dropdown} + </li> + </ul> + <div className='post__body'> + <div className={postClass}> + {loading} + <div + ref='message_holder' + onClick={TextFormatting.handleClick} + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(post.message)}} + /> + </div> + {fileAttachment} </div> - {fileAttachment} </div> </div> </div> diff --git a/web/react/components/rhs_root_post.jsx b/web/react/components/rhs_root_post.jsx index 84fdc014a..358bf8440 100644 --- a/web/react/components/rhs_root_post.jsx +++ b/web/react/components/rhs_root_post.jsx @@ -66,7 +66,7 @@ export default class RhsRootPost extends React.Component { ownerOptions = ( <div> <a href='#' - className='dropdown-toggle theme' + className='post__dropdown dropdown-toggle' type='button' data-toggle='dropdown' aria-expanded='false' @@ -129,7 +129,7 @@ export default class RhsRootPost extends React.Component { ); } - botIndicator = <li className='post-header-col post-header__name bot-indicator'>{'BOT'}</li>; + botIndicator = <li className='col col__name bot-indicator'>{'BOT'}</li>; } let src = '/api/v1/users/' + post.user_id + '/image?time=' + timestamp + '&' + utils.getSessionIndex(); @@ -140,47 +140,47 @@ export default class RhsRootPost extends React.Component { } const profilePic = ( - <div className='post-profile-img__container'> - <img - className='post-profile-img' - src={src} - height='36' - width='36' - /> - </div> + <img + className='post-profile-img' + src={src} + height='36' + width='36' + /> ); return ( <div className={'post post--root ' + currentUserCss}> <div className='post-right-channel__name'>{channelName}</div> - <div className='post-profile-img__container'> - {profilePic} - </div> <div className='post__content'> - <ul className='post-header'> - <li className='post-header-col'><strong>{userProfile}</strong></li> - {botIndicator} - <li className='post-header-col'> - <time className='post-profile-time'> - {utils.displayCommentDateTime(post.create_at)} - </time> - </li> - <li className='post-header-col post-header__reply'> - <div className='dropdown'> - {ownerOptions} - </div> - </li> - </ul> - <div className='post-body'> - <div - ref='message_holder' - onClick={TextFormatting.handleClick} - dangerouslySetInnerHTML={{__html: TextFormatting.formatText(post.message)}} - /> - <PostBodyAdditionalContent - post={post} - /> - {fileAttachment} + <div className='post__img'> + {profilePic} + </div> + <div> + <ul className='post__header'> + <li className='col__name'>{userProfile}</li> + {botIndicator} + <li className='col'> + <time className='post__time'> + {utils.displayCommentDateTime(post.create_at)} + </time> + </li> + <li className='col col__reply'> + <div className='dropdown'> + {ownerOptions} + </div> + </li> + </ul> + <div className='post__body'> + <div + ref='message_holder' + onClick={TextFormatting.handleClick} + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(post.message)}} + /> + <PostBodyAdditionalContent + post={post} + /> + {fileAttachment} + </div> </div> </div> <hr /> diff --git a/web/react/components/search_results_item.jsx b/web/react/components/search_results_item.jsx index a8bd4db2c..52766a8a0 100644 --- a/web/react/components/search_results_item.jsx +++ b/web/react/components/search_results_item.jsx @@ -74,29 +74,30 @@ export default class SearchResultsItem extends React.Component { onClick={this.handleClick} > <div className='search-channel__name'>{channelName}</div> - <div className='post-profile-img__container'> - <img - className='post-profile-img' - src={'/api/v1/users/' + this.props.post.user_id + '/image?time=' + timestamp + '&' + utils.getSessionIndex()} - height='36' - width='36' - /> - </div> <div className='post__content'> - <ul className='post-header'> - <li className='post-header-col'><strong><UserProfile userId={this.props.post.user_id} /></strong></li> - <li className='post-header-col'> - <time className='search-item-time'> - {utils.displayDate(this.props.post.create_at) + ' ' + utils.displayTime(this.props.post.create_at)} - </time> - </li> - </ul> - <div className='search-item-snippet'> - <span - onClick={this.handleClick} - dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.props.post.message, formattingOptions)}} + <div className='post__img'> + <img + src={'/api/v1/users/' + this.props.post.user_id + '/image?time=' + timestamp + '&' + utils.getSessionIndex()} + height='36' + width='36' /> </div> + <div> + <ul className='post__header'> + <li className='col__name'><strong><UserProfile userId={this.props.post.user_id} /></strong></li> + <li className='col'> + <time className='search-item-time'> + {utils.displayDate(this.props.post.create_at) + ' ' + utils.displayTime(this.props.post.create_at)} + </time> + </li> + </ul> + <div className='search-item-snippet'> + <span + onClick={this.handleClick} + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.props.post.message, formattingOptions)}} + /> + </div> + </div> </div> </div> ); diff --git a/web/react/components/time_since.jsx b/web/react/components/time_since.jsx index c37739b9c..212beb080 100644 --- a/web/react/components/time_since.jsx +++ b/web/react/components/time_since.jsx @@ -34,7 +34,7 @@ export default class TimeSince extends React.Component { placement='top' overlay={tooltip} > - <time className='post-profile-time'> + <time className='post__time'> {Utils.displayDateTime(this.props.eventTime)} </time> </OverlayTrigger> diff --git a/web/react/components/user_settings/custom_theme_chooser.jsx b/web/react/components/user_settings/custom_theme_chooser.jsx index 3dbed72c3..3d0a2b548 100644 --- a/web/react/components/user_settings/custom_theme_chooser.jsx +++ b/web/react/components/user_settings/custom_theme_chooser.jsx @@ -14,7 +14,10 @@ export default class CustomThemeChooser extends React.Component { this.state = {}; } componentDidMount() { - $('.color-picker').colorpicker().on('changeColor', this.onPickerChange); + $('.color-picker').colorpicker({ + format: 'hex' + }); + $('.color-picker').on('changeColor', this.onPickerChange); } onPickerChange(e) { const theme = this.props.theme; diff --git a/web/react/utils/channel_intro_mssages.jsx b/web/react/utils/channel_intro_mssages.jsx index 11b69e28f..28a458e3f 100644 --- a/web/react/utils/channel_intro_mssages.jsx +++ b/web/react/utils/channel_intro_mssages.jsx @@ -205,6 +205,7 @@ export function createStandardIntroMessage(channel, showInviteModal) { <i className='fa fa-pencil'></i>{'Set a header'} </a> <a + className='intro-links' href='#' onClick={showInviteModal} > diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 5af837822..bf40d8c65 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -538,12 +538,11 @@ export function applyTheme(theme) { if (theme.sidebarText) { changeCss('.sidebar--left .nav-pills__container li>a, .sidebar--right, .settings-modal .nav-pills>li a, .sidebar--menu', 'color:' + changeOpacity(theme.sidebarText, 0.6), 1); - changeCss('@media(max-width: 768px){.settings-modal .settings-table .nav>li>a', 'color:' + theme.sidebarText, 1); + changeCss('@media(max-width: 960px){.settings-modal .settings-table .nav>li>a', 'color:' + theme.sidebarText, 1); changeCss('.sidebar--left .nav-pills__container li>h4, .sidebar--left .add-channel-btn', 'color:' + changeOpacity(theme.sidebarText, 0.6), 1); changeCss('.sidebar--left .add-channel-btn:hover, .sidebar--left .add-channel-btn:focus', 'color:' + theme.sidebarText, 1); - changeCss('.sidebar--left, .sidebar--right .sidebar--right__header', 'border-color:' + changeOpacity(theme.sidebarText, 0.2), 1); changeCss('.sidebar--left .status path', 'fill:' + changeOpacity(theme.sidebarText, 0.5), 1); - changeCss('@media(max-width: 768px){.settings-modal .settings-table .nav>li>a', 'border-color:' + changeOpacity(theme.sidebarText, 0.2), 2); + changeCss('@media(max-width: 960px){.settings-modal .settings-table .nav>li>a', 'border-color:' + changeOpacity(theme.sidebarText, 0.2), 2); } if (theme.sidebarUnreadText) { @@ -552,7 +551,7 @@ export function applyTheme(theme) { if (theme.sidebarTextHoverBg) { changeCss('.sidebar--left .nav-pills__container li>a:hover, .sidebar--left .nav-pills__container li>a:focus, .settings-modal .nav-pills>li:hover a, .settings-modal .nav-pills>li:focus a', 'background:' + theme.sidebarTextHoverBg, 1); - changeCss('@media(max-width: 768px){.settings-modal .settings-table .nav>li:hover a', 'background:' + theme.sidebarTextHoverBg, 1); + changeCss('@media(max-width: 960px){.settings-modal .settings-table .nav>li:hover a', 'background:' + theme.sidebarTextHoverBg, 1); } if (theme.sidebarTextActiveBorder) { @@ -570,7 +569,7 @@ export function applyTheme(theme) { changeCss('.sidebar--left .team__header, .sidebar--menu .team__header', 'background:' + theme.sidebarHeaderBg, 1); changeCss('.modal .modal-header', 'background:' + theme.sidebarHeaderBg, 1); changeCss('#navbar .navbar-default', 'background:' + theme.sidebarHeaderBg, 1); - changeCss('@media(max-width: 768px){.search-bar__container', 'background:' + theme.sidebarHeaderBg, 1); + changeCss('@media(max-width: 960px){.search-bar__container', 'background:' + theme.sidebarHeaderBg, 1); changeCss('.attachment .attachment__container', 'border-left-color:' + theme.sidebarHeaderBg, 1); } @@ -581,7 +580,7 @@ export function applyTheme(theme) { changeCss('.modal .modal-header .modal-title, .modal .modal-header .modal-title .name, .modal .modal-header button.close', 'color:' + theme.sidebarHeaderTextColor, 1); changeCss('#navbar .navbar-default .navbar-brand .heading', 'color:' + theme.sidebarHeaderTextColor, 1); changeCss('#navbar .navbar-default .navbar-toggle .icon-bar, ', 'background:' + theme.sidebarHeaderTextColor, 1); - changeCss('@media(max-width: 768px){.search-bar__container', 'color:' + theme.sidebarHeaderTextColor, 2); + changeCss('@media(max-width: 960px){.search-bar__container', 'color:' + theme.sidebarHeaderTextColor, 2); } if (theme.onlineIndicator) { @@ -614,17 +613,16 @@ export function applyTheme(theme) { } if (theme.centerChannelColor) { + changeCss('.sidebar--left, .sidebar--right .sidebar--right__header', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1); changeCss('.app__content, .post-create__container .post-create-body .btn-file, .post-create__container .post-create-footer .msg-typing, .command-name, .modal .modal-content, .dropdown-menu, .popover, .mentions-name, .tip-overlay', 'color:' + theme.centerChannelColor, 1); changeCss('#post-create', 'color:' + theme.centerChannelColor, 2); - changeCss('.channel-header__links a', 'fill:' + changeOpacity(theme.centerChannelColor, 0.9), 1); - changeCss('.channel-header__links a:hover, .channel-header__links a:active', 'fill:' + theme.centerChannelColor, 2); changeCss('.mentions--top, .command-box', 'box-shadow:' + changeOpacity(theme.centerChannelColor, 0.2) + ' 1px -3px 12px', 3); changeCss('.mentions--top, .command-box', '-webkit-box-shadow:' + changeOpacity(theme.centerChannelColor, 0.2) + ' 1px -3px 12px', 2); changeCss('.mentions--top, .command-box', '-moz-box-shadow:' + changeOpacity(theme.centerChannelColor, 0.2) + ' 1px -3px 12px', 1); changeCss('.dropdown-menu, .popover ', 'box-shadow:' + changeOpacity(theme.centerChannelColor, 0.1) + ' 0px 6px 12px', 3); changeCss('.dropdown-menu, .popover ', '-webkit-box-shadow:' + changeOpacity(theme.centerChannelColor, 0.1) + ' 0px 6px 12px', 2); changeCss('.dropdown-menu, .popover ', '-moz-box-shadow:' + changeOpacity(theme.centerChannelColor, 0.1) + ' 0px 6px 12px', 1); - changeCss('.post-body hr, .loading-screen .loading__content .round, .tutorial__circles .circle', 'background:' + theme.centerChannelColor, 1); + changeCss('.post__body hr, .loading-screen .loading__content .round, .tutorial__circles .circle', 'background:' + theme.centerChannelColor, 1); changeCss('.channel-header .heading', 'color:' + theme.centerChannelColor, 1); changeCss('.markdown__table tbody tr:nth-child(2n)', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1); changeCss('.channel-header__info>div.dropdown .header-dropdown__icon', 'color:' + changeOpacity(theme.centerChannelColor, 0.8), 1); @@ -642,25 +640,23 @@ export function applyTheme(theme) { changeCss('.post-image__column .post-image__details', 'color:' + theme.centerChannelColor, 2); changeCss('.post-image__column a, .post-image__column a:hover, .post-image__column a:focus', 'color:' + theme.centerChannelColor, 1); changeCss('.search-bar__container .search__form .search-bar, .form-control', 'color:' + theme.centerChannelColor, 2); - changeCss('@media(max-width: 768px){.search-bar__container .search__form .search-bar', 'background:' + changeOpacity(theme.centerChannelColor, 0.2) + '; color: inherit;', 1); + changeCss('@media(max-width: 960px){.search-bar__container .search__form .search-bar', 'background:' + changeOpacity(theme.centerChannelColor, 0.2) + '; color: inherit;', 1); changeCss('.input-group-addon, .search-bar__container .search__form, .form-control', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1); - changeCss('.form-control:focus, .channel-header__links', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.3), 1); + changeCss('.form-control:focus', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.3), 1); changeCss('.attachment .attachment__content', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.3), 1); changeCss('.channel-intro .channel-intro__content, .webhooks__container', 'background:' + changeOpacity(theme.centerChannelColor, 0.05), 1); changeCss('.date-separator .separator__text', 'color:' + theme.centerChannelColor, 2); changeCss('.date-separator .separator__hr, .modal-footer, .modal .custom-textarea, .post-right__container .post.post--root hr, .search-item-container', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1); changeCss('.modal .custom-textarea:focus', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.3), 1); changeCss('.channel-intro, .settings-modal .settings-table .settings-content .divider-dark, hr, .settings-modal .settings-table .settings-links', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1); - changeCss('.post.current--user .post-body, .post.post--comment.other--root.current--user .post-comment, pre', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1); - changeCss('.post.current--user .post-body, .post.post--comment.other--root.current--user .post-comment, .post.post--comment.other--root .post-comment, .post.same--root .post-body, .modal .more-table tbody>tr td, .member-div:first-child, .member-div, .access-history__table .access__report, .activity-log__table', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.1), 2); - changeCss('@media(max-width: 1440px){.post.same--root', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.07), 2); - changeCss('@media(max-width: 1440px){.post.same--root', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.07), 2); + changeCss('.post.current--user .post__body, .post.post--comment.other--root.current--user .post-comment, pre', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1); + changeCss('.post.current--user .post__body, .post.post--comment.other--root.current--user .post-comment, .post.same--root.post--comment .post__body, .modal .more-table tbody>tr td, .member-div:first-child, .member-div, .access-history__table .access__report, .activity-log__table', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.1), 2); changeCss('@media(max-width: 1800px){.inner__wrap.move--left .post.post--comment.same--root', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.07), 2); - changeCss('.post:hover, .channel-header__links a:hover, .channel-header__links a:active, .modal .more-table tbody>tr:hover td, .settings-modal .settings-table .settings-content .section-min:hover', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1); + changeCss('.post:hover, .modal .more-table tbody>tr:hover td, .settings-modal .settings-table .settings-content .section-min:hover', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1); changeCss('.date-separator.hovered--before:after, .date-separator.hovered--after:before, .new-separator.hovered--after:before, .new-separator.hovered--before:after', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1); changeCss('.command-name:hover, .mentions-name:hover, .mentions-focus, .dropdown-menu>li>a:focus, .dropdown-menu>li>a:hover, .bot-indicator', 'background:' + changeOpacity(theme.centerChannelColor, 0.15), 1); changeCss('code', 'background:' + changeOpacity(theme.centerChannelColor, 0.1), 1); - changeCss('.post.current--user:hover .post-body ', 'background: none;', 1); + changeCss('.post.current--user:hover .post__body ', 'background: none;', 1); changeCss('.sidebar--right', 'color:' + theme.centerChannelColor, 2); } diff --git a/web/sass-files/sass/partials/_base.scss b/web/sass-files/sass/partials/_base.scss index ad4a65c00..c5b1a7425 100644 --- a/web/sass-files/sass/partials/_base.scss +++ b/web/sass-files/sass/partials/_base.scss @@ -85,6 +85,7 @@ img { a { word-break: break-word; color: $primary-color; + cursor: pointer; } a:focus, a:hover { diff --git a/web/sass-files/sass/partials/_headers.scss b/web/sass-files/sass/partials/_headers.scss index 69bc56841..51625afe0 100644 --- a/web/sass-files/sass/partials/_headers.scss +++ b/web/sass-files/sass/partials/_headers.scss @@ -54,9 +54,9 @@ } .channel-intro { - padding-bottom:5px; - margin: 0 1em 35px; - max-width: 850px; + margin: 0 auto 35px; + padding: 0 1em 5px; + max-width: 1000px; border-bottom: 1px solid lightgrey; .intro-links { margin: 0 1.5em 10px 0; @@ -216,7 +216,6 @@ font-size: 14px; line-height: 56px; #member_popover { - margin-right: 5px; width: 45px; color: #999; cursor: pointer; @@ -292,20 +291,21 @@ } .channel-header__links { - height: 32px; - width: 32px; - @include border-radius(50px); - border: 1px solid #ccc; + height: 30px; + width: 24px; + line-height: 26px; margin-right: 10px; + font-size: 22px; > a { - @include border-radius(50px); - height: 100%; - display: block; + color: inherit; + text-decoration: none; + @include opacity(0.6); @include single-transition(all, 0.1s, ease-in); - } - svg { - vertical-align: top; - margin: 7px 0 0 0px; - fill: inherit; + &:hover { + @include opacity(1); + } + &:focus { + color: inherit; + } } } diff --git a/web/sass-files/sass/partials/_markdown.scss b/web/sass-files/sass/partials/_markdown.scss index 8b0a32704..ea5ccd2d2 100644 --- a/web/sass-files/sass/partials/_markdown.scss +++ b/web/sass-files/sass/partials/_markdown.scss @@ -16,7 +16,7 @@ } } -.post-body { +.post__body { hr { height: 4px; padding: 0; diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss index fad6f5074..743a76693 100644 --- a/web/sass-files/sass/partials/_post.scss +++ b/web/sass-files/sass/partials/_post.scss @@ -47,23 +47,23 @@ body.ios { .textarea-wrapper { position:relative; - .textbox-preview-area { - position: absolute; - z-index: 2; - top: 0; - left: 0; - box-shadow: none; - } + .textbox-preview-area { + position: absolute; + z-index: 2; + top: 0; + left: 0; + box-shadow: none; + } .textbox-preview-link, .textbox-help-link { - position: absolute; - z-index: 3; - bottom: -23px; + position: absolute; + z-index: 3; + bottom: -23px; font-size: 13px; - cursor: pointer; - } - .textbox-preview-link { - right: 45px; - } + cursor: pointer; + } + .textbox-preview-link { + right: 45px; + } .textbox-help-link { right: 0; } @@ -206,6 +206,7 @@ body.ios { @include flex(1 1 auto); position: relative; overflow-y: hidden; + .post-list-holder-by-time { background: #fff; overflow-y: scroll; @@ -224,31 +225,7 @@ body.ios { display: inline; } } - .post-list__table { - display: table; - table-layout: fixed; - width: 100%; - min-height: 100%; - height: 100%; - .post-list__content { - display: table-cell; - vertical-align: bottom; - div { - &:last-child { - .post { - .post-header { - .post-header-col.post-header__reply { - .dropdown-menu { - top: auto; - bottom: 25px; - } - } - } - } - } - } - } - } + .more-messages-text { margin-top: 2px; margin-bottom: 5px; @@ -269,13 +246,13 @@ body.ios { .post-create__container { form { width: 100%; - padding: 0 1em; - margin: 0; + padding: 0.5em 14px 0; + margin: 0 auto; + max-width: 1028px; } .post-create-body { - max-width: 850px; - padding: 0 0 2px; position: relative; + padding: 0 0 2px; .post-body__cell { vertical-align: top; position: relative; @@ -355,95 +332,271 @@ body.ios { } } +.post-list__table { + display: table; + table-layout: fixed; + width: 100%; + min-height: 100%; + height: 100%; + .post-list__content { + display: table-cell; + vertical-align: bottom; + > div:last-child { + .dropdown-menu { + top: auto; + bottom: 25px; + } + } + } +} + .post { word-wrap: break-word; - padding: 8px 1em; + padding: 8px 1em 0; position: relative; max-width: 100%; @include legacy-pie-clearfix; + &:hover { - .post-header .post-header-col.post-header__reply { - .dropdown, .comment-icon__container { - @include opacity(1); - } + .dropdown, .comment-icon__container { + visibility: visible; + } + } + + ul { + margin: 0; + padding: 0; + list-style: none; + } + + p { + margin: 0 0 1em; + line-height: 1.6em; + font-size: 0.97em; + white-space: pre-wrap; + + &:last-child { + margin-bottom: 0.5em; } - background: #f5f5f5; + } - &.current--user { - .post-body { - @include border-radius(4px); - background: rgba(#000, 0.05); + + span { + p:last-child { + margin-bottom: 0.5em; } } - &.post--comment { - &.other--root { - .post-comment { - border-left: 4px solid #EEE; - padding: 4px 0 6px 10px; - margin: 0 0 0 30px; - @include border-radius(0 4px 4px 0); + + &.post--root { + + .comment-icon__container { + visibility: visible; + } + + } + + &.same--root { + + &.same--user { + padding: 0 1em; + + &:hover { + + .post__time { + + &:before { + @include opacity(0.5); + } + + } + } - .post-body { - background: transparent; + + .post__header { margin: 0; - padding: 1px 0px; + height: 0; + + .col__name { + display: none; + } + + .col__reply { + top: 6px; + } + + } + + .post__time { + top: 24px; } - &.current--user { - .post-comment { - background: #f5f5f5; + + .post__time { + display: inline-block; + font: normal normal normal 14px/1 FontAwesome; + font-size: inherit; + text-rendering: auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-size: 0; + position: absolute; + top: -3px; + left: 17px; + width: 30px; + height: 30px; + line-height: 37px; + + &:before { + @include opacity(0); + content: "\f017"; + content: "\f017"; + font-size: 19px; } + } + } - &.same--root { - .post-body { - @include border-radius(0 4px 4px 0); + + &.post--comment { + + .post__link { + display: none; } - .post-body { - border-left: 4px solid #EEE; - width: 570px; - margin-left: 30px; - padding-left: 10px; - .post-link { + + .post__img { + img { display: none; } } + + } + + &.post--comment { + .post__body { + border-left: 4px solid #ddd; + } } + } - &.same--root { + + &.other--root { + .comment-icon__container { - @include opacity(0); + + &.icon--show { + visibility: visible; + } + } - div.post-profile-img__container { - height: 1px; - .post-profile-img { - visibility: hidden; + + &.post--comment { + + .post__header { + + .col__reply { + top: 53px; + } + } + + } + + } + + .post__content { + margin: 0 auto; + position: relative; + max-width: 1000px; + display: table; + width: 100%; + + > div { + display: table-cell; + vertical-align: top; } - .post__content { - padding: 0; + + } + + .post__header { + margin-bottom: 2px; + + li { + display: inline-block; } - &.same--user { - .post__content { - padding-left: 46px; + + .col__name { + margin-right: 7px; + font-weight: 600; + } + + .col__reply { + position: absolute; + right: 0; + top: 30px; + width: 70px; + } + + } + + .post__img { + width: 46px; + img { + width: 36px; + height: 36px; + vertical-align: inherit; + @include border-radius(50px); + } + } + + .dropdown { + display: inline-block; + visibility: hidden; + top: -1px; + float: right; + + .dropdown-menu { + right: 0; + left: auto; + min-width: 130px; + padding: 2px 0; + + li { + display: block; + } - .post-header-post { - visibility: hidden; + + a { + padding: 5px 15px; } + } - } - p { - margin: 0 0 1em; - line-height: 1.6em; - font-size: 0.97em; - white-space: pre-wrap; + } - span { - p:last-child { - margin-bottom: 0.5em; + .post__dropdown { + &:after { + content: '[...]'; + top: -1px; + position: relative; } } + .post__body { + word-wrap: break-word; + padding: 0.2em 0.5em 0em; + @include legacy-pie-clearfix; + width: calc(100% - 80px); + } + + .post__link { + margin: 2px 0 5px; + font-size: 13px; + } + + .post__time { + font-size: 13px; + @include opacity(0.6); + } + .post-loading-gif { height:10px; width:10px; @@ -459,8 +612,15 @@ body.ios { } .comment-icon__container { - margin-left: 7px; + margin-right: 7px; fill: $primary-color; + display: inline-block; + visibility: hidden; + + &.icon--visible { + visibility: visible; + } + .comment-icon { display: inline-block; top: 3px; @@ -468,132 +628,13 @@ body.ios { margin-right: 3px; fill: inherit; } + path { fill: inherit; } + } - > div { - &.post-profile-img__container { - float: left; - .post-profile-img { - width: 36px; - height: 36px; - margin-right: 10px; - vertical-align: inherit; - @include border-radius(50px); - } - } - &.post__content { - padding-left: 46px; - max-width: 100%; - @include legacy-pie-clearfix; - } - } - .post-image__columns { - @include legacy-pie-clearfix; - padding-bottom: 5px; - } - .post-info--hidden { - display: none; - } - .post-body { - position: relative; - z-index: 1; - max-width: 100%; - width: 600px; - float: left; - word-wrap: break-word; - padding: 0.3em 0.5em 0.1em; - margin: -0.3em 0 0; - .post-link { - @include clearfix; - text-overflow: ellipsis; - white-space: nowrap; - cursor: pointer; - } - .post-body--code { - font-size: .97em; - position:relative; - .post-body--code__language { - position: absolute; - right: 0; - background: #fff; - cursor: default; - padding: 0.3em 0.5em 0.1em; - border-bottom-left-radius: 4px; - @include opacity(.3); - } - &.tex .katex-display { - text-align: left; - } - code { - white-space: pre; - } - } - } - .create-reply-form-wrap { - width: 100%; - margin-top: 5px; - form { - padding: 0; - } - .comment-btn { - display: none; - } - } - .post-header { - position: relative; - list-style-type: none; - margin: 0 0 1px; - padding-left: 0px; - &.post-header-post { - position: relative; - z-index: 1; - width: 200px; - text-align: right; - float: left; - padding-right: 15px; - } - &.post-info { - .post-profile-time { - vertical-align: top; - width: 150px; - max-width: 220px; - overflow: hidden; - display: block; - white-space: nowrap; - text-overflow: ellipsis; - margin: 0 0 0 50px; - } - } - .post-header-col { - vertical-align: top; - display: inline-block; - margin-right: 10px; - &.post-header__reply { - position: relative; - top: -1px; - min-width: 70px; - .dropdown-menu { - right: 0; - left: auto; - } - .dropdown { - min-width: 18px; - display: inline-block; - @include opacity(0); - } - .dropdown-toggle:after { - content: '[...]'; - } - } - } - .post-profile-time { - @include opacity(0.5); - } - } - .post-comment { - } + .web-embed-data { padding: 2px 0 0 10px; background: #f9f9f9; @@ -621,22 +662,6 @@ body.ios { display: none; } } - .msg-typing { - margin-top: 2px; - margin-left: 5px; - color: #a8adb7; - } - .post-grey { - color:lightgrey; - } - .post-reply { - clear: both; - width: 100%; - word-break: break-word; - .btn-file { - width: 40px; - } - } } .bot-indicator { diff --git a/web/sass-files/sass/partials/_post_right.scss b/web/sass-files/sass/partials/_post_right.scss index 54c3bcdf8..582b72c90 100644 --- a/web/sass-files/sass/partials/_post_right.scss +++ b/web/sass-files/sass/partials/_post_right.scss @@ -9,28 +9,31 @@ .post-right-comments-container { position: relative; - padding: 0; + padding: 0.7em 0 0; } .post { + &.post--root { - padding: 1em 1em 0; - margin: 0 0 1em; - width: 100%; - hr { - border-color: #DDD; - margin: 1em 0 0 0; - } + padding-bottom: 0; } - .post-header { - .post-profile-time { - width: 200px; - display: inline-block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + + .post__header { + + .col__reply { + top: 0; } + } + + .post__body { + width: 100%; + } + + } + + hr { + margin-bottom: 0; } .post-create__container { diff --git a/web/sass-files/sass/partials/_responsive.scss b/web/sass-files/sass/partials/_responsive.scss index 2db7f6eeb..368027cbf 100644 --- a/web/sass-files/sass/partials/_responsive.scss +++ b/web/sass-files/sass/partials/_responsive.scss @@ -8,96 +8,10 @@ } } } - .post { - &.post--comment { - &.same--root { - margin-left: 104px; - padding-left: 10px; - border-left: 4px solid #EEE; - div.post-profile-img__container { - .post-profile-img { - display: none; - } - } - .post__content { - width: 825px; - margin-left: 0; - } - .post-body { - width: 736px; - border: none; - margin: 3px 0 0; - } - } - &.other--root { - .post-comment { - margin-left: 0; - } - } - &.same--root { - margin-top: 10px; - margin-bottom: 10px; - } - } - .post-body { - float: none; - width: 750px; - margin: 3px 0 0; - } - .post__content { - width: 920px; - } - .post-header { - &.post-header-post { - z-index: inherit; - width: auto; - float: none; - text-align: left; - padding-right: 0; - } - &.post-info { - display: none; - float: none; - } - .post-header-col { - &.post-header__reply { - float: right; - text-align: right; - .reply-link { - margin-right: 0; - } - .dropdown { - margin-left: 10px; - } - } - } - .post-info--hidden { - display: inline; - .post-info { - display: inline; - .tooltip { - margin-top: -25px; - margin-left: 40px; - } - .post-profile-time { - margin: 0; - } - } - } - } - } } } } @media screen and (max-width: 1440px) { - .post-create__container { - .post-create-body { - max-width: 810px; - } - } - .channel-intro { - max-width: 810px; - } .date-separator, .new-separator { &.hovered--comment { &:before, &:after { @@ -105,105 +19,6 @@ } } } - .post { - &.same--root.same--user { - .post-header-post { - visibility: hidden; - width: 100%; - position: relative; - top: -5px; - .post-header-col.post-header__name { - display: none; - } - } - .post-body { - top: -15px; - margin-bottom: -10px; - } - &:hover .post-header-post { - visibility: visible; - } - } - - &.post--comment { - &.other--root { - .post-comment { - margin-left: 0; - } - } - &.same--root { - margin-top: 5px; - margin-bottom: 5px; - margin-left: 104px; - padding-left: 10px; - border-left: 4px solid #EEE; - div.post-profile-img__container { - .post-profile-img { - display: none; - } - } - .post-body { - margin-left: 0; - border-left: 0; - } - &.same--user { - .post__content { - margin-left: 0; - padding-left: 0; - } - } - } - .post__content { - width: 810px; - } - .post-body { - width: 736px; - border: none; - margin: 3px 0 0; - } - } - .post__content { - width: 880px; - } - .post-header { - &.post-header-post { - z-index: inherit; - width: auto; - float: none; - text-align: left; - padding-right: 0; - } - &.post-info { - display: none; - float: none; - } - .post-header-col { - &.post-header__reply { - float: right; - margin: 0; - } - } - .post-info--hidden { - display: inline; - .post-info { - display: inline; - .tooltip { - margin-top: -25px; - margin-left: 40px; - } - .post-profile-time { - width: auto; - margin: 0; - } - } - } - } - .post-body { - margin: 3px 0 0; - float: none; - width: 750px; - } - } } @media screen and (max-width: 1140px) { @@ -232,77 +47,36 @@ } } } - .post { - .post__content { - width: 100%; - } - .post-header { - .post-header-col { - &.post-header__reply { - .reply-link { - margin-right: 0; - } - .dropdown { - margin-left: 10px; - } - } - } - } - } } @media screen and (max-width: 960px) { - .post { - .post-header .post-header-col.post-header__reply { - .comment-icon__container__hide { - display: none; - } - .dropdown { - @include opacity(1); - } - .comment-icon__container__show { - @include opacity(1); - } - } - } + .signup-team__container { font-size: 1em; } + .post { - .post-header { - .post-header-col { - &.post-header__reply { - text-align: right; - display: inline-block; - } + + .dropdown, .comment-icon__container { + visibility: visible; + } + + .post__img { + width: 40px; + + img { + width: 32px; + height: 32px; } } - } -} -@media (min-width: 992px){ - .modal-lg { - width: 700px; } -} -@media screen and (min-width: 768px) { - .second-bar { - display: none; + .post-image__column { + width: 200px; + height: 95px; } -} -@media screen and (max-height: 640px) { - .signup-team__container { - padding: 30px 0; - margin-bottom: 30px; - font-size: 0.9em; - .signup-team__name { - font-size: 2em; - } - } -} -@media screen and (max-width: 768px) { .textarea-wrapper { .textbox-preview-link, .textbox-help-link { display: none; @@ -385,35 +159,6 @@ } } } - .post { - &:hover { - background: none; - .post-header .post-header-col.post-header__reply { - .dropdown-toggle:after { - content: '...'; - } - } - } - &.post--comment { - &.same--root { - margin-left: 25px; - } - &.other--root { - margin-left: 0; - &:hover { - background: none; - } - } - } - .post-header .post-header-col.post-header__reply { - .dropdown-toggle:after { - content: '...'; - } - } - &.same--root.same--user .post__content{ - padding-left: 0; - } - } .signup-team__container { padding: 30px 0; margin-bottom: 30px; @@ -860,24 +605,6 @@ } } - .post { - .post-profile-img__container { - display: none; - } - &.post--comment { - &.other--root { - .post-comment { - margin-left: 11px; - } - } - } - > div { - &.post__content { - padding: 0; - } - } - } - .post-comments { padding: 9px 21px 10px 10px !important; } @@ -958,3 +685,27 @@ } } } + + +@media (min-width: 992px){ + .modal-lg { + width: 700px; + } +} + +@media screen and (min-width: 768px) { + .second-bar { + display: none; + } +} + +@media screen and (max-height: 640px) { + .signup-team__container { + padding: 30px 0; + margin-bottom: 30px; + font-size: 0.9em; + .signup-team__name { + font-size: 2em; + } + } +}
\ No newline at end of file diff --git a/web/sass-files/sass/partials/_search.scss b/web/sass-files/sass/partials/_search.scss index bedf35376..27b55f214 100644 --- a/web/sass-files/sass/partials/_search.scss +++ b/web/sass-files/sass/partials/_search.scss @@ -1,5 +1,5 @@ #channel-header .search-bar__container { - padding: 0 8px 0 0; + padding: 0 8px 0 3px; } .search-bar__container { padding: 12px 8px 0 0; diff --git a/web/sass-files/sass/partials/_sidebar--right.scss b/web/sass-files/sass/partials/_sidebar--right.scss index 2527eef28..43162831d 100644 --- a/web/sass-files/sass/partials/_sidebar--right.scss +++ b/web/sass-files/sass/partials/_sidebar--right.scss @@ -36,20 +36,6 @@ height: calc(100% - 56px); @include border-radius(2px 0 0 0); } - .post { - .post-header { - .post-header-col { - &.post-header__reply { - min-width: 30px; - text-align: right; - float: right; - } - } - } - .post-body { - margin: 3px 0 0; - } - } .sidebar__overlay { width: 100%; height: 100%; |