diff options
author | Joram Wilander <jwawilander@gmail.com> | 2016-07-12 09:36:27 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2016-07-12 09:36:27 -0400 |
commit | ad343a0f4ad175053f7d0da12a0587bcbb396d1c (patch) | |
tree | 8e1be00202a1d3a037ec75879538eb0ba1f25c01 | |
parent | 06eacf30b97aacf6544552448635b7f078d2c90b (diff) | |
download | chat-ad343a0f4ad175053f7d0da12a0587bcbb396d1c.tar.gz chat-ad343a0f4ad175053f7d0da12a0587bcbb396d1c.tar.bz2 chat-ad343a0f4ad175053f7d0da12a0587bcbb396d1c.zip |
Added infrastructure for basic WebSocket API (#3432)
32 files changed, 820 insertions, 363 deletions
diff --git a/api/api.go b/api/api.go index 37172260b..4cc11168c 100644 --- a/api/api.go +++ b/api/api.go @@ -48,6 +48,8 @@ type Routes struct { Public *mux.Router // 'api/v3/public' Emoji *mux.Router // 'api/v3/emoji' + + WebSocket *WebSocketRouter // websocket api } var BaseRoutes *Routes @@ -76,6 +78,8 @@ func InitApi() { BaseRoutes.Public = BaseRoutes.ApiRoot.PathPrefix("/public").Subrouter() BaseRoutes.Emoji = BaseRoutes.ApiRoot.PathPrefix("/emoji").Subrouter() + BaseRoutes.WebSocket = NewWebSocketRouter() + InitUser() InitTeam() InitChannel() diff --git a/api/apitestlib.go b/api/apitestlib.go index c6796a56c..ea0de4716 100644 --- a/api/apitestlib.go +++ b/api/apitestlib.go @@ -103,6 +103,10 @@ func (me *TestHelper) CreateClient() *model.Client { return model.NewClient("http://localhost" + utils.Cfg.ServiceSettings.ListenAddress) } +func (me *TestHelper) CreateWebSocketClient() (*model.WebSocketClient, *model.AppError) { + return model.NewWebSocketClient("ws://localhost"+utils.Cfg.ServiceSettings.ListenAddress, me.BasicClient.AuthToken) +} + func (me *TestHelper) CreateTeam(client *model.Client) *model.Team { id := model.NewId() team := &model.Team{ diff --git a/api/channel.go b/api/channel.go index 2e4eb2bb5..2a5b6f8b0 100644 --- a/api/channel.go +++ b/api/channel.go @@ -158,7 +158,7 @@ func CreateDirectChannel(userId string, otherUserId string) (*model.Channel, *mo return nil, result.Err } } else { - message := model.NewMessage("", channel.Id, userId, model.ACTION_DIRECT_ADDED) + message := model.NewWebSocketEvent("", channel.Id, userId, model.WEBSOCKET_EVENT_DIRECT_ADDED) message.Add("teammate_id", otherUserId) go Publish(message) @@ -587,7 +587,7 @@ func AddUserToChannel(user *model.User, channel *model.Channel) (*model.ChannelM go func() { InvalidateCacheForUser(user.Id) - message := model.NewMessage(channel.TeamId, channel.Id, user.Id, model.ACTION_USER_ADDED) + message := model.NewWebSocketEvent(channel.TeamId, channel.Id, user.Id, model.WEBSOCKET_EVENT_USER_ADDED) go Publish(message) }() @@ -772,7 +772,7 @@ func deleteChannel(c *Context, w http.ResponseWriter, r *http.Request) { go func() { InvalidateCacheForChannel(channel.Id) - message := model.NewMessage(c.TeamId, channel.Id, c.Session.UserId, model.ACTION_CHANNEL_DELETED) + message := model.NewWebSocketEvent(c.TeamId, channel.Id, c.Session.UserId, model.WEBSOCKET_EVENT_CHANNEL_DELETED) go Publish(message) post := &model.Post{ @@ -806,7 +806,7 @@ func updateLastViewedAt(c *Context, w http.ResponseWriter, r *http.Request) { Srv.Store.Preference().Save(&model.Preferences{preference}) - message := model.NewMessage(c.TeamId, id, c.Session.UserId, model.ACTION_CHANNEL_VIEWED) + message := model.NewWebSocketEvent(c.TeamId, id, c.Session.UserId, model.WEBSOCKET_EVENT_CHANNEL_VIEWED) message.Add("channel_id", id) go Publish(message) @@ -1032,7 +1032,7 @@ func RemoveUserFromChannel(userIdToRemove string, removerUserId string, channel InvalidateCacheForUser(userIdToRemove) - message := model.NewMessage(channel.TeamId, channel.Id, userIdToRemove, model.ACTION_USER_REMOVED) + message := model.NewWebSocketEvent(channel.TeamId, channel.Id, userIdToRemove, model.WEBSOCKET_EVENT_USER_REMOVED) message.Add("remover_id", removerUserId) go Publish(message) diff --git a/api/command_expand_collapse.go b/api/command_expand_collapse.go index 6015e8bc1..c56845a9e 100644 --- a/api/command_expand_collapse.go +++ b/api/command_expand_collapse.go @@ -69,7 +69,7 @@ func setCollapsePreference(c *Context, value string) *model.CommandResponse { return &model.CommandResponse{Text: c.T("api.command_expand_collapse.fail.app_error"), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL} } - socketMessage := model.NewMessage("", "", c.Session.UserId, model.ACTION_PREFERENCE_CHANGED) + socketMessage := model.NewWebSocketEvent("", "", c.Session.UserId, model.WEBSOCKET_EVENT_PREFERENCE_CHANGED) socketMessage.Add("preference", pref.ToJson()) go Publish(socketMessage) diff --git a/api/general.go b/api/general.go index fdf884d6b..4124d2e95 100644 --- a/api/general.go +++ b/api/general.go @@ -21,6 +21,7 @@ func InitGeneral() { BaseRoutes.General.Handle("/log_client", ApiAppHandler(logClient)).Methods("POST") BaseRoutes.General.Handle("/ping", ApiAppHandler(ping)).Methods("GET") + BaseRoutes.WebSocket.Handle("ping", ApiWebSocketHandler(webSocketPing)) } func getClientConfig(c *Context, w http.ResponseWriter, r *http.Request) { @@ -71,3 +72,8 @@ func ping(c *Context, w http.ResponseWriter, r *http.Request) { m["node_id"] = "" w.Write([]byte(model.MapToJson(m))) } + +func webSocketPing(req *model.WebSocketRequest, responseData map[string]interface{}) *model.AppError { + responseData["text"] = "pong" + return nil +} diff --git a/api/post.go b/api/post.go index 20363c80e..60ac11a2b 100644 --- a/api/post.go +++ b/api/post.go @@ -329,7 +329,7 @@ func makeDirectChannelVisible(teamId string, channelId string) { if saveResult := <-Srv.Store.Preference().Save(&model.Preferences{*preference}); saveResult.Err != nil { l4g.Error(utils.T("api.post.make_direct_channel_visible.save_pref.error"), member.UserId, otherUserId, saveResult.Err.Message) } else { - message := model.NewMessage(teamId, channelId, member.UserId, model.ACTION_PREFERENCE_CHANGED) + message := model.NewWebSocketEvent(teamId, channelId, member.UserId, model.WEBSOCKET_EVENT_PREFERENCE_CHANGED) message.Add("preference", preference.ToJson()) go Publish(message) @@ -344,7 +344,7 @@ func makeDirectChannelVisible(teamId string, channelId string) { if updateResult := <-Srv.Store.Preference().Save(&model.Preferences{preference}); updateResult.Err != nil { l4g.Error(utils.T("api.post.make_direct_channel_visible.update_pref.error"), member.UserId, otherUserId, updateResult.Err.Message) } else { - message := model.NewMessage(teamId, channelId, member.UserId, model.ACTION_PREFERENCE_CHANGED) + message := model.NewWebSocketEvent(teamId, channelId, member.UserId, model.WEBSOCKET_EVENT_PREFERENCE_CHANGED) message.Add("preference", preference.ToJson()) go Publish(message) @@ -627,7 +627,7 @@ func sendNotifications(c *Context, post *model.Post, team *model.Team, channel * } } - message := model.NewMessage(c.TeamId, post.ChannelId, post.UserId, model.ACTION_POSTED) + message := model.NewWebSocketEvent(c.TeamId, post.ChannelId, post.UserId, model.WEBSOCKET_EVENT_POSTED) message.Add("post", post.ToJson()) message.Add("channel_type", channel.Type) message.Add("channel_display_name", channel.DisplayName) @@ -905,7 +905,7 @@ func SendEphemeralPost(teamId, userId string, post *model.Post) { post.Filenames = []string{} } - message := model.NewMessage(teamId, post.ChannelId, userId, model.ACTION_EPHEMERAL_MESSAGE) + message := model.NewWebSocketEvent(teamId, post.ChannelId, userId, model.WEBSOCKET_EVENT_EPHEMERAL_MESSAGE) message.Add("post", post.ToJson()) go Publish(message) @@ -967,7 +967,7 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) { } else { rpost := result.Data.(*model.Post) - message := model.NewMessage(c.TeamId, rpost.ChannelId, c.Session.UserId, model.ACTION_POST_EDITED) + message := model.NewWebSocketEvent(c.TeamId, rpost.ChannelId, c.Session.UserId, model.WEBSOCKET_EVENT_POST_EDITED) message.Add("post", rpost.ToJson()) go Publish(message) @@ -1231,7 +1231,7 @@ func deletePost(c *Context, w http.ResponseWriter, r *http.Request) { return } - message := model.NewMessage(c.TeamId, post.ChannelId, c.Session.UserId, model.ACTION_POST_DELETED) + message := model.NewWebSocketEvent(c.TeamId, post.ChannelId, c.Session.UserId, model.WEBSOCKET_EVENT_POST_DELETED) message.Add("post", post.ToJson()) go Publish(message) diff --git a/api/team.go b/api/team.go index 7f8a421ce..702ea96d1 100644 --- a/api/team.go +++ b/api/team.go @@ -298,7 +298,7 @@ func JoinUserToTeam(team *model.Team, user *model.User) *model.AppError { InvalidateCacheForUser(user.Id) // This message goes to every channel, so the channelId is irrelevant - go Publish(model.NewMessage("", "", user.Id, model.ACTION_NEW_USER)) + go Publish(model.NewWebSocketEvent("", "", user.Id, model.WEBSOCKET_EVENT_NEW_USER)) return nil } @@ -348,7 +348,7 @@ func LeaveTeam(team *model.Team, user *model.User) *model.AppError { RemoveAllSessionsForUserId(user.Id) InvalidateCacheForUser(user.Id) - go Publish(model.NewMessage(team.Id, "", user.Id, model.ACTION_LEAVE_TEAM)) + go Publish(model.NewWebSocketEvent(team.Id, "", user.Id, model.WEBSOCKET_EVENT_LEAVE_TEAM)) return nil } diff --git a/api/user.go b/api/user.go index 84906eece..3666bfd7a 100644 --- a/api/user.go +++ b/api/user.go @@ -75,6 +75,8 @@ func InitUser() { BaseRoutes.Root.Handle("/login/sso/saml", AppHandlerIndependent(loginWithSaml)).Methods("GET") BaseRoutes.Root.Handle("/login/sso/saml", AppHandlerIndependent(completeSaml)).Methods("POST") + + BaseRoutes.WebSocket.Handle("user_typing", ApiWebSocketHandler(userTyping)) } func createUser(c *Context, w http.ResponseWriter, r *http.Request) { @@ -269,7 +271,7 @@ func CreateUser(user *model.User) (*model.User, *model.AppError) { ruser.Sanitize(map[string]bool{}) // This message goes to every channel, so the channelId is irrelevant - go Publish(model.NewMessage("", "", ruser.Id, model.ACTION_NEW_USER)) + go Publish(model.NewWebSocketEvent("", "", ruser.Id, model.WEBSOCKET_EVENT_NEW_USER)) return ruser, nil } @@ -2540,3 +2542,22 @@ func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, GetProtocol(r)+"://"+r.Host, http.StatusFound) } } + +func userTyping(req *model.WebSocketRequest, responseData 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") + } + + var parentId string + if parentId, ok = req.Data["parent_id"].(string); !ok { + parentId = "" + } + + event := model.NewWebSocketEvent("", channelId, req.Session.UserId, model.WEBSOCKET_EVENT_TYPING) + event.Add("parent_id", parentId) + go Publish(event) + + return nil +} diff --git a/api/user_test.go b/api/user_test.go index 7dabc8e9b..12390135e 100644 --- a/api/user_test.go +++ b/api/user_test.go @@ -1719,3 +1719,85 @@ func TestCheckMfa(t *testing.T) { // need to add more test cases when enterprise bits can be loaded into tests } + +func TestUserTyping(t *testing.T) { + th := Setup().InitBasic() + Client := th.BasicClient + WebSocketClient, err := th.CreateWebSocketClient() + if err != nil { + t.Fatal(err) + } + defer WebSocketClient.Close() + WebSocketClient.Listen() + + WebSocketClient.UserTyping("", "") + time.Sleep(300 * time.Millisecond) + if resp := <-WebSocketClient.ResponseChannel; resp.Error.Id != "api.websocket_handler.invalid_param.app_error" { + t.Fatal("should have been invalid param response") + } + + th.LoginBasic2() + Client.Must(Client.JoinChannel(th.BasicChannel.Id)) + + WebSocketClient2, err2 := th.CreateWebSocketClient() + if err2 != nil { + t.Fatal(err2) + } + defer WebSocketClient2.Close() + WebSocketClient2.Listen() + + WebSocketClient.UserTyping(th.BasicChannel.Id, "") + + time.Sleep(300 * time.Millisecond) + + stop := make(chan bool) + eventHit := false + + go func() { + for { + select { + case resp := <-WebSocketClient2.EventChannel: + if resp.Event == model.WEBSOCKET_EVENT_TYPING && resp.UserId == th.BasicUser.Id { + eventHit = true + } + case <-stop: + return + } + } + }() + + time.Sleep(300 * time.Millisecond) + + stop <- true + + if !eventHit { + t.Fatal("did not receive typing event") + } + + WebSocketClient.UserTyping(th.BasicChannel.Id, "someparentid") + + time.Sleep(300 * time.Millisecond) + + eventHit = false + + go func() { + for { + select { + case resp := <-WebSocketClient2.EventChannel: + if resp.Event == model.WEBSOCKET_EVENT_TYPING && resp.Data["parent_id"] == "someparentid" { + eventHit = true + } + case <-stop: + return + } + } + }() + + time.Sleep(300 * time.Millisecond) + + stop <- true + + if !eventHit { + t.Fatal("did not receive typing event") + } +} diff --git a/api/web_conn.go b/api/web_conn.go index 971cc8cb8..3f4414c5e 100644 --- a/api/web_conn.go +++ b/api/web_conn.go @@ -6,10 +6,12 @@ package api import ( "time" - l4g "github.com/alecthomas/log4go" - "github.com/gorilla/websocket" "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" ) const ( @@ -22,32 +24,36 @@ const ( type WebConn struct { WebSocket *websocket.Conn - Send chan *model.Message + Send chan model.WebSocketMessage SessionToken string UserId string + T goi18n.TranslateFunc + Locale string hasPermissionsToChannel map[string]bool hasPermissionsToTeam map[string]bool } -func NewWebConn(ws *websocket.Conn, userId string, sessionToken string) *WebConn { +func NewWebConn(c *Context, ws *websocket.Conn) *WebConn { go func() { - achan := Srv.Store.User().UpdateUserAndSessionActivity(userId, sessionToken, model.GetMillis()) - pchan := Srv.Store.User().UpdateLastPingAt(userId, model.GetMillis()) + 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"), userId, sessionToken, result.Err) + 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"), userId, result.Err) + l4g.Error(utils.T("api.web_conn.new_web_conn.last_ping.error"), c.Session.UserId, result.Err) } }() return &WebConn{ - Send: make(chan *model.Message, 64), + Send: make(chan model.WebSocketMessage, 64), WebSocket: ws, - UserId: userId, - SessionToken: sessionToken, + UserId: c.Session.UserId, + SessionToken: c.Session.Token, + T: c.T, + Locale: c.Locale, hasPermissionsToChannel: make(map[string]bool), hasPermissionsToTeam: make(map[string]bool), } @@ -73,12 +79,11 @@ func (c *WebConn) readPump() { }) for { - var msg model.Message - if err := c.WebSocket.ReadJSON(&msg); err != nil { + var req model.WebSocketRequest + if err := c.WebSocket.ReadJSON(&req); err != nil { return } else { - msg.UserId = c.UserId - go Publish(&msg) + BaseRoutes.WebSocket.ServeWebSocket(c, &req) } } } diff --git a/api/web_hub.go b/api/web_hub.go index 133bb162a..db0f31bb7 100644 --- a/api/web_hub.go +++ b/api/web_hub.go @@ -13,7 +13,7 @@ type Hub struct { connections map[*WebConn]bool register chan *WebConn unregister chan *WebConn - broadcast chan *model.Message + broadcast chan *model.WebSocketEvent stop chan string invalidateUser chan string invalidateChannel chan string @@ -23,13 +23,13 @@ var hub = &Hub{ register: make(chan *WebConn), unregister: make(chan *WebConn), connections: make(map[*WebConn]bool), - broadcast: make(chan *model.Message), + broadcast: make(chan *model.WebSocketEvent), stop: make(chan string), invalidateUser: make(chan string), invalidateChannel: make(chan string), } -func Publish(message *model.Message) { +func Publish(message *model.WebSocketEvent) { hub.Broadcast(message) } @@ -49,7 +49,7 @@ func (h *Hub) Unregister(webConn *WebConn) { h.unregister <- webConn } -func (h *Hub) Broadcast(message *model.Message) { +func (h *Hub) Broadcast(message *model.WebSocketEvent) { if message != nil { h.broadcast <- message } @@ -108,11 +108,10 @@ func (h *Hub) Start() { }() } -func shouldSendEvent(webCon *WebConn, msg *model.Message) bool { - +func shouldSendEvent(webCon *WebConn, msg *model.WebSocketEvent) bool { if webCon.UserId == msg.UserId { // Don't need to tell the user they are typing - if msg.Action == model.ACTION_TYPING { + if msg.Event == model.WEBSOCKET_EVENT_TYPING { return false } @@ -127,11 +126,11 @@ func shouldSendEvent(webCon *WebConn, msg *model.Message) bool { } } else { // Don't share a user's view or preference events with other users - if msg.Action == model.ACTION_CHANNEL_VIEWED { + if msg.Event == model.WEBSOCKET_EVENT_CHANNEL_VIEWED { return false - } else if msg.Action == model.ACTION_PREFERENCE_CHANGED { + } else if msg.Event == model.WEBSOCKET_EVENT_PREFERENCE_CHANGED { return false - } else if msg.Action == model.ACTION_EPHEMERAL_MESSAGE { + } else if msg.Event == model.WEBSOCKET_EVENT_EPHEMERAL_MESSAGE { // For now, ephemeral messages are sent directly to individual users return false } @@ -146,7 +145,7 @@ func shouldSendEvent(webCon *WebConn, msg *model.Message) bool { } // Only report events to users who are in the channel for the event execept deleted events - if len(msg.ChannelId) > 0 && msg.Action != model.ACTION_CHANNEL_DELETED { + if len(msg.ChannelId) > 0 && msg.Event != model.WEBSOCKET_EVENT_CHANNEL_DELETED { allowed := webCon.HasPermissionsToChannel(msg.ChannelId) if !allowed { diff --git a/api/web_socket_test.go b/api/web_socket_test.go deleted file mode 100644 index 7cb04e93e..000000000 --- a/api/web_socket_test.go +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -package api - -import ( - "github.com/gorilla/websocket" - "github.com/mattermost/platform/model" - "github.com/mattermost/platform/utils" - "net/http" - "testing" - "time" -) - -func TestSocket(t *testing.T) { - th := Setup().InitBasic() - Client := th.BasicClient - team := th.BasicTeam - channel1 := th.BasicChannel - channel2 := th.CreateChannel(Client, team) - Client.Must(Client.AddChannelMember(channel1.Id, th.BasicUser2.Id)) - - url := "ws://localhost" + utils.Cfg.ServiceSettings.ListenAddress + model.API_URL_SUFFIX + "/users/websocket" - - header1 := http.Header{} - header1.Set(model.HEADER_AUTH, "BEARER "+Client.AuthToken) - - c1, _, err := websocket.DefaultDialer.Dial(url, header1) - if err != nil { - t.Fatal(err) - } - - th.LoginBasic2() - - header2 := http.Header{} - header2.Set(model.HEADER_AUTH, "BEARER "+Client.AuthToken) - - c2, _, err := websocket.DefaultDialer.Dial(url, header2) - if err != nil { - t.Fatal(err) - } - - time.Sleep(300 * time.Millisecond) - - var rmsg model.Message - - // Test sending message without a channelId - m := model.NewMessage(team.Id, "", "", model.ACTION_TYPING) - m.Add("RootId", model.NewId()) - m.Add("ParentId", model.NewId()) - - c1.WriteJSON(m) - - if err := c2.ReadJSON(&rmsg); err != nil { - t.Fatal(err) - } - - t.Log(rmsg.ToJson()) - - if team.Id != rmsg.TeamId { - t.Fatal("Ids do not match") - } - - if m.Props["RootId"] != rmsg.Props["RootId"] { - t.Fatal("Ids do not match") - } - - // Test sending messsage to Channel you have access to - m = model.NewMessage(team.Id, channel1.Id, "", model.ACTION_TYPING) - m.Add("RootId", model.NewId()) - m.Add("ParentId", model.NewId()) - - c1.WriteJSON(m) - - if err := c2.ReadJSON(&rmsg); err != nil { - t.Fatal(err) - } - - if team.Id != rmsg.TeamId { - t.Fatal("Ids do not match") - } - - if m.Props["RootId"] != rmsg.Props["RootId"] { - t.Fatal("Ids do not match") - } - - // Test sending message to Channel you *do not* have access too - m = model.NewMessage("", channel2.Id, "", model.ACTION_TYPING) - m.Add("RootId", model.NewId()) - m.Add("ParentId", model.NewId()) - - c1.WriteJSON(m) - - go func() { - if err := c2.ReadJSON(&rmsg); err != nil { - t.Fatal(err) - } - - t.Fatal(err) - }() - - time.Sleep(2 * time.Second) -} diff --git a/api/webhook_test.go b/api/webhook_test.go index 95e4d92be..f2375fb19 100644 --- a/api/webhook_test.go +++ b/api/webhook_test.go @@ -8,7 +8,6 @@ import ( "github.com/mattermost/platform/model" "github.com/mattermost/platform/utils" "testing" - "time" ) func TestCreateIncomingHook(t *testing.T) { @@ -629,12 +628,3 @@ func TestIncomingWebhooks(t *testing.T) { t.Fatal("should have failed - webhooks turned off") } } - -func TestZZWebSocketTearDown(t *testing.T) { - // *IMPORTANT* - Kind of hacky - // This should be the last function in any test file - // that calls Setup() - // Should be in the last file too sorted by name - time.Sleep(2 * time.Second) - TearDown() -} diff --git a/api/web_socket.go b/api/websocket.go index 4c4a56c52..fe9fa0bf9 100644 --- a/api/web_socket.go +++ b/api/websocket.go @@ -33,7 +33,7 @@ func connect(c *Context, w http.ResponseWriter, r *http.Request) { return } - wc := NewWebConn(ws, c.Session.UserId, c.Session.Token) + wc := NewWebConn(c, ws) hub.Register(wc) go wc.writePump() wc.readPump() diff --git a/api/websocket_handler.go b/api/websocket_handler.go new file mode 100644 index 000000000..8abec6715 --- /dev/null +++ b/api/websocket_handler.go @@ -0,0 +1,42 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api + +import ( + l4g "github.com/alecthomas/log4go" + + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +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 +} + +func (wh *webSocketHandler) ServeWebSocket(conn *WebConn, r *model.WebSocketRequest) { + l4g.Debug("/api/v3/users/websocket:%s", r.Action) + + r.Session = *GetSession(conn.SessionToken) + r.T = conn.T + r.Locale = conn.Locale + + data := make(map[string]interface{}) + + if err := wh.handlerFunc(r, data); 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) + return + } + + conn.Send <- model.NewWebSocketResponse(model.STATUS_OK, r.Seq, data) +} + +func NewInvalidWebSocketParamError(action string, name string) *model.AppError { + return model.NewLocAppError("/api/v3/users/websocket:"+action, "api.websocket_handler.invalid_param.app_error", map[string]interface{}{"Name": name}, "") +} diff --git a/api/websocket_router.go b/api/websocket_router.go new file mode 100644 index 000000000..cd3ff4d1a --- /dev/null +++ b/api/websocket_router.go @@ -0,0 +1,59 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api + +import ( + l4g "github.com/alecthomas/log4go" + + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +type WebSocketRouter struct { + handlers map[string]*webSocketHandler +} + +func NewWebSocketRouter() *WebSocketRouter { + router := &WebSocketRouter{} + router.handlers = make(map[string]*webSocketHandler) + return router +} + +func (wr *WebSocketRouter) Handle(action string, handler *webSocketHandler) { + wr.handlers[action] = handler +} + +func (wr *WebSocketRouter) ServeWebSocket(conn *WebConn, r *model.WebSocketRequest) { + if r.Action == "" { + err := model.NewLocAppError("ServeWebSocket", "api.web_socket_router.no_action.app_error", nil, "") + wr.ReturnWebSocketError(conn, r, err) + return + } + + if r.Seq <= 0 { + err := model.NewLocAppError("ServeWebSocket", "api.web_socket_router.bad_seq.app_error", nil, "") + wr.ReturnWebSocketError(conn, r, err) + return + } + + var handler *webSocketHandler + if h, ok := wr.handlers[r.Action]; !ok { + err := model.NewLocAppError("ServeWebSocket", "api.web_socket_router.bad_action.app_error", nil, "") + wr.ReturnWebSocketError(conn, r, err) + return + } else { + handler = h + } + + handler.ServeWebSocket(conn, r) +} + +func (wr *WebSocketRouter) ReturnWebSocketError(conn *WebConn, r *model.WebSocketRequest, err *model.AppError) { + l4g.Error(utils.T("api.web_socket_router.log.error"), r.Seq, conn.UserId, err.SystemMessage(utils.T), err.DetailedError) + + err.DetailedError = "" + errorResp := model.NewWebSocketError(r.Seq, err) + + conn.Send <- errorResp +} diff --git a/api/websocket_test.go b/api/websocket_test.go new file mode 100644 index 000000000..b0dc1e955 --- /dev/null +++ b/api/websocket_test.go @@ -0,0 +1,144 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api + +import ( + "testing" + "time" + + "github.com/mattermost/platform/model" +) + +func TestWebSocket(t *testing.T) { + th := Setup().InitBasic() + WebSocketClient, err := th.CreateWebSocketClient() + if err != nil { + t.Fatal(err) + } + defer WebSocketClient.Close() + + time.Sleep(300 * time.Millisecond) + + // Test closing and reconnecting + WebSocketClient.Close() + if err := WebSocketClient.Connect(); err != nil { + t.Fatal(err) + } + + WebSocketClient.Listen() + + time.Sleep(300 * time.Millisecond) + + WebSocketClient.SendMessage("ping", nil) + time.Sleep(300 * time.Millisecond) + if resp := <-WebSocketClient.ResponseChannel; resp.Data["text"].(string) != "pong" { + t.Fatal("wrong response") + } + + WebSocketClient.SendMessage("", nil) + time.Sleep(300 * time.Millisecond) + if resp := <-WebSocketClient.ResponseChannel; resp.Error.Id != "api.web_socket_router.no_action.app_error" { + t.Fatal("should have been no action response") + } + + WebSocketClient.SendMessage("junk", nil) + time.Sleep(300 * time.Millisecond) + if resp := <-WebSocketClient.ResponseChannel; resp.Error.Id != "api.web_socket_router.bad_action.app_error" { + t.Fatal("should have been bad action response") + } + + req := &model.WebSocketRequest{} + req.Seq = 0 + req.Action = "ping" + WebSocketClient.Conn.WriteJSON(req) + time.Sleep(300 * time.Millisecond) + if resp := <-WebSocketClient.ResponseChannel; resp.Error.Id != "api.web_socket_router.bad_seq.app_error" { + t.Fatal("should have been bad action response") + } + + WebSocketClient.UserTyping("", "") + time.Sleep(300 * time.Millisecond) + if resp := <-WebSocketClient.ResponseChannel; resp.Error.Id != "api.websocket_handler.invalid_param.app_error" { + t.Fatal("should have been invalid param response") + } else { + if resp.Error.DetailedError != "" { + t.Fatal("detailed error not cleared") + } + } +} + +func TestWebSocketEvent(t *testing.T) { + th := Setup().InitBasic() + WebSocketClient, err := th.CreateWebSocketClient() + if err != nil { + t.Fatal(err) + } + defer WebSocketClient.Close() + + WebSocketClient.Listen() + + evt1 := model.NewWebSocketEvent(th.BasicTeam.Id, th.BasicChannel.Id, "somerandomid", model.WEBSOCKET_EVENT_TYPING) + go Publish(evt1) + time.Sleep(300 * time.Millisecond) + + stop := make(chan bool) + eventHit := false + + go func() { + for { + select { + case resp := <-WebSocketClient.EventChannel: + if resp.Event == model.WEBSOCKET_EVENT_TYPING && resp.UserId == "somerandomid" { + eventHit = true + } + case <-stop: + return + } + } + }() + + time.Sleep(300 * time.Millisecond) + + stop <- true + + if !eventHit { + t.Fatal("did not receive typing event") + } + + evt2 := model.NewWebSocketEvent(th.BasicTeam.Id, "somerandomid", "somerandomid", model.WEBSOCKET_EVENT_TYPING) + go Publish(evt2) + time.Sleep(300 * time.Millisecond) + + eventHit = false + + go func() { + for { + select { + case resp := <-WebSocketClient.EventChannel: + if resp.Event == model.WEBSOCKET_EVENT_TYPING { + eventHit = true + } + case <-stop: + return + } + } + }() + + time.Sleep(300 * time.Millisecond) + + stop <- true + + if eventHit { + t.Fatal("got typing event for bad channel id") + } +} + +func TestZZWebSocketTearDown(t *testing.T) { + // *IMPORTANT* - Kind of hacky + // This should be the last function in any test file + // that calls Setup() + // Should be in the last file too sorted by name + time.Sleep(2 * time.Second) + TearDown() +} diff --git a/i18n/en.json b/i18n/en.json index b8a00d5a8..e8efcf331 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -600,6 +600,18 @@ "translation": "%v:%v code=%v rid=%v uid=%v ip=%v %v [details: %v]" }, { + "id": "api.web_socket_router.log.error", + "translation": "websocket routing error: seq=%v uid=%v %v [details: %v]" + }, + { + "id": "api.web_socket_handler.log.error", + "translation": "%v:%v seq=%v uid=%v %v [details: %v]" + }, + { + "id": "api.websocket_handler.invalid_param.app_error", + "translation": "Invalid {{.Name}} parameter" + }, + { "id": "api.context.permissions.app_error", "translation": "You do not have the appropriate permissions" }, diff --git a/model/client.go b/model/client.go index 2f1e846c2..0ba8913af 100644 --- a/model/client.go +++ b/model/client.go @@ -32,6 +32,7 @@ const ( HEADER_REQUESTED_WITH_XML = "XMLHttpRequest" STATUS = "status" STATUS_OK = "OK" + STATUS_FAIL = "FAIL" API_URL_SUFFIX_V1 = "/api/v1" API_URL_SUFFIX_V3 = "/api/v3" diff --git a/model/message.go b/model/message.go deleted file mode 100644 index 12f3be663..000000000 --- a/model/message.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -package model - -import ( - "encoding/json" - "io" -) - -const ( - ACTION_TYPING = "typing" - ACTION_POSTED = "posted" - ACTION_POST_EDITED = "post_edited" - ACTION_POST_DELETED = "post_deleted" - ACTION_CHANNEL_DELETED = "channel_deleted" - ACTION_CHANNEL_VIEWED = "channel_viewed" - ACTION_DIRECT_ADDED = "direct_added" - ACTION_NEW_USER = "new_user" - ACTION_LEAVE_TEAM = "leave_team" - ACTION_USER_ADDED = "user_added" - ACTION_USER_REMOVED = "user_removed" - ACTION_PREFERENCE_CHANGED = "preference_changed" - ACTION_EPHEMERAL_MESSAGE = "ephemeral_message" -) - -type Message struct { - TeamId string `json:"team_id"` - ChannelId string `json:"channel_id"` - UserId string `json:"user_id"` - Action string `json:"action"` - Props map[string]string `json:"props"` -} - -func (m *Message) Add(key string, value string) { - m.Props[key] = value -} - -func NewMessage(teamId string, channelId string, userId string, action string) *Message { - return &Message{TeamId: teamId, ChannelId: channelId, UserId: userId, Action: action, Props: make(map[string]string)} -} - -func (o *Message) ToJson() string { - b, err := json.Marshal(o) - if err != nil { - return "" - } else { - return string(b) - } -} - -func MessageFromJson(data io.Reader) *Message { - decoder := json.NewDecoder(data) - var o Message - err := decoder.Decode(&o) - if err == nil { - return &o - } else { - return nil - } -} diff --git a/model/message_test.go b/model/message_test.go deleted file mode 100644 index 182678d8e..000000000 --- a/model/message_test.go +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -package model - -import ( - "strings" - "testing" -) - -func TestMessgaeJson(t *testing.T) { - m := NewMessage(NewId(), NewId(), NewId(), ACTION_TYPING) - m.Add("RootId", NewId()) - json := m.ToJson() - result := MessageFromJson(strings.NewReader(json)) - - if m.TeamId != result.TeamId { - t.Fatal("Ids do not match") - } - - if m.Props["RootId"] != result.Props["RootId"] { - t.Fatal("Ids do not match") - } -} diff --git a/model/utils.go b/model/utils.go index 27ab3e27e..a4a4208c2 100644 --- a/model/utils.go +++ b/model/utils.go @@ -34,12 +34,12 @@ type EncryptStringMap map[string]string type AppError struct { Id string `json:"id"` - Message string `json:"message"` // Message to be display to the end user without debugging information - DetailedError string `json:"detailed_error"` // Internal error string to help the developer - RequestId string `json:"request_id"` // The RequestId that's also set in the header - StatusCode int `json:"status_code"` // The http status code - Where string `json:"-"` // The function where it happened in the form of Struct.Func - IsOAuth bool `json:"is_oauth"` // Whether the error is OAuth specific + Message string `json:"message"` // Message to be display to the end user without debugging information + DetailedError string `json:"detailed_error"` // Internal error string to help the developer + RequestId string `json:"request_id,omitempty"` // The RequestId that's also set in the header + StatusCode int `json:"status_code,omitempty"` // The http status code + Where string `json:"-"` // The function where it happened in the form of Struct.Func + IsOAuth bool `json:"is_oauth,omitempty"` // Whether the error is OAuth specific params map[string]interface{} `json:"-"` } diff --git a/model/websocket_client.go b/model/websocket_client.go new file mode 100644 index 000000000..7b9dc0b50 --- /dev/null +++ b/model/websocket_client.go @@ -0,0 +1,102 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "github.com/gorilla/websocket" + "net/http" +) + +type WebSocketClient struct { + Url string // The location of the server like "ws://localhost:8065" + ApiUrl string // The api location of the server like "ws://localhost:8065/api/v3" + Conn *websocket.Conn // The WebSocket connection + AuthToken string // The token used to open the WebSocket + Sequence int64 // The ever-incrementing sequence attached to each WebSocket action + EventChannel chan *WebSocketEvent + ResponseChannel chan *WebSocketResponse +} + +// NewWebSocketClient constructs a new WebSocket client with convienence +// methods for talking to the server. +func NewWebSocketClient(url, authToken string) (*WebSocketClient, *AppError) { + header := http.Header{} + header.Set(HEADER_AUTH, "BEARER "+authToken) + conn, _, err := websocket.DefaultDialer.Dial(url+API_URL_SUFFIX+"/users/websocket", header) + if err != nil { + return nil, NewLocAppError("NewWebSocketClient", "model.websocket_client.connect_fail.app_error", nil, err.Error()) + } + + return &WebSocketClient{ + url, + url + API_URL_SUFFIX, + conn, + authToken, + 1, + make(chan *WebSocketEvent, 100), + make(chan *WebSocketResponse, 100), + }, nil +} + +func (wsc *WebSocketClient) Connect() *AppError { + header := http.Header{} + header.Set(HEADER_AUTH, "BEARER "+wsc.AuthToken) + + var err error + wsc.Conn, _, err = websocket.DefaultDialer.Dial(wsc.ApiUrl+"/users/websocket", header) + if err != nil { + return NewLocAppError("NewWebSocketClient", "model.websocket_client.connect_fail.app_error", nil, err.Error()) + } + + return nil +} + +func (wsc *WebSocketClient) Close() { + wsc.Conn.Close() +} + +func (wsc *WebSocketClient) Listen() { + go func() { + for { + var rawMsg json.RawMessage + var err error + if _, rawMsg, err = wsc.Conn.ReadMessage(); err != nil { + return + } + + var event WebSocketEvent + if err := json.Unmarshal(rawMsg, &event); err == nil && event.IsValid() { + wsc.EventChannel <- &event + continue + } + + var response WebSocketResponse + if err := json.Unmarshal(rawMsg, &response); err == nil && response.IsValid() { + wsc.ResponseChannel <- &response + continue + } + } + }() +} + +func (wsc *WebSocketClient) SendMessage(action string, data map[string]interface{}) { + req := &WebSocketRequest{} + req.Seq = wsc.Sequence + req.Action = action + req.Data = data + + wsc.Sequence++ + + wsc.Conn.WriteJSON(req) +} + +func (wsc *WebSocketClient) UserTyping(channelId, parentId string) { + data := map[string]interface{}{ + "channel_id": channelId, + "parent_id": parentId, + } + + wsc.SendMessage("user_typing", data) +} diff --git a/model/websocket_message.go b/model/websocket_message.go new file mode 100644 index 000000000..ae9a140c3 --- /dev/null +++ b/model/websocket_message.go @@ -0,0 +1,114 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +const ( + WEBSOCKET_EVENT_TYPING = "typing" + WEBSOCKET_EVENT_POSTED = "posted" + WEBSOCKET_EVENT_POST_EDITED = "post_edited" + WEBSOCKET_EVENT_POST_DELETED = "post_deleted" + WEBSOCKET_EVENT_CHANNEL_DELETED = "channel_deleted" + WEBSOCKET_EVENT_CHANNEL_VIEWED = "channel_viewed" + WEBSOCKET_EVENT_DIRECT_ADDED = "direct_added" + WEBSOCKET_EVENT_NEW_USER = "new_user" + WEBSOCKET_EVENT_LEAVE_TEAM = "leave_team" + WEBSOCKET_EVENT_USER_ADDED = "user_added" + WEBSOCKET_EVENT_USER_REMOVED = "user_removed" + WEBSOCKET_EVENT_PREFERENCE_CHANGED = "preference_changed" + WEBSOCKET_EVENT_EPHEMERAL_MESSAGE = "ephemeral_message" + WEBSOCKET_EVENT_STATUS_CHANGE = "status_change" +) + +type WebSocketMessage interface { + ToJson() string + IsValid() bool +} + +type WebSocketEvent struct { + TeamId string `json:"team_id"` + ChannelId string `json:"channel_id"` + UserId string `json:"user_id"` + Event string `json:"event"` + Data map[string]interface{} `json:"data"` +} + +func (m *WebSocketEvent) Add(key string, value interface{}) { + m.Data[key] = value +} + +func NewWebSocketEvent(teamId string, channelId string, userId string, event string) *WebSocketEvent { + return &WebSocketEvent{TeamId: teamId, ChannelId: channelId, UserId: userId, Event: event, Data: make(map[string]interface{})} +} + +func (o *WebSocketEvent) IsValid() bool { + return o.Event != "" +} + +func (o *WebSocketEvent) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func WebSocketEventFromJson(data io.Reader) *WebSocketEvent { + decoder := json.NewDecoder(data) + var o WebSocketEvent + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} + +type WebSocketResponse struct { + Status string `json:"status"` + SeqReply int64 `json:"seq_reply,omitempty"` + Data map[string]interface{} `json:"data,omitempty"` + Error *AppError `json:"error,omitempty"` +} + +func (m *WebSocketResponse) Add(key string, value interface{}) { + m.Data[key] = value +} + +func NewWebSocketResponse(status string, seqReply int64, data map[string]interface{}) *WebSocketResponse { + return &WebSocketResponse{Status: status, SeqReply: seqReply, Data: data} +} + +func NewWebSocketError(seqReply int64, err *AppError) *WebSocketResponse { + return &WebSocketResponse{Status: STATUS_FAIL, SeqReply: seqReply, Error: err} +} + +func (o *WebSocketResponse) IsValid() bool { + return o.Status != "" +} + +func (o *WebSocketResponse) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func WebSocketResponseFromJson(data io.Reader) *WebSocketResponse { + decoder := json.NewDecoder(data) + var o WebSocketResponse + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} diff --git a/model/websocket_message_test.go b/model/websocket_message_test.go new file mode 100644 index 000000000..cbc564b6c --- /dev/null +++ b/model/websocket_message_test.go @@ -0,0 +1,56 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "strings" + "testing" +) + +func TestWebSocketEvent(t *testing.T) { + m := NewWebSocketEvent(NewId(), NewId(), NewId(), "some_event") + m.Add("RootId", NewId()) + json := m.ToJson() + result := WebSocketEventFromJson(strings.NewReader(json)) + + badresult := WebSocketEventFromJson(strings.NewReader("junk")) + if badresult != nil { + t.Fatal("should not have parsed") + } + + if !m.IsValid() { + t.Fatal("should be valid") + } + + if m.TeamId != result.TeamId { + t.Fatal("Ids do not match") + } + + if m.Data["RootId"] != result.Data["RootId"] { + t.Fatal("Ids do not match") + } +} + +func TestWebSocketResponse(t *testing.T) { + m := NewWebSocketResponse("OK", 1, map[string]interface{}{}) + e := NewWebSocketError(1, &AppError{}) + m.Add("RootId", NewId()) + json := m.ToJson() + result := WebSocketResponseFromJson(strings.NewReader(json)) + json2 := e.ToJson() + WebSocketResponseFromJson(strings.NewReader(json2)) + + badresult := WebSocketResponseFromJson(strings.NewReader("junk")) + if badresult != nil { + t.Fatal("should not have parsed") + } + + if !m.IsValid() { + t.Fatal("should be valid") + } + + if m.Data["RootId"] != result.Data["RootId"] { + t.Fatal("Ids do not match") + } +} diff --git a/model/websocket_request.go b/model/websocket_request.go new file mode 100644 index 000000000..d0f35f68b --- /dev/null +++ b/model/websocket_request.go @@ -0,0 +1,43 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" + + goi18n "github.com/nicksnyder/go-i18n/i18n" +) + +type WebSocketRequest struct { + // Client-provided fields + Seq int64 `json:"seq"` + Action string `json:"action"` + Data map[string]interface{} `json:"data"` + + // Server-provided fields + Session Session `json:"-"` + T goi18n.TranslateFunc `json:"-"` + Locale string `json:"-"` +} + +func (o *WebSocketRequest) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func WebSocketRequestFromJson(data io.Reader) *WebSocketRequest { + decoder := json.NewDecoder(data) + var o WebSocketRequest + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} diff --git a/model/websocket_request_test.go b/model/websocket_request_test.go new file mode 100644 index 000000000..52de82069 --- /dev/null +++ b/model/websocket_request_test.go @@ -0,0 +1,25 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "strings" + "testing" +) + +func TestWebSocketRequest(t *testing.T) { + m := WebSocketRequest{Seq: 1, Action: "test"} + json := m.ToJson() + result := WebSocketRequestFromJson(strings.NewReader(json)) + + if result == nil { + t.Fatal("should not be nil") + } + + badresult := WebSocketRequestFromJson(strings.NewReader("junk")) + + if badresult != nil { + t.Fatal("should have been nil") + } +} diff --git a/webapp/actions/global_actions.jsx b/webapp/actions/global_actions.jsx index d9b89f987..8d90b226d 100644 --- a/webapp/actions/global_actions.jsx +++ b/webapp/actions/global_actions.jsx @@ -12,7 +12,6 @@ import TeamStore from 'stores/team_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import SearchStore from 'stores/search_store.jsx'; -import * as Websockets from 'actions/websocket_actions.jsx'; import {handleNewPost} from 'actions/post_actions.jsx'; import Constants from 'utils/constants.jsx'; @@ -20,6 +19,7 @@ const ActionTypes = Constants.ActionTypes; import Client from 'utils/web_client.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; +import WebSocketClient from 'utils/websocket_client.jsx'; import * as Utils from 'utils/utils.jsx'; import en from 'i18n/en.json'; @@ -439,7 +439,7 @@ var lastTimeTypingSent = 0; export function emitLocalUserTypingEvent(channelId, parentId) { const t = Date.now(); if ((t - lastTimeTypingSent) > Constants.UPDATE_TYPING_MS) { - Websockets.sendMessage({channel_id: channelId, action: 'typing', props: {parent_id: parentId}, state: {}}); + WebSocketClient.userTyping(channelId, parentId); lastTimeTypingSent = t; } } diff --git a/webapp/actions/websocket_actions.jsx b/webapp/actions/websocket_actions.jsx index 7be9d84f3..e6997b9cc 100644 --- a/webapp/actions/websocket_actions.jsx +++ b/webapp/actions/websocket_actions.jsx @@ -11,6 +11,7 @@ import ErrorStore from 'stores/error_store.jsx'; import NotificationStore from 'stores/notification_store.jsx'; //eslint-disable-line no-unused-vars 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'; @@ -23,16 +24,9 @@ const SocketEvents = Constants.SocketEvents; import {browserHistory} from 'react-router/es6'; const MAX_WEBSOCKET_FAILS = 7; -const MIN_WEBSOCKET_RETRY_TIME = 3000; // 3 sec -const MAX_WEBSOCKET_RETRY_TIME = 300000; // 5 mins - -var conn = null; -var connectFailCount = 0; -var pastFirstInit = false; -var manuallyClosed = false; export function initialize() { - if (window.WebSocket && !conn) { + if (window.WebSocket) { let protocol = 'ws://'; if (window.location.protocol === 'https:') { protocol = 'wss://'; @@ -40,85 +34,35 @@ export function initialize() { const connUrl = protocol + location.host + ((/:\d+/).test(location.host) ? '' : Utils.getWebsocketPort(protocol)) + Client.getUsersRoute() + '/websocket'; - if (connectFailCount === 0) { - console.log('websocket connecting to ' + connUrl); //eslint-disable-line no-console - } - - manuallyClosed = false; - - conn = new WebSocket(connUrl); - - conn.onopen = () => { - if (connectFailCount > 0) { - console.log('websocket re-established connection'); //eslint-disable-line no-console - AsyncClient.getChannels(); - AsyncClient.getPosts(ChannelStore.getCurrentId()); - } - - if (pastFirstInit) { - ErrorStore.clearLastError(); - ErrorStore.emitChange(); - } - - pastFirstInit = true; - connectFailCount = 0; - }; - - conn.onclose = () => { - conn = null; - - if (connectFailCount === 0) { - console.log('websocket closed'); //eslint-disable-line no-console - } - - if (manuallyClosed) { - return; - } - - connectFailCount = connectFailCount + 1; - - var retryTime = MIN_WEBSOCKET_RETRY_TIME; - - if (connectFailCount > MAX_WEBSOCKET_FAILS) { - ErrorStore.storeLastError({message: Utils.localizeMessage('channel_loader.socketError', 'Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.')}); - - // If we've failed a bunch of connections then start backing off - retryTime = MIN_WEBSOCKET_RETRY_TIME * connectFailCount * connectFailCount; - if (retryTime > MAX_WEBSOCKET_RETRY_TIME) { - retryTime = MAX_WEBSOCKET_RETRY_TIME; - } - } - - ErrorStore.setConnectionErrorCount(connectFailCount); - ErrorStore.emitChange(); - - setTimeout( - () => { - initialize(); - }, - retryTime - ); - }; - - conn.onerror = (evt) => { - if (connectFailCount <= 1) { - console.log('websocket error'); //eslint-disable-line no-console - console.log(evt); //eslint-disable-line no-console - } - }; - - conn.onmessage = (evt) => { - const msg = JSON.parse(evt.data); - handleMessage(msg); - }; + WebSocketClient.initialize(connUrl); + WebSocketClient.setEventCallback(handleEvent); + WebSocketClient.setReconnectCallback(handleReconnect); + WebSocketClient.setCloseCallback(handleClose); } } -function handleMessage(msg) { - // Let the store know we are online. This probably shouldn't be here. - UserStore.setStatus(msg.user_id, 'online'); +export function close() { + WebSocketClient.close(); +} + +function handleReconnect() { + AsyncClient.getChannels(); + AsyncClient.getPosts(ChannelStore.getCurrentId()); + ErrorStore.clearLastError(); + ErrorStore.emitChange(); +} + +function handleClose(failCount) { + if (failCount > MAX_WEBSOCKET_FAILS) { + ErrorStore.storeLastError({message: Utils.localizeMessage('channel_loader.socketError', 'Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.')}); + } + + ErrorStore.setConnectionErrorCount(failCount); + ErrorStore.emitChange(); +} - switch (msg.action) { +function handleEvent(msg) { + switch (msg.event) { case SocketEvents.POSTED: case SocketEvents.EPHEMERAL_MESSAGE: handleNewPostEvent(msg); @@ -172,36 +116,14 @@ function handleMessage(msg) { } } -export function sendMessage(msg) { - if (conn && conn.readyState === WebSocket.OPEN) { - var teamId = TeamStore.getCurrentId(); - if (teamId && teamId.length > 0) { - msg.team_id = teamId; - } - - conn.send(JSON.stringify(msg)); - } else if (!conn || conn.readyState === WebSocket.Closed) { - conn = null; - initialize(); - } -} - -export function close() { - manuallyClosed = true; - connectFailCount = 0; - if (conn && conn.readyState === WebSocket.OPEN) { - conn.close(); - } -} - function handleNewPostEvent(msg) { - const post = JSON.parse(msg.props.post); + const post = JSON.parse(msg.data.post); handleNewPost(post, msg); } function handlePostEditEvent(msg) { // Store post - const post = JSON.parse(msg.props.post); + const post = JSON.parse(msg.data.post); PostStore.storePost(post); PostStore.emitChange(); @@ -214,7 +136,7 @@ function handlePostEditEvent(msg) { } function handlePostDeleteEvent(msg) { - const post = JSON.parse(msg.props.post); + const post = JSON.parse(msg.data.post); GlobalActions.emitPostDeletedEvent(post); } @@ -257,12 +179,12 @@ function handleUserRemovedEvent(msg) { if (UserStore.getCurrentId() === msg.user_id) { AsyncClient.getChannels(); - if (msg.props.remover_id !== msg.user_id && + if (msg.data.remover_id !== msg.user_id && msg.channel_id === ChannelStore.getCurrentId() && $('#removed_from_channel').length > 0) { var sentState = {}; sentState.channelName = ChannelStore.getCurrent().display_name; - sentState.remover = UserStore.getProfile(msg.props.remover_id).username; + sentState.remover = UserStore.getProfile(msg.data.remover_id).username; BrowserStore.setItem('channel-removed-state', sentState); $('#removed_from_channel').modal('show'); @@ -290,12 +212,10 @@ function handleChannelDeletedEvent(msg) { } function handlePreferenceChangedEvent(msg) { - const preference = JSON.parse(msg.props.preference); + const preference = JSON.parse(msg.data.preference); GlobalActions.emitPreferenceChangedEvent(preference); } function handleUserTypingEvent(msg) { - if (TeamStore.getCurrentId() === msg.team_id) { - GlobalActions.emitRemoteUserTypingEvent(msg.channel_id, msg.user_id, msg.props.parent_id); - } + GlobalActions.emitRemoteUserTypingEvent(msg.channel_id, msg.user_id, msg.data.parent_id); } diff --git a/webapp/package.json b/webapp/package.json index 3db9d0794..984affd08 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#5815f14f0d1960aa4c99797b09d949d2959eb24f", + "mattermost": "mattermost/mattermost-javascript#4cdaeba22ff82bf93dc417af1ab4e89e3248d624", "object-assign": "4.1.0", "perfect-scrollbar": "0.6.11", "react": "15.0.2", diff --git a/webapp/utils/websocket_client.jsx b/webapp/utils/websocket_client.jsx new file mode 100644 index 000000000..135d96466 --- /dev/null +++ b/webapp/utils/websocket_client.jsx @@ -0,0 +1,7 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import WebSocketClient from 'mattermost/websocket_client.jsx'; + +var WebClient = new WebSocketClient(); +export default WebClient; diff --git a/webapp/webpack.config.js b/webapp/webpack.config.js index 2911c0c7d..88635ef03 100644 --- a/webapp/webpack.config.js +++ b/webapp/webpack.config.js @@ -53,6 +53,15 @@ var config = { } }, { + test: /node_modules\/mattermost\/websocket_client\.jsx?$/, + loader: 'babel', + query: { + presets: ['react', 'es2015-webpack', 'stage-0'], + plugins: ['transform-runtime'], + cacheDirectory: DEV + } + }, + { test: /\.json$/, loader: 'json' }, |