From c0ab2636d699c8544ce03a58f61b95cfd66ff7ce Mon Sep 17 00:00:00 2001 From: Joram Wilander Date: Mon, 18 Jul 2016 11:10:03 -0400 Subject: PLT-2241 Refactored statuses into a more real-time system (#3573) * Refactored statuses into a more real-time system * Updated package.json with correct commit and fixed minor bug * Minor updates to statuses based on feedback * When setting status online, update only LastActivityAt if status already exists --- Makefile | 2 +- api/api.go | 1 + api/context.go | 7 +- api/general.go | 11 ++- api/post.go | 8 +- api/status.go | 155 ++++++++++++++++++++++++++++++++ api/status_test.go | 153 ++++++++++++++++++++++++++++++++ api/user.go | 38 +------- api/user_test.go | 60 +------------ api/web_conn.go | 23 +---- api/web_hub.go | 13 +++ api/websocket_handler.go | 9 +- config/config.json | 3 +- i18n/en.json | 34 +++++-- mattermost.go | 10 ++- model/client.go | 5 +- model/config.go | 6 ++ model/status.go | 42 +++++++++ model/status_test.go | 27 ++++++ model/user.go | 16 ---- model/websocket_client.go | 7 ++ store/sql_status_store.go | 166 +++++++++++++++++++++++++++++++++++ store/sql_status_store_test.go | 83 ++++++++++++++++++ store/sql_store.go | 8 ++ store/sql_user_store.go | 86 +----------------- store/sql_user_store_test.go | 99 +-------------------- store/store.go | 14 ++- webapp/actions/websocket_actions.jsx | 27 +++++- webapp/components/channel_header.jsx | 2 + webapp/components/logged_in.jsx | 16 ++-- webapp/components/sidebar.jsx | 2 +- webapp/package.json | 2 +- webapp/stores/user_store.jsx | 16 ++-- webapp/utils/async_client.jsx | 16 +--- webapp/utils/constants.jsx | 9 +- 35 files changed, 800 insertions(+), 376 deletions(-) create mode 100644 api/status.go create mode 100644 api/status_test.go create mode 100644 model/status.go create mode 100644 model/status_test.go create mode 100644 store/sql_status_store.go create mode 100644 store/sql_status_store_test.go diff --git a/Makefile b/Makefile index 7dcc79e5c..766cb6dd7 100644 --- a/Makefile +++ b/Makefile @@ -159,7 +159,7 @@ test-server: start-docker prepare-enterprise rm -f cover.out echo "mode: count" > cover.out - $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=340s -covermode=count -coverprofile=capi.out ./api || exit 1 + $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=440s -covermode=count -coverprofile=capi.out ./api || exit 1 $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=60s -covermode=count -coverprofile=cmodel.out ./model || exit 1 $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=180s -covermode=count -coverprofile=cstore.out ./store || exit 1 $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=120s -covermode=count -coverprofile=cutils.out ./utils || exit 1 diff --git a/api/api.go b/api/api.go index 4cc11168c..9e73bd125 100644 --- a/api/api.go +++ b/api/api.go @@ -94,6 +94,7 @@ func InitApi() { InitPreference() InitLicense() InitEmoji() + InitStatus() // 404 on any api route before web.go has a chance to serve it Srv.Router.Handle("/api/{anything:.*}", http.HandlerFunc(Handle404)) diff --git a/api/context.go b/api/context.go index 93ff83247..2132ce0e7 100644 --- a/api/context.go +++ b/api/context.go @@ -20,6 +20,7 @@ import ( ) var sessionCache *utils.Cache = utils.NewLru(model.SESSION_CACHE_SIZE) +var statusCache *utils.Cache = utils.NewLru(model.STATUS_CACHE_SIZE) var allowedMethods []string = []string{ "POST", @@ -196,11 +197,7 @@ func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } if c.Err == nil && h.isUserActivity && token != "" && len(c.Session.UserId) > 0 { - go func() { - if err := (<-Srv.Store.User().UpdateUserAndSessionActivity(c.Session.UserId, c.Session.Id, model.GetMillis())).Err; err != nil { - l4g.Error(utils.T("api.context.last_activity_at.error"), c.Session.UserId, c.Session.Id, err) - } - }() + SetStatusOnline(c.Session.UserId, c.Session.Id) } if c.Err == nil { diff --git a/api/general.go b/api/general.go index 4124d2e95..233484e43 100644 --- a/api/general.go +++ b/api/general.go @@ -73,7 +73,12 @@ func ping(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.MapToJson(m))) } -func webSocketPing(req *model.WebSocketRequest, responseData map[string]interface{}) *model.AppError { - responseData["text"] = "pong" - return nil +func webSocketPing(req *model.WebSocketRequest) (map[string]interface{}, *model.AppError) { + data := map[string]interface{}{} + data["text"] = "pong" + data["version"] = model.CurrentVersion + data["server_time"] = model.GetMillis() + data["node_id"] = "" + + return data, nil } diff --git a/api/post.go b/api/post.go index 60ac11a2b..4533823f6 100644 --- a/api/post.go +++ b/api/post.go @@ -597,7 +597,13 @@ func sendNotifications(c *Context, post *model.Post, team *model.Team, channel * for _, id := range mentionedUsersList { userAllowsEmails := profileMap[id].NotifyProps["email"] != "false" - if userAllowsEmails && (profileMap[id].IsAway() || profileMap[id].IsOffline()) { + var status *model.Status + var err *model.AppError + if status, err = GetStatus(id); err != nil { + status = &model.Status{id, model.STATUS_OFFLINE, 0} + } + + if userAllowsEmails && status.Status != model.STATUS_ONLINE { sendNotificationEmail(c, post, profileMap[id], channel, team, senderName) } } diff --git a/api/status.go b/api/status.go new file mode 100644 index 000000000..88f024f4e --- /dev/null +++ b/api/status.go @@ -0,0 +1,155 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api + +import ( + "net/http" + + l4g "github.com/alecthomas/log4go" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/store" + "github.com/mattermost/platform/utils" +) + +func InitStatus() { + l4g.Debug(utils.T("api.status.init.debug")) + + BaseRoutes.Users.Handle("/status", ApiUserRequiredActivity(getStatusesHttp, false)).Methods("GET") + BaseRoutes.WebSocket.Handle("get_statuses", ApiWebSocketHandler(getStatusesWebSocket)) +} + +func getStatusesHttp(c *Context, w http.ResponseWriter, r *http.Request) { + statusMap, err := GetAllStatuses() + if err != nil { + c.Err = err + return + } + + w.Write([]byte(model.StringInterfaceToJson(statusMap))) +} + +func getStatusesWebSocket(req *model.WebSocketRequest) (map[string]interface{}, *model.AppError) { + statusMap, err := GetAllStatuses() + if err != nil { + return nil, err + } + + return statusMap, nil +} + +func GetAllStatuses() (map[string]interface{}, *model.AppError) { + if result := <-Srv.Store.Status().GetOnlineAway(); result.Err != nil { + return nil, result.Err + } else { + statuses := result.Data.([]*model.Status) + + statusMap := map[string]interface{}{} + for _, s := range statuses { + statusMap[s.UserId] = s.Status + } + + return statusMap, nil + } +} + +func SetStatusOnline(userId string, sessionId string) { + broadcast := false + saveStatus := false + + var status *model.Status + var err *model.AppError + if status, err = GetStatus(userId); err != nil { + status = &model.Status{userId, model.STATUS_ONLINE, model.GetMillis()} + broadcast = true + saveStatus = true + } else { + if status.Status != model.STATUS_ONLINE { + broadcast = true + } + status.Status = model.STATUS_ONLINE + status.LastActivityAt = model.GetMillis() + } + + statusCache.Add(status.UserId, status) + + achan := Srv.Store.Session().UpdateLastActivityAt(sessionId, model.GetMillis()) + + var schan store.StoreChannel + if saveStatus { + 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 := <-schan; result.Err != nil { + l4g.Error(utils.T("api.status.save_status.error"), userId, result.Err) + } + + if broadcast { + event := model.NewWebSocketEvent("", "", status.UserId, model.WEBSOCKET_EVENT_STATUS_CHANGE) + event.Add("status", model.STATUS_ONLINE) + go Publish(event) + } +} + +func SetStatusOffline(userId string) { + status := &model.Status{userId, model.STATUS_OFFLINE, model.GetMillis()} + + statusCache.Add(status.UserId, status) + + if result := <-Srv.Store.Status().SaveOrUpdate(status); result.Err != nil { + l4g.Error(utils.T("api.status.save_status.error"), userId, result.Err) + } + + event := model.NewWebSocketEvent("", "", status.UserId, model.WEBSOCKET_EVENT_STATUS_CHANGE) + event.Add("status", model.STATUS_OFFLINE) + go Publish(event) +} + +func SetStatusAwayIfNeeded(userId string) { + status, err := GetStatus(userId) + if err != nil { + status = &model.Status{userId, model.STATUS_OFFLINE, 0} + } + + if status.Status == model.STATUS_AWAY { + return + } + + if !IsUserAway(status.LastActivityAt) { + return + } + + status.Status = model.STATUS_AWAY + + statusCache.Add(status.UserId, status) + + if result := <-Srv.Store.Status().SaveOrUpdate(status); result.Err != nil { + l4g.Error(utils.T("api.status.save_status.error"), userId, result.Err) + } + + event := model.NewWebSocketEvent("", "", status.UserId, model.WEBSOCKET_EVENT_STATUS_CHANGE) + event.Add("status", model.STATUS_AWAY) + 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 := <-Srv.Store.Status().Get(userId); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.Status), nil + } +} + +func IsUserAway(lastActivityAt int64) bool { + return model.GetMillis()-lastActivityAt >= *utils.Cfg.TeamSettings.UserStatusAwayTimeout*1000 +} diff --git a/api/status_test.go b/api/status_test.go new file mode 100644 index 000000000..a035cf8bf --- /dev/null +++ b/api/status_test.go @@ -0,0 +1,153 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api + +import ( + "strings" + "testing" + "time" + + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/store" + "github.com/mattermost/platform/utils" +) + +func TestStatuses(t *testing.T) { + th := Setup().InitBasic() + Client := th.BasicClient + WebSocketClient, err := th.CreateWebSocketClient() + if err != nil { + t.Fatal(err) + } + defer WebSocketClient.Close() + WebSocketClient.Listen() + + team := model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} + rteam, _ := Client.CreateTeam(&team) + + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "passwd1"} + ruser := Client.Must(Client.CreateUser(&user, "")).Data.(*model.User) + LinkUserToTeam(ruser, rteam.Data.(*model.Team)) + store.Must(Srv.Store.User().VerifyEmail(ruser.Id)) + + user2 := model.User{Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "passwd1"} + ruser2 := Client.Must(Client.CreateUser(&user2, "")).Data.(*model.User) + LinkUserToTeam(ruser2, rteam.Data.(*model.Team)) + store.Must(Srv.Store.User().VerifyEmail(ruser2.Id)) + + Client.Login(user.Email, user.Password) + Client.SetTeamId(team.Id) + + r1, err := Client.GetStatuses() + if err != nil { + t.Fatal(err) + } + + statuses := r1.Data.(map[string]string) + + for _, status := range statuses { + if status != model.STATUS_OFFLINE && status != model.STATUS_AWAY && status != model.STATUS_ONLINE { + t.Fatal("one of the statuses had an invalid value") + } + } + + th.LoginBasic2() + + WebSocketClient2, err2 := th.CreateWebSocketClient() + if err2 != nil { + t.Fatal(err2) + } + + time.Sleep(300 * time.Millisecond) + + WebSocketClient.GetStatuses() + 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.Fatal("should have had user status") + } else if status != model.STATUS_ONLINE { + t.Log(status) + t.Fatal("status should have been online") + } + } + + SetStatusAwayIfNeeded(th.BasicUser2.Id) + + awayTimeout := *utils.Cfg.TeamSettings.UserStatusAwayTimeout + defer func() { + *utils.Cfg.TeamSettings.UserStatusAwayTimeout = awayTimeout + }() + *utils.Cfg.TeamSettings.UserStatusAwayTimeout = 1 + + time.Sleep(1500 * time.Millisecond) + + SetStatusAwayIfNeeded(th.BasicUser2.Id) + SetStatusAwayIfNeeded(th.BasicUser2.Id) + + WebSocketClient2.Close() + time.Sleep(300 * time.Millisecond) + + WebSocketClient.GetStatuses() + if resp := <-WebSocketClient.ResponseChannel; resp.Error != nil { + t.Fatal(resp.Error) + } else { + if resp.SeqReply != WebSocketClient.Sequence-1 { + t.Fatal("bad sequence number") + } + + if _, ok := resp.Data[th.BasicUser2.Id]; ok { + t.Fatal("should not have had user status") + } + } + + 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.UserId == th.BasicUser2.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: + return + } + } + }() + + time.Sleep(500 * time.Millisecond) + + stop <- true + + if !onlineHit { + t.Fatal("didn't get online event") + } + if !awayHit { + t.Fatal("didn't get away event") + } + if !offlineHit { + t.Fatal("didn't get offline event") + } +} diff --git a/api/user.go b/api/user.go index 7d2eb85bf..652da14ad 100644 --- a/api/user.go +++ b/api/user.go @@ -54,7 +54,6 @@ func InitUser() { BaseRoutes.Users.Handle("/newimage", ApiUserRequired(uploadProfileImage)).Methods("POST") BaseRoutes.Users.Handle("/me", ApiAppHandler(getMe)).Methods("GET") BaseRoutes.Users.Handle("/initial_load", ApiAppHandler(getInitialLoad)).Methods("GET") - BaseRoutes.Users.Handle("/status", ApiUserRequiredActivity(getStatuses, false)).Methods("POST") 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") @@ -1955,35 +1954,6 @@ func updateUserNotify(c *Context, w http.ResponseWriter, r *http.Request) { } } -func getStatuses(c *Context, w http.ResponseWriter, r *http.Request) { - userIds := model.ArrayFromJson(r.Body) - if len(userIds) == 0 { - c.SetInvalidParam("getStatuses", "userIds") - return - } - - if result := <-Srv.Store.User().GetProfileByIds(userIds); result.Err != nil { - c.Err = result.Err - return - } else { - profiles := result.Data.(map[string]*model.User) - - statuses := map[string]string{} - for _, profile := range profiles { - if profile.IsOffline() { - statuses[profile.Id] = model.USER_OFFLINE - } else if profile.IsAway() { - statuses[profile.Id] = model.USER_AWAY - } else { - statuses[profile.Id] = model.USER_ONLINE - } - } - - w.Write([]byte(model.MapToJson(statuses))) - return - } -} - func IsUsernameTaken(name string) bool { if !model.IsValidUsername(name) { @@ -2312,7 +2282,7 @@ func resendVerification(c *Context, w http.ResponseWriter, r *http.Request) { c.Err = error return } else { - if user.LastActivityAt > 0 { + if _, err := GetStatus(user.Id); err != nil { go SendEmailChangeVerifyEmail(c, user.Id, user.Email, c.GetSiteURL()) } else { go SendVerifyEmail(c, user.Id, user.Email, c.GetSiteURL()) @@ -2551,11 +2521,11 @@ func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) { } } -func userTyping(req *model.WebSocketRequest, responseData map[string]interface{}) *model.AppError { +func userTyping(req *model.WebSocketRequest) (map[string]interface{}, *model.AppError) { var ok bool var channelId string if channelId, ok = req.Data["channel_id"].(string); !ok || len(channelId) != 26 { - return NewInvalidWebSocketParamError(req.Action, "channel_id") + return nil, NewInvalidWebSocketParamError(req.Action, "channel_id") } var parentId string @@ -2567,5 +2537,5 @@ func userTyping(req *model.WebSocketRequest, responseData map[string]interface{} event.Add("parent_id", parentId) go Publish(event) - return nil + return nil, nil } diff --git a/api/user_test.go b/api/user_test.go index fcb2c4f00..6f3f616e7 100644 --- a/api/user_test.go +++ b/api/user_test.go @@ -669,9 +669,7 @@ func TestUserUpdate(t *testing.T) { team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team) - time1 := model.GetMillis() - - user := &model.User{Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "passwd1", LastActivityAt: time1, LastPingAt: time1, Roles: ""} + user := &model.User{Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "passwd1", Roles: ""} user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User) LinkUserToTeam(user, team) store.Must(Srv.Store.User().VerifyEmail(user.Id)) @@ -683,15 +681,7 @@ func TestUserUpdate(t *testing.T) { Client.Login(user.Email, "passwd1") Client.SetTeamId(team.Id) - time.Sleep(100 * time.Millisecond) - - time2 := model.GetMillis() - - time.Sleep(100 * time.Millisecond) - user.Nickname = "Jim Jimmy" - user.LastActivityAt = time2 - user.LastPingAt = time2 user.Roles = model.ROLE_TEAM_ADMIN user.LastPasswordUpdate = 123 @@ -701,12 +691,6 @@ func TestUserUpdate(t *testing.T) { if result.Data.(*model.User).Nickname != "Jim Jimmy" { t.Fatal("Nickname did not update properly") } - if result.Data.(*model.User).LastActivityAt == time2 { - t.Fatal("LastActivityAt should not have updated") - } - if result.Data.(*model.User).LastPingAt == time2 { - t.Fatal("LastPingAt should not have updated") - } if result.Data.(*model.User).Roles != "" { t.Fatal("Roles should not have updated") } @@ -1347,48 +1331,6 @@ func TestFuzzyUserCreate(t *testing.T) { } } -func TestStatuses(t *testing.T) { - th := Setup() - Client := th.CreateClient() - - team := model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} - rteam, _ := Client.CreateTeam(&team) - - user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "passwd1"} - ruser := Client.Must(Client.CreateUser(&user, "")).Data.(*model.User) - LinkUserToTeam(ruser, rteam.Data.(*model.Team)) - store.Must(Srv.Store.User().VerifyEmail(ruser.Id)) - - user2 := model.User{Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "passwd1"} - ruser2 := Client.Must(Client.CreateUser(&user2, "")).Data.(*model.User) - LinkUserToTeam(ruser2, rteam.Data.(*model.Team)) - store.Must(Srv.Store.User().VerifyEmail(ruser2.Id)) - - Client.Login(user.Email, user.Password) - Client.SetTeamId(team.Id) - - userIds := []string{ruser2.Id} - - r1, err := Client.GetStatuses(userIds) - if err != nil { - t.Fatal(err) - } - - statuses := r1.Data.(map[string]string) - - if len(statuses) != 1 { - t.Log(statuses) - t.Fatal("invalid number of statuses") - } - - for _, status := range statuses { - if status != model.USER_OFFLINE && status != model.USER_AWAY && status != model.USER_ONLINE { - t.Fatal("one of the statuses had an invalid value") - } - } - -} - func TestEmailToOAuth(t *testing.T) { th := Setup() Client := th.CreateClient() diff --git a/api/web_conn.go b/api/web_conn.go index 3f4414c5e..8741873fd 100644 --- a/api/web_conn.go +++ b/api/web_conn.go @@ -7,9 +7,7 @@ import ( "time" "github.com/mattermost/platform/model" - "github.com/mattermost/platform/utils" - l4g "github.com/alecthomas/log4go" "github.com/gorilla/websocket" goi18n "github.com/nicksnyder/go-i18n/i18n" ) @@ -34,18 +32,7 @@ type WebConn struct { } func NewWebConn(c *Context, ws *websocket.Conn) *WebConn { - go func() { - achan := Srv.Store.User().UpdateUserAndSessionActivity(c.Session.UserId, c.Session.Token, model.GetMillis()) - pchan := Srv.Store.User().UpdateLastPingAt(c.Session.UserId, model.GetMillis()) - - if result := <-achan; result.Err != nil { - l4g.Error(utils.T("api.web_conn.new_web_conn.last_activity.error"), c.Session.UserId, c.Session.Token, result.Err) - } - - if result := <-pchan; result.Err != nil { - l4g.Error(utils.T("api.web_conn.new_web_conn.last_ping.error"), c.Session.UserId, result.Err) - } - }() + go SetStatusOnline(c.Session.UserId, c.Session.Id) return &WebConn{ Send: make(chan model.WebSocketMessage, 64), @@ -68,13 +55,7 @@ func (c *WebConn) readPump() { c.WebSocket.SetReadDeadline(time.Now().Add(PONG_WAIT)) c.WebSocket.SetPongHandler(func(string) error { c.WebSocket.SetReadDeadline(time.Now().Add(PONG_WAIT)) - - go func() { - if result := <-Srv.Store.User().UpdateLastPingAt(c.UserId, model.GetMillis()); result.Err != nil { - l4g.Error(utils.T("api.web_conn.new_web_conn.last_ping.error"), c.UserId, result.Err) - } - }() - + go SetStatusAwayIfNeeded(c.UserId) return nil }) diff --git a/api/web_hub.go b/api/web_hub.go index db0f31bb7..455189f70 100644 --- a/api/web_hub.go +++ b/api/web_hub.go @@ -67,10 +67,23 @@ func (h *Hub) Start() { h.connections[webCon] = true case webCon := <-h.unregister: + userId := webCon.UserId if _, ok := h.connections[webCon]; ok { delete(h.connections, webCon) close(webCon.Send) } + + found := false + for webCon := range h.connections { + if userId == webCon.UserId { + found = true + break + } + } + + if !found { + go SetStatusOffline(userId) + } case userId := <-h.invalidateUser: for webCon := range h.connections { if webCon.UserId == userId { diff --git a/api/websocket_handler.go b/api/websocket_handler.go index 8abec6715..5a313fe13 100644 --- a/api/websocket_handler.go +++ b/api/websocket_handler.go @@ -10,12 +10,12 @@ import ( "github.com/mattermost/platform/utils" ) -func ApiWebSocketHandler(wh func(*model.WebSocketRequest, map[string]interface{}) *model.AppError) *webSocketHandler { +func ApiWebSocketHandler(wh func(*model.WebSocketRequest) (map[string]interface{}, *model.AppError)) *webSocketHandler { return &webSocketHandler{wh} } type webSocketHandler struct { - handlerFunc func(*model.WebSocketRequest, map[string]interface{}) *model.AppError + handlerFunc func(*model.WebSocketRequest) (map[string]interface{}, *model.AppError) } func (wh *webSocketHandler) ServeWebSocket(conn *WebConn, r *model.WebSocketRequest) { @@ -25,9 +25,10 @@ func (wh *webSocketHandler) ServeWebSocket(conn *WebConn, r *model.WebSocketRequ r.T = conn.T r.Locale = conn.Locale - data := make(map[string]interface{}) + var data map[string]interface{} + var err *model.AppError - if err := wh.handlerFunc(r, data); err != nil { + 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) diff --git a/config/config.json b/config/config.json index 874af146f..745be73e3 100644 --- a/config/config.json +++ b/config/config.json @@ -40,7 +40,8 @@ "RestrictDirectMessage": "any", "RestrictTeamInvite": "all", "RestrictPublicChannelManagement": "all", - "RestrictPrivateChannelManagement": "all" + "RestrictPrivateChannelManagement": "all", + "UserStatusAwayTimeout": 300 }, "SqlSettings": { "DriverName": "mysql", diff --git a/i18n/en.json b/i18n/en.json index 245ae8d83..c29059ebb 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1980,12 +1980,12 @@ "translation": "Bad verify email link." }, { - "id": "api.web_conn.new_web_conn.last_activity.error", + "id": "api.status.last_activity.error", "translation": "Failed to update LastActivityAt for user_id=%v and session_id=%v, err=%v" }, { - "id": "api.web_conn.new_web_conn.last_ping.error", - "translation": "Failed to update LastPingAt for user_id=%v, err=%v" + "id": "api.status.save_status.error", + "translation": "Failed to save status for user_id=%v, err=%v" }, { "id": "api.web_hub.start.stopping.debug", @@ -4036,8 +4036,32 @@ "translation": "We encountered an error while finding user profiles" }, { - "id": "store.sql_user.get_total_active_users_count.app_error", - "translation": "We could not count the users" + "id": "store.sql_status.get_total_active_users_count.app_error", + "translation": "We could not count the active users" + }, + { + "id": "store.sql_status.reset_all.app_error", + "translation": "Encountered an error resetting all the statuses" + }, + { + "id": "store.sql_status.get_online_away.app_error", + "translation": "Encountered an error retrieving all the online/away statuses" + }, + { + "id": "store.sql_status.get.app_error", + "translation": "Encountered an error retrieving the status" + }, + { + "id": "store.sql_status.get.missing.app_error", + "translation": "No entry for that status exists" + }, + { + "id": "store.sql_status.update.app_error", + "translation": "Encountered an error updating the status" + }, + { + "id": "store.sql_status.save.app_error", + "translation": "Encountered an error saving the status" }, { "id": "store.sql_user.get_total_users_count.app_error", diff --git a/mattermost.go b/mattermost.go index 14f297a66..c07c38b2b 100644 --- a/mattermost.go +++ b/mattermost.go @@ -125,6 +125,8 @@ func main() { if flagRunCmds { runCmds() } else { + resetStatuses() + api.StartServer() // If we allow testing then listen for manual testing URL hits @@ -149,6 +151,12 @@ func main() { } } +func resetStatuses() { + if result := <-api.Srv.Store.Status().ResetAll(); result.Err != nil { + l4g.Error(utils.T("mattermost.reset_status.error"), result.Err.Error()) + } +} + func setDiagnosticId() { if result := <-api.Srv.Store.System().Get(); result.Err == nil { props := result.Data.(model.StringMap) @@ -200,7 +208,7 @@ func doSecurityAndDiagnostics() { v.Set(utils.PROP_DIAGNOSTIC_USER_COUNT, strconv.FormatInt(ucr.Data.(int64), 10)) } - if ucr := <-api.Srv.Store.User().GetTotalActiveUsersCount(); ucr.Err == nil { + if ucr := <-api.Srv.Store.Status().GetTotalActiveUsersCount(); ucr.Err == nil { v.Set(utils.PROP_DIAGNOSTIC_ACTIVE_USER_COUNT, strconv.FormatInt(ucr.Data.(int64), 10)) } diff --git a/model/client.go b/model/client.go index e12cd595d..b97a2f7ad 100644 --- a/model/client.go +++ b/model/client.go @@ -1415,8 +1415,9 @@ func (c *Client) AdminResetPassword(userId, newPassword string) (*Result, *AppEr } } -func (c *Client) GetStatuses(data []string) (*Result, *AppError) { - if r, err := c.DoApiPost("/users/status", ArrayToJson(data)); err != nil { +// GetStatuses returns a map of string statuses using user id as the key +func (c *Client) GetStatuses() (*Result, *AppError) { + if r, err := c.DoApiGet("/users/status", "", ""); err != nil { return nil, err } else { defer closeBody(r) diff --git a/model/config.go b/model/config.go index 532584a1a..4767378fb 100644 --- a/model/config.go +++ b/model/config.go @@ -195,6 +195,7 @@ type TeamSettings struct { RestrictTeamInvite *string RestrictPublicChannelManagement *string RestrictPrivateChannelManagement *string + UserStatusAwayTimeout *int64 } type LdapSettings struct { @@ -435,6 +436,11 @@ func (o *Config) SetDefaults() { *o.TeamSettings.RestrictPrivateChannelManagement = PERMISSIONS_ALL } + if o.TeamSettings.UserStatusAwayTimeout == nil { + o.TeamSettings.UserStatusAwayTimeout = new(int64) + *o.TeamSettings.UserStatusAwayTimeout = 300 + } + if o.EmailSettings.EnableSignInWithEmail == nil { o.EmailSettings.EnableSignInWithEmail = new(bool) diff --git a/model/status.go b/model/status.go new file mode 100644 index 000000000..8bf26f2f0 --- /dev/null +++ b/model/status.go @@ -0,0 +1,42 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +const ( + STATUS_OFFLINE = "offline" + STATUS_AWAY = "away" + STATUS_ONLINE = "online" + STATUS_CACHE_SIZE = 10000 +) + +type Status struct { + UserId string `json:"user_id"` + Status string `json:"status"` + LastActivityAt int64 `json:"last_activity_at"` +} + +func (o *Status) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func StatusFromJson(data io.Reader) *Status { + decoder := json.NewDecoder(data) + var o Status + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} diff --git a/model/status_test.go b/model/status_test.go new file mode 100644 index 000000000..ccdac53b6 --- /dev/null +++ b/model/status_test.go @@ -0,0 +1,27 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "strings" + "testing" +) + +func TestStatus(t *testing.T) { + status := Status{NewId(), STATUS_ONLINE, 0} + json := status.ToJson() + status2 := StatusFromJson(strings.NewReader(json)) + + if status.UserId != status2.UserId { + t.Fatal("UserId should have matched") + } + + if status.Status != status2.Status { + t.Fatal("Status should have matched") + } + + if status.LastActivityAt != status2.LastActivityAt { + t.Fatal("LastActivityAt should have matched") + } +} diff --git a/model/user.go b/model/user.go index bf6866b27..2a7427748 100644 --- a/model/user.go +++ b/model/user.go @@ -16,11 +16,6 @@ import ( const ( ROLE_SYSTEM_ADMIN = "system_admin" - USER_AWAY_TIMEOUT = 5 * 60 * 1000 // 5 minutes - USER_OFFLINE_TIMEOUT = 1 * 60 * 1000 // 1 minute - USER_OFFLINE = "offline" - USER_AWAY = "away" - USER_ONLINE = "online" USER_NOTIFY_ALL = "all" USER_NOTIFY_MENTION = "mention" USER_NOTIFY_NONE = "none" @@ -44,8 +39,6 @@ type User struct { FirstName string `json:"first_name"` LastName string `json:"last_name"` Roles string `json:"roles"` - LastActivityAt int64 `json:"last_activity_at,omitempty"` - LastPingAt int64 `json:"last_ping_at,omitempty"` AllowMarketing bool `json:"allow_marketing,omitempty"` Props StringMap `json:"props,omitempty"` NotifyProps StringMap `json:"notify_props,omitempty"` @@ -222,14 +215,6 @@ func (u *User) Etag(showFullName, showEmail bool) string { return Etag(u.Id, u.UpdateAt, showFullName, showEmail) } -func (u *User) IsOffline() bool { - return (GetMillis()-u.LastPingAt) > USER_OFFLINE_TIMEOUT && (GetMillis()-u.LastActivityAt) > USER_OFFLINE_TIMEOUT -} - -func (u *User) IsAway() bool { - return (GetMillis() - u.LastActivityAt) > USER_AWAY_TIMEOUT -} - // Remove any private data from the user object func (u *User) Sanitize(options map[string]bool) { u.Password = "" @@ -258,7 +243,6 @@ func (u *User) ClearNonProfileFields() { u.MfaActive = false u.MfaSecret = "" u.EmailVerified = false - u.LastPingAt = 0 u.AllowMarketing = false u.Props = StringMap{} u.NotifyProps = StringMap{} diff --git a/model/websocket_client.go b/model/websocket_client.go index 7b9dc0b50..a048bd855 100644 --- a/model/websocket_client.go +++ b/model/websocket_client.go @@ -92,6 +92,8 @@ func (wsc *WebSocketClient) SendMessage(action string, data map[string]interface wsc.Conn.WriteJSON(req) } +// UserTyping will push a user_typing event out to all connected users +// who are in the specified channel func (wsc *WebSocketClient) UserTyping(channelId, parentId string) { data := map[string]interface{}{ "channel_id": channelId, @@ -100,3 +102,8 @@ func (wsc *WebSocketClient) UserTyping(channelId, parentId string) { wsc.SendMessage("user_typing", data) } + +// GetStatuses will return a map of string statuses using user id as the key +func (wsc *WebSocketClient) GetStatuses() { + wsc.SendMessage("get_statuses", nil) +} diff --git a/store/sql_status_store.go b/store/sql_status_store.go new file mode 100644 index 000000000..235d12fa8 --- /dev/null +++ b/store/sql_status_store.go @@ -0,0 +1,166 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package store + +import ( + "database/sql" + "github.com/mattermost/platform/model" +) + +const ( + MISSING_STATUS_ERROR = "store.sql_status.get.missing.app_error" +) + +type SqlStatusStore struct { + *SqlStore +} + +func NewSqlStatusStore(sqlStore *SqlStore) StatusStore { + s := &SqlStatusStore{sqlStore} + + for _, db := range sqlStore.GetAllConns() { + table := db.AddTableWithName(model.Status{}, "Status").SetKeys(false, "UserId") + table.ColMap("UserId").SetMaxSize(26) + table.ColMap("Status").SetMaxSize(32) + } + + return s +} + +func (s SqlStatusStore) UpgradeSchemaIfNeeded() { +} + +func (s SqlStatusStore) CreateIndexesIfNotExists() { + s.CreateIndexIfNotExists("idx_status_user_id", "Status", "UserId") + s.CreateIndexIfNotExists("idx_status_status", "Status", "Status") +} + +func (s SqlStatusStore) SaveOrUpdate(status *model.Status) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + 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, "") + } + } else { + if err := s.GetMaster().Insert(status); err != nil { + result.Err = model.NewLocAppError("SqlStatusStore.SaveOrUpdate", "store.sql_status.save.app_error", nil, "") + } + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlStatusStore) Get(userId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var status model.Status + + if err := s.GetReplica().SelectOne(&status, + `SELECT + * + FROM + Status + WHERE + UserId = :UserId`, map[string]interface{}{"UserId": userId}); err != nil { + if err == sql.ErrNoRows { + result.Err = model.NewLocAppError("SqlStatusStore.Get", MISSING_STATUS_ERROR, nil, err.Error()) + } else { + result.Err = model.NewLocAppError("SqlStatusStore.Get", "store.sql_status.get.app_error", nil, err.Error()) + } + } else { + result.Data = &status + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlStatusStore) GetOnlineAway() StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + 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 { + result.Err = model.NewLocAppError("SqlStatusStore.GetOnline", "store.sql_status.get_online_away.app_error", nil, err.Error()) + } else { + result.Data = statuses + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlStatusStore) ResetAll() StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if _, err := s.GetMaster().Exec("UPDATE Status SET Status = :Status", map[string]interface{}{"Status": model.STATUS_OFFLINE}); err != nil { + result.Err = model.NewLocAppError("SqlStatusStore.ResetAll", "store.sql_status.reset_all.app_error", nil, "") + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlStatusStore) GetTotalActiveUsersCount() StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + time := model.GetMillis() - (1000 * 60 * 60 * 24) + + if count, err := s.GetReplica().SelectInt("SELECT COUNT(UserId) FROM Status WHERE LastActivityAt > :Time", map[string]interface{}{"Time": time}); err != nil { + result.Err = model.NewLocAppError("SqlStatusStore.GetTotalActiveUsersCount", "store.sql_status.get_total_active_users_count.app_error", nil, err.Error()) + } else { + result.Data = count + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlStatusStore) UpdateLastActivityAt(userId string, lastActivityAt int64) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if _, err := s.GetMaster().Exec("UPDATE Status SET LastActivityAt = :Time WHERE UserId = :UserId", map[string]interface{}{"UserId": userId, "Time": lastActivityAt}); err != nil { + result.Err = model.NewLocAppError("SqlStatusStore.UpdateLastActivityAt", "store.sql_status.update_last_activity_at.app_error", nil, "") + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_status_store_test.go b/store/sql_status_store_test.go new file mode 100644 index 000000000..e16dc14d0 --- /dev/null +++ b/store/sql_status_store_test.go @@ -0,0 +1,83 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package store + +import ( + "github.com/mattermost/platform/model" + "testing" +) + +func TestSqlStatusStore(t *testing.T) { + Setup() + + status := &model.Status{model.NewId(), model.STATUS_ONLINE, 0} + + if err := (<-store.Status().SaveOrUpdate(status)).Err; err != nil { + t.Fatal(err) + } + + status.LastActivityAt = 10 + + if err := (<-store.Status().SaveOrUpdate(status)).Err; err != nil { + t.Fatal(err) + } + + if err := (<-store.Status().Get(status.UserId)).Err; err != nil { + t.Fatal(err) + } + + status2 := &model.Status{model.NewId(), model.STATUS_AWAY, 0} + if err := (<-store.Status().SaveOrUpdate(status2)).Err; err != nil { + t.Fatal(err) + } + + status3 := &model.Status{model.NewId(), model.STATUS_OFFLINE, 0} + if err := (<-store.Status().SaveOrUpdate(status3)).Err; err != nil { + t.Fatal(err) + } + + if result := <-store.Status().GetOnlineAway(); result.Err != nil { + t.Fatal(result.Err) + } else { + statuses := result.Data.([]*model.Status) + for _, status := range statuses { + if status.Status == model.STATUS_OFFLINE { + t.Fatal("should not have returned offline statuses") + } + } + } + + if err := (<-store.Status().ResetAll()).Err; err != nil { + t.Fatal(err) + } + + if result := <-store.Status().Get(status.UserId); result.Err != nil { + t.Fatal(result.Err) + } else { + status := result.Data.(*model.Status) + if status.Status != model.STATUS_OFFLINE { + t.Fatal("should be offline") + } + } + + if result := <-store.Status().UpdateLastActivityAt(status.UserId, 10); result.Err != nil { + t.Fatal(result.Err) + } +} + +func TestActiveUserCount(t *testing.T) { + Setup() + + status := &model.Status{model.NewId(), model.STATUS_ONLINE, model.GetMillis()} + Must(store.Status().SaveOrUpdate(status)) + + if result := <-store.Status().GetTotalActiveUsersCount(); result.Err != nil { + t.Fatal(result.Err) + } else { + count := result.Data.(int64) + if count <= 0 { + t.Fatal() + } + } +} diff --git a/store/sql_store.go b/store/sql_store.go index c33da62cc..2047ad150 100644 --- a/store/sql_store.go +++ b/store/sql_store.go @@ -53,6 +53,7 @@ type SqlStore struct { license LicenseStore recovery PasswordRecoveryStore emoji EmojiStore + status StatusStore SchemaVersion string } @@ -129,6 +130,7 @@ func NewSqlStore() Store { sqlStore.license = NewSqlLicenseStore(sqlStore) sqlStore.recovery = NewSqlPasswordRecoveryStore(sqlStore) sqlStore.emoji = NewSqlEmojiStore(sqlStore) + sqlStore.status = NewSqlStatusStore(sqlStore) err := sqlStore.master.CreateTablesIfNotExists() if err != nil { @@ -152,6 +154,7 @@ func NewSqlStore() Store { sqlStore.license.(*SqlLicenseStore).UpgradeSchemaIfNeeded() sqlStore.recovery.(*SqlPasswordRecoveryStore).UpgradeSchemaIfNeeded() sqlStore.emoji.(*SqlEmojiStore).UpgradeSchemaIfNeeded() + sqlStore.status.(*SqlStatusStore).UpgradeSchemaIfNeeded() sqlStore.team.(*SqlTeamStore).CreateIndexesIfNotExists() sqlStore.channel.(*SqlChannelStore).CreateIndexesIfNotExists() @@ -168,6 +171,7 @@ func NewSqlStore() Store { sqlStore.license.(*SqlLicenseStore).CreateIndexesIfNotExists() sqlStore.recovery.(*SqlPasswordRecoveryStore).CreateIndexesIfNotExists() sqlStore.emoji.(*SqlEmojiStore).CreateIndexesIfNotExists() + sqlStore.status.(*SqlStatusStore).CreateIndexesIfNotExists() sqlStore.preference.(*SqlPreferenceStore).DeleteUnusedFeatures() @@ -696,6 +700,10 @@ func (ss SqlStore) Emoji() EmojiStore { return ss.emoji } +func (ss SqlStore) Status() StatusStore { + return ss.status +} + func (ss SqlStore) DropAllTables() { ss.master.TruncateTables() } diff --git a/store/sql_user_store.go b/store/sql_user_store.go index 325008670..c9e435f34 100644 --- a/store/sql_user_store.go +++ b/store/sql_user_store.go @@ -108,6 +108,10 @@ func (us SqlUserStore) UpgradeSchemaIfNeeded() { us.Preference().Save(&data) } } + + // ADDED for 3.3 remove for 3.7 + us.RemoveColumnIfExists("Users", "LastActivityAt") + us.RemoveColumnIfExists("Users", "LastPingAt") } func themeMigrationFailed(err error) { @@ -187,8 +191,6 @@ func (us SqlUserStore) Update(user *model.User, trustedUpdateData bool) StoreCha user.Password = oldUser.Password user.LastPasswordUpdate = oldUser.LastPasswordUpdate user.LastPictureUpdate = oldUser.LastPictureUpdate - user.LastActivityAt = oldUser.LastActivityAt - user.LastPingAt = oldUser.LastPingAt user.EmailVerified = oldUser.EmailVerified user.FailedAttempts = oldUser.FailedAttempts user.MfaSecret = oldUser.MfaSecret @@ -283,65 +285,6 @@ func (us SqlUserStore) UpdateUpdateAt(userId string) StoreChannel { return storeChannel } -func (us SqlUserStore) UpdateLastPingAt(userId string, time int64) StoreChannel { - storeChannel := make(StoreChannel) - - go func() { - result := StoreResult{} - - if _, err := us.GetMaster().Exec("UPDATE Users SET LastPingAt = :LastPingAt WHERE Id = :UserId", map[string]interface{}{"LastPingAt": time, "UserId": userId}); err != nil { - result.Err = model.NewLocAppError("SqlUserStore.UpdateLastPingAt", "store.sql_user.update_last_ping.app_error", nil, "user_id="+userId) - } else { - result.Data = userId - } - - storeChannel <- result - close(storeChannel) - }() - - return storeChannel -} - -func (us SqlUserStore) UpdateLastActivityAt(userId string, time int64) StoreChannel { - storeChannel := make(StoreChannel) - - go func() { - result := StoreResult{} - - if _, err := us.GetMaster().Exec("UPDATE Users SET LastActivityAt = :LastActivityAt WHERE Id = :UserId", map[string]interface{}{"LastActivityAt": time, "UserId": userId}); err != nil { - result.Err = model.NewLocAppError("SqlUserStore.UpdateLastActivityAt", "store.sql_user.update_last_activity.app_error", nil, "user_id="+userId) - } else { - result.Data = userId - } - - storeChannel <- result - close(storeChannel) - }() - - return storeChannel -} - -func (us SqlUserStore) UpdateUserAndSessionActivity(userId string, sessionId string, time int64) StoreChannel { - storeChannel := make(StoreChannel) - - go func() { - result := StoreResult{} - - if _, err := us.GetMaster().Exec("UPDATE Users SET LastActivityAt = :UserLastActivityAt WHERE Id = :UserId", map[string]interface{}{"UserLastActivityAt": time, "UserId": userId}); err != nil { - result.Err = model.NewLocAppError("SqlUserStore.UpdateLastActivityAt", "store.sql_user.update_last_activity.app_error", nil, "1 user_id="+userId+" session_id="+sessionId+" err="+err.Error()) - } else if _, err := us.GetMaster().Exec("UPDATE Sessions SET LastActivityAt = :SessionLastActivityAt WHERE Id = :SessionId", map[string]interface{}{"SessionLastActivityAt": time, "SessionId": sessionId}); err != nil { - result.Err = model.NewLocAppError("SqlUserStore.UpdateLastActivityAt", "store.sql_user.update_last_activity.app_error", nil, "2 user_id="+userId+" session_id="+sessionId+" err="+err.Error()) - } else { - result.Data = userId - } - - storeChannel <- result - close(storeChannel) - }() - - return storeChannel -} - func (us SqlUserStore) UpdatePassword(userId, hashedPassword string) StoreChannel { storeChannel := make(StoreChannel) @@ -988,27 +931,6 @@ func (us SqlUserStore) GetTotalUsersCount() StoreChannel { return storeChannel } -func (us SqlUserStore) GetTotalActiveUsersCount() StoreChannel { - storeChannel := make(StoreChannel) - - go func() { - result := StoreResult{} - - time := model.GetMillis() - (1000 * 60 * 60 * 24) - - if count, err := us.GetReplica().SelectInt("SELECT COUNT(Id) FROM Users WHERE LastActivityAt > :Time", map[string]interface{}{"Time": time}); err != nil { - result.Err = model.NewLocAppError("SqlUserStore.GetTotalActiveUsersCount", "store.sql_user.get_total_active_users_count.app_error", nil, err.Error()) - } else { - result.Data = count - } - - storeChannel <- result - close(storeChannel) - }() - - return storeChannel -} - func (us SqlUserStore) PermanentDelete(userId string) StoreChannel { storeChannel := make(StoreChannel) diff --git a/store/sql_user_store_test.go b/store/sql_user_store_test.go index b6cfbb0ec..4b722b0b3 100644 --- a/store/sql_user_store_test.go +++ b/store/sql_user_store_test.go @@ -129,50 +129,6 @@ func TestUserStoreUpdateUpdateAt(t *testing.T) { } -func TestUserStoreUpdateLastPingAt(t *testing.T) { - Setup() - - u1 := &model.User{} - u1.Email = model.NewId() - Must(store.User().Save(u1)) - Must(store.Team().SaveMember(&model.TeamMember{TeamId: model.NewId(), UserId: u1.Id})) - - if err := (<-store.User().UpdateLastPingAt(u1.Id, 1234567890)).Err; err != nil { - t.Fatal(err) - } - - if r1 := <-store.User().Get(u1.Id); r1.Err != nil { - t.Fatal(r1.Err) - } else { - if r1.Data.(*model.User).LastPingAt != 1234567890 { - t.Fatal("LastPingAt not updated correctly") - } - } - -} - -func TestUserStoreUpdateLastActivityAt(t *testing.T) { - Setup() - - u1 := &model.User{} - u1.Email = model.NewId() - Must(store.User().Save(u1)) - Must(store.Team().SaveMember(&model.TeamMember{TeamId: model.NewId(), UserId: u1.Id})) - - if err := (<-store.User().UpdateLastActivityAt(u1.Id, 1234567890)).Err; err != nil { - t.Fatal(err) - } - - if r1 := <-store.User().Get(u1.Id); r1.Err != nil { - t.Fatal(r1.Err) - } else { - if r1.Data.(*model.User).LastActivityAt != 1234567890 { - t.Fatal("LastActivityAt not updated correctly") - } - } - -} - func TestUserStoreUpdateFailedPasswordAttempts(t *testing.T) { Setup() @@ -189,41 +145,7 @@ func TestUserStoreUpdateFailedPasswordAttempts(t *testing.T) { t.Fatal(r1.Err) } else { if r1.Data.(*model.User).FailedAttempts != 3 { - t.Fatal("LastActivityAt not updated correctly") - } - } - -} - -func TestUserStoreUpdateUserAndSessionActivity(t *testing.T) { - Setup() - - u1 := &model.User{} - u1.Email = model.NewId() - Must(store.User().Save(u1)) - Must(store.Team().SaveMember(&model.TeamMember{TeamId: model.NewId(), UserId: u1.Id})) - - s1 := model.Session{} - s1.UserId = u1.Id - Must(store.Session().Save(&s1)) - - if err := (<-store.User().UpdateUserAndSessionActivity(u1.Id, s1.Id, 1234567890)).Err; err != nil { - t.Fatal(err) - } - - if r1 := <-store.User().Get(u1.Id); r1.Err != nil { - t.Fatal(r1.Err) - } else { - if r1.Data.(*model.User).LastActivityAt != 1234567890 { - t.Fatal("LastActivityAt not updated correctly for user") - } - } - - if r2 := <-store.Session().Get(s1.Id); r2.Err != nil { - t.Fatal(r2.Err) - } else { - if r2.Data.(*model.Session).LastActivityAt != 1234567890 { - t.Fatal("LastActivityAt not updated correctly for session") + t.Fatal("FailedAttempts not updated correctly") } } @@ -268,25 +190,6 @@ func TestUserCount(t *testing.T) { } } -func TestActiveUserCount(t *testing.T) { - Setup() - - u1 := &model.User{} - u1.Email = model.NewId() - Must(store.User().Save(u1)) - Must(store.Team().SaveMember(&model.TeamMember{TeamId: model.NewId(), UserId: u1.Id})) - <-store.User().UpdateLastActivityAt(u1.Id, model.GetMillis()) - - if result := <-store.User().GetTotalActiveUsersCount(); result.Err != nil { - t.Fatal(result.Err) - } else { - count := result.Data.(int64) - if count <= 0 { - t.Fatal() - } - } -} - func TestUserStoreGetAllProfiles(t *testing.T) { Setup() diff --git a/store/store.go b/store/store.go index 4b5c0e8cd..c495ff927 100644 --- a/store/store.go +++ b/store/store.go @@ -43,6 +43,7 @@ type Store interface { License() LicenseStore PasswordRecovery() PasswordRecoveryStore Emoji() EmojiStore + Status() StatusStore MarkSystemRanUnitTests() Close() DropAllTables() @@ -126,9 +127,6 @@ type UserStore interface { Update(user *model.User, allowRoleUpdate bool) StoreChannel UpdateLastPictureUpdate(userId string) StoreChannel UpdateUpdateAt(userId string) StoreChannel - UpdateLastPingAt(userId string, time int64) StoreChannel - UpdateLastActivityAt(userId string, time int64) StoreChannel - UpdateUserAndSessionActivity(userId string, sessionId string, time int64) StoreChannel UpdatePassword(userId, newPassword string) StoreChannel UpdateAuthData(userId string, service string, authData *string, email string) StoreChannel UpdateMfaSecret(userId, secret string) StoreChannel @@ -150,7 +148,6 @@ type UserStore interface { GetEtagForDirectProfiles(userId string) StoreChannel UpdateFailedPasswordAttempts(userId string, attempts int) StoreChannel GetTotalUsersCount() StoreChannel - GetTotalActiveUsersCount() StoreChannel GetSystemAdminProfiles() StoreChannel PermanentDelete(userId string) StoreChannel AnalyticsUniqueUserCount(teamId string) StoreChannel @@ -264,3 +261,12 @@ type EmojiStore interface { GetAll() StoreChannel Delete(id string, time int64) StoreChannel } + +type StatusStore interface { + SaveOrUpdate(status *model.Status) StoreChannel + Get(userId string) StoreChannel + GetOnlineAway() StoreChannel + ResetAll() StoreChannel + GetTotalActiveUsersCount() StoreChannel + UpdateLastActivityAt(userId string, lastActivityAt int64) StoreChannel +} diff --git a/webapp/actions/websocket_actions.jsx b/webapp/actions/websocket_actions.jsx index f7e6adf5d..03c01b60b 100644 --- a/webapp/actions/websocket_actions.jsx +++ b/webapp/actions/websocket_actions.jsx @@ -2,6 +2,9 @@ // See License.txt for license information. 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'; @@ -14,12 +17,14 @@ import Client from 'utils/web_client.jsx'; import WebSocketClient from 'utils/websocket_client.jsx'; 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 Constants from 'utils/constants.jsx'; const SocketEvents = Constants.SocketEvents; +const ActionTypes = Constants.ActionTypes; import {browserHistory} from 'react-router/es6'; @@ -34,10 +39,10 @@ export function initialize() { const connUrl = protocol + location.host + ((/:\d+/).test(location.host) ? '' : Utils.getWebsocketPort(protocol)) + Client.getUsersRoute() + '/websocket'; - WebSocketClient.initialize(connUrl); WebSocketClient.setEventCallback(handleEvent); WebSocketClient.setReconnectCallback(handleReconnect); WebSocketClient.setCloseCallback(handleClose); + WebSocketClient.initialize(connUrl); } } @@ -45,9 +50,21 @@ export function close() { WebSocketClient.close(); } +export function getStatuses() { + WebSocketClient.getStatuses( + (resp) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_STATUSES, + statuses: resp.data + }); + } + ); +} + function handleReconnect() { AsyncClient.getChannels(); AsyncClient.getPosts(ChannelStore.getCurrentId()); + getStatuses(); ErrorStore.clearLastError(); ErrorStore.emitChange(); } @@ -112,6 +129,10 @@ function handleEvent(msg) { handleUserTypingEvent(msg); break; + case SocketEvents.STATUS_CHANGED: + handleStatusChangedEvent(msg); + break; + default: } } @@ -218,3 +239,7 @@ function handlePreferenceChangedEvent(msg) { function handleUserTypingEvent(msg) { GlobalActions.emitRemoteUserTypingEvent(msg.channel_id, msg.user_id, msg.data.parent_id); } + +function handleStatusChangedEvent(msg) { + UserStore.setStatus(msg.user_id, msg.data.status); +} diff --git a/webapp/components/channel_header.jsx b/webapp/components/channel_header.jsx index bb7508a40..e69f470a3 100644 --- a/webapp/components/channel_header.jsx +++ b/webapp/components/channel_header.jsx @@ -29,6 +29,7 @@ import * as TextFormatting from 'utils/text_formatting.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; import Client from 'utils/web_client.jsx'; import Constants from 'utils/constants.jsx'; +const UserStatuses = Constants.UserStatuses; import {FormattedMessage} from 'react-intl'; import {browserHistory} from 'react-router/es6'; @@ -189,6 +190,7 @@ export default class ChannelHeader extends React.Component { if (teammate) { return UserStore.getStatus(teammate.id); } + return UserStatuses.OFFLINE; } return null; } diff --git a/webapp/components/logged_in.jsx b/webapp/components/logged_in.jsx index 2ac858dfb..14b7e138b 100644 --- a/webapp/components/logged_in.jsx +++ b/webapp/components/logged_in.jsx @@ -8,13 +8,12 @@ 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 Websockets from 'actions/websocket_actions.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; +import * as WebSocketActions from 'actions/websocket_actions.jsx'; import Constants from 'utils/constants.jsx'; import {browserHistory} from 'react-router/es6'; -const CLIENT_STATUS_INTERVAL = 30000; const BACKSPACE_CHAR = 8; import React from 'react'; @@ -26,8 +25,8 @@ export default class LoggedIn extends React.Component { this.onUserChanged = this.onUserChanged.bind(this); this.setupUser = this.setupUser.bind(this); - // Initalize websockets - Websockets.initialize(); + // Initalize websocket + WebSocketActions.initialize(); // Force logout of all tabs if one tab is logged out $(window).bind('storage', (e) => { @@ -109,10 +108,6 @@ export default class LoggedIn extends React.Component { // Listen for user UserStore.addChangeListener(this.onUserChanged); - // Get all statuses regularally. (Soon to be switched to websocket) - AsyncClient.getStatuses(); - this.intervalId = setInterval(() => AsyncClient.getStatuses(), CLIENT_STATUS_INTERVAL); - // ??? $('body').on('mouseenter mouseleave', '.post', function mouseOver(ev) { if (ev.type === 'mouseenter') { @@ -144,7 +139,7 @@ export default class LoggedIn extends React.Component { } }); - // Pervent backspace from navigating back a page + // Prevent backspace from navigating back a page $(window).on('keydown.preventBackspace', (e) => { if (e.which === BACKSPACE_CHAR && !$(e.target).is('input, textarea')) { e.preventDefault(); @@ -159,9 +154,8 @@ export default class LoggedIn extends React.Component { componentWillUnmount() { $('#root').attr('class', ''); - clearInterval(this.intervalId); - Websockets.close(); + WebSocketActions.close(); UserStore.removeChangeListener(this.onUserChanged); $('body').off('click.userpopover'); diff --git a/webapp/components/sidebar.jsx b/webapp/components/sidebar.jsx index fdcae1dff..161f2fb7d 100644 --- a/webapp/components/sidebar.jsx +++ b/webapp/components/sidebar.jsx @@ -125,7 +125,7 @@ export default class Sidebar extends React.Component { directChannel.display_name = Utils.displayUsername(teammateId); directChannel.teammate_id = teammateId; - directChannel.status = UserStore.getStatus(teammateId); + directChannel.status = UserStore.getStatus(teammateId) || 'offline'; if (UserStore.hasTeamProfile(teammateId)) { directChannels.push(directChannel); diff --git a/webapp/package.json b/webapp/package.json index f16def242..fb34b3d90 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -18,7 +18,7 @@ "keymirror": "0.1.1", "marked": "mattermost/marked#12d2be4cdf54d4ec95fead934e18840b6a2c1a7b", "match-at": "0.1.0", - "mattermost": "mattermost/mattermost-javascript#4cdaeba22ff82bf93dc417af1ab4e89e3248d624", + "mattermost": "mattermost/mattermost-javascript#84b6f1ebf33aa4b5d8e7ddd7be97d3f5bff5ed17", "object-assign": "4.1.0", "perfect-scrollbar": "0.6.12", "react": "15.2.1", diff --git a/webapp/stores/user_store.jsx b/webapp/stores/user_store.jsx index 218a7f1db..8936b1e49 100644 --- a/webapp/stores/user_store.jsx +++ b/webapp/stores/user_store.jsx @@ -9,6 +9,7 @@ import LocalizationStore from './localization_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 = 'change'; @@ -292,18 +293,11 @@ class UserStoreClass extends EventEmitter { } setStatuses(statuses) { - this.pSetStatuses(statuses); - this.emitStatusesChange(); - } - - pSetStatuses(statuses) { - this.statuses = statuses; + this.statuses = Object.assign(this.statuses, statuses); } setStatus(userId, status) { - var statuses = this.getStatuses(); - statuses[userId] = status; - this.pSetStatuses(statuses); + this.statuses[userId] = status; this.emitStatusesChange(); } @@ -312,7 +306,7 @@ class UserStoreClass extends EventEmitter { } getStatus(id) { - return this.getStatuses()[id]; + return this.getStatuses()[id] || UserStatuses.OFFLINE; } getNoAccounts() { @@ -370,7 +364,7 @@ UserStore.dispatchToken = AppDispatcher.register((payload) => { UserStore.emitAuditsChange(); break; case ActionTypes.RECEIVED_STATUSES: - UserStore.pSetStatuses(action.statuses); + UserStore.setStatuses(action.statuses); UserStore.emitStatusesChange(); break; default: diff --git a/webapp/utils/async_client.jsx b/webapp/utils/async_client.jsx index b31a2a6b9..0241db90d 100644 --- a/webapp/utils/async_client.jsx +++ b/webapp/utils/async_client.jsx @@ -7,7 +7,6 @@ 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 PreferenceStore from 'stores/preference_store.jsx'; import PostStore from 'stores/post_store.jsx'; import UserStore from 'stores/user_store.jsx'; import * as utils from './utils.jsx'; @@ -744,21 +743,12 @@ export function getMe() { } export function getStatuses() { - const preferences = PreferenceStore.getCategory(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW); - - const teammateIds = []; - for (const [name, value] of preferences) { - if (value === 'true') { - teammateIds.push(name); - } - } - - if (isCallInProgress('getStatuses') || teammateIds.length === 0) { + if (isCallInProgress('getStatuses')) { return; } callTracker.getStatuses = utils.getTimestamp(); - Client.getStatuses(teammateIds, + Client.getStatuses( (data) => { callTracker.getStatuses = 0; @@ -1535,4 +1525,4 @@ export function deleteEmoji(id) { dispatchError(err, 'deleteEmoji'); } ); -} \ No newline at end of file +} diff --git a/webapp/utils/constants.jsx b/webapp/utils/constants.jsx index d780efe30..fda0508af 100644 --- a/webapp/utils/constants.jsx +++ b/webapp/utils/constants.jsx @@ -191,7 +191,8 @@ export const Constants = { USER_REMOVED: 'user_removed', TYPING: 'typing', PREFERENCE_CHANGED: 'preference_changed', - EPHEMERAL_MESSAGE: 'ephemeral_message' + EPHEMERAL_MESSAGE: 'ephemeral_message', + STATUS_CHANGED: 'status_change' }, UserUpdateEvents: { @@ -210,6 +211,12 @@ export const Constants = { POST: 5 }, + UserStatuses: { + OFFLINE: 'offline', + AWAY: 'away', + ONLINE: 'online' + }, + SPECIAL_MENTIONS: ['all', 'channel'], CHARACTER_LIMIT: 4000, IMAGE_TYPES: ['jpg', 'gif', 'bmp', 'png', 'jpeg'], -- cgit v1.2.3-1-g7c22