summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--api/api.go4
-rw-r--r--api/apitestlib.go4
-rw-r--r--api/channel.go10
-rw-r--r--api/command_expand_collapse.go2
-rw-r--r--api/general.go6
-rw-r--r--api/post.go12
-rw-r--r--api/team.go4
-rw-r--r--api/user.go23
-rw-r--r--api/user_test.go82
-rw-r--r--api/web_conn.go35
-rw-r--r--api/web_hub.go21
-rw-r--r--api/web_socket_test.go103
-rw-r--r--api/webhook_test.go10
-rw-r--r--api/websocket.go (renamed from api/web_socket.go)2
-rw-r--r--api/websocket_handler.go42
-rw-r--r--api/websocket_router.go59
-rw-r--r--api/websocket_test.go144
-rw-r--r--i18n/en.json14
-rw-r--r--model/client.go1
-rw-r--r--model/message.go61
-rw-r--r--model/message_test.go24
-rw-r--r--model/utils.go12
-rw-r--r--model/websocket_client.go102
-rw-r--r--model/websocket_message.go114
-rw-r--r--model/websocket_message_test.go56
-rw-r--r--model/websocket_request.go43
-rw-r--r--model/websocket_request_test.go25
-rw-r--r--webapp/actions/global_actions.jsx4
-rw-r--r--webapp/actions/websocket_actions.jsx148
-rw-r--r--webapp/components/admin_console/webhook_settings.jsx6
-rw-r--r--webapp/components/post_view/components/post_list.jsx19
-rw-r--r--webapp/components/suggestion/switch_channel_provider.jsx8
-rw-r--r--webapp/components/team_members_dropdown.jsx2
-rw-r--r--webapp/i18n/en.json6
-rw-r--r--webapp/package.json2
-rw-r--r--webapp/sass/layout/_headers.scss4
-rw-r--r--webapp/utils/channel_intro_messages.jsx33
-rw-r--r--webapp/utils/websocket_client.jsx7
-rw-r--r--webapp/webpack.config.js9
39 files changed, 870 insertions, 393 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 7dd26efd6..c64315440 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
}
@@ -2548,3 +2550,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 d0a70c1c0..5fc0a99dc 100644
--- a/api/user_test.go
+++ b/api/user_test.go
@@ -1775,3 +1775,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 19c97bc95..4198b82af 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"
},
@@ -2321,7 +2333,7 @@
},
{
"id": "error.not_found.message",
- "translation": "The page you where trying to reach does not exist."
+ "translation": "The page you were trying to reach does not exist."
},
{
"id": "error.not_found.title",
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/components/admin_console/webhook_settings.jsx b/webapp/components/admin_console/webhook_settings.jsx
index 18a3ed7ad..3c8ea5466 100644
--- a/webapp/components/admin_console/webhook_settings.jsx
+++ b/webapp/components/admin_console/webhook_settings.jsx
@@ -64,7 +64,7 @@ export default class WebhookSettings extends AdminSettings {
helpText={
<FormattedMessage
id='admin.service.webhooksDescription'
- defaultMessage='When true, incoming webhooks will be allowed. To help combat phishing attacks, all posts from webhooks will be labelled by a BOT tag.'
+ defaultMessage='When true, incoming webhooks will be allowed. To help combat phishing attacks, all posts from webhooks will be labelled by a BOT tag. See <a href="http://docs.mattermost.com/developer/webhooks-incoming.html" target="_blank">documentation</a> to learn more.'
/>
}
value={this.state.enableIncomingWebhooks}
@@ -81,7 +81,7 @@ export default class WebhookSettings extends AdminSettings {
helpText={
<FormattedMessage
id='admin.service.outWebhooksDesc'
- defaultMessage='When true, outgoing webhooks will be allowed.'
+ defaultMessage='When true, outgoing webhooks will be allowed. See <a href="http://docs.mattermost.com/developer/webhooks-outgoing.html" target="_blank">documentation</a> to learn more.'
/>
}
value={this.state.enableOutgoingWebhooks}
@@ -98,7 +98,7 @@ export default class WebhookSettings extends AdminSettings {
helpText={
<FormattedMessage
id='admin.service.cmdsDesc'
- defaultMessage='When true, user created slash commands will be allowed.'
+ defaultMessage='When true, custom slash commands will be allowed. See <a href="http://docs.mattermost.com/developer/slash-commands.html" target="_blank">documentation</a> to learn more.'
/>
}
value={this.state.enableCommands}
diff --git a/webapp/components/post_view/components/post_list.jsx b/webapp/components/post_view/components/post_list.jsx
index 17e29da2e..690cd96c7 100644
--- a/webapp/components/post_view/components/post_list.jsx
+++ b/webapp/components/post_view/components/post_list.jsx
@@ -18,11 +18,15 @@ import DelayedAction from 'utils/delayed_action.jsx';
import Constants from 'utils/constants.jsx';
const ScrollTypes = Constants.ScrollTypes;
+import PreferenceStore from 'stores/preference_store.jsx';
+
import {FormattedDate, FormattedMessage} from 'react-intl';
import React from 'react';
import ReactDOM from 'react-dom';
+const Preferences = Constants.Preferences;
+
export default class PostList extends React.Component {
constructor(props) {
super(props);
@@ -44,16 +48,17 @@ export default class PostList extends React.Component {
this.scrollStopAction = new DelayedAction(this.handleScrollStop);
- if (props.channel) {
- this.introText = createChannelIntroMessage(props.channel);
- } else {
- this.introText = this.getArchivesIntroMessage();
- }
-
this.state = {
isScrolling: false,
+ fullWidthIntro: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CHANNEL_DISPLAY_MODE, Preferences.CHANNEL_DISPLAY_MODE_DEFAULT) === Preferences.CHANNEL_DISPLAY_MODE_FULL_SCREEN,
topPostId: null
};
+
+ if (props.channel) {
+ this.introText = createChannelIntroMessage(props.channel, this.state.fullWidthIntro);
+ } else {
+ this.introText = this.getArchivesIntroMessage();
+ }
}
isAtBottom() {
@@ -395,7 +400,7 @@ export default class PostList extends React.Component {
getArchivesIntroMessage() {
return (
- <div className='channel-intro'>
+ <div className={'channel-intro'}>
<h4 className='channel-intro__title'>
<FormattedMessage
id='post_focus_view.beginning'
diff --git a/webapp/components/suggestion/switch_channel_provider.jsx b/webapp/components/suggestion/switch_channel_provider.jsx
index c12918c51..e092d9b5c 100644
--- a/webapp/components/suggestion/switch_channel_provider.jsx
+++ b/webapp/components/suggestion/switch_channel_provider.jsx
@@ -57,7 +57,13 @@ export default class SwitchChannelProvider {
}
}
- channels.sort((a, b) => a.display_name.localeCompare(b.display_name));
+ channels.sort((a, b) => {
+ if (a.display_name === b.display_name) {
+ return a.name.localeCompare(b.name);
+ }
+ return a.display_name.localeCompare(b.display_name);
+ });
+
const channelNames = channels.map((channel) => channel.name);
SuggestionStore.addSuggestions(suggestionId, channelNames, channels, SwitchChannelSuggestion, channelPrefix);
diff --git a/webapp/components/team_members_dropdown.jsx b/webapp/components/team_members_dropdown.jsx
index 43449635d..f8c217aed 100644
--- a/webapp/components/team_members_dropdown.jsx
+++ b/webapp/components/team_members_dropdown.jsx
@@ -186,7 +186,7 @@ export default class TeamMembersDropdown extends React.Component {
}
const me = UserStore.getCurrentUser();
- let showMakeMember = teamMember.roles === 'admin' || user.roles === 'system_admin';
+ let showMakeMember = teamMember.roles === 'admin' && user.roles !== 'system_admin';
let showMakeAdmin = teamMember.roles === '' && user.roles !== 'system_admin';
let showMakeActive = false;
let showMakeNotActive = user.roles !== 'system_admin';
diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json
index 0d3080d03..f000dbf55 100644
--- a/webapp/i18n/en.json
+++ b/webapp/i18n/en.json
@@ -521,7 +521,7 @@
"admin.service.attemptDescription": "Number of login attempts allowed before a user is locked out and required to reset their password via email.",
"admin.service.attemptExample": "Ex \"10\"",
"admin.service.attemptTitle": "Maximum Login Attempts:",
- "admin.service.cmdsDesc": "When true, user created slash commands will be allowed.",
+ "admin.service.cmdsDesc": "When true, custom slash commands will be allowed. See <a href='http://docs.mattermost.com/developer/slash-commands.html' target='_blank'>documentation</a> to learn more.",
"admin.service.cmdsTitle": "Enable Custom Slash Commands: ",
"admin.service.corsDescription": "Enable HTTP Cross origin request from a specific domain. Use \"*\" if you want to allow CORS from any domain or leave it blank to disable it.",
"admin.service.corsEx": "http://example.com",
@@ -544,7 +544,7 @@
"admin.service.mfaTitle": "Enable Multi-factor Authentication:",
"admin.service.mobileSessionDays": "Session length for mobile apps (days):",
"admin.service.mobileSessionDaysDesc": "Mobile sessions will expire after the number of days specified and will require users to sign in again.",
- "admin.service.outWebhooksDesc": "When true, outgoing webhooks will be allowed.",
+ "admin.service.outWebhooksDesc": "When true, outgoing webhooks will be allowed. See <a href='http://docs.mattermost.com/developer/webhooks-outgoing.html' target='_blank'>documentation</a> to learn more.",
"admin.service.outWebhooksTitle": "Enable Outgoing Webhooks: ",
"admin.service.overrideDescription": "When true, webhooks and slash commands will be allowed to change the username they are posting as. Note, combined with allowing icon overriding, this could open users up to phishing attacks.",
"admin.service.overrideTitle": "Enable webhooks and slash commands to override usernames:",
@@ -562,7 +562,7 @@
"admin.service.testingTitle": "Enable Testing Commands: ",
"admin.service.webSessionDays": "Session length for email and LDAP authentication (days):",
"admin.service.webSessionDaysDesc": "Email or LDAP sessions will expire after the number of days specified and will require users to sign in again.",
- "admin.service.webhooksDescription": "When true, incoming webhooks will be allowed. To help combat phishing attacks, all posts from webhooks will be labelled by a BOT tag.",
+ "admin.service.webhooksDescription": "When true, incoming webhooks will be allowed. To help combat phishing attacks, all posts from webhooks will be labelled by a BOT tag. See <a href='http://docs.mattermost.com/developer/webhooks-incoming.html' target='_blank'>documentation</a> to learn more.",
"admin.service.webhooksTitle": "Enable Incoming Webhooks: ",
"admin.sidebar.addTeamSidebar": "Add team from sidebar menu",
"admin.sidebar.advanced": "Advanced",
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/sass/layout/_headers.scss b/webapp/sass/layout/_headers.scss
index 56f7cd6e8..1e7046340 100644
--- a/webapp/sass/layout/_headers.scss
+++ b/webapp/sass/layout/_headers.scss
@@ -162,6 +162,10 @@
margin: 0 auto 15px;
padding: 0 15px;
+ &.channel-intro--centered {
+ max-width: 1020px;
+ }
+
.intro-links {
display: inline-block;
margin: 0 1.5em 10px 0;
diff --git a/webapp/utils/channel_intro_messages.jsx b/webapp/utils/channel_intro_messages.jsx
index 50d12ed42..9a232dbc0 100644
--- a/webapp/utils/channel_intro_messages.jsx
+++ b/webapp/utils/channel_intro_messages.jsx
@@ -16,20 +16,25 @@ import Client from 'utils/web_client.jsx';
import React from 'react';
import {FormattedMessage, FormattedHTMLMessage, FormattedDate} from 'react-intl';
-export function createChannelIntroMessage(channel) {
+export function createChannelIntroMessage(channel, fullWidthIntro) {
+ let centeredIntro = '';
+ if (!fullWidthIntro) {
+ centeredIntro = 'channel-intro--centered';
+ }
+
if (channel.type === 'D') {
- return createDMIntroMessage(channel);
+ return createDMIntroMessage(channel, centeredIntro);
} else if (ChannelStore.isDefault(channel)) {
- return createDefaultIntroMessage(channel);
+ return createDefaultIntroMessage(channel, centeredIntro);
} else if (channel.name === Constants.OFFTOPIC_CHANNEL) {
- return createOffTopicIntroMessage(channel);
+ return createOffTopicIntroMessage(channel, centeredIntro);
} else if (channel.type === 'O' || channel.type === 'P') {
- return createStandardIntroMessage(channel);
+ return createStandardIntroMessage(channel, centeredIntro);
}
return null;
}
-export function createDMIntroMessage(channel) {
+export function createDMIntroMessage(channel, centeredIntro) {
var teammate = Utils.getDirectTeammate(channel.id);
if (teammate) {
@@ -39,7 +44,7 @@ export function createDMIntroMessage(channel) {
}
return (
- <div className='channel-intro'>
+ <div className={'channel-intro ' + centeredIntro}>
<div className='post-profile-img__container channel-intro-img'>
<img
className='post-profile-img'
@@ -68,7 +73,7 @@ export function createDMIntroMessage(channel) {
}
return (
- <div className='channel-intro'>
+ <div className={'channel-intro ' + centeredIntro}>
<p className='channel-intro-text'>
<FormattedMessage
id='intro_messages.teammate'
@@ -79,9 +84,9 @@ export function createDMIntroMessage(channel) {
);
}
-export function createOffTopicIntroMessage(channel) {
+export function createOffTopicIntroMessage(channel, centeredIntro) {
return (
- <div className='channel-intro'>
+ <div className={'channel-intro ' + centeredIntro}>
<FormattedHTMLMessage
id='intro_messages.offTopic'
defaultMessage='<h4 class="channel-intro__title">Beginning of {display_name}</h4><p class="channel-intro__content">This is the start of {display_name}, a channel for non-work-related conversations.<br/></p>'
@@ -95,7 +100,7 @@ export function createOffTopicIntroMessage(channel) {
);
}
-export function createDefaultIntroMessage(channel) {
+export function createDefaultIntroMessage(channel, centeredIntro) {
let inviteModalLink = (
<a
className='intro-links'
@@ -122,7 +127,7 @@ export function createDefaultIntroMessage(channel) {
}
return (
- <div className='channel-intro'>
+ <div className={'channel-intro ' + centeredIntro}>
<FormattedHTMLMessage
id='intro_messages.default'
defaultMessage="<h4 class='channel-intro__title'>Beginning of {display_name}</h4><p class='channel-intro__content'><strong>Welcome to {display_name}!</strong><br/><br/>This is the first channel teammates see when they sign up - use it for posting updates everyone needs to know.</p>"
@@ -137,7 +142,7 @@ export function createDefaultIntroMessage(channel) {
);
}
-export function createStandardIntroMessage(channel) {
+export function createStandardIntroMessage(channel, centeredIntro) {
var uiName = channel.display_name;
var creatorName = '';
@@ -211,7 +216,7 @@ export function createStandardIntroMessage(channel) {
}
return (
- <div className='channel-intro'>
+ <div className={'channel-intro ' + centeredIntro}>
<h4 className='channel-intro__title'>
<FormattedMessage
id='intro_messages.beginning'
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'
},