diff options
author | Joram Wilander <jwawilander@gmail.com> | 2016-10-19 14:49:25 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2016-10-19 14:49:25 -0400 |
commit | 365b8b465e8a53ebb2da2bf3aef659ac81a2bc6a (patch) | |
tree | 643b2dd52b478c2c0b049ac28798d870b9dfd397 | |
parent | 0512bd26ee85473aa47206d5f207a9a506019138 (diff) | |
download | chat-365b8b465e8a53ebb2da2bf3aef659ac81a2bc6a.tar.gz chat-365b8b465e8a53ebb2da2bf3aef659ac81a2bc6a.tar.bz2 chat-365b8b465e8a53ebb2da2bf3aef659ac81a2bc6a.zip |
Merging performance branch into master (#4268)
* improve performance on sendNotifications
* Fix SQL queries
* Remove get direct profiles, not needed anymore
* Add raw data to error details if AppError fails to decode
* men
* Fix decode (#4052)
* Fixing json decode
* Adding unit test
* Initial work for client scaling (#4051)
* Begin adding paging to profiles API
* Added more paging functionality
* Finish hooking up admin console user lists
* Add API for searching users and add searching to all user lists
* Add lazy loading of profiles
* Revert config.json
* Fix unit tests and some style issues
* Add GetProfilesFromList to Go driver and fix web unit test
* Update etag for GetProfiles
* Updating ui for filters and pagination (#4044)
* Updating UI for pagination
* Adjusting margins for filter row
* Adjusting margin for specific modals
* Adding relative padding to system console
* Adjusting responsive view
* Update client user tests
* Minor fixes for direct messages modal (#4056)
* Remove some unneeded initial load calls (#4057)
* UX updates to user lists, added smart counts and bug fixes (#4059)
* Improved getExplicitMentions and unit tests (#4064)
* Refactor getting posts to lazy load profiles correctly (#4062)
* Comment out SetActiveChannel test (#4066)
* Profiler cpu, block, and memory profiler. (#4081)
* Fix TestSetActiveChannel unit test (#4071)
* Fixing build failure caused by dependancies updating (#4076)
* Adding profiler
* Fix admin_team_member_dropdown eslint errors
* Bumping session cache size (#4077)
* Bumping session cache size
* Bumping status cache
* Refactor how the client handles channel members to be large team friendly (#4106)
* Refactor how the client handles channel members to be large team friendly
* Change Id to ChannelId in ChannelStats model
* Updated getChannelMember and getProfilesByIds routes to match proposal
* Performance improvements (#4100)
* Performance improvements
* Fixing re-connect issue
* Fixing error message
* Some other minor perf tweaks
* Some other minor perf tweaks
* Fixing config file
* Fixing buffer size
* Fixing web socket send message
* adding some error logging
* fix getMe to be user required
* Fix websocket event for new user
* Fixing shutting down
* Reverting web socket changes
* Fixing logging lvl
* Adding caching to GetMember
* Adding some logging
* Fixing caching
* Fixing caching invalidate
* Fixing direct message caching
* Fixing caching
* Fixing caching
* Remove GetDirectProfiles from initial load
* Adding logging and fixing websocket client
* Adding back caching from bad merge.
* Explicitly close go driver requests (#4162)
* Refactored how the client handles team members to be more large team friendly (#4159)
* Refactor getProfilesForDirectMessageList API into getAllProfiles API
* Refactored how the client handles team members to be more large team friendly
* Fix js error when receiving a notification
* Fix JS error caused by current user being overwritten with sanitized version (#4165)
* Adding error message to status failure (#4167)
* Fix a few bugs caused by client scaling refactoring (#4170)
* When there is no read replica, don't open a second set of connections to the master database (#4173)
* Adding connection tacking to stats (#4174)
* Reduce DB writes for statuses and other status related changes (#4175)
* Fix bug preventing opening of DM channels from more modal (#4181)
* Fixing socket timing error (#4183)
* Fixing ping/pong handler
* Fixing socket timing error
* Commenting out status broadcasting
* Removing user status changes
* Removing user status changes
* Removing user status changes
* Removing user status changes
* Adding DoPreComputeJson()
* Performance improvements (#4194)
* * Fix System Console Analytics queries
* Add db.SetConnMaxLifetime to 15 minutes
* Add "net/http/pprof" for profiling
* Add FreeOSMemory() to manually release memory on reload config
* Add flag to enable http profiler
* Fix memory leak (#4197)
* Fix memory leak
* removed unneeded nil assignment
* Fixing go routine leak (#4208)
* Merge fixes
* Merge fix
* Refactored statuses to be queried by the client rather than broadcast by the server (#4212)
* Refactored server code to reduce status broadcasts and to allow getting statuses by IDs
* Refactor client code to periodically fetch statuses
* Add store unit test for getting statuses by ids
* Fix status unit test
* Add getStatusesByIds REST API and move the client over to use that instead of the WebSocket
* Adding multiple threads to websocket hub (#4230)
* Adding multiple threads to websocket hub
* Fixing unit tests
* Fixing so websocket connections from the same user end up in the sameā¦ (#4240)
* Fixing so websocket connections from the same user end up in the same list
* Removing old comment
* Refactor user autocomplete to query the server (#4239)
* Add API for autocompleting users
* Converted at mention autocomplete to query server
* Converted user search autocomplete to query server
* Switch autocomplete API naming to use term instead of username
* Split autocomplete API into two, one for channels and for teams
* Fix copy/paste error
* Some final client scaling fixes (#4246)
* Add lazy loading of profiles to integration pages
* Add lazy loading of profiles to emoji page
* Fix JS error when receiving post in select team menu and also clean up channel store
129 files changed, 6295 insertions, 2414 deletions
@@ -361,7 +361,7 @@ run-server: prepare-enterprise start-docker run-cli: prepare-enterprise start-docker @echo Running mattermost for development - @echo Example should be like >'make ARGS="-version" run-cli' + @echo Example should be like 'make ARGS="-version" run-cli' $(GO) run $(GOFLAGS) $(GO_LINKER_FLAGS) *.go ${ARGS} diff --git a/api/admin.go b/api/admin.go index 9ac071e6d..0edfb246b 100644 --- a/api/admin.go +++ b/api/admin.go @@ -20,6 +20,7 @@ import ( "github.com/mattermost/platform/store" "github.com/mattermost/platform/utils" "github.com/mssola/user_agent" + "runtime/debug" ) func InitAdmin() { @@ -48,7 +49,7 @@ func InitAdmin() { BaseRoutes.Admin.Handle("/remove_certificate", ApiAdminSystemRequired(removeCertificate)).Methods("POST") BaseRoutes.Admin.Handle("/saml_cert_status", ApiAdminSystemRequired(samlCertificateStatus)).Methods("GET") BaseRoutes.Admin.Handle("/cluster_status", ApiAdminSystemRequired(getClusterStatus)).Methods("GET") - BaseRoutes.Admin.Handle("/recently_active_users/{team_id:[A-Za-z0-9]+}", ApiUserRequiredActivity(getRecentlyActiveUsers, false)).Methods("GET") + BaseRoutes.Admin.Handle("/recently_active_users/{team_id:[A-Za-z0-9]+}", ApiUserRequired(getRecentlyActiveUsers)).Methods("GET") } func getLogs(c *Context, w http.ResponseWriter, r *http.Request) { @@ -134,6 +135,7 @@ func getConfig(c *Context, w http.ResponseWriter, r *http.Request) { } func reloadConfig(c *Context, w http.ResponseWriter, r *http.Request) { + debug.FreeOSMemory() utils.LoadConfig(utils.CfgFileName) // start/restart email batching job if necessary @@ -338,12 +340,15 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) { name := params["name"] if name == "standard" { - var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 5) + var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 8) rows[0] = &model.AnalyticsRow{"channel_open_count", 0} rows[1] = &model.AnalyticsRow{"channel_private_count", 0} rows[2] = &model.AnalyticsRow{"post_count", 0} rows[3] = &model.AnalyticsRow{"unique_user_count", 0} rows[4] = &model.AnalyticsRow{"team_count", 0} + rows[5] = &model.AnalyticsRow{"total_websocket_connections", 0} + rows[6] = &model.AnalyticsRow{"total_master_db_connections", 0} + rows[7] = &model.AnalyticsRow{"total_read_db_connections", 0} openChan := Srv.Store.Channel().AnalyticsTypeCount(teamId, model.CHANNEL_OPEN) privateChan := Srv.Store.Channel().AnalyticsTypeCount(teamId, model.CHANNEL_PRIVATE) @@ -386,6 +391,10 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) { rows[4].Value = float64(r.Data.(int64)) } + rows[5].Value = float64(TotalWebsocketConnections()) + rows[6].Value = float64(Srv.Store.TotalMasterDbConnections()) + rows[7].Value = float64(Srv.Store.TotalReadDbConnections()) + w.Write([]byte(rows.ToJson())) } else if name == "post_counts_day" { if r := <-Srv.Store.Post().AnalyticsPostCountsByDay(teamId); r.Err != nil { @@ -706,32 +715,14 @@ func samlCertificateStatus(c *Context, w http.ResponseWriter, r *http.Request) { } func getRecentlyActiveUsers(c *Context, w http.ResponseWriter, r *http.Request) { - statusMap := map[string]interface{}{} - - if result := <-Srv.Store.Status().GetAllFromTeam(c.TeamId); result.Err != nil { - c.Err = result.Err - return - } else { - statuses := result.Data.([]*model.Status) - for _, s := range statuses { - statusMap[s.UserId] = s.LastActivityAt - } - } - - if result := <-Srv.Store.User().GetProfiles(c.TeamId); result.Err != nil { + if result := <-Srv.Store.User().GetRecentlyActiveUsersForTeam(c.TeamId); result.Err != nil { c.Err = result.Err return } else { profiles := result.Data.(map[string]*model.User) - for k, p := range profiles { - p = sanitizeProfile(c, p) - - if lastActivityAt, ok := statusMap[p.Id].(int64); ok { - p.LastActivityAt = lastActivityAt - } - - profiles[k] = p + for _, p := range profiles { + sanitizeProfile(c, p) } w.Write([]byte(model.UserMapToJson(profiles))) diff --git a/api/admin_test.go b/api/admin_test.go index 445d2de38..e1520877c 100644 --- a/api/admin_test.go +++ b/api/admin_test.go @@ -527,17 +527,13 @@ func TestAdminLdapSyncNow(t *testing.T) { } } +// Needs more work func TestGetRecentlyActiveUsers(t *testing.T) { th := Setup().InitBasic() - user1Id := th.BasicUser.Id - user2Id := th.BasicUser2.Id - if userMap, err := th.BasicClient.GetRecentlyActiveUsers(th.BasicTeam.Id); err != nil { t.Fatal(err) - } else if len(userMap.Data.(map[string]*model.User)) != 2 { - t.Fatal("should have been 2") - } else if userMap.Data.(map[string]*model.User)[user1Id].Id != user1Id || userMap.Data.(map[string]*model.User)[user2Id].Id != user2Id { - t.Fatal("should have been valid") + } else if len(userMap.Data.(map[string]*model.User)) >= 2 { + t.Fatal("should have been at least 2") } } diff --git a/api/apitestlib.go b/api/apitestlib.go index 9345d3fc4..37367b71d 100644 --- a/api/apitestlib.go +++ b/api/apitestlib.go @@ -36,7 +36,7 @@ func SetupEnterprise() *TestHelper { *utils.Cfg.RateLimitSettings.Enable = false utils.DisableDebugLogForTest() utils.License.Features.SetDefaults() - NewServer() + NewServer(false) StartServer() utils.InitHTML() InitApi() @@ -57,7 +57,7 @@ func Setup() *TestHelper { utils.Cfg.TeamSettings.MaxUsersPerTeam = 50 *utils.Cfg.RateLimitSettings.Enable = false utils.DisableDebugLogForTest() - NewServer() + NewServer(false) StartServer() InitApi() utils.EnableDebugLogForTest() diff --git a/api/authorization.go b/api/authorization.go index 5badf244b..8b3140b0f 100644 --- a/api/authorization.go +++ b/api/authorization.go @@ -5,6 +5,7 @@ package api import ( "net/http" + "strings" l4g "github.com/alecthomas/log4go" "github.com/mattermost/platform/model" @@ -65,15 +66,16 @@ func HasPermissionToTeam(user *model.User, teamMember *model.TeamMember, permiss } func HasPermissionToChannelContext(c *Context, channelId string, permission *model.Permission) bool { - cmc := Srv.Store.Channel().GetMember(channelId, c.Session.UserId) + cmc := Srv.Store.Channel().GetAllChannelMembersForUser(c.Session.UserId, true) var channelRoles []string if cmcresult := <-cmc; cmcresult.Err == nil { - channelMember := cmcresult.Data.(model.ChannelMember) - channelRoles = channelMember.GetRoles() - - if CheckIfRolesGrantPermission(channelRoles, permission.Id) { - return true + ids := cmcresult.Data.(map[string]string) + if roles, ok := ids[channelId]; ok { + channelRoles = strings.Fields(roles) + if CheckIfRolesGrantPermission(channelRoles, permission.Id) { + return true + } } } diff --git a/api/auto_users.go b/api/auto_users.go index a23b76246..7439de96e 100644 --- a/api/auto_users.go +++ b/api/auto_users.go @@ -80,6 +80,13 @@ func (cfg *AutoUserCreator) createRandomUser() (*model.User, bool) { ruser := result.Data.(*model.User) + status := &model.Status{ruser.Id, model.STATUS_ONLINE, false, model.GetMillis(), ""} + if result := <-Srv.Store.Status().SaveOrUpdate(status); result.Err != nil { + result.Err.Translate(utils.T) + l4g.Error(result.Err.Error()) + return nil, false + } + // We need to cheat to verify the user's email store.Must(Srv.Store.User().VerifyEmail(ruser.Id)) diff --git a/api/channel.go b/api/channel.go index f40c979ca..9cc8976c2 100644 --- a/api/channel.go +++ b/api/channel.go @@ -6,7 +6,6 @@ package api import ( "fmt" "net/http" - "strconv" "strings" l4g "github.com/alecthomas/log4go" @@ -16,16 +15,12 @@ import ( "github.com/mattermost/platform/utils" ) -const ( - defaultExtraMemberLimit = 100 -) - func InitChannel() { l4g.Debug(utils.T("api.channel.init.debug")) - BaseRoutes.Channels.Handle("/", ApiUserRequiredActivity(getChannels, false)).Methods("GET") + BaseRoutes.Channels.Handle("/", ApiUserRequired(getChannels)).Methods("GET") BaseRoutes.Channels.Handle("/more", ApiUserRequired(getMoreChannels)).Methods("GET") - BaseRoutes.Channels.Handle("/counts", ApiUserRequiredActivity(getChannelCounts, false)).Methods("GET") + BaseRoutes.Channels.Handle("/counts", ApiUserRequired(getChannelCounts)).Methods("GET") BaseRoutes.Channels.Handle("/create", ApiUserRequired(createChannel)).Methods("POST") BaseRoutes.Channels.Handle("/create_direct", ApiUserRequired(createDirectChannel)).Methods("POST") BaseRoutes.Channels.Handle("/update", ApiUserRequired(updateChannel)).Methods("POST") @@ -35,9 +30,9 @@ func InitChannel() { BaseRoutes.NeedChannelName.Handle("/join", ApiUserRequired(join)).Methods("POST") - BaseRoutes.NeedChannel.Handle("/", ApiUserRequiredActivity(getChannel, false)).Methods("GET") - BaseRoutes.NeedChannel.Handle("/extra_info", ApiUserRequired(getChannelExtraInfo)).Methods("GET") - BaseRoutes.NeedChannel.Handle("/extra_info/{member_limit:-?[0-9]+}", ApiUserRequired(getChannelExtraInfo)).Methods("GET") + BaseRoutes.NeedChannel.Handle("/", ApiUserRequired(getChannel)).Methods("GET") + BaseRoutes.NeedChannel.Handle("/stats", ApiUserRequired(getChannelStats)).Methods("GET") + BaseRoutes.NeedChannel.Handle("/members/{user_id:[A-Za-z0-9]+}", ApiUserRequired(getChannelMember)).Methods("GET") BaseRoutes.NeedChannel.Handle("/join", ApiUserRequired(join)).Methods("POST") BaseRoutes.NeedChannel.Handle("/leave", ApiUserRequired(leave)).Methods("POST") BaseRoutes.NeedChannel.Handle("/delete", ApiUserRequired(deleteChannel)).Methods("POST") @@ -150,11 +145,14 @@ func CreateDirectChannel(userId string, otherUserId string) (*model.Channel, *mo } else { channel := result.Data.(*model.Channel) + InvalidateCacheForUser(userId) + InvalidateCacheForUser(otherUserId) + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_DIRECT_ADDED, "", channel.Id, "", nil) message.Add("teammate_id", otherUserId) go Publish(message) - return result.Data.(*model.Channel), nil + return channel, nil } } @@ -566,6 +564,7 @@ func AddUserToChannel(user *model.User, channel *model.Channel) (*model.ChannelM go func() { InvalidateCacheForUser(user.Id) + Srv.Store.User().InvalidateProfilesInChannelCache(channel.Id) message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_USER_ADDED, "", channel.Id, "", nil) message.Add("user_id", user.Id) @@ -609,6 +608,8 @@ func JoinDefaultChannels(teamId string, user *model.User, channelRole string) *m if _, err := CreatePost(fakeContext, post, false); err != nil { l4g.Error(utils.T("api.channel.post_user_add_remove_message_and_forget.error"), err) } + + Srv.Store.User().InvalidateProfilesInChannelCache(result.Data.(*model.Channel).Id) } if result := <-Srv.Store.Channel().GetByName(teamId, "off-topic"); result.Err != nil { @@ -631,6 +632,8 @@ func JoinDefaultChannels(teamId string, user *model.User, channelRole string) *m if _, err := CreatePost(fakeContext, post, false); err != nil { l4g.Error(utils.T("api.channel.post_user_add_remove_message_and_forget.error"), err) } + + Srv.Store.User().InvalidateProfilesInChannelCache(result.Data.(*model.Channel).Id) } return err @@ -778,9 +781,9 @@ func deleteChannel(c *Context, w http.ResponseWriter, r *http.Request) { c.LogAudit("name=" + channel.Name) go func() { - InvalidateCacheForChannel(channel.Id) message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_CHANNEL_DELETED, c.TeamId, "", "", nil) message.Add("channel_id", channel.Id) + go Publish(message) post := &model.Post{ @@ -917,54 +920,27 @@ func getChannel(c *Context, w http.ResponseWriter, r *http.Request) { } -func getChannelExtraInfo(c *Context, w http.ResponseWriter, r *http.Request) { +func getChannelStats(c *Context, w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) id := params["channel_id"] - var memberLimit int - if memberLimitString, ok := params["member_limit"]; !ok { - memberLimit = defaultExtraMemberLimit - } else if memberLimitInt64, err := strconv.ParseInt(memberLimitString, 10, 0); err != nil { - c.Err = model.NewLocAppError("getChannelExtraInfo", "api.channel.get_channel_extra_info.member_limit.app_error", nil, err.Error()) - return - } else { - memberLimit = int(memberLimitInt64) - } - sc := Srv.Store.Channel().Get(id) var channel *model.Channel - if cresult := <-sc; cresult.Err != nil { - c.Err = cresult.Err + if result := <-sc; result.Err != nil { + c.Err = result.Err return } else { - channel = cresult.Data.(*model.Channel) + channel = result.Data.(*model.Channel) } - extraEtag := channel.ExtraEtag(memberLimit) - if HandleEtag(extraEtag, w, r) { - return - } - - scm := Srv.Store.Channel().GetMember(id, c.Session.UserId) - ecm := Srv.Store.Channel().GetExtraMembers(id, memberLimit) - ccm := Srv.Store.Channel().GetMemberCount(id) - - if cmresult := <-scm; cmresult.Err != nil { - c.Err = cmresult.Err - return - } else if ecmresult := <-ecm; ecmresult.Err != nil { - c.Err = ecmresult.Err - return - } else if ccmresult := <-ccm; ccmresult.Err != nil { - c.Err = ccmresult.Err + if result := <-Srv.Store.Channel().GetMemberCount(id); result.Err != nil { + c.Err = result.Err return } else { - //member := cmresult.Data.(model.ChannelMember) - extraMembers := ecmresult.Data.([]model.ExtraMember) - memberCount := ccmresult.Data.(int64) + memberCount := result.Data.(int64) if channel.DeleteAt > 0 { - c.Err = model.NewLocAppError("getChannelExtraInfo", "api.channel.get_channel_extra_info.deleted.app_error", nil, "") + c.Err = model.NewLocAppError("getChannelStats", "api.channel.get_channel_extra_info.deleted.app_error", nil, "") c.Err.StatusCode = http.StatusBadRequest return } @@ -973,12 +949,29 @@ func getChannelExtraInfo(c *Context, w http.ResponseWriter, r *http.Request) { return } - data := model.ChannelExtra{Id: channel.Id, Members: extraMembers, MemberCount: memberCount} - w.Header().Set(model.HEADER_ETAG_SERVER, extraEtag) + data := model.ChannelStats{ChannelId: channel.Id, MemberCount: memberCount} w.Write([]byte(data.ToJson())) } } +func getChannelMember(c *Context, w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + channelId := params["channel_id"] + userId := params["user_id"] + + if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_READ_CHANNEL) { + return + } + + if result := <-Srv.Store.Channel().GetMember(channelId, userId); result.Err != nil { + c.Err = result.Err + return + } else { + member := result.Data.(model.ChannelMember) + w.Write([]byte(member.ToJson())) + } +} + func addMember(c *Context, w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) id := params["channel_id"] @@ -1101,6 +1094,7 @@ func RemoveUserFromChannel(userIdToRemove string, removerUserId string, channel } InvalidateCacheForUser(userIdToRemove) + Srv.Store.User().InvalidateProfilesInChannelCache(channel.Id) message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_USER_REMOVED, "", channel.Id, "", nil) message.Add("user_id", userIdToRemove) diff --git a/api/channel_test.go b/api/channel_test.go index 1d0f0270d..4835ee9b7 100644 --- a/api/channel_test.go +++ b/api/channel_test.go @@ -10,7 +10,6 @@ import ( "time" "github.com/mattermost/platform/model" - "github.com/mattermost/platform/store" "github.com/mattermost/platform/utils" ) @@ -1106,7 +1105,7 @@ func TestDeleteChannel(t *testing.T) { } } -func TestGetChannelExtraInfo(t *testing.T) { +func TestGetChannelStats(t *testing.T) { th := Setup().InitBasic() Client := th.BasicClient team := th.BasicTeam @@ -1114,115 +1113,13 @@ func TestGetChannelExtraInfo(t *testing.T) { channel1 := &model.Channel{DisplayName: "A Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) - rget := Client.Must(Client.GetChannelExtraInfo(channel1.Id, -1, "")) - data := rget.Data.(*model.ChannelExtra) - if data.Id != channel1.Id { + rget := Client.Must(Client.GetChannelStats(channel1.Id, "")) + data := rget.Data.(*model.ChannelStats) + if data.ChannelId != channel1.Id { t.Fatal("couldnt't get extra info") - } else if len(data.Members) != 1 { - t.Fatal("got incorrect members") } else if data.MemberCount != 1 { t.Fatal("got incorrect member count") } - - // - // Testing etag caching - // - - currentEtag := rget.Etag - - if cache_result, err := Client.GetChannelExtraInfo(channel1.Id, -1, currentEtag); err != nil { - t.Fatal(err) - } else if cache_result.Data.(*model.ChannelExtra) != nil { - t.Log(cache_result.Data) - t.Fatal("response should be empty") - } else { - currentEtag = cache_result.Etag - } - - Client2 := model.NewClient("http://localhost" + utils.Cfg.ServiceSettings.ListenAddress) - - user2 := &model.User{Email: "success+" + model.NewId() + "@simulator.amazonses.com", Nickname: "Tester 2", Password: "passwd1"} - user2 = Client2.Must(Client2.CreateUser(user2, "")).Data.(*model.User) - LinkUserToTeam(user2, team) - Client2.SetTeamId(team.Id) - store.Must(Srv.Store.User().VerifyEmail(user2.Id)) - - Client2.Login(user2.Email, "passwd1") - Client2.Must(Client2.JoinChannel(channel1.Id)) - - if cache_result, err := Client.GetChannelExtraInfo(channel1.Id, -1, currentEtag); err != nil { - t.Fatal(err) - } else if cache_result.Data.(*model.ChannelExtra) == nil { - t.Log(cache_result.Data) - t.Fatal("response should not be empty") - } else { - currentEtag = cache_result.Etag - } - - if cache_result, err := Client.GetChannelExtraInfo(channel1.Id, -1, currentEtag); err != nil { - t.Fatal(err) - } else if cache_result.Data.(*model.ChannelExtra) != nil { - t.Log(cache_result.Data) - t.Fatal("response should be empty") - } else { - currentEtag = cache_result.Etag - } - - Client2.Must(Client2.LeaveChannel(channel1.Id)) - - if cache_result, err := Client.GetChannelExtraInfo(channel1.Id, -1, currentEtag); err != nil { - t.Fatal(err) - } else if cache_result.Data.(*model.ChannelExtra) == nil { - t.Log(cache_result.Data) - t.Fatal("response should not be empty") - } else { - currentEtag = cache_result.Etag - } - - if cache_result, err := Client.GetChannelExtraInfo(channel1.Id, -1, currentEtag); err != nil { - t.Fatal(err) - } else if cache_result.Data.(*model.ChannelExtra) != nil { - t.Log(cache_result.Data) - t.Fatal("response should be empty") - } else { - currentEtag = cache_result.Etag - } - - Client2.Must(Client2.JoinChannel(channel1.Id)) - - if cache_result, err := Client.GetChannelExtraInfo(channel1.Id, 2, currentEtag); err != nil { - t.Fatal(err) - } else if extra := cache_result.Data.(*model.ChannelExtra); extra == nil { - t.Fatal("response should not be empty") - } else if len(extra.Members) != 2 { - t.Fatal("should've returned 2 members") - } else if extra.MemberCount != 2 { - t.Fatal("should've returned member count of 2") - } else { - currentEtag = cache_result.Etag - } - - if cache_result, err := Client.GetChannelExtraInfo(channel1.Id, 1, currentEtag); err != nil { - t.Fatal(err) - } else if extra := cache_result.Data.(*model.ChannelExtra); extra == nil { - t.Fatal("response should not be empty") - } else if len(extra.Members) != 1 { - t.Fatal("should've returned only 1 member") - } else if extra.MemberCount != 2 { - t.Fatal("should've returned member count of 2") - } else { - currentEtag = cache_result.Etag - } - - if cache_result, err := Client.GetChannelExtraInfo(channel1.Id, 1, currentEtag); err != nil { - t.Fatal(err) - } else if cache_result.Data.(*model.ChannelExtra) != nil { - t.Log(cache_result.Data) - t.Fatal("response should be empty") - } else { - currentEtag = cache_result.Etag - } - } func TestAddChannelMember(t *testing.T) { @@ -1495,3 +1392,41 @@ func TestFuzzyChannel(t *testing.T) { } } } + +func TestGetChannelMember(t *testing.T) { + th := Setup().InitBasic() + Client := th.BasicClient + team := th.BasicTeam + + channel1 := &model.Channel{DisplayName: "A Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} + channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) + + if result, err := Client.GetChannelMember(channel1.Id, th.BasicUser.Id); err != nil { + t.Fatal(err) + } else { + cm := result.Data.(*model.ChannelMember) + + if cm.UserId != th.BasicUser.Id { + t.Fatal("user ids didn't match") + } + if cm.ChannelId != channel1.Id { + t.Fatal("channel ids didn't match") + } + } + + if _, err := Client.GetChannelMember(channel1.Id, th.BasicUser2.Id); err == nil { + t.Fatal("should have failed - user not in channel") + } + + if _, err := Client.GetChannelMember("junk", th.BasicUser2.Id); err == nil { + t.Fatal("should have failed - bad channel id") + } + + if _, err := Client.GetChannelMember(channel1.Id, "junk"); err == nil { + t.Fatal("should have failed - bad user id") + } + + if _, err := Client.GetChannelMember("junk", "junk"); err == nil { + t.Fatal("should have failed - bad channel and user id") + } +} diff --git a/api/cli_test.go b/api/cli_test.go index de2347058..4613988f3 100644 --- a/api/cli_test.go +++ b/api/cli_test.go @@ -68,7 +68,7 @@ func TestCliCreateUserWithTeam(t *testing.T) { t.Fatal(err) } - profiles := th.SystemAdminClient.Must(th.SystemAdminClient.GetProfiles(th.SystemAdminTeam.Id, "")).Data.(map[string]*model.User) + profiles := th.SystemAdminClient.Must(th.SystemAdminClient.GetProfilesInTeam(th.SystemAdminTeam.Id, 0, 1000, "")).Data.(map[string]*model.User) found := false @@ -318,7 +318,7 @@ func TestCliJoinTeam(t *testing.T) { t.Fatal(err) } - profiles := th.SystemAdminClient.Must(th.SystemAdminClient.GetProfiles(th.SystemAdminTeam.Id, "")).Data.(map[string]*model.User) + profiles := th.SystemAdminClient.Must(th.SystemAdminClient.GetProfilesInTeam(th.SystemAdminTeam.Id, 0, 1000, "")).Data.(map[string]*model.User) found := false @@ -348,7 +348,7 @@ func TestCliLeaveTeam(t *testing.T) { t.Fatal(err) } - profiles := th.BasicClient.Must(th.BasicClient.GetProfiles(th.BasicTeam.Id, "")).Data.(map[string]*model.User) + profiles := th.BasicClient.Must(th.BasicClient.GetProfilesInTeam(th.BasicTeam.Id, 0, 1000, "")).Data.(map[string]*model.User) found := false @@ -359,8 +359,8 @@ func TestCliLeaveTeam(t *testing.T) { } - if !found { - t.Fatal("profile still should be in team even if deleted") + if found { + t.Fatal("profile should not be on team") } if result := <-Srv.Store.Team().GetTeamsByUserId(th.BasicUser.Id); result.Err != nil { diff --git a/api/command_loadtest.go b/api/command_loadtest.go index beb831b22..8f5163a66 100644 --- a/api/command_loadtest.go +++ b/api/command_loadtest.go @@ -288,7 +288,7 @@ func (me *LoadTestProvider) PostsCommand(c *Context, channelId string, message s } var usernames []string - if result := <-Srv.Store.User().GetProfiles(c.TeamId); result.Err == nil { + if result := <-Srv.Store.User().GetProfiles(c.TeamId, 0, 1000); result.Err == nil { profileUsers := result.Data.(map[string]*model.User) usernames = make([]string, len(profileUsers)) i := 0 diff --git a/api/command_msg.go b/api/command_msg.go index aac657385..2e0e25397 100644 --- a/api/command_msg.go +++ b/api/command_msg.go @@ -47,20 +47,22 @@ func (me *msgProvider) DoCommand(c *Context, channelId string, message string) * targetUser = strings.SplitN(message, " ", 2)[0] targetUser = strings.TrimPrefix(targetUser, "@") - if profileList := <-Srv.Store.User().GetAllProfiles(); profileList.Err != nil { + // FIX ME + // Why isn't this selecting by username since we have that? + if profileList := <-Srv.Store.User().GetAll(); profileList.Err != nil { c.Err = profileList.Err return &model.CommandResponse{Text: c.T("api.command_msg.list.app_error"), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL} } else { - profileUsers := profileList.Data.(map[string]*model.User) + profileUsers := profileList.Data.([]*model.User) for _, userProfile := range profileUsers { - //Don't let users open DMs with themselves. It probably won't work out well. + // Don't let users open DMs with themselves. It probably won't work out well. if userProfile.Id == c.Session.UserId { continue } if userProfile.Username == targetUser { targetChannelId := "" - //Find the channel based on this user + // Find the channel based on this user channelName := model.GetDMNameFromIds(c.Session.UserId, userProfile.Id) if channel := <-Srv.Store.Channel().GetByName(c.TeamId, channelName); channel.Err != nil { diff --git a/api/command_statuses_test.go b/api/command_statuses_test.go index 1c8026a9f..73c6354f4 100644 --- a/api/command_statuses_test.go +++ b/api/command_statuses_test.go @@ -27,7 +27,7 @@ func commandAndTest(t *testing.T, th *TestHelper, status string) { t.Fatal("Command failed to execute") } - time.Sleep(300 * time.Millisecond) + time.Sleep(500 * time.Millisecond) statuses := Client.Must(Client.GetStatuses()).Data.(map[string]string) diff --git a/api/context.go b/api/context.go index 524ccf402..257f43174 100644 --- a/api/context.go +++ b/api/context.go @@ -57,7 +57,7 @@ func AppHandlerIndependent(h func(*Context, http.ResponseWriter, *http.Request)) } func ApiUserRequired(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler { - return &handler{h, true, false, true, true, false, false} + return &handler{h, true, false, true, false, false, false} } func ApiUserRequiredActivity(h func(*Context, http.ResponseWriter, *http.Request), isUserActivity bool) http.Handler { @@ -85,7 +85,7 @@ func ApiAppHandlerTrustRequester(h func(*Context, http.ResponseWriter, *http.Req } func ApiUserRequiredTrustRequester(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler { - return &handler{h, true, false, true, true, false, true} + return &handler{h, true, false, true, false, false, true} } func ApiAppHandlerTrustRequesterIndependent(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler { @@ -220,7 +220,7 @@ func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { c.LogError(c.Err) c.Err.Where = r.URL.Path - // Block out detailed error whenn not in developer mode + // Block out detailed error when not in developer mode if !*utils.Cfg.ServiceSettings.EnableDeveloper { c.Err.DetailedError = "" } diff --git a/api/post.go b/api/post.go index 498f5b363..5ae5f60db 100644 --- a/api/post.go +++ b/api/post.go @@ -35,18 +35,18 @@ const ( func InitPost() { l4g.Debug(utils.T("api.post.init.debug")) - BaseRoutes.NeedTeam.Handle("/posts/search", ApiUserRequired(searchPosts)).Methods("POST") - BaseRoutes.NeedTeam.Handle("/posts/flagged/{offset:[0-9]+}/{limit:[0-9]+}", ApiUserRequiredActivity(getFlaggedPosts, false)).Methods("GET") + BaseRoutes.NeedTeam.Handle("/posts/search", ApiUserRequiredActivity(searchPosts, true)).Methods("POST") + BaseRoutes.NeedTeam.Handle("/posts/flagged/{offset:[0-9]+}/{limit:[0-9]+}", ApiUserRequired(getFlaggedPosts)).Methods("GET") BaseRoutes.NeedTeam.Handle("/posts/{post_id}", ApiUserRequired(getPostById)).Methods("GET") BaseRoutes.NeedTeam.Handle("/pltmp/{post_id}", ApiUserRequired(getPermalinkTmp)).Methods("GET") - BaseRoutes.Posts.Handle("/create", ApiUserRequired(createPost)).Methods("POST") - BaseRoutes.Posts.Handle("/update", ApiUserRequired(updatePost)).Methods("POST") - BaseRoutes.Posts.Handle("/page/{offset:[0-9]+}/{limit:[0-9]+}", ApiUserRequiredActivity(getPosts, false)).Methods("GET") - BaseRoutes.Posts.Handle("/since/{time:[0-9]+}", ApiUserRequiredActivity(getPostsSince, false)).Methods("GET") + BaseRoutes.Posts.Handle("/create", ApiUserRequiredActivity(createPost, true)).Methods("POST") + BaseRoutes.Posts.Handle("/update", ApiUserRequiredActivity(updatePost, true)).Methods("POST") + BaseRoutes.Posts.Handle("/page/{offset:[0-9]+}/{limit:[0-9]+}", ApiUserRequired(getPosts)).Methods("GET") + BaseRoutes.Posts.Handle("/since/{time:[0-9]+}", ApiUserRequired(getPostsSince)).Methods("GET") BaseRoutes.NeedPost.Handle("/get", ApiUserRequired(getPost)).Methods("GET") - BaseRoutes.NeedPost.Handle("/delete", ApiUserRequired(deletePost)).Methods("POST") + BaseRoutes.NeedPost.Handle("/delete", ApiUserRequiredActivity(deletePost, true)).Methods("POST") BaseRoutes.NeedPost.Handle("/before/{offset:[0-9]+}/{num_posts:[0-9]+}", ApiUserRequired(getPostsBefore)).Methods("GET") BaseRoutes.NeedPost.Handle("/after/{offset:[0-9]+}/{num_posts:[0-9]+}", ApiUserRequired(getPostsAfter)).Methods("GET") BaseRoutes.NeedPost.Handle("/get_file_infos", ApiUserRequired(getFileInfosForPost)).Methods("GET") @@ -154,7 +154,7 @@ func CreatePost(c *Context, post *model.Post, triggerWebhooks bool) (*model.Post } } - go handlePostEvents(c, rpost, triggerWebhooks) + handlePostEvents(c, rpost, triggerWebhooks) return rpost, nil } @@ -250,7 +250,7 @@ func handlePostEvents(c *Context, post *model.Post, triggerWebhooks bool) { channel = result.Data.(*model.Channel) } - go sendNotifications(c, post, team, channel) + sendNotifications(c, post, team, channel) var user *model.User if result := <-uchan; result.Err != nil { @@ -441,40 +441,31 @@ func handleWebhookEvents(c *Context, post *model.Post, team *model.Team, channel } } -// Given a map of user IDs to profiles and a map of user IDs of channel members, returns a list of mention -// keywords for all users on the team. Users that are members of the channel will have all their mention -// keywords returned while users that aren't in the channel will only have their @mentions returned. -func getMentionKeywords(profiles map[string]*model.User, members map[string]string) map[string][]string { +// Given a map of user IDs to profiles, returns a list of mention +// keywords for all users in the channel. +func getMentionKeywordsInChannel(profiles map[string]*model.User) map[string][]string { keywords := make(map[string][]string) for id, profile := range profiles { - _, inChannel := members[id] - - if inChannel { - if len(profile.NotifyProps["mention_keys"]) > 0 { - // Add all the user's mention keys - splitKeys := strings.Split(profile.NotifyProps["mention_keys"], ",") - for _, k := range splitKeys { - // note that these are made lower case so that we can do a case insensitive check for them - key := strings.ToLower(k) - keywords[key] = append(keywords[key], id) - } + if len(profile.NotifyProps["mention_keys"]) > 0 { + // Add all the user's mention keys + splitKeys := strings.Split(profile.NotifyProps["mention_keys"], ",") + for _, k := range splitKeys { + // note that these are made lower case so that we can do a case insensitive check for them + key := strings.ToLower(k) + keywords[key] = append(keywords[key], id) } + } - // If turned on, add the user's case sensitive first name - if profile.NotifyProps["first_name"] == "true" { - keywords[profile.FirstName] = append(keywords[profile.FirstName], profile.Id) - } + // If turned on, add the user's case sensitive first name + if profile.NotifyProps["first_name"] == "true" { + keywords[profile.FirstName] = append(keywords[profile.FirstName], profile.Id) + } - // Add @channel and @all to keywords if user has them turned on - if profile.NotifyProps["channel"] == "true" { - keywords["@channel"] = append(keywords["@channel"], profile.Id) - keywords["@all"] = append(keywords["@all"], profile.Id) - } - } else { - // user isn't in channel, so just look for @mentions - key := "@" + strings.ToLower(profile.Username) - keywords[key] = append(keywords[key], id) + // Add @channel and @all to keywords if user has them turned on + if profile.NotifyProps["channel"] == "true" { + keywords["@channel"] = append(keywords["@channel"], profile.Id) + keywords["@all"] = append(keywords["@all"], profile.Id) } } @@ -482,9 +473,11 @@ func getMentionKeywords(profiles map[string]*model.User, members map[string]stri } // Given a message and a map mapping mention keywords to the users who use them, returns a map of mentioned -// users and whether or not @here was mentioned. -func getExplicitMentions(message string, keywords map[string][]string) (map[string]bool, bool) { +// users and a slice of potencial mention users not in the channel and whether or not @here was mentioned. +func getExplicitMentions(message string, keywords map[string][]string) (map[string]bool, []string, bool) { mentioned := make(map[string]bool) + potentialOthersMentioned := make([]string, 0) + systemMentions := map[string]bool{"@here": true, "@channel": true, "@all": true} hereMentioned := false addMentionedUsers := func(ids []string) { @@ -510,6 +503,9 @@ func getExplicitMentions(message string, keywords map[string][]string) (map[stri if ids, match := keywords[word]; match { addMentionedUsers(ids) isMention = true + } else if _, ok := systemMentions[word]; !ok && strings.HasPrefix(word, "@") { + potentialOthersMentioned = append(potentialOthersMentioned, word[1:]) + continue } if !isMention { @@ -532,19 +528,19 @@ func getExplicitMentions(message string, keywords map[string][]string) (map[stri // Case-sensitive check for first name if ids, match := keywords[splitWord]; match { addMentionedUsers(ids) + } else if _, ok := systemMentions[word]; !ok && strings.HasPrefix(word, "@") { + username := word[1:len(splitWord)] + potentialOthersMentioned = append(potentialOthersMentioned, username) } } } } - return mentioned, hereMentioned + return mentioned, potentialOthersMentioned, hereMentioned } func sendNotifications(c *Context, post *model.Post, team *model.Team, channel *model.Channel) { - // get profiles for all users we could be mentioning - pchan := Srv.Store.User().GetProfiles(c.TeamId) - dpchan := Srv.Store.User().GetDirectProfiles(c.Session.UserId) - mchan := Srv.Store.Channel().GetMembers(post.ChannelId) + pchan := Srv.Store.User().GetProfilesInChannel(channel.Id, -1, -1, true) fchan := Srv.Store.FileInfo().GetForPost(post.Id) var profileMap map[string]*model.User @@ -555,30 +551,11 @@ func sendNotifications(c *Context, post *model.Post, team *model.Team, channel * profileMap = result.Data.(map[string]*model.User) } - if result := <-dpchan; result.Err != nil { - l4g.Error(utils.T("api.post.handle_post_events_and_forget.profiles.error"), c.TeamId, result.Err) - return - } else { - dps := result.Data.(map[string]*model.User) - for k, v := range dps { - profileMap[k] = v - } - } - + // If the user who made the post is mention don't send a notification if _, ok := profileMap[post.UserId]; !ok { l4g.Error(utils.T("api.post.send_notifications_and_forget.user_id.error"), post.UserId) return } - // using a map as a pseudo-set since we're checking for containment a lot - members := make(map[string]string) - if result := <-mchan; result.Err != nil { - l4g.Error(utils.T("api.post.handle_post_events_and_forget.members.error"), post.ChannelId, result.Err) - return - } else { - for _, member := range result.Data.([]model.ChannelMember) { - members[member.UserId] = member.UserId - } - } mentionedUserIds := make(map[string]bool) allActivityPushUserIds := []string{} @@ -595,11 +572,11 @@ func sendNotifications(c *Context, post *model.Post, team *model.Team, channel * mentionedUserIds[otherUserId] = true } else { - keywords := getMentionKeywords(profileMap, members) + keywords := getMentionKeywordsInChannel(profileMap) - // get users that are explicitly mentioned var mentioned map[string]bool - mentioned, hereNotification = getExplicitMentions(post.Message, keywords) + var potentialOtherMentions []string + mentioned, potentialOtherMentions, hereNotification = getExplicitMentions(post.Message, keywords) // get users that have comment thread mentions enabled if len(post.RootId) > 0 { @@ -623,25 +600,15 @@ func sendNotifications(c *Context, post *model.Post, team *model.Team, channel * delete(mentioned, post.UserId) } - outOfChannelMentions := make(map[string]bool) - for id := range mentioned { - if _, inChannel := members[id]; inChannel { - mentionedUserIds[id] = true - } else { - outOfChannelMentions[id] = true + if len(potentialOtherMentions) > 0 { + if result := <-Srv.Store.User().GetProfilesByUsernames(potentialOtherMentions, team.Id); result.Err == nil { + outOfChannelMentions := result.Data.(map[string]*model.User) + go sendOutOfChannelMentions(c, post, outOfChannelMentions) } } - go sendOutOfChannelMentions(c, post, profileMap, outOfChannelMentions) - // find which users in the channel are set up to always receive mobile notifications - for id := range members { - profile := profileMap[id] - if profile == nil { - l4g.Warn(utils.T("api.post.notification.member_profile.warn"), id) - continue - } - + for _, profile := range profileMap { if profile.NotifyProps["push"] == model.USER_NOTIFY_ALL && (post.UserId != profile.Id || post.Props["from_webhook"] == "true") && !post.IsSystemMessage() { @@ -699,10 +666,9 @@ func sendNotifications(c *Context, post *model.Post, team *model.Team, channel * } _, profileFound := profileMap[status.UserId] - _, isChannelMember := members[status.UserId] _, alreadyMentioned := mentionedUserIds[status.UserId] - if status.Status == model.STATUS_ONLINE && profileFound && isChannelMember && !alreadyMentioned { + if status.Status == model.STATUS_ONLINE && profileFound && !alreadyMentioned { mentionedUsersList = append(mentionedUsersList, status.UserId) updateMentionChans = append(updateMentionChans, Srv.Store.Channel().IncrementMentionCount(post.ChannelId, status.UserId)) } @@ -787,7 +753,8 @@ func sendNotifications(c *Context, post *model.Post, team *model.Team, channel * message.Add("mentions", model.ArrayToJson(mentionedUsersList)) } - go Publish(message) + Publish(message) + return } func sendNotificationEmail(c *Context, post *model.Post, user *model.User, channel *model.Channel, team *model.Team, senderName string, sender *model.User) { @@ -1045,14 +1012,14 @@ func getMobileAppSession(userId string) *model.Session { return nil } -func sendOutOfChannelMentions(c *Context, post *model.Post, profiles map[string]*model.User, outOfChannelMentions map[string]bool) { - if len(outOfChannelMentions) == 0 { +func sendOutOfChannelMentions(c *Context, post *model.Post, profiles map[string]*model.User) { + if len(profiles) == 0 { return } var usernames []string - for id := range outOfChannelMentions { - usernames = append(usernames, profiles[id].Username) + for _, user := range profiles { + usernames = append(usernames, user.Username) } sort.Strings(usernames) diff --git a/api/post_test.go b/api/post_test.go index bdc5278e4..3c917aec3 100644 --- a/api/post_test.go +++ b/api/post_test.go @@ -883,10 +883,9 @@ func TestGetMentionKeywords(t *testing.T) { } profiles := map[string]*model.User{user1.Id: user1} - members := map[string]string{user1.Id: user1.Id} - mentions := getMentionKeywords(profiles, members) + mentions := getMentionKeywordsInChannel(profiles) if len(mentions) != 3 { - t.Fatal("should've returned two mention keywords") + t.Fatal("should've returned three mention keywords") } else if ids, ok := mentions["user"]; !ok || ids[0] != user1.Id { t.Fatal("should've returned mention key of user") } else if ids, ok := mentions["@user"]; !ok || ids[0] != user1.Id { @@ -906,8 +905,7 @@ func TestGetMentionKeywords(t *testing.T) { } profiles = map[string]*model.User{user2.Id: user2} - members = map[string]string{user2.Id: user2.Id} - mentions = getMentionKeywords(profiles, members) + mentions = getMentionKeywordsInChannel(profiles) if len(mentions) != 1 { t.Fatal("should've returned one mention keyword") } else if ids, ok := mentions["First"]; !ok || ids[0] != user2.Id { @@ -925,8 +923,7 @@ func TestGetMentionKeywords(t *testing.T) { } profiles = map[string]*model.User{user3.Id: user3} - members = map[string]string{user3.Id: user3.Id} - mentions = getMentionKeywords(profiles, members) + mentions = getMentionKeywordsInChannel(profiles) if len(mentions) != 2 { t.Fatal("should've returned two mention keywords") } else if ids, ok := mentions["@channel"]; !ok || ids[0] != user3.Id { @@ -948,8 +945,7 @@ func TestGetMentionKeywords(t *testing.T) { } profiles = map[string]*model.User{user4.Id: user4} - members = map[string]string{user4.Id: user4.Id} - mentions = getMentionKeywords(profiles, members) + mentions = getMentionKeywordsInChannel(profiles) if len(mentions) != 6 { t.Fatal("should've returned six mention keywords") } else if ids, ok := mentions["user"]; !ok || ids[0] != user4.Id { @@ -973,13 +969,7 @@ func TestGetMentionKeywords(t *testing.T) { user3.Id: user3, user4.Id: user4, } - members = map[string]string{ - user1.Id: user1.Id, - user2.Id: user2.Id, - user3.Id: user3.Id, - user4.Id: user4.Id, - } - mentions = getMentionKeywords(profiles, members) + mentions = getMentionKeywordsInChannel(profiles) if len(mentions) != 6 { t.Fatal("should've returned six mention keywords") } else if ids, ok := mentions["user"]; !ok || len(ids) != 2 || (ids[0] != user1.Id && ids[1] != user1.Id) || (ids[0] != user4.Id && ids[1] != user4.Id) { @@ -995,16 +985,6 @@ func TestGetMentionKeywords(t *testing.T) { } else if ids, ok := mentions["@all"]; !ok || len(ids) != 2 || (ids[0] != user3.Id && ids[1] != user3.Id) || (ids[0] != user4.Id && ids[1] != user4.Id) { t.Fatal("should've mentioned user3 and user4 with @all") } - - // a user that's not in the channel - profiles = map[string]*model.User{user4.Id: user4} - members = map[string]string{} - mentions = getMentionKeywords(profiles, members) - if len(mentions) != 1 { - t.Fatal("should've returned one mention keyword") - } else if ids, ok := mentions["@user"]; !ok || len(ids) != 1 || ids[0] != user4.Id { - t.Fatal("should've returned mention key of @user") - } } func TestGetExplicitMentionsAtHere(t *testing.T) { @@ -1051,7 +1031,7 @@ func TestGetExplicitMentionsAtHere(t *testing.T) { } for message, shouldMention := range cases { - if _, hereMentioned := getExplicitMentions(message, nil); hereMentioned && !shouldMention { + if _, _, hereMentioned := getExplicitMentions(message, nil); hereMentioned && !shouldMention { t.Fatalf("shouldn't have mentioned @here with \"%v\"", message) } else if !hereMentioned && shouldMention { t.Fatalf("should've have mentioned @here with \"%v\"", message) @@ -1060,10 +1040,12 @@ func TestGetExplicitMentionsAtHere(t *testing.T) { // mentioning @here and someone id := model.NewId() - if mentions, hereMentioned := getExplicitMentions("@here @user", map[string][]string{"@user": {id}}); !hereMentioned { + if mentions, potential, hereMentioned := getExplicitMentions("@here @user @potential", map[string][]string{"@user": {id}}); !hereMentioned { t.Fatal("should've mentioned @here with \"@here @user\"") } else if len(mentions) != 1 || !mentions[id] { t.Fatal("should've mentioned @user with \"@here @user\"") + } else if len(potential) > 1 { + t.Fatal("should've potential mentions for @potential") } } @@ -1074,69 +1056,76 @@ func TestGetExplicitMentions(t *testing.T) { // not mentioning anybody message := "this is a message" keywords := map[string][]string{} - if mentions, _ := getExplicitMentions(message, keywords); len(mentions) != 0 { - t.Fatal("shouldn't have mentioned anybody") + if mentions, potential, _ := getExplicitMentions(message, keywords); len(mentions) != 0 || len(potential) != 0 { + t.Fatal("shouldn't have mentioned anybody or have any potencial mentions") } // mentioning a user that doesn't exist message = "this is a message for @user" - if mentions, _ := getExplicitMentions(message, keywords); len(mentions) != 0 { + if mentions, _, _ := getExplicitMentions(message, keywords); len(mentions) != 0 { t.Fatal("shouldn't have mentioned user that doesn't exist") } // mentioning one person keywords = map[string][]string{"@user": {id1}} - if mentions, _ := getExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] { + if mentions, _, _ := getExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] { t.Fatal("should've mentioned @user") } // mentioning one person without an @mention message = "this is a message for @user" keywords = map[string][]string{"this": {id1}} - if mentions, _ := getExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] { + if mentions, _, _ := getExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] { t.Fatal("should've mentioned this") } // mentioning multiple people with one word message = "this is a message for @user" keywords = map[string][]string{"@user": {id1, id2}} - if mentions, _ := getExplicitMentions(message, keywords); len(mentions) != 2 || !mentions[id1] || !mentions[id2] { + if mentions, _, _ := getExplicitMentions(message, keywords); len(mentions) != 2 || !mentions[id1] || !mentions[id2] { t.Fatal("should've mentioned two users with @user") } // mentioning only one of multiple people keywords = map[string][]string{"@user": {id1}, "@mention": {id2}} - if mentions, _ := getExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] || mentions[id2] { + if mentions, _, _ := getExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] || mentions[id2] { t.Fatal("should've mentioned @user and not @mention") } // mentioning multiple people with multiple words message = "this is an @mention for @user" keywords = map[string][]string{"@user": {id1}, "@mention": {id2}} - if mentions, _ := getExplicitMentions(message, keywords); len(mentions) != 2 || !mentions[id1] || !mentions[id2] { + if mentions, _, _ := getExplicitMentions(message, keywords); len(mentions) != 2 || !mentions[id1] || !mentions[id2] { t.Fatal("should've mentioned two users with @user and @mention") } // mentioning @channel (not a special case, but it's good to double check) message = "this is an message for @channel" keywords = map[string][]string{"@channel": {id1, id2}} - if mentions, _ := getExplicitMentions(message, keywords); len(mentions) != 2 || !mentions[id1] || !mentions[id2] { + if mentions, _, _ := getExplicitMentions(message, keywords); len(mentions) != 2 || !mentions[id1] || !mentions[id2] { t.Fatal("should've mentioned two users with @channel") } // mentioning @all (not a special case, but it's good to double check) message = "this is an message for @all" keywords = map[string][]string{"@all": {id1, id2}} - if mentions, _ := getExplicitMentions(message, keywords); len(mentions) != 2 || !mentions[id1] || !mentions[id2] { + if mentions, _, _ := getExplicitMentions(message, keywords); len(mentions) != 2 || !mentions[id1] || !mentions[id2] { t.Fatal("should've mentioned two users with @all") } // mentioning user.period without mentioning user (PLT-3222) message = "user.period doesn't complicate things at all by including periods in their username" keywords = map[string][]string{"user.period": {id1}, "user": {id2}} - if mentions, _ := getExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] || mentions[id2] { + if mentions, _, _ := getExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] || mentions[id2] { t.Fatal("should've mentioned user.period and not user") } + + // mentioning a potential out of channel user + message = "this is an message for @potential and @user" + keywords = map[string][]string{"@user": {id1}} + if mentions, potential, _ := getExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] || len(potential) != 1 { + t.Fatal("should've mentioned user and have a potential not in channel") + } } func TestGetFlaggedPosts(t *testing.T) { diff --git a/api/server.go b/api/server.go index a7b9716c4..fee74e373 100644 --- a/api/server.go +++ b/api/server.go @@ -7,6 +7,7 @@ import ( "crypto/tls" "net" "net/http" + "net/http/pprof" "strings" "time" @@ -36,7 +37,20 @@ const TIME_TO_WAIT_FOR_CONNECTIONS_TO_CLOSE_ON_SERVER_SHUTDOWN = time.Second var Srv *Server -func NewServer() { +func AttachProfiler(router *mux.Router) { + router.HandleFunc("/debug/pprof/", pprof.Index) + router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + router.HandleFunc("/debug/pprof/profile", pprof.Profile) + router.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + + // Manually add support for paths linked to by index page at /debug/pprof/ + router.Handle("/debug/pprof/goroutine", pprof.Handler("goroutine")) + router.Handle("/debug/pprof/heap", pprof.Handler("heap")) + router.Handle("/debug/pprof/threadcreate", pprof.Handler("threadcreate")) + router.Handle("/debug/pprof/block", pprof.Handler("block")) +} + +func NewServer(enableProfiler bool) { l4g.Info(utils.T("api.server.new_server.init.info")) @@ -44,6 +58,10 @@ func NewServer() { Srv.Store = store.NewSqlStore() Srv.Router = mux.NewRouter() + if enableProfiler { + AttachProfiler(Srv.Router) + l4g.Info("Enabled HTTP Profiler") + } Srv.Router.NotFoundHandler = http.HandlerFunc(Handle404) } @@ -177,7 +195,7 @@ func StopServer() { Srv.GracefulServer.Stop(TIME_TO_WAIT_FOR_CONNECTIONS_TO_CLOSE_ON_SERVER_SHUTDOWN) Srv.Store.Close() - hub.Stop() + HubStop() l4g.Info(utils.T("api.server.stop_server.stopped.info")) } diff --git a/api/status.go b/api/status.go index a1a5ac496..897102a4b 100644 --- a/api/status.go +++ b/api/status.go @@ -31,9 +31,11 @@ func AddStatusCache(status *model.Status) { func InitStatus() { l4g.Debug(utils.T("api.status.init.debug")) - BaseRoutes.Users.Handle("/status", ApiUserRequiredActivity(getStatusesHttp, false)).Methods("GET") - BaseRoutes.Users.Handle("/status/set_active_channel", ApiUserRequiredActivity(setActiveChannel, false)).Methods("POST") + BaseRoutes.Users.Handle("/status", ApiUserRequired(getStatusesHttp)).Methods("GET") + BaseRoutes.Users.Handle("/status/ids", ApiUserRequired(getStatusesByIdsHttp)).Methods("POST") + BaseRoutes.Users.Handle("/status/set_active_channel", ApiUserRequired(setActiveChannel)).Methods("POST") BaseRoutes.WebSocket.Handle("get_statuses", ApiWebSocketHandler(getStatusesWebSocket)) + BaseRoutes.WebSocket.Handle("get_statuses_by_ids", ApiWebSocketHandler(getStatusesByIdsWebSocket)) } func getStatusesHttp(c *Context, w http.ResponseWriter, r *http.Request) { @@ -55,6 +57,7 @@ func getStatusesWebSocket(req *model.WebSocketRequest) (map[string]interface{}, return statusMap, nil } +// Only returns 300 statuses max func GetAllStatuses() (map[string]interface{}, *model.AppError) { if result := <-Srv.Store.Status().GetOnlineAway(); result.Err != nil { return nil, result.Err @@ -70,11 +73,82 @@ func GetAllStatuses() (map[string]interface{}, *model.AppError) { } } +func getStatusesByIdsHttp(c *Context, w http.ResponseWriter, r *http.Request) { + userIds := model.ArrayFromJson(r.Body) + + if len(userIds) == 0 { + c.SetInvalidParam("getStatusesByIdsHttp", "user_ids") + return + } + + statusMap, err := GetStatusesByIds(userIds) + if err != nil { + c.Err = err + return + } + + w.Write([]byte(model.StringInterfaceToJson(statusMap))) +} + +func getStatusesByIdsWebSocket(req *model.WebSocketRequest) (map[string]interface{}, *model.AppError) { + var userIds []string + if userIds = model.ArrayFromInterface(req.Data["user_ids"]); len(userIds) == 0 { + l4g.Error(model.StringInterfaceToJson(req.Data)) + return nil, NewInvalidWebSocketParamError(req.Action, "user_ids") + } + + statusMap, err := GetStatusesByIds(userIds) + if err != nil { + return nil, err + } + + return statusMap, nil +} + +func GetStatusesByIds(userIds []string) (map[string]interface{}, *model.AppError) { + statusMap := map[string]interface{}{} + + missingUserIds := []string{} + for _, userId := range userIds { + if result, ok := statusCache.Get(userId); ok { + statusMap[userId] = result.(*model.Status).Status + } else { + missingUserIds = append(missingUserIds, userId) + } + } + + if len(missingUserIds) > 0 { + if result := <-Srv.Store.Status().GetByIds(missingUserIds); result.Err != nil { + return nil, result.Err + } else { + statuses := result.Data.([]*model.Status) + + for _, s := range statuses { + AddStatusCache(s) + statusMap[s.UserId] = s.Status + } + } + } + + // For the case where the user does not have a row in the Status table and cache + for _, userId := range missingUserIds { + if _, ok := statusMap[userId]; !ok { + statusMap[userId] = model.STATUS_OFFLINE + } + } + + return statusMap, nil +} + func SetStatusOnline(userId string, sessionId string, manual bool) { broadcast := false + var oldStatus string = model.STATUS_OFFLINE + var oldTime int64 = 0 + var oldManual bool = false var status *model.Status var err *model.AppError + if status, err = GetStatus(userId); err != nil { status = &model.Status{userId, model.STATUS_ONLINE, false, model.GetMillis(), ""} broadcast = true @@ -82,35 +156,45 @@ func SetStatusOnline(userId string, sessionId string, manual bool) { if status.Manual && !manual { return // manually set status always overrides non-manual one } + if status.Status != model.STATUS_ONLINE { broadcast = true } + + oldStatus = status.Status + oldTime = status.LastActivityAt + oldManual = status.Manual + status.Status = model.STATUS_ONLINE - status.Manual = false // for "online" there's no manually or auto set + status.Manual = false // for "online" there's no manual setting status.LastActivityAt = model.GetMillis() } AddStatusCache(status) - achan := Srv.Store.Session().UpdateLastActivityAt(sessionId, model.GetMillis()) + // Only update the database if the status has changed, the status has been manually set, + // or enough time has passed since the previous action + if status.Status != oldStatus || status.Manual != oldManual || status.LastActivityAt-oldTime > model.STATUS_MIN_UPDATE_TIME { + achan := Srv.Store.Session().UpdateLastActivityAt(sessionId, status.LastActivityAt) - var schan store.StoreChannel - if broadcast { - schan = Srv.Store.Status().SaveOrUpdate(status) - } else { - schan = Srv.Store.Status().UpdateLastActivityAt(status.UserId, status.LastActivityAt) - } + var schan store.StoreChannel + if broadcast { + schan = Srv.Store.Status().SaveOrUpdate(status) + } else { + schan = Srv.Store.Status().UpdateLastActivityAt(status.UserId, status.LastActivityAt) + } - if result := <-achan; result.Err != nil { - l4g.Error(utils.T("api.status.last_activity.error"), userId, sessionId, result.Err) - } + if result := <-achan; result.Err != nil { + l4g.Error(utils.T("api.status.last_activity.error"), userId, sessionId, result.Err) + } - if result := <-schan; result.Err != nil { - l4g.Error(utils.T("api.status.save_status.error"), userId, result.Err) + if result := <-schan; result.Err != nil { + l4g.Error(utils.T("api.status.save_status.error"), userId, result.Err) + } } if broadcast { - event := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_STATUS_CHANGE, "", "", "", nil) + event := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_STATUS_CHANGE, "", "", status.UserId, nil) event.Add("status", model.STATUS_ONLINE) event.Add("user_id", status.UserId) go Publish(event) @@ -131,7 +215,7 @@ func SetStatusOffline(userId string, manual bool) { l4g.Error(utils.T("api.status.save_status.error"), userId, result.Err) } - event := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_STATUS_CHANGE, "", "", "", nil) + event := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_STATUS_CHANGE, "", "", status.UserId, nil) event.Add("status", model.STATUS_OFFLINE) event.Add("user_id", status.UserId) go Publish(event) @@ -168,15 +252,18 @@ func SetStatusAwayIfNeeded(userId string, manual bool) { l4g.Error(utils.T("api.status.save_status.error"), userId, result.Err) } - event := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_STATUS_CHANGE, "", "", "", nil) + event := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_STATUS_CHANGE, "", "", status.UserId, nil) event.Add("status", model.STATUS_AWAY) event.Add("user_id", status.UserId) go Publish(event) } func GetStatus(userId string) (*model.Status, *model.AppError) { - if status, ok := statusCache.Get(userId); ok { - return status.(*model.Status), nil + if result, ok := statusCache.Get(userId); ok { + status := result.(*model.Status) + statusCopy := &model.Status{} + *statusCopy = *status + return statusCopy, nil } if result := <-Srv.Store.Status().Get(userId); result.Err != nil { @@ -232,6 +319,10 @@ func SetActiveChannel(userId string, channelId string) *model.AppError { status = &model.Status{userId, model.STATUS_ONLINE, false, model.GetMillis(), channelId} } else { status.ActiveChannel = channelId + if !status.Manual { + status.Status = model.STATUS_ONLINE + } + status.LastActivityAt = model.GetMillis() } AddStatusCache(status) diff --git a/api/status_test.go b/api/status_test.go index f15c239db..ffc946817 100644 --- a/api/status_test.go +++ b/api/status_test.go @@ -4,13 +4,12 @@ package api import ( - "strings" - "testing" - "time" - "github.com/mattermost/platform/model" "github.com/mattermost/platform/store" "github.com/mattermost/platform/utils" + "strings" + "testing" + "time" ) func TestStatuses(t *testing.T) { @@ -59,7 +58,7 @@ func TestStatuses(t *testing.T) { t.Fatal(err2) } - time.Sleep(300 * time.Millisecond) + time.Sleep(500 * time.Millisecond) WebSocketClient.GetStatuses() if resp := <-WebSocketClient.ResponseChannel; resp.Error != nil { @@ -76,6 +75,7 @@ func TestStatuses(t *testing.T) { } if status, ok := resp.Data[th.BasicUser2.Id]; !ok { + t.Log(len(resp.Data)) t.Fatal("should have had user status") } else if status != model.STATUS_ONLINE { t.Log(status) @@ -83,7 +83,55 @@ func TestStatuses(t *testing.T) { } } - SetStatusAwayIfNeeded(th.BasicUser2.Id, false) + WebSocketClient.GetStatusesByIds([]string{th.BasicUser2.Id}) + if resp := <-WebSocketClient.ResponseChannel; resp.Error != nil { + t.Fatal(resp.Error) + } else { + if resp.SeqReply != WebSocketClient.Sequence-1 { + t.Fatal("bad sequence number") + } + + for _, status := range resp.Data { + if status != model.STATUS_OFFLINE && status != model.STATUS_AWAY && status != model.STATUS_ONLINE { + t.Fatal("one of the statuses had an invalid value") + } + } + + if status, ok := resp.Data[th.BasicUser2.Id]; !ok { + t.Log(len(resp.Data)) + t.Fatal("should have had user status") + } else if status != model.STATUS_ONLINE { + t.Log(status) + t.Fatal("status should have been online") + } else if len(resp.Data) != 1 { + t.Fatal("only 1 status should be returned") + } + } + + WebSocketClient.GetStatusesByIds([]string{ruser2.Id, "junk"}) + if resp := <-WebSocketClient.ResponseChannel; resp.Error != nil { + t.Fatal(resp.Error) + } else { + if resp.SeqReply != WebSocketClient.Sequence-1 { + t.Fatal("bad sequence number") + } + + if len(resp.Data) != 2 { + t.Fatal("2 statuses should be returned") + } + } + + WebSocketClient.GetStatusesByIds([]string{}) + if resp := <-WebSocketClient.ResponseChannel; resp.Error == nil { + if resp.SeqReply != WebSocketClient.Sequence-1 { + t.Fatal("bad sequence number") + } + t.Fatal("should have errored - empty user ids") + } + + WebSocketClient2.Close() + + SetStatusAwayIfNeeded(th.BasicUser.Id, false) awayTimeout := *utils.Cfg.TeamSettings.UserStatusAwayTimeout defer func() { @@ -93,10 +141,9 @@ func TestStatuses(t *testing.T) { time.Sleep(1500 * time.Millisecond) - SetStatusAwayIfNeeded(th.BasicUser2.Id, false) - SetStatusAwayIfNeeded(th.BasicUser2.Id, false) + SetStatusAwayIfNeeded(th.BasicUser.Id, false) + SetStatusOnline(th.BasicUser.Id, "junk", false) - WebSocketClient2.Close() time.Sleep(300 * time.Millisecond) WebSocketClient.GetStatuses() @@ -115,20 +162,17 @@ func TestStatuses(t *testing.T) { stop := make(chan bool) onlineHit := false awayHit := false - offlineHit := false go func() { for { select { case resp := <-WebSocketClient.EventChannel: - if resp.Event == model.WEBSOCKET_EVENT_STATUS_CHANGE && resp.Data["user_id"].(string) == th.BasicUser2.Id { + if resp.Event == model.WEBSOCKET_EVENT_STATUS_CHANGE && resp.Data["user_id"].(string) == th.BasicUser.Id { status := resp.Data["status"].(string) if status == model.STATUS_ONLINE { onlineHit = true } else if status == model.STATUS_AWAY { awayHit = true - } else if status == model.STATUS_OFFLINE { - offlineHit = true } } case <-stop: @@ -147,11 +191,40 @@ func TestStatuses(t *testing.T) { if !awayHit { t.Fatal("didn't get away event") } - if !offlineHit { - t.Fatal("didn't get offline event") + + time.Sleep(500 * time.Millisecond) + + WebSocketClient.Close() +} + +func TestGetStatusesByIds(t *testing.T) { + th := Setup().InitBasic() + Client := th.BasicClient + + if result, err := Client.GetStatusesByIds([]string{th.BasicUser.Id}); err != nil { + t.Fatal(err) + } else { + statuses := result.Data.(map[string]string) + if len(statuses) != 1 { + t.Fatal("should only have 1 status") + } + } + + if result, err := Client.GetStatusesByIds([]string{th.BasicUser.Id, th.BasicUser2.Id, "junk"}); err != nil { + t.Fatal(err) + } else { + statuses := result.Data.(map[string]string) + if len(statuses) != 3 { + t.Fatal("should have 3 statuses") + } + } + + if _, err := Client.GetStatusesByIds([]string{}); err == nil { + t.Fatal("should have errored") } } +/* func TestSetActiveChannel(t *testing.T) { th := Setup().InitBasic() Client := th.BasicClient @@ -185,8 +258,9 @@ func TestSetActiveChannel(t *testing.T) { time.Sleep(500 * time.Millisecond) status, _ = GetStatus(th.BasicUser.Id) - // need to check if offline to catch race + need to check if offline to catch race if status.Status != model.STATUS_OFFLINE && status.ActiveChannel != th.BasicChannel.Id { t.Fatal("active channel should be set") } } +*/ diff --git a/api/team.go b/api/team.go index 2be7b8545..c57591d46 100644 --- a/api/team.go +++ b/api/team.go @@ -31,9 +31,12 @@ func InitTeam() { BaseRoutes.Teams.Handle("/all_team_listings", ApiUserRequired(GetAllTeamListings)).Methods("GET") BaseRoutes.Teams.Handle("/get_invite_info", ApiAppHandler(getInviteInfo)).Methods("POST") BaseRoutes.Teams.Handle("/find_team_by_name", ApiAppHandler(findTeamByName)).Methods("POST") - BaseRoutes.Teams.Handle("/members/{id:[A-Za-z0-9]+}", ApiUserRequired(getMembers)).Methods("GET") BaseRoutes.NeedTeam.Handle("/me", ApiUserRequired(getMyTeam)).Methods("GET") + BaseRoutes.NeedTeam.Handle("/stats", ApiUserRequired(getTeamStats)).Methods("GET") + BaseRoutes.NeedTeam.Handle("/members/{offset:[0-9]+}/{limit:[0-9]+}", ApiUserRequired(getTeamMembers)).Methods("GET") + BaseRoutes.NeedTeam.Handle("/members/ids", ApiUserRequired(getTeamMembersByIds)).Methods("POST") + BaseRoutes.NeedTeam.Handle("/members/{user_id:[A-Za-z0-9]+}", ApiUserRequired(getTeamMember)).Methods("GET") BaseRoutes.NeedTeam.Handle("/update", ApiUserRequired(updateTeam)).Methods("POST") BaseRoutes.NeedTeam.Handle("/update_member_roles", ApiUserRequired(updateMemberRoles)).Methods("POST") @@ -305,7 +308,9 @@ func JoinUserToTeam(team *model.Team, user *model.User) *model.AppError { InvalidateCacheForUser(user.Id) // This message goes to everyone, so the teamId, channelId and userId are irrelevant - go Publish(model.NewWebSocketEvent(model.WEBSOCKET_EVENT_NEW_USER, "", "", "", nil)) + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_NEW_USER, "", "", "", nil) + message.Add("user_id", user.Id) + go Publish(message) return nil } @@ -335,11 +340,10 @@ func LeaveTeam(team *model.Team, user *model.User) *model.AppError { for _, channel := range channelMembers.Channels { if channel.Type != model.CHANNEL_DIRECT { + Srv.Store.User().InvalidateProfilesInChannelCache(channel.Id) if result := <-Srv.Store.Channel().RemoveMember(channel.Id, user.Id); result.Err != nil { return result.Err } - - InvalidateCacheForChannel(channel.Id) } } @@ -889,6 +893,25 @@ func getMyTeam(c *Context, w http.ResponseWriter, r *http.Request) { } } +func getTeamStats(c *Context, w http.ResponseWriter, r *http.Request) { + if c.Session.GetTeamByTeamId(c.TeamId) == nil { + if !HasPermissionToContext(c, model.PERMISSION_MANAGE_SYSTEM) { + return + } + } + + if result := <-Srv.Store.Team().GetMemberCount(c.TeamId); result.Err != nil { + c.Err = result.Err + return + } else { + stats := &model.TeamStats{} + stats.MemberCount = result.Data.(int64) + stats.TeamId = c.TeamId + w.Write([]byte(stats.ToJson())) + return + } +} + func importTeam(c *Context, w http.ResponseWriter, r *http.Request) { if !HasPermissionToCurrentTeamContext(c, model.PERMISSION_IMPORT_TEAM) { c.Err = model.NewLocAppError("importTeam", "api.team.import_team.admin.app_error", nil, "userId="+c.Session.UserId) @@ -982,17 +1005,76 @@ func getInviteInfo(c *Context, w http.ResponseWriter, r *http.Request) { } } -func getMembers(c *Context, w http.ResponseWriter, r *http.Request) { +func getTeamMembers(c *Context, w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) - id := params["id"] - if c.Session.GetTeamByTeamId(id) == nil { - if !HasPermissionToTeamContext(c, id, model.PERMISSION_MANAGE_SYSTEM) { + offset, err := strconv.Atoi(params["offset"]) + if err != nil { + c.SetInvalidParam("getTeamMembers", "offset") + return + } + + limit, err := strconv.Atoi(params["limit"]) + if err != nil { + c.SetInvalidParam("getTeamMembers", "limit") + return + } + + if c.Session.GetTeamByTeamId(c.TeamId) == nil { + if !HasPermissionToTeamContext(c, c.TeamId, model.PERMISSION_MANAGE_SYSTEM) { + return + } + } + + if result := <-Srv.Store.Team().GetMembers(c.TeamId, offset, limit); result.Err != nil { + c.Err = result.Err + return + } else { + members := result.Data.([]*model.TeamMember) + w.Write([]byte(model.TeamMembersToJson(members))) + return + } +} + +func getTeamMember(c *Context, w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + + userId := params["user_id"] + if len(userId) < 26 { + c.SetInvalidParam("getTeamMember", "user_id") + return + } + + if c.Session.GetTeamByTeamId(c.TeamId) == nil { + if !HasPermissionToTeamContext(c, c.TeamId, model.PERMISSION_MANAGE_SYSTEM) { + return + } + } + + if result := <-Srv.Store.Team().GetMember(c.TeamId, userId); result.Err != nil { + c.Err = result.Err + return + } else { + member := result.Data.(model.TeamMember) + w.Write([]byte(member.ToJson())) + return + } +} + +func getTeamMembersByIds(c *Context, w http.ResponseWriter, r *http.Request) { + userIds := model.ArrayFromJson(r.Body) + if len(userIds) == 0 { + c.SetInvalidParam("getTeamMembersByIds", "user_ids") + return + } + + if c.Session.GetTeamByTeamId(c.TeamId) == nil { + if !HasPermissionToTeamContext(c, c.TeamId, model.PERMISSION_MANAGE_SYSTEM) { return } } - if result := <-Srv.Store.Team().GetMembers(id); result.Err != nil { + if result := <-Srv.Store.Team().GetMembersByIds(c.TeamId, userIds); result.Err != nil { c.Err = result.Err return } else { diff --git a/api/team_test.go b/api/team_test.go index 1a66a826f..a58710145 100644 --- a/api/team_test.go +++ b/api/team_test.go @@ -560,14 +560,80 @@ func TestGetMyTeam(t *testing.T) { func TestGetTeamMembers(t *testing.T) { th := Setup().InitBasic() - if result, err := th.BasicClient.GetTeamMembers(th.BasicTeam.Id); err != nil { + if result, err := th.BasicClient.GetTeamMembers(th.BasicTeam.Id, 0, 100); err != nil { t.Fatal(err) } else { members := result.Data.([]*model.TeamMember) - if members == nil { + if len(members) == 0 { + t.Fatal("should have results") + } + } + + if _, err := th.BasicClient.GetTeamMembers("junk", 0, 100); err == nil { + t.Fatal("should have errored - bad team id") + } +} + +func TestGetTeamMember(t *testing.T) { + th := Setup().InitBasic() + + if result, err := th.BasicClient.GetTeamMember(th.BasicTeam.Id, th.BasicUser.Id); err != nil { + t.Fatal(err) + } else { + member := result.Data.(*model.TeamMember) + if member == nil { t.Fatal("should be valid") } } + + if _, err := th.BasicClient.GetTeamMember("junk", th.BasicUser.Id); err == nil { + t.Fatal("should have errored - bad team id") + } + + if _, err := th.BasicClient.GetTeamMember(th.BasicTeam.Id, ""); err == nil { + t.Fatal("should have errored - blank user id") + } + + if _, err := th.BasicClient.GetTeamMember(th.BasicTeam.Id, "junk"); err == nil { + t.Fatal("should have errored - bad user id") + } + + if _, err := th.BasicClient.GetTeamMember(th.BasicTeam.Id, "12345678901234567890123456"); err == nil { + t.Fatal("should have errored - bad user id") + } +} + +func TestGetTeamMembersByIds(t *testing.T) { + th := Setup().InitBasic() + + if result, err := th.BasicClient.GetTeamMembersByIds(th.BasicTeam.Id, []string{th.BasicUser.Id}); err != nil { + t.Fatal(err) + } else { + member := result.Data.([]*model.TeamMember)[0] + if member.UserId != th.BasicUser.Id { + t.Fatal("user id did not match") + } + if member.TeamId != th.BasicTeam.Id { + t.Fatal("team id did not match") + } + } + + if result, err := th.BasicClient.GetTeamMembersByIds(th.BasicTeam.Id, []string{th.BasicUser.Id, th.BasicUser2.Id, model.NewId()}); err != nil { + t.Fatal(err) + } else { + members := result.Data.([]*model.TeamMember) + if len(members) != 2 { + t.Fatal("length should have been 2") + } + } + + if _, err := th.BasicClient.GetTeamMembersByIds("junk", []string{th.BasicUser.Id}); err == nil { + t.Fatal("should have errored - bad team id") + } + + if _, err := th.BasicClient.GetTeamMembersByIds(th.BasicTeam.Id, []string{}); err == nil { + t.Fatal("should have errored - empty user ids") + } } func TestUpdateTeamMemberRoles(t *testing.T) { @@ -632,3 +698,42 @@ func TestUpdateTeamMemberRoles(t *testing.T) { t.Fatal("Should have worked, user is team admin") } } + +func TestGetTeamStats(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + Client := th.BasicClient + + if result, err := th.SystemAdminClient.GetTeamStats(th.BasicTeam.Id); err != nil { + t.Fatal(err) + } else { + if result.Data.(*model.TeamStats).MemberCount != 2 { + t.Fatal("wrong count") + } + } + + if result, err := th.SystemAdminClient.GetTeamStats("junk"); err != nil { + t.Fatal(err) + } else { + if result.Data.(*model.TeamStats).MemberCount != 0 { + t.Fatal("wrong count") + } + } + + if result, err := th.SystemAdminClient.GetTeamStats(th.BasicTeam.Id); err != nil { + t.Fatal(err) + } else { + if result.Data.(*model.TeamStats).MemberCount != 2 { + t.Fatal("wrong count") + } + } + + user := model.User{Email: "success+" + model.NewId() + "@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "passwd1"} + ruser, _ := Client.CreateUser(&user, "") + store.Must(Srv.Store.User().VerifyEmail(ruser.Data.(*model.User).Id)) + + Client.Login(user.Email, user.Password) + + if _, err := Client.GetTeamStats(th.BasicTeam.Id); err == nil { + t.Fatal("should have errored - not on team") + } +} diff --git a/api/user.go b/api/user.go index 5f7e3ad10..12e57a33f 100644 --- a/api/user.go +++ b/api/user.go @@ -53,9 +53,15 @@ func InitUser() { BaseRoutes.Users.Handle("/newimage", ApiUserRequired(uploadProfileImage)).Methods("POST") BaseRoutes.Users.Handle("/me", ApiUserRequired(getMe)).Methods("GET") BaseRoutes.Users.Handle("/initial_load", ApiAppHandler(getInitialLoad)).Methods("GET") - BaseRoutes.Users.Handle("/direct_profiles", ApiUserRequired(getDirectProfiles)).Methods("GET") - BaseRoutes.Users.Handle("/profiles/{id:[A-Za-z0-9]+}", ApiUserRequired(getProfiles)).Methods("GET") - BaseRoutes.Users.Handle("/profiles_for_dm_list/{id:[A-Za-z0-9]+}", ApiUserRequired(getProfilesForDirectMessageList)).Methods("GET") + BaseRoutes.Users.Handle("/{offset:[0-9]+}/{limit:[0-9]+}", ApiUserRequired(getProfiles)).Methods("GET") + BaseRoutes.NeedTeam.Handle("/users/{offset:[0-9]+}/{limit:[0-9]+}", ApiUserRequired(getProfilesInTeam)).Methods("GET") + BaseRoutes.NeedChannel.Handle("/users/{offset:[0-9]+}/{limit:[0-9]+}", ApiUserRequired(getProfilesInChannel)).Methods("GET") + BaseRoutes.NeedChannel.Handle("/users/not_in_channel/{offset:[0-9]+}/{limit:[0-9]+}", ApiUserRequired(getProfilesNotInChannel)).Methods("GET") + BaseRoutes.Users.Handle("/search", ApiUserRequired(searchUsers)).Methods("POST") + BaseRoutes.Users.Handle("/ids", ApiUserRequired(getProfilesByIds)).Methods("POST") + + BaseRoutes.NeedTeam.Handle("/users/autocomplete", ApiUserRequired(autocompleteUsersInTeam)).Methods("GET") + BaseRoutes.NeedChannel.Handle("/users/autocomplete", ApiUserRequired(autocompleteUsersInChannel)).Methods("GET") BaseRoutes.Users.Handle("/mfa", ApiAppHandler(checkMfa)).Methods("POST") BaseRoutes.Users.Handle("/generate_mfa_qr", ApiUserRequiredTrustRequester(generateMfaQrCode)).Methods("GET") @@ -270,7 +276,9 @@ func CreateUser(user *model.User) (*model.User, *model.AppError) { ruser.Sanitize(map[string]bool{}) // This message goes to everyone, so the teamId, channelId and userId are irrelevant - go Publish(model.NewWebSocketEvent(model.WEBSOCKET_EVENT_NEW_USER, "", "", "", nil)) + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_NEW_USER, "", "", "", nil) + message.Add("user_id", ruser.Id) + go Publish(message) return ruser, nil } @@ -379,7 +387,7 @@ func sendWelcomeEmail(c *Context, userId string, email string, siteURL string, v func addDirectChannels(teamId string, user *model.User) { var profiles map[string]*model.User - if result := <-Srv.Store.User().GetProfiles(teamId); result.Err != nil { + if result := <-Srv.Store.User().GetProfiles(teamId, 0, 100); result.Err != nil { l4g.Error(utils.T("api.user.add_direct_channels_and_forget.failed.error"), user.Id, teamId, result.Err.Error()) return } else { @@ -875,7 +883,6 @@ func getInitialLoad(c *Context, w http.ResponseWriter, r *http.Request) { uchan := Srv.Store.User().Get(c.Session.UserId) pchan := Srv.Store.Preference().GetAll(c.Session.UserId) tchan := Srv.Store.Team().GetTeamsByUserId(c.Session.UserId) - dpchan := Srv.Store.User().GetDirectProfiles(c.Session.UserId) il.TeamMembers = c.Session.TeamMembers @@ -904,19 +911,6 @@ func getInitialLoad(c *Context, w http.ResponseWriter, r *http.Request) { team.Sanitize() } } - - if dp := <-dpchan; dp.Err != nil { - c.Err = dp.Err - return - } else { - profiles := dp.Data.(map[string]*model.User) - - for k, p := range profiles { - profiles[k] = sanitizeProfile(c, p) - } - - il.DirectProfiles = profiles - } } if cchan != nil { @@ -960,25 +954,27 @@ func getUser(c *Context, w http.ResponseWriter, r *http.Request) { } } -func getProfilesForDirectMessageList(c *Context, w http.ResponseWriter, r *http.Request) { +func getProfiles(c *Context, w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) - id := params["id"] - var pchan store.StoreChannel + offset, err := strconv.Atoi(params["offset"]) + if err != nil { + c.SetInvalidParam("getProfiles", "offset") + return + } - if *utils.Cfg.TeamSettings.RestrictDirectMessage == model.DIRECT_MESSAGE_TEAM { - if c.Session.GetTeamByTeamId(id) == nil { - if !HasPermissionToContext(c, model.PERMISSION_MANAGE_SYSTEM) { - return - } - } + limit, err := strconv.Atoi(params["limit"]) + if err != nil { + c.SetInvalidParam("getProfiles", "limit") + return + } - pchan = Srv.Store.User().GetProfiles(id) - } else { - pchan = Srv.Store.User().GetAllProfiles() + etag := (<-Srv.Store.User().GetEtagForAllProfiles()).Data.(string) + if HandleEtag(etag, w, r) { + return } - if result := <-pchan; result.Err != nil { + if result := <-Srv.Store.User().GetAllProfiles(offset, limit); result.Err != nil { c.Err = result.Err return } else { @@ -988,26 +984,39 @@ func getProfilesForDirectMessageList(c *Context, w http.ResponseWriter, r *http. profiles[k] = sanitizeProfile(c, p) } + w.Header().Set(model.HEADER_ETAG_SERVER, etag) w.Write([]byte(model.UserMapToJson(profiles))) } } -func getProfiles(c *Context, w http.ResponseWriter, r *http.Request) { +func getProfilesInTeam(c *Context, w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) - id := params["id"] + teamId := params["team_id"] - if c.Session.GetTeamByTeamId(id) == nil { + if c.Session.GetTeamByTeamId(teamId) == nil { if !HasPermissionToContext(c, model.PERMISSION_MANAGE_SYSTEM) { return } } - etag := (<-Srv.Store.User().GetEtagForProfiles(id)).Data.(string) + offset, err := strconv.Atoi(params["offset"]) + if err != nil { + c.SetInvalidParam("getProfilesInTeam", "offset") + return + } + + limit, err := strconv.Atoi(params["limit"]) + if err != nil { + c.SetInvalidParam("getProfilesInTeam", "limit") + return + } + + etag := (<-Srv.Store.User().GetEtagForProfiles(teamId)).Data.(string) if HandleEtag(etag, w, r) { return } - if result := <-Srv.Store.User().GetProfiles(id); result.Err != nil { + if result := <-Srv.Store.User().GetProfiles(teamId, offset, limit); result.Err != nil { c.Err = result.Err return } else { @@ -1022,13 +1031,73 @@ func getProfiles(c *Context, w http.ResponseWriter, r *http.Request) { } } -func getDirectProfiles(c *Context, w http.ResponseWriter, r *http.Request) { - etag := (<-Srv.Store.User().GetEtagForDirectProfiles(c.Session.UserId)).Data.(string) - if HandleEtag(etag, w, r) { +func getProfilesInChannel(c *Context, w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + channelId := params["channel_id"] + + if c.Session.GetTeamByTeamId(c.TeamId) == nil { + if !HasPermissionToContext(c, model.PERMISSION_MANAGE_SYSTEM) { + return + } + } + + if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_READ_CHANNEL) { + return + } + + offset, err := strconv.Atoi(params["offset"]) + if err != nil { + c.SetInvalidParam("getProfiles", "offset") return } - if result := <-Srv.Store.User().GetDirectProfiles(c.Session.UserId); result.Err != nil { + limit, err := strconv.Atoi(params["limit"]) + if err != nil { + c.SetInvalidParam("getProfiles", "limit") + return + } + + if result := <-Srv.Store.User().GetProfilesInChannel(channelId, offset, limit, false); result.Err != nil { + c.Err = result.Err + return + } else { + profiles := result.Data.(map[string]*model.User) + + for k, p := range profiles { + profiles[k] = sanitizeProfile(c, p) + } + + w.Write([]byte(model.UserMapToJson(profiles))) + } +} + +func getProfilesNotInChannel(c *Context, w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + channelId := params["channel_id"] + + if c.Session.GetTeamByTeamId(c.TeamId) == nil { + if !HasPermissionToContext(c, model.PERMISSION_MANAGE_SYSTEM) { + return + } + } + + if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_READ_CHANNEL) { + return + } + + offset, err := strconv.Atoi(params["offset"]) + if err != nil { + c.SetInvalidParam("getProfiles", "offset") + return + } + + limit, err := strconv.Atoi(params["limit"]) + if err != nil { + c.SetInvalidParam("getProfiles", "limit") + return + } + + if result := <-Srv.Store.User().GetProfilesNotInChannel(c.TeamId, channelId, offset, limit); result.Err != nil { c.Err = result.Err return } else { @@ -1038,7 +1107,6 @@ func getDirectProfiles(c *Context, w http.ResponseWriter, r *http.Request) { profiles[k] = sanitizeProfile(c, p) } - w.Header().Set(model.HEADER_ETAG_SERVER, etag) w.Write([]byte(model.UserMapToJson(profiles))) } } @@ -2522,3 +2590,152 @@ func sanitizeProfile(c *Context, user *model.User) *model.User { return user } + +func searchUsers(c *Context, w http.ResponseWriter, r *http.Request) { + props := model.MapFromJson(r.Body) + + term := props["term"] + if len(term) == 0 { + c.SetInvalidParam("searchUsers", "term") + return + } + + teamId := props["team_id"] + inChannelId := props["in_channel"] + notInChannelId := props["not_in_channel"] + + if inChannelId != "" && !HasPermissionToChannelContext(c, inChannelId, model.PERMISSION_READ_CHANNEL) { + return + } + + if notInChannelId != "" && !HasPermissionToChannelContext(c, notInChannelId, model.PERMISSION_READ_CHANNEL) { + return + } + + var uchan store.StoreChannel + if inChannelId != "" { + uchan = Srv.Store.User().SearchInChannel(inChannelId, term, store.USER_SEARCH_TYPE_USERNAME) + } else if notInChannelId != "" { + uchan = Srv.Store.User().SearchNotInChannel(teamId, notInChannelId, term, store.USER_SEARCH_TYPE_USERNAME) + } else { + uchan = Srv.Store.User().Search(teamId, term, store.USER_SEARCH_TYPE_USERNAME) + } + + if result := <-uchan; result.Err != nil { + c.Err = result.Err + return + } else { + profiles := result.Data.([]*model.User) + + for _, p := range profiles { + sanitizeProfile(c, p) + } + + w.Write([]byte(model.UserListToJson(profiles))) + } +} + +func getProfilesByIds(c *Context, w http.ResponseWriter, r *http.Request) { + userIds := model.ArrayFromJson(r.Body) + + if len(userIds) == 0 { + c.SetInvalidParam("getProfilesByIds", "user_ids") + return + } + + if result := <-Srv.Store.User().GetProfileByIds(userIds); result.Err != nil { + c.Err = result.Err + return + } else { + profiles := result.Data.(map[string]*model.User) + + for _, p := range profiles { + sanitizeProfile(c, p) + } + + w.Write([]byte(model.UserMapToJson(profiles))) + } +} + +func autocompleteUsersInChannel(c *Context, w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + channelId := params["channel_id"] + teamId := params["team_id"] + + term := r.URL.Query().Get("term") + + if c.Session.GetTeamByTeamId(teamId) == nil { + if !HasPermissionToContext(c, model.PERMISSION_MANAGE_SYSTEM) { + return + } + } + + if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_READ_CHANNEL) { + return + } + + uchan := Srv.Store.User().SearchInChannel(channelId, term, store.USER_SEARCH_TYPE_ALL) + nuchan := Srv.Store.User().SearchNotInChannel(teamId, channelId, term, store.USER_SEARCH_TYPE_ALL) + + autocomplete := &model.UserAutocompleteInChannel{} + + if result := <-uchan; result.Err != nil { + c.Err = result.Err + return + } else { + profiles := result.Data.([]*model.User) + + for _, p := range profiles { + sanitizeProfile(c, p) + } + + autocomplete.InChannel = profiles + } + + if result := <-nuchan; result.Err != nil { + c.Err = result.Err + return + } else { + profiles := result.Data.([]*model.User) + + for _, p := range profiles { + sanitizeProfile(c, p) + } + + autocomplete.OutOfChannel = profiles + } + + w.Write([]byte(autocomplete.ToJson())) +} + +func autocompleteUsersInTeam(c *Context, w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + teamId := params["team_id"] + + term := r.URL.Query().Get("term") + + if c.Session.GetTeamByTeamId(teamId) == nil { + if !HasPermissionToContext(c, model.PERMISSION_MANAGE_SYSTEM) { + return + } + } + + uchan := Srv.Store.User().Search(teamId, term, store.USER_SEARCH_TYPE_ALL) + + autocomplete := &model.UserAutocompleteInTeam{} + + if result := <-uchan; result.Err != nil { + c.Err = result.Err + return + } else { + profiles := result.Data.([]*model.User) + + for _, p := range profiles { + sanitizeProfile(c, p) + } + + autocomplete.InTeam = profiles + } + + w.Write([]byte(autocomplete.ToJson())) +} diff --git a/api/user_test.go b/api/user_test.go index 3800f5d9e..3b6fcb1fb 100644 --- a/api/user_test.go +++ b/api/user_test.go @@ -435,7 +435,7 @@ func TestGetUser(t *testing.T) { } } - if userMap, err := Client.GetProfiles(rteam.Data.(*model.Team).Id, ""); err != nil { + if userMap, err := Client.GetProfilesInTeam(rteam.Data.(*model.Team).Id, 0, 100, ""); err != nil { t.Fatal(err) } else if len(userMap.Data.(map[string]*model.User)) != 2 { t.Fatal("should have been 2") @@ -444,7 +444,7 @@ func TestGetUser(t *testing.T) { } else { // test etag caching - if cache_result, err := Client.GetProfiles(rteam.Data.(*model.Team).Id, userMap.Etag); err != nil { + if cache_result, err := Client.GetProfilesInTeam(rteam.Data.(*model.Team).Id, 0, 100, userMap.Etag); err != nil { t.Fatal(err) } else if cache_result.Data.(map[string]*model.User) != nil { t.Log(cache_result.Data) @@ -452,7 +452,25 @@ func TestGetUser(t *testing.T) { } } - if _, err := Client.GetProfiles(rteam2.Data.(*model.Team).Id, ""); err == nil { + if userMap, err := Client.GetProfilesInTeam(rteam.Data.(*model.Team).Id, 0, 1, ""); err != nil { + t.Fatal(err) + } else if len(userMap.Data.(map[string]*model.User)) != 1 { + t.Fatal("should have been 1") + } + + if userMap, err := Client.GetProfilesInTeam(rteam.Data.(*model.Team).Id, 1, 1, ""); err != nil { + t.Fatal(err) + } else if len(userMap.Data.(map[string]*model.User)) != 1 { + t.Fatal("should have been 1") + } + + if userMap, err := Client.GetProfilesInTeam(rteam.Data.(*model.Team).Id, 10, 10, ""); err != nil { + t.Fatal(err) + } else if len(userMap.Data.(map[string]*model.User)) != 0 { + t.Fatal("should have been 0") + } + + if _, err := Client.GetProfilesInTeam(rteam2.Data.(*model.Team).Id, 0, 100, ""); err == nil { t.Fatal("shouldn't have access") } @@ -468,12 +486,12 @@ func TestGetUser(t *testing.T) { Client.Login(user.Email, "passwd1") - if _, err := Client.GetProfiles(rteam2.Data.(*model.Team).Id, ""); err != nil { + if _, err := Client.GetProfilesInTeam(rteam2.Data.(*model.Team).Id, 0, 100, ""); err != nil { t.Fatal(err) } } -func TestGetDirectProfiles(t *testing.T) { +func TestGetProfiles(t *testing.T) { th := Setup().InitBasic() th.BasicClient.Must(th.BasicClient.CreateDirectChannel(th.BasicUser2.Id)) @@ -485,41 +503,42 @@ func TestGetDirectProfiles(t *testing.T) { utils.Cfg.PrivacySettings.ShowEmailAddress = true - if result, err := th.BasicClient.GetDirectProfiles(""); err != nil { + if result, err := th.BasicClient.GetProfiles(0, 100, ""); err != nil { t.Fatal(err) } else { users := result.Data.(map[string]*model.User) - if len(users) != 1 { + if len(users) < 1 { t.Fatal("map was wrong length") } - if users[th.BasicUser2.Id] == nil { - t.Fatal("missing expected user") - } - for _, user := range users { if user.Email == "" { t.Fatal("problem with show email") } } + + // test etag caching + if cache_result, err := th.BasicClient.GetProfiles(0, 100, result.Etag); err != nil { + t.Fatal(err) + } else if cache_result.Data.(map[string]*model.User) != nil { + t.Log(cache_result.Etag) + t.Log(result.Etag) + t.Fatal("cache should be empty") + } } utils.Cfg.PrivacySettings.ShowEmailAddress = false - if result, err := th.BasicClient.GetDirectProfiles(""); err != nil { + if result, err := th.BasicClient.GetProfiles(0, 100, ""); err != nil { t.Fatal(err) } else { users := result.Data.(map[string]*model.User) - if len(users) != 1 { + if len(users) < 1 { t.Fatal("map was wrong length") } - if users[th.BasicUser2.Id] == nil { - t.Fatal("missing expected user") - } - for _, user := range users { if user.Email != "" { t.Fatal("problem with show email") @@ -528,11 +547,9 @@ func TestGetDirectProfiles(t *testing.T) { } } -func TestGetProfilesForDirectMessageList(t *testing.T) { +func TestGetProfilesByIds(t *testing.T) { th := Setup().InitBasic() - th.BasicClient.Must(th.BasicClient.CreateDirectChannel(th.BasicUser2.Id)) - prevShowEmail := utils.Cfg.PrivacySettings.ShowEmailAddress defer func() { utils.Cfg.PrivacySettings.ShowEmailAddress = prevShowEmail @@ -540,12 +557,12 @@ func TestGetProfilesForDirectMessageList(t *testing.T) { utils.Cfg.PrivacySettings.ShowEmailAddress = true - if result, err := th.BasicClient.GetProfilesForDirectMessageList(th.BasicTeam.Id); err != nil { + if result, err := th.BasicClient.GetProfilesByIds([]string{th.BasicUser.Id}); err != nil { t.Fatal(err) } else { users := result.Data.(map[string]*model.User) - if len(users) < 1 { + if len(users) != 1 { t.Fatal("map was wrong length") } @@ -558,12 +575,12 @@ func TestGetProfilesForDirectMessageList(t *testing.T) { utils.Cfg.PrivacySettings.ShowEmailAddress = false - if result, err := th.BasicClient.GetProfilesForDirectMessageList(th.BasicTeam.Id); err != nil { + if result, err := th.BasicClient.GetProfilesByIds([]string{th.BasicUser.Id}); err != nil { t.Fatal(err) } else { users := result.Data.(map[string]*model.User) - if len(users) < 1 { + if len(users) != 1 { t.Fatal("map was wrong length") } @@ -573,6 +590,16 @@ func TestGetProfilesForDirectMessageList(t *testing.T) { } } } + + if result, err := th.BasicClient.GetProfilesByIds([]string{th.BasicUser.Id, th.BasicUser2.Id}); err != nil { + t.Fatal(err) + } else { + users := result.Data.(map[string]*model.User) + + if len(users) != 2 { + t.Fatal("map was wrong length") + } + } } func TestGetAudits(t *testing.T) { @@ -1837,3 +1864,366 @@ func TestUserTyping(t *testing.T) { t.Fatal("did not receive typing event") } } + +func TestGetProfilesInChannel(t *testing.T) { + th := Setup().InitBasic() + Client := th.BasicClient + + prevShowEmail := utils.Cfg.PrivacySettings.ShowEmailAddress + defer func() { + utils.Cfg.PrivacySettings.ShowEmailAddress = prevShowEmail + }() + + utils.Cfg.PrivacySettings.ShowEmailAddress = true + + if result, err := Client.GetProfilesInChannel(th.BasicChannel.Id, 0, 100, ""); err != nil { + t.Fatal(err) + } else { + users := result.Data.(map[string]*model.User) + + if len(users) < 1 { + t.Fatal("map was wrong length") + } + + for _, user := range users { + if user.Email == "" { + t.Fatal("problem with show email") + } + } + } + + th.LoginBasic2() + + if _, err := Client.GetProfilesInChannel(th.BasicChannel.Id, 0, 100, ""); err == nil { + t.Fatal("should not have access") + } + + Client.Must(Client.JoinChannel(th.BasicChannel.Id)) + + utils.Cfg.PrivacySettings.ShowEmailAddress = false + + if result, err := Client.GetProfilesInChannel(th.BasicChannel.Id, 0, 100, ""); err != nil { + t.Fatal(err) + } else { + users := result.Data.(map[string]*model.User) + + if len(users) < 1 { + t.Fatal("map was wrong length") + } + + found := false + for _, user := range users { + if user.Email != "" { + t.Fatal("problem with show email") + } + if user.Id == th.BasicUser2.Id { + found = true + } + } + + if !found { + t.Fatal("should have found profile") + } + } + + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "passwd1"} + Client.Must(Client.CreateUser(&user, "")) + + Client.Login(user.Email, "passwd1") + Client.SetTeamId("junk") + + if _, err := Client.GetProfilesInChannel(th.BasicChannel.Id, 0, 100, ""); err == nil { + t.Fatal("should not have access") + } +} + +func TestGetProfilesNotInChannel(t *testing.T) { + th := Setup().InitBasic() + Client := th.BasicClient + + prevShowEmail := utils.Cfg.PrivacySettings.ShowEmailAddress + defer func() { + utils.Cfg.PrivacySettings.ShowEmailAddress = prevShowEmail + }() + + utils.Cfg.PrivacySettings.ShowEmailAddress = true + + if result, err := Client.GetProfilesNotInChannel(th.BasicChannel.Id, 0, 100, ""); err != nil { + t.Fatal(err) + } else { + users := result.Data.(map[string]*model.User) + + if len(users) < 1 { + t.Fatal("map was wrong length") + } + + found := false + for _, user := range users { + if user.Email == "" { + t.Fatal("problem with show email") + } + if user.Id == th.BasicUser2.Id { + found = true + } + } + + if !found { + t.Fatal("should have found profile") + } + } + + user := &model.User{Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "passwd1"} + user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User) + LinkUserToTeam(user, th.BasicTeam) + + th.LoginBasic2() + + if _, err := Client.GetProfilesNotInChannel(th.BasicChannel.Id, 0, 100, ""); err == nil { + t.Fatal("should not have access") + } + + Client.Must(Client.JoinChannel(th.BasicChannel.Id)) + + utils.Cfg.PrivacySettings.ShowEmailAddress = false + + if result, err := Client.GetProfilesNotInChannel(th.BasicChannel.Id, 0, 100, ""); err != nil { + t.Fatal(err) + } else { + users := result.Data.(map[string]*model.User) + + if len(users) < 1 { + t.Fatal("map was wrong length") + } + + found := false + for _, user := range users { + if user.Email != "" { + t.Fatal("problem with show email") + } + if user.Id == th.BasicUser2.Id { + found = true + } + } + + if found { + t.Fatal("should not have found profile") + } + } + + user2 := model.User{Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "passwd1"} + Client.Must(Client.CreateUser(&user2, "")) + + Client.Login(user2.Email, "passwd1") + Client.SetTeamId(th.BasicTeam.Id) + + if _, err := Client.GetProfilesNotInChannel(th.BasicChannel.Id, 0, 100, ""); err == nil { + t.Fatal("should not have access") + } +} + +func TestSearchUsers(t *testing.T) { + th := Setup().InitBasic() + Client := th.BasicClient + + if result, err := Client.SearchUsers(th.BasicUser.Username, "", map[string]string{}); err != nil { + t.Fatal(err) + } else { + users := result.Data.([]*model.User) + + found := false + for _, user := range users { + if user.Id == th.BasicUser.Id { + found = true + } + } + + if !found { + t.Fatal("should have found profile") + } + } + + if result, err := Client.SearchUsers(th.BasicUser.Username, "", map[string]string{"in_channel": th.BasicChannel.Id}); err != nil { + t.Fatal(err) + } else { + users := result.Data.([]*model.User) + + if len(users) != 1 { + t.Fatal("map was wrong length") + } + + found := false + for _, user := range users { + if user.Id == th.BasicUser.Id { + found = true + } + } + + if !found { + t.Fatal("should have found profile") + } + } + + if result, err := Client.SearchUsers(th.BasicUser2.Username, "", map[string]string{"not_in_channel": th.BasicChannel.Id}); err != nil { + t.Fatal(err) + } else { + users := result.Data.([]*model.User) + + if len(users) != 1 { + t.Fatal("map was wrong length") + } + + found1 := false + found2 := false + for _, user := range users { + if user.Id == th.BasicUser.Id { + found1 = true + } else if user.Id == th.BasicUser2.Id { + found2 = true + } + } + + if found1 { + t.Fatal("should not have found profile") + } + if !found2 { + t.Fatal("should have found profile") + } + } + + if result, err := Client.SearchUsers(th.BasicUser2.Username, th.BasicTeam.Id, map[string]string{"not_in_channel": th.BasicChannel.Id}); err != nil { + t.Fatal(err) + } else { + users := result.Data.([]*model.User) + + if len(users) != 1 { + t.Fatal("map was wrong length") + } + + found1 := false + found2 := false + for _, user := range users { + if user.Id == th.BasicUser.Id { + found1 = true + } else if user.Id == th.BasicUser2.Id { + found2 = true + } + } + + if found1 { + t.Fatal("should not have found profile") + } + if !found2 { + t.Fatal("should have found profile") + } + } + + if result, err := Client.SearchUsers(th.BasicUser.Username, "junk", map[string]string{"not_in_channel": th.BasicChannel.Id}); err != nil { + t.Fatal(err) + } else { + users := result.Data.([]*model.User) + + if len(users) != 0 { + t.Fatal("map was wrong length") + } + } + + th.LoginBasic2() + + if result, err := Client.SearchUsers(th.BasicUser.Username, "", map[string]string{}); err != nil { + t.Fatal(err) + } else { + users := result.Data.([]*model.User) + + found := false + for _, user := range users { + if user.Id == th.BasicUser.Id { + found = true + } + } + + if !found { + t.Fatal("should have found profile") + } + } + + if _, err := Client.SearchUsers("", "", map[string]string{}); err == nil { + t.Fatal("should have errored - blank term") + } + + if _, err := Client.SearchUsers(th.BasicUser.Username, "", map[string]string{"in_channel": th.BasicChannel.Id}); err == nil { + t.Fatal("should not have access") + } + + if _, err := Client.SearchUsers(th.BasicUser.Username, "", map[string]string{"not_in_channel": th.BasicChannel.Id}); err == nil { + t.Fatal("should not have access") + } +} + +func TestAutocompleteUsers(t *testing.T) { + th := Setup().InitBasic() + Client := th.BasicClient + + if result, err := Client.AutocompleteUsersInTeam(th.BasicUser.Username); err != nil { + t.Fatal(err) + } else { + autocomplete := result.Data.(*model.UserAutocompleteInTeam) + if len(autocomplete.InTeam) != 1 { + t.Fatal("should have returned 1 user in") + } + } + + if result, err := Client.AutocompleteUsersInTeam(th.BasicUser.Username[0:5]); err != nil { + t.Fatal(err) + } else { + autocomplete := result.Data.(*model.UserAutocompleteInTeam) + if len(autocomplete.InTeam) < 1 { + t.Fatal("should have returned at least 1 user in") + } + } + + if result, err := Client.AutocompleteUsersInChannel(th.BasicUser.Username, th.BasicChannel.Id); err != nil { + t.Fatal(err) + } else { + autocomplete := result.Data.(*model.UserAutocompleteInChannel) + if len(autocomplete.InChannel) != 1 { + t.Fatal("should have returned 1 user in") + } + if len(autocomplete.OutOfChannel) != 0 { + t.Fatal("should have returned no users out") + } + } + + if result, err := Client.AutocompleteUsersInChannel("", th.BasicChannel.Id); err != nil { + t.Fatal(err) + } else { + autocomplete := result.Data.(*model.UserAutocompleteInChannel) + if len(autocomplete.InChannel) != 1 && autocomplete.InChannel[0].Id != th.BasicUser2.Id { + t.Fatal("should have returned at 1 user in") + } + if len(autocomplete.OutOfChannel) != 1 && autocomplete.OutOfChannel[0].Id != th.BasicUser2.Id { + t.Fatal("should have returned 1 user out") + } + } + + if result, err := Client.AutocompleteUsersInTeam(""); err != nil { + t.Fatal(err) + } else { + autocomplete := result.Data.(*model.UserAutocompleteInTeam) + if len(autocomplete.InTeam) != 2 { + t.Fatal("should have returned 2 users in") + } + } + + if _, err := Client.AutocompleteUsersInChannel("", "junk"); err == nil { + t.Fatal("should have errored - bad channel id") + } + + Client.SetTeamId("junk") + if _, err := Client.AutocompleteUsersInChannel("", th.BasicChannel.Id); err == nil { + t.Fatal("should have errored - bad team id") + } + + if _, err := Client.AutocompleteUsersInTeam(""); err == nil { + t.Fatal("should have errored - bad team id") + } +} diff --git a/api/web_conn.go b/api/web_conn.go index f4bd493bb..7f3c1f875 100644 --- a/api/web_conn.go +++ b/api/web_conn.go @@ -4,54 +4,52 @@ package api import ( + "fmt" "time" "github.com/mattermost/platform/model" + l4g "github.com/alecthomas/log4go" "github.com/gorilla/websocket" goi18n "github.com/nicksnyder/go-i18n/i18n" ) const ( - WRITE_WAIT = 10 * time.Second - PONG_WAIT = 60 * time.Second - PING_PERIOD = (PONG_WAIT * 9) / 10 - MAX_SIZE = 512 - REDIS_WAIT = 60 * time.Second + WRITE_WAIT = 30 * time.Second + PONG_WAIT = 100 * time.Second + PING_PERIOD = (PONG_WAIT * 6) / 10 ) type WebConn struct { - WebSocket *websocket.Conn - Send chan model.WebSocketMessage - SessionToken string - UserId string - T goi18n.TranslateFunc - Locale string - isMemberOfChannel map[string]bool - isMemberOfTeam map[string]bool + WebSocket *websocket.Conn + Send chan model.WebSocketMessage + SessionToken string + UserId string + T goi18n.TranslateFunc + Locale string + AllChannelMembers map[string]string + LastAllChannelMembersTime int64 } func NewWebConn(c *Context, ws *websocket.Conn) *WebConn { go SetStatusOnline(c.Session.UserId, c.Session.Id, false) return &WebConn{ - Send: make(chan model.WebSocketMessage, 64), - WebSocket: ws, - UserId: c.Session.UserId, - SessionToken: c.Session.Token, - T: c.T, - Locale: c.Locale, - isMemberOfChannel: make(map[string]bool), - isMemberOfTeam: make(map[string]bool), + Send: make(chan model.WebSocketMessage, 256), + WebSocket: ws, + UserId: c.Session.UserId, + SessionToken: c.Session.Token, + T: c.T, + Locale: c.Locale, } } func (c *WebConn) readPump() { defer func() { - hub.Unregister(c) + HubUnregister(c) c.WebSocket.Close() }() - c.WebSocket.SetReadLimit(MAX_SIZE) + c.WebSocket.SetReadLimit(SOCKET_MAX_MESSAGE_SIZE_KB) c.WebSocket.SetReadDeadline(time.Now().Add(PONG_WAIT)) c.WebSocket.SetPongHandler(func(string) error { c.WebSocket.SetReadDeadline(time.Now().Add(PONG_WAIT)) @@ -62,6 +60,13 @@ func (c *WebConn) readPump() { for { var req model.WebSocketRequest if err := c.WebSocket.ReadJSON(&req); err != nil { + // browsers will appear as CloseNoStatusReceived + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { + l4g.Debug(fmt.Sprintf("websocket.read: client side closed socket userId=%v", c.UserId)) + } else { + l4g.Debug(fmt.Sprintf("websocket.read: cannot read, closing websocket for userId=%v error=%v", c.UserId, err.Error())) + } + return } else { BaseRoutes.WebSocket.ServeWebSocket(c, &req) @@ -87,63 +92,97 @@ func (c *WebConn) writePump() { } c.WebSocket.SetWriteDeadline(time.Now().Add(WRITE_WAIT)) - if err := c.WebSocket.WriteJSON(msg); err != nil { + if err := c.WebSocket.WriteMessage(websocket.TextMessage, msg.GetPreComputeJson()); err != nil { + // browsers will appear as CloseNoStatusReceived + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { + l4g.Debug(fmt.Sprintf("websocket.send: client side closed socket userId=%v", c.UserId)) + } else { + l4g.Debug(fmt.Sprintf("websocket.send: cannot send, closing websocket for userId=%v, error=%v", c.UserId, err.Error())) + } + return } case <-ticker.C: c.WebSocket.SetWriteDeadline(time.Now().Add(WRITE_WAIT)) if err := c.WebSocket.WriteMessage(websocket.PingMessage, []byte{}); err != nil { + // browsers will appear as CloseNoStatusReceived + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { + l4g.Debug(fmt.Sprintf("websocket.ticker: client side closed socket userId=%v", c.UserId)) + } else { + l4g.Debug(fmt.Sprintf("websocket.ticker: cannot read, closing websocket for userId=%v error=%v", c.UserId, err.Error())) + } + return } } } } -func (c *WebConn) InvalidateCache() { - c.isMemberOfTeam = make(map[string]bool) - c.isMemberOfChannel = make(map[string]bool) -} +func (webCon *WebConn) InvalidateCache() { + webCon.AllChannelMembers = nil + webCon.LastAllChannelMembersTime = 0 -func (c *WebConn) InvalidateCacheForChannel(channelId string) { - delete(c.isMemberOfChannel, channelId) } -func (c *WebConn) IsMemberOfTeam(teamId string) bool { - isMember, ok := c.isMemberOfTeam[teamId] - if !ok { - session := GetSession(c.SessionToken) - if session == nil { - isMember = false - c.isMemberOfTeam[teamId] = isMember - } else { - member := session.GetTeamByTeamId(teamId) +func (webCon *WebConn) ShouldSendEvent(msg *model.WebSocketEvent) bool { + // If the event is destined to a specific user + if len(msg.Broadcast.UserId) > 0 && webCon.UserId != msg.Broadcast.UserId { + return false + } + + // if the user is omitted don't send the message + if len(msg.Broadcast.OmitUsers) > 0 { + if _, ok := msg.Broadcast.OmitUsers[webCon.UserId]; ok { + return false + } + } - if member != nil { - isMember = true - c.isMemberOfTeam[teamId] = isMember + // Only report events to users who are in the channel for the event + if len(msg.Broadcast.ChannelId) > 0 { + + if model.GetMillis()-webCon.LastAllChannelMembersTime > 1000*60*15 { // 15 minutes + webCon.AllChannelMembers = nil + webCon.LastAllChannelMembersTime = 0 + } + + if webCon.AllChannelMembers == nil { + if result := <-Srv.Store.Channel().GetAllChannelMembersForUser(webCon.UserId, true); result.Err != nil { + l4g.Error("webhub.shouldSendEvent: " + result.Err.Error()) + return false } else { - isMember = true - c.isMemberOfTeam[teamId] = isMember + webCon.AllChannelMembers = result.Data.(map[string]string) + webCon.LastAllChannelMembersTime = model.GetMillis() } + } + if _, ok := webCon.AllChannelMembers[msg.Broadcast.ChannelId]; ok { + return true + } else { + return false } } - return isMember + // Only report events to users who are in the team for the event + if len(msg.Broadcast.TeamId) > 0 { + return webCon.IsMemberOfTeam(msg.Broadcast.TeamId) + + } + + return true } -func (c *WebConn) IsMemberOfChannel(channelId string) bool { - isMember, ok := c.isMemberOfChannel[channelId] - if !ok { - if cresult := <-Srv.Store.Channel().GetMember(channelId, c.UserId); cresult.Err != nil { - isMember = false - c.isMemberOfChannel[channelId] = isMember +func (webCon *WebConn) IsMemberOfTeam(teamId string) bool { + session := GetSession(webCon.SessionToken) + if session == nil { + return false + } else { + member := session.GetTeamByTeamId(teamId) + + if member != nil { + return true } else { - isMember = true - c.isMemberOfChannel[channelId] = isMember + return false } } - - return isMember } diff --git a/api/web_hub.go b/api/web_hub.go index 4a9719d80..5f480880e 100644 --- a/api/web_hub.go +++ b/api/web_hub.go @@ -5,6 +5,8 @@ package api import ( "fmt" + "hash/fnv" + "runtime" l4g "github.com/alecthomas/log4go" @@ -14,27 +16,77 @@ import ( ) type Hub struct { - connections map[*WebConn]bool - register chan *WebConn - unregister chan *WebConn - broadcast chan *model.WebSocketEvent - stop chan string - invalidateUser chan string - invalidateChannel chan string + connections map[*WebConn]bool + register chan *WebConn + unregister chan *WebConn + broadcast chan *model.WebSocketEvent + stop chan string + invalidateUser chan string } -var hub = &Hub{ - register: make(chan *WebConn), - unregister: make(chan *WebConn), - connections: make(map[*WebConn]bool), - broadcast: make(chan *model.WebSocketEvent), - stop: make(chan string), - invalidateUser: make(chan string), - invalidateChannel: make(chan string), +var hubs []*Hub = make([]*Hub, 0) + +func NewWebHub() *Hub { + return &Hub{ + register: make(chan *WebConn), + unregister: make(chan *WebConn), + connections: make(map[*WebConn]bool, model.SESSION_CACHE_SIZE), + broadcast: make(chan *model.WebSocketEvent, 4096), + stop: make(chan string), + invalidateUser: make(chan string), + } +} + +func TotalWebsocketConnections() int { + // XXX TODO FIXME, this is racy and needs to be fixed + count := 0 + for _, hub := range hubs { + count = count + len(hub.connections) + } + + return count +} + +func HubStart() { + l4g.Info(utils.T("api.web_hub.start.starting.debug"), runtime.NumCPU()*2) + + // Total number of hubs is twice the number of CPUs. + hubs = make([]*Hub, runtime.NumCPU()*2) + + for i := 0; i < len(hubs); i++ { + hubs[i] = NewWebHub() + hubs[i].Start() + } +} + +func HubStop() { + l4g.Info(utils.T("api.web_hub.start.stopping.debug")) + + for _, hub := range hubs { + hub.Stop() + } + + hubs = make([]*Hub, 0) +} + +func HubRegister(webConn *WebConn) { + hash := fnv.New32a() + hash.Write([]byte(webConn.UserId)) + index := hash.Sum32() % uint32(len(hubs)) + hubs[index].Register(webConn) +} + +func HubUnregister(webConn *WebConn) { + for _, hub := range hubs { + hub.Unregister(webConn) + } } func Publish(message *model.WebSocketEvent) { - hub.Broadcast(message) + message.DoPreComputeJson() + for _, hub := range hubs { + hub.Broadcast(message) + } if einterfaces.GetClusterInterface() != nil { einterfaces.GetClusterInterface().Publish(message) @@ -42,11 +94,19 @@ func Publish(message *model.WebSocketEvent) { } func PublishSkipClusterSend(message *model.WebSocketEvent) { - hub.Broadcast(message) + message.DoPreComputeJson() + for _, hub := range hubs { + hub.Broadcast(message) + } } func InvalidateCacheForUser(userId string) { - hub.invalidateUser <- userId + + Srv.Store.Channel().InvalidateAllChannelMembersForUser(userId) + + for _, hub := range hubs { + hub.InvalidateUser(userId) + } if einterfaces.GetClusterInterface() != nil { einterfaces.GetClusterInterface().InvalidateCacheForUser(userId) @@ -54,11 +114,17 @@ func InvalidateCacheForUser(userId string) { } func InvalidateCacheForChannel(channelId string) { - hub.invalidateChannel <- channelId - if einterfaces.GetClusterInterface() != nil { - einterfaces.GetClusterInterface().InvalidateCacheForChannel(channelId) - } + // XXX TODO FIX ME + // This can be removed, but the performance branch + // needs to be merged into master so it can be removed + // from the enterprise repo as well. + + // hub.invalidateChannel <- channelId + + // if einterfaces.GetClusterInterface() != nil { + // einterfaces.GetClusterInterface().InvalidateCacheForChannel(channelId) + // } } func (h *Hub) Register(webConn *WebConn) { @@ -79,6 +145,10 @@ func (h *Hub) Broadcast(message *model.WebSocketEvent) { } } +func (h *Hub) InvalidateUser(userId string) { + h.invalidateUser <- userId +} + func (h *Hub) Stop() { h.stop <- "all" } @@ -108,6 +178,7 @@ func (h *Hub) Start() { if !found { go SetStatusOffline(userId, false) } + case userId := <-h.invalidateUser: for webCon := range h.connections { if webCon.UserId == userId { @@ -115,26 +186,20 @@ func (h *Hub) Start() { } } - case channelId := <-h.invalidateChannel: - for webCon := range h.connections { - webCon.InvalidateCacheForChannel(channelId) - } - case msg := <-h.broadcast: for webCon := range h.connections { - if shouldSendEvent(webCon, msg) { + if webCon.ShouldSendEvent(msg) { select { case webCon.Send <- msg: default: + l4g.Error(fmt.Sprintf("webhub.broadcast: cannot send, closing websocket for userId=%v", webCon.UserId)) close(webCon.Send) delete(h.connections, webCon) } } } - case s := <-h.stop: - l4g.Debug(utils.T("api.web_hub.start.stopping.debug"), s) - + case <-h.stop: for webCon := range h.connections { webCon.WebSocket.Close() } @@ -144,28 +209,3 @@ func (h *Hub) Start() { } }() } - -func shouldSendEvent(webCon *WebConn, msg *model.WebSocketEvent) bool { - // If the event is destined to a specific user - if len(msg.Broadcast.UserId) > 0 && webCon.UserId != msg.Broadcast.UserId { - return false - } - - // if the user is omitted don't send the message - if _, ok := msg.Broadcast.OmitUsers[webCon.UserId]; ok { - return false - } - - // Only report events to users who are in the channel for the event - if len(msg.Broadcast.ChannelId) > 0 { - return webCon.IsMemberOfChannel(msg.Broadcast.ChannelId) - } - - // Only report events to users who are in the team for the event - if len(msg.Broadcast.TeamId) > 0 { - return webCon.IsMemberOfTeam(msg.Broadcast.TeamId) - - } - - return true -} diff --git a/api/websocket.go b/api/websocket.go index fe9fa0bf9..34d95f705 100644 --- a/api/websocket.go +++ b/api/websocket.go @@ -11,16 +11,20 @@ import ( "net/http" ) +const ( + SOCKET_MAX_MESSAGE_SIZE_KB = 8 * 1024 // 8KB +) + func InitWebSocket() { l4g.Debug(utils.T("api.web_socket.init.debug")) BaseRoutes.Users.Handle("/websocket", ApiUserRequiredTrustRequester(connect)).Methods("GET") - hub.Start() + HubStart() } func connect(c *Context, w http.ResponseWriter, r *http.Request) { upgrader := websocket.Upgrader{ - ReadBufferSize: 1024, - WriteBufferSize: 1024, + ReadBufferSize: SOCKET_MAX_MESSAGE_SIZE_KB, + WriteBufferSize: SOCKET_MAX_MESSAGE_SIZE_KB, CheckOrigin: func(r *http.Request) bool { return true }, @@ -34,7 +38,7 @@ func connect(c *Context, w http.ResponseWriter, r *http.Request) { } wc := NewWebConn(c, ws) - hub.Register(wc) + HubRegister(wc) go wc.writePump() wc.readPump() } diff --git a/api/websocket_handler.go b/api/websocket_handler.go index 5a313fe13..95aad8fee 100644 --- a/api/websocket_handler.go +++ b/api/websocket_handler.go @@ -31,11 +31,17 @@ func (wh *webSocketHandler) ServeWebSocket(conn *WebConn, r *model.WebSocketRequ if data, err = wh.handlerFunc(r); err != nil { l4g.Error(utils.T("api.web_socket_handler.log.error"), "/api/v3/users/websocket", r.Action, r.Seq, r.Session.UserId, err.SystemMessage(utils.T), err.DetailedError) err.DetailedError = "" - conn.Send <- model.NewWebSocketError(r.Seq, err) + errResp := model.NewWebSocketError(r.Seq, err) + errResp.DoPreComputeJson() + + conn.Send <- errResp return } - conn.Send <- model.NewWebSocketResponse(model.STATUS_OK, r.Seq, data) + resp := model.NewWebSocketResponse(model.STATUS_OK, r.Seq, data) + resp.DoPreComputeJson() + + conn.Send <- resp } func NewInvalidWebSocketParamError(action string, name string) *model.AppError { diff --git a/api/websocket_router.go b/api/websocket_router.go index cd3ff4d1a..34b576464 100644 --- a/api/websocket_router.go +++ b/api/websocket_router.go @@ -54,6 +54,7 @@ func (wr *WebSocketRouter) ReturnWebSocketError(conn *WebConn, r *model.WebSocke err.DetailedError = "" errorResp := model.NewWebSocketError(r.Seq, err) + errorResp.DoPreComputeJson() conn.Send <- errorResp } diff --git a/api/websocket_test.go b/api/websocket_test.go index dcbc8e0f4..b7ca4b691 100644 --- a/api/websocket_test.go +++ b/api/websocket_test.go @@ -82,7 +82,8 @@ func TestWebSocketEvent(t *testing.T) { omitUser["somerandomid"] = true evt1 := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_TYPING, "", th.BasicChannel.Id, "", omitUser) evt1.Add("user_id", "somerandomid") - go Publish(evt1) + Publish(evt1) + time.Sleep(300 * time.Millisecond) stop := make(chan bool) diff --git a/config/config.json b/config/config.json index 330ae78a2..195c0255e 100644 --- a/config/config.json +++ b/config/config.json @@ -64,7 +64,7 @@ }, "LogSettings": { "EnableConsole": true, - "ConsoleLevel": "DEBUG", + "ConsoleLevel": "INFO", "EnableFile": true, "FileLevel": "INFO", "FileFormat": "", @@ -240,4 +240,4 @@ "TurnUsername": "", "TurnSharedKey": "" } -}
\ No newline at end of file +} diff --git a/i18n/en.json b/i18n/en.json index 4e213d446..5d27af7f1 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2404,8 +2404,12 @@ "translation": "Bad verify email link." }, { + "id": "api.web_hub.start.starting.debug", + "translation": "Starting %v websocket hubs" + }, + { "id": "api.web_hub.start.stopping.debug", - "translation": "stopping %v connections" + "translation": "stopping websocket hub connections" }, { "id": "api.web_socket.connect.error", @@ -4628,6 +4632,10 @@ "translation": "We couldn't get the team member" }, { + "id": "store.sql_team.get_members_by_ids.app_error", + "translation": "We couldn't get the team members" + }, + { "id": "store.sql_team.get_member.missing.app_error", "translation": "No team member found for that user id and team id" }, @@ -4636,6 +4644,10 @@ "translation": "We couldn't get the team members" }, { + "id": "store.sql_team.get_member_count.app_error", + "translation": "We couldn't count the team members" + }, + { "id": "store.sql_team.get_teams_for_email.app_error", "translation": "We encountered a problem when looking up teams" }, @@ -4724,6 +4736,10 @@ "translation": "We encountered an error while finding user profiles" }, { + "id": "store.sql_user.get_recently_active_users.app_error", + "translation": "We encountered an error while finding the recently active users" + }, + { "id": "store.sql_user.get_sysadmin_profiles.app_error", "translation": "We encountered an error while finding user profiles" }, diff --git a/mattermost.go b/mattermost.go index 8d4880504..7b9f6c0c1 100644 --- a/mattermost.go +++ b/mattermost.go @@ -14,6 +14,7 @@ import ( "os/exec" "os/signal" "runtime" + "runtime/pprof" "strconv" "strings" "syscall" @@ -83,6 +84,10 @@ var flagChannelHeader string var flagChannelPurpose string var flagUserSetInactive bool var flagImportArchive string +var flagCpuProfile bool +var flagMemProfile bool +var flagBlockProfile bool +var flagHttpProfiler bool func doLoadConfig(filename string) (err string) { defer func() { @@ -122,7 +127,26 @@ func main() { cmdUpdateDb30() - api.NewServer() + if flagCpuProfile { + f, err := os.Create(utils.GetLogFileLocation(utils.Cfg.LogSettings.FileLocation) + ".cpu.prof") + if err != nil { + l4g.Error("Error creating cpu profile log: " + err.Error()) + } + + l4g.Info("CPU Profiler is logging to " + utils.GetLogFileLocation(utils.Cfg.LogSettings.FileLocation) + ".cpu.prof") + pprof.StartCPUProfile(f) + } + + if flagBlockProfile { + l4g.Info("Block Profiler is logging to " + utils.GetLogFileLocation(utils.Cfg.LogSettings.FileLocation) + ".blk.prof") + runtime.SetBlockProfileRate(1) + } + + if flagMemProfile { + l4g.Info("Memory Profiler is logging to " + utils.GetLogFileLocation(utils.Cfg.LogSettings.FileLocation) + ".mem.prof") + } + + api.NewServer(flagHttpProfiler) api.InitApi() web.InitWeb() @@ -169,6 +193,37 @@ func main() { } api.StopServer() + + if flagCpuProfile { + l4g.Info("Closing CPU Profiler") + pprof.StopCPUProfile() + } + + if flagBlockProfile { + f, err := os.Create(utils.GetLogFileLocation(utils.Cfg.LogSettings.FileLocation) + ".blk.prof") + if err != nil { + l4g.Error("Error creating block profile log: " + err.Error()) + } + + l4g.Info("Writing Block Profiler to: " + utils.GetLogFileLocation(utils.Cfg.LogSettings.FileLocation) + ".blk.prof") + pprof.Lookup("block").WriteTo(f, 0) + f.Close() + runtime.SetBlockProfileRate(0) + } + + if flagMemProfile { + f, err := os.Create(utils.GetLogFileLocation(utils.Cfg.LogSettings.FileLocation) + ".mem.prof") + if err != nil { + l4g.Error("Error creating memory profile file: " + err.Error()) + } + + l4g.Info("Writing Memory Profiler to: " + utils.GetLogFileLocation(utils.Cfg.LogSettings.FileLocation) + ".mem.prof") + runtime.GC() + if err := pprof.WriteHeapProfile(f); err != nil { + l4g.Error("Error creating memory profile: " + err.Error()) + } + f.Close() + } } } @@ -380,6 +435,10 @@ func parseCmds() { flag.BoolVar(&flagCmdActivateUser, "activate_user", false, "") flag.BoolVar(&flagCmdSlackImport, "slack_import", false, "") flag.BoolVar(&flagUserSetInactive, "inactive", false, "") + flag.BoolVar(&flagCpuProfile, "cpuprofile", false, "") + flag.BoolVar(&flagMemProfile, "memprofile", false, "") + flag.BoolVar(&flagBlockProfile, "blkprofile", false, "") + flag.BoolVar(&flagHttpProfiler, "httpprofiler", false, "") flag.Parse() diff --git a/model/autocomplete.go b/model/autocomplete.go new file mode 100644 index 000000000..b7449a792 --- /dev/null +++ b/model/autocomplete.go @@ -0,0 +1,58 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +type UserAutocompleteInChannel struct { + InChannel []*User `json:"in_channel"` + OutOfChannel []*User `json:"out_of_channel"` +} + +type UserAutocompleteInTeam struct { + InTeam []*User `json:"in_team"` +} + +func (o *UserAutocompleteInChannel) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func UserAutocompleteInChannelFromJson(data io.Reader) *UserAutocompleteInChannel { + decoder := json.NewDecoder(data) + var o UserAutocompleteInChannel + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} + +func (o *UserAutocompleteInTeam) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func UserAutocompleteInTeamFromJson(data io.Reader) *UserAutocompleteInTeam { + decoder := json.NewDecoder(data) + var o UserAutocompleteInTeam + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} diff --git a/model/channel.go b/model/channel.go index 3da9f4feb..7dee079c5 100644 --- a/model/channel.go +++ b/model/channel.go @@ -57,8 +57,8 @@ func (o *Channel) Etag() string { return Etag(o.Id, o.UpdateAt) } -func (o *Channel) ExtraEtag(memberLimit int) string { - return Etag(o.Id, o.ExtraUpdateAt, memberLimit) +func (o *Channel) StatsEtag() string { + return Etag(o.Id, o.ExtraUpdateAt) } func (o *Channel) IsValid() *AppError { diff --git a/model/channel_extra.go b/model/channel_extra.go deleted file mode 100644 index 55da588af..000000000 --- a/model/channel_extra.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -package model - -import ( - "encoding/json" - "io" -) - -type ExtraMember struct { - Id string `json:"id"` - Nickname string `json:"nickname"` - Email string `json:"email"` - Roles string `json:"roles"` - Username string `json:"username"` -} - -func (o *ExtraMember) Sanitize(options map[string]bool) { - if len(options) == 0 || !options["email"] { - o.Email = "" - } -} - -type ChannelExtra struct { - Id string `json:"id"` - Members []ExtraMember `json:"members"` - MemberCount int64 `json:"member_count"` -} - -func (o *ChannelExtra) ToJson() string { - b, err := json.Marshal(o) - if err != nil { - return "" - } else { - return string(b) - } -} - -func ChannelExtraFromJson(data io.Reader) *ChannelExtra { - decoder := json.NewDecoder(data) - var o ChannelExtra - err := decoder.Decode(&o) - if err == nil { - return &o - } else { - return nil - } -} diff --git a/model/channel_stats.go b/model/channel_stats.go new file mode 100644 index 000000000..079769eb0 --- /dev/null +++ b/model/channel_stats.go @@ -0,0 +1,34 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +type ChannelStats struct { + ChannelId string `json:"channel_id"` + MemberCount int64 `json:"member_count"` +} + +func (o *ChannelStats) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func ChannelStatsFromJson(data io.Reader) *ChannelStats { + decoder := json.NewDecoder(data) + var o ChannelStats + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} diff --git a/model/client.go b/model/client.go index f9a56b86e..f5aeea4db 100644 --- a/model/client.go +++ b/model/client.go @@ -131,6 +131,7 @@ func (c *Client) GetFileRoute(fileId string) string { func (c *Client) DoPost(url, data, contentType string) (*http.Response, *AppError) { rq, _ := http.NewRequest("POST", c.Url+url, strings.NewReader(data)) rq.Header.Set("Content-Type", contentType) + rq.Close = true if rp, err := c.HttpClient.Do(rq); err != nil { return nil, NewLocAppError(url, "model.client.connecting.app_error", nil, err.Error()) @@ -144,6 +145,7 @@ func (c *Client) DoPost(url, data, contentType string) (*http.Response, *AppErro func (c *Client) DoApiPost(url string, data string) (*http.Response, *AppError) { rq, _ := http.NewRequest("POST", c.ApiUrl+url, strings.NewReader(data)) + rq.Close = true if len(c.AuthToken) > 0 { rq.Header.Set(HEADER_AUTH, c.AuthType+" "+c.AuthToken) @@ -161,6 +163,7 @@ func (c *Client) DoApiPost(url string, data string) (*http.Response, *AppError) func (c *Client) DoApiGet(url string, data string, etag string) (*http.Response, *AppError) { rq, _ := http.NewRequest("GET", c.ApiUrl+url, strings.NewReader(data)) + rq.Close = true if len(etag) > 0 { rq.Header.Set(HEADER_ETAG_CLIENT, etag) @@ -508,10 +511,9 @@ func (c *Client) GetMe(etag string) (*Result, *AppError) { } } -// GetProfilesForDirectMessageList returns a map of users for a team that can be direct -// messaged, using user id as the key. Must be authenticated. -func (c *Client) GetProfilesForDirectMessageList(teamId string) (*Result, *AppError) { - if r, err := c.DoApiGet("/users/profiles_for_dm_list/"+teamId, "", ""); err != nil { +// GetProfiles returns a map of users using user id as the key. Must be authenticated. +func (c *Client) GetProfiles(offset int, limit int, etag string) (*Result, *AppError) { + if r, err := c.DoApiGet(fmt.Sprintf("/users/%v/%v", offset, limit), "", etag); err != nil { return nil, err } else { defer closeBody(r) @@ -520,10 +522,10 @@ func (c *Client) GetProfilesForDirectMessageList(teamId string) (*Result, *AppEr } } -// GetProfiles returns a map of users for a team using user id as the key. Must +// GetProfilesInTeam returns a map of users for a team using user id as the key. Must // be authenticated. -func (c *Client) GetProfiles(teamId string, etag string) (*Result, *AppError) { - if r, err := c.DoApiGet("/users/profiles/"+teamId, "", etag); err != nil { +func (c *Client) GetProfilesInTeam(teamId string, offset int, limit int, etag string) (*Result, *AppError) { + if r, err := c.DoApiGet(fmt.Sprintf("/teams/%v/users/%v/%v", teamId, offset, limit), "", etag); err != nil { return nil, err } else { defer closeBody(r) @@ -532,10 +534,22 @@ func (c *Client) GetProfiles(teamId string, etag string) (*Result, *AppError) { } } -// GetDirectProfiles gets a map of users that are currently shown in the sidebar, -// using user id as the key. Must be authenticated. -func (c *Client) GetDirectProfiles(etag string) (*Result, *AppError) { - if r, err := c.DoApiGet("/users/direct_profiles", "", etag); err != nil { +// GetProfilesInChannel returns a map of users for a channel using user id as the key. Must +// be authenticated. +func (c *Client) GetProfilesInChannel(channelId string, offset int, limit int, etag string) (*Result, *AppError) { + if r, err := c.DoApiGet(fmt.Sprintf(c.GetChannelRoute(channelId)+"/users/%v/%v", offset, limit), "", etag); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), UserMapFromJson(r.Body)}, nil + } +} + +// GetProfilesNotInChannel returns a map of users not in a channel but on the team using user id as the key. Must +// be authenticated. +func (c *Client) GetProfilesNotInChannel(channelId string, offset int, limit int, etag string) (*Result, *AppError) { + if r, err := c.DoApiGet(fmt.Sprintf(c.GetChannelRoute(channelId)+"/users/not_in_channel/%v/%v", offset, limit), "", etag); err != nil { return nil, err } else { defer closeBody(r) @@ -544,6 +558,60 @@ func (c *Client) GetDirectProfiles(etag string) (*Result, *AppError) { } } +// GetProfilesByIds returns a map of users based on the user ids provided. Must +// be authenticated. +func (c *Client) GetProfilesByIds(userIds []string) (*Result, *AppError) { + if r, err := c.DoApiPost("/users/ids", ArrayToJson(userIds)); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), UserMapFromJson(r.Body)}, nil + } +} + +// SearchUsers returns a list of users that have a username matching or similar to the search term. Must +// be authenticated. +func (c *Client) SearchUsers(term string, teamId string, options map[string]string) (*Result, *AppError) { + options["term"] = term + options["team_id"] = teamId + if r, err := c.DoApiPost("/users/search", MapToJson(options)); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), UserListFromJson(r.Body)}, nil + } +} + +// AutocompleteUsersInChannel returns two lists for autocompletion of users in a channel. The first list "in_channel", +// specifies users in the channel. The second list "out_of_channel" specifies users outside of the +// channel. Term, the string to search against, is required, channel id is also required. Must be authenticated. +func (c *Client) AutocompleteUsersInChannel(term string, channelId string) (*Result, *AppError) { + url := fmt.Sprintf("%s/users/autocomplete?term=%s", c.GetChannelRoute(channelId), url.QueryEscape(term)) + if r, err := c.DoApiGet(url, "", ""); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), UserAutocompleteInChannelFromJson(r.Body)}, nil + } +} + +// AutocompleteUsersInTeam returns a list for autocompletion of users in a team. The list "in_team" specifies +// the users in the team that match the provided term, matching against username, full name and +// nickname. Must be authenticated. +func (c *Client) AutocompleteUsersInTeam(term string) (*Result, *AppError) { + url := fmt.Sprintf("%s/users/autocomplete?term=%s", c.GetTeamRoute(), url.QueryEscape(term)) + if r, err := c.DoApiGet(url, "", ""); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), UserAutocompleteInTeamFromJson(r.Body)}, nil + } +} + // LoginById authenticates a user by user id and password. func (c *Client) LoginById(id string, password string) (*Result, *AppError) { m := make(map[string]string) @@ -942,6 +1010,7 @@ func (c *Client) SaveComplianceReport(job *Compliance) (*Result, *AppError) { func (c *Client) DownloadComplianceReport(id string) (*Result, *AppError) { var rq *http.Request rq, _ = http.NewRequest("GET", c.ApiUrl+"/admin/download_compliance_report/"+id, nil) + rq.Close = true if len(c.AuthToken) > 0 { rq.Header.Set(HEADER_AUTH, "BEARER "+c.AuthToken) @@ -1174,13 +1243,23 @@ func (c *Client) UpdateLastViewedAt(channelId string, active bool) (*Result, *Ap } } -func (c *Client) GetChannelExtraInfo(id string, memberLimit int, etag string) (*Result, *AppError) { - if r, err := c.DoApiGet(c.GetChannelRoute(id)+"/extra_info/"+strconv.FormatInt(int64(memberLimit), 10), "", etag); err != nil { +func (c *Client) GetChannelStats(id string, etag string) (*Result, *AppError) { + if r, err := c.DoApiGet(c.GetChannelRoute(id)+"/stats", "", etag); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), ChannelStatsFromJson(r.Body)}, nil + } +} + +func (c *Client) GetChannelMember(channelId string, userId string) (*Result, *AppError) { + if r, err := c.DoApiGet(c.GetChannelRoute(channelId)+"/members/"+userId, "", ""); err != nil { return nil, err } else { defer closeBody(r) return &Result{r.Header.Get(HEADER_REQUEST_ID), - r.Header.Get(HEADER_ETAG_SERVER), ChannelExtraFromJson(r.Body)}, nil + r.Header.Get(HEADER_ETAG_SERVER), ChannelMemberFromJson(r.Body)}, nil } } @@ -1325,6 +1404,7 @@ func (c *Client) UploadPostAttachment(data []byte, channelId string, filename st func (c *Client) uploadFile(url string, data []byte, contentType string) (*Result, *AppError) { rq, _ := http.NewRequest("POST", url, bytes.NewReader(data)) rq.Header.Set("Content-Type", contentType) + rq.Close = true if len(c.AuthToken) > 0 { rq.Header.Set(HEADER_AUTH, "BEARER "+c.AuthToken) @@ -1525,6 +1605,18 @@ func (c *Client) GetStatuses() (*Result, *AppError) { } } +// GetStatusesByIds returns a map of string statuses using user id as the key, +// based on the provided user ids +func (c *Client) GetStatusesByIds(userIds []string) (*Result, *AppError) { + if r, err := c.DoApiPost("/users/status/ids", ArrayToJson(userIds)); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), MapFromJson(r.Body)}, nil + } +} + // SetActiveChannel sets the the channel id the user is currently viewing. // The channelId key is required but the value can be blank. Returns standard // response. @@ -1550,8 +1642,46 @@ func (c *Client) GetMyTeam(etag string) (*Result, *AppError) { } } -func (c *Client) GetTeamMembers(teamId string) (*Result, *AppError) { - if r, err := c.DoApiGet("/teams/members/"+teamId, "", ""); err != nil { +// GetTeamMembers will return a page of team member objects as an array paged based on the +// team id, offset and limit provided. Must be authenticated. +func (c *Client) GetTeamMembers(teamId string, offset int, limit int) (*Result, *AppError) { + if r, err := c.DoApiGet(fmt.Sprintf("/teams/%v/members/%v/%v", teamId, offset, limit), "", ""); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), TeamMembersFromJson(r.Body)}, nil + } +} + +// GetTeamMember will return a team member object based on the team id and user id provided. +// Must be authenticated. +func (c *Client) GetTeamMember(teamId string, userId string) (*Result, *AppError) { + if r, err := c.DoApiGet(fmt.Sprintf("/teams/%v/members/%v", teamId, userId), "", ""); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), TeamMemberFromJson(r.Body)}, nil + } +} + +// GetTeamStats will return a team stats object containing the number of users on the team +// based on the team id provided. Must be authenticated. +func (c *Client) GetTeamStats(teamId string) (*Result, *AppError) { + if r, err := c.DoApiGet(fmt.Sprintf("/teams/%v/stats", teamId), "", ""); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), TeamStatsFromJson(r.Body)}, nil + } +} + +// GetTeamMembersByIds will return team member objects as an array based on the +// team id and a list of user ids provided. Must be authenticated. +func (c *Client) GetTeamMembersByIds(teamId string, userIds []string) (*Result, *AppError) { + if r, err := c.DoApiPost(fmt.Sprintf("/teams/%v/members/ids", teamId), ArrayToJson(userIds)); err != nil { return nil, err } else { defer closeBody(r) @@ -1866,6 +1996,7 @@ func (c *Client) CreateEmoji(emoji *Emoji, image []byte, filename string) (*Emoj rq, _ := http.NewRequest("POST", c.ApiUrl+c.GetEmojiRoute()+"/create", body) rq.Header.Set("Content-Type", writer.FormDataContentType()) + rq.Close = true if len(c.AuthToken) > 0 { rq.Header.Set(HEADER_AUTH, "BEARER "+c.AuthToken) @@ -1908,6 +2039,7 @@ func (c *Client) UploadCertificateFile(data []byte, contentType string) *AppErro url := c.ApiUrl + "/admin/add_certificate" rq, _ := http.NewRequest("POST", url, bytes.NewReader(data)) rq.Header.Set("Content-Type", contentType) + rq.Close = true if len(c.AuthToken) > 0 { rq.Header.Set(HEADER_AUTH, "BEARER "+c.AuthToken) diff --git a/model/initial_load.go b/model/initial_load.go index d7587e6d4..afb0a2762 100644 --- a/model/initial_load.go +++ b/model/initial_load.go @@ -9,14 +9,13 @@ import ( ) type InitialLoad struct { - User *User `json:"user"` - TeamMembers []*TeamMember `json:"team_members"` - Teams []*Team `json:"teams"` - DirectProfiles map[string]*User `json:"direct_profiles"` - Preferences Preferences `json:"preferences"` - ClientCfg map[string]string `json:"client_cfg"` - LicenseCfg map[string]string `json:"license_cfg"` - NoAccounts bool `json:"no_accounts"` + User *User `json:"user"` + TeamMembers []*TeamMember `json:"team_members"` + Teams []*Team `json:"teams"` + Preferences Preferences `json:"preferences"` + ClientCfg map[string]string `json:"client_cfg"` + LicenseCfg map[string]string `json:"license_cfg"` + NoAccounts bool `json:"no_accounts"` } func (me *InitialLoad) ToJson() string { diff --git a/model/session.go b/model/session.go index c3171ed7c..a6a753e7f 100644 --- a/model/session.go +++ b/model/session.go @@ -11,7 +11,7 @@ import ( const ( SESSION_COOKIE_TOKEN = "MMAUTHTOKEN" - SESSION_CACHE_SIZE = 10000 + SESSION_CACHE_SIZE = 25000 SESSION_PROP_PLATFORM = "platform" SESSION_PROP_OS = "os" SESSION_PROP_BROWSER = "browser" diff --git a/model/status.go b/model/status.go index f4ad8e775..324866427 100644 --- a/model/status.go +++ b/model/status.go @@ -12,8 +12,9 @@ const ( STATUS_OFFLINE = "offline" STATUS_AWAY = "away" STATUS_ONLINE = "online" - STATUS_CACHE_SIZE = 10000 - STATUS_CHANNEL_TIMEOUT = 20000 // 20 seconds + STATUS_CACHE_SIZE = 25000 + STATUS_CHANNEL_TIMEOUT = 20000 // 20 seconds + STATUS_MIN_UPDATE_TIME = 120000 // 2 minutes ) type Status struct { diff --git a/model/team_stats.go b/model/team_stats.go new file mode 100644 index 000000000..8634c3d1b --- /dev/null +++ b/model/team_stats.go @@ -0,0 +1,34 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +type TeamStats struct { + TeamId string `json:"team_id"` + MemberCount int64 `json:"member_count"` +} + +func (o *TeamStats) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func TeamStatsFromJson(data io.Reader) *TeamStats { + decoder := json.NewDecoder(data) + var o TeamStats + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} diff --git a/model/user.go b/model/user.go index 1b51171ad..e713444ba 100644 --- a/model/user.go +++ b/model/user.go @@ -413,6 +413,26 @@ func UserMapFromJson(data io.Reader) map[string]*User { } } +func UserListToJson(u []*User) string { + b, err := json.Marshal(u) + if err != nil { + return "" + } else { + return string(b) + } +} + +func UserListFromJson(data io.Reader) []*User { + decoder := json.NewDecoder(data) + var users []*User + err := decoder.Decode(&users) + if err == nil { + return users + } else { + return nil + } +} + // HashPassword generates a hash using the bcrypt.GenerateFromPassword func HashPassword(password string) string { hash, err := bcrypt.GenerateFromPassword([]byte(password), 10) diff --git a/model/utils.go b/model/utils.go index a9c441fee..4ebd23939 100644 --- a/model/utils.go +++ b/model/utils.go @@ -10,6 +10,7 @@ import ( "encoding/json" "fmt" "io" + "io/ioutil" "net/mail" "net/url" "regexp" @@ -74,13 +75,21 @@ func (er *AppError) ToJson() string { // AppErrorFromJson will decode the input and return an AppError func AppErrorFromJson(data io.Reader) *AppError { - decoder := json.NewDecoder(data) + str := "" + bytes, rerr := ioutil.ReadAll(data) + if rerr != nil { + str = rerr.Error() + } else { + str = string(bytes) + } + + decoder := json.NewDecoder(strings.NewReader(str)) var er AppError err := decoder.Decode(&er) if err == nil { return &er } else { - return NewLocAppError("AppErrorFromJson", "model.utils.decode_json.app_error", nil, err.Error()) + return NewLocAppError("AppErrorFromJson", "model.utils.decode_json.app_error", nil, "body: "+str) } } @@ -166,6 +175,23 @@ func ArrayFromJson(data io.Reader) []string { } } +func ArrayFromInterface(data interface{}) []string { + stringArray := []string{} + + dataArray, ok := data.([]interface{}) + if !ok { + return stringArray + } + + for _, v := range dataArray { + if str, ok := v.(string); ok { + stringArray = append(stringArray, str) + } + } + + return stringArray +} + func StringInterfaceToJson(objmap map[string]interface{}) string { if b, err := json.Marshal(objmap); err != nil { return "" diff --git a/model/utils_test.go b/model/utils_test.go index 5d9289a9a..dbe1e59be 100644 --- a/model/utils_test.go +++ b/model/utils_test.go @@ -37,6 +37,13 @@ func TestAppError(t *testing.T) { err.Error() } +func TestAppErrorJunk(t *testing.T) { + rerr := AppErrorFromJson(strings.NewReader("<html><body>This is a broken test</body></html>")) + if "body: <html><body>This is a broken test</body></html>" != rerr.DetailedError { + t.Fatal() + } +} + func TestMapJson(t *testing.T) { m := make(map[string]string) diff --git a/model/websocket_client.go b/model/websocket_client.go index a048bd855..a4983e385 100644 --- a/model/websocket_client.go +++ b/model/websocket_client.go @@ -17,6 +17,7 @@ type WebSocketClient struct { Sequence int64 // The ever-incrementing sequence attached to each WebSocket action EventChannel chan *WebSocketEvent ResponseChannel chan *WebSocketResponse + ListenError *AppError } // NewWebSocketClient constructs a new WebSocket client with convienence @@ -37,6 +38,7 @@ func NewWebSocketClient(url, authToken string) (*WebSocketClient, *AppError) { 1, make(chan *WebSocketEvent, 100), make(chan *WebSocketResponse, 100), + nil, }, nil } @@ -59,10 +61,20 @@ func (wsc *WebSocketClient) Close() { func (wsc *WebSocketClient) Listen() { go func() { + defer func() { + wsc.Conn.Close() + close(wsc.EventChannel) + close(wsc.ResponseChannel) + }() + for { var rawMsg json.RawMessage var err error if _, rawMsg, err = wsc.Conn.ReadMessage(); err != nil { + if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { + wsc.ListenError = NewLocAppError("NewWebSocketClient", "model.websocket_client.connect_fail.app_error", nil, err.Error()) + } + return } @@ -107,3 +119,12 @@ func (wsc *WebSocketClient) UserTyping(channelId, parentId string) { func (wsc *WebSocketClient) GetStatuses() { wsc.SendMessage("get_statuses", nil) } + +// GetStatusesByIds will fetch certain user statuses based on ids and return +// a map of string statuses using user id as the key +func (wsc *WebSocketClient) GetStatusesByIds(userIds []string) { + data := map[string]interface{}{ + "user_ids": userIds, + } + wsc.SendMessage("get_statuses_by_ids", data) +} diff --git a/model/websocket_message.go b/model/websocket_message.go index 9bce1e825..c7ab04857 100644 --- a/model/websocket_message.go +++ b/model/websocket_message.go @@ -31,6 +31,8 @@ const ( type WebSocketMessage interface { ToJson() string IsValid() bool + DoPreComputeJson() + GetPreComputeJson() []byte } type WebsocketBroadcast struct { @@ -41,9 +43,10 @@ type WebsocketBroadcast struct { } type WebSocketEvent struct { - Event string `json:"event"` - Data map[string]interface{} `json:"data"` - Broadcast *WebsocketBroadcast `json:"broadcast"` + Event string `json:"event"` + Data map[string]interface{} `json:"data"` + Broadcast *WebsocketBroadcast `json:"broadcast"` + PreComputeJson []byte `json:"-"` } func (m *WebSocketEvent) Add(key string, value interface{}) { @@ -59,6 +62,19 @@ func (o *WebSocketEvent) IsValid() bool { return o.Event != "" } +func (o *WebSocketEvent) DoPreComputeJson() { + b, err := json.Marshal(o) + if err != nil { + o.PreComputeJson = []byte("") + } else { + o.PreComputeJson = b + } +} + +func (o *WebSocketEvent) GetPreComputeJson() []byte { + return o.PreComputeJson +} + func (o *WebSocketEvent) ToJson() string { b, err := json.Marshal(o) if err != nil { @@ -80,10 +96,11 @@ func WebSocketEventFromJson(data io.Reader) *WebSocketEvent { } type WebSocketResponse struct { - Status string `json:"status"` - SeqReply int64 `json:"seq_reply,omitempty"` - Data map[string]interface{} `json:"data,omitempty"` - Error *AppError `json:"error,omitempty"` + Status string `json:"status"` + SeqReply int64 `json:"seq_reply,omitempty"` + Data map[string]interface{} `json:"data,omitempty"` + Error *AppError `json:"error,omitempty"` + PreComputeJson []byte `json:"-"` } func (m *WebSocketResponse) Add(key string, value interface{}) { @@ -111,6 +128,19 @@ func (o *WebSocketResponse) ToJson() string { } } +func (o *WebSocketResponse) DoPreComputeJson() { + b, err := json.Marshal(o) + if err != nil { + o.PreComputeJson = []byte("") + } else { + o.PreComputeJson = b + } +} + +func (o *WebSocketResponse) GetPreComputeJson() []byte { + return o.PreComputeJson +} + func WebSocketResponseFromJson(data io.Reader) *WebSocketResponse { decoder := json.NewDecoder(data) var o WebSocketResponse diff --git a/store/sql_channel_store.go b/store/sql_channel_store.go index eb150a63c..a860fea73 100644 --- a/store/sql_channel_store.go +++ b/store/sql_channel_store.go @@ -6,21 +6,26 @@ package store import ( "database/sql" + l4g "github.com/alecthomas/log4go" "github.com/go-gorp/gorp" "github.com/mattermost/platform/model" "github.com/mattermost/platform/utils" ) const ( - MISSING_CHANNEL_ERROR = "store.sql_channel.get_by_name.missing.app_error" - MISSING_CHANNEL_MEMBER_ERROR = "store.sql_channel.get_member.missing.app_error" - CHANNEL_EXISTS_ERROR = "store.sql_channel.save_channel.exists.app_error" + MISSING_CHANNEL_ERROR = "store.sql_channel.get_by_name.missing.app_error" + MISSING_CHANNEL_MEMBER_ERROR = "store.sql_channel.get_member.missing.app_error" + CHANNEL_EXISTS_ERROR = "store.sql_channel.save_channel.exists.app_error" + ALL_CHANNEL_MEMBERS_FOR_USER_CACHE_SIZE = model.SESSION_CACHE_SIZE + ALL_CHANNEL_MEMBERS_FOR_USER_CACHE_SEC = 900 // 15 mins ) type SqlChannelStore struct { *SqlStore } +var allChannelMembersForUserCache *utils.Cache = utils.NewLru(ALL_CHANNEL_MEMBERS_FOR_USER_CACHE_SIZE) + func NewSqlChannelStore(sqlStore *SqlStore) ChannelStore { s := &SqlChannelStore{sqlStore} @@ -517,6 +522,8 @@ func (s SqlChannelStore) SaveMember(member *model.ChannelMember) StoreChannel { } } + s.InvalidateAllChannelMembersForUser(member.UserId) + storeChannel <- result close(storeChannel) }() @@ -619,6 +626,33 @@ func (s SqlChannelStore) GetMember(channelId string, userId string) StoreChannel return storeChannel } +func (us SqlChannelStore) InvalidateAllChannelMembersForUser(userId string) { + allChannelMembersForUserCache.Remove(userId) +} + +func (us SqlChannelStore) IsUserInChannelUseCache(userId string, channelId string) bool { + if cacheItem, ok := allChannelMembersForUserCache.Get(userId); ok { + ids := cacheItem.(map[string]string) + if _, ok := ids[channelId]; ok { + return true + } else { + return false + } + } + + if result := <-us.GetAllChannelMembersForUser(userId, true); result.Err != nil { + l4g.Error("SqlChannelStore.IsUserInChannelUseCache: " + result.Err.Error()) + return false + } else { + ids := result.Data.(map[string]string) + if _, ok := ids[channelId]; ok { + return true + } else { + return false + } + } +} + func (s SqlChannelStore) GetMemberForPost(postId string, userId string) StoreChannel { storeChannel := make(StoreChannel, 1) @@ -649,26 +683,43 @@ func (s SqlChannelStore) GetMemberForPost(postId string, userId string) StoreCha return storeChannel } -func (s SqlChannelStore) GetMemberCount(channelId string) StoreChannel { +type allChannelMember struct { + ChannelId string + Roles string +} + +func (s SqlChannelStore) GetAllChannelMembersForUser(userId string, allowFromCache bool) StoreChannel { storeChannel := make(StoreChannel, 1) go func() { result := StoreResult{} - count, err := s.GetReplica().SelectInt(` - SELECT - count(*) - FROM - ChannelMembers, - Users - WHERE - ChannelMembers.UserId = Users.Id - AND ChannelMembers.ChannelId = :ChannelId - AND Users.DeleteAt = 0`, map[string]interface{}{"ChannelId": channelId}) + if allowFromCache { + if cacheItem, ok := allChannelMembersForUserCache.Get(userId); ok { + result.Data = cacheItem.(map[string]string) + storeChannel <- result + close(storeChannel) + return + } + } + + var data []allChannelMember + _, err := s.GetReplica().Select(&data, "SELECT ChannelId, Roles FROM Channels, ChannelMembers WHERE Channels.Id = ChannelMembers.ChannelId AND ChannelMembers.UserId = :UserId AND Channels.DeleteAt = 0", map[string]interface{}{"UserId": userId}) + if err != nil { - result.Err = model.NewLocAppError("SqlChannelStore.GetMemberCount", "store.sql_channel.get_member_count.app_error", nil, "channel_id="+channelId+", "+err.Error()) + result.Err = model.NewLocAppError("SqlChannelStore.GetAllChannelMembersForUser", "store.sql_channel.get_channels.get.app_error", nil, "userId="+userId+", err="+err.Error()) } else { - result.Data = count + + ids := make(map[string]string) + for i := range data { + ids[data[i].ChannelId] = data[i].Roles + } + + result.Data = ids + + if allowFromCache { + allChannelMembersForUserCache.AddWithExpiresInSecs(userId, ids, ALL_CHANNEL_MEMBERS_FOR_USER_CACHE_SEC) + } } storeChannel <- result @@ -678,55 +729,26 @@ func (s SqlChannelStore) GetMemberCount(channelId string) StoreChannel { return storeChannel } -func (s SqlChannelStore) GetExtraMembers(channelId string, limit int) StoreChannel { +func (s SqlChannelStore) GetMemberCount(channelId string) StoreChannel { storeChannel := make(StoreChannel, 1) go func() { result := StoreResult{} - var members []model.ExtraMember - var err error - - if limit != -1 { - _, err = s.GetReplica().Select(&members, ` - SELECT - Id, - Nickname, - Email, - ChannelMembers.Roles, - Username - FROM - ChannelMembers, - Users - WHERE - ChannelMembers.UserId = Users.Id - AND Users.DeleteAt = 0 - AND ChannelId = :ChannelId - LIMIT :Limit`, map[string]interface{}{"ChannelId": channelId, "Limit": limit}) - } else { - _, err = s.GetReplica().Select(&members, ` + count, err := s.GetReplica().SelectInt(` SELECT - Id, - Nickname, - Email, - ChannelMembers.Roles, - Username + count(*) FROM ChannelMembers, Users WHERE ChannelMembers.UserId = Users.Id - AND Users.DeleteAt = 0 - AND ChannelId = :ChannelId`, map[string]interface{}{"ChannelId": channelId}) - } - + AND ChannelMembers.ChannelId = :ChannelId + AND Users.DeleteAt = 0`, map[string]interface{}{"ChannelId": channelId}) if err != nil { - result.Err = model.NewLocAppError("SqlChannelStore.GetExtraMembers", "store.sql_channel.get_extra_members.app_error", nil, "channel_id="+channelId+", "+err.Error()) + result.Err = model.NewLocAppError("SqlChannelStore.GetMemberCount", "store.sql_channel.get_member_count.app_error", nil, "channel_id="+channelId+", "+err.Error()) } else { - for i := range members { - members[i].Sanitize(utils.Cfg.GetSanitizeOptions()) - } - result.Data = members + result.Data = count } storeChannel <- result diff --git a/store/sql_channel_store_test.go b/store/sql_channel_store_test.go index 19db3d003..d80d54d52 100644 --- a/store/sql_channel_store_test.go +++ b/store/sql_channel_store_test.go @@ -408,11 +408,6 @@ func TestChannelMemberStore(t *testing.T) { t.Fatal("should have go member") } - extraMembers := (<-store.Channel().GetExtraMembers(o1.ChannelId, 20)).Data.([]model.ExtraMember) - if len(extraMembers) != 1 { - t.Fatal("should have 1 extra members") - } - if err := (<-store.Channel().SaveMember(&o1)).Err; err == nil { t.Fatal("Should have been a duplicate") } @@ -422,18 +417,6 @@ func TestChannelMemberStore(t *testing.T) { if t4 != t3 { t.Fatal("Should not update time upon failure") } - - // rejoin the channel and make sure that an inactive user isn't returned by GetExtraMambers - Must(store.Channel().SaveMember(&o2)) - - u2.DeleteAt = 1000 - Must(store.User().Update(&u2, true)) - - if result := <-store.Channel().GetExtraMembers(o1.ChannelId, 20); result.Err != nil { - t.Fatal(result.Err) - } else if extraMembers := result.Data.([]model.ExtraMember); len(extraMembers) != 1 { - t.Fatal("should have 1 extra members") - } } func TestChannelDeleteMemberStore(t *testing.T) { @@ -534,6 +517,42 @@ func TestChannelStoreGetChannels(t *testing.T) { if list.Channels[0].Id != o1.Id { t.Fatal("missing channel") } + + acresult := <-store.Channel().GetAllChannelMembersForUser(m1.UserId, false) + ids := acresult.Data.(map[string]string) + if _, ok := ids[o1.Id]; !ok { + t.Fatal("missing channel") + } + + acresult2 := <-store.Channel().GetAllChannelMembersForUser(m1.UserId, true) + ids2 := acresult2.Data.(map[string]string) + if _, ok := ids2[o1.Id]; !ok { + t.Fatal("missing channel") + } + + acresult3 := <-store.Channel().GetAllChannelMembersForUser(m1.UserId, true) + ids3 := acresult3.Data.(map[string]string) + if _, ok := ids3[o1.Id]; !ok { + t.Fatal("missing channel") + } + + if !store.Channel().IsUserInChannelUseCache(m1.UserId, o1.Id) { + t.Fatal("missing channel") + } + + if store.Channel().IsUserInChannelUseCache(m1.UserId, o2.Id) { + t.Fatal("missing channel") + } + + if store.Channel().IsUserInChannelUseCache(m1.UserId, "blahblah") { + t.Fatal("missing channel") + } + + if store.Channel().IsUserInChannelUseCache("blahblah", "blahblah") { + t.Fatal("missing channel") + } + + store.Channel().InvalidateAllChannelMembersForUser(m1.UserId) } func TestChannelStoreGetMoreChannels(t *testing.T) { @@ -974,22 +993,10 @@ func TestUpdateExtrasByUser(t *testing.T) { t.Fatal("failed to update extras by user: %v", result.Err) } - if result := <-store.Channel().GetExtraMembers(c1.Id, -1); result.Err != nil { - t.Fatal("failed to get extras: %v", result.Err) - } else if len(result.Data.([]model.ExtraMember)) != 0 { - t.Fatal("got incorrect member count %v", len(result.Data.([]model.ExtraMember))) - } - u1.DeleteAt = 0 Must(store.User().Update(u1, true)) if result := <-store.Channel().ExtraUpdateByUser(u1.Id, u1.DeleteAt); result.Err != nil { t.Fatal("failed to update extras by user: %v", result.Err) } - - if result := <-store.Channel().GetExtraMembers(c1.Id, -1); result.Err != nil { - t.Fatal("failed to get extras: %v", result.Err) - } else if len(result.Data.([]model.ExtraMember)) != 1 { - t.Fatal("got incorrect member count %v", len(result.Data.([]model.ExtraMember))) - } } diff --git a/store/sql_post_store.go b/store/sql_post_store.go index ec8679b31..44ffb556e 100644 --- a/store/sql_post_store.go +++ b/store/sql_post_store.go @@ -43,6 +43,7 @@ func (s SqlPostStore) CreateIndexesIfNotExists() { s.CreateIndexIfNotExists("idx_posts_create_at", "Posts", "CreateAt") s.CreateIndexIfNotExists("idx_posts_channel_id", "Posts", "ChannelId") s.CreateIndexIfNotExists("idx_posts_root_id", "Posts", "RootId") + s.CreateIndexIfNotExists("idx_posts_user_id", "Posts", "UserId") s.CreateFullTextIndexIfNotExists("idx_posts_message_txt", "Posts", "Message") s.CreateFullTextIndexIfNotExists("idx_posts_hashtags_txt", "Posts", "Hashtags") @@ -811,47 +812,36 @@ func (s SqlPostStore) AnalyticsUserCountsWithPostsByDay(teamId string) StoreChan result := StoreResult{} query := - `SELECT - t1.Name, COUNT(t1.UserId) AS Value - FROM - (SELECT DISTINCT + `SELECT DISTINCT DATE(FROM_UNIXTIME(Posts.CreateAt / 1000)) AS Name, - Posts.UserId - FROM - Posts, Channels - WHERE - Posts.ChannelId = Channels.Id` + COUNT(DISTINCT Posts.UserId) AS Value + FROM Posts + INNER JOIN Channels + ON Posts.ChannelId = Channels.Id` if len(teamId) > 0 { query += " AND Channels.TeamId = :TeamId" } query += ` AND Posts.CreateAt >= :StartTime AND Posts.CreateAt <= :EndTime - ORDER BY Name DESC) AS t1 - GROUP BY Name + GROUP BY DATE(FROM_UNIXTIME(Posts.CreateAt / 1000)) ORDER BY Name DESC LIMIT 30` if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES { query = `SELECT - TO_CHAR(t1.Name, 'YYYY-MM-DD') AS Name, COUNT(t1.UserId) AS Value - FROM - (SELECT DISTINCT - DATE(TO_TIMESTAMP(Posts.CreateAt / 1000)) AS Name, - Posts.UserId - FROM - Posts, Channels - WHERE - Posts.ChannelId = Channels.Id` + TO_CHAR(DATE(TO_TIMESTAMP(Posts.CreateAt / 1000)), 'YYYY-MM-DD') AS Name, COUNT(DISTINCT Posts.UserId) AS Value + FROM Posts + INNER JOIN Channels + ON Posts.ChannelId = Channels.Id` if len(teamId) > 0 { query += " AND Channels.TeamId = :TeamId" } query += ` AND Posts.CreateAt >= :StartTime AND Posts.CreateAt <= :EndTime - ORDER BY Name DESC) AS t1 - GROUP BY Name + GROUP BY DATE(TO_TIMESTAMP(Posts.CreateAt / 1000)) ORDER BY Name DESC LIMIT 30` } @@ -884,15 +874,12 @@ func (s SqlPostStore) AnalyticsPostCountsByDay(teamId string) StoreChannel { result := StoreResult{} query := - `SELECT - Name, COUNT(Value) AS Value - FROM - (SELECT + `SELECT DATE(FROM_UNIXTIME(Posts.CreateAt / 1000)) AS Name, - '1' AS Value - FROM - Posts, Channels - WHERE + COUNT(Posts.Id) AS Value + FROM Posts + INNER JOIN Channels + ON Posts.ChannelId = Channels.Id` if len(teamId) > 0 { @@ -900,31 +887,26 @@ func (s SqlPostStore) AnalyticsPostCountsByDay(teamId string) StoreChannel { } query += ` AND Posts.CreateAt <= :EndTime - AND Posts.CreateAt >= :StartTime) AS t1 - GROUP BY Name + AND Posts.CreateAt >= :StartTime + GROUP BY DATE(FROM_UNIXTIME(Posts.CreateAt / 1000)) ORDER BY Name DESC LIMIT 30` if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES { query = - `SELECT - Name, COUNT(Value) AS Value - FROM - (SELECT - TO_CHAR(DATE(TO_TIMESTAMP(Posts.CreateAt / 1000)), 'YYYY-MM-DD') AS Name, - '1' AS Value - FROM - Posts, Channels - WHERE - Posts.ChannelId = Channels.Id` + `SELECT + TO_CHAR(DATE(TO_TIMESTAMP(Posts.CreateAt / 1000)), 'YYYY-MM-DD') AS Name, Count(Posts.Id) AS Value + FROM Posts + INNER JOIN Channels + ON Posts.ChannelId = Channels.Id` if len(teamId) > 0 { query += " AND Channels.TeamId = :TeamId" } query += ` AND Posts.CreateAt <= :EndTime - AND Posts.CreateAt >= :StartTime) AS t1 - GROUP BY Name + AND Posts.CreateAt >= :StartTime + GROUP BY DATE(TO_TIMESTAMP(Posts.CreateAt / 1000)) ORDER BY Name DESC LIMIT 30` } diff --git a/store/sql_status_store.go b/store/sql_status_store.go index 4d186a30e..7b9fdea5d 100644 --- a/store/sql_status_store.go +++ b/store/sql_status_store.go @@ -5,6 +5,7 @@ package store import ( "database/sql" + "strconv" "github.com/mattermost/platform/model" ) @@ -43,11 +44,11 @@ func (s SqlStatusStore) SaveOrUpdate(status *model.Status) StoreChannel { if err := s.GetReplica().SelectOne(&model.Status{}, "SELECT * FROM Status WHERE UserId = :UserId", map[string]interface{}{"UserId": status.UserId}); err == nil { if _, err := s.GetMaster().Update(status); err != nil { - result.Err = model.NewLocAppError("SqlStatusStore.SaveOrUpdate", "store.sql_status.update.app_error", nil, "") + result.Err = model.NewLocAppError("SqlStatusStore.SaveOrUpdate", "store.sql_status.update.app_error", nil, err.Error()) } } else { if err := s.GetMaster().Insert(status); err != nil { - result.Err = model.NewLocAppError("SqlStatusStore.SaveOrUpdate", "store.sql_status.save.app_error", nil, "") + result.Err = model.NewLocAppError("SqlStatusStore.SaveOrUpdate", "store.sql_status.save.app_error", nil, err.Error()) } } @@ -89,6 +90,38 @@ func (s SqlStatusStore) Get(userId string) StoreChannel { return storeChannel } +func (s SqlStatusStore) GetByIds(userIds []string) StoreChannel { + storeChannel := make(StoreChannel, 1) + + go func() { + result := StoreResult{} + + props := make(map[string]interface{}) + idQuery := "" + + for index, userId := range userIds { + if len(idQuery) > 0 { + idQuery += ", " + } + + props["userId"+strconv.Itoa(index)] = userId + idQuery += ":userId" + strconv.Itoa(index) + } + + var statuses []*model.Status + if _, err := s.GetReplica().Select(&statuses, "SELECT * FROM Status WHERE UserId IN ("+idQuery+")", props); err != nil { + result.Err = model.NewLocAppError("SqlStatusStore.GetByIds", "store.sql_status.get.app_error", nil, err.Error()) + } else { + result.Data = statuses + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + func (s SqlStatusStore) GetOnlineAway() StoreChannel { storeChannel := make(StoreChannel, 1) @@ -96,7 +129,7 @@ func (s SqlStatusStore) GetOnlineAway() StoreChannel { result := StoreResult{} var statuses []*model.Status - if _, err := s.GetReplica().Select(&statuses, "SELECT * FROM Status WHERE Status = :Online OR Status = :Away", map[string]interface{}{"Online": model.STATUS_ONLINE, "Away": model.STATUS_AWAY}); err != nil { + if _, err := s.GetReplica().Select(&statuses, "SELECT * FROM Status WHERE Status = :Online OR Status = :Away LIMIT 300", map[string]interface{}{"Online": model.STATUS_ONLINE, "Away": model.STATUS_AWAY}); err != nil { result.Err = model.NewLocAppError("SqlStatusStore.GetOnlineAway", "store.sql_status.get_online_away.app_error", nil, err.Error()) } else { result.Data = statuses @@ -157,7 +190,7 @@ func (s SqlStatusStore) ResetAll() StoreChannel { go func() { result := StoreResult{} - if _, err := s.GetMaster().Exec("UPDATE Status SET Status = :Status", map[string]interface{}{"Status": model.STATUS_OFFLINE}); err != nil { + if _, err := s.GetMaster().Exec("UPDATE Status SET Status = :Status WHERE Manual = 0", map[string]interface{}{"Status": model.STATUS_OFFLINE}); err != nil { result.Err = model.NewLocAppError("SqlStatusStore.ResetAll", "store.sql_status.reset_all.app_error", nil, "") } diff --git a/store/sql_status_store_test.go b/store/sql_status_store_test.go index dff4db55e..dce973850 100644 --- a/store/sql_status_store_test.go +++ b/store/sql_status_store_test.go @@ -60,6 +60,15 @@ func TestSqlStatusStore(t *testing.T) { } } + if result := <-store.Status().GetByIds([]string{status.UserId, "junk"}); result.Err != nil { + t.Fatal(result.Err) + } else { + statuses := result.Data.([]*model.Status) + if len(statuses) != 1 { + t.Fatal("should only have 1 status") + } + } + if err := (<-store.Status().ResetAll()).Err; err != nil { t.Fatal(err) } diff --git a/store/sql_store.go b/store/sql_store.go index a2bc8f1b8..1c0de5932 100644 --- a/store/sql_store.go +++ b/store/sql_store.go @@ -33,6 +33,7 @@ import ( const ( INDEX_TYPE_FULL_TEXT = "full_text" INDEX_TYPE_DEFAULT = "default" + MAX_DB_CONN_LIFETIME = 15 ) const ( @@ -94,9 +95,7 @@ func initConnection() *SqlStore { if len(utils.Cfg.SqlSettings.DataSourceReplicas) == 0 { sqlStore.replicas = make([]*gorp.DbMap, 1) - sqlStore.replicas[0] = setupConnection(fmt.Sprintf("replica-%v", 0), utils.Cfg.SqlSettings.DriverName, utils.Cfg.SqlSettings.DataSource, - utils.Cfg.SqlSettings.MaxIdleConns, utils.Cfg.SqlSettings.MaxOpenConns, - utils.Cfg.SqlSettings.Trace) + sqlStore.replicas[0] = sqlStore.master } else { sqlStore.replicas = make([]*gorp.DbMap, len(utils.Cfg.SqlSettings.DataSourceReplicas)) for i, replica := range utils.Cfg.SqlSettings.DataSourceReplicas { @@ -183,6 +182,7 @@ func setupConnection(con_type string, driver string, dataSource string, maxIdle db.SetMaxIdleConns(maxIdle) db.SetMaxOpenConns(maxOpen) + db.SetConnMaxLifetime(time.Duration(MAX_DB_CONN_LIFETIME) * time.Minute) var dbmap *gorp.DbMap @@ -205,6 +205,26 @@ func setupConnection(con_type string, driver string, dataSource string, maxIdle return dbmap } +func (ss SqlStore) TotalMasterDbConnections() int { + return ss.GetMaster().Db.Stats().OpenConnections +} + +func (ss SqlStore) TotalReadDbConnections() int { + + if len(utils.Cfg.SqlSettings.DataSourceReplicas) == 0 { + return 0 + } else { + count := 0 + for _, db := range ss.replicas { + count = count + db.Db.Stats().OpenConnections + } + + return count + } + + return 0 +} + func (ss SqlStore) GetCurrentSchemaVersion() string { version, _ := ss.GetMaster().SelectStr("SELECT Value FROM Systems WHERE Name='Version'") return version diff --git a/store/sql_team_store.go b/store/sql_team_store.go index 34a4a097d..a69c84904 100644 --- a/store/sql_team_store.go +++ b/store/sql_team_store.go @@ -5,6 +5,7 @@ package store import ( "database/sql" + "strconv" "github.com/mattermost/platform/model" "github.com/mattermost/platform/utils" @@ -441,14 +442,14 @@ func (s SqlTeamStore) GetMember(teamId string, userId string) StoreChannel { return storeChannel } -func (s SqlTeamStore) GetMembers(teamId string) StoreChannel { +func (s SqlTeamStore) GetMembers(teamId string, offset int, limit int) StoreChannel { storeChannel := make(StoreChannel, 1) go func() { result := StoreResult{} var members []*model.TeamMember - _, err := s.GetReplica().Select(&members, "SELECT * FROM TeamMembers WHERE TeamId = :TeamId", map[string]interface{}{"TeamId": teamId}) + _, err := s.GetReplica().Select(&members, "SELECT * FROM TeamMembers WHERE TeamId = :TeamId AND DeleteAt = 0 LIMIT :Limit OFFSET :Offset", map[string]interface{}{"TeamId": teamId, "Offset": offset, "Limit": limit}) if err != nil { result.Err = model.NewLocAppError("SqlTeamStore.GetMembers", "store.sql_team.get_members.app_error", nil, "teamId="+teamId+" "+err.Error()) } else { @@ -462,6 +463,70 @@ func (s SqlTeamStore) GetMembers(teamId string) StoreChannel { return storeChannel } +func (s SqlTeamStore) GetMemberCount(teamId string) StoreChannel { + storeChannel := make(StoreChannel, 1) + + go func() { + result := StoreResult{} + + count, err := s.GetReplica().SelectInt(` + SELECT + count(*) + FROM + TeamMembers, + Users + WHERE + TeamMembers.UserId = Users.Id + AND TeamMembers.TeamId = :TeamId + AND TeamMembers.DeleteAt = 0 + AND Users.DeleteAt = 0`, map[string]interface{}{"TeamId": teamId}) + if err != nil { + result.Err = model.NewLocAppError("SqlTeamStore.GetMemberCount", "store.sql_team.get_member_count.app_error", nil, "teamId="+teamId+" "+err.Error()) + } else { + result.Data = count + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlTeamStore) GetMembersByIds(teamId string, userIds []string) StoreChannel { + storeChannel := make(StoreChannel, 1) + + go func() { + result := StoreResult{} + + var members []*model.TeamMember + props := make(map[string]interface{}) + idQuery := "" + + for index, userId := range userIds { + if len(idQuery) > 0 { + idQuery += ", " + } + + props["userId"+strconv.Itoa(index)] = userId + idQuery += ":userId" + strconv.Itoa(index) + } + + props["TeamId"] = teamId + + if _, err := s.GetReplica().Select(&members, "SELECT * FROM TeamMembers WHERE TeamId = :TeamId AND UserId IN ("+idQuery+") AND DeleteAt = 0", props); err != nil { + result.Err = model.NewLocAppError("SqlTeamStore.GetMembersByIds", "store.sql_team.get_members_by_ids.app_error", nil, "teamId="+teamId+" "+err.Error()) + } else { + result.Data = members + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + func (s SqlTeamStore) GetTeamsForUser(userId string) StoreChannel { storeChannel := make(StoreChannel, 1) diff --git a/store/sql_team_store_test.go b/store/sql_team_store_test.go index be72786d3..46215d9be 100644 --- a/store/sql_team_store_test.go +++ b/store/sql_team_store_test.go @@ -298,7 +298,7 @@ func TestTeamMembers(t *testing.T) { Must(store.Team().SaveMember(m2)) Must(store.Team().SaveMember(m3)) - if r1 := <-store.Team().GetMembers(teamId1); r1.Err != nil { + if r1 := <-store.Team().GetMembers(teamId1, 0, 100); r1.Err != nil { t.Fatal(r1.Err) } else { ms := r1.Data.([]*model.TeamMember) @@ -308,7 +308,7 @@ func TestTeamMembers(t *testing.T) { } } - if r1 := <-store.Team().GetMembers(teamId2); r1.Err != nil { + if r1 := <-store.Team().GetMembers(teamId2, 0, 100); r1.Err != nil { t.Fatal(r1.Err) } else { ms := r1.Data.([]*model.TeamMember) @@ -342,7 +342,7 @@ func TestTeamMembers(t *testing.T) { t.Fatal(r1.Err) } - if r1 := <-store.Team().GetMembers(teamId1); r1.Err != nil { + if r1 := <-store.Team().GetMembers(teamId1, 0, 100); r1.Err != nil { t.Fatal(r1.Err) } else { ms := r1.Data.([]*model.TeamMember) @@ -363,7 +363,7 @@ func TestTeamMembers(t *testing.T) { t.Fatal(r1.Err) } - if r1 := <-store.Team().GetMembers(teamId1); r1.Err != nil { + if r1 := <-store.Team().GetMembers(teamId1, 0, 100); r1.Err != nil { t.Fatal(r1.Err) } else { ms := r1.Data.([]*model.TeamMember) @@ -434,3 +434,74 @@ func TestGetTeamMember(t *testing.T) { t.Fatal("empty team id - should have failed") } } + +func TestGetTeamMembersByIds(t *testing.T) { + Setup() + + teamId1 := model.NewId() + + m1 := &model.TeamMember{TeamId: teamId1, UserId: model.NewId()} + Must(store.Team().SaveMember(m1)) + + if r := <-store.Team().GetMembersByIds(m1.TeamId, []string{m1.UserId}); r.Err != nil { + t.Fatal(r.Err) + } else { + rm1 := r.Data.([]*model.TeamMember)[0] + + if rm1.TeamId != m1.TeamId { + t.Fatal("bad team id") + } + + if rm1.UserId != m1.UserId { + t.Fatal("bad user id") + } + } + + m2 := &model.TeamMember{TeamId: teamId1, UserId: model.NewId()} + Must(store.Team().SaveMember(m2)) + + if r := <-store.Team().GetMembersByIds(m1.TeamId, []string{m1.UserId, m2.UserId, model.NewId()}); r.Err != nil { + t.Fatal(r.Err) + } else { + rm := r.Data.([]*model.TeamMember) + + if len(rm) != 2 { + t.Fatal("return wrong number of results") + } + } + + if r := <-store.Team().GetMembersByIds(m1.TeamId, []string{}); r.Err == nil { + t.Fatal("empty user ids - should have failed") + } +} + +func TestTeamStoreMemberCount(t *testing.T) { + Setup() + + u1 := &model.User{} + u1.Email = model.NewId() + Must(store.User().Save(u1)) + + teamId1 := model.NewId() + m1 := &model.TeamMember{TeamId: teamId1, UserId: u1.Id} + Must(store.Team().SaveMember(m1)) + + if result := <-store.Team().GetMemberCount(teamId1); result.Err != nil { + t.Fatal(result.Err) + } else { + if result.Data.(int64) != 1 { + t.Fatal("wrong count") + } + } + + m2 := &model.TeamMember{TeamId: teamId1, UserId: model.NewId()} + Must(store.Team().SaveMember(m2)) + + if result := <-store.Team().GetMemberCount(teamId1); result.Err != nil { + t.Fatal(result.Err) + } else { + if result.Data.(int64) != 1 { + t.Fatal("wrong count") + } + } +} diff --git a/store/sql_user_store.go b/store/sql_user_store.go index 8ada9eb2c..ca86ef115 100644 --- a/store/sql_user_store.go +++ b/store/sql_user_store.go @@ -15,14 +15,20 @@ import ( ) const ( - MISSING_ACCOUNT_ERROR = "store.sql_user.missing_account.const" - MISSING_AUTH_ACCOUNT_ERROR = "store.sql_user.get_by_auth.missing_account.app_error" + MISSING_ACCOUNT_ERROR = "store.sql_user.missing_account.const" + MISSING_AUTH_ACCOUNT_ERROR = "store.sql_user.get_by_auth.missing_account.app_error" + PROFILES_IN_CHANNEL_CACHE_SIZE = 5000 + PROFILES_IN_CHANNEL_CACHE_SEC = 900 // 15 mins + USER_SEARCH_TYPE_ALL = "Username, FirstName, LastName, Nickname" + USER_SEARCH_TYPE_USERNAME = "Username" ) type SqlUserStore struct { *SqlStore } +var profilesInChannelCache *utils.Cache = utils.NewLru(PROFILES_IN_CHANNEL_CACHE_SIZE) + func NewSqlUserStore(sqlStore *SqlStore) UserStore { us := &SqlUserStore{sqlStore} @@ -49,6 +55,9 @@ func NewSqlUserStore(sqlStore *SqlStore) UserStore { func (us SqlUserStore) CreateIndexesIfNotExists() { us.CreateIndexIfNotExists("idx_users_email", "Users", "Email") + + us.CreateFullTextIndexIfNotExists("idx_users_username_txt", "Users", USER_SEARCH_TYPE_USERNAME) + us.CreateFullTextIndexIfNotExists("idx_users_all_names_txt", "Users", USER_SEARCH_TYPE_ALL) } func (us SqlUserStore) Save(user *model.User) StoreChannel { @@ -457,7 +466,7 @@ func (s SqlUserStore) GetEtagForAllProfiles() StoreChannel { return storeChannel } -func (us SqlUserStore) GetAllProfiles() StoreChannel { +func (us SqlUserStore) GetAllProfiles(offset int, limit int) StoreChannel { storeChannel := make(StoreChannel, 1) @@ -466,8 +475,8 @@ func (us SqlUserStore) GetAllProfiles() StoreChannel { var users []*model.User - if _, err := us.GetReplica().Select(&users, "SELECT * FROM Users"); err != nil { - result.Err = model.NewLocAppError("SqlUserStore.GetProfiles", "store.sql_user.get_profiles.app_error", nil, err.Error()) + if _, err := us.GetReplica().Select(&users, "SELECT * FROM Users ORDER BY Username ASC LIMIT :Limit OFFSET :Offset", map[string]interface{}{"Offset": offset, "Limit": limit}); err != nil { + result.Err = model.NewLocAppError("SqlUserStore.GetAllProfiles", "store.sql_user.get_profiles.app_error", nil, err.Error()) } else { userMap := make(map[string]*model.User) @@ -509,7 +518,7 @@ func (s SqlUserStore) GetEtagForProfiles(teamId string) StoreChannel { return storeChannel } -func (us SqlUserStore) GetProfiles(teamId string) StoreChannel { +func (us SqlUserStore) GetProfiles(teamId string, offset int, limit int) StoreChannel { storeChannel := make(StoreChannel, 1) @@ -518,7 +527,7 @@ func (us SqlUserStore) GetProfiles(teamId string) StoreChannel { var users []*model.User - if _, err := us.GetReplica().Select(&users, "SELECT Users.* FROM Users, TeamMembers WHERE TeamMembers.TeamId = :TeamId AND Users.Id = TeamMembers.UserId", map[string]interface{}{"TeamId": teamId}); err != nil { + if _, err := us.GetReplica().Select(&users, "SELECT Users.* FROM Users, TeamMembers WHERE TeamMembers.TeamId = :TeamId AND Users.Id = TeamMembers.UserId AND TeamMembers.DeleteAt = 0 ORDER BY Users.Username ASC LIMIT :Limit OFFSET :Offset", map[string]interface{}{"TeamId": teamId, "Offset": offset, "Limit": limit}); err != nil { result.Err = model.NewLocAppError("SqlUserStore.GetProfiles", "store.sql_user.get_profiles.app_error", nil, err.Error()) } else { @@ -541,9 +550,64 @@ func (us SqlUserStore) GetProfiles(teamId string) StoreChannel { return storeChannel } -func (us SqlUserStore) GetDirectProfiles(userId string) StoreChannel { +func (us SqlUserStore) InvalidateProfilesInChannelCache(channelId string) { + profilesInChannelCache.Remove(channelId) +} - storeChannel := make(StoreChannel, 1) +func (us SqlUserStore) GetProfilesInChannel(channelId string, offset int, limit int, allowFromCache bool) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if allowFromCache && offset == -1 && limit == -1 { + if cacheItem, ok := profilesInChannelCache.Get(channelId); ok { + result.Data = cacheItem.(map[string]*model.User) + storeChannel <- result + close(storeChannel) + return + } + } + + var users []*model.User + + query := "SELECT Users.* FROM Users, ChannelMembers WHERE ChannelMembers.ChannelId = :ChannelId AND Users.Id = ChannelMembers.UserId AND Users.DeleteAt = 0" + + if limit >= 0 && offset >= 0 { + query += " ORDER BY Users.Username ASC LIMIT :Limit OFFSET :Offset" + } + + if _, err := us.GetReplica().Select(&users, query, map[string]interface{}{"ChannelId": channelId, "Offset": offset, "Limit": limit}); err != nil { + result.Err = model.NewLocAppError("SqlUserStore.GetProfilesInChannel", "store.sql_user.get_profiles.app_error", nil, err.Error()) + } else { + + userMap := make(map[string]*model.User) + + for _, u := range users { + u.Password = "" + u.AuthData = new(string) + *u.AuthData = "" + userMap[u.Id] = u + } + + result.Data = userMap + + if allowFromCache && offset == -1 && limit == -1 { + profilesInChannelCache.AddWithExpiresInSecs(channelId, userMap, PROFILES_IN_CHANNEL_CACHE_SEC) + } + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (us SqlUserStore) GetProfilesNotInChannel(teamId string, channelId string, offset int, limit int) StoreChannel { + + storeChannel := make(StoreChannel) go func() { result := StoreResult{} @@ -551,35 +615,20 @@ func (us SqlUserStore) GetDirectProfiles(userId string) StoreChannel { var users []*model.User if _, err := us.GetReplica().Select(&users, ` - SELECT - Users.* - FROM - Users - WHERE - Id IN (SELECT DISTINCT - UserId - FROM - ChannelMembers - WHERE - ChannelMembers.UserId != :UserId - AND ChannelMembers.ChannelId IN (SELECT - Channels.Id - FROM - Channels, - ChannelMembers - WHERE - Channels.Type = 'D' - AND Channels.Id = ChannelMembers.ChannelId - AND ChannelMembers.UserId = :UserId)) - OR Id IN (SELECT - Name - FROM - Preferences - WHERE - UserId = :UserId - AND Category = 'direct_channel_show') - `, map[string]interface{}{"UserId": userId}); err != nil { - result.Err = model.NewLocAppError("SqlUserStore.GetDirectProfiles", "store.sql_user.get_profiles.app_error", nil, err.Error()) + SELECT + u.* + FROM Users u + INNER JOIN TeamMembers tm + ON tm.UserId = u.Id + AND tm.TeamId = :TeamId + LEFT JOIN ChannelMembers cm + ON cm.UserId = u.Id + AND cm.ChannelId = :ChannelId + WHERE cm.UserId IS NULL + ORDER BY u.Username ASC + LIMIT :Limit OFFSET :Offset + `, map[string]interface{}{"TeamId": teamId, "ChannelId": channelId, "Offset": offset, "Limit": limit}); err != nil { + result.Err = model.NewLocAppError("SqlUserStore.GetProfilesNotInChannel", "store.sql_user.get_profiles.app_error", nil, err.Error()) } else { userMap := make(map[string]*model.User) @@ -601,6 +650,99 @@ func (us SqlUserStore) GetDirectProfiles(userId string) StoreChannel { return storeChannel } +func (us SqlUserStore) GetProfilesByUsernames(usernames []string, teamId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var users []*model.User + props := make(map[string]interface{}) + idQuery := "" + + for index, usernames := range usernames { + if len(idQuery) > 0 { + idQuery += ", " + } + + props["username"+strconv.Itoa(index)] = usernames + idQuery += ":username" + strconv.Itoa(index) + } + + props["TeamId"] = teamId + + if _, err := us.GetReplica().Select(&users, `SELECT Users.* FROM Users INNER JOIN TeamMembers ON + Users.Id = TeamMembers.UserId AND Users.Username IN (`+idQuery+`) AND TeamMembers.TeamId = :TeamId `, props); err != nil { + result.Err = model.NewLocAppError("SqlUserStore.GetProfilesByUsernames", "store.sql_user.get_profiles.app_error", nil, err.Error()) + } else { + userMap := make(map[string]*model.User) + + for _, u := range users { + u.Password = "" + u.AuthData = new(string) + *u.AuthData = "" + userMap[u.Id] = u + } + + result.Data = userMap + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +type UserWithLastActivityAt struct { + model.User + LastActivityAt int64 +} + +func (us SqlUserStore) GetRecentlyActiveUsersForTeam(teamId string) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var users []*UserWithLastActivityAt + + if _, err := us.GetReplica().Select(&users, ` + SELECT + u.*, + s.LastActivityAt + FROM Users AS u + INNER JOIN TeamMembers AS t ON u.Id = t.UserId + INNER JOIN Status AS s ON s.UserId = t.UserId + WHERE t.TeamId = :TeamId + ORDER BY s.LastActivityAt DESC + LIMIT 100 + `, map[string]interface{}{"TeamId": teamId}); err != nil { + result.Err = model.NewLocAppError("SqlUserStore.GetRecentlyActiveUsers", "store.sql_user.get_recently_active_users.app_error", nil, err.Error()) + } else { + + userMap := make(map[string]*model.User) + + for _, userWithLastActivityAt := range users { + u := userWithLastActivityAt.User + u.Password = "" + u.AuthData = new(string) + *u.AuthData = "" + u.LastActivityAt = userWithLastActivityAt.LastActivityAt + userMap[u.Id] = &u + } + + result.Data = userMap + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + func (us SqlUserStore) GetProfileByIds(userIds []string) StoreChannel { storeChannel := make(StoreChannel, 1) @@ -938,3 +1080,144 @@ func (us SqlUserStore) GetUnreadCountForChannel(userId string, channelId string) return storeChannel } + +func (us SqlUserStore) Search(teamId string, term string, searchType string) StoreChannel { + storeChannel := make(StoreChannel, 1) + + go func() { + searchQuery := "" + if teamId == "" { + searchQuery = ` + SELECT + * + FROM + Users + WHERE + DeleteAt = 0 + SEARCH_CLAUSE + ORDER BY Username ASC + LIMIT 50` + } else { + searchQuery = ` + SELECT + Users.* + FROM + Users, TeamMembers + WHERE + TeamMembers.TeamId = :TeamId + AND Users.Id = TeamMembers.UserId + AND Users.DeleteAt = 0 + AND TeamMembers.DeleteAt = 0 + SEARCH_CLAUSE + ORDER BY Users.Username ASC + LIMIT 100` + } + + storeChannel <- us.performSearch(searchQuery, term, searchType, map[string]interface{}{"TeamId": teamId}) + close(storeChannel) + + }() + + return storeChannel +} + +func (us SqlUserStore) SearchNotInChannel(teamId string, channelId string, term string, searchType string) StoreChannel { + storeChannel := make(StoreChannel, 1) + + go func() { + searchQuery := "" + if teamId == "" { + searchQuery = ` + SELECT + u.* + FROM Users u + LEFT JOIN ChannelMembers cm + ON cm.UserId = u.Id + AND cm.ChannelId = :ChannelId + WHERE cm.UserId IS NULL + SEARCH_CLAUSE + ORDER BY u.Username ASC + LIMIT 100` + } else { + searchQuery = ` + SELECT + u.* + FROM Users u + INNER JOIN TeamMembers tm + ON tm.UserId = u.Id + AND tm.TeamId = :TeamId + LEFT JOIN ChannelMembers cm + ON cm.UserId = u.Id + AND cm.ChannelId = :ChannelId + WHERE cm.UserId IS NULL + SEARCH_CLAUSE + ORDER BY u.Username ASC + LIMIT 100` + } + + storeChannel <- us.performSearch(searchQuery, term, searchType, map[string]interface{}{"TeamId": teamId, "ChannelId": channelId}) + close(storeChannel) + + }() + + return storeChannel +} + +func (us SqlUserStore) SearchInChannel(channelId string, term string, searchType string) StoreChannel { + storeChannel := make(StoreChannel, 1) + + go func() { + searchQuery := ` + SELECT + Users.* + FROM + Users, ChannelMembers + WHERE + ChannelMembers.ChannelId = :ChannelId + AND ChannelMembers.UserId = Users.Id + AND Users.DeleteAt = 0 + SEARCH_CLAUSE + ORDER BY Users.Username ASC + LIMIT 100` + + storeChannel <- us.performSearch(searchQuery, term, searchType, map[string]interface{}{"ChannelId": channelId}) + close(storeChannel) + + }() + + return storeChannel +} + +func (us SqlUserStore) performSearch(searchQuery string, term string, searchType string, parameters map[string]interface{}) StoreResult { + result := StoreResult{} + + if term == "" { + searchQuery = strings.Replace(searchQuery, "SEARCH_CLAUSE", "", 1) + } else if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES { + term = term + ":*" + searchClause := fmt.Sprintf("AND (%s) @@ to_tsquery(:Term)", searchType) + searchQuery = strings.Replace(searchQuery, "SEARCH_CLAUSE", searchClause, 1) + } else if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_MYSQL { + term = term + "*" + searchClause := fmt.Sprintf("AND MATCH(%s) AGAINST (:Term IN BOOLEAN MODE)", searchType) + searchQuery = strings.Replace(searchQuery, "SEARCH_CLAUSE", searchClause, 1) + } + + var users []*model.User + + parameters["Term"] = term + + if _, err := us.GetReplica().Select(&users, searchQuery, parameters); err != nil { + result.Err = model.NewLocAppError("SqlUserStore.Search", "store.sql_user.search.app_error", nil, "term="+term+", "+"search_type="+searchType+", "+err.Error()) + } else { + for _, u := range users { + u.Password = "" + u.AuthData = new(string) + *u.AuthData = "" + } + + result.Data = users + } + + return result +} diff --git a/store/sql_user_store_test.go b/store/sql_user_store_test.go index 076be1a81..7ffb68a47 100644 --- a/store/sql_user_store_test.go +++ b/store/sql_user_store_test.go @@ -205,7 +205,7 @@ func TestUserStoreGetAllProfiles(t *testing.T) { Must(store.User().Save(u2)) Must(store.Team().SaveMember(&model.TeamMember{TeamId: teamId, UserId: u2.Id})) - if r1 := <-store.User().GetAllProfiles(); r1.Err != nil { + if r1 := <-store.User().GetAllProfiles(0, 100); r1.Err != nil { t.Fatal(r1.Err) } else { users := r1.Data.(map[string]*model.User) @@ -213,6 +213,15 @@ func TestUserStoreGetAllProfiles(t *testing.T) { t.Fatal("invalid returned users") } } + + if r2 := <-store.User().GetAllProfiles(0, 1); r2.Err != nil { + t.Fatal(r2.Err) + } else { + users := r2.Data.(map[string]*model.User) + if len(users) != 1 { + t.Fatal("invalid returned users, limit did not work") + } + } } func TestUserStoreGetProfiles(t *testing.T) { @@ -230,7 +239,7 @@ func TestUserStoreGetProfiles(t *testing.T) { Must(store.User().Save(u2)) Must(store.Team().SaveMember(&model.TeamMember{TeamId: teamId, UserId: u2.Id})) - if r1 := <-store.User().GetProfiles(teamId); r1.Err != nil { + if r1 := <-store.User().GetProfiles(teamId, 0, 100); r1.Err != nil { t.Fatal(r1.Err) } else { users := r1.Data.(map[string]*model.User) @@ -243,7 +252,7 @@ func TestUserStoreGetProfiles(t *testing.T) { } } - if r2 := <-store.User().GetProfiles("123"); r2.Err != nil { + if r2 := <-store.User().GetProfiles("123", 0, 100); r2.Err != nil { t.Fatal(r2.Err) } else { if len(r2.Data.(map[string]*model.User)) != 0 { @@ -252,7 +261,7 @@ func TestUserStoreGetProfiles(t *testing.T) { } } -func TestUserStoreGetDirectProfiles(t *testing.T) { +func TestUserStoreGetProfilesInChannel(t *testing.T) { Setup() teamId := model.NewId() @@ -267,22 +276,166 @@ func TestUserStoreGetDirectProfiles(t *testing.T) { Must(store.User().Save(u2)) Must(store.Team().SaveMember(&model.TeamMember{TeamId: teamId, UserId: u2.Id})) - if r1 := <-store.User().GetDirectProfiles(u1.Id); r1.Err != nil { + c1 := model.Channel{} + c1.TeamId = teamId + c1.DisplayName = "Profiles in channel" + c1.Name = "profiles-" + model.NewId() + c1.Type = model.CHANNEL_OPEN + + c2 := model.Channel{} + c2.TeamId = teamId + c2.DisplayName = "Profiles in private" + c2.Name = "profiles-" + model.NewId() + c2.Type = model.CHANNEL_PRIVATE + + Must(store.Channel().Save(&c1)) + Must(store.Channel().Save(&c2)) + + m1 := model.ChannelMember{} + m1.ChannelId = c1.Id + m1.UserId = u1.Id + m1.NotifyProps = model.GetDefaultChannelNotifyProps() + + m2 := model.ChannelMember{} + m2.ChannelId = c1.Id + m2.UserId = u2.Id + m2.NotifyProps = model.GetDefaultChannelNotifyProps() + + m3 := model.ChannelMember{} + m3.ChannelId = c2.Id + m3.UserId = u1.Id + m3.NotifyProps = model.GetDefaultChannelNotifyProps() + + Must(store.Channel().SaveMember(&m1)) + Must(store.Channel().SaveMember(&m2)) + Must(store.Channel().SaveMember(&m3)) + + if r1 := <-store.User().GetProfilesInChannel(c1.Id, -1, -1, false); r1.Err != nil { t.Fatal(r1.Err) } else { users := r1.Data.(map[string]*model.User) - if len(users) != 0 { + if len(users) != 2 { t.Fatal("invalid returned users") } + + if users[u1.Id].Id != u1.Id { + t.Fatal("invalid returned user") + } } - if r2 := <-store.User().GetDirectProfiles("123"); r2.Err != nil { + if r2 := <-store.User().GetProfilesInChannel(c2.Id, -1, -1, false); r2.Err != nil { t.Fatal(r2.Err) } else { - if len(r2.Data.(map[string]*model.User)) != 0 { + if len(r2.Data.(map[string]*model.User)) != 1 { + t.Fatal("should have returned empty map") + } + } + + if r2 := <-store.User().GetProfilesInChannel(c2.Id, -1, -1, true); r2.Err != nil { + t.Fatal(r2.Err) + } else { + if len(r2.Data.(map[string]*model.User)) != 1 { + t.Fatal("should have returned empty map") + } + } + + if r2 := <-store.User().GetProfilesInChannel(c2.Id, -1, -1, true); r2.Err != nil { + t.Fatal(r2.Err) + } else { + if len(r2.Data.(map[string]*model.User)) != 1 { t.Fatal("should have returned empty map") } } + + store.User().InvalidateProfilesInChannelCache(c2.Id) +} + +func TestUserStoreGetProfilesNotInChannel(t *testing.T) { + Setup() + + teamId := model.NewId() + + u1 := &model.User{} + u1.Email = model.NewId() + Must(store.User().Save(u1)) + Must(store.Team().SaveMember(&model.TeamMember{TeamId: teamId, UserId: u1.Id})) + + u2 := &model.User{} + u2.Email = model.NewId() + Must(store.User().Save(u2)) + Must(store.Team().SaveMember(&model.TeamMember{TeamId: teamId, UserId: u2.Id})) + + c1 := model.Channel{} + c1.TeamId = teamId + c1.DisplayName = "Profiles in channel" + c1.Name = "profiles-" + model.NewId() + c1.Type = model.CHANNEL_OPEN + + c2 := model.Channel{} + c2.TeamId = teamId + c2.DisplayName = "Profiles in private" + c2.Name = "profiles-" + model.NewId() + c2.Type = model.CHANNEL_PRIVATE + + Must(store.Channel().Save(&c1)) + Must(store.Channel().Save(&c2)) + + if r1 := <-store.User().GetProfilesNotInChannel(teamId, c1.Id, 0, 100); r1.Err != nil { + t.Fatal(r1.Err) + } else { + users := r1.Data.(map[string]*model.User) + if len(users) != 2 { + t.Fatal("invalid returned users") + } + + if users[u1.Id].Id != u1.Id { + t.Fatal("invalid returned user") + } + } + + if r2 := <-store.User().GetProfilesNotInChannel(teamId, c2.Id, 0, 100); r2.Err != nil { + t.Fatal(r2.Err) + } else { + if len(r2.Data.(map[string]*model.User)) != 2 { + t.Fatal("invalid returned users") + } + } + + m1 := model.ChannelMember{} + m1.ChannelId = c1.Id + m1.UserId = u1.Id + m1.NotifyProps = model.GetDefaultChannelNotifyProps() + + m2 := model.ChannelMember{} + m2.ChannelId = c1.Id + m2.UserId = u2.Id + m2.NotifyProps = model.GetDefaultChannelNotifyProps() + + m3 := model.ChannelMember{} + m3.ChannelId = c2.Id + m3.UserId = u1.Id + m3.NotifyProps = model.GetDefaultChannelNotifyProps() + + Must(store.Channel().SaveMember(&m1)) + Must(store.Channel().SaveMember(&m2)) + Must(store.Channel().SaveMember(&m3)) + + if r1 := <-store.User().GetProfilesNotInChannel(teamId, c1.Id, 0, 100); r1.Err != nil { + t.Fatal(r1.Err) + } else { + users := r1.Data.(map[string]*model.User) + if len(users) != 0 { + t.Fatal("invalid returned users") + } + } + + if r2 := <-store.User().GetProfilesNotInChannel(teamId, c2.Id, 0, 100); r2.Err != nil { + t.Fatal(r2.Err) + } else { + if len(r2.Data.(map[string]*model.User)) != 1 { + t.Fatal("should have had 1 user not in channel") + } + } } func TestUserStoreGetProfilesByIds(t *testing.T) { @@ -326,7 +479,7 @@ func TestUserStoreGetProfilesByIds(t *testing.T) { } } - if r2 := <-store.User().GetProfiles("123"); r2.Err != nil { + if r2 := <-store.User().GetProfiles("123", 0, 100); r2.Err != nil { t.Fatal(r2.Err) } else { if len(r2.Data.(map[string]*model.User)) != 0 { @@ -335,6 +488,50 @@ func TestUserStoreGetProfilesByIds(t *testing.T) { } } +func TestUserStoreGetProfilesByUsernames(t *testing.T) { + Setup() + + teamId := model.NewId() + + u1 := &model.User{} + u1.Email = model.NewId() + u1.Username = "username1" + model.NewId() + Must(store.User().Save(u1)) + Must(store.Team().SaveMember(&model.TeamMember{TeamId: teamId, UserId: u1.Id})) + + u2 := &model.User{} + u2.Email = model.NewId() + u2.Username = "username2" + model.NewId() + Must(store.User().Save(u2)) + Must(store.Team().SaveMember(&model.TeamMember{TeamId: teamId, UserId: u2.Id})) + + if r1 := <-store.User().GetProfilesByUsernames([]string{u1.Username, u2.Username}, teamId); r1.Err != nil { + t.Fatal(r1.Err) + } else { + users := r1.Data.(map[string]*model.User) + if len(users) != 2 { + t.Fatal("invalid returned users") + } + + if users[u1.Id].Id != u1.Id { + t.Fatal("invalid returned user") + } + } + + if r1 := <-store.User().GetProfilesByUsernames([]string{u1.Username}, teamId); r1.Err != nil { + t.Fatal(r1.Err) + } else { + users := r1.Data.(map[string]*model.User) + if len(users) != 1 { + t.Fatal("invalid returned users") + } + + if users[u1.Id].Id != u1.Id { + t.Fatal("invalid returned user") + } + } +} + func TestUserStoreGetSystemAdminProfiles(t *testing.T) { Setup() @@ -713,3 +910,216 @@ func TestUserStoreUpdateMfaActive(t *testing.T) { t.Fatal(err) } } + +func TestUserStoreGetRecentlyActiveUsersForTeam(t *testing.T) { + Setup() + + u1 := &model.User{} + u1.Email = model.NewId() + Must(store.User().Save(u1)) + Must(store.Status().SaveOrUpdate(&model.Status{u1.Id, model.STATUS_ONLINE, false, model.GetMillis(), ""})) + tid := model.NewId() + Must(store.Team().SaveMember(&model.TeamMember{TeamId: tid, UserId: u1.Id})) + + if r1 := <-store.User().GetRecentlyActiveUsersForTeam(tid); r1.Err != nil { + t.Fatal(r1.Err) + } +} + +func TestUserStoreSearch(t *testing.T) { + Setup() + + u1 := &model.User{} + u1.Username = "jimbo" + model.NewId() + u1.FirstName = "Tim" + u1.LastName = "Bill" + u1.Nickname = "Rob" + u1.Email = model.NewId() + Must(store.User().Save(u1)) + + tid := model.NewId() + Must(store.Team().SaveMember(&model.TeamMember{TeamId: tid, UserId: u1.Id})) + + if r1 := <-store.User().Search(tid, "jimb", USER_SEARCH_TYPE_USERNAME); r1.Err != nil { + t.Fatal(r1.Err) + } else { + profiles := r1.Data.([]*model.User) + found := false + for _, profile := range profiles { + if profile.Id == u1.Id { + found = true + break + } + } + + if !found { + t.Fatal("should have found user") + } + } + + if r1 := <-store.User().Search("", "jimb", USER_SEARCH_TYPE_USERNAME); r1.Err != nil { + t.Fatal(r1.Err) + } else { + profiles := r1.Data.([]*model.User) + found := false + for _, profile := range profiles { + if profile.Id == u1.Id { + found = true + break + } + } + + if !found { + t.Fatal("should have found user") + } + } + + if r1 := <-store.User().Search(tid, "", USER_SEARCH_TYPE_USERNAME); r1.Err != nil { + t.Fatal(r1.Err) + } + + c1 := model.Channel{} + c1.TeamId = tid + c1.DisplayName = "NameName" + c1.Name = "a" + model.NewId() + "b" + c1.Type = model.CHANNEL_OPEN + c1 = *Must(store.Channel().Save(&c1)).(*model.Channel) + + if r1 := <-store.User().SearchNotInChannel(tid, c1.Id, "jimb", USER_SEARCH_TYPE_USERNAME); r1.Err != nil { + t.Fatal(r1.Err) + } else { + profiles := r1.Data.([]*model.User) + found := false + for _, profile := range profiles { + if profile.Id == u1.Id { + found = true + break + } + } + + if !found { + t.Fatal("should have found user") + } + } + + if r1 := <-store.User().SearchNotInChannel("", c1.Id, "jimb", USER_SEARCH_TYPE_USERNAME); r1.Err != nil { + t.Fatal(r1.Err) + } else { + profiles := r1.Data.([]*model.User) + found := false + for _, profile := range profiles { + if profile.Id == u1.Id { + found = true + break + } + } + + if !found { + t.Fatal("should have found user") + } + } + + if r1 := <-store.User().SearchNotInChannel("junk", c1.Id, "jimb", USER_SEARCH_TYPE_USERNAME); r1.Err != nil { + t.Fatal(r1.Err) + } else { + profiles := r1.Data.([]*model.User) + found := false + for _, profile := range profiles { + if profile.Id == u1.Id { + found = true + break + } + } + + if found { + t.Fatal("should not have found user") + } + } + + if r1 := <-store.User().SearchInChannel(c1.Id, "jimb", USER_SEARCH_TYPE_USERNAME); r1.Err != nil { + t.Fatal(r1.Err) + } else { + profiles := r1.Data.([]*model.User) + found := false + for _, profile := range profiles { + if profile.Id == u1.Id { + found = true + break + } + } + + if found { + t.Fatal("should not have found user") + } + } + + Must(store.Channel().SaveMember(&model.ChannelMember{ChannelId: c1.Id, UserId: u1.Id, NotifyProps: model.GetDefaultChannelNotifyProps()})) + + if r1 := <-store.User().SearchInChannel(c1.Id, "jimb", USER_SEARCH_TYPE_USERNAME); r1.Err != nil { + t.Fatal(r1.Err) + } else { + profiles := r1.Data.([]*model.User) + found := false + for _, profile := range profiles { + if profile.Id == u1.Id { + found = true + break + } + } + + if !found { + t.Fatal("should have found user") + } + } + + if r1 := <-store.User().Search(tid, "Tim", USER_SEARCH_TYPE_ALL); r1.Err != nil { + t.Fatal(r1.Err) + } else { + profiles := r1.Data.([]*model.User) + found := false + for _, profile := range profiles { + if profile.Id == u1.Id { + found = true + break + } + } + + if !found { + t.Fatal("should have found user") + } + } + + if r1 := <-store.User().Search(tid, "Bill", USER_SEARCH_TYPE_ALL); r1.Err != nil { + t.Fatal(r1.Err) + } else { + profiles := r1.Data.([]*model.User) + found := false + for _, profile := range profiles { + if profile.Id == u1.Id { + found = true + break + } + } + + if !found { + t.Fatal("should have found user") + } + } + + if r1 := <-store.User().Search(tid, "Rob", USER_SEARCH_TYPE_ALL); r1.Err != nil { + t.Fatal(r1.Err) + } else { + profiles := r1.Data.([]*model.User) + found := false + for _, profile := range profiles { + if profile.Id == u1.Id { + found = true + break + } + } + + if !found { + t.Fatal("should have found user") + } + } +} diff --git a/store/store.go b/store/store.go index 7474d3afb..900709f16 100644 --- a/store/store.go +++ b/store/store.go @@ -49,6 +49,8 @@ type Store interface { MarkSystemRanUnitTests() Close() DropAllTables() + TotalMasterDbConnections() int + TotalReadDbConnections() int } type TeamStore interface { @@ -66,7 +68,9 @@ type TeamStore interface { SaveMember(member *model.TeamMember) StoreChannel UpdateMember(member *model.TeamMember) StoreChannel GetMember(teamId string, userId string) StoreChannel - GetMembers(teamId string) StoreChannel + GetMembers(teamId string, offset int, limit int) StoreChannel + GetMembersByIds(teamId string, userIds []string) StoreChannel + GetMemberCount(teamId string) StoreChannel GetTeamsForUser(userId string) StoreChannel RemoveMember(teamId string, userId string) StoreChannel RemoveAllMembersByTeam(teamId string) StoreChannel @@ -89,16 +93,17 @@ type ChannelStore interface { GetChannelCounts(teamId string, userId string) StoreChannel GetAll(teamId string) StoreChannel GetForPost(postId string) StoreChannel - SaveMember(member *model.ChannelMember) StoreChannel UpdateMember(member *model.ChannelMember) StoreChannel GetMembers(channelId string) StoreChannel GetMember(channelId string, userId string) StoreChannel + GetAllChannelMembersForUser(userId string, allowFromCache bool) StoreChannel + InvalidateAllChannelMembersForUser(userId string) + IsUserInChannelUseCache(userId string, channelId string) bool GetMemberForPost(postId string, userId string) StoreChannel GetMemberCount(channelId string) StoreChannel RemoveMember(channelId string, userId string) StoreChannel PermanentDeleteMembersByUser(userId string) StoreChannel - GetExtraMembers(channelId string, limit int) StoreChannel UpdateLastViewedAt(channelId string, userId string) StoreChannel SetLastViewedAt(channelId string, userId string, newLastViewedAt int64) StoreChannel IncrementMentionCount(channelId string, userId string) StoreChannel @@ -135,9 +140,12 @@ type UserStore interface { UpdateMfaActive(userId string, active bool) StoreChannel Get(id string) StoreChannel GetAll() StoreChannel - GetAllProfiles() StoreChannel - GetProfiles(teamId string) StoreChannel - GetDirectProfiles(userId string) StoreChannel + InvalidateProfilesInChannelCache(channelId string) + GetProfilesInChannel(channelId string, offset int, limit int, allowFromCache bool) StoreChannel + GetProfilesNotInChannel(teamId string, channelId string, offset int, limit int) StoreChannel + GetProfilesByUsernames(usernames []string, teamId string) StoreChannel + GetAllProfiles(offset int, limit int) StoreChannel + GetProfiles(teamId string, offset int, limit int) StoreChannel GetProfileByIds(userId []string) StoreChannel GetByEmail(email string) StoreChannel GetByAuth(authData *string, authService string) StoreChannel @@ -147,7 +155,6 @@ type UserStore interface { VerifyEmail(userId string) StoreChannel GetEtagForAllProfiles() StoreChannel GetEtagForProfiles(teamId string) StoreChannel - GetEtagForDirectProfiles(userId string) StoreChannel UpdateFailedPasswordAttempts(userId string, attempts int) StoreChannel GetTotalUsersCount() StoreChannel GetSystemAdminProfiles() StoreChannel @@ -155,6 +162,10 @@ type UserStore interface { AnalyticsUniqueUserCount(teamId string) StoreChannel GetUnreadCount(userId string) StoreChannel GetUnreadCountForChannel(userId string, channelId string) StoreChannel + GetRecentlyActiveUsersForTeam(teamId string) StoreChannel + Search(teamId string, term string, searchType string) StoreChannel + SearchInChannel(channelId string, term string, searchType string) StoreChannel + SearchNotInChannel(teamId string, channelId string, term string, searchType string) StoreChannel } type SessionStore interface { @@ -274,6 +285,7 @@ type EmojiStore interface { type StatusStore interface { SaveOrUpdate(status *model.Status) StoreChannel Get(userId string) StoreChannel + GetByIds(userIds []string) StoreChannel GetOnlineAway() StoreChannel GetOnline() StoreChannel GetAllFromTeam(teamId string) StoreChannel diff --git a/web/web_test.go b/web/web_test.go index 21819191b..66969930f 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -23,7 +23,7 @@ func Setup() { utils.TranslationsPreInit() utils.LoadConfig("config.json") utils.InitTranslations(utils.Cfg.LocalizationSettings) - api.NewServer() + api.NewServer(false) api.StartServer() api.InitApi() InitWeb() @@ -161,7 +161,7 @@ func TestGetAccessToken(t *testing.T) { } } - if result, err := ApiClient.DoApiGet("/users/profiles/"+teamId+"?access_token="+token, "", ""); err != nil { + if result, err := ApiClient.DoApiGet("/teams/"+teamId+"/users/0/100?access_token="+token, "", ""); err != nil { t.Fatal(err) } else { userMap := model.UserMapFromJson(result.Body) @@ -170,16 +170,16 @@ func TestGetAccessToken(t *testing.T) { } } - if _, err := ApiClient.DoApiGet("/users/profiles/"+teamId, "", ""); err == nil { + if _, err := ApiClient.DoApiGet("/teams/"+teamId+"/users/0/100", "", ""); err == nil { t.Fatal("should have failed - no access token provided") } - if _, err := ApiClient.DoApiGet("/users/profiles/"+teamId+"?access_token=junk", "", ""); err == nil { + if _, err := ApiClient.DoApiGet("/teams/"+teamId+"/users/0/100?access_token=junk", "", ""); err == nil { t.Fatal("should have failed - bad access token provided") } ApiClient.SetOAuthToken(token) - if result, err := ApiClient.DoApiGet("/users/profiles/"+teamId, "", ""); err != nil { + if result, err := ApiClient.DoApiGet("/teams/"+teamId+"/users/0/100", "", ""); err != nil { t.Fatal(err) } else { userMap := model.UserMapFromJson(result.Body) diff --git a/webapp/actions/channel_actions.jsx b/webapp/actions/channel_actions.jsx index ed8e00db6..8364fe9b6 100644 --- a/webapp/actions/channel_actions.jsx +++ b/webapp/actions/channel_actions.jsx @@ -1,18 +1,26 @@ // Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import {browserHistory} from 'react-router/es6'; -import * as Utils from 'utils/utils.jsx'; +import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; + import TeamStore from 'stores/team_store.jsx'; import UserStore from 'stores/user_store.jsx'; import ChannelStore from 'stores/channel_store.jsx'; +import PreferenceStore from 'stores/preference_store.jsx'; + +import {loadProfilesAndTeamMembersForDMSidebar} from 'actions/user_actions.jsx'; + +import Client from 'client/web_client.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; import * as UserAgent from 'utils/user_agent.jsx'; -import Client from 'client/web_client.jsx'; +import * as Utils from 'utils/utils.jsx'; +import {Preferences, ActionTypes} from 'utils/constants.jsx'; + +import {browserHistory} from 'react-router/es6'; export function goToChannel(channel) { if (channel.fake) { - Utils.openDirectChannelToUser( + openDirectChannelToUser( UserStore.getProfileByUsername(channel.display_name), () => { browserHistory.push(TeamStore.getCurrentTeamRelativeUrl() + '/channels/' + channel.name); @@ -53,3 +61,124 @@ export function setChannelAsRead(channelIdParam) { ChannelStore.emitLastViewed(Number.MAX_VALUE, false); } } + +export function addUserToChannel(channelId, userId, success, error) { + Client.addChannelMember( + channelId, + userId, + (data) => { + UserStore.removeProfileNotInChannel(channelId, userId); + const profile = UserStore.getProfile(userId); + if (profile) { + UserStore.saveProfileInChannel(channelId, profile); + UserStore.emitInChannelChange(); + } + UserStore.emitNotInChannelChange(); + + if (success) { + success(data); + } + }, + (err) => { + AsyncClient.dispatchError(err, 'addChannelMember'); + + if (error) { + error(err); + } + } + ); +} + +export function removeUserFromChannel(channelId, userId, success, error) { + Client.removeChannelMember( + channelId, + userId, + (data) => { + UserStore.removeProfileInChannel(channelId, userId); + const profile = UserStore.getProfile(userId); + if (profile) { + UserStore.saveProfileNotInChannel(channelId, profile); + UserStore.emitNotInChannelChange(); + } + UserStore.emitInChannelChange(); + + if (success) { + success(data); + } + }, + (err) => { + AsyncClient.dispatchError(err, 'removeChannelMember'); + + if (error) { + error(err); + } + } + ); +} + +export function openDirectChannelToUser(user, success, error) { + const channelName = Utils.getDirectChannelName(UserStore.getCurrentId(), user.id); + let channel = ChannelStore.getByName(channelName); + + if (channel) { + PreferenceStore.setPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, user.id, 'true'); + loadProfilesAndTeamMembersForDMSidebar(); + + AsyncClient.savePreference( + Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, + user.id, + 'true' + ); + + if (success) { + success(channel, true); + } + + return; + } + + channel = { + name: channelName, + last_post_at: 0, + total_msg_count: 0, + type: 'D', + display_name: user.username, + teammate_id: user.id, + status: UserStore.getStatus(user.id) + }; + + Client.createDirectChannel( + user.id, + (data) => { + Client.getChannel( + data.id, + (data2) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_CHANNEL, + channel: data2.channel, + member: data2.member + }); + + PreferenceStore.setPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, user.id, 'true'); + loadProfilesAndTeamMembersForDMSidebar(); + + AsyncClient.savePreference( + Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, + user.id, + 'true' + ); + + if (success) { + success(data2.channel, false); + } + } + ); + }, + () => { + browserHistory.push(TeamStore.getCurrentTeamUrl() + '/channels/' + channelName); + if (error) { + error(); + } + } + ); +} diff --git a/webapp/actions/emoji_actions.jsx b/webapp/actions/emoji_actions.jsx new file mode 100644 index 000000000..128a9325a --- /dev/null +++ b/webapp/actions/emoji_actions.jsx @@ -0,0 +1,46 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; + +import UserStore from 'stores/user_store.jsx'; + +import * as AsyncClient from 'utils/async_client.jsx'; +import Client from 'client/web_client.jsx'; + +import {ActionTypes} from 'utils/constants.jsx'; + +export function loadEmoji(getProfiles = true) { + Client.listEmoji( + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_CUSTOM_EMOJIS, + emojis: data + }); + + if (getProfiles) { + loadProfilesForEmoji(data); + } + }, + (err) => { + AsyncClient.dispatchError(err, 'listEmoji'); + } + ); +} + +function loadProfilesForEmoji(emojiList) { + const profilesToLoad = {}; + for (let i = 0; i < emojiList.length; i++) { + const emoji = emojiList[i]; + if (!UserStore.hasProfile(emoji.creator_id)) { + profilesToLoad[emoji.creator_id] = true; + } + } + + const list = Object.keys(profilesToLoad); + if (list.length === 0) { + return; + } + + AsyncClient.getProfilesByIds(list); +} diff --git a/webapp/actions/global_actions.jsx b/webapp/actions/global_actions.jsx index 941aa34f4..23ff5a295 100644 --- a/webapp/actions/global_actions.jsx +++ b/webapp/actions/global_actions.jsx @@ -12,7 +12,8 @@ import TeamStore from 'stores/team_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import SearchStore from 'stores/search_store.jsx'; -import {handleNewPost} from 'actions/post_actions.jsx'; +import {handleNewPost, loadPosts, loadPostsBefore, loadPostsAfter} from 'actions/post_actions.jsx'; +import {loadProfilesAndTeamMembersForDMSidebar} from 'actions/user_actions.jsx'; import Constants from 'utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; @@ -43,9 +44,9 @@ export function emitChannelClickEvent(channel) { function switchToChannel(chan) { AsyncClient.getChannels(true); AsyncClient.getMoreChannels(true); - AsyncClient.getChannelExtraInfo(chan.id); + AsyncClient.getChannelStats(chan.id); AsyncClient.updateLastViewedAt(chan.id); - AsyncClient.getPosts(chan.id); + loadPosts(chan.id); trackPage(); AppDispatcher.handleViewAction({ @@ -108,7 +109,7 @@ export function emitInitialLoad(callback) { if (data.team_members) { AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_TEAM_MEMBERS, + type: ActionTypes.RECEIVED_MY_TEAM_MEMBERS, team_members: data.team_members }); } @@ -143,9 +144,9 @@ export function doFocusPost(channelId, postId, data) { }); AsyncClient.getChannels(true); AsyncClient.getMoreChannels(true); - AsyncClient.getChannelExtraInfo(channelId); - AsyncClient.getPostsBefore(postId, 0, Constants.POST_FOCUS_CONTEXT_RADIUS, true); - AsyncClient.getPostsAfter(postId, 0, Constants.POST_FOCUS_CONTEXT_RADIUS, true); + AsyncClient.getChannelStats(channelId); + loadPostsBefore(postId, 0, Constants.POST_FOCUS_CONTEXT_RADIUS, true); + loadPostsAfter(postId, 0, Constants.POST_FOCUS_CONTEXT_RADIUS, true); } export function emitPostFocusEvent(postId, onSuccess) { @@ -246,14 +247,14 @@ export function emitLoadMorePostsFocusedTopEvent() { export function loadMorePostsTop(id, isFocusPost) { const earliestPostId = PostStore.getEarliestPost(id).id; if (PostStore.requestVisibilityIncrease(id, Constants.POST_CHUNK_SIZE)) { - AsyncClient.getPostsBefore(earliestPostId, 0, Constants.POST_CHUNK_SIZE, isFocusPost); + loadPostsBefore(earliestPostId, 0, Constants.POST_CHUNK_SIZE, isFocusPost); } } export function emitLoadMorePostsFocusedBottomEvent() { const id = PostStore.getFocusedPostId(); const latestPostId = PostStore.getLatestPost(id).id; - AsyncClient.getPostsAfter(latestPostId, 0, Constants.POST_CHUNK_SIZE, Boolean(id)); + loadPostsAfter(latestPostId, 0, Constants.POST_CHUNK_SIZE, Boolean(id)); } export function emitUserPostedEvent(post) { @@ -362,7 +363,7 @@ export function emitClearSuggestions(suggestionId) { export function emitPreferenceChangedEvent(preference) { if (preference.category === Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW) { - AsyncClient.getDirectProfiles(); + loadProfilesAndTeamMembersForDMSidebar(); } AppDispatcher.handleServerAction({ @@ -437,7 +438,7 @@ export function loadDefaultLocale() { export function viewLoggedIn() { AsyncClient.getChannels(); AsyncClient.getMoreChannels(); - AsyncClient.getChannelExtraInfo(); + AsyncClient.getChannelStats(); // Clear pending posts (shouldn't have pending posts if we are loading) PostStore.clearPendingPosts(); diff --git a/webapp/actions/integration_actions.jsx b/webapp/actions/integration_actions.jsx new file mode 100644 index 000000000..5fd2b024d --- /dev/null +++ b/webapp/actions/integration_actions.jsx @@ -0,0 +1,114 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; + +import TeamStore from 'stores/team_store.jsx'; +import UserStore from 'stores/user_store.jsx'; + +import * as AsyncClient from 'utils/async_client.jsx'; +import Client from 'client/web_client.jsx'; + +import {ActionTypes} from 'utils/constants.jsx'; + +export function loadIncomingHooks() { + Client.listIncomingHooks( + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_INCOMING_WEBHOOKS, + teamId: TeamStore.getCurrentId(), + incomingWebhooks: data + }); + + loadProfilesForIncomingHooks(data); + }, + (err) => { + AsyncClient.dispatchError(err, 'listIncomingHooks'); + } + ); +} + +function loadProfilesForIncomingHooks(hooks) { + const profilesToLoad = {}; + for (let i = 0; i < hooks.length; i++) { + const hook = hooks[i]; + if (!UserStore.hasProfile(hook.user_id)) { + profilesToLoad[hook.user_id] = true; + } + } + + const list = Object.keys(profilesToLoad); + if (list.length === 0) { + return; + } + + AsyncClient.getProfilesByIds(list); +} + +export function loadOutgoingHooks() { + Client.listOutgoingHooks( + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_OUTGOING_WEBHOOKS, + teamId: TeamStore.getCurrentId(), + outgoingWebhooks: data + }); + + loadProfilesForOutgoingHooks(data); + }, + (err) => { + AsyncClient.dispatchError(err, 'listOutgoingHooks'); + } + ); +} + +function loadProfilesForOutgoingHooks(hooks) { + const profilesToLoad = {}; + for (let i = 0; i < hooks.length; i++) { + const hook = hooks[i]; + if (!UserStore.hasProfile(hook.creator_id)) { + profilesToLoad[hook.creator_id] = true; + } + } + + const list = Object.keys(profilesToLoad); + if (list.length === 0) { + return; + } + + AsyncClient.getProfilesByIds(list); +} + +export function loadTeamCommands() { + Client.listTeamCommands( + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_COMMANDS, + teamId: Client.teamId, + commands: data + }); + + loadProfilesForCommands(data); + }, + (err) => { + AsyncClient.dispatchError(err, 'loadTeamCommands'); + } + ); +} + +function loadProfilesForCommands(commands) { + const profilesToLoad = {}; + for (let i = 0; i < commands.length; i++) { + const command = commands[i]; + if (!UserStore.hasProfile(command.creator_id)) { + profilesToLoad[command.creator_id] = true; + } + } + + const list = Object.keys(profilesToLoad); + if (list.length === 0) { + return; + } + + AsyncClient.getProfilesByIds(list); +} diff --git a/webapp/actions/post_actions.jsx b/webapp/actions/post_actions.jsx index 63e3feec5..462576021 100644 --- a/webapp/actions/post_actions.jsx +++ b/webapp/actions/post_actions.jsx @@ -8,6 +8,8 @@ import PostStore from 'stores/post_store.jsx'; import TeamStore from 'stores/team_store.jsx'; import UserStore from 'stores/user_store.jsx'; +import {loadStatusesForChannel} from 'actions/status_actions.jsx'; + import * as PostUtils from 'utils/post_utils.jsx'; import Client from 'client/web_client.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; @@ -52,6 +54,8 @@ export function handleNewPost(post, msg) { post, websocketMessageProps }); + + loadProfilesForPosts(data.posts); }, (err) => { AsyncClient.dispatchError(err, 'getPost'); @@ -115,7 +119,7 @@ export function setUnreadPost(channelId, postId) { member.last_viewed_at = lastViewed; member.msg_count = channel.total_msg_count - unreadPosts; member.mention_count = 0; - ChannelStore.setChannelMember(member); + ChannelStore.storeMyChannelMember(member); ChannelStore.setUnreadCount(channelId); AsyncClient.setLastViewedAt(lastViewed, channelId); } @@ -153,9 +157,156 @@ export function getFlaggedPosts() { results: data, is_flagged_posts: true }); + + loadProfilesForPosts(data.posts); }, (err) => { AsyncClient.dispatchError(err, 'getFlaggedPosts'); } ); } + +export function loadPosts(channelId = ChannelStore.getCurrentId()) { + const postList = PostStore.getAllPosts(channelId); + const latestPostTime = PostStore.getLatestPostFromPageTime(channelId); + + if (!postList || Object.keys(postList).length === 0 || postList.order.length < Constants.POST_CHUNK_SIZE || latestPostTime === 0) { + loadPostsPage(channelId, Constants.POST_CHUNK_SIZE); + return; + } + + Client.getPosts( + channelId, + latestPostTime, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_POSTS, + id: channelId, + before: true, + numRequested: 0, + post_list: data + }); + + loadProfilesForPosts(data.posts); + loadStatusesForChannel(channelId); + }, + (err) => { + AsyncClient.dispatchError(err, 'loadPosts'); + } + ); +} + +export function loadPostsPage(channelId = ChannelStore.getCurrentId(), max = Constants.POST_CHUNK_SIZE) { + const postList = PostStore.getAllPosts(channelId); + + // if we already have more than POST_CHUNK_SIZE posts, + // let's get the amount we have but rounded up to next multiple of POST_CHUNK_SIZE, + // with a max + let numPosts = Math.min(max, Constants.POST_CHUNK_SIZE); + if (postList && postList.order.length > 0) { + numPosts = Math.min(max, Constants.POST_CHUNK_SIZE * Math.ceil(postList.order.length / Constants.POST_CHUNK_SIZE)); + } + + Client.getPostsPage( + channelId, + 0, + numPosts, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_POSTS, + id: channelId, + before: true, + numRequested: numPosts, + checkLatest: true, + post_list: data + }); + + loadProfilesForPosts(data.posts); + loadStatusesForChannel(channelId); + }, + (err) => { + AsyncClient.dispatchError(err, 'loadPostsPage'); + } + ); +} + +export function loadPostsBefore(postId, offset, numPost, isPost) { + const channelId = ChannelStore.getCurrentId(); + if (channelId == null) { + return; + } + + Client.getPostsBefore( + channelId, + postId, + offset, + numPost, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_POSTS, + id: channelId, + before: true, + numRequested: numPost, + post_list: data, + isPost + }); + + loadProfilesForPosts(data.posts); + loadStatusesForChannel(channelId); + }, + (err) => { + AsyncClient.dispatchError(err, 'loadPostsBefore'); + } + ); +} + +export function loadPostsAfter(postId, offset, numPost, isPost) { + const channelId = ChannelStore.getCurrentId(); + if (channelId == null) { + return; + } + + Client.getPostsAfter( + channelId, + postId, + offset, + numPost, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_POSTS, + id: channelId, + before: false, + numRequested: numPost, + post_list: data, + isPost + }); + + loadProfilesForPosts(data.posts); + loadStatusesForChannel(channelId); + }, + (err) => { + AsyncClient.dispatchError(err, 'loadPostsAfter'); + } + ); +} + +function loadProfilesForPosts(posts) { + const profilesToLoad = {}; + for (const pid in posts) { + if (!posts.hasOwnProperty(pid)) { + continue; + } + + const post = posts[pid]; + if (!UserStore.hasProfile(post.user_id)) { + profilesToLoad[post.user_id] = true; + } + } + + const list = Object.keys(profilesToLoad); + if (list.length === 0) { + return; + } + + AsyncClient.getProfilesByIds(list); +} diff --git a/webapp/actions/status_actions.jsx b/webapp/actions/status_actions.jsx new file mode 100644 index 000000000..c198c52ac --- /dev/null +++ b/webapp/actions/status_actions.jsx @@ -0,0 +1,133 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; + +import ChannelStore from 'stores/channel_store.jsx'; +import PostStore from 'stores/post_store.jsx'; +import PreferenceStore from 'stores/preference_store.jsx'; + +import Client from 'client/web_client.jsx'; + +import {ActionTypes, Preferences, Constants} from 'utils/constants.jsx'; + +export function loadStatusesForChannel(channelId = ChannelStore.getCurrentId()) { + const postList = PostStore.getVisiblePosts(channelId); + if (!postList || !postList.posts) { + return; + } + + const statusesToLoad = {}; + for (const pid in postList.posts) { + if (!postList.posts.hasOwnProperty(pid)) { + continue; + } + + const post = postList.posts[pid]; + statusesToLoad[post.user_id] = true; + } + + loadStatusesByIds(Object.keys(statusesToLoad)); +} + +export function loadStatusesForDMSidebar() { + const dmPrefs = PreferenceStore.getCategory(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW); + const statusesToLoad = []; + + for (const [key, value] of dmPrefs) { + if (value === 'true') { + statusesToLoad.push(key); + } + } + + loadStatusesByIds(statusesToLoad); +} + +export function loadStatusesForChannelAndSidebar() { + const statusesToLoad = {}; + + const channelId = ChannelStore.getCurrentId(); + const postList = PostStore.getVisiblePosts(channelId); + if (postList && postList.posts) { + for (const pid in postList.posts) { + if (!postList.posts.hasOwnProperty(pid)) { + continue; + } + + const post = postList.posts[pid]; + statusesToLoad[post.user_id] = true; + } + } + + const dmPrefs = PreferenceStore.getCategory(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW); + + for (const [key, value] of dmPrefs) { + if (value === 'true') { + statusesToLoad[key] = true; + } + } + + loadStatusesByIds(Object.keys(statusesToLoad)); +} + +export function loadStatusesForProfilesList(users) { + if (users == null) { + return; + } + + const statusesToLoad = []; + for (let i = 0; i < users.length; i++) { + statusesToLoad.push(users[i].id); + } + + loadStatusesByIds(statusesToLoad); +} + +export function loadStatusesForProfilesMap(users) { + if (users == null) { + return; + } + + const statusesToLoad = []; + for (const userId in users) { + if (!users.hasOwnProperty(userId)) { + return; + } + statusesToLoad.push(userId); + } + + loadStatusesByIds(statusesToLoad); +} + +export function loadStatusesByIds(userIds) { + if (userIds.length === 0) { + return; + } + + Client.getStatusesByIds( + userIds, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_STATUSES, + statuses: data + }); + } + ); +} + +let intervalId = ''; + +export function startPeriodicStatusUpdates() { + clearInterval(intervalId); + + intervalId = setInterval( + () => { + loadStatusesForChannelAndSidebar(); + }, + Constants.STATUS_INTERVAL + ); +} + +export function stopPeriodicStatusUpdates() { + clearInterval(intervalId); +} diff --git a/webapp/actions/team_actions.jsx b/webapp/actions/team_actions.jsx index 3bf25c193..e0403529e 100644 --- a/webapp/actions/team_actions.jsx +++ b/webapp/actions/team_actions.jsx @@ -2,6 +2,7 @@ // See License.txt for license information. import UserStore from 'stores/user_store.jsx'; +import TeamStore from 'stores/team_store.jsx'; import Constants from 'utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; @@ -19,8 +20,6 @@ export function checkIfTeamExists(teamName, onSuccess, onError) { export function createTeam(team, onSuccess, onError) { Client.createTeam(team, (rteam) => { - AsyncClient.getDirectProfiles(); - AppDispatcher.handleServerAction({ type: ActionTypes.CREATED_TEAM, team: rteam, @@ -36,3 +35,25 @@ export function createTeam(team, onSuccess, onError) { onError ); } + +export function removeUserFromTeam(teamId, userId, success, error) { + Client.removeUserFromTeam( + teamId, + userId, + () => { + TeamStore.removeMemberInTeam(teamId, userId); + AsyncClient.getUser(userId); + + if (success) { + success(); + } + }, + (err) => { + AsyncClient.dispatchError(err, 'removeUserFromTeam'); + + if (error) { + error(err); + } + } + ); +} diff --git a/webapp/actions/user_actions.jsx b/webapp/actions/user_actions.jsx index 2d5fd805c..900353701 100644 --- a/webapp/actions/user_actions.jsx +++ b/webapp/actions/user_actions.jsx @@ -2,12 +2,17 @@ // See License.txt for license information. import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; -import Client from 'client/web_client.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import TeamStore from 'stores/team_store.jsx'; import UserStore from 'stores/user_store.jsx'; +import ChannelStore from 'stores/channel_store.jsx'; + +import {loadStatusesForProfilesList, loadStatusesForProfilesMap} from 'actions/status_actions.jsx'; + +import {getDirectChannelName} from 'utils/utils.jsx'; +import * as AsyncClient from 'utils/async_client.jsx'; +import Client from 'client/web_client.jsx'; import {ActionTypes, Preferences} from 'utils/constants.jsx'; @@ -29,9 +34,179 @@ export function switchFromLdapToEmail(email, password, ldapPassword, onSuccess, ); } -export function getMoreDmList() { - AsyncClient.getTeamMembers(TeamStore.getCurrentId()); - AsyncClient.getProfilesForDirectMessageList(); +export function loadProfilesAndTeamMembers(offset, limit, teamId = TeamStore.getCurrentId(), success, error) { + Client.getProfilesInTeam( + teamId, + offset, + limit, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_PROFILES_IN_TEAM, + profiles: data, + team_id: teamId, + offset, + count: Object.keys(data).length + }); + + loadTeamMembersForProfilesMap(data, teamId, success, error); + loadStatusesForProfilesMap(data); + }, + (err) => { + AsyncClient.dispatchError(err, 'getProfilesInTeam'); + } + ); +} + +export function loadTeamMembersForProfilesMap(profiles, teamId = TeamStore.getCurrentId(), success, error) { + const membersToLoad = {}; + for (const pid in profiles) { + if (!profiles.hasOwnProperty(pid)) { + continue; + } + + if (!TeamStore.hasActiveMemberInTeam(teamId, pid)) { + membersToLoad[pid] = true; + } + } + + const list = Object.keys(membersToLoad); + if (list.length === 0) { + if (success) { + success({}); + } + return; + } + + loadTeamMembersForProfiles(list, teamId, success, error); +} + +export function loadTeamMembersForProfilesList(profiles, teamId = TeamStore.getCurrentId(), success, error) { + const membersToLoad = {}; + for (let i = 0; i < profiles.length; i++) { + const pid = profiles[i].id; + + if (!TeamStore.hasActiveMemberInTeam(teamId, pid)) { + membersToLoad[pid] = true; + } + } + + const list = Object.keys(membersToLoad); + if (list.length === 0) { + if (success) { + success({}); + } + return; + } + + loadTeamMembersForProfiles(list, teamId, success, error); +} + +function loadTeamMembersForProfiles(userIds, teamId, success, error) { + Client.getTeamMembersByIds( + teamId, + userIds, + (data) => { + const memberMap = {}; + for (let i = 0; i < data.length; i++) { + memberMap[data[i].user_id] = data[i]; + } + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_MEMBERS_IN_TEAM, + team_id: teamId, + team_members: memberMap + }); + + if (success) { + success(data); + } + }, + (err) => { + AsyncClient.dispatchError(err, 'getTeamMembersByIds'); + + if (error) { + error(err); + } + } + ); +} + +function populateDMChannelsWithProfiles(userIds) { + const currentUserId = UserStore.getCurrentId(); + + for (let i = 0; i < userIds.length; i++) { + const channelName = getDirectChannelName(currentUserId, userIds[i]); + const channel = ChannelStore.getByName(channelName); + if (channel) { + UserStore.saveUserIdInChannel(channel.id, userIds[i]); + } + } +} + +export function loadProfilesAndTeamMembersForDMSidebar() { + const dmPrefs = PreferenceStore.getCategory(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW); + const teamId = TeamStore.getCurrentId(); + const profilesToLoad = []; + const membersToLoad = []; + + for (const [key, value] of dmPrefs) { + if (value === 'true') { + if (!UserStore.hasProfile(key)) { + profilesToLoad.push(key); + } + membersToLoad.push(key); + } + } + + if (profilesToLoad.length > 0) { + Client.getProfilesByIds( + profilesToLoad, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_PROFILES, + profiles: data + }); + + // Use membersToLoad so we get all the DM profiles even if they were already loaded + populateDMChannelsWithProfiles(membersToLoad); + }, + (err) => { + AsyncClient.dispatchError(err, 'getProfilesByIds'); + } + ); + } else { + populateDMChannelsWithProfiles(membersToLoad); + } + + if (membersToLoad.length > 0) { + Client.getTeamMembersByIds( + teamId, + membersToLoad, + (data) => { + const memberMap = {}; + for (let i = 0; i < data.length; i++) { + memberMap[data[i].user_id] = data[i]; + } + + const nonMembersMap = {}; + for (let i = 0; i < membersToLoad.length; i++) { + if (!memberMap[membersToLoad[i]]) { + nonMembersMap[membersToLoad[i]] = true; + } + } + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_MEMBERS_IN_TEAM, + team_id: teamId, + team_members: memberMap, + non_team_members: nonMembersMap + }); + }, + (err) => { + AsyncClient.dispatchError(err, 'getTeamMembersByIds'); + } + ); + } } export function saveTheme(teamId, theme, onSuccess, onError) { @@ -82,3 +257,62 @@ function onThemeSaved(teamId, theme, onSuccess) { onSuccess(); } + +export function searchUsers(term, teamId = TeamStore.getCurrentId(), options = {}, success, error) { + Client.searchUsers( + term, + teamId, + options, + (data) => { + loadStatusesForProfilesList(data); + + if (success) { + success(data); + } + }, + (err) => { + AsyncClient.dispatchError(err, 'searchUsers'); + + if (error) { + error(err); + } + } + ); +} + +export function autocompleteUsersInChannel(username, channelId, success, error) { + Client.autocompleteUsersInChannel( + username, + channelId, + (data) => { + if (success) { + success(data); + } + }, + (err) => { + AsyncClient.dispatchError(err, 'autocompleteUsersInChannel'); + + if (error) { + error(err); + } + } + ); +} + +export function autocompleteUsersInTeam(username, success, error) { + Client.autocompleteUsersInTeam( + username, + (data) => { + if (success) { + success(data); + } + }, + (err) => { + AsyncClient.dispatchError(err, 'autocompleteUsersInTeam'); + + if (error) { + error(err); + } + } + ); +} diff --git a/webapp/actions/websocket_actions.jsx b/webapp/actions/websocket_actions.jsx index 08449b87e..14a150692 100644 --- a/webapp/actions/websocket_actions.jsx +++ b/webapp/actions/websocket_actions.jsx @@ -3,8 +3,6 @@ import $ from 'jquery'; -import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; - import UserStore from 'stores/user_store.jsx'; import TeamStore from 'stores/team_store.jsx'; import PostStore from 'stores/post_store.jsx'; @@ -20,10 +18,11 @@ import * as Utils from 'utils/utils.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; -import * as UserActions from 'actions/user_actions.jsx'; -import {handleNewPost} from 'actions/post_actions.jsx'; +import {handleNewPost, loadPosts} from 'actions/post_actions.jsx'; +import {loadProfilesAndTeamMembersForDMSidebar} from 'actions/user_actions.jsx'; +import * as StatusActions from 'actions/status_actions.jsx'; -import {Constants, SocketEvents, ActionTypes} from 'utils/constants.jsx'; +import {Constants, SocketEvents, UserStatuses} from 'utils/constants.jsx'; import {browserHistory} from 'react-router/es6'; @@ -53,6 +52,7 @@ export function initialize() { connUrl += Client.getUsersRoute() + '/websocket'; WebSocketClient.setEventCallback(handleEvent); + WebSocketClient.setFirstConnectCallback(handleFirstConnect); WebSocketClient.setReconnectCallback(handleReconnect); WebSocketClient.setCloseCallback(handleClose); WebSocketClient.initialize(connUrl); @@ -64,22 +64,19 @@ export function close() { } export function getStatuses() { - WebSocketClient.getStatuses( - (resp) => { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_STATUSES, - statuses: resp.data - }); - } - ); + StatusActions.loadStatusesForChannelAndSidebar(); +} + +function handleFirstConnect() { + getStatuses(); + ErrorStore.clearLastError(); + ErrorStore.emitChange(); } function handleReconnect() { if (Client.teamId) { AsyncClient.getChannels(); - AsyncClient.getPosts(ChannelStore.getCurrentId()); - AsyncClient.getTeamMembers(TeamStore.getCurrentId()); - AsyncClient.getProfiles(); + loadPosts(ChannelStore.getCurrentId()); } getStatuses(); @@ -112,7 +109,7 @@ function handleEvent(msg) { break; case SocketEvents.NEW_USER: - handleNewUserEvent(); + handleNewUserEvent(msg); break; case SocketEvents.LEAVE_TEAM: @@ -170,6 +167,10 @@ function handleEvent(msg) { function handleNewPostEvent(msg) { const post = JSON.parse(msg.data.post); handleNewPost(post, msg); + + if (UserStore.getStatus(post.user_id) !== UserStatuses.ONLINE) { + StatusActions.loadStatusesByIds([post.user_id]); + } } function handlePostEditEvent(msg) { @@ -196,36 +197,33 @@ function handlePostDeleteEvent(msg) { } } -function handleNewUserEvent() { - AsyncClient.getTeamMembers(TeamStore.getCurrentId()); - AsyncClient.getProfiles(); - AsyncClient.getDirectProfiles(); - AsyncClient.getChannelExtraInfo(); +function handleNewUserEvent(msg) { + AsyncClient.getUser(msg.user_id); + AsyncClient.getChannelStats(); + loadProfilesAndTeamMembersForDMSidebar(); } function handleLeaveTeamEvent(msg) { if (UserStore.getCurrentId() === msg.data.user_id) { - TeamStore.removeTeamMember(msg.broadcast.team_id); + TeamStore.removeMyTeamMember(msg.broadcast.team_id); - // if the are on the team begin removed redirect them to the root + // if they are on the team being removed redirect them to the root if (TeamStore.getCurrentId() === msg.broadcast.team_id) { TeamStore.setCurrentId(''); Client.setTeamId(''); browserHistory.push('/'); } - } else if (TeamStore.getCurrentId() === msg.broadcast.team_id) { - UserActions.getMoreDmList(); } } function handleDirectAddedEvent(msg) { AsyncClient.getChannel(msg.broadcast.channel_id); - AsyncClient.getDirectProfiles(); + loadProfilesAndTeamMembersForDMSidebar(); } function handleUserAddedEvent(msg) { if (ChannelStore.getCurrentId() === msg.broadcast.channel_id) { - AsyncClient.getChannelExtraInfo(); + AsyncClient.getChannelStats(); } if (TeamStore.getCurrentId() === msg.data.team_id && UserStore.getCurrentId() === msg.data.user_id) { @@ -248,7 +246,7 @@ function handleUserRemovedEvent(msg) { $('#removed_from_channel').modal('show'); } } else if (ChannelStore.getCurrentId() === msg.broadcast.channel_id) { - AsyncClient.getChannelExtraInfo(); + AsyncClient.getChannelStats(); } } @@ -287,6 +285,10 @@ function handlePreferenceChangedEvent(msg) { function handleUserTypingEvent(msg) { GlobalActions.emitRemoteUserTypingEvent(msg.broadcast.channel_id, msg.data.user_id, msg.data.parent_id); + + if (UserStore.getStatus(msg.data.user_id) !== UserStatuses.ONLINE) { + StatusActions.loadStatusesByIds([msg.data.user_id]); + } } function handleStatusChangedEvent(msg) { @@ -301,4 +303,4 @@ function handleHelloEvent(msg) { function handleWebrtc(msg) { const data = msg.data; return WebrtcActions.handle(data); -}
\ No newline at end of file +} diff --git a/webapp/client/client.jsx b/webapp/client/client.jsx index 334f8374d..596242e41 100644 --- a/webapp/client/client.jsx +++ b/webapp/client/client.jsx @@ -73,11 +73,7 @@ export default class Client { return `${this.url}${this.urlVersion}/teams`; } - getTeamNeededRoute() { - return `${this.url}${this.urlVersion}/teams/${this.getTeamId()}`; - } - - getTeamNeededManualRoute(teamId) { + getTeamNeededRoute(teamId = this.getTeamId()) { return `${this.url}${this.urlVersion}/teams/${teamId}`; } @@ -565,15 +561,43 @@ export default class Client { end(this.handleResponse.bind(this, 'getMyTeam', success, error)); } - getTeamMembers(teamId, success, error) { + getTeamMembers(teamId, offset, limit, success, error) { request. - get(`${this.getTeamsRoute()}/members/${teamId}`). + get(`${this.getTeamNeededRoute(teamId)}/members/${offset}/${limit}`). set(this.defaultHeaders). type('application/json'). accept('application/json'). end(this.handleResponse.bind(this, 'getTeamMembers', success, error)); } + getTeamMember(teamId, userId, success, error) { + request. + get(`${this.getTeamNeededRoute(teamId)}/members/${userId}`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + end(this.handleResponse.bind(this, 'getTeamMember', success, error)); + } + + getTeamMembersByIds(teamId, userIds, success, error) { + request. + post(`${this.getTeamNeededRoute(teamId)}/members/ids`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + send(userIds). + end(this.handleResponse.bind(this, 'getTeamMembersByIds', success, error)); + } + + getTeamStats(teamId, success, error) { + request. + get(`${this.getTeamNeededRoute(teamId)}/stats`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + end(this.handleResponse.bind(this, 'getTeamStats', success, error)); + } + inviteMembers(data, success, error) { request. post(`${this.getTeamNeededRoute()}/invite_members`). @@ -740,7 +764,7 @@ export default class Client { }; request. - post(`${this.getTeamNeededManualRoute(teamId)}/update_member_roles`). + post(`${this.getTeamNeededRoute(teamId)}/update_member_roles`). set(this.defaultHeaders). type('application/json'). accept('application/json'). @@ -1003,40 +1027,78 @@ export default class Client { end(this.handleResponse.bind(this, 'getRecentlyActiveUsers', success, error)); } - getDirectProfiles(success, error) { + getProfiles(offset, limit, success, error) { request. - get(`${this.getUsersRoute()}/direct_profiles`). + get(`${this.getUsersRoute()}/${offset}/${limit}`). set(this.defaultHeaders). type('application/json'). accept('application/json'). - end(this.handleResponse.bind(this, 'getDirectProfiles', success, error)); + end(this.handleResponse.bind(this, 'getProfiles', success, error)); } - getProfiles(success, error) { + getProfilesInTeam(teamId, offset, limit, success, error) { request. - get(`${this.getUsersRoute()}/profiles/${this.getTeamId()}`). + get(`${this.getTeamNeededRoute(teamId)}/users/${offset}/${limit}`). set(this.defaultHeaders). type('application/json'). accept('application/json'). - end(this.handleResponse.bind(this, 'getProfiles', success, error)); + end(this.handleResponse.bind(this, 'getProfilesInTeam', success, error)); + } + + getProfilesInChannel(channelId, offset, limit, success, error) { + request. + get(`${this.getChannelNeededRoute(channelId)}/users/${offset}/${limit}`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + end(this.handleResponse.bind(this, 'getProfilesInChannel', success, error)); + } + + getProfilesNotInChannel(channelId, offset, limit, success, error) { + request. + get(`${this.getChannelNeededRoute(channelId)}/users/not_in_channel/${offset}/${limit}`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + end(this.handleResponse.bind(this, 'getProfilesNotInChannel', success, error)); + } + + getProfilesByIds(userIds, success, error) { + request. + post(`${this.getUsersRoute()}/ids`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + send(userIds). + end(this.handleResponse.bind(this, 'getProfilesByIds', success, error)); + } + + searchUsers(term, teamId, options, success, error) { + request. + post(`${this.getUsersRoute()}/search`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + send({term, team_id: teamId, ...options}). + end(this.handleResponse.bind(this, 'searchUsers', success, error)); } - getProfilesForTeam(teamId, success, error) { + autocompleteUsersInChannel(term, channelId, success, error) { request. - get(`${this.getUsersRoute()}/profiles/${teamId}`). + get(`${this.getChannelNeededRoute(channelId)}/users/autocomplete?term=${encodeURIComponent(term)}`). set(this.defaultHeaders). type('application/json'). accept('application/json'). - end(this.handleResponse.bind(this, 'getProfilesForTeam', success, error)); + end(this.handleResponse.bind(this, 'autocompleteUsers', success, error)); } - getProfilesForDirectMessageList(success, error) { + autocompleteUsersInTeam(term, success, error) { request. - get(`${this.getUsersRoute()}/profiles_for_dm_list/${this.getTeamId()}`). + get(`${this.getTeamNeededRoute()}/users/autocomplete?term=${encodeURIComponent(term)}`). set(this.defaultHeaders). type('application/json'). accept('application/json'). - end(this.handleResponse.bind(this, 'getProfilesForDirectMessageList', success, error)); + end(this.handleResponse.bind(this, 'autocompleteUsers', success, error)); } getStatuses(success, error) { @@ -1048,6 +1110,16 @@ export default class Client { end(this.handleResponse.bind(this, 'getStatuses', success, error)); } + getStatusesByIds(userIds, success, error) { + request. + post(`${this.getUsersRoute()}/status/ids`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + send(userIds). + end(this.handleResponse.bind(this, 'getStatuses', success, error)); + } + setActiveChannel(id, success, error) { request. post(`${this.getUsersRoute()}/status/set_active_channel`). @@ -1285,18 +1357,22 @@ export default class Client { end(this.handleResponse.bind(this, 'getChannelCounts', success, error)); } - getChannelExtraInfo(channelId, memberLimit, success, error) { - var url = `${this.getChannelNeededRoute(channelId)}/extra_info`; - if (memberLimit) { - url += '/' + memberLimit; - } + getChannelStats(channelId, success, error) { + request. + get(`${this.getChannelNeededRoute(channelId)}/stats`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + end(this.handleResponse.bind(this, 'getChannelStats', success, error)); + } + getChannelMember(channelId, userId, success, error) { request. - get(url). + get(`${this.getChannelNeededRoute(channelId)}/members/${userId}`). set(this.defaultHeaders). type('application/json'). accept('application/json'). - end(this.handleResponse.bind(this, 'getChannelExtraInfo', success, error)); + end(this.handleResponse.bind(this, 'getChannelMember', success, error)); } addChannelMember(channelId, userId, success, error) { diff --git a/webapp/client/websocket_client.jsx b/webapp/client/websocket_client.jsx index aa78d8d98..035e30be5 100644 --- a/webapp/client/websocket_client.jsx +++ b/webapp/client/websocket_client.jsx @@ -12,6 +12,7 @@ export default class WebSocketClient { this.connectFailCount = 0; this.eventCallback = null; this.responseCallbacks = {}; + this.firstConnectCallback = null; this.reconnectCallback = null; this.errorCallback = null; this.closeCallback = null; @@ -29,12 +30,13 @@ export default class WebSocketClient { this.conn = new WebSocket(connectionUrl); this.conn.onopen = () => { - if (this.reconnectCallback) { - this.reconnectCallback(); - } - if (this.connectFailCount > 0) { console.log('websocket re-established connection'); //eslint-disable-line no-console + if (this.reconnectCallback) { + this.reconnectCallback(); + } + } else if (this.firstConnectCallback) { + this.firstConnectCallback(); } this.connectFailCount = 0; @@ -104,6 +106,10 @@ export default class WebSocketClient { this.eventCallback = callback; } + setFirstConnectCallback(callback) { + this.firstConnectCallback = callback; + } + setReconnectCallback(callback) { this.reconnectCallback = callback; } @@ -157,4 +163,10 @@ export default class WebSocketClient { getStatuses(callback) { this.sendMessage('get_statuses', null, callback); } + + getStatusesByIds(userIds, callback) { + const data = {}; + data.user_ids = userIds; + this.sendMessage('get_statuses_by_ids', data, callback); + } } diff --git a/webapp/components/admin_console/admin_navbar_dropdown.jsx b/webapp/components/admin_console/admin_navbar_dropdown.jsx index 7b958cbb0..f20451b4b 100644 --- a/webapp/components/admin_console/admin_navbar_dropdown.jsx +++ b/webapp/components/admin_console/admin_navbar_dropdown.jsx @@ -22,7 +22,7 @@ export default class AdminNavbarDropdown extends React.Component { this.state = { teams: TeamStore.getAll(), - teamMembers: TeamStore.getTeamMembers() + teamMembers: TeamStore.getMyTeamMembers() }; } @@ -45,7 +45,7 @@ export default class AdminNavbarDropdown extends React.Component { onTeamChange() { this.setState({ teams: TeamStore.getAll(), - teamMembers: TeamStore.getTeamMembers() + teamMembers: TeamStore.getMyTeamMembers() }); } diff --git a/webapp/components/admin_console/user_item.jsx b/webapp/components/admin_console/admin_team_members_dropdown.jsx index ac548afe0..85daa86ba 100644 --- a/webapp/components/admin_console/user_item.jsx +++ b/webapp/components/admin_console/admin_team_members_dropdown.jsx @@ -8,11 +8,11 @@ import UserStore from 'stores/user_store.jsx'; import ConfirmModal from '../confirm_modal.jsx'; import TeamStore from 'stores/team_store.jsx'; -import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; +import {FormattedMessage} from 'react-intl'; import React from 'react'; -export default class UserItem extends React.Component { +export default class AdminTeamMembersDropdown extends React.Component { constructor(props) { super(props); @@ -50,7 +50,7 @@ export default class UserItem extends React.Component { } ); Client.updateTeamMemberRoles( - this.props.team.id, + this.props.teamMember.team_id, this.props.user.id, 'team_user', () => { @@ -74,7 +74,7 @@ export default class UserItem extends React.Component { handleRemoveFromTeam() { Client.removeUserFromTeam( - this.props.team.id, + this.props.teamMember.team_id, this.props.user.id, () => { this.props.refreshProfiles(); @@ -111,7 +111,7 @@ export default class UserItem extends React.Component { doMakeTeamAdmin() { Client.updateTeamMemberRoles( - this.props.team.id, + this.props.teamMember.team_id, this.props.user.id, 'team_user team_admin', () => { @@ -241,7 +241,6 @@ export default class UserItem extends React.Component { } const me = UserStore.getCurrentUser(); - const email = user.email; let showMakeMember = Utils.isAdmin(teamMember.roles) || Utils.isSystemAdmin(user.roles); let showMakeAdmin = !Utils.isAdmin(teamMember.roles) && !Utils.isSystemAdmin(user.roles); let showMakeSystemAdmin = !Utils.isSystemAdmin(user.roles); @@ -406,39 +405,8 @@ export default class UserItem extends React.Component { ); } - let mfaActiveText; - if (mfaEnabled) { - if (user.mfa_active) { - mfaActiveText = ( - <FormattedHTMLMessage - id='admin.user_item.mfaYes' - defaultMessage=', <strong>MFA</strong>: Yes' - /> - ); - } else { - mfaActiveText = ( - <FormattedHTMLMessage - id='admin.user_item.mfaNo' - defaultMessage=', <strong>MFA</strong>: No' - /> - ); - } - } - - let authServiceText; let passwordReset; if (user.auth_service) { - const service = (user.auth_service === Constants.LDAP_SERVICE || user.auth_service === Constants.SAML_SERVICE) ? user.auth_service.toUpperCase() : Utils.toTitleCase(user.auth_service); - authServiceText = ( - <FormattedHTMLMessage - id='admin.user_item.authServiceNotEmail' - defaultMessage=', <strong>Sign-in Method:</strong> {service}' - values={{ - service - }} - /> - ); - passwordReset = ( <li role='presentation'> <a @@ -454,13 +422,6 @@ export default class UserItem extends React.Component { </li> ); } else { - authServiceText = ( - <FormattedHTMLMessage - id='admin.user_item.authServiceEmail' - defaultMessage=', <strong>Sign-in Method:</strong> Email' - /> - ); - passwordReset = ( <li role='presentation'> <a @@ -531,63 +492,38 @@ export default class UserItem extends React.Component { } return ( - <div className='more-modal__row'> - <img - className='more-modal__image pull-left' - src={`${Client.getUsersRoute()}/${user.id}/image?time=${user.update_at}`} - height='36' - width='36' - /> - <div className='more-modal__details'> - <div className='more-modal__name'>{displayedName}</div> - <div className='more-modal__description'> - <FormattedHTMLMessage - id='admin.user_item.emailTitle' - defaultMessage='<strong>Email:</strong> {email}' - values={{ - email - }} - /> - {authServiceText} - {mfaActiveText} - </div> - {serverError} - </div> - <div className='more-modal__actions'> - <div className='dropdown member-drop'> - <a - href='#' - className='dropdown-toggle theme' - type='button' - data-toggle='dropdown' - aria-expanded='true' - > - <span>{currentRoles} </span> - <span className='caret'/> - </a> - <ul - className='dropdown-menu member-menu' - role='menu' - > - {removeFromTeam} - {makeAdmin} - {makeMember} - {makeActive} - {makeNotActive} - {makeSystemAdmin} - {mfaReset} - {passwordReset} - </ul> - </div> - </div> + <div className='dropdown member-drop'> + <a + href='#' + className='dropdown-toggle theme' + type='button' + data-toggle='dropdown' + aria-expanded='true' + > + <span>{currentRoles} </span> + <span className='caret'/> + </a> + <ul + className='dropdown-menu member-menu' + role='menu' + > + {removeFromTeam} + {makeAdmin} + {makeMember} + {makeActive} + {makeNotActive} + {makeSystemAdmin} + {mfaReset} + {passwordReset} + </ul> {makeDemoteModal} + {serverError} </div> ); } } -UserItem.propTypes = { - team: React.PropTypes.object.isRequired, +AdminTeamMembersDropdown.propTypes = { user: React.PropTypes.object.isRequired, teamMember: React.PropTypes.object.isRequired, refreshProfiles: React.PropTypes.func.isRequired, diff --git a/webapp/components/admin_console/team_users.jsx b/webapp/components/admin_console/team_users.jsx index 56b76c195..8fa73b084 100644 --- a/webapp/components/admin_console/team_users.jsx +++ b/webapp/components/admin_console/team_users.jsx @@ -1,16 +1,25 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import AdminStore from 'stores/admin_store.jsx'; -import Client from 'client/web_client.jsx'; -import FormError from 'components/form_error.jsx'; -import LoadingScreen from '../loading_screen.jsx'; -import UserItem from './user_item.jsx'; +import SearchableUserList from 'components/searchable_user_list.jsx'; +import AdminTeamMembersDropdown from './admin_team_members_dropdown.jsx'; import ResetPasswordModal from './reset_password_modal.jsx'; +import FormError from 'components/form_error.jsx'; + +import AdminStore from 'stores/admin_store.jsx'; +import TeamStore from 'stores/team_store.jsx'; +import UserStore from 'stores/user_store.jsx'; + +import {searchUsers, loadProfilesAndTeamMembers, loadTeamMembersForProfilesList} from 'actions/user_actions.jsx'; +import {getTeamStats} from 'utils/async_client.jsx'; -import {FormattedMessage} from 'react-intl'; +import Constants from 'utils/constants.jsx'; +import * as Utils from 'utils/utils.jsx'; import React from 'react'; +import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; + +const USERS_PER_PAGE = 50; export default class UserList extends React.Component { static get propTypes() { @@ -23,34 +32,49 @@ export default class UserList extends React.Component { super(props); this.onAllTeamsChange = this.onAllTeamsChange.bind(this); + this.onStatsChange = this.onStatsChange.bind(this); + this.onUsersChange = this.onUsersChange.bind(this); + this.onTeamChange = this.onTeamChange.bind(this); - this.getTeamProfiles = this.getTeamProfiles.bind(this); - this.getCurrentTeamProfiles = this.getCurrentTeamProfiles.bind(this); this.doPasswordReset = this.doPasswordReset.bind(this); this.doPasswordResetDismiss = this.doPasswordResetDismiss.bind(this); this.doPasswordResetSubmit = this.doPasswordResetSubmit.bind(this); - this.getTeamMemberForUser = this.getTeamMemberForUser.bind(this); + this.nextPage = this.nextPage.bind(this); + this.search = this.search.bind(this); + this.loadComplete = this.loadComplete.bind(this); + + const stats = TeamStore.getStats(this.props.params.team); this.state = { team: AdminStore.getTeam(this.props.params.team), - users: null, - teamMembers: null, + users: [], + teamMembers: TeamStore.getMembersInTeam(this.props.params.team), + total: stats.member_count, serverError: null, showPasswordModal: false, + loading: true, user: null }; } componentDidMount() { - this.getCurrentTeamProfiles(); - AdminStore.addAllTeamsChangeListener(this.onAllTeamsChange); + UserStore.addInTeamChangeListener(this.onUsersChange); + TeamStore.addChangeListener(this.onTeamChange); + TeamStore.addStatsChangeListener(this.onStatsChange); + + loadProfilesAndTeamMembers(0, Constants.PROFILE_CHUNK_SIZE, this.props.params.team, this.loadComplete); + getTeamStats(this.props.params.team); } componentWillReceiveProps(nextProps) { if (nextProps.params.team !== this.props.params.team) { + const stats = TeamStore.getStats(nextProps.params.team); this.setState({ - team: AdminStore.getTeam(nextProps.params.team) + team: AdminStore.getTeam(nextProps.params.team), + users: [], + teamMembers: TeamStore.getMembersInTeam(nextProps.params.team), + total: stats.member_count }); this.getTeamProfiles(nextProps.params.team); @@ -59,6 +83,13 @@ export default class UserList extends React.Component { componentWillUnmount() { AdminStore.removeAllTeamsChangeListener(this.onAllTeamsChange); + UserStore.removeInTeamChangeListener(this.onUsersChange); + TeamStore.removeChangeListener(this.onTeamChange); + TeamStore.removeStatsChangeListener(this.onStatsChange); + } + + loadComplete() { + this.setState({loading: false}); } onAllTeamsChange() { @@ -67,59 +98,21 @@ export default class UserList extends React.Component { }); } - getCurrentTeamProfiles() { - this.getTeamProfiles(this.props.params.team); + onStatsChange() { + const stats = TeamStore.getStats(this.props.params.team); + this.setState({total: stats.member_count}); } - getTeamProfiles(teamId) { - Client.getTeamMembers( - teamId, - (data) => { - this.setState({ - teamMembers: data - }); - }, - (err) => { - this.setState({ - teamMembers: null, - serverError: err.message - }); - } - ); - - Client.getProfilesForTeam( - teamId, - (users) => { - var memberList = []; - for (var id in users) { - if (users.hasOwnProperty(id)) { - memberList.push(users[id]); - } - } - - memberList.sort((a, b) => { - if (a.username < b.username) { - return -1; - } + onUsersChange() { + this.setState({users: UserStore.getProfileListInTeam(this.props.params.team)}); + } - if (a.username > b.username) { - return 1; - } + onTeamChange() { + this.setState({teamMembers: TeamStore.getMembersInTeam(this.props.params.team)}); + } - return 0; - }); - - this.setState({ - users: memberList - }); - }, - (err) => { - this.setState({ - users: null, - serverError: err.message - }); - } - ); + nextPage(page) { + loadProfilesAndTeamMembers((page + 1) * USERS_PER_PAGE, USERS_PER_PAGE, this.props.params.team); } doPasswordReset(user) { @@ -144,20 +137,21 @@ export default class UserList extends React.Component { }); } - getTeamMemberForUser(userId) { - if (this.state.teamMembers) { - for (const index in this.state.teamMembers) { - if (this.state.teamMembers.hasOwnProperty(index)) { - var teamMember = this.state.teamMembers[index]; - - if (teamMember.user_id === userId) { - return teamMember; - } - } - } + search(term) { + if (term === '') { + this.setState({search: false, users: UserStore.getProfileListInTeam(this.props.params.team)}); + return; } - return null; + searchUsers( + term, + this.props.params.team, + {}, + (users) => { + this.setState({loading: true, search: true, users}); + loadTeamMembersForProfilesList(users, this.props.params.team, this.loadComplete); + } + ); } render() { @@ -165,41 +159,71 @@ export default class UserList extends React.Component { return null; } - if (this.state.users == null || this.state.teamMembers == null) { - return ( - <div className='wrapper--fixed'> - <h3> - <FormattedMessage - id='admin.userList.title' - defaultMessage='Users for {team}' - values={{ - team: this.state.team.name - }} - /> - </h3> - <FormError error={this.state.serverError}/> - <LoadingScreen/> - </div> - ); - } + const teamMembers = this.state.teamMembers; + const users = this.state.users; + const actionUserProps = {}; + const extraInfo = {}; + const mfaEnabled = global.window.mm_license.IsLicensed === 'true' && global.window.mm_license.MFA === 'true' && global.window.mm_config.EnableMultifactorAuthentication === 'true'; + + let usersToDisplay; + if (this.state.loading) { + usersToDisplay = null; + } else { + usersToDisplay = []; + + for (let i = 0; i < users.length; i++) { + const user = users[i]; + + if (teamMembers[user.id]) { + usersToDisplay.push(user); + actionUserProps[user.id] = { + teamMember: teamMembers[user.id] + }; + + const info = []; + + if (user.auth_service) { + const service = (user.auth_service === Constants.LDAP_SERVICE || user.auth_service === Constants.SAML_SERVICE) ? user.auth_service.toUpperCase() : Utils.toTitleCase(user.auth_service); + info.push( + <FormattedHTMLMessage + id='admin.user_item.authServiceNotEmail' + defaultMessage='<strong>Sign-in Method:</strong> {service}' + values={{ + service + }} + /> + ); + } else { + info.push( + <FormattedHTMLMessage + id='admin.user_item.authServiceEmail' + defaultMessage='<strong>Sign-in Method:</strong> Email' + /> + ); + } - var memberList = this.state.users.map((user) => { - var teamMember = this.getTeamMemberForUser(user.id); + if (mfaEnabled) { + if (user.mfa_active) { + info.push( + <FormattedHTMLMessage + id='admin.user_item.mfaYes' + defaultMessage='<strong>MFA</strong>: Yes' + /> + ); + } else { + info.push( + <FormattedHTMLMessage + id='admin.user_item.mfaNo' + defaultMessage='<strong>MFA</strong>: No' + /> + ); + } + } - if (!teamMember || teamMember.delete_at > 0) { - return null; + extraInfo[user.id] = info; + } } - - return ( - <UserItem - team={this.state.team} - key={'user_' + user.id} - user={user} - teamMember={teamMember} - refreshProfiles={this.getCurrentTeamProfiles} - doPasswordReset={this.doPasswordReset} - />); - }); + } return ( <div className='wrapper--fixed'> @@ -209,7 +233,7 @@ export default class UserList extends React.Component { defaultMessage='Users for {team} ({count})' values={{ team: this.state.team.name, - count: this.state.users.length + count: this.state.total }} /> </h3> @@ -219,7 +243,20 @@ export default class UserList extends React.Component { role='form' > <div className='more-modal__list member-list-holder'> - {memberList} + <SearchableUserList + users={usersToDisplay} + usersPerPage={USERS_PER_PAGE} + total={this.state.total} + extraInfo={extraInfo} + nextPage={this.nextPage} + search={this.search} + actions={[AdminTeamMembersDropdown]} + actionProps={{ + refreshProfiles: this.getCurrentTeamProfiles, + doPasswordReset: this.doPasswordReset + }} + actionUserProps={actionUserProps} + /> </div> </form> <ResetPasswordModal diff --git a/webapp/components/analytics/system_analytics.jsx b/webapp/components/analytics/system_analytics.jsx index 5bd8b1d28..2b4b5b48f 100644 --- a/webapp/components/analytics/system_analytics.jsx +++ b/webapp/components/analytics/system_analytics.jsx @@ -82,6 +82,7 @@ class SystemAnalytics extends React.Component { const stats = this.state.stats; let advancedCounts; + let advancedStats; let advancedGraphs; let banner; if (global.window.mm_license.IsLicensed === 'true') { @@ -130,6 +131,41 @@ class SystemAnalytics extends React.Component { </div> ); + advancedStats = ( + <div className='row'> + <StatisticCount + title={ + <FormattedMessage + id='analytics.system.totalWebsockets' + defaultMessage='Websocket Conns' + /> + } + icon='fa-user' + count={stats[StatTypes.TOTAL_WEBSOCKET_CONNECTIONS]} + /> + <StatisticCount + title={ + <FormattedMessage + id='analytics.system.totalMasterDbConnections' + defaultMessage='Master DB Conns' + /> + } + icon='fa-terminal' + count={stats[StatTypes.TOTAL_MASTER_DB_CONNECTIONS]} + /> + <StatisticCount + title={ + <FormattedMessage + id='analytics.system.totalReadDbConnections' + defaultMessage='Replica DB Conns' + /> + } + icon='fa-terminal' + count={stats[StatTypes.TOTAL_READ_DB_CONNECTIONS]} + /> + </div> + ); + const channelTypeData = formatChannelDoughtnutData(stats[StatTypes.TOTAL_PUBLIC_CHANNELS], stats[StatTypes.TOTAL_PRIVATE_GROUPS], this.props.intl); const postTypeData = formatPostDoughtnutData(stats[StatTypes.TOTAL_FILE_POSTS], stats[StatTypes.TOTAL_HASHTAG_POSTS], stats[StatTypes.TOTAL_POSTS], this.props.intl); @@ -246,6 +282,7 @@ class SystemAnalytics extends React.Component { /> </div> {advancedCounts} + {advancedStats} {advancedGraphs} <div className='row'> <LineChart diff --git a/webapp/components/channel_header.jsx b/webapp/components/channel_header.jsx index bd57271ed..1a8625cd2 100644 --- a/webapp/components/channel_header.jsx +++ b/webapp/components/channel_header.jsx @@ -63,13 +63,15 @@ export default class ChannelHeader extends React.Component { } getStateFromStores() { - const extraInfo = ChannelStore.getExtraInfo(this.props.channelId); + const stats = ChannelStore.getStats(this.props.channelId); + + const users = UserStore.getProfileListInChannel(this.props.channelId); return { channel: ChannelStore.get(this.props.channelId), - memberChannel: ChannelStore.getMember(this.props.channelId), - users: extraInfo.members, - userCount: extraInfo.member_count, + memberChannel: ChannelStore.getMyMember(this.props.channelId), + users, + userCount: stats.member_count, currentUser: UserStore.getCurrentUser(), enableFormatting: PreferenceStore.getBool(Preferences.CATEGORY_ADVANCED_SETTINGS, 'formatting', true), isBusy: WebrtcStore.isBusy() @@ -89,10 +91,10 @@ export default class ChannelHeader extends React.Component { componentDidMount() { ChannelStore.addChangeListener(this.onListenerChange); - ChannelStore.addExtraInfoChangeListener(this.onListenerChange); + ChannelStore.addStatsChangeListener(this.onListenerChange); SearchStore.addSearchChangeListener(this.onListenerChange); PreferenceStore.addChangeListener(this.onListenerChange); - UserStore.addChangeListener(this.onListenerChange); + UserStore.addInChannelChangeListener(this.onListenerChange); UserStore.addStatusesChangeListener(this.onListenerChange); WebrtcStore.addChangedListener(this.onListenerChange); WebrtcStore.addBusyListener(this.onBusy); @@ -102,10 +104,10 @@ export default class ChannelHeader extends React.Component { componentWillUnmount() { ChannelStore.removeChangeListener(this.onListenerChange); - ChannelStore.removeExtraInfoChangeListener(this.onListenerChange); + ChannelStore.removeStatsChangeListener(this.onListenerChange); SearchStore.removeSearchChangeListener(this.onListenerChange); PreferenceStore.removeChangeListener(this.onListenerChange); - UserStore.removeChangeListener(this.onListenerChange); + UserStore.removeInChannelChangeListener(this.onListenerChange); UserStore.removeStatusesChangeListener(this.onListenerChange); WebrtcStore.removeChangedListener(this.onListenerChange); WebrtcStore.removeBusyListener(this.onBusy); @@ -117,10 +119,7 @@ export default class ChannelHeader extends React.Component { } onListenerChange() { - const newState = this.getStateFromStores(); - if (!Utils.areObjectsEqual(newState, this.state)) { - this.setState(newState); - } + this.setState(this.getStateFromStores()); } handleLeave() { @@ -265,7 +264,6 @@ export default class ChannelHeader extends React.Component { </Popover> ); let channelTitle = channel.display_name; - const currentId = this.state.currentUser.id; const isAdmin = TeamStore.isTeamAdminForCurrentTeam() || UserStore.isSystemAdminForCurrentUser(); const isSystemAdmin = UserStore.isSystemAdminForCurrentUser(); const isDirect = (this.state.channel.type === 'D'); @@ -273,13 +271,8 @@ export default class ChannelHeader extends React.Component { if (isDirect) { const userMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia; - let contact; - if (this.state.users.length > 1) { - if (this.state.users[0].id === currentId) { - contact = this.state.users[1]; - } else { - contact = this.state.users[0]; - } + const contact = this.state.users[0]; + if (contact) { channelTitle = Utils.displayUsername(contact.id); } diff --git a/webapp/components/channel_invite_button.jsx b/webapp/components/channel_invite_button.jsx index 59eda8e41..290c2bea4 100644 --- a/webapp/components/channel_invite_button.jsx +++ b/webapp/components/channel_invite_button.jsx @@ -1,13 +1,12 @@ // Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import React from 'react'; +import SpinnerButton from 'components/spinner_button.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; -import Client from 'client/web_client.jsx'; +import {addUserToChannel} from 'actions/channel_actions.jsx'; +import React from 'react'; import {FormattedMessage} from 'react-intl'; -import SpinnerButton from 'components/spinner_button.jsx'; export default class ChannelInviteButton extends React.Component { static get propTypes() { @@ -37,7 +36,7 @@ export default class ChannelInviteButton extends React.Component { addingUser: true }); - Client.addChannelMember( + addUserToChannel( this.props.channel.id, this.props.user.id, () => { @@ -46,7 +45,6 @@ export default class ChannelInviteButton extends React.Component { }); this.props.onInviteError(null); - AsyncClient.getChannelExtraInfo(); }, (err) => { this.setState({ diff --git a/webapp/components/channel_invite_modal.jsx b/webapp/components/channel_invite_modal.jsx index c7c1906a5..99a4b9313 100644 --- a/webapp/components/channel_invite_modal.jsx +++ b/webapp/components/channel_invite_modal.jsx @@ -1,124 +1,85 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import $ from 'jquery'; import ChannelInviteButton from './channel_invite_button.jsx'; -import FilteredUserList from './filtered_user_list.jsx'; +import SearchableUserList from './searchable_user_list.jsx'; import LoadingScreen from './loading_screen.jsx'; import ChannelStore from 'stores/channel_store.jsx'; import UserStore from 'stores/user_store.jsx'; +import TeamStore from 'stores/team_store.jsx'; + +import {searchUsers} from 'actions/user_actions.jsx'; import * as Utils from 'utils/utils.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; -import {FormattedMessage} from 'react-intl'; - +import React from 'react'; import {Modal} from 'react-bootstrap'; +import {FormattedMessage} from 'react-intl'; -import React from 'react'; +const USERS_PER_PAGE = 50; export default class ChannelInviteModal extends React.Component { constructor(props) { super(props); - this.onListenerChange = this.onListenerChange.bind(this); - this.getStateFromStores = this.getStateFromStores.bind(this); + this.onChange = this.onChange.bind(this); this.handleInviteError = this.handleInviteError.bind(this); + this.nextPage = this.nextPage.bind(this); + this.search = this.search.bind(this); - this.state = this.getStateFromStores(); - } - shouldComponentUpdate(nextProps, nextState) { - if (!this.props.show && !nextProps.show) { - return false; - } - - if (!Utils.areObjectsEqual(this.props, nextProps)) { - return true; - } - - if (!Utils.areObjectsEqual(this.state, nextState)) { - return true; - } - - return false; - } - getStateFromStores() { - const users = UserStore.getActiveOnlyProfiles(); - - if ($.isEmptyObject(users)) { - return { - loading: true - }; - } - - // make sure we have all members of this channel before rendering - const extraInfo = ChannelStore.getCurrentExtraInfo(); - if (extraInfo.member_count !== extraInfo.members.length) { - AsyncClient.getChannelExtraInfo(this.props.channel.id, -1); - - return { - loading: true - }; - } - - const currentUser = UserStore.getCurrentUser(); - if (!currentUser) { - return { - loading: true - }; - } - - const currentMember = ChannelStore.getCurrentMember(); - if (!currentMember) { - return { - loading: true - }; - } + this.term = ''; - const memberIds = extraInfo.members.map((user) => user.id); + const channelStats = ChannelStore.getStats(props.channel.id); + const teamStats = TeamStore.getCurrentStats(); - var nonmembers = []; - for (var id in users) { - if (memberIds.indexOf(id) === -1) { - nonmembers.push(users[id]); - } - } - - nonmembers.sort((a, b) => { - return a.username.localeCompare(b.username); - }); - - return { - nonmembers, - loading: false, - currentUser, - currentMember + this.state = { + users: [], + total: teamStats.member_count - channelStats.member_count, + search: false }; } + componentWillReceiveProps(nextProps) { if (!this.props.show && nextProps.show) { - ChannelStore.addExtraInfoChangeListener(this.onListenerChange); - ChannelStore.addChangeListener(this.onListenerChange); - UserStore.addChangeListener(this.onListenerChange); - this.onListenerChange(); + TeamStore.addStatsChangeListener(this.onChange); + ChannelStore.addStatsChangeListener(this.onChange); + UserStore.addNotInChannelChangeListener(this.onChange); + UserStore.addStatusesChangeListener(this.onChange); + + this.onChange(); + AsyncClient.getProfilesNotInChannel(this.props.channel.id, 0); + AsyncClient.getTeamStats(TeamStore.getCurrentId()); } else if (this.props.show && !nextProps.show) { - ChannelStore.removeExtraInfoChangeListener(this.onListenerChange); - ChannelStore.removeChangeListener(this.onListenerChange); - UserStore.removeChangeListener(this.onListenerChange); + TeamStore.removeStatsChangeListener(this.onChange); + ChannelStore.removeStatsChangeListener(this.onChange); + UserStore.removeNotInChannelChangeListener(this.onChange); + UserStore.removeStatusesChangeListener(this.onChange); } } + componentWillUnmount() { - ChannelStore.removeExtraInfoChangeListener(this.onListenerChange); - ChannelStore.removeChangeListener(this.onListenerChange); - UserStore.removeChangeListener(this.onListenerChange); + ChannelStore.removeStatsChangeListener(this.onChange); + ChannelStore.removeChangeListener(this.onChange); + UserStore.removeNotInChannelChangeListener(this.onChange); } - onListenerChange() { - var newState = this.getStateFromStores(); - if (!Utils.areObjectsEqual(this.state, newState)) { - this.setState(newState); + + onChange() { + if (this.state.search) { + this.search(this.term); + return; } + + const channelStats = ChannelStore.getStats(this.props.channel.id); + const teamStats = TeamStore.getCurrentStats(); + + this.setState({ + users: UserStore.getProfileListNotInChannel(this.props.channel.id), + total: teamStats.member_count - channelStats.member_count + }); } + handleInviteError(err) { if (err) { this.setState({ @@ -130,6 +91,29 @@ export default class ChannelInviteModal extends React.Component { }); } } + + nextPage(page) { + AsyncClient.getProfilesNotInChannel(this.props.channel.id, (page + 1) * USERS_PER_PAGE, USERS_PER_PAGE); + } + + search(term) { + this.term = term; + + if (term === '') { + this.setState({users: UserStore.getProfileListNotInChannel(), search: false}); + return; + } + + searchUsers( + term, + TeamStore.getCurrentId(), + {not_in_channel: this.props.channel.id}, + (users) => { + this.setState({search: true, users}); + } + ); + } + render() { var inviteError = null; if (this.state.inviteError) { @@ -145,9 +129,13 @@ export default class ChannelInviteModal extends React.Component { maxHeight = Utils.windowHeight() - 300; } content = ( - <FilteredUserList + <SearchableUserList style={{maxHeight}} - users={this.state.nonmembers} + users={this.state.users} + usersPerPage={USERS_PER_PAGE} + total={this.state.total} + nextPage={this.nextPage} + search={this.search} actions={[ChannelInviteButton]} actionProps={{ channel: this.props.channel, diff --git a/webapp/components/channel_members_modal.jsx b/webapp/components/channel_members_modal.jsx index d20c00623..511209b42 100644 --- a/webapp/components/channel_members_modal.jsx +++ b/webapp/components/channel_members_modal.jsx @@ -1,122 +1,89 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import FilteredUserList from './filtered_user_list.jsx'; +import SearchableUserList from './searchable_user_list.jsx'; import LoadingScreen from './loading_screen.jsx'; import ChannelInviteModal from './channel_invite_modal.jsx'; import UserStore from 'stores/user_store.jsx'; import ChannelStore from 'stores/channel_store.jsx'; +import TeamStore from 'stores/team_store.jsx'; + +import {searchUsers} from 'actions/user_actions.jsx'; +import {removeUserFromChannel} from 'actions/channel_actions.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; -import Client from 'client/web_client.jsx'; import * as Utils from 'utils/utils.jsx'; -import {FormattedMessage} from 'react-intl'; - +import React from 'react'; import {Modal} from 'react-bootstrap'; +import {FormattedMessage} from 'react-intl'; -import React from 'react'; +const USERS_PER_PAGE = 50; export default class ChannelMembersModal extends React.Component { constructor(props) { super(props); - this.getStateFromStores = this.getStateFromStores.bind(this); this.onChange = this.onChange.bind(this); this.handleRemove = this.handleRemove.bind(this); - this.createRemoveMemberButton = this.createRemoveMemberButton.bind(this); + this.search = this.search.bind(this); + this.nextPage = this.nextPage.bind(this); - // the rest of the state gets populated when the modal is shown - this.state = { - showInviteModal: false - }; - } - shouldComponentUpdate(nextProps, nextState) { - if (!Utils.areObjectsEqual(this.props, nextProps)) { - return true; - } + this.term = ''; - if (!Utils.areObjectsEqual(this.state, nextState)) { - return true; - } - - return false; - } - getStateFromStores() { - const extraInfo = ChannelStore.getCurrentExtraInfo(); - const profiles = UserStore.getActiveOnlyProfiles(); - - if (extraInfo.member_count !== extraInfo.members.length) { - AsyncClient.getChannelExtraInfo(this.props.channel.id, -1); - - return { - loading: true - }; - } - - const memberList = extraInfo.members.map((member) => { - return profiles[member.id]; - }); - - function compareByUsername(a, b) { - if (a.username < b.username) { - return -1; - } else if (a.username > b.username) { - return 1; - } + const stats = ChannelStore.getStats(props.channel.id); - return 0; - } - - memberList.sort(compareByUsername); - - return { - memberList, - loading: false + this.state = { + users: [], + total: stats.member_count, + showInviteModal: false, + search: false }; } + componentWillReceiveProps(nextProps) { if (!this.props.show && nextProps.show) { - ChannelStore.addExtraInfoChangeListener(this.onChange); - ChannelStore.addChangeListener(this.onChange); + ChannelStore.addStatsChangeListener(this.onChange); + UserStore.addInChannelChangeListener(this.onChange); + UserStore.addStatusesChangeListener(this.onChange); this.onChange(); + AsyncClient.getProfilesInChannel(this.props.channel.id, 0); } else if (this.props.show && !nextProps.show) { - ChannelStore.removeExtraInfoChangeListener(this.onChange); - ChannelStore.removeChangeListener(this.onChange); + ChannelStore.removeStatsChangeListener(this.onChange); + UserStore.removeInChannelChangeListener(this.onChange); + UserStore.removeStatusesChangeListener(this.onChange); } } + onChange() { - const newState = this.getStateFromStores(); - if (!Utils.areObjectsEqual(this.state, newState)) { - this.setState(newState); + if (this.state.search) { + this.search(this.term); + return; } + + const stats = ChannelStore.getStats(this.props.channel.id); + this.setState({ + users: UserStore.getProfileListInChannel(this.props.channel.id), + total: stats.member_count + }); } + handleRemove(user) { const userId = user.id; - Client.removeChannelMember( - ChannelStore.getCurrentId(), + removeUserFromChannel( + this.props.channel.id, userId, - () => { - const memberList = this.state.memberList.slice(); - for (let i = 0; i < memberList.length; i++) { - if (userId === memberList[i].id) { - memberList.splice(i, 1); - break; - } - } - - this.setState({memberList}); - AsyncClient.getChannelExtraInfo(); - }, + null, (err) => { this.setState({inviteError: err.message}); } ); } + createRemoveMemberButton({user}) { if (user.id === UserStore.getCurrentId()) { return null; @@ -135,6 +102,29 @@ export default class ChannelMembersModal extends React.Component { </button> ); } + + nextPage(page) { + AsyncClient.getProfilesInChannel(this.props.channel.id, (page + 1) * USERS_PER_PAGE, USERS_PER_PAGE); + } + + search(term) { + this.term = term; + + if (term === '') { + this.setState({users: UserStore.getProfileListInChannel(this.props.channel.id), search: false}); + return; + } + + searchUsers( + term, + TeamStore.getCurrentId(), + {in_channel: this.props.channel.id}, + (users) => { + this.setState({search: true, users}); + } + ); + } + render() { let content; if (this.state.loading) { @@ -151,9 +141,13 @@ export default class ChannelMembersModal extends React.Component { } content = ( - <FilteredUserList + <SearchableUserList style={{maxHeight}} - users={this.state.memberList} + users={this.state.users} + usersPerPage={USERS_PER_PAGE} + total={this.state.total} + nextPage={this.nextPage} + search={this.search} actions={removeButton} /> ); diff --git a/webapp/components/channel_notifications_modal.jsx b/webapp/components/channel_notifications_modal.jsx index 35a2e4087..91563a096 100644 --- a/webapp/components/channel_notifications_modal.jsx +++ b/webapp/components/channel_notifications_modal.jsx @@ -65,9 +65,9 @@ export default class ChannelNotificationsModal extends React.Component { Client.updateChannelNotifyProps(data, () => { // YUCK - var member = ChannelStore.getMember(channelId); + var member = ChannelStore.getMyMember(channelId); member.notify_props.desktop = notifyLevel; - ChannelStore.setChannelMember(member); + ChannelStore.storeMyChannelMember(member); this.updateSection(''); }, (err) => { @@ -256,13 +256,13 @@ export default class ChannelNotificationsModal extends React.Component { mark_unread: markUnreadLevel }; - //TODO: This should be fixed, moved to event_helpers + //TODO: This should be fixed, moved to actions Client.updateChannelNotifyProps(data, () => { // Yuck... - var member = ChannelStore.getMember(channelId); + var member = ChannelStore.getMyMember(channelId); member.notify_props.mark_unread = markUnreadLevel; - ChannelStore.setChannelMember(member); + ChannelStore.storeMyChannelMember(member); this.updateSection(''); }, (err) => { diff --git a/webapp/components/channel_switch_modal.jsx b/webapp/components/channel_switch_modal.jsx index ec257bab5..7d15a9c45 100644 --- a/webapp/components/channel_switch_modal.jsx +++ b/webapp/components/channel_switch_modal.jsx @@ -8,12 +8,13 @@ import SwitchChannelProvider from './suggestion/switch_channel_provider.jsx'; import {FormattedMessage} from 'react-intl'; import {Modal} from 'react-bootstrap'; +import {goToChannel, openDirectChannelToUser} from 'actions/channel_actions.jsx'; + import ChannelStore from 'stores/channel_store.jsx'; import UserStore from 'stores/user_store.jsx'; import Constants from 'utils/constants.jsx'; import * as Utils from 'utils/utils.jsx'; -import * as ChannelActions from 'actions/channel_actions.jsx'; import React from 'react'; import $ from 'jquery'; @@ -27,30 +28,14 @@ export default class SwitchChannelModal extends React.Component { this.onExited = this.onExited.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); this.handleSubmit = this.handleSubmit.bind(this); - this.handleDmUserChange = this.handleDmUserChange.bind(this); this.suggestionProviders = [new SwitchChannelProvider()]; this.state = { - dmUsers: UserStore.getDirectProfiles(), text: '', error: '' }; } - componentDidMount() { - UserStore.addDmListChangeListener(this.handleDmUserChange); - } - - componentWillUnmount() { - UserStore.removeDmListChangeListener(this.handleDmUserChange); - } - - handleDmUserChange() { - this.setState({ - dmUsers: UserStore.getDirectProfiles() - }); - } - componentDidUpdate(prevProps) { if (this.props.show && !prevProps.show) { const textbox = this.refs.search.getTextbox(); @@ -97,18 +82,13 @@ export default class SwitchChannelModal extends React.Component { const name = this.state.text.trim(); let channel = null; + // TODO: Replace this hack with something reasonable if (name.indexOf(Utils.localizeMessage('channel_switch_modal.dm', '(Direct Message)')) > 0) { const dmUsername = name.substr(0, name.indexOf(Utils.localizeMessage('channel_switch_modal.dm', '(Direct Message)')) - 1); - let user = null; - for (const id in this.state.dmUsers) { - if (this.state.dmUsers[id].username === dmUsername) { - user = this.state.dmUsers[id]; - break; - } - } + const user = UserStore.getProfileByUsername(dmUsername); if (user) { - Utils.openDirectChannelToUser( + openDirectChannelToUser( user, (ch) => { channel = ch; @@ -123,7 +103,7 @@ export default class SwitchChannelModal extends React.Component { } if (channel !== null) { - ChannelActions.goToChannel(channel); + goToChannel(channel); this.onHide(); } else if (this.state.text !== '') { this.setState({ diff --git a/webapp/components/edit_post_modal.jsx b/webapp/components/edit_post_modal.jsx index 44050bb12..263fd31c2 100644 --- a/webapp/components/edit_post_modal.jsx +++ b/webapp/components/edit_post_modal.jsx @@ -1,26 +1,27 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import $ from 'jquery'; -import ReactDOM from 'react-dom'; -import Client from 'client/web_client.jsx'; -import * as UserAgent from 'utils/user_agent.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; -import * as GlobalActions from 'actions/global_actions.jsx'; import Textbox from './textbox.jsx'; + import BrowserStore from 'stores/browser_store.jsx'; import PostStore from 'stores/post_store.jsx'; import MessageHistoryStore from 'stores/message_history_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; -import * as Utils from 'utils/utils.jsx'; - -import Constants from 'utils/constants.jsx'; -import {FormattedMessage} from 'react-intl'; +import * as GlobalActions from 'actions/global_actions.jsx'; +import {loadPosts} from 'actions/post_actions.jsx'; -var KeyCodes = Constants.KeyCodes; +import Client from 'client/web_client.jsx'; +import * as UserAgent from 'utils/user_agent.jsx'; +import * as AsyncClient from 'utils/async_client.jsx'; +import * as Utils from 'utils/utils.jsx'; +import Constants from 'utils/constants.jsx'; +const KeyCodes = Constants.KeyCodes; +import $ from 'jquery'; import React from 'react'; +import ReactDOM from 'react-dom'; +import {FormattedMessage} from 'react-intl'; export default class EditPostModal extends React.Component { constructor(props) { @@ -77,7 +78,7 @@ export default class EditPostModal extends React.Component { Client.updatePost( updatedPost, () => { - AsyncClient.getPosts(updatedPost.channel_id); + loadPosts(updatedPost.channel_id); window.scrollTo(0, 0); }, (err) => { diff --git a/webapp/components/emoji/components/emoji_list.jsx b/webapp/components/emoji/components/emoji_list.jsx index 340fc6afc..76c509f12 100644 --- a/webapp/components/emoji/components/emoji_list.jsx +++ b/webapp/components/emoji/components/emoji_list.jsx @@ -1,16 +1,20 @@ // Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import React from 'react'; +import EmojiListItem from './emoji_list_item.jsx'; +import LoadingScreen from 'components/loading_screen.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; import EmojiStore from 'stores/emoji_store.jsx'; +import UserStore from 'stores/user_store.jsx'; + +import {loadEmoji} from 'actions/emoji_actions.jsx'; + +import * as AsyncClient from 'utils/async_client.jsx'; import * as Utils from 'utils/utils.jsx'; -import {FormattedMessage} from 'react-intl'; -import EmojiListItem from './emoji_list_item.jsx'; +import React from 'react'; import {Link} from 'react-router'; -import LoadingScreen from 'components/loading_screen.jsx'; +import {FormattedMessage} from 'react-intl'; export default class EmojiList extends React.Component { static get propTypes() { @@ -24,28 +28,30 @@ export default class EmojiList extends React.Component { super(props); this.handleEmojiChange = this.handleEmojiChange.bind(this); - + this.handleUserChange = this.handleUserChange.bind(this); this.deleteEmoji = this.deleteEmoji.bind(this); - this.updateFilter = this.updateFilter.bind(this); this.state = { emojis: EmojiStore.getCustomEmojiMap(), loading: !EmojiStore.hasReceivedCustomEmojis(), - filter: '' + filter: '', + users: UserStore.getProfiles() }; } componentDidMount() { EmojiStore.addChangeListener(this.handleEmojiChange); + UserStore.addChangeListener(this.handleUserChange); if (window.mm_config.EnableCustomEmoji === 'true') { - AsyncClient.listEmoji(); + loadEmoji(); } } componentWillUnmount() { EmojiStore.removeChangeListener(this.handleEmojiChange); + UserStore.removeChangeListener(this.handleUserChange); } handleEmojiChange() { @@ -55,6 +61,10 @@ export default class EmojiList extends React.Component { }); } + handleUserChange() { + this.setState({users: UserStore.getProfiles()}); + } + updateFilter(e) { this.setState({ filter: e.target.value @@ -98,6 +108,7 @@ export default class EmojiList extends React.Component { emoji={emoji} onDelete={onDelete} filter={filter} + creator={this.state.users[emoji.creator_id] || {}} /> ); } diff --git a/webapp/components/emoji/components/emoji_list_item.jsx b/webapp/components/emoji/components/emoji_list_item.jsx index 0428f0286..dc27f3691 100644 --- a/webapp/components/emoji/components/emoji_list_item.jsx +++ b/webapp/components/emoji/components/emoji_list_item.jsx @@ -4,7 +4,7 @@ import React from 'react'; import EmojiStore from 'stores/emoji_store.jsx'; -import UserStore from 'stores/user_store.jsx'; + import * as Utils from 'utils/utils.jsx'; import {FormattedMessage} from 'react-intl'; @@ -14,7 +14,8 @@ export default class EmojiListItem extends React.Component { return { emoji: React.PropTypes.object.isRequired, onDelete: React.PropTypes.func.isRequired, - filter: React.PropTypes.string + filter: React.PropTypes.string, + creator: React.PropTypes.object.isRequired }; } @@ -22,10 +23,6 @@ export default class EmojiListItem extends React.Component { super(props); this.handleDelete = this.handleDelete.bind(this); - - this.state = { - creator: UserStore.getProfile(this.props.emoji.creator_id) - }; } handleDelete(e) { @@ -57,7 +54,7 @@ export default class EmojiListItem extends React.Component { render() { const emoji = this.props.emoji; - const creator = this.state.creator; + const creator = this.props.creator; const filter = this.props.filter ? this.props.filter.toLowerCase() : ''; if (!this.matchesFilter(emoji, creator, filter)) { diff --git a/webapp/components/integrations/components/installed_command.jsx b/webapp/components/integrations/components/installed_command.jsx index 658126f19..f149a21ac 100644 --- a/webapp/components/integrations/components/installed_command.jsx +++ b/webapp/components/integrations/components/installed_command.jsx @@ -2,9 +2,6 @@ // See License.txt for license information. import React from 'react'; - -import * as Utils from 'utils/utils.jsx'; - import {FormattedMessage} from 'react-intl'; export default class InstalledCommand extends React.Component { @@ -13,7 +10,8 @@ export default class InstalledCommand extends React.Component { command: React.PropTypes.object.isRequired, onRegenToken: React.PropTypes.func.isRequired, onDelete: React.PropTypes.func.isRequired, - filter: React.PropTypes.string + filter: React.PropTypes.string, + creator: React.PropTypes.object.isRequired }; } @@ -113,7 +111,7 @@ export default class InstalledCommand extends React.Component { id='installed_integrations.creation' defaultMessage='Created by {creator} on {createAt, date, full}' values={{ - creator: Utils.displayUsername(command.creator_id), + creator: this.props.creator.username, createAt: command.create_at }} /> diff --git a/webapp/components/integrations/components/installed_commands.jsx b/webapp/components/integrations/components/installed_commands.jsx index f6429c33e..1c5ef9000 100644 --- a/webapp/components/integrations/components/installed_commands.jsx +++ b/webapp/components/integrations/components/installed_commands.jsx @@ -1,16 +1,20 @@ // Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import React from 'react'; +import BackstageList from 'components/backstage/components/backstage_list.jsx'; +import InstalledCommand from './installed_command.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; import IntegrationStore from 'stores/integration_store.jsx'; import TeamStore from 'stores/team_store.jsx'; +import UserStore from 'stores/user_store.jsx'; + +import {loadTeamCommands} from 'actions/integration_actions.jsx'; + +import * as AsyncClient from 'utils/async_client.jsx'; import * as Utils from 'utils/utils.jsx'; -import BackstageList from 'components/backstage/components/backstage_list.jsx'; +import React from 'react'; import {FormattedMessage} from 'react-intl'; -import InstalledCommand from './installed_command.jsx'; export default class InstalledCommands extends React.Component { static get propTypes() { @@ -23,7 +27,7 @@ export default class InstalledCommands extends React.Component { super(props); this.handleIntegrationChange = this.handleIntegrationChange.bind(this); - + this.handleUserChange = this.handleUserChange.bind(this); this.regenCommandToken = this.regenCommandToken.bind(this); this.deleteCommand = this.deleteCommand.bind(this); @@ -31,20 +35,23 @@ export default class InstalledCommands extends React.Component { this.state = { commands: IntegrationStore.getCommands(teamId), - loading: !IntegrationStore.hasReceivedCommands(teamId) + loading: !IntegrationStore.hasReceivedCommands(teamId), + users: UserStore.getProfiles() }; } componentDidMount() { IntegrationStore.addChangeListener(this.handleIntegrationChange); + UserStore.addChangeListener(this.handleUserChange); if (window.mm_config.EnableCommands === 'true') { - AsyncClient.listTeamCommands(); + loadTeamCommands(); } } componentWillUnmount() { IntegrationStore.removeChangeListener(this.handleIntegrationChange); + UserStore.removeChangeListener(this.handleUserChange); } handleIntegrationChange() { @@ -56,6 +63,10 @@ export default class InstalledCommands extends React.Component { }); } + handleUserChange() { + this.setState({users: UserStore.getProfiles()}); + } + regenCommandToken(command) { AsyncClient.regenCommandToken(command.id); } @@ -72,6 +83,7 @@ export default class InstalledCommands extends React.Component { command={command} onRegenToken={this.regenCommandToken} onDelete={this.deleteCommand} + creator={this.state.users[command.creator_id] || {}} /> ); }); diff --git a/webapp/components/integrations/components/installed_incoming_webhook.jsx b/webapp/components/integrations/components/installed_incoming_webhook.jsx index 2b514d5ec..86274c3d6 100644 --- a/webapp/components/integrations/components/installed_incoming_webhook.jsx +++ b/webapp/components/integrations/components/installed_incoming_webhook.jsx @@ -13,7 +13,8 @@ export default class InstalledIncomingWebhook extends React.Component { return { incomingWebhook: React.PropTypes.object.isRequired, onDelete: React.PropTypes.func.isRequired, - filter: React.PropTypes.string + filter: React.PropTypes.string, + creator: React.PropTypes.object.isRequired }; } @@ -108,7 +109,7 @@ export default class InstalledIncomingWebhook extends React.Component { id='installed_integrations.creation' defaultMessage='Created by {creator} on {createAt, date, full}' values={{ - creator: Utils.displayUsername(incomingWebhook.user_id), + creator: this.props.creator.username, createAt: incomingWebhook.create_at }} /> diff --git a/webapp/components/integrations/components/installed_incoming_webhooks.jsx b/webapp/components/integrations/components/installed_incoming_webhooks.jsx index b14d1e3e8..243195b8b 100644 --- a/webapp/components/integrations/components/installed_incoming_webhooks.jsx +++ b/webapp/components/integrations/components/installed_incoming_webhooks.jsx @@ -1,16 +1,20 @@ // Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import React from 'react'; +import BackstageList from 'components/backstage/components/backstage_list.jsx'; +import InstalledIncomingWebhook from './installed_incoming_webhook.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; import IntegrationStore from 'stores/integration_store.jsx'; import TeamStore from 'stores/team_store.jsx'; +import UserStore from 'stores/user_store.jsx'; + +import {loadIncomingHooks} from 'actions/integration_actions.jsx'; + +import * as AsyncClient from 'utils/async_client.jsx'; import * as Utils from 'utils/utils.jsx'; -import BackstageList from 'components/backstage/components/backstage_list.jsx'; +import React from 'react'; import {FormattedMessage} from 'react-intl'; -import InstalledIncomingWebhook from './installed_incoming_webhook.jsx'; export default class InstalledIncomingWebhooks extends React.Component { static get propTypes() { @@ -23,27 +27,30 @@ export default class InstalledIncomingWebhooks extends React.Component { super(props); this.handleIntegrationChange = this.handleIntegrationChange.bind(this); - + this.handleUserChange = this.handleUserChange.bind(this); this.deleteIncomingWebhook = this.deleteIncomingWebhook.bind(this); const teamId = TeamStore.getCurrentId(); this.state = { incomingWebhooks: IntegrationStore.getIncomingWebhooks(teamId), - loading: !IntegrationStore.hasReceivedIncomingWebhooks(teamId) + loading: !IntegrationStore.hasReceivedIncomingWebhooks(teamId), + users: UserStore.getProfiles() }; } componentDidMount() { IntegrationStore.addChangeListener(this.handleIntegrationChange); + UserStore.addChangeListener(this.handleUserChange); if (window.mm_config.EnableIncomingWebhooks === 'true') { - AsyncClient.listIncomingHooks(); + loadIncomingHooks(); } } componentWillUnmount() { IntegrationStore.removeChangeListener(this.handleIntegrationChange); + UserStore.removeChangeListener(this.handleUserChange); } handleIntegrationChange() { @@ -55,6 +62,12 @@ export default class InstalledIncomingWebhooks extends React.Component { }); } + handleUserChange() { + this.setState({ + users: UserStore.getProfiles() + }); + } + deleteIncomingWebhook(incomingWebhook) { AsyncClient.deleteIncomingHook(incomingWebhook.id); } @@ -66,6 +79,7 @@ export default class InstalledIncomingWebhooks extends React.Component { key={incomingWebhook.id} incomingWebhook={incomingWebhook} onDelete={this.deleteIncomingWebhook} + creator={this.state.users[incomingWebhook.user_id] || {}} /> ); }); diff --git a/webapp/components/integrations/components/installed_outgoing_webhook.jsx b/webapp/components/integrations/components/installed_outgoing_webhook.jsx index 664439843..3ff2c01a4 100644 --- a/webapp/components/integrations/components/installed_outgoing_webhook.jsx +++ b/webapp/components/integrations/components/installed_outgoing_webhook.jsx @@ -4,7 +4,6 @@ import React from 'react'; import ChannelStore from 'stores/channel_store.jsx'; -import * as Utils from 'utils/utils.jsx'; import {FormattedMessage} from 'react-intl'; @@ -14,7 +13,8 @@ export default class InstalledOutgoingWebhook extends React.Component { outgoingWebhook: React.PropTypes.object.isRequired, onRegenToken: React.PropTypes.func.isRequired, onDelete: React.PropTypes.func.isRequired, - filter: React.PropTypes.string + filter: React.PropTypes.string, + creator: React.PropTypes.object.isRequired }; } @@ -195,7 +195,7 @@ export default class InstalledOutgoingWebhook extends React.Component { id='installed_integrations.creation' defaultMessage='Created by {creator} on {createAt, date, full}' values={{ - creator: Utils.displayUsername(outgoingWebhook.creator_id), + creator: this.props.creator.username, createAt: outgoingWebhook.create_at }} /> diff --git a/webapp/components/integrations/components/installed_outgoing_webhooks.jsx b/webapp/components/integrations/components/installed_outgoing_webhooks.jsx index 214e60a48..21176f8b7 100644 --- a/webapp/components/integrations/components/installed_outgoing_webhooks.jsx +++ b/webapp/components/integrations/components/installed_outgoing_webhooks.jsx @@ -1,16 +1,20 @@ // Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import React from 'react'; +import BackstageList from 'components/backstage/components/backstage_list.jsx'; +import InstalledOutgoingWebhook from './installed_outgoing_webhook.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; import IntegrationStore from 'stores/integration_store.jsx'; import TeamStore from 'stores/team_store.jsx'; +import UserStore from 'stores/user_store.jsx'; + +import {loadOutgoingHooks} from 'actions/integration_actions.jsx'; + import * as Utils from 'utils/utils.jsx'; +import * as AsyncClient from 'utils/async_client.jsx'; -import BackstageList from 'components/backstage/components/backstage_list.jsx'; +import React from 'react'; import {FormattedMessage} from 'react-intl'; -import InstalledOutgoingWebhook from './installed_outgoing_webhook.jsx'; export default class InstalledOutgoingWebhooks extends React.Component { static get propTypes() { @@ -23,7 +27,7 @@ export default class InstalledOutgoingWebhooks extends React.Component { super(props); this.handleIntegrationChange = this.handleIntegrationChange.bind(this); - + this.handleUserChange = this.handleUserChange.bind(this); this.regenOutgoingWebhookToken = this.regenOutgoingWebhookToken.bind(this); this.deleteOutgoingWebhook = this.deleteOutgoingWebhook.bind(this); @@ -31,20 +35,23 @@ export default class InstalledOutgoingWebhooks extends React.Component { this.state = { outgoingWebhooks: IntegrationStore.getOutgoingWebhooks(teamId), - loading: !IntegrationStore.hasReceivedOutgoingWebhooks(teamId) + loading: !IntegrationStore.hasReceivedOutgoingWebhooks(teamId), + users: UserStore.getProfiles() }; } componentDidMount() { IntegrationStore.addChangeListener(this.handleIntegrationChange); + UserStore.addChangeListener(this.handleUserChange); if (window.mm_config.EnableOutgoingWebhooks === 'true') { - AsyncClient.listOutgoingHooks(); + loadOutgoingHooks(); } } componentWillUnmount() { IntegrationStore.removeChangeListener(this.handleIntegrationChange); + UserStore.removeChangeListener(this.handleUserChange); } handleIntegrationChange() { @@ -56,6 +63,10 @@ export default class InstalledOutgoingWebhooks extends React.Component { }); } + handleUserChange() { + this.setState({users: UserStore.getProfiles()}); + } + regenOutgoingWebhookToken(outgoingWebhook) { AsyncClient.regenOutgoingHookToken(outgoingWebhook.id); } @@ -72,6 +83,7 @@ export default class InstalledOutgoingWebhooks extends React.Component { outgoingWebhook={outgoingWebhook} onRegenToken={this.regenOutgoingWebhookToken} onDelete={this.deleteOutgoingWebhook} + creator={this.state.users[outgoingWebhook.creator_id] || {}} /> ); }); diff --git a/webapp/components/logged_in.jsx b/webapp/components/logged_in.jsx index 3b712ffe2..824e7b91d 100644 --- a/webapp/components/logged_in.jsx +++ b/webapp/components/logged_in.jsx @@ -1,21 +1,24 @@ // Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import $ from 'jquery'; import LoadingScreen from 'components/loading_screen.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; + import UserStore from 'stores/user_store.jsx'; import BrowserStore from 'stores/browser_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; -import * as Utils from 'utils/utils.jsx'; + import * as GlobalActions from 'actions/global_actions.jsx'; import * as WebSocketActions from 'actions/websocket_actions.jsx'; +import {loadEmoji} from 'actions/emoji_actions.jsx'; + +import * as Utils from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; import {browserHistory} from 'react-router/es6'; const BACKSPACE_CHAR = 8; +import $ from 'jquery'; import React from 'react'; // import the EmojiStore so that it'll register to receive the results of the listEmojis call further down @@ -148,7 +151,7 @@ export default class LoggedIn extends React.Component { // Get custom emoji from the server if (window.mm_config.EnableCustomEmoji === 'true') { - AsyncClient.listEmoji(); + loadEmoji(false); } } diff --git a/webapp/components/member_list_team.jsx b/webapp/components/member_list_team.jsx index 9f18fba33..a3e43af28 100644 --- a/webapp/components/member_list_team.jsx +++ b/webapp/components/member_list_team.jsx @@ -1,62 +1,94 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import FilteredUserList from './filtered_user_list.jsx'; -import TeamMembersDropdown from './team_members_dropdown.jsx'; +import SearchableUserList from 'components/searchable_user_list.jsx'; +import TeamMembersDropdown from 'components/team_members_dropdown.jsx'; + import UserStore from 'stores/user_store.jsx'; import TeamStore from 'stores/team_store.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; + +import {searchUsers, loadProfilesAndTeamMembers, loadTeamMembersForProfilesList} from 'actions/user_actions.jsx'; +import {getTeamStats} from 'utils/async_client.jsx'; + +import Constants from 'utils/constants.jsx'; import React from 'react'; +const USERS_PER_PAGE = 50; + export default class MemberListTeam extends React.Component { constructor(props) { super(props); - this.getUsers = this.getUsers.bind(this); this.onChange = this.onChange.bind(this); - this.onTeamChange = this.onTeamChange.bind(this); + this.onStatsChange = this.onStatsChange.bind(this); + this.search = this.search.bind(this); + this.loadComplete = this.loadComplete.bind(this); + + const stats = TeamStore.getCurrentStats(); this.state = { - users: this.getUsers(), - teamMembers: TeamStore.getMembersForTeam() + users: UserStore.getProfileListInTeam(), + teamMembers: Object.assign([], TeamStore.getMembersInTeam()), + total: stats.member_count, + search: false, + loading: true }; } componentDidMount() { - UserStore.addChangeListener(this.onChange); - TeamStore.addChangeListener(this.onTeamChange); - AsyncClient.getTeamMembers(TeamStore.getCurrentId()); + UserStore.addInTeamChangeListener(this.onChange); + UserStore.addStatusesChangeListener(this.onChange); + TeamStore.addChangeListener(this.onChange); + TeamStore.addStatsChangeListener(this.onStatsChange); + + loadProfilesAndTeamMembers(0, Constants.PROFILE_CHUNK_SIZE, TeamStore.getCurrentId(), this.loadComplete); + getTeamStats(TeamStore.getCurrentId()); } componentWillUnmount() { - UserStore.removeChangeListener(this.onChange); - TeamStore.removeChangeListener(this.onTeamChange); + UserStore.removeInTeamChangeListener(this.onChange); + UserStore.removeStatusesChangeListener(this.onChange); + TeamStore.removeChangeListener(this.onChange); + TeamStore.removeStatsChangeListener(this.onStatsChange); } - getUsers() { - const profiles = UserStore.getProfiles(); - const users = []; + loadComplete() { + this.setState({loading: false}); + } - for (const id of Object.keys(profiles)) { - users.push(profiles[id]); + onChange() { + if (!this.state.search) { + this.setState({users: UserStore.getProfileListInTeam()}); } - users.sort((a, b) => a.username.localeCompare(b.username)); + this.setState({teamMembers: Object.assign([], TeamStore.getMembersInTeam())}); + } - return users; + onStatsChange() { + const stats = TeamStore.getCurrentStats(); + this.setState({total: stats.member_count}); } - onChange() { - this.setState({ - users: this.getUsers() - }); + nextPage(page) { + loadProfilesAndTeamMembers((page + 1) * USERS_PER_PAGE, USERS_PER_PAGE); } - onTeamChange() { - this.setState({ - teamMembers: TeamStore.getMembersForTeam() - }); + search(term) { + if (term === '') { + this.setState({search: false, users: UserStore.getProfileListInTeam()}); + return; + } + + searchUsers( + term, + TeamStore.getCurrentId(), + {}, + (users) => { + this.setState({loading: true, search: true, users}); + loadTeamMembersForProfilesList(users, TeamStore.getCurrentId(), this.loadComplete); + } + ); } render() { @@ -65,12 +97,38 @@ export default class MemberListTeam extends React.Component { teamMembersDropdown = [TeamMembersDropdown]; } + const teamMembers = this.state.teamMembers; + const users = this.state.users; + const actionUserProps = {}; + + let usersToDisplay; + if (this.state.loading) { + usersToDisplay = null; + } else { + usersToDisplay = []; + + for (let i = 0; i < users.length; i++) { + const user = users[i]; + + if (teamMembers[user.id]) { + usersToDisplay.push(user); + actionUserProps[user.id] = { + teamMember: teamMembers[user.id] + }; + } + } + } + return ( - <FilteredUserList + <SearchableUserList style={this.props.style} - users={this.state.users} - teamMembers={this.state.teamMembers} + users={usersToDisplay} + usersPerPage={USERS_PER_PAGE} + total={this.state.total} + nextPage={this.nextPage} + search={this.search} actions={teamMembersDropdown} + actionUserProps={actionUserProps} /> ); } diff --git a/webapp/components/more_direct_channels.jsx b/webapp/components/more_direct_channels.jsx index 24718387e..11849f718 100644 --- a/webapp/components/more_direct_channels.jsx +++ b/webapp/components/more_direct_channels.jsx @@ -1,73 +1,67 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import FilteredUserList from 'components/filtered_user_list.jsx'; +import SearchableUserList from 'components/searchable_user_list.jsx'; import SpinnerButton from 'components/spinner_button.jsx'; -import LoadingScreen from 'components/loading_screen.jsx'; -import {getMoreDmList} from 'actions/user_actions.jsx'; +import {searchUsers} from 'actions/user_actions.jsx'; +import {openDirectChannelToUser} from 'actions/channel_actions.jsx'; import UserStore from 'stores/user_store.jsx'; import TeamStore from 'stores/team_store.jsx'; +import * as AsyncClient from 'utils/async_client.jsx'; import * as Utils from 'utils/utils.jsx'; +import Constants from 'utils/constants.jsx'; import React from 'react'; import {Modal} from 'react-bootstrap'; import {FormattedMessage} from 'react-intl'; import {browserHistory} from 'react-router/es6'; +const USERS_PER_PAGE = 50; + export default class MoreDirectChannels extends React.Component { constructor(props) { super(props); this.handleHide = this.handleHide.bind(this); this.handleShowDirectChannel = this.handleShowDirectChannel.bind(this); - this.handleUserChange = this.handleUserChange.bind(this); - this.onTeamChange = this.onTeamChange.bind(this); + this.onChange = this.onChange.bind(this); this.createJoinDirectChannelButton = this.createJoinDirectChannelButton.bind(this); + this.toggleList = this.toggleList.bind(this); + this.nextPage = this.nextPage.bind(this); + this.search = this.search.bind(this); + this.loadComplete = this.loadComplete.bind(this); this.state = { - users: UserStore.getProfilesForDmList(), - teamMembers: TeamStore.getMembersForTeam(), + users: UserStore.getProfileListInTeam(TeamStore.getCurrentId(), true), loadingDMChannel: -1, - usersLoaded: false, - teamMembersLoaded: false + listType: 'team', + loading: false, + search: false }; } componentDidMount() { - UserStore.addDmListChangeListener(this.handleUserChange); - TeamStore.addChangeListener(this.onTeamChange); + UserStore.addChangeListener(this.onChange); + UserStore.addInTeamChangeListener(this.onChange); + UserStore.addStatusesChangeListener(this.onChange); + TeamStore.addChangeListener(this.onChange); + + AsyncClient.getProfiles(0, Constants.PROFILE_CHUNK_SIZE); + AsyncClient.getProfilesInTeam(TeamStore.getCurrentId(), 0, Constants.PROFILE_CHUNK_SIZE); } componentWillUnmount() { - UserStore.removeDmListChangeListener(this.handleUserChange); - TeamStore.removeChangeListener(this.onTeamChange); + UserStore.removeChangeListener(this.onChange); + UserStore.removeInTeamChangeListener(this.onChange); + UserStore.removeStatusesChangeListener(this.onChange); + TeamStore.removeChangeListener(this.onChange); } - shouldComponentUpdate(nextProps, nextState) { - if (nextProps.show !== this.props.show) { - return true; - } - - if (nextProps.onModalDismissed.toString() !== this.props.onModalDismissed.toString()) { - return true; - } - - if (nextState.loadingDMChannel !== this.state.loadingDMChannel) { - return true; - } - - if (!Utils.areObjectsEqual(nextState.users, this.state.users)) { - return true; - } - - if (!Utils.areObjectsEqual(nextState.teamMembers, this.state.teamMembers)) { - return true; - } - - return false; + loadComplete() { + this.setState({loading: false}); } handleHide() { @@ -84,7 +78,7 @@ export default class MoreDirectChannels extends React.Component { } this.setState({loadingDMChannel: teammate.id}); - Utils.openDirectChannelToUser( + openDirectChannelToUser( teammate, (channel) => { browserHistory.push(TeamStore.getCurrentTeamUrl() + '/channels/' + channel.name); @@ -97,17 +91,35 @@ export default class MoreDirectChannels extends React.Component { ); } - handleUserChange() { + onChange(force) { + if (this.state.search && !force) { + return; + } + + let users; + if (this.state.listType === 'any') { + users = UserStore.getProfileList(); + } else { + users = UserStore.getProfileListInTeam(TeamStore.getCurrentId(), true); + } + this.setState({ - users: UserStore.getProfilesForDmList(), - usersLoaded: true + users }); } - onTeamChange() { + toggleList(e) { + const listType = e.target.value; + let users; + if (listType === 'any') { + users = UserStore.getProfileList(); + } else { + users = UserStore.getProfileListInTeam(TeamStore.getCurrentId(), true); + } + this.setState({ - teamMembers: TeamStore.getMembersForTeam(), - teamMembersLoaded: true + users, + listType }); } @@ -126,38 +138,96 @@ export default class MoreDirectChannels extends React.Component { ); } + nextPage(page) { + if (this.state.listType === 'any') { + AsyncClient.getProfiles((page + 1) * USERS_PER_PAGE, USERS_PER_PAGE); + } else { + AsyncClient.getProfilesInTeam(TeamStore.getCurrentId(), (page + 1) * USERS_PER_PAGE, USERS_PER_PAGE); + } + } + + search(term) { + if (term === '') { + this.onChange(true); + this.setState({search: false}); + return; + } + + let teamId; + if (this.state.listType === 'any') { + teamId = ''; + } else { + teamId = TeamStore.getCurrentId(); + } + + searchUsers( + term, + teamId, + {}, + (users) => { + for (let i = 0; i < users.length; i++) { + if (users[i].id === UserStore.getCurrentId()) { + users.splice(i, 1); + break; + } + } + this.setState({search: true, users}); + } + ); + } + render() { let maxHeight = 1000; if (Utils.windowHeight() <= 1200) { maxHeight = Utils.windowHeight() - 300; } - var body = null; - if (!this.state.usersLoaded || !this.state.teamMembersLoaded) { - body = (<LoadingScreen/>); - } else { - var showTeamToggle = false; - if (global.window.mm_config.RestrictDirectMessage === 'any') { - showTeamToggle = true; - } - - body = ( - <FilteredUserList - style={{maxHeight}} - users={this.state.users} - teamMembers={this.state.teamMembers} - actions={[this.createJoinDirectChannelButton]} - showTeamToggle={showTeamToggle} - /> + let teamToggle; + if (global.window.mm_config.RestrictDirectMessage === 'any') { + teamToggle = ( + <div className='member-select__container'> + <select + className='form-control' + id='restrictList' + ref='restrictList' + defaultValue='team' + onChange={this.toggleList} + > + <option value='any'> + <FormattedMessage + id='filtered_user_list.any_team' + defaultMessage='All Users' + /> + </option> + <option value='team'> + <FormattedMessage + id='filtered_user_list.team_only' + defaultMessage='Members of this Team' + /> + </option> + </select> + <span + className='member-show' + > + <FormattedMessage + id='filtered_user_list.show' + defaultMessage='Filter:' + /> + </span> + </div> ); } + let users = this.state.users; + if (this.state.loading) { + users = null; + } + return ( <Modal dialogClassName='more-modal more-direct-channels' show={this.props.show} onHide={this.handleHide} - onEntered={getMoreDmList} > <Modal.Header closeButton={true}> <Modal.Title> @@ -168,7 +238,16 @@ export default class MoreDirectChannels extends React.Component { </Modal.Title> </Modal.Header> <Modal.Body> - {body} + {teamToggle} + <SearchableUserList + key={'moreDirectChannelsList_' + this.state.listType} + style={{maxHeight}} + users={users} + usersPerPage={USERS_PER_PAGE} + nextPage={this.nextPage} + search={this.search} + actions={[this.createJoinDirectChannelButton]} + /> </Modal.Body> <Modal.Footer> <button diff --git a/webapp/components/navbar.jsx b/webapp/components/navbar.jsx index 72066780e..865e2ac78 100644 --- a/webapp/components/navbar.jsx +++ b/webapp/components/navbar.jsx @@ -69,8 +69,8 @@ export default class Navbar extends React.Component { return { channel: ChannelStore.getCurrent(), member: ChannelStore.getCurrentMember(), - users: ChannelStore.getCurrentExtraInfo().members, - userCount: ChannelStore.getCurrentExtraInfo().member_count, + users: [], + userCount: ChannelStore.getCurrentStats().member_count, currentUser: UserStore.getCurrentUser() }; } @@ -81,7 +81,7 @@ export default class Navbar extends React.Component { componentDidMount() { ChannelStore.addChangeListener(this.onChange); - ChannelStore.addExtraInfoChangeListener(this.onChange); + ChannelStore.addStatsChangeListener(this.onChange); UserStore.addStatusesChangeListener(this.onChange); $('.inner-wrap').click(this.hideSidebars); document.addEventListener('keydown', this.showChannelSwitchModal); @@ -89,7 +89,7 @@ export default class Navbar extends React.Component { componentWillUnmount() { ChannelStore.removeChangeListener(this.onChange); - ChannelStore.removeExtraInfoChangeListener(this.onChange); + ChannelStore.removeStatsChangeListener(this.onChange); UserStore.removeStatusesChangeListener(this.onChange); document.removeEventListener('keydown', this.showChannelSwitchModal); } diff --git a/webapp/components/needs_team.jsx b/webapp/components/needs_team.jsx index f7244018d..e210fcbee 100644 --- a/webapp/components/needs_team.jsx +++ b/webapp/components/needs_team.jsx @@ -13,6 +13,7 @@ import UserStore from 'stores/user_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import ChannelStore from 'stores/channel_store.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; +import {startPeriodicStatusUpdates, stopPeriodicStatusUpdates} from 'actions/status_actions.jsx'; import Constants from 'utils/constants.jsx'; const TutorialSteps = Constants.TutorialSteps; const Preferences = Constants.Preferences; @@ -80,6 +81,7 @@ export default class NeedsTeam extends React.Component { if (tutorialStep <= TutorialSteps.INTRO_SCREENS) { browserHistory.push(TeamStore.getCurrentTeamRelativeUrl() + '/tutorial'); } + stopPeriodicStatusUpdates(); } componentDidMount() { @@ -89,6 +91,8 @@ export default class NeedsTeam extends React.Component { // Emit view action GlobalActions.viewLoggedIn(); + startPeriodicStatusUpdates(); + // Set up tracking for whether the window is active window.isActive = true; $(window).on('focus', () => { diff --git a/webapp/components/notify_counts.jsx b/webapp/components/notify_counts.jsx index 8f9eadab7..6ccbd228b 100644 --- a/webapp/components/notify_counts.jsx +++ b/webapp/components/notify_counts.jsx @@ -7,7 +7,7 @@ import ChannelStore from 'stores/channel_store.jsx'; function getCountsStateFromStores() { var count = 0; var channels = ChannelStore.getAll(); - var members = ChannelStore.getAllMembers(); + var members = ChannelStore.getMyMembers(); channels.forEach((channel) => { var channelMember = members[channel.id]; diff --git a/webapp/components/popover_list_members.jsx b/webapp/components/popover_list_members.jsx index bfbe66677..9cea3922a 100644 --- a/webapp/components/popover_list_members.jsx +++ b/webapp/components/popover_list_members.jsx @@ -6,9 +6,11 @@ import ProfilePicture from 'components/profile_picture.jsx'; import TeamStore from 'stores/team_store.jsx'; import UserStore from 'stores/user_store.jsx'; +import {openDirectChannelToUser} from 'actions/channel_actions.jsx'; + +import * as AsyncClient from 'utils/async_client.jsx'; import Client from 'client/web_client.jsx'; import * as Utils from 'utils/utils.jsx'; -import Constants from 'utils/constants.jsx'; import $ from 'jquery'; import React from 'react'; @@ -22,20 +24,18 @@ export default class PopoverListMembers extends React.Component { this.handleShowDirectChannel = this.handleShowDirectChannel.bind(this); this.closePopover = this.closePopover.bind(this); + + this.state = {showPopover: false}; } componentDidUpdate() { $('.member-list__popover .popover-content').perfectScrollbar(); } - componentWillMount() { - this.setState({showPopover: false}); - } - handleShowDirectChannel(teammate, e) { e.preventDefault(); - Utils.openDirectChannelToUser( + openDirectChannelToUser( teammate, (channel, channelAlreadyExisted) => { browserHistory.push(TeamStore.getCurrentTeamRelativeUrl() + '/channels/' + channel.name); @@ -90,12 +90,6 @@ export default class PopoverListMembers extends React.Component { } if (name) { - let status; - if (m.status) { - status = m.status; - } else { - status = UserStore.getStatus(m.id); - } popoverHtml.push( <div className='more-modal__row' @@ -103,7 +97,6 @@ export default class PopoverListMembers extends React.Component { > <ProfilePicture src={`${Client.getUsersRoute()}/${m.id}/image?time=${m.update_at}`} - status={status} width='26' height='26' /> @@ -123,19 +116,27 @@ export default class PopoverListMembers extends React.Component { ); } }); - } - - let count = this.props.memberCount; - let countText = '-'; - // fall back to checking the length of the member list if the count isn't set - if (!count && members) { - count = members.length; + popoverHtml.push( + <div + className='more-modal__row' + key={'popover-member-more'} + > + <div className='col-sm-5'/> + <div className='more-modal__details'> + <div + className='more-modal__name' + > + {'...'} + </div> + </div> + </div> + ); } - if (count > Constants.MAX_CHANNEL_POPOVER_COUNT) { - countText = Constants.MAX_CHANNEL_POPOVER_COUNT + '+'; - } else if (count > 0) { + const count = this.props.memberCount; + let countText = '-'; + if (count > 0) { countText = count.toString(); } @@ -151,7 +152,10 @@ export default class PopoverListMembers extends React.Component { id='member_popover' className='member-popover__trigger' ref='member_popover_target' - onClick={(e) => this.setState({popoverTarget: e.target, showPopover: !this.state.showPopover})} + onClick={(e) => { + this.setState({popoverTarget: e.target, showPopover: !this.state.showPopover}); + AsyncClient.getProfilesInChannel(this.props.channel.id, 0); + }} > <div> {countText} diff --git a/webapp/components/post_view/components/pending_post_options.jsx b/webapp/components/post_view/components/pending_post_options.jsx index 711ea832c..44f4794ef 100644 --- a/webapp/components/post_view/components/pending_post_options.jsx +++ b/webapp/components/post_view/components/pending_post_options.jsx @@ -4,11 +4,10 @@ import PostStore from 'stores/post_store.jsx'; import ChannelStore from 'stores/channel_store.jsx'; -import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; +import {loadPosts} from 'actions/post_actions.jsx'; +import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; import Client from 'client/web_client.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; - import Constants from 'utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; @@ -29,13 +28,13 @@ export default class PendingPostOptions extends React.Component { var post = this.props.post; Client.createPost(post, (data) => { - AsyncClient.getPosts(post.channel_id); + loadPosts(post.channel_id); var channel = ChannelStore.get(post.channel_id); - var member = ChannelStore.getMember(post.channel_id); + var member = ChannelStore.getMyMember(post.channel_id); member.msg_count = channel.total_msg_count; member.last_viewed_at = (new Date()).getTime(); - ChannelStore.setChannelMember(member); + ChannelStore.storeMyChannelMember(member); AppDispatcher.handleServerAction({ type: ActionTypes.RECEIVED_POST, diff --git a/webapp/components/post_view/components/post_list.jsx b/webapp/components/post_view/components/post_list.jsx index d686b28e5..46ce0ed67 100644 --- a/webapp/components/post_view/components/post_list.jsx +++ b/webapp/components/post_view/components/post_list.jsx @@ -66,6 +66,16 @@ export default class PostList extends React.Component { } } + componentWillReceiveProps(nextProps) { + // TODO: Clean-up intro text creation + if (this.props.channel && this.props.channel.type === Constants.DM_CHANNEL) { + const teammateId = Utils.getUserIdFromChannelName(this.props.channel); + if (!this.props.profiles[teammateId] && nextProps.profiles[teammateId]) { + this.introText = createChannelIntroMessage(this.props.channel, this.state.fullWidthIntro); + } + } + } + handleKeyDown(e) { if (e.which === Constants.KeyCodes.ESCAPE && $('.popover.in,.modal.in').length === 0) { e.preventDefault(); diff --git a/webapp/components/post_view/post_focus_view_controller.jsx b/webapp/components/post_view/post_focus_view_controller.jsx index 4e21cb29f..8edec6970 100644 --- a/webapp/components/post_view/post_focus_view_controller.jsx +++ b/webapp/components/post_view/post_focus_view_controller.jsx @@ -35,10 +35,7 @@ export default class PostFocusView extends React.Component { const focusedPostId = PostStore.getFocusedPostId(); const channel = ChannelStore.getCurrent(); - let profiles = UserStore.getProfiles(); - if (channel && channel.type === Constants.DM_CHANNEL) { - profiles = Object.assign({}, profiles, UserStore.getDirectProfiles()); - } + const profiles = UserStore.getProfiles(); const joinLeaveEnabled = PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'join_leave', true); @@ -115,12 +112,7 @@ export default class PostFocusView extends React.Component { } onUserChange() { - const channel = ChannelStore.getCurrent(); - let profiles = UserStore.getProfiles(); - if (channel && channel.type === Constants.DM_CHANNEL) { - profiles = Object.assign({}, profiles, UserStore.getDirectProfiles()); - } - this.setState({currentUser: UserStore.getCurrentUser(), profiles: JSON.parse(JSON.stringify(profiles))}); + this.setState({currentUser: UserStore.getCurrentUser(), profiles: JSON.parse(JSON.stringify(UserStore.getProfiles()))}); } onStatusChange() { diff --git a/webapp/components/post_view/post_view_controller.jsx b/webapp/components/post_view/post_view_controller.jsx index 12fd5cd63..57b488b54 100644 --- a/webapp/components/post_view/post_view_controller.jsx +++ b/webapp/components/post_view/post_view_controller.jsx @@ -34,13 +34,10 @@ export default class PostViewController extends React.Component { this.onBusy = this.onBusy.bind(this); const channel = props.channel; - let profiles = UserStore.getProfiles(); - if (channel && channel.type === Constants.DM_CHANNEL) { - profiles = Object.assign({}, profiles, UserStore.getDirectProfiles()); - } + const profiles = UserStore.getProfiles(); let lastViewed = Number.MAX_VALUE; - const member = ChannelStore.getMember(channel.id); + const member = ChannelStore.getMyMember(channel.id); if (member != null) { lastViewed = member.last_viewed_at; } @@ -107,12 +104,7 @@ export default class PostViewController extends React.Component { } onUserChange() { - const channel = this.state.channel; - let profiles = UserStore.getProfiles(); - if (channel && channel.type === Constants.DM_CHANNEL) { - profiles = Object.assign({}, profiles, UserStore.getDirectProfiles()); - } - this.setState({currentUser: UserStore.getCurrentUser(), profiles: JSON.parse(JSON.stringify(profiles))}); + this.setState({currentUser: UserStore.getCurrentUser(), profiles: JSON.parse(JSON.stringify(UserStore.getProfiles()))}); } onPostsChange() { @@ -165,15 +157,12 @@ export default class PostViewController extends React.Component { const channel = nextProps.channel; let lastViewed = Number.MAX_VALUE; - const member = ChannelStore.getMember(channel.id); + const member = ChannelStore.getMyMember(channel.id); if (member != null) { lastViewed = member.last_viewed_at; } - let profiles = UserStore.getProfiles(); - if (channel && channel.type === Constants.DM_CHANNEL) { - profiles = Object.assign({}, profiles, UserStore.getDirectProfiles()); - } + const profiles = UserStore.getProfiles(); const joinLeaveEnabled = PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'join_leave', true); diff --git a/webapp/components/rhs_root_post.jsx b/webapp/components/rhs_root_post.jsx index 7d643bd38..27446c85a 100644 --- a/webapp/components/rhs_root_post.jsx +++ b/webapp/components/rhs_root_post.jsx @@ -61,6 +61,10 @@ export default class RhsRootPost extends React.Component { return true; } + if (!Utils.areObjectsEqual(nextProps.user, this.props.user)) { + return true; + } + if (!Utils.areObjectsEqual(nextProps.currentUser, this.props.currentUser)) { return true; } @@ -85,7 +89,7 @@ export default class RhsRootPost extends React.Component { var isOwner = this.props.currentUser.id === post.user_id; var isAdmin = TeamStore.isTeamAdminForCurrentTeam() || UserStore.isSystemAdminForCurrentUser(); const isSystemMessage = post.type && post.type.startsWith(Constants.SYSTEM_MESSAGE_PREFIX); - var timestamp = UserStore.getProfile(post.user_id).update_at; + var timestamp = user.update_at; var channel = ChannelStore.get(post.channel_id); const flagIcon = Constants.FLAG_ICON_SVG; diff --git a/webapp/components/rhs_thread.jsx b/webapp/components/rhs_thread.jsx index 7d0de8590..11c79d722 100644 --- a/webapp/components/rhs_thread.jsx +++ b/webapp/components/rhs_thread.jsx @@ -8,7 +8,6 @@ import RootPost from './rhs_root_post.jsx'; import Comment from './rhs_comment.jsx'; import FileUploadOverlay from './file_upload_overlay.jsx'; -import ChannelStore from 'stores/channel_store.jsx'; import PostStore from 'stores/post_store.jsx'; import UserStore from 'stores/user_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; @@ -238,12 +237,7 @@ export default class RhsThread extends React.Component { render() { const postsArray = this.state.postsArray; const selected = this.state.selected; - const channel = ChannelStore.get(this.state.selected.channel_id); - - let profiles = this.state.profiles || {}; - if (channel && channel.type === Constants.DM_CHANNEL) { - profiles = Object.assign({}, profiles, UserStore.getDirectProfiles()); - } + const profiles = this.state.profiles || {}; if (postsArray == null || selected == null) { return ( diff --git a/webapp/components/searchable_user_list.jsx b/webapp/components/searchable_user_list.jsx new file mode 100644 index 000000000..8d4f74ab3 --- /dev/null +++ b/webapp/components/searchable_user_list.jsx @@ -0,0 +1,226 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import UserList from 'components/user_list.jsx'; + +import * as Utils from 'utils/utils.jsx'; +import Constants from 'utils/constants.jsx'; +const KeyCodes = Constants.KeyCodes; + +import $ from 'jquery'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import {FormattedMessage} from 'react-intl'; + +const NEXT_BUTTON_TIMEOUT = 500; + +export default class SearchableUserList extends React.Component { + constructor(props) { + super(props); + + this.nextPage = this.nextPage.bind(this); + this.previousPage = this.previousPage.bind(this); + this.doSearch = this.doSearch.bind(this); + this.onSearchBoxKeyPress = this.onSearchBoxKeyPress.bind(this); + this.onSearchBoxChange = this.onSearchBoxChange.bind(this); + + this.nextTimeoutId = 0; + + this.state = { + page: 0, + search: false, + nextDisabled: false + }; + } + + componentDidUpdate(prevProps, prevState) { + if (this.state.page !== prevState.page) { + $(ReactDOM.findDOMNode(this.refs.userList)).scrollTop(0); + } + } + + componentWillUnmount() { + clearTimeout(this.nextTimeoutId); + } + + nextPage(e) { + e.preventDefault(); + this.setState({page: this.state.page + 1, nextDisabled: true}); + this.nextTimeoutId = setTimeout(() => this.setState({nextDisabled: false}), NEXT_BUTTON_TIMEOUT); + this.props.nextPage(this.state.page + 1); + } + + previousPage(e) { + e.preventDefault(); + this.setState({page: this.state.page - 1}); + } + + doSearch() { + const term = this.refs.filter.value; + this.props.search(term); + if (term === '') { + this.setState({page: 0, search: false}); + } else { + this.setState({search: true}); + } + } + + onSearchBoxKeyPress(e) { + if (e.charCode === KeyCodes.ENTER) { + e.preventDefault(); + this.doSearch(); + } + } + + onSearchBoxChange(e) { + if (e.target.value === '') { + this.props.search(''); // clear search + this.setState({page: 0, search: false}); + } + } + + render() { + let nextButton; + let previousButton; + let usersToDisplay; + let count; + + if (this.props.users == null) { + usersToDisplay = this.props.users; + } else if (this.state.search || this.props.users == null) { + usersToDisplay = this.props.users; + + if (this.props.total) { + count = ( + <FormattedMessage + id='filtered_user_list.countTotal' + defaultMessage='{count} {count, plural, =0 {0 members} one {member} other {members}} of {total} total' + values={{ + count: usersToDisplay.length || 0, + total: this.props.total + }} + /> + ); + } + } else { + const pageStart = this.state.page * this.props.usersPerPage; + const pageEnd = pageStart + this.props.usersPerPage; + usersToDisplay = this.props.users.slice(pageStart, pageEnd); + + if (usersToDisplay.length >= this.props.usersPerPage) { + nextButton = ( + <button + className='btn btn-default filter-control filter-control__next' + onClick={this.nextPage} + disabled={this.state.nextDisabled} + > + {'Next'} + </button> + ); + } + + if (this.state.page > 0) { + previousButton = ( + <button + className='btn btn-default filter-control filter-control__prev' + onClick={this.previousPage} + > + {'Previous'} + </button> + ); + } + + if (this.props.total) { + const startCount = this.state.page * this.props.usersPerPage; + const endCount = startCount + usersToDisplay.length; + + count = ( + <FormattedMessage + id='filtered_user_list.countTotalPage' + defaultMessage='{startCount, number} - {endCount, number} {count, plural, =0 {0 members} one {member} other {members}} of {total} total' + values={{ + count: usersToDisplay.length, + startCount: startCount + 1, + endCount, + total: this.props.total + }} + /> + ); + } + } + + return ( + <div + className='filtered-user-list' + style={this.props.style} + > + <div className='filter-row'> + <div className='col-sm-5'> + <input + ref='filter' + className='form-control filter-textbox' + placeholder={Utils.localizeMessage('filtered_user_list.search', 'Press enter to search')} + onKeyPress={this.onSearchBoxKeyPress} + onChange={this.onSearchBoxChange} + /> + </div> + <div className='col-sm-2 filter-button'> + <button + type='button' + className='btn btn-primary' + onClick={this.doSearch} + disabled={this.props.users == null} + > + <FormattedMessage + id='filtered_user_list.searchButton' + defaultMessage='Search' + /> + </button> + </div> + <div className='col-sm-12'> + <span className='member-count pull-left'>{count}</span> + </div> + </div> + <div + ref='userList' + className='more-modal__list' + > + <UserList + users={usersToDisplay} + extraInfo={this.props.extraInfo} + actions={this.props.actions} + actionProps={this.props.actionProps} + actionUserProps={this.props.actionUserProps} + /> + </div> + <div className='filter-controls'> + {previousButton} + {nextButton} + </div> + </div> + ); + } +} + +SearchableUserList.defaultProps = { + users: [], + usersPerPage: 50, //eslint-disable-line no-magic-numbers + extraInfo: {}, + actions: [], + actionProps: {}, + actionUserProps: {}, + showTeamToggle: false +}; + +SearchableUserList.propTypes = { + users: React.PropTypes.arrayOf(React.PropTypes.object), + usersPerPage: React.PropTypes.number, + total: React.PropTypes.number, + extraInfo: React.PropTypes.object, + nextPage: React.PropTypes.func.isRequired, + search: React.PropTypes.func.isRequired, + actions: React.PropTypes.arrayOf(React.PropTypes.func), + actionProps: React.PropTypes.object, + actionUserProps: React.PropTypes.object, + style: React.PropTypes.object +}; diff --git a/webapp/components/select_team/select_team.jsx b/webapp/components/select_team/select_team.jsx index 5f8d9f463..283299b37 100644 --- a/webapp/components/select_team/select_team.jsx +++ b/webapp/components/select_team/select_team.jsx @@ -46,7 +46,7 @@ export default class SelectTeam extends React.Component { getStateFromStores(loaded) { return { teams: TeamStore.getAll(), - teamMembers: TeamStore.getTeamMembers(), + teamMembers: TeamStore.getMyTeamMembers(), teamListings: TeamStore.getTeamListings(), loaded }; diff --git a/webapp/components/sidebar.jsx b/webapp/components/sidebar.jsx index dc52ebb91..c8a7e1eb9 100644 --- a/webapp/components/sidebar.jsx +++ b/webapp/components/sidebar.jsx @@ -19,6 +19,7 @@ import LocalizationStore from 'stores/localization_store.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; import * as Utils from 'utils/utils.jsx'; import * as ChannelActions from 'actions/channel_actions.jsx'; +import {loadProfilesAndTeamMembersForDMSidebar} from 'actions/user_actions.jsx'; import Constants from 'utils/constants.jsx'; @@ -93,7 +94,7 @@ export default class Sidebar extends React.Component { } getStateFromStores() { - const members = ChannelStore.getAllMembers(); + const members = ChannelStore.getMyMembers(); const currentChannelId = ChannelStore.getCurrentId(); const currentUserId = UserStore.getCurrentId(); @@ -133,9 +134,9 @@ export default class Sidebar extends React.Component { directChannel.teammate_id = teammateId; directChannel.status = UserStore.getStatus(teammateId) || 'offline'; - if (UserStore.hasTeamProfile(teammateId) && TeamStore.hasActiveMemberForTeam(teammateId)) { + if (TeamStore.hasActiveMemberInTeam(TeamStore.getCurrentId(), teammateId)) { directChannels.push(directChannel); - } else { + } else if (TeamStore.hasMemberNotInTeam(TeamStore.getCurrentId(), teammateId)) { directNonTeamChannels.push(directChannel); } } @@ -164,6 +165,7 @@ export default class Sidebar extends React.Component { componentDidMount() { ChannelStore.addChangeListener(this.onChange); UserStore.addChangeListener(this.onChange); + UserStore.addInTeamChangeListener(this.onChange); UserStore.addStatusesChangeListener(this.onChange); TeamStore.addChangeListener(this.onChange); PreferenceStore.addChangeListener(this.onChange); @@ -173,6 +175,8 @@ export default class Sidebar extends React.Component { document.addEventListener('keydown', this.navigateChannelShortcut); document.addEventListener('keydown', this.navigateUnreadChannelShortcut); + + loadProfilesAndTeamMembersForDMSidebar(); } shouldComponentUpdate(nextProps, nextState) { @@ -205,6 +209,7 @@ export default class Sidebar extends React.Component { componentWillUnmount() { ChannelStore.removeChangeListener(this.onChange); UserStore.removeChangeListener(this.onChange); + UserStore.removeInTeamChangeListener(this.onChange); UserStore.removeStatusesChangeListener(this.onChange); TeamStore.removeChangeListener(this.onChange); PreferenceStore.removeChangeListener(this.onChange); diff --git a/webapp/components/sidebar_header_dropdown.jsx b/webapp/components/sidebar_header_dropdown.jsx index dccac64b3..76ed6271a 100644 --- a/webapp/components/sidebar_header_dropdown.jsx +++ b/webapp/components/sidebar_header_dropdown.jsx @@ -54,7 +54,7 @@ export default class SidebarHeaderDropdown extends React.Component { this.state = { teams: TeamStore.getAll(), - teamMembers: TeamStore.getTeamMembers(), + teamMembers: TeamStore.getMyTeamMembers(), showDropdown: false }; } @@ -118,7 +118,7 @@ export default class SidebarHeaderDropdown extends React.Component { onTeamChange() { this.setState({ teams: TeamStore.getAll(), - teamMembers: TeamStore.getTeamMembers() + teamMembers: TeamStore.getMyTeamMembers() }); } diff --git a/webapp/components/suggestion/at_mention_provider.jsx b/webapp/components/suggestion/at_mention_provider.jsx index 9998e6357..d4f441f98 100644 --- a/webapp/components/suggestion/at_mention_provider.jsx +++ b/webapp/components/suggestion/at_mention_provider.jsx @@ -1,19 +1,19 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import React from 'react'; +import Suggestion from './suggestion.jsx'; -import SuggestionStore from 'stores/suggestion_store.jsx'; import ChannelStore from 'stores/channel_store.jsx'; -import UserStore from 'stores/user_store.jsx'; + +import {autocompleteUsersInChannel} from 'actions/user_actions.jsx'; + +import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; import * as Utils from 'utils/utils.jsx'; import Client from 'client/web_client.jsx'; -import Constants from 'utils/constants.jsx'; +import {Constants, ActionTypes} from 'utils/constants.jsx'; +import React from 'react'; import {FormattedMessage} from 'react-intl'; -import Suggestion from './suggestion.jsx'; - -const MaxUserSuggestions = 40; class AtMentionSuggestion extends Suggestion { render() { @@ -99,92 +99,66 @@ class AtMentionSuggestion extends Suggestion { } } -function filterUsersByPrefix(users, prefix, limit, type) { - const filtered = []; - - for (const id of Object.keys(users)) { - if (filtered.length >= limit) { - break; - } - - const user = users[id]; - - if (user.delete_at > 0) { - continue; - } - - if (user.username.startsWith(prefix) || - (user.first_name && user.first_name.toLowerCase().startsWith(prefix)) || - (user.last_name && user.last_name.toLowerCase().startsWith(prefix)) || - (user.nickname && user.nickname.toLowerCase().startsWith(prefix))) { - // create a new object here since we're mutating it by adding the type field - filtered.push(Object.assign({}, user, {type})); - } - } - - return filtered; -} - export default class AtMentionProvider { constructor(channelId) { this.channelId = channelId; + this.timeoutId = ''; + } + + componentWillUnmount() { + clearTimeout(this.timeoutId); } handlePretextChanged(suggestionId, pretext) { + clearTimeout(this.timeoutId); + const captured = (/@([a-z0-9\-\._]*)$/i).exec(pretext.toLowerCase()); if (captured) { const prefix = captured[1]; - // Group users into members and nonmembers of the channel. - const users = UserStore.getActiveOnlyProfiles(true); - const channelMembers = {}; - const channelNonmembers = users; - if (this.channelId != null) { - const extraInfo = ChannelStore.getExtraInfo(this.channelId); - for (let i = 0; i < extraInfo.members.length; i++) { - const id = extraInfo.members[i].id; - if (users[id]) { - channelMembers[id] = users[id]; - Reflect.deleteProperty(channelNonmembers, id); + function autocomplete() { + autocompleteUsersInChannel( + prefix, + this.channelId, + (data) => { + const members = data.in_channel; + for (const id of Object.keys(members)) { + members[id].type = Constants.MENTION_MEMBERS; + } + + const nonmembers = data.out_of_channel; + for (const id of Object.keys(nonmembers)) { + nonmembers[id].type = Constants.MENTION_NONMEMBERS; + } + + let specialMentions = []; + if (!pretext.startsWith('/msg')) { + specialMentions = ['here', 'channel', 'all'].filter((item) => { + return item.startsWith(prefix); + }).map((name) => { + return {username: name, type: Constants.MENTION_SPECIAL}; + }); + } + + const users = members.concat(specialMentions).concat(nonmembers); + const mentions = users.map((user) => '@' + user.username); + + AppDispatcher.handleServerAction({ + type: ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS, + id: suggestionId, + matchedPretext: captured[0], + terms: mentions, + items: users, + component: AtMentionSuggestion + }); } - } - } - - // Filter users by prefix. - const filteredMembers = filterUsersByPrefix( - channelMembers, prefix, MaxUserSuggestions, Constants.MENTION_MEMBERS); - const filteredNonmembers = filterUsersByPrefix( - channelNonmembers, prefix, MaxUserSuggestions - filteredMembers.length, Constants.MENTION_NONMEMBERS); - let filteredSpecialMentions = []; - if (!pretext.startsWith('/msg')) { - filteredSpecialMentions = ['here', 'channel', 'all'].filter((item) => { - return item.startsWith(prefix); - }).map((name) => { - return {username: name, type: Constants.MENTION_SPECIAL}; - }); + ); } - // Sort users by username. - [filteredMembers, filteredNonmembers].forEach((items) => { - items.sort((a, b) => { - const aPrefix = a.username.startsWith(prefix); - const bPrefix = b.username.startsWith(prefix); - - if (aPrefix === bPrefix) { - return a.username.localeCompare(b.username); - } else if (aPrefix) { - return -1; - } - - return 1; - }); - }); - - const filtered = filteredMembers.concat(filteredSpecialMentions).concat(filteredNonmembers); - - const mentions = filtered.map((user) => '@' + user.username); - - SuggestionStore.addSuggestions(suggestionId, mentions, filtered, AtMentionSuggestion, captured[0]); + this.timeoutId = setTimeout( + autocomplete.bind(this), + Constants.AUTOCOMPLETE_TIMEOUT + ); } } } diff --git a/webapp/components/suggestion/search_user_provider.jsx b/webapp/components/suggestion/search_user_provider.jsx index b5466cf39..baf91cd94 100644 --- a/webapp/components/suggestion/search_user_provider.jsx +++ b/webapp/components/suggestion/search_user_provider.jsx @@ -1,13 +1,16 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import React from 'react'; +import Suggestion from './suggestion.jsx'; +import {autocompleteUsersInTeam} from 'actions/user_actions.jsx'; + +import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; import Client from 'client/web_client.jsx'; -import SuggestionStore from 'stores/suggestion_store.jsx'; -import UserStore from 'stores/user_store.jsx'; +import * as Utils from 'utils/utils.jsx'; +import {Constants, ActionTypes} from 'utils/constants.jsx'; -import Suggestion from './suggestion.jsx'; +import React from 'react'; class SearchUserSuggestion extends Suggestion { render() { @@ -18,6 +21,17 @@ class SearchUserSuggestion extends Suggestion { className += ' selected'; } + const username = item.username; + let description = ''; + + if ((item.first_name || item.last_name) && item.nickname) { + description = `- ${Utils.getFullName(item)} (${item.nickname})`; + } else if (item.nickname) { + description = `- (${item.nickname})`; + } else if (item.first_name || item.last_name) { + description = `- ${Utils.getFullName(item)}`; + } + return ( <div className={className} @@ -27,34 +41,60 @@ class SearchUserSuggestion extends Suggestion { className='profile-img rounded' src={Client.getUsersRoute() + '/' + item.id + '/image?time=' + item.update_at} /> - <i className='fa fa fa-plus-square'/>{item.username} + <i className='fa fa fa-plus-square'/> + <div className='mention--align'> + <span> + {username} + </span> + <span className='mention__fullname'> + {' '} + {description} + </span> + </div> </div> ); } } export default class SearchUserProvider { + constructor() { + this.timeoutId = ''; + } + + componentWillUnmount() { + clearTimeout(this.timeoutId); + } + handlePretextChanged(suggestionId, pretext) { + clearTimeout(this.timeoutId); + const captured = (/\bfrom:\s*(\S*)$/i).exec(pretext.toLowerCase()); if (captured) { const usernamePrefix = captured[1]; - const users = UserStore.getProfiles(); - let filtered = []; - - for (const id of Object.keys(users)) { - const user = users[id]; + function autocomplete() { + autocompleteUsersInTeam( + usernamePrefix, + (data) => { + const users = data.in_team; + const mentions = users.map((user) => user.username); - if (user.username.startsWith(usernamePrefix)) { - filtered.push(user); - } + AppDispatcher.handleServerAction({ + type: ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS, + id: suggestionId, + matchedPretext: usernamePrefix, + terms: mentions, + items: users, + component: SearchUserSuggestion + }); + } + ); } - filtered = filtered.sort((a, b) => a.username.localeCompare(b.username)); - - const usernames = filtered.map((user) => user.username); - - SuggestionStore.addSuggestions(suggestionId, usernames, filtered, SearchUserSuggestion, usernamePrefix); + this.timeoutId = setTimeout( + autocomplete.bind(this), + Constants.AUTOCOMPLETE_TIMEOUT + ); } } } diff --git a/webapp/components/suggestion/suggestion_list.jsx b/webapp/components/suggestion/suggestion_list.jsx index 7d8059e1e..65311a582 100644 --- a/webapp/components/suggestion/suggestion_list.jsx +++ b/webapp/components/suggestion/suggestion_list.jsx @@ -163,4 +163,4 @@ SuggestionList.propTypes = { SuggestionList.defaultProps = { renderDividers: false -};
\ No newline at end of file +}; diff --git a/webapp/components/suggestion/switch_channel_provider.jsx b/webapp/components/suggestion/switch_channel_provider.jsx index 70e95b9b1..94622b536 100644 --- a/webapp/components/suggestion/switch_channel_provider.jsx +++ b/webapp/components/suggestion/switch_channel_provider.jsx @@ -4,7 +4,6 @@ import React from 'react'; import ChannelStore from 'stores/channel_store.jsx'; -import UserStore from 'stores/user_store.jsx'; import SuggestionStore from 'stores/suggestion_store.jsx'; import Suggestion from './suggestion.jsx'; import Constants from 'utils/constants.jsx'; @@ -58,7 +57,10 @@ export default class SwitchChannelProvider { const channel = allChannels[id]; if (channel.display_name.toLowerCase().startsWith(channelPrefix.toLowerCase())) { channels.push(channel); - } else if (channel.type === Constants.DM_CHANNEL && Utils.getDirectTeammate(channel.id).username.startsWith(channelPrefix.toLowerCase())) { + } + + // TODO: Fix with auto-complete refactor + /*else if (channel.type === Constants.DM_CHANNEL && Utils.getDirectTeammate(channel.id).username.startsWith(channelPrefix.toLowerCase())) { // New channel to not modify existing channel const otherUser = Utils.getDirectTeammate(channel.id); const newChannel = { @@ -68,7 +70,7 @@ export default class SwitchChannelProvider { status: UserStore.getStatus(otherUser.id) || 'offline' }; channels.push(newChannel); - } + }*/ } channels.sort((a, b) => { diff --git a/webapp/components/team_members_dropdown.jsx b/webapp/components/team_members_dropdown.jsx index d459d0b02..3b6bc87f3 100644 --- a/webapp/components/team_members_dropdown.jsx +++ b/webapp/components/team_members_dropdown.jsx @@ -1,17 +1,20 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import ConfirmModal from './confirm_modal.jsx'; + +import TeamStore from 'stores/team_store.jsx'; import UserStore from 'stores/user_store.jsx'; import ChannelStore from 'stores/channel_store.jsx'; + +import {removeUserFromTeam} from 'actions/team_actions.jsx'; + import Client from 'client/web_client.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; import * as Utils from 'utils/utils.jsx'; -import ConfirmModal from './confirm_modal.jsx'; -import TeamStore from 'stores/team_store.jsx'; - -import {FormattedMessage} from 'react-intl'; import React from 'react'; +import {FormattedMessage} from 'react-intl'; import {browserHistory} from 'react-router/es6'; export default class TeamMembersDropdown extends React.Component { @@ -44,8 +47,8 @@ export default class TeamMembersDropdown extends React.Component { this.props.user.id, 'team_user', () => { - AsyncClient.getTeamMembers(TeamStore.getCurrentId()); - AsyncClient.getProfiles(); + AsyncClient.getTeamMember(this.props.teamMember.team_id); + AsyncClient.getUser(this.props.user.id); }, (err) => { this.setState({serverError: err.message}); @@ -54,24 +57,23 @@ export default class TeamMembersDropdown extends React.Component { } } handleRemoveFromTeam() { - Client.removeUserFromTeam( - '', - this.props.user.id, - () => { - AsyncClient.getTeamMembers(TeamStore.getCurrentId()); - AsyncClient.getProfiles(); - }, - (err) => { - this.setState({serverError: err.message}); - } - ); + removeUserFromTeam( + this.props.teamMember.team_id, + this.props.user.id, + () => { + AsyncClient.getTeamStats(this.props.teamMember.team_id); + }, + (err) => { + this.setState({serverError: err.message}); + } + ); } handleMakeActive() { Client.updateActive(this.props.user.id, true, () => { - AsyncClient.getTeamMembers(TeamStore.getCurrentId()); - AsyncClient.getProfiles(); - AsyncClient.getChannelExtraInfo(ChannelStore.getCurrentId()); + AsyncClient.getUser(this.props.user.id); + AsyncClient.getChannelStats(ChannelStore.getCurrentId()); + AsyncClient.getTeamStats(this.props.teamMember.team_id); }, (err) => { this.setState({serverError: err.message}); @@ -81,9 +83,9 @@ export default class TeamMembersDropdown extends React.Component { handleMakeNotActive() { Client.updateActive(this.props.user.id, false, () => { - AsyncClient.getTeamMembers(TeamStore.getCurrentId()); - AsyncClient.getProfiles(); - AsyncClient.getChannelExtraInfo(ChannelStore.getCurrentId()); + AsyncClient.getUser(this.props.user.id); + AsyncClient.getChannelStats(ChannelStore.getCurrentId()); + AsyncClient.getTeamStats(this.props.teamMember.team_id); }, (err) => { this.setState({serverError: err.message}); @@ -100,8 +102,8 @@ export default class TeamMembersDropdown extends React.Component { this.props.user.id, 'team_user team_admin', () => { - AsyncClient.getTeamMembers(TeamStore.getCurrentId()); - AsyncClient.getProfiles(); + AsyncClient.getTeamMember(this.props.teamMember.team_id, this.props.user.id); + AsyncClient.getUser(this.props.user.id); }, (err) => { this.setState({serverError: err.message}); @@ -133,8 +135,8 @@ export default class TeamMembersDropdown extends React.Component { this.props.user.id, this.state.newRole, () => { - AsyncClient.getTeamMembers(TeamStore.getCurrentId()); - AsyncClient.getProfiles(); + AsyncClient.getTeamMember(this.props.teamMember.team_id, this.props.user.id); + AsyncClient.getUser(this.props.user.id); const teamUrl = TeamStore.getCurrentTeamUrl(); if (teamUrl) { diff --git a/webapp/components/textbox.jsx b/webapp/components/textbox.jsx index fa2ffec1e..44468a67a 100644 --- a/webapp/components/textbox.jsx +++ b/webapp/components/textbox.jsx @@ -109,7 +109,7 @@ export default class Textbox extends React.Component { } componentWillReceiveProps(nextProps) { - if (nextProps.channelId !== this.channelId) { + if (nextProps.channelId !== this.props.channelId) { // Update channel id for AtMentionProvider. const providers = this.suggestionProviders; for (let i = 0; i < providers.length; i++) { diff --git a/webapp/components/user_list.jsx b/webapp/components/user_list.jsx index 626cb3cf5..d34404c89 100644 --- a/webapp/components/user_list.jsx +++ b/webapp/components/user_list.jsx @@ -1,32 +1,29 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import {FormattedMessage} from 'react-intl'; import UserListRow from './user_list_row.jsx'; +import LoadingScreen from 'components/loading_screen.jsx'; import React from 'react'; +import {FormattedMessage} from 'react-intl'; export default class UserList extends React.Component { render() { const users = this.props.users; let content; - if (users.length > 0) { + if (users == null) { + return <LoadingScreen/>; + } else if (users.length > 0) { content = users.map((user) => { - var teamMember; - for (var index in this.props.teamMembers) { - if (this.props.teamMembers[index].user_id === user.id) { - teamMember = this.props.teamMembers[index]; - } - } - return ( <UserListRow key={user.id} user={user} - teamMember={teamMember} + extraInfo={this.props.extraInfo[user.id]} actions={this.props.actions} actionProps={this.props.actionProps} + actionUserProps={this.props.actionUserProps[user.id]} /> ); }); @@ -56,14 +53,15 @@ export default class UserList extends React.Component { UserList.defaultProps = { users: [], - teamMembers: [], + extraInfo: {}, actions: [], actionProps: {} }; UserList.propTypes = { users: React.PropTypes.arrayOf(React.PropTypes.object), - teamMembers: React.PropTypes.arrayOf(React.PropTypes.object), + extraInfo: React.PropTypes.object, actions: React.PropTypes.arrayOf(React.PropTypes.func), - actionProps: React.PropTypes.object + actionProps: React.PropTypes.object, + actionUserProps: React.PropTypes.object }; diff --git a/webapp/components/user_list_row.jsx b/webapp/components/user_list_row.jsx index 9f80d4caa..ff381a30b 100644 --- a/webapp/components/user_list_row.jsx +++ b/webapp/components/user_list_row.jsx @@ -11,8 +11,9 @@ import * as Utils from 'utils/utils.jsx'; import Client from 'client/web_client.jsx'; import React from 'react'; +import {FormattedHTMLMessage} from 'react-intl'; -export default function UserListRow({user, teamMember, actions, actionProps}) { +export default function UserListRow({user, extraInfo, actions, actionProps, actionUserProps}) { const nameFormat = PreferenceStore.get(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', ''); let name = user.username; @@ -29,15 +30,29 @@ export default function UserListRow({user, teamMember, actions, actionProps}) { <Action key={index.toString()} user={user} - teamMember={teamMember} {...actionProps} + {...actionUserProps} /> ); }); } + // QUICK HACK, NEEDS A PROP FOR TOGGLING STATUS + let email = user.email; + let emailStyle = 'more-modal__description'; let status; - if (user.status) { + if (extraInfo && extraInfo.length > 0) { + email = ( + <FormattedHTMLMessage + id='admin.user_item.emailTitle' + defaultMessage='<strong>Email:</strong> {email}' + values={{ + email: user.email + }} + /> + ); + emailStyle = ''; + } else if (user.status) { status = user.status; } else { status = UserStore.getStatus(user.id); @@ -60,9 +75,10 @@ export default function UserListRow({user, teamMember, actions, actionProps}) { <div className='more-modal__name'> {name} </div> - <div className='more-modal__description'> - {user.email} + <div className={emailStyle}> + {email} </div> + {extraInfo} </div> <div className='more-modal__actions' @@ -74,17 +90,16 @@ export default function UserListRow({user, teamMember, actions, actionProps}) { } UserListRow.defaultProps = { - teamMember: { - team_id: '', - roles: '' - }, + extraInfo: [], actions: [], - actionProps: {} + actionProps: {}, + actionUserProps: {} }; UserListRow.propTypes = { user: React.PropTypes.object.isRequired, - teamMember: React.PropTypes.object.isRequired, + extraInfo: React.PropTypes.arrayOf(React.PropTypes.object), actions: React.PropTypes.arrayOf(React.PropTypes.func), - actionProps: React.PropTypes.object + actionProps: React.PropTypes.object, + actionUserProps: React.PropTypes.object }; diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index b8b5e1249..8493c335a 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -808,8 +808,8 @@ "admin.true": "true", "admin.userList.title": "Users for {team}", "admin.userList.title2": "Users for {team} ({count})", - "admin.user_item.authServiceEmail": ", <strong>Sign-in Method:</strong> Email", - "admin.user_item.authServiceNotEmail": ", <strong>Sign-in Method:</strong> {service}", + "admin.user_item.authServiceEmail": "<strong>Sign-in Method:</strong> Email", + "admin.user_item.authServiceNotEmail": "<strong>Sign-in Method:</strong> {service}", "admin.user_item.confirmDemoteDescription": "If you demote yourself from the System Admin role and there is not another user with System Admin privileges, you'll need to re-assign a System Admin by accessing the Mattermost server through a terminal and running the following command.", "admin.user_item.confirmDemoteRoleTitle": "Confirm demotion from System Admin role", "admin.user_item.confirmDemotion": "Confirm Demotion", @@ -822,8 +822,8 @@ "admin.user_item.makeSysAdmin": "Make System Admin", "admin.user_item.makeTeamAdmin": "Make Team Admin", "admin.user_item.member": "Member", - "admin.user_item.mfaNo": ", <strong>MFA</strong>: No", - "admin.user_item.mfaYes": ", <strong>MFA</strong>: Yes", + "admin.user_item.mfaNo": "<strong>MFA</strong>: No", + "admin.user_item.mfaYes": "<strong>MFA</strong>: Yes", "admin.user_item.resetMfa": "Remove MFA", "admin.user_item.resetPwd": "Reset Password", "admin.user_item.switchToEmail": "Switch to Email/Password", @@ -881,6 +881,9 @@ "analytics.system.totalSessions": "Total Sessions", "analytics.system.totalTeams": "Total Teams", "analytics.system.totalUsers": "Total Users", + "analytics.system.totalWebsockets" : "Websocket Conns", + "analytics.system.totalMasterDbConnections": "Master DB Conns", + "analytics.system.totalReadDbConnections": "Replica DB Conns", "analytics.team.activeUsers": "Active Users With Posts", "analytics.team.newlyCreated": "Newly Created Users", "analytics.team.privateGroups": "Private Groups", @@ -1192,13 +1195,14 @@ "file_upload.limited": "Uploads limited to {count} files maximum. Please use additional posts for more files.", "file_upload.pasted": "Image Pasted at ", "filtered_channels_list.count": "{count} {count, plural, =0 {0 channels} one {channel} other {channels}}", - "filtered_channels_list.countTotal": "{count} {count, plural, =0 {0 channels} one {channel} other {channels}} of {total} Total", + "filtered_channels_list.countTotal": "{count} {count, plural, =0 {0 channels} one {channel} other {channels}} of {total} total", "filtered_channels_list.search": "Search channels", "filtered_user_list.any_team": "All Users", "filtered_user_list.count": "{count} {count, plural, =0 {0 members} one {member} other {members}}", - "filtered_user_list.countTotal": "{count} {count, plural, =0 {0 members} one {member} other {members}} of {total} Total", + "filtered_user_list.countTotal": "{count} {count, plural, =0 {0 members} one {member} other {members}} of {total} total", + "filtered_user_list.countTotalPage": "{startCount, number} - {endCount, number} {count, plural, =0 {0 members} one {member} other {members}} of {total} total", "filtered_user_list.member": "Member", - "filtered_user_list.search": "Search members", + "filtered_user_list.search": "Press enter to search", "filtered_user_list.show": "Filter:", "filtered_user_list.team_only": "Members of this Team", "find_team.email": "Email", diff --git a/webapp/routes/route_team.jsx b/webapp/routes/route_team.jsx index 1b4e48a51..e63be5a5e 100644 --- a/webapp/routes/route_team.jsx +++ b/webapp/routes/route_team.jsx @@ -58,7 +58,6 @@ function preNeedsTeam(nextState, replace, callback) { // for the current url. const teamName = nextState.params.team; var team = TeamStore.getByName(teamName); - const oldTeamId = TeamStore.getCurrentId(); if (!team) { browserHistory.push('/'); @@ -70,15 +69,7 @@ function preNeedsTeam(nextState, replace, callback) { TeamStore.saveMyTeam(team); TeamStore.emitChange(); - // If the old team id is null then we will already have the direct - // profiles from initial load - if (oldTeamId != null) { - AsyncClient.getDirectProfiles(); - } - var d1 = $.Deferred(); //eslint-disable-line new-cap - var d2 = $.Deferred(); //eslint-disable-line new-cap - var d3 = $.Deferred(); //eslint-disable-line new-cap Client.getChannels( (data) => { @@ -96,38 +87,7 @@ function preNeedsTeam(nextState, replace, callback) { } ); - Client.getProfiles( - (data) => { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_PROFILES, - profiles: data - }); - - d2.resolve(); - }, - (err) => { - AsyncClient.dispatchError(err, 'getProfiles'); - d2.resolve(); - } - ); - - Client.getTeamMembers( - TeamStore.getCurrentId(), - (data) => { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_MEMBERS_FOR_TEAM, - team_members: data - }); - - d3.resolve(); - }, - (err) => { - AsyncClient.dispatchError(err, 'getTeamMembers'); - d3.resolve(); - } - ); - - $.when(d1, d2, d3).done(() => { + $.when(d1).done(() => { callback(); }); } diff --git a/webapp/sass/components/_modal.scss b/webapp/sass/components/_modal.scss index 00fd838f0..53c52fdf1 100644 --- a/webapp/sass/components/_modal.scss +++ b/webapp/sass/components/_modal.scss @@ -236,7 +236,7 @@ &.more-channel__modal { .modal-body { overflow-x: hidden; - padding: 10px 0 20px; + padding: 10px 0 15px; } .channel-count { @@ -457,12 +457,12 @@ .modal-body { overflow-x: hidden; - padding: 10px 0 20px; + padding: 10px 0 15px; } .filter-row { @include clearfix; - margin: 10px 0; + margin: 5px 0 10px; } .member-count { @@ -602,9 +602,16 @@ } } +.member-select__container { + position: absolute; + right: 15px; + top: 15px; +} + .filtered-user-list { display: flex; flex-direction: column; + width: 100%; .filter-row { flex-grow: 0; @@ -615,4 +622,20 @@ flex-grow: 1; flex-shrink: 1; } + + .filter-controls { + @include clearfix; + flex-grow: 0; + flex-shrink: 0; + padding: 1em 1.5em 0; + + .filter-control__next { + float: right; + } + } + + .filter-button { + margin-left: 0; + padding-left: 0; + } } diff --git a/webapp/sass/responsive/_mobile.scss b/webapp/sass/responsive/_mobile.scss index 9a0190ebd..a3e7ab5f5 100644 --- a/webapp/sass/responsive/_mobile.scss +++ b/webapp/sass/responsive/_mobile.scss @@ -96,7 +96,13 @@ } .member-select__container { + margin-bottom: 10px; margin-top: 10px; + overflow: hidden; + position: relative; + right: 10px; + top: 0; + width: 100%; } .user-popover { @@ -844,6 +850,10 @@ @include translate3d(0, 0, 0); } + .nav-pills__container { + height: 100%; + } + > div { padding-bottom: 70px; } diff --git a/webapp/sass/routes/_admin-console.scss b/webapp/sass/routes/_admin-console.scss index cbed38a8b..83c68dc6b 100644 --- a/webapp/sass/routes/_admin-console.scss +++ b/webapp/sass/routes/_admin-console.scss @@ -239,6 +239,19 @@ } } + .more-modal__list { + .filtered-user-list { + .filter-controls { + padding-bottom: 1em; + } + } + + .filter-row { + margin: 10px 0; + overflow: hidden; + } + } + .member-list-holder { background: $white; margin-bottom: 4em; @@ -451,4 +464,3 @@ overflow: hidden; text-overflow: ellipsis; } - diff --git a/webapp/stores/channel_store.jsx b/webapp/stores/channel_store.jsx index 1870ad15b..4d3042be7 100644 --- a/webapp/stores/channel_store.jsx +++ b/webapp/stores/channel_store.jsx @@ -12,7 +12,7 @@ const NotificationPrefs = Constants.NotificationPrefs; const CHANGE_EVENT = 'change'; const LEAVE_EVENT = 'leave'; const MORE_CHANGE_EVENT = 'change'; -const EXTRA_INFO_EVENT = 'extra_info'; +const STATS_EVENT = 'stats'; const LAST_VIEVED_EVENT = 'last_viewed'; class ChannelStoreClass extends EventEmitter { @@ -21,41 +21,13 @@ class ChannelStoreClass extends EventEmitter { this.setMaxListeners(15); - this.emitChange = this.emitChange.bind(this); - this.addChangeListener = this.addChangeListener.bind(this); - this.removeChangeListener = this.removeChangeListener.bind(this); - this.emitMoreChange = this.emitMoreChange.bind(this); - this.addMoreChangeListener = this.addMoreChangeListener.bind(this); - this.removeMoreChangeListener = this.removeMoreChangeListener.bind(this); - this.emitExtraInfoChange = this.emitExtraInfoChange.bind(this); - this.addExtraInfoChangeListener = this.addExtraInfoChangeListener.bind(this); - this.removeExtraInfoChangeListener = this.removeExtraInfoChangeListener.bind(this); - this.emitLeave = this.emitLeave.bind(this); - this.addLeaveListener = this.addLeaveListener.bind(this); - this.removeLeaveListener = this.removeLeaveListener.bind(this); - this.emitLastViewed = this.emitLastViewed.bind(this); - this.addLastViewedListener = this.addLastViewedListener.bind(this); - this.removeLastViewedListener = this.removeLastViewedListener.bind(this); - this.findFirstBy = this.findFirstBy.bind(this); - this.get = this.get.bind(this); - this.getMember = this.getMember.bind(this); - this.getByName = this.getByName.bind(this); - this.getByDisplayName = this.getByDisplayName.bind(this); - this.setPostMode = this.setPostMode.bind(this); - this.getPostMode = this.getPostMode.bind(this); - this.setUnreadCount = this.setUnreadCount.bind(this); - this.setUnreadCounts = this.setUnreadCounts.bind(this); - this.getUnreadCount = this.getUnreadCount.bind(this); - this.getUnreadCounts = this.getUnreadCounts.bind(this); - this.getChannelNamesMap = this.getChannelNamesMap.bind(this); - this.currentId = null; this.postMode = this.POST_MODE_CHANNEL; this.channels = []; - this.channelMembers = {}; + this.myChannelMembers = {}; this.moreChannels = {}; this.moreChannels.loading = true; - this.extraInfos = {}; + this.stats = {}; this.unreadCounts = {}; } @@ -91,16 +63,16 @@ class ChannelStoreClass extends EventEmitter { this.removeListener(MORE_CHANGE_EVENT, callback); } - emitExtraInfoChange() { - this.emit(EXTRA_INFO_EVENT); + emitStatsChange() { + this.emit(STATS_EVENT); } - addExtraInfoChangeListener(callback) { - this.on(EXTRA_INFO_EVENT, callback); + addStatsChangeListener(callback) { + this.on(STATS_EVENT, callback); } - removeExtraInfoChangeListener(callback) { - this.removeListener(EXTRA_INFO_EVENT, callback); + removeStatsChangeListener(callback) { + this.removeListener(STATS_EVENT, callback); } emitLeave(id) { this.emit(LEAVE_EVENT, id); @@ -148,8 +120,8 @@ class ChannelStoreClass extends EventEmitter { return this.findFirstBy('id', id); } - getMember(id) { - return this.getAllMembers()[id]; + getMyMember(id) { + return this.getMyMembers()[id]; } getByName(name) { @@ -168,10 +140,6 @@ class ChannelStoreClass extends EventEmitter { return this.getChannels(); } - getAllMembers() { - return this.getChannelMembers(); - } - getMoreAll() { return this.getMoreChannels(); } @@ -181,7 +149,7 @@ class ChannelStoreClass extends EventEmitter { } resetCounts(id) { - const cm = this.channelMembers; + const cm = this.myChannelMembers; for (var cmid in cm) { if (cm[cmid].channel_id === id) { var c = this.get(id); @@ -213,41 +181,34 @@ class ChannelStoreClass extends EventEmitter { var currentId = this.getCurrentId(); if (currentId) { - return this.getAllMembers()[currentId]; + return this.getMyMembers()[currentId]; } return null; } - setChannelMember(member) { - var members = this.getChannelMembers(); - members[member.channel_id] = member; - this.storeChannelMembers(members); - this.emitChange(); + getCurrentStats() { + return this.getStats(this.getCurrentId()); } - getCurrentExtraInfo() { - return this.getExtraInfo(this.getCurrentId()); - } - - getExtraInfo(channelId) { - var extra = null; + getStats(channelId) { + let stats; if (channelId) { - extra = this.getExtraInfos()[channelId]; + stats = this.stats[channelId]; } - if (extra) { + if (stats) { // create a defensive copy - extra = JSON.parse(JSON.stringify(extra)); + stats = Object.assign({}, stats); } else { - extra = {members: []}; + stats = {member_count: 0}; } - return extra; + return stats; } - pStoreChannel(channel) { + storeChannel(channel) { var channels = this.getChannels(); var found; @@ -279,18 +240,18 @@ class ChannelStoreClass extends EventEmitter { return this.channels; } - pStoreChannelMember(channelMember) { - var members = this.getChannelMembers(); + storeMyChannelMember(channelMember) { + const members = Object.assign({}, this.getMyMembers()); members[channelMember.channel_id] = channelMember; - this.storeChannelMembers(members); + this.storeMyChannelMembers(members); } - storeChannelMembers(channelMembers) { - this.channelMembers = channelMembers; + storeMyChannelMembers(channelMembers) { + this.myChannelMembers = channelMembers; } - getChannelMembers() { - return this.channelMembers; + getMyMembers() { + return this.myChannelMembers; } storeMoreChannels(channels) { @@ -301,12 +262,8 @@ class ChannelStoreClass extends EventEmitter { return this.moreChannels; } - storeExtraInfos(extraInfos) { - this.extraInfos = extraInfos; - } - - getExtraInfos() { - return this.extraInfos; + storeStats(stats) { + this.stats = stats; } isDefault(channel) { @@ -323,7 +280,7 @@ class ChannelStoreClass extends EventEmitter { setUnreadCount(id) { const ch = this.get(id); - const chMember = this.getMember(id); + const chMember = this.getMyMember(id); const chMentionCount = chMember.mention_count; let chUnreadCount = ch.total_msg_count - chMember.msg_count; @@ -351,7 +308,7 @@ class ChannelStoreClass extends EventEmitter { } leaveChannel(id) { - Reflect.deleteProperty(this.channelMembers, id); + Reflect.deleteProperty(this.myChannelMembers, id); const element = this.channels.indexOf(id); if (element > -1) { this.channels.splice(element, 1); @@ -405,7 +362,7 @@ ChannelStore.dispatchToken = AppDispatcher.register((payload) => { case ActionTypes.RECEIVED_CHANNELS: ChannelStore.storeChannels(action.channels); - ChannelStore.storeChannelMembers(action.members); + ChannelStore.storeMyChannelMembers(action.members); currentId = ChannelStore.getCurrentId(); if (currentId && window.isActive) { ChannelStore.resetCounts(currentId); @@ -415,9 +372,9 @@ ChannelStore.dispatchToken = AppDispatcher.register((payload) => { break; case ActionTypes.RECEIVED_CHANNEL: - ChannelStore.pStoreChannel(action.channel); + ChannelStore.storeChannel(action.channel); if (action.member) { - ChannelStore.pStoreChannelMember(action.member); + ChannelStore.storeMyChannelMember(action.member); } currentId = ChannelStore.getCurrentId(); if (currentId && window.isActive) { @@ -432,11 +389,11 @@ ChannelStore.dispatchToken = AppDispatcher.register((payload) => { ChannelStore.emitMoreChange(); break; - case ActionTypes.RECEIVED_CHANNEL_EXTRA_INFO: - var extraInfos = ChannelStore.getExtraInfos(); - extraInfos[action.extra_info.id] = action.extra_info; - ChannelStore.storeExtraInfos(extraInfos); - ChannelStore.emitExtraInfoChange(); + case ActionTypes.RECEIVED_CHANNEL_STATS: + var stats = Object.assign({}, ChannelStore.getStats()); + stats[action.stats.channel_id] = action.stats; + ChannelStore.storeStats(stats); + ChannelStore.emitStatsChange(); break; case ActionTypes.LEAVE_CHANNEL: diff --git a/webapp/stores/notification_store.jsx b/webapp/stores/notification_store.jsx index 02826d586..dc707b50e 100644 --- a/webapp/stores/notification_store.jsx +++ b/webapp/stores/notification_store.jsx @@ -44,7 +44,7 @@ class NotificationStoreClass extends EventEmitter { const channel = ChannelStore.get(post.channel_id); const user = UserStore.getCurrentUser(); - const member = ChannelStore.getMember(post.channel_id); + const member = ChannelStore.getMyMember(post.channel_id); let notifyLevel = member && member.notify_props ? member.notify_props.desktop : 'default'; if (notifyLevel === 'default') { diff --git a/webapp/stores/post_store.jsx b/webapp/stores/post_store.jsx index 2d0d7a674..cdd3f5860 100644 --- a/webapp/stores/post_store.jsx +++ b/webapp/stores/post_store.jsx @@ -178,15 +178,15 @@ class PostStoreClass extends EventEmitter { } // Returns true if posts need to be fetched - requestVisibilityIncrease(id, ammount) { + requestVisibilityIncrease(id, amount) { const endVisible = this.postsInfo[id].endVisible; const postList = this.postsInfo[id].postList; if (this.getVisibilityAtTop(id)) { return false; } - this.postsInfo[id].endVisible += ammount; + this.postsInfo[id].endVisible += amount; this.emitChange(); - return endVisible + ammount > postList.order.length; + return endVisible + amount > postList.order.length; } getFocusedPostId() { diff --git a/webapp/stores/suggestion_store.jsx b/webapp/stores/suggestion_store.jsx index c59c26a66..c528f7360 100644 --- a/webapp/stores/suggestion_store.jsx +++ b/webapp/stores/suggestion_store.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; import Constants from 'utils/constants.jsx'; import EventEmitter from 'events'; @@ -222,7 +222,9 @@ class SuggestionStore extends EventEmitter { switch (type) { case ActionTypes.SUGGESTION_PRETEXT_CHANGED: - this.clearSuggestions(id); + if (other.pretext === '') { + this.clearSuggestions(id); + } this.setPretext(id, other.pretext); this.emitPretextChanged(id, other.pretext); @@ -231,6 +233,8 @@ class SuggestionStore extends EventEmitter { this.emitSuggestionsChanged(id); break; case ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS: + this.clearSuggestions(id); + // ensure the matched pretext hasn't changed so that we don't receive suggestions for outdated pretext this.addSuggestions(id, other.terms, other.items, other.component, other.matchedPretext); diff --git a/webapp/stores/team_store.jsx b/webapp/stores/team_store.jsx index c71cc685b..3a4ae73b9 100644 --- a/webapp/stores/team_store.jsx +++ b/webapp/stores/team_store.jsx @@ -9,6 +9,7 @@ import Constants from 'utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; const CHANGE_EVENT = 'change'; +const STATS_EVENT = 'stats'; var Utils; @@ -20,8 +21,10 @@ class TeamStoreClass extends EventEmitter { clear() { this.teams = {}; - this.team_members = []; - this.members_for_team = []; + this.my_team_members = []; + this.members_in_team = {}; + this.members_not_in_team = {}; + this.stats = {}; this.teamListings = {}; this.currentTeamId = ''; } @@ -38,6 +41,18 @@ class TeamStoreClass extends EventEmitter { this.removeListener(CHANGE_EVENT, callback); } + emitStatsChange() { + this.emit(STATS_EVENT); + } + + addStatsChangeListener(callback) { + this.on(STATS_EVENT, callback); + } + + removeStatsChangeListener(callback) { + this.removeListener(STATS_EVENT, callback); + } + get(id) { var c = this.getAll(); return c[id]; @@ -114,6 +129,27 @@ class TeamStoreClass extends EventEmitter { return origin + '/' + team.name; } + getCurrentStats() { + return this.getStats(this.getCurrentId()); + } + + getStats(teamId) { + let stats; + + if (teamId) { + stats = this.stats[teamId]; + } + + if (stats) { + // create a defensive copy + stats = Object.assign({}, stats); + } else { + stats = {member_count: 0}; + } + + return stats; + } + saveTeam(team) { this.teams[team.id] = team; } @@ -127,44 +163,62 @@ class TeamStoreClass extends EventEmitter { this.currentTeamId = team.id; } - saveTeamMembers(members) { - this.team_members = members; + saveStats(teamId, stats) { + this.stats[teamId] = stats; } - appendTeamMember(member) { - this.team_members.push(member); + saveMyTeamMembers(members) { + this.my_team_members = members; } - removeTeamMember(teamId) { - for (var index in this.team_members) { - if (this.team_members.hasOwnProperty(index)) { - if (this.team_members[index].team_id === teamId) { - this.team_members.splice(index, 1); + appendMyTeamMember(member) { + this.my_team_members.push(member); + } + + removeMyTeamMember(teamId) { + for (var index in this.my_team_members) { + if (this.my_team_members.hasOwnProperty(index)) { + if (this.my_team_members[index].team_id === teamId) { + Reflect.deleteProperty(this.my_team_members, index); } } } } - getTeamMembers() { - return this.team_members; + getMyTeamMembers() { + return this.my_team_members; } - saveMembersForTeam(members) { - this.members_for_team = members; + saveMembersInTeam(teamId = this.getCurrentId(), members) { + const oldMembers = this.members_in_team[teamId] || {}; + this.members_in_team[teamId] = Object.assign({}, oldMembers, members); } - getMembersForTeam() { - return this.members_for_team; + saveMembersNotInTeam(teamId = this.getCurrentId(), nonmembers) { + this.members_not_in_team[teamId] = nonmembers; } - hasActiveMemberForTeam(userId) { - for (var index in this.members_for_team) { - if (this.members_for_team.hasOwnProperty(index)) { - if (this.members_for_team[index].user_id === userId && - this.members_for_team[index].team_id === this.currentTeamId) { - return this.members_for_team[index].delete_at === 0; - } - } + removeMemberInTeam(teamId = this.getCurrentId(), userId) { + if (this.members_in_team[teamId]) { + Reflect.deleteProperty(this.members_in_team[teamId], userId); + } + } + + getMembersInTeam(teamId = this.getCurrentId()) { + return this.members_in_team[teamId] || {}; + } + + hasActiveMemberInTeam(teamId = this.getCurrentId(), userId) { + if (this.members_in_team[teamId] && this.members_in_team[teamId][userId]) { + return true; + } + + return false; + } + + hasMemberNotInTeam(teamId = this.getCurrentId(), userId) { + if (this.members_not_in_team[teamId] && this.members_not_in_team[teamId][userId]) { + return true; } return false; @@ -187,7 +241,7 @@ class TeamStoreClass extends EventEmitter { Utils = require('utils/utils.jsx'); //eslint-disable-line global-require } - var teamMembers = this.getTeamMembers(); + var teamMembers = this.getMyTeamMembers(); const teamMember = teamMembers.find((m) => m.user_id === userId && m.team_id === teamId); if (teamMember) { @@ -210,25 +264,32 @@ TeamStore.dispatchToken = AppDispatcher.register((payload) => { break; case ActionTypes.CREATED_TEAM: TeamStore.saveTeam(action.team); - TeamStore.appendTeamMember(action.member); + TeamStore.appendMyTeamMember(action.member); TeamStore.emitChange(); break; case ActionTypes.RECEIVED_ALL_TEAMS: TeamStore.saveTeams(action.teams); TeamStore.emitChange(); break; - case ActionTypes.RECEIVED_TEAM_MEMBERS: - TeamStore.saveTeamMembers(action.team_members); + case ActionTypes.RECEIVED_MY_TEAM_MEMBERS: + TeamStore.saveMyTeamMembers(action.team_members); TeamStore.emitChange(); break; case ActionTypes.RECEIVED_ALL_TEAM_LISTINGS: TeamStore.saveTeamListings(action.teams); TeamStore.emitChange(); break; - case ActionTypes.RECEIVED_MEMBERS_FOR_TEAM: - TeamStore.saveMembersForTeam(action.team_members); + case ActionTypes.RECEIVED_MEMBERS_IN_TEAM: + TeamStore.saveMembersInTeam(action.team_id, action.team_members); + if (action.non_team_members) { + TeamStore.saveMembersNotInTeam(action.team_id, action.non_team_members); + } TeamStore.emitChange(); break; + case ActionTypes.RECEIVED_TEAM_STATS: + TeamStore.saveStats(action.team_id, action.stats); + TeamStore.emitStatsChange(); + break; default: } }); diff --git a/webapp/stores/user_store.jsx b/webapp/stores/user_store.jsx index 859f385c0..d93848670 100644 --- a/webapp/stores/user_store.jsx +++ b/webapp/stores/user_store.jsx @@ -6,12 +6,16 @@ import EventEmitter from 'events'; import * as GlobalActions from 'actions/global_actions.jsx'; import LocalizationStore from './localization_store.jsx'; +import ChannelStore from 'stores/channel_store.jsx'; +import TeamStore from 'stores/team_store.jsx'; import Constants from 'utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; const UserStatuses = Constants.UserStatuses; -const CHANGE_EVENT_DM_LIST = 'change_dm_list'; +const CHANGE_EVENT_NOT_IN_CHANNEL = 'change_not_in_channel'; +const CHANGE_EVENT_IN_CHANNEL = 'change_in_channel'; +const CHANGE_EVENT_IN_TEAM = 'change_in_team'; const CHANGE_EVENT = 'change'; const CHANGE_EVENT_SESSIONS = 'change_sessions'; const CHANGE_EVENT_AUDITS = 'change_audits'; @@ -26,9 +30,26 @@ class UserStoreClass extends EventEmitter { } clear() { - this.profiles_for_dm_list = {}; + // All the profiles, regardless of where they came from this.profiles = {}; - this.direct_profiles = {}; + this.paging_offset = 0; + this.paging_count = 0; + + // Lists of sorted IDs for users in a team + this.profiles_in_team = {}; + this.in_team_offset = 0; + this.in_team_count = 0; + + // Lists of sorted IDs for users in a channel + this.profiles_in_channel = {}; + this.in_channel_offset = {}; + this.in_channel_count = {}; + + // Lists of sorted IDs for users not in a channel + this.profiles_not_in_channel = {}; + this.not_in_channel_offset = {}; + this.not_in_channel_count = {}; + this.statuses = {}; this.sessions = {}; this.audits = {}; @@ -48,16 +69,40 @@ class UserStoreClass extends EventEmitter { this.removeListener(CHANGE_EVENT, callback); } - emitDmListChange() { - this.emit(CHANGE_EVENT_DM_LIST); + emitInTeamChange() { + this.emit(CHANGE_EVENT_IN_TEAM); + } + + addInTeamChangeListener(callback) { + this.on(CHANGE_EVENT_IN_TEAM, callback); } - addDmListChangeListener(callback) { - this.on(CHANGE_EVENT_DM_LIST, callback); + removeInTeamChangeListener(callback) { + this.removeListener(CHANGE_EVENT_IN_TEAM, callback); } - removeDmListChangeListener(callback) { - this.removeListener(CHANGE_EVENT_DM_LIST, callback); + emitInChannelChange() { + this.emit(CHANGE_EVENT_IN_CHANNEL); + } + + addInChannelChangeListener(callback) { + this.on(CHANGE_EVENT_IN_CHANNEL, callback); + } + + removeInChannelChangeListener(callback) { + this.removeListener(CHANGE_EVENT_IN_CHANNEL, callback); + } + + emitNotInChannelChange() { + this.emit(CHANGE_EVENT_NOT_IN_CHANNEL); + } + + addNotInChannelChangeListener(callback) { + this.on(CHANGE_EVENT_NOT_IN_CHANNEL, callback); + } + + removeNotInChannelChangeListener(callback) { + this.removeListener(CHANGE_EVENT_NOT_IN_CHANNEL, callback); } emitSessionsChange() { @@ -96,6 +141,8 @@ class UserStoreClass extends EventEmitter { this.removeListener(CHANGE_EVENT_STATUSES, callback); } + // General + getCurrentUser() { return this.getProfiles()[this.currentUserId]; } @@ -119,29 +166,30 @@ class UserStoreClass extends EventEmitter { return null; } - hasProfile(userId) { - return this.getProfile(userId) != null; - } + // System-Wide Profiles - hasTeamProfile(userId) { - return this.getProfiles()[userId]; + saveProfiles(profiles) { + const currentId = this.getCurrentId(); + if (profiles[currentId]) { + Reflect.deleteProperty(profiles, currentId); + } + this.profiles = Object.assign({}, this.profiles, profiles); } - hasDirectProfile(userId) { - return this.getDirectProfiles()[userId]; + getProfiles() { + return this.profiles; } getProfile(userId) { - if (userId === this.getCurrentId()) { - return this.getCurrentUser(); + if (this.profiles[userId]) { + return Object.assign({}, this.profiles[userId]); } - const user = this.getProfiles()[userId]; - if (user) { - return user; - } + return null; + } - return this.getDirectProfiles()[userId]; + hasProfile(userId) { + return this.getProfile(userId) != null; } getProfileByUsername(username) { @@ -162,22 +210,6 @@ class UserStoreClass extends EventEmitter { return profileUsernameMap; } - getDirectProfiles() { - return this.direct_profiles; - } - - saveDirectProfile(profile) { - this.direct_profiles[profile.id] = profile; - } - - saveDirectProfiles(profiles) { - this.direct_profiles = profiles; - } - - getProfiles() { - return this.profiles; - } - getActiveOnlyProfiles(skipCurrent) { const active = {}; const profiles = this.getProfiles(); @@ -195,14 +227,54 @@ class UserStoreClass extends EventEmitter { getActiveOnlyProfileList() { const profileMap = this.getActiveOnlyProfiles(); const profiles = []; - const currentId = this.getCurrentId(); for (const id in profileMap) { - if (profileMap.hasOwnProperty(id) && id !== currentId) { + if (profileMap.hasOwnProperty(id)) { profiles.push(profileMap[id]); } } + profiles.sort((a, b) => { + if (a.username < b.username) { + return -1; + } + if (a.username > b.username) { + return 1; + } + return 0; + }); + + return profiles; + } + + getProfileList(skipCurrent) { + const profiles = []; + const currentId = this.getCurrentId(); + + for (const id in this.profiles) { + if (this.profiles.hasOwnProperty(id)) { + var profile = this.profiles[id]; + + if (skipCurrent && id === currentId) { + continue; + } + + if (profile.delete_at === 0) { + profiles.push(profile); + } + } + } + + profiles.sort((a, b) => { + if (a.username < b.username) { + return -1; + } + if (a.username > b.username) { + return 1; + } + return 0; + }); + return profiles; } @@ -210,44 +282,194 @@ class UserStoreClass extends EventEmitter { this.profiles[profile.id] = profile; } - saveProfiles(profiles) { + // Team-Wide Profiles + + saveProfilesInTeam(teamId, profiles) { + const oldProfileList = this.profiles_in_team[teamId] || []; + const oldProfileMap = {}; + for (let i = 0; i < oldProfileList.length; i++) { + oldProfileMap[oldProfileList[i]] = this.getProfile(oldProfileList[i]); + } + + const newProfileMap = Object.assign({}, oldProfileMap, profiles); + const newProfileList = Object.keys(newProfileMap); + + newProfileList.sort((a, b) => { + const aProfile = newProfileMap[a]; + const bProfile = newProfileMap[b]; + + if (aProfile.username < bProfile.username) { + return -1; + } + if (aProfile.username > bProfile.username) { + return 1; + } + return 0; + }); + + this.profiles_in_team[teamId] = newProfileList; + this.saveProfiles(profiles); + } + + getProfileListInTeam(teamId = TeamStore.getCurrentId(), skipCurrent) { + const userIds = this.profiles_in_team[teamId] || []; + const profiles = []; const currentId = this.getCurrentId(); - const currentUser = this.profiles[currentId]; - if (currentUser) { - if (currentId in this.profiles) { - Reflect.deleteProperty(this.profiles, currentId); + + for (let i = 0; i < userIds.length; i++) { + const profile = this.getProfile(userIds[i]); + + if (skipCurrent && profile.id === currentId) { + continue; } - this.profiles = profiles; - this.profiles[currentId] = currentUser; - } else { - this.profiles = profiles; + if (profile) { + profiles.push(profile); + } } + + return profiles; } - getProfilesForDmList() { - const currentId = this.getCurrentId(); - const profiles = []; + // Channel-Wide Profiles - for (const id in this.profiles_for_dm_list) { - if (this.profiles_for_dm_list.hasOwnProperty(id) && id !== currentId) { - var profile = this.profiles_for_dm_list[id]; + saveProfilesInChannel(channelId = ChannelStore.getCurrentId(), profiles) { + const oldProfileList = this.profiles_in_channel[channelId] || []; + const oldProfileMap = {}; + for (let i = 0; i < oldProfileList.length; i++) { + oldProfileMap[oldProfileList[i]] = this.getProfile(oldProfileList[i]); + } - if (profile.delete_at === 0) { - profiles.push(profile); - } + const newProfileMap = Object.assign({}, oldProfileMap, profiles); + const newProfileList = Object.keys(newProfileMap); + + newProfileList.sort((a, b) => { + const aProfile = newProfileMap[a]; + const bProfile = newProfileMap[b]; + + if (aProfile.username < bProfile.username) { + return -1; } + if (aProfile.username > bProfile.username) { + return 1; + } + return 0; + }); + + this.profiles_in_channel[channelId] = newProfileList; + this.saveProfiles(profiles); + } + + saveProfileInChannel(channelId = ChannelStore.getCurrentId(), profile) { + const profileMap = {}; + profileMap[profile.id] = profile; + this.saveProfilesInChannel(channelId, profileMap); + } + + saveUserIdInChannel(channelId = ChannelStore.getCurrentId(), userId) { + const profile = this.getProfile(userId); + + // Must have profile or we can't sort the list + if (!profile) { + return false; + } + + this.saveProfileInChannel(channelId, profile); + + return true; + } + + removeProfileInChannel(channelId, userId) { + const userIds = this.profiles_in_channel[channelId]; + if (!userIds) { + return; } - profiles.sort((a, b) => a.username.localeCompare(b.username)); + const index = userIds.indexOf(userId); + if (index === -1) { + return; + } + + userIds.splice(index, 1); + } + + getProfileListInChannel(channelId = ChannelStore.getCurrentId()) { + const userIds = this.profiles_in_channel[channelId] || []; + const profiles = []; + + for (let i = 0; i < userIds.length; i++) { + const profile = this.getProfile(userIds[i]); + if (profile) { + profiles.push(profile); + } + } return profiles; } - saveProfilesForDmList(profiles) { - this.profiles_for_dm_list = profiles; + saveProfilesNotInChannel(channelId = ChannelStore.getCurrentId(), profiles) { + const oldProfileList = this.profiles_not_in_channel[channelId] || []; + const oldProfileMap = {}; + for (let i = 0; i < oldProfileList.length; i++) { + oldProfileMap[oldProfileList[i]] = this.getProfile(oldProfileList[i]); + } + + const newProfileMap = Object.assign({}, oldProfileMap, profiles); + const newProfileList = Object.keys(newProfileMap); + + newProfileList.sort((a, b) => { + const aProfile = newProfileMap[a]; + const bProfile = newProfileMap[b]; + + if (aProfile.username < bProfile.username) { + return -1; + } + if (aProfile.username > bProfile.username) { + return 1; + } + return 0; + }); + + this.profiles_not_in_channel[channelId] = newProfileList; + this.saveProfiles(profiles); + } + + saveProfileNotInChannel(channelId = ChannelStore.getCurrentId(), profile) { + const profileMap = {}; + profileMap[profile.id] = profile; + this.saveProfilesNotInChannel(channelId, profileMap); + } + + removeProfileNotInChannel(channelId, userId) { + const userIds = this.profiles_not_in_channel[channelId]; + if (!userIds) { + return; + } + + const index = userIds.indexOf(userId); + if (index === -1) { + return; + } + + userIds.splice(index, 1); + } + + getProfileListNotInChannel(channelId = ChannelStore.getCurrentId()) { + const userIds = this.profiles_not_in_channel[channelId] || []; + const profiles = []; + + for (let i = 0; i < userIds.length; i++) { + const profile = this.getProfile(userIds[i]); + if (profile) { + profiles.push(profile); + } + } + + return profiles; } + // Other + setSessions(sessions) { this.sessions = sessions; } @@ -331,6 +553,58 @@ class UserStoreClass extends EventEmitter { return false; } + + setPage(offset, count) { + this.paging_offset = offset + count; + this.paging_count = this.paging_count + count; + } + + getPagingOffset() { + return this.paging_offset; + } + + getPagingCount() { + return this.paging_count; + } + + setInTeamPage(offset, count) { + this.in_team_offset = offset + count; + this.in_team_count = this.in_team_count + count; + } + + getInTeamPagingOffset() { + return this.in_team_offset; + } + + getInTeamPagingCount() { + return this.in_team_count; + } + + setInChannelPage(channelId, offset, count) { + this.in_channel_offset[channelId] = offset + count; + this.in_channel_count[channelId] = this.dm_paging_count + count; + } + + getInChannelPagingOffset(channelId) { + return this.in_channel_offset[channelId] | 0; + } + + getInChannelPagingCount(channelId) { + return this.in_channel_count[channelId] | 0; + } + + setNotInChannelPage(channelId, offset, count) { + this.not_in_channel_offset[channelId] = offset + count; + this.not_in_channel_count[channelId] = this.dm_paging_count + count; + } + + getNotInChannelPagingOffset(channelId) { + return this.not_in_channel_offset[channelId] | 0; + } + + getNotInChannelPagingCount(channelId) { + return this.not_in_channel_count[channelId] | 0; + } } var UserStore = new UserStoreClass(); @@ -340,16 +614,36 @@ UserStore.dispatchToken = AppDispatcher.register((payload) => { var action = payload.action; switch (action.type) { - case ActionTypes.RECEIVED_PROFILES_FOR_DM_LIST: - UserStore.saveProfilesForDmList(action.profiles); - UserStore.emitDmListChange(); - break; case ActionTypes.RECEIVED_PROFILES: UserStore.saveProfiles(action.profiles); + if (action.offset != null && action.count != null) { + UserStore.setPage(action.offset, action.count); + } UserStore.emitChange(); break; - case ActionTypes.RECEIVED_DIRECT_PROFILES: - UserStore.saveDirectProfiles(action.profiles); + case ActionTypes.RECEIVED_PROFILES_IN_TEAM: + UserStore.saveProfilesInTeam(action.team_id, action.profiles); + if (action.offset != null && action.count != null) { + UserStore.setInTeamPage(action.offset, action.count); + } + UserStore.emitInTeamChange(); + break; + case ActionTypes.RECEIVED_PROFILES_IN_CHANNEL: + UserStore.saveProfilesInChannel(action.channel_id, action.profiles); + if (action.offset != null && action.count != null) { + UserStore.setInChannelPage(action.offset, action.count); + } + UserStore.emitInChannelChange(); + break; + case ActionTypes.RECEIVED_PROFILES_NOT_IN_CHANNEL: + UserStore.saveProfilesNotInChannel(action.channel_id, action.profiles); + if (action.offset != null && action.count != null) { + UserStore.setNotInChannelPage(action.offset, action.count); + } + UserStore.emitNotInChannelChange(); + break; + case ActionTypes.RECEIVED_PROFILE: + UserStore.saveProfile(action.profile); UserStore.emitChange(); break; case ActionTypes.RECEIVED_ME: diff --git a/webapp/tests/client_channel.test.jsx b/webapp/tests/client_channel.test.jsx index ccfcb32a4..92145f6e1 100644 --- a/webapp/tests/client_channel.test.jsx +++ b/webapp/tests/client_channel.test.jsx @@ -285,11 +285,10 @@ describe('Client.Channels', function() { }); }); - it('getChannelExtraInfo', function(done) { + it('getChannelStats', function(done) { TestHelper.initBasic(() => { - TestHelper.basicClient().getChannelExtraInfo( + TestHelper.basicClient().getChannelStats( TestHelper.basicChannel().id, - 5, function(data) { assert.equal(data.member_count, 1); done(); @@ -301,6 +300,23 @@ describe('Client.Channels', function() { }); }); + it('getChannelMember', function(done) { + TestHelper.initBasic(() => { + TestHelper.basicClient().getChannelMember( + TestHelper.basicChannel().id, + TestHelper.basicUser().id, + function(data) { + assert.equal(data.channel_id, TestHelper.basicChannel().id); + assert.equal(data.user_id, TestHelper.basicUser().id); + done(); + }, + function(err) { + done(new Error(err.message)); + } + ); + }); + }); + /* TODO FIX THIS TEST it('addChannelMember', function(done) { TestHelper.initBasic(() => { diff --git a/webapp/tests/client_team.test.jsx b/webapp/tests/client_team.test.jsx index 157a2f4a5..642307986 100644 --- a/webapp/tests/client_team.test.jsx +++ b/webapp/tests/client_team.test.jsx @@ -130,10 +130,12 @@ describe('Client.Team', function() { }); }); - it('GetTeamMembers', function(done) { + it('getTeamMembers', function(done) { TestHelper.initBasic(() => { TestHelper.basicClient().getTeamMembers( TestHelper.basicTeam().id, + 0, + 100, function(data) { assert.equal(data.length > 0, true); done(); @@ -145,6 +147,55 @@ describe('Client.Team', function() { }); }); + it('getTeamMember', function(done) { + TestHelper.initBasic(() => { + TestHelper.basicClient().getTeamMember( + TestHelper.basicTeam().id, + TestHelper.basicUser().id, + function(data) { + assert.equal(data.user_id, TestHelper.basicUser().id); + assert.equal(data.team_id, TestHelper.basicTeam().id); + done(); + }, + function(err) { + done(new Error(err.message)); + } + ); + }); + }); + + it('getTeamStats', function(done) { + TestHelper.initBasic(() => { + TestHelper.basicClient().getTeamStats( + TestHelper.basicTeam().id, + function(data) { + assert.equal(data.member_count > 0, true); + done(); + }, + function(err) { + done(new Error(err.message)); + } + ); + }); + }); + + it('getTeamMembersByIds', function(done) { + TestHelper.initBasic(() => { + TestHelper.basicClient().getTeamMembersByIds( + TestHelper.basicTeam().id, + [TestHelper.basicUser().id], + function(data) { + assert.equal(data[0].user_id, TestHelper.basicUser().id); + assert.equal(data[0].team_id, TestHelper.basicTeam().id); + done(); + }, + function(err) { + done(new Error(err.message)); + } + ); + }); + }); + it('inviteMembers', function(done) { TestHelper.initBasic(() => { var data = {}; diff --git a/webapp/tests/client_user.test.jsx b/webapp/tests/client_user.test.jsx index 48a0150d4..8c6f0f970 100644 --- a/webapp/tests/client_user.test.jsx +++ b/webapp/tests/client_user.test.jsx @@ -444,23 +444,28 @@ describe('Client.User', function() { }); }); - it('getDirectProfiles', function(done) { + it('getProfiles', function(done) { TestHelper.initBasic(() => { - TestHelper.basicClient().getDirectProfiles( + TestHelper.basicClient().getProfiles( + 0, + 100, function(data) { - assert.equal(Object.keys(data).length === 0, true); + assert.equal(Object.keys(data).length > 0, true); done(); }, function(err) { - done(new Error(err.getDirectProfiles)); + done(new Error(err.message)); } ); }); }); - it('getProfiles', function(done) { + it('getProfilesInTeam', function(done) { TestHelper.initBasic(() => { - TestHelper.basicClient().getProfiles( + TestHelper.basicClient().getProfilesInTeam( + TestHelper.basicTeam().id, + 0, + 100, function(data) { assert.equal(data[TestHelper.basicUser().id].id, TestHelper.basicUser().id); done(); @@ -472,10 +477,10 @@ describe('Client.User', function() { }); }); - it('getProfilesForTeam', function(done) { + it('getProfilesByIds', function(done) { TestHelper.initBasic(() => { - TestHelper.basicClient().getProfilesForTeam( - TestHelper.basicTeam().id, + TestHelper.basicClient().getProfilesByIds( + [TestHelper.basicUser().id], function(data) { assert.equal(data[TestHelper.basicUser().id].id, TestHelper.basicUser().id); done(); @@ -487,9 +492,12 @@ describe('Client.User', function() { }); }); - it('getProfilesForDirectMessageList', function(done) { + it('getProfilesInChannel', function(done) { TestHelper.initBasic(() => { - TestHelper.basicClient().getProfilesForDirectMessageList( + TestHelper.basicClient().getProfilesInChannel( + TestHelper.basicChannel().id, + 0, + 100, function(data) { assert.equal(Object.keys(data).length > 0, true); done(); @@ -501,16 +509,80 @@ describe('Client.User', function() { }); }); - /* TODO: FIX THIS TEST - it('getStatuses', function(done) { + it('getProfilesNotInChannel', function(done) { + TestHelper.initBasic(() => { + TestHelper.basicClient().getProfilesNotInChannel( + TestHelper.basicChannel().id, + 0, + 100, + function(data) { + assert.equal(Object.keys(data).length > 0, false); + done(); + }, + function(err) { + done(new Error(err.message)); + } + ); + }); + }); + + it('searchUsers', function(done) { + TestHelper.initBasic(() => { + TestHelper.basicClient().searchUsers( + 'uid', + TestHelper.basicTeam().id, + {}, + function(data) { + assert.equal(data.length > 0, true); + done(); + }, + function(err) { + done(new Error(err.message)); + } + ); + }); + }); + + it('autocompleteUsersInChannel', function(done) { + TestHelper.initBasic(() => { + TestHelper.basicClient().autocompleteUsersInChannel( + 'uid', + TestHelper.basicChannel().id, + function(data) { + assert.equal(data != null, true); + done(); + }, + function(err) { + done(new Error(err.message)); + } + ); + }); + }); + + it('autocompleteUsersInTeam', function(done) { + TestHelper.initBasic(() => { + TestHelper.basicClient().autocompleteUsersInTeam( + 'uid', + function(data) { + assert.equal(data != null, true); + done(); + }, + function(err) { + done(new Error(err.message)); + } + ); + }); + }); + + it('getStatusesByIds', function(done) { TestHelper.initBasic(() => { var ids = []; ids.push(TestHelper.basicUser().id); - TestHelper.basicClient().getStatuses( + TestHelper.basicClient().getStatusesByIds( ids, function(data) { - assert.equal(data[TestHelper.basicUser().id], 'online'); + assert.equal(data[TestHelper.basicUser().id] != null, true); done(); }, function(err) { @@ -519,7 +591,6 @@ describe('Client.User', function() { ); }); }); - */ it('setActiveChannel', function(done) { TestHelper.initBasic(() => { diff --git a/webapp/utils/async_client.jsx b/webapp/utils/async_client.jsx index 30bc474f8..24d540929 100644 --- a/webapp/utils/async_client.jsx +++ b/webapp/utils/async_client.jsx @@ -1,19 +1,21 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import $ from 'jquery'; -import Client from 'client/web_client.jsx'; -import * as GlobalActions from 'actions/global_actions.jsx'; -import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; import BrowserStore from 'stores/browser_store.jsx'; import ChannelStore from 'stores/channel_store.jsx'; -import PostStore from 'stores/post_store.jsx'; import UserStore from 'stores/user_store.jsx'; -import * as utils from './utils.jsx'; -import * as UserAgent from './user_agent.jsx'; +import TeamStore from 'stores/team_store.jsx'; import ErrorStore from 'stores/error_store.jsx'; -import Constants from './constants.jsx'; +import * as GlobalActions from 'actions/global_actions.jsx'; +import {loadStatusesForProfilesMap} from 'actions/status_actions.jsx'; + +import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; +import Client from 'client/web_client.jsx'; +import * as utils from 'utils/utils.jsx'; +import * as UserAgent from 'utils/user_agent.jsx'; + +import Constants from 'utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; const StatTypes = Constants.StatTypes; @@ -215,94 +217,89 @@ export function getMoreChannels(force) { } } -export function getChannelExtraInfo(id, memberLimit) { - let channelId; - if (id) { - channelId = id; - } else { - channelId = ChannelStore.getCurrentId(); +export function getChannelStats(channelId = ChannelStore.getCurrentId()) { + if (isCallInProgress('getChannelStats' + channelId)) { + return; } - if (channelId != null) { - if (isCallInProgress('getChannelExtraInfo_' + channelId)) { - return; - } - - callTracker['getChannelExtraInfo_' + channelId] = utils.getTimestamp(); + callTracker['getChannelStats' + channelId] = utils.getTimestamp(); - Client.getChannelExtraInfo( - channelId, - memberLimit, - (data) => { - callTracker['getChannelExtraInfo_' + channelId] = 0; + Client.getChannelStats( + channelId, + (data) => { + callTracker['getChannelStats' + channelId] = 0; - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_CHANNEL_EXTRA_INFO, - extra_info: data - }); - }, - (err) => { - callTracker['getChannelExtraInfo_' + channelId] = 0; - dispatchError(err, 'getChannelExtraInfo'); - } - ); - } + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_CHANNEL_STATS, + stats: data + }); + }, + (err) => { + callTracker['getChannelStats' + channelId] = 0; + dispatchError(err, 'getChannelStats'); + } + ); } -export function getTeamMembers(teamId) { - if (isCallInProgress('getTeamMembers')) { +export function getChannelMember(channelId, userId) { + if (isCallInProgress(`getChannelMember${channelId}${userId}`)) { return; } - callTracker.getTeamMembers = utils.getTimestamp(); - Client.getTeamMembers( - teamId, + callTracker[`getChannelMember${channelId}${userId}`] = utils.getTimestamp(); + + Client.getChannelMember( + channelId, + userId, (data) => { - callTracker.getTeamMembers = 0; + callTracker[`getChannelMember${channelId}${userId}`] = 0; AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_MEMBERS_FOR_TEAM, - team_members: data + type: ActionTypes.RECEIVED_CHANNEL_MEMBER, + member: data }); }, (err) => { - callTracker.getTeamMembers = 0; - dispatchError(err, 'getTeamMembers'); + callTracker[`getChannelMember${channelId}${userId}`] = 0; + dispatchError(err, 'getChannelMember'); } ); } -export function getProfilesForDirectMessageList() { - if (isCallInProgress('getProfilesForDirectMessageList')) { +export function getUser(userId) { + if (isCallInProgress(`getUser${userId}`)) { return; } - callTracker.getProfilesForDirectMessageList = utils.getTimestamp(); - Client.getProfilesForDirectMessageList( + callTracker[`getUser${userId}`] = utils.getTimestamp(); + Client.getUser( + userId, (data) => { - callTracker.getProfilesForDirectMessageList = 0; + callTracker[`getUser${userId}`] = 0; AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_PROFILES_FOR_DM_LIST, - profiles: data + type: ActionTypes.RECEIVED_PROFILE, + profile: data }); }, (err) => { - callTracker.getProfilesForDirectMessageList = 0; - dispatchError(err, 'getProfilesForDirectMessageList'); + callTracker[`getUser${userId}`] = 0; + dispatchError(err, 'getUser'); } ); } -export function getProfiles() { - if (isCallInProgress('getProfiles')) { +export function getProfiles(offset = UserStore.getPagingOffset(), limit = Constants.PROFILE_CHUNK_SIZE) { + if (isCallInProgress(`getProfiles${offset}${limit}`)) { return; } - callTracker.getProfiles = utils.getTimestamp(); + callTracker[`getProfiles${offset}${limit}`] = utils.getTimestamp(); Client.getProfiles( + offset, + limit, (data) => { - callTracker.getProfiles = 0; + callTracker[`getProfiles${offset}${limit}`] = 0; AppDispatcher.handleServerAction({ type: ActionTypes.RECEIVED_PROFILES, @@ -310,30 +307,123 @@ export function getProfiles() { }); }, (err) => { - callTracker.getProfiles = 0; + callTracker[`getProfiles${offset}${limit}`] = 0; dispatchError(err, 'getProfiles'); } ); } -export function getDirectProfiles() { - if (isCallInProgress('getDirectProfiles')) { +export function getProfilesInTeam(teamId = TeamStore.getCurrentId(), offset = UserStore.getInTeamPagingOffset(), limit = Constants.PROFILE_CHUNK_SIZE) { + if (isCallInProgress(`getProfilesInTeam${offset}${limit}`)) { + return; + } + + callTracker[`getProfilesInTeam${offset}${limit}`] = utils.getTimestamp(); + Client.getProfilesInTeam( + teamId, + offset, + limit, + (data) => { + callTracker[`getProfilesInTeam${offset}${limit}`] = 0; + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_PROFILES_IN_TEAM, + profiles: data, + team_id: teamId, + offset, + count: Object.keys(data).length + }); + }, + (err) => { + callTracker[`getProfilesInTeam${offset}${limit}`] = 0; + dispatchError(err, 'getProfilesInTeam'); + } + ); +} + +export function getProfilesInChannel(channelId = ChannelStore.getCurrentId(), offset = UserStore.getInChannelPagingOffset(), limit = Constants.PROFILE_CHUNK_SIZE) { + if (isCallInProgress(`getProfilesInChannel${offset}${limit}`)) { + return; + } + + callTracker[`getProfilesInChannel${offset}${limit}`] = utils.getTimestamp(); + Client.getProfilesInChannel( + channelId, + offset, + limit, + (data) => { + callTracker[`getProfilesInChannel${offset}${limit}`] = 0; + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_PROFILES_IN_CHANNEL, + channel_id: channelId, + profiles: data, + offset, + count: Object.keys(data).length + }); + + loadStatusesForProfilesMap(data); + }, + (err) => { + callTracker[`getProfilesInChannel${offset}${limit}`] = 0; + dispatchError(err, 'getProfilesInChannel'); + } + ); +} + +export function getProfilesNotInChannel(channelId = ChannelStore.getCurrentId(), offset = UserStore.getNotInChannelPagingOffset(), limit = Constants.PROFILE_CHUNK_SIZE) { + if (isCallInProgress(`getProfilesNotInChannel${offset}${limit}`)) { + return; + } + + callTracker[`getProfilesNotInChannel${offset}${limit}`] = utils.getTimestamp(); + Client.getProfilesNotInChannel( + channelId, + offset, + limit, + (data) => { + callTracker[`getProfilesNotInChannel${offset}${limit}`] = 0; + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_PROFILES_NOT_IN_CHANNEL, + channel_id: channelId, + profiles: data, + offset, + count: Object.keys(data).length + }); + + loadStatusesForProfilesMap(data); + }, + (err) => { + callTracker[`getProfilesNotInChannel${offset}${limit}`] = 0; + dispatchError(err, 'getProfilesNotInChannel'); + } + ); +} + +export function getProfilesByIds(userIds) { + if (isCallInProgress('getProfilesByIds')) { return; } - callTracker.getDirectProfiles = utils.getTimestamp(); - Client.getDirectProfiles( + if (!userIds || userIds.length === 0) { + return; + } + + callTracker.getProfilesByIds = utils.getTimestamp(); + Client.getProfilesByIds( + userIds, (data) => { - callTracker.getDirectProfiles = 0; + callTracker.getProfilesByIds = 0; AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_DIRECT_PROFILES, + type: ActionTypes.RECEIVED_PROFILES, profiles: data }); }, (err) => { - callTracker.getDirectProfiles = 0; - dispatchError(err, 'getDirectProfiles'); + callTracker.getProfilesByIds = 0; + dispatchError(err, 'getProfilesByIds'); } ); } @@ -548,173 +638,6 @@ export function search(terms, isOrSearch) { ); } -export function getPostsPage(id, maxPosts) { - let channelId = id; - if (channelId == null) { - channelId = ChannelStore.getCurrentId(); - if (channelId == null) { - return; - } - } - - if (isCallInProgress('getPostsPage_' + channelId)) { - return; - } - - var postList = PostStore.getAllPosts(id); - - var max = maxPosts; - if (max == null) { - max = Constants.POST_CHUNK_SIZE * Constants.MAX_POST_CHUNKS; - } - - // if we already have more than POST_CHUNK_SIZE posts, - // let's get the amount we have but rounded up to next multiple of POST_CHUNK_SIZE, - // with a max at maxPosts - var numPosts = Math.min(max, Constants.POST_CHUNK_SIZE); - if (postList && postList.order.length > 0) { - numPosts = Math.min(max, Constants.POST_CHUNK_SIZE * Math.ceil(postList.order.length / Constants.POST_CHUNK_SIZE)); - } - - if (channelId != null) { - callTracker['getPostsPage_' + channelId] = utils.getTimestamp(); - - Client.getPostsPage( - channelId, - 0, - numPosts, - (data) => { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_POSTS, - id: channelId, - before: true, - numRequested: numPosts, - checkLatest: true, - post_list: data - }); - }, - (err) => { - dispatchError(err, 'getPostsPage'); - }, - () => { - callTracker['getPostsPage_' + channelId] = 0; - } - ); - } -} - -export function getPosts(id) { - let channelId = id; - if (channelId == null) { - channelId = ChannelStore.getCurrentId(); - if (channelId == null) { - return; - } - } - - if (isCallInProgress('getPosts_' + channelId)) { - return; - } - - const postList = PostStore.getAllPosts(channelId); - const latestPostTime = PostStore.getLatestPostFromPageTime(id); - - if ($.isEmptyObject(postList) || postList.order.length < Constants.POST_CHUNK_SIZE || latestPostTime === 0) { - getPostsPage(channelId, Constants.POST_CHUNK_SIZE); - return; - } - - callTracker['getPosts_' + channelId] = utils.getTimestamp(); - - Client.getPosts( - channelId, - latestPostTime, - (data) => { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_POSTS, - id: channelId, - before: true, - numRequested: 0, - post_list: data - }); - }, - (err) => { - dispatchError(err, 'getPosts'); - }, - () => { - callTracker['getPosts_' + channelId] = 0; - } - ); -} - -export function getPostsBefore(postId, offset, numPost, isPost) { - const channelId = ChannelStore.getCurrentId(); - if (channelId == null) { - return; - } - - if (isCallInProgress('getPostsBefore_' + channelId)) { - return; - } - - Client.getPostsBefore( - channelId, - postId, - offset, - numPost, - (data) => { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_POSTS, - id: channelId, - before: true, - numRequested: numPost, - post_list: data, - isPost - }); - }, - (err) => { - dispatchError(err, 'getPostsBefore'); - }, - () => { - callTracker['getPostsBefore_' + channelId] = 0; - } - ); -} - -export function getPostsAfter(postId, offset, numPost, isPost) { - const channelId = ChannelStore.getCurrentId(); - if (channelId == null) { - return; - } - - if (isCallInProgress('getPostsAfter_' + channelId)) { - return; - } - - Client.getPostsAfter( - channelId, - postId, - offset, - numPost, - (data) => { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_POSTS, - id: channelId, - before: false, - numRequested: numPost, - post_list: data, - isPost - }); - }, - (err) => { - dispatchError(err, 'getPostsAfter'); - }, - () => { - callTracker['getPostsAfter_' + channelId] = 0; - } - ); -} - export function getFileInfosForPost(channelId, postId) { const callName = 'getFileInfosForPost' + postId; @@ -828,6 +751,58 @@ export function getMyTeam() { ); } +export function getTeamMember(teamId, userId) { + const callName = `getTeamMember${teamId}${userId}`; + if (isCallInProgress(callName)) { + return; + } + + callTracker[callName] = utils.getTimestamp(); + Client.getTeamMember( + (data) => { + callTracker[callName] = 0; + + const memberMap = {}; + memberMap[userId] = data; + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_MEMBERS_IN_TEAM, + team_id: teamId, + team_members: memberMap + }); + }, + (err) => { + callTracker[callName] = 0; + dispatchError(err, 'getTeamMember'); + } + ); +} + +export function getTeamStats(teamId) { + const callName = `getTeamStats${teamId}`; + if (isCallInProgress(callName)) { + return; + } + + callTracker[callName] = utils.getTimestamp(); + Client.getTeamStats( + teamId, + (data) => { + callTracker[callName] = 0; + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_TEAM_STATS, + team_id: teamId, + stats: data + }); + }, + (err) => { + callTracker[callName] = 0; + dispatchError(err, 'getTeamStats'); + } + ); +} + export function getAllPreferences() { if (isCallInProgress('getAllPreferences')) { return; @@ -987,6 +962,18 @@ export function getStandardAnalytics(teamId) { if (data[index].name === 'team_count' && teamId == null) { stats[StatTypes.TOTAL_TEAMS] = data[index].value; } + + if (data[index].name === 'total_websocket_connections') { + stats[StatTypes.TOTAL_WEBSOCKET_CONNECTIONS] = data[index].value; + } + + if (data[index].name === 'total_master_db_connections') { + stats[StatTypes.TOTAL_MASTER_DB_CONNECTIONS] = data[index].value; + } + + if (data[index].name === 'total_read_db_connections') { + stats[StatTypes.TOTAL_READ_DB_CONNECTIONS] = data[index].value; + } } AppDispatcher.handleServerAction({ @@ -1212,54 +1199,6 @@ export function getRecentAndNewUsersAnalytics(teamId) { ); } -export function listIncomingHooks() { - if (isCallInProgress('listIncomingHooks')) { - return; - } - - callTracker.listIncomingHooks = utils.getTimestamp(); - - Client.listIncomingHooks( - (data) => { - callTracker.listIncomingHooks = 0; - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_INCOMING_WEBHOOKS, - teamId: Client.teamId, - incomingWebhooks: data - }); - }, - (err) => { - callTracker.listIncomingHooks = 0; - dispatchError(err, 'getIncomingHooks'); - } - ); -} - -export function listOutgoingHooks() { - if (isCallInProgress('listOutgoingHooks')) { - return; - } - - callTracker.listOutgoingHooks = utils.getTimestamp(); - - Client.listOutgoingHooks( - (data) => { - callTracker.listOutgoingHooks = 0; - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_OUTGOING_WEBHOOKS, - teamId: Client.teamId, - outgoingWebhooks: data - }); - }, - (err) => { - callTracker.listOutgoingHooks = 0; - dispatchError(err, 'getOutgoingHooks'); - } - ); -} - export function addIncomingHook(hook, success, error) { Client.addIncomingHook( hook, @@ -1353,30 +1292,6 @@ export function regenOutgoingHookToken(id) { ); } -export function listTeamCommands() { - if (isCallInProgress('listTeamCommands')) { - return; - } - - callTracker.listTeamCommands = utils.getTimestamp(); - - Client.listTeamCommands( - (data) => { - callTracker.listTeamCommands = 0; - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_COMMANDS, - teamId: Client.teamId, - commands: data - }); - }, - (err) => { - callTracker.listTeamCommands = 0; - dispatchError(err, 'listTeamCommands'); - } - ); -} - export function addCommand(command, success, error) { Client.addCommand( command, @@ -1459,29 +1374,6 @@ export function getPublicLink(fileId, success, error) { ); } -export function listEmoji() { - if (isCallInProgress('listEmoji')) { - return; - } - - callTracker.listEmoji = utils.getTimestamp(); - - Client.listEmoji( - (data) => { - callTracker.listEmoji = 0; - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_CUSTOM_EMOJIS, - emojis: data - }); - }, - (err) => { - callTracker.listEmoji = 0; - dispatchError(err, 'listEmoji'); - } - ); -} - export function addEmoji(emoji, image, success, error) { const callName = 'addEmoji' + emoji.name; diff --git a/webapp/utils/constants.jsx b/webapp/utils/constants.jsx index 10f1a2879..83d64358c 100644 --- a/webapp/utils/constants.jsx +++ b/webapp/utils/constants.jsx @@ -71,7 +71,7 @@ export const ActionTypes = keyMirror({ RECEIVED_CHANNELS: null, RECEIVED_CHANNEL: null, RECEIVED_MORE_CHANNELS: null, - RECEIVED_CHANNEL_EXTRA_INFO: null, + RECEIVED_CHANNEL_STATS: null, FOCUS_POST: null, RECEIVED_POSTS: null, @@ -84,9 +84,11 @@ export const ActionTypes = keyMirror({ RECEIVED_MENTION_DATA: null, RECEIVED_ADD_MENTION: null, - RECEIVED_PROFILES_FOR_DM_LIST: null, RECEIVED_PROFILES: null, - RECEIVED_DIRECT_PROFILES: null, + RECEIVED_PROFILES_IN_TEAM: null, + RECEIVED_PROFILE: null, + RECEIVED_PROFILES_IN_CHANNEL: null, + RECEIVED_PROFILE_NOT_IN_CHANNEL: null, RECEIVED_ME: null, RECEIVED_SESSIONS: null, RECEIVED_AUDITS: null, @@ -129,8 +131,9 @@ export const ActionTypes = keyMirror({ RECEIVED_SERVER_COMPLIANCE_REPORTS: null, RECEIVED_ALL_TEAMS: null, RECEIVED_ALL_TEAM_LISTINGS: null, - RECEIVED_TEAM_MEMBERS: null, - RECEIVED_MEMBERS_FOR_TEAM: null, + RECEIVED_MY_TEAM_MEMBERS: null, + RECEIVED_MEMBERS_IN_TEAM: null, + RECEIVED_TEAM_STATS: null, RECEIVED_LOCALE: null, @@ -232,7 +235,10 @@ export const Constants = { POST_PER_DAY: null, USERS_WITH_POSTS_PER_DAY: null, RECENTLY_ACTIVE_USERS: null, - NEWLY_CREATED_USERS: null + NEWLY_CREATED_USERS: null, + TOTAL_WEBSOCKET_CONNECTIONS: null, + TOTAL_MASTER_DB_CONNECTIONS: null, + TOTAL_READ_DB_CONNECTIONS: null }), STAT_MAX_ACTIVE_USERS: 20, STAT_MAX_NEW_USERS: 20, @@ -313,7 +319,7 @@ export const Constants = { SIGNIN_VERIFIED: 'verified', SESSION_EXPIRED: 'expired', POST_CHUNK_SIZE: 60, - MAX_POST_CHUNKS: 3, + PROFILE_CHUNK_SIZE: 100, POST_FOCUS_CONTEXT_RADIUS: 10, POST_LOADING: 'loading', POST_FAILED: 'failed', @@ -843,7 +849,9 @@ export const Constants = { MENTION_MEMBERS: 'mention.members', MENTION_NONMEMBERS: 'mention.nonmembers', MENTION_SPECIAL: 'mention.special', - DEFAULT_NOTIFICATION_DURATION: 5000 + DEFAULT_NOTIFICATION_DURATION: 5000, + STATUS_INTERVAL: 60000, + AUTOCOMPLETE_TIMEOUT: 200 }; export default Constants; diff --git a/webapp/utils/utils.jsx b/webapp/utils/utils.jsx index 10a59c25c..fcfec3592 100644 --- a/webapp/utils/utils.jsx +++ b/webapp/utils/utils.jsx @@ -10,7 +10,6 @@ import PreferenceStore from 'stores/preference_store.jsx'; import TeamStore from 'stores/team_store.jsx'; import Constants from 'utils/constants.jsx'; var ActionTypes = Constants.ActionTypes; -import * as AsyncClient from './async_client.jsx'; import Client from 'client/web_client.jsx'; import * as UserAgent from 'utils/user_agent.jsx'; @@ -1074,66 +1073,6 @@ export function windowHeight() { return $(window).height(); } -export function openDirectChannelToUser(user, successCb, errorCb) { - AsyncClient.savePreference( - Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, - user.id, - 'true' - ); - - // if the user in another team and isn't already in the direct message - // list then we should add him so his name shows up correctly. - var profileUser = UserStore.getProfile(user.id); - if (!profileUser) { - UserStore.getDirectProfiles()[user.id] = user; - } - - const channelName = this.getDirectChannelName(UserStore.getCurrentId(), user.id); - let channel = ChannelStore.getByName(channelName); - - if (channel) { - if ($.isFunction(successCb)) { - successCb(channel, true); - } - } else { - channel = { - name: channelName, - last_post_at: 0, - total_msg_count: 0, - type: 'D', - display_name: user.username, - teammate_id: user.id, - status: UserStore.getStatus(user.id) - }; - - Client.createDirectChannel( - user.id, - (data) => { - Client.getChannel( - data.id, - (data2) => { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_CHANNEL, - channel: data2.channel, - member: data2.member - }); - - if ($.isFunction(successCb)) { - successCb(data2.channel, false); - } - } - ); - }, - () => { - browserHistory.push(TeamStore.getCurrentTeamUrl() + '/channels/' + channelName); - if ($.isFunction(errorCb)) { - errorCb(); - } - } - ); - } -} - // Use when sorting multiple channels or teams by their `display_name` field export function sortByDisplayName(a, b) { let aDisplayName = ''; |