summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Makefile2
-rw-r--r--api/api.go1
-rw-r--r--api/context.go7
-rw-r--r--api/general.go11
-rw-r--r--api/post.go8
-rw-r--r--api/status.go155
-rw-r--r--api/status_test.go153
-rw-r--r--api/user.go38
-rw-r--r--api/user_test.go60
-rw-r--r--api/web_conn.go23
-rw-r--r--api/web_hub.go13
-rw-r--r--api/websocket_handler.go9
-rw-r--r--config/config.json3
-rw-r--r--i18n/en.json34
-rw-r--r--mattermost.go10
-rw-r--r--model/client.go5
-rw-r--r--model/config.go6
-rw-r--r--model/status.go42
-rw-r--r--model/status_test.go27
-rw-r--r--model/user.go16
-rw-r--r--model/websocket_client.go7
-rw-r--r--store/sql_status_store.go166
-rw-r--r--store/sql_status_store_test.go83
-rw-r--r--store/sql_store.go8
-rw-r--r--store/sql_user_store.go86
-rw-r--r--store/sql_user_store_test.go99
-rw-r--r--store/store.go14
-rw-r--r--webapp/actions/websocket_actions.jsx27
-rw-r--r--webapp/components/channel_header.jsx2
-rw-r--r--webapp/components/logged_in.jsx16
-rw-r--r--webapp/components/sidebar.jsx2
-rw-r--r--webapp/package.json2
-rw-r--r--webapp/stores/user_store.jsx16
-rw-r--r--webapp/utils/async_client.jsx16
-rw-r--r--webapp/utils/constants.jsx9
35 files changed, 800 insertions, 376 deletions
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'],