summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Makefile10
-rw-r--r--api/admin.go1
-rw-r--r--api/api.go4
-rw-r--r--api/apitestlib.go4
-rw-r--r--api/channel.go39
-rw-r--r--api/command.go4
-rw-r--r--api/command_expand_collapse.go2
-rw-r--r--api/command_msg.go2
-rw-r--r--api/command_shortcuts.go9
-rw-r--r--api/command_shortcuts_test.go7
-rw-r--r--api/export.go292
-rw-r--r--api/file.go18
-rw-r--r--api/general.go6
-rw-r--r--api/post.go12
-rw-r--r--api/preference.go28
-rw-r--r--api/preference_test.go46
-rw-r--r--api/team.go4
-rw-r--r--api/user.go41
-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--config/config.json2
-rw-r--r--glide.lock2
-rw-r--r--glide.yaml1
-rw-r--r--i18n/en.json160
-rw-r--r--model/channel.go3
-rw-r--r--model/client.go90
-rw-r--r--model/config.go52
-rw-r--r--model/message.go61
-rw-r--r--model/message_test.go24
-rw-r--r--model/post.go3
-rw-r--r--model/preference.go44
-rw-r--r--model/preference_test.go39
-rw-r--r--model/team.go3
-rw-r--r--model/user.go32
-rw-r--r--model/user_test.go13
-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--store/sql_channel_store.go74
-rw-r--r--store/sql_post_store.go24
-rw-r--r--store/sql_preference_store.go27
-rw-r--r--store/sql_preference_store_test.go27
-rw-r--r--store/sql_team_store.go2
-rw-r--r--store/sql_user_store.go116
-rw-r--r--store/store.go5
-rw-r--r--templates/welcome_body.html2
-rw-r--r--vendor/github.com/kardianos/osext/LICENSE27
-rw-r--r--vendor/github.com/kardianos/osext/README.md16
-rw-r--r--vendor/github.com/kardianos/osext/osext.go33
-rw-r--r--vendor/github.com/kardianos/osext/osext_plan9.go20
-rw-r--r--vendor/github.com/kardianos/osext/osext_procfs.go36
-rw-r--r--vendor/github.com/kardianos/osext/osext_sysctl.go126
-rw-r--r--vendor/github.com/kardianos/osext/osext_test.go203
-rw-r--r--vendor/github.com/kardianos/osext/osext_windows.go34
-rw-r--r--web/web.go12
-rw-r--r--webapp/.eslintrc.json13
-rw-r--r--webapp/Makefile2
-rw-r--r--webapp/actions/channel_actions.jsx26
-rw-r--r--webapp/actions/global_actions.jsx4
-rw-r--r--webapp/actions/post_actions.jsx54
-rw-r--r--webapp/actions/user_actions.jsx56
-rw-r--r--webapp/actions/websocket_actions.jsx149
-rw-r--r--webapp/components/about_build_modal.jsx7
-rw-r--r--webapp/components/admin_console/admin_settings.jsx5
-rw-r--r--webapp/components/admin_console/custom_brand_settings.jsx2
-rw-r--r--webapp/components/admin_console/external_service_settings.jsx2
-rw-r--r--webapp/components/admin_console/team_users.jsx2
-rw-r--r--webapp/components/admin_console/text_setting.jsx6
-rw-r--r--webapp/components/admin_console/user_item.jsx7
-rw-r--r--webapp/components/admin_console/webhook_settings.jsx6
-rw-r--r--webapp/components/channel_info_modal.jsx46
-rw-r--r--webapp/components/channel_switch_modal.jsx8
-rw-r--r--webapp/components/create_comment.jsx69
-rw-r--r--webapp/components/create_post.jsx58
-rw-r--r--webapp/components/edit_post_modal.jsx125
-rw-r--r--webapp/components/emoji/components/add_emoji.jsx5
-rw-r--r--webapp/components/file_upload.jsx3
-rw-r--r--webapp/components/form_error.jsx11
-rw-r--r--webapp/components/integrations/components/add_command.jsx7
-rw-r--r--webapp/components/integrations/components/add_incoming_webhook.jsx5
-rw-r--r--webapp/components/integrations/components/add_outgoing_webhook.jsx5
-rw-r--r--webapp/components/invite_member_modal.jsx10
-rw-r--r--webapp/components/logged_in.jsx12
-rw-r--r--webapp/components/msg_typing.jsx2
-rw-r--r--webapp/components/navbar.jsx2
-rw-r--r--webapp/components/needs_team.jsx37
-rw-r--r--webapp/components/new_channel_flow.jsx6
-rw-r--r--webapp/components/new_channel_modal.jsx44
-rw-r--r--webapp/components/post_view/components/post.jsx9
-rw-r--r--webapp/components/post_view/components/post_list.jsx35
-rw-r--r--webapp/components/post_view/post_view_controller.jsx14
-rw-r--r--webapp/components/removed_from_channel_modal.jsx2
-rw-r--r--webapp/components/rename_channel_modal.jsx2
-rw-r--r--webapp/components/search_bar.jsx51
-rw-r--r--webapp/components/setting_item_max.jsx4
-rw-r--r--webapp/components/suggestion/suggestion_box.jsx52
-rw-r--r--webapp/components/suggestion/suggestion_list.jsx2
-rw-r--r--webapp/components/suggestion/switch_channel_provider.jsx8
-rw-r--r--webapp/components/team_export_tab.jsx127
-rw-r--r--webapp/components/team_general_tab.jsx2
-rw-r--r--webapp/components/team_members_dropdown.jsx2
-rw-r--r--webapp/components/team_settings.jsx8
-rw-r--r--webapp/components/team_settings_modal.jsx7
-rw-r--r--webapp/components/textbox.jsx14
-rw-r--r--webapp/components/user_settings/import_theme_modal.jsx91
-rw-r--r--webapp/components/user_settings/premade_theme_chooser.jsx16
-rw-r--r--webapp/components/user_settings/user_settings_general.jsx13
-rw-r--r--webapp/components/user_settings/user_settings_theme.jsx142
-rw-r--r--webapp/dispatcher/app_dispatcher.jsx4
-rw-r--r--webapp/i18n/en.json11
-rw-r--r--webapp/i18n/i18n.jsx10
-rw-r--r--webapp/package.json70
-rw-r--r--webapp/root.html3
-rw-r--r--webapp/sass/components/_modal.scss2
-rw-r--r--webapp/sass/layout/_headers.scss4
-rw-r--r--webapp/sass/layout/_post-right.scss234
-rw-r--r--webapp/sass/layout/_post.scss2
-rw-r--r--webapp/sass/layout/_sidebar-left.scss4
-rw-r--r--webapp/sass/responsive/_mobile.scss54
-rw-r--r--webapp/sass/routes/_settings.scss16
-rw-r--r--webapp/stores/channel_store.jsx16
-rw-r--r--webapp/stores/preference_store.jsx32
-rw-r--r--webapp/utils/async_client.jsx60
-rw-r--r--webapp/utils/channel_intro_messages.jsx37
-rw-r--r--webapp/utils/constants.jsx237
-rw-r--r--webapp/utils/text_formatting.jsx21
-rw-r--r--webapp/utils/utils.jsx10
-rw-r--r--webapp/utils/websocket_client.jsx7
-rw-r--r--webapp/webpack.config.js29
139 files changed, 2658 insertions, 2294 deletions
diff --git a/Makefile b/Makefile
index fdf3e4a68..7dcc79e5c 100644
--- a/Makefile
+++ b/Makefile
@@ -181,14 +181,19 @@ ifeq ($(BUILD_ENTERPRISE_READY),true)
$(GO) test $(GOFLAGS) -run=$(TESTS) -covermode=count -c ./enterprise/ldap && ./ldap.test -test.v -test.timeout=120s -test.coverprofile=cldap.out || exit 1
$(GO) test $(GOFLAGS) -run=$(TESTS) -covermode=count -c ./enterprise/compliance && ./compliance.test -test.v -test.timeout=120s -test.coverprofile=ccompliance.out || exit 1
$(GO) test $(GOFLAGS) -run=$(TESTS) -covermode=count -c ./enterprise/emoji && ./emoji.test -test.v -test.timeout=120s -test.coverprofile=cemoji.out || exit 1
+ $(GO) test $(GOFLAGS) -run=$(TESTS) -covermode=count -c ./enterprise/saml && ./saml.test -test.v -test.timeout=60s -test.coverprofile=csaml.out || exit 1
tail -n +2 cldap.out >> ecover.out
tail -n +2 ccompliance.out >> ecover.out
tail -n +2 cemoji.out >> ecover.out
- rm -f cldap.out ccompliance.out cemoji.out
+ tail -n +2 csaml.out >> ecover.out
+ rm -f cldap.out ccompliance.out cemoji.out csaml.out
rm -r ldap.test
rm -r compliance.test
rm -r emoji.test
+ rm -r saml.test
+ rm -f config/*.crt
+ rm -f config/*.key
endif
internal-test-web-client: start-docker prepare-enterprise
@@ -263,9 +268,6 @@ package: build build-client
@# Package webapp
mkdir -p $(DIST_PATH)/webapp/dist
cp -RL $(BUILD_WEBAPP_DIR)/dist $(DIST_PATH)/webapp
- mv $(DIST_PATH)/webapp/dist/bundle.js $(DIST_PATH)/webapp/dist/bundle-$(BUILD_NUMBER).js
- sed -i'.bak' 's|bundle.js|bundle-$(BUILD_NUMBER).js|g' $(DIST_PATH)/webapp/dist/root.html
- rm $(DIST_PATH)/webapp/dist/root.html.bak
@# Help files
ifeq ($(BUILD_ENTERPRISE_READY),true)
diff --git a/api/admin.go b/api/admin.go
index d7d07c0d1..ca7961753 100644
--- a/api/admin.go
+++ b/api/admin.go
@@ -552,7 +552,6 @@ func adminResetPassword(c *Context, w http.ResponseWriter, r *http.Request) {
newPassword := props["new_password"]
if err := utils.IsPasswordValid(newPassword); err != nil {
c.Err = err
- c.SetInvalidParam("adminResetPassword", "new_password")
return
}
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..3fef273e5 100644
--- a/api/channel.go
+++ b/api/channel.go
@@ -44,6 +44,7 @@ func InitChannel() {
BaseRoutes.NeedChannel.Handle("/add", ApiUserRequired(addMember)).Methods("POST")
BaseRoutes.NeedChannel.Handle("/remove", ApiUserRequired(removeMember)).Methods("POST")
BaseRoutes.NeedChannel.Handle("/update_last_viewed_at", ApiUserRequired(updateLastViewedAt)).Methods("POST")
+ BaseRoutes.NeedChannel.Handle("/set_last_viewed_at", ApiUserRequired(setLastViewedAt)).Methods("POST")
}
func createChannel(c *Context, w http.ResponseWriter, r *http.Request) {
@@ -158,7 +159,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 +588,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 +773,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{
@@ -791,6 +792,34 @@ func deleteChannel(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
+func setLastViewedAt(c *Context, w http.ResponseWriter, r *http.Request) {
+ params := mux.Vars(r)
+ id := params["channel_id"]
+
+ data := model.StringInterfaceFromJson(r.Body)
+ newLastViewedAt := int64(data["last_viewed_at"].(float64))
+
+ Srv.Store.Channel().SetLastViewedAt(id, c.Session.UserId, newLastViewedAt)
+
+ preference := model.Preference{
+ UserId: c.Session.UserId,
+ Category: model.PREFERENCE_CATEGORY_LAST,
+ Name: model.PREFERENCE_NAME_LAST_CHANNEL,
+ Value: id,
+ }
+
+ Srv.Store.Preference().Save(&model.Preferences{preference})
+
+ message := model.NewWebSocketEvent(c.TeamId, id, c.Session.UserId, model.WEBSOCKET_EVENT_CHANNEL_VIEWED)
+ message.Add("channel_id", id)
+
+ go Publish(message)
+
+ result := make(map[string]string)
+ result["id"] = id
+ w.Write([]byte(model.MapToJson(result)))
+}
+
func updateLastViewedAt(c *Context, w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
id := params["channel_id"]
@@ -806,7 +835,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 +1061,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.go b/api/command.go
index 857ae2e9a..1e58308ad 100644
--- a/api/command.go
+++ b/api/command.go
@@ -106,6 +106,7 @@ func executeCommand(c *Context, w http.ResponseWriter, r *http.Request) {
parts := strings.Split(command, " ")
trigger := parts[0][1:]
+ trigger = strings.ToLower(trigger)
message := strings.Join(parts[1:], " ")
provider := GetCommandProvider(trigger)
@@ -249,7 +250,7 @@ func handleResponse(c *Context, w http.ResponseWriter, response *model.CommandRe
if _, err := CreatePost(c, post, true); err != nil {
c.Err = model.NewLocAppError("command", "api.command.execute_command.save.app_error", nil, "")
}
- } else if response.ResponseType == model.COMMAND_RESPONSE_TYPE_EPHEMERAL {
+ } else if response.ResponseType == model.COMMAND_RESPONSE_TYPE_EPHEMERAL && response.Text != "" {
post.Message = response.Text
post.CreateAt = model.GetMillis()
SendEphemeralPost(
@@ -286,6 +287,7 @@ func createCommand(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
+ cmd.Trigger = strings.ToLower(cmd.Trigger)
cmd.CreatorId = c.Session.UserId
cmd.TeamId = c.TeamId
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/command_msg.go b/api/command_msg.go
index 7fd281077..d32a7ca5e 100644
--- a/api/command_msg.go
+++ b/api/command_msg.go
@@ -88,7 +88,7 @@ func (me *msgProvider) DoCommand(c *Context, channelId string, message string) *
}
}
- return &model.CommandResponse{GotoLocation: c.GetTeamURL() + "/channels/" + channelName, Text: c.T("api.command_msg.success"), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL}
+ return &model.CommandResponse{GotoLocation: c.GetTeamURL() + "/channels/" + channelName, Text: "", ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL}
}
}
}
diff --git a/api/command_shortcuts.go b/api/command_shortcuts.go
index 77f9f4441..0cdf821c8 100644
--- a/api/command_shortcuts.go
+++ b/api/command_shortcuts.go
@@ -5,6 +5,7 @@ package api
import (
"github.com/mattermost/platform/model"
+ "strings"
)
type ShortcutsProvider struct {
@@ -33,5 +34,11 @@ func (me *ShortcutsProvider) GetCommand(c *Context) *model.Command {
}
func (me *ShortcutsProvider) DoCommand(c *Context, channelId string, message string) *model.CommandResponse {
- return &model.CommandResponse{ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL, Text: c.T("api.command_shortcuts.list")}
+ stringId := "api.command_shortcuts.list"
+
+ if strings.Contains(message, "mac") {
+ stringId = "api.command_shortcuts.list_mac"
+ }
+
+ return &model.CommandResponse{ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL, Text: c.T(stringId)}
}
diff --git a/api/command_shortcuts_test.go b/api/command_shortcuts_test.go
index a00bd3440..01c56b465 100644
--- a/api/command_shortcuts_test.go
+++ b/api/command_shortcuts_test.go
@@ -15,7 +15,12 @@ func TestShortcutsCommand(t *testing.T) {
channel := th.BasicChannel
rs := Client.Must(Client.Command(channel.Id, "/shortcuts ", false)).Data.(*model.CommandResponse)
- if !strings.Contains(rs.Text, "ALT") {
+ if !strings.Contains(rs.Text, "CTRL") {
t.Fatal("failed to display shortcuts")
}
+
+ rs = Client.Must(Client.Command(channel.Id, "/shortcuts mac", false)).Data.(*model.CommandResponse)
+ if !strings.Contains(rs.Text, "CMD") {
+ t.Fatal("failed to display Mac shortcuts")
+ }
}
diff --git a/api/export.go b/api/export.go
deleted file mode 100644
index da066379f..000000000
--- a/api/export.go
+++ /dev/null
@@ -1,292 +0,0 @@
-// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
-// See License.txt for license information.
-
-package api
-
-import (
- "archive/zip"
- "encoding/json"
- "github.com/mattermost/platform/model"
- "github.com/mattermost/platform/utils"
- "io"
- "os"
-)
-
-const (
- EXPORT_PATH = "export/"
- EXPORT_FILENAME = "MattermostExport.zip"
- EXPORT_OPTIONS_FILE = "options.json"
- EXPORT_TEAMS_FOLDER = "teams"
- EXPORT_CHANNELS_FOLDER = "channels"
- EXPORT_CHANNEL_MEMBERS_FOLDER = "members"
- EXPORT_POSTS_FOLDER = "posts"
- EXPORT_USERS_FOLDER = "users"
- EXPORT_LOCAL_STORAGE_FOLDER = "files"
-)
-
-type ExportWriter interface {
- Create(name string) (io.Writer, error)
-}
-
-type ExportOptions struct {
- TeamsToExport []string `json:"teams"`
- ChannelsToExport []string `json:"channels"`
- UsersToExport []string `json:"users"`
- ExportLocalStorage bool `json:"export_local_storage"`
-}
-
-func (options *ExportOptions) ToJson() string {
- b, err := json.Marshal(options)
- if err != nil {
- return ""
- } else {
- return string(b)
- }
-}
-
-func ExportOptionsFromJson(data io.Reader) *ExportOptions {
- decoder := json.NewDecoder(data)
- var o ExportOptions
- decoder.Decode(&o)
- return &o
-}
-
-func ExportToFile(options *ExportOptions) (link string, err *model.AppError) {
- // Open file for export
- if file, err := openFileWriteStream(EXPORT_PATH + EXPORT_FILENAME); err != nil {
- return "", err
- } else {
- defer closeFileWriteStream(file)
- ExportToWriter(file, options)
- }
-
- return model.API_URL_SUFFIX + "/files/get_export", nil
-}
-
-func ExportToWriter(w io.Writer, options *ExportOptions) *model.AppError {
- // Open a writer to write to zip file
- zipWriter := zip.NewWriter(w)
- defer zipWriter.Close()
-
- // Write our options to file
- if optionsFile, err := zipWriter.Create(EXPORT_OPTIONS_FILE); err != nil {
- return model.NewLocAppError("ExportToWriter", "api.export.options.create.app_error", nil, err.Error())
- } else {
- if _, err := optionsFile.Write([]byte(options.ToJson())); err != nil {
- return model.NewLocAppError("ExportToWriter", "api.export.options.write.app_error", nil, err.Error())
- }
- }
-
- // Export Teams
- ExportTeams(zipWriter, options)
-
- return nil
-}
-
-func ExportTeams(writer ExportWriter, options *ExportOptions) *model.AppError {
- // Get the teams
- var teams []*model.Team
- if len(options.TeamsToExport) == 0 {
- if result := <-Srv.Store.Team().GetAll(); result.Err != nil {
- return result.Err
- } else {
- teams = result.Data.([]*model.Team)
- }
- } else {
- for _, teamId := range options.TeamsToExport {
- if result := <-Srv.Store.Team().Get(teamId); result.Err != nil {
- return result.Err
- } else {
- team := result.Data.(*model.Team)
- teams = append(teams, team)
- }
- }
- }
-
- // Export the teams
- for i := range teams {
- // Sanitize
- teams[i].PreExport()
-
- if teamFile, err := writer.Create(EXPORT_TEAMS_FOLDER + "/" + teams[i].Name + ".json"); err != nil {
- return model.NewLocAppError("ExportTeams", "api.export.open_file.app_error", nil, err.Error())
- } else {
- if _, err := teamFile.Write([]byte(teams[i].ToJson())); err != nil {
- return model.NewLocAppError("ExportTeams", "api.export.write_file.app_error", nil, err.Error())
- }
- }
-
- }
-
- // Export the channels, local storage and users
- for _, team := range teams {
- if err := ExportChannels(writer, options, team.Id); err != nil {
- return err
- }
- if err := ExportUsers(writer, options, team.Id); err != nil {
- return err
- }
- if err := ExportLocalStorage(writer, options, team.Id); err != nil {
- return err
- }
- }
-
- return nil
-}
-
-func ExportChannels(writer ExportWriter, options *ExportOptions, teamId string) *model.AppError {
- // Get the channels
- var channels []*model.Channel
- if len(options.ChannelsToExport) == 0 {
- if result := <-Srv.Store.Channel().GetForExport(teamId); result.Err != nil {
- return result.Err
- } else {
- channels = result.Data.([]*model.Channel)
- }
- } else {
- for _, channelId := range options.ChannelsToExport {
- if result := <-Srv.Store.Channel().Get(channelId); result.Err != nil {
- return result.Err
- } else {
- channel := result.Data.(*model.Channel)
- channels = append(channels, channel)
- }
- }
- }
-
- for i := range channels {
- // Get members
- mchan := Srv.Store.Channel().GetMembers(channels[i].Id)
-
- // Sanitize
- channels[i].PreExport()
-
- if channelFile, err := writer.Create(EXPORT_CHANNELS_FOLDER + "/" + channels[i].Id + ".json"); err != nil {
- return model.NewLocAppError("ExportChannels", "api.export.open_file.app_error", nil, err.Error())
- } else {
- if _, err := channelFile.Write([]byte(channels[i].ToJson())); err != nil {
- return model.NewLocAppError("ExportChannels", "api.export.write_file.app_error", nil, err.Error())
- }
- }
-
- var members []model.ChannelMember
- if result := <-mchan; result.Err != nil {
- return result.Err
- } else {
- members = result.Data.([]model.ChannelMember)
- }
-
- if membersFile, err := writer.Create(EXPORT_CHANNELS_FOLDER + "/" + channels[i].Id + "_members.json"); err != nil {
- return model.NewLocAppError("ExportChannels", "api.export.open_file.app_error", nil, err.Error())
- } else {
- result, err2 := json.Marshal(members)
- if err2 != nil {
- return model.NewLocAppError("ExportChannels", "api.export.json.app_error", nil, err.Error())
- }
- if _, err3 := membersFile.Write([]byte(result)); err3 != nil {
- return model.NewLocAppError("ExportChannels", "api.export.write_file.app_error", nil, err.Error())
- }
- }
- }
-
- for _, channel := range channels {
- if err := ExportPosts(writer, options, channel.Id); err != nil {
- return err
- }
- }
-
- return nil
-}
-
-func ExportPosts(writer ExportWriter, options *ExportOptions, channelId string) *model.AppError {
- // Get the posts
- var posts []*model.Post
- if result := <-Srv.Store.Post().GetForExport(channelId); result.Err != nil {
- return result.Err
- } else {
- posts = result.Data.([]*model.Post)
- }
-
- // Export the posts
- if postsFile, err := writer.Create(EXPORT_POSTS_FOLDER + "/" + channelId + "_posts.json"); err != nil {
- return model.NewLocAppError("ExportPosts", "api.export.open_file.app_error", nil, err.Error())
- } else {
- result, err2 := json.Marshal(posts)
- if err2 != nil {
- return model.NewLocAppError("ExportPosts", "api.export.json.app_error", nil, err.Error())
- }
- if _, err3 := postsFile.Write([]byte(result)); err3 != nil {
- return model.NewLocAppError("ExportPosts", "api.export.write_file.app_error", nil, err.Error())
- }
- }
-
- return nil
-}
-
-func ExportUsers(writer ExportWriter, options *ExportOptions, teamId string) *model.AppError {
- // Get the users
- var users []*model.User
- if result := <-Srv.Store.User().GetForExport(teamId); result.Err != nil {
- return result.Err
- } else {
- users = result.Data.([]*model.User)
- }
-
- // Write the users
- if usersFile, err := writer.Create(EXPORT_USERS_FOLDER + "/" + teamId + "_users.json"); err != nil {
- return model.NewLocAppError("ExportUsers", "api.export.open_file.app_error", nil, err.Error())
- } else {
- result, err2 := json.Marshal(users)
- if err2 != nil {
- return model.NewLocAppError("ExportUsers", "api.export.json.app_error", nil, err.Error())
- }
- if _, err3 := usersFile.Write([]byte(result)); err3 != nil {
- return model.NewLocAppError("ExportUsers", "api.export.write_file.app_error", nil, err.Error())
- }
- }
- return nil
-}
-
-func copyDirToExportWriter(writer ExportWriter, inPath string, outPath string) *model.AppError {
- dir, err := os.Open(inPath)
- if err != nil {
- return model.NewLocAppError("copyDirToExportWriter", "api.export.open_dir.app_error", nil, err.Error())
- }
-
- fileInfoList, err := dir.Readdir(0)
- if err != nil {
- return model.NewLocAppError("copyDirToExportWriter", "api.export.read_dir.app_error", nil, err.Error())
- }
-
- for _, fileInfo := range fileInfoList {
- if fileInfo.IsDir() {
- copyDirToExportWriter(writer, inPath+"/"+fileInfo.Name(), outPath+"/"+fileInfo.Name())
- } else {
- if toFile, err := writer.Create(outPath + "/" + fileInfo.Name()); err != nil {
- return model.NewLocAppError("copyDirToExportWriter", "api.export.open_file.app_error", nil, err.Error())
- } else {
- fromFile, err := os.Open(inPath + "/" + fileInfo.Name())
- if err != nil {
- return model.NewLocAppError("copyDirToExportWriter", "api.export.open.app_error", nil, err.Error())
- }
- io.Copy(toFile, fromFile)
- }
- }
- }
-
- return nil
-}
-
-func ExportLocalStorage(writer ExportWriter, options *ExportOptions, teamId string) *model.AppError {
- teamDir := utils.Cfg.FileSettings.Directory + "teams/" + teamId
-
- if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 {
- return model.NewLocAppError("ExportLocalStorage", "api.export.s3.app_error", nil, "")
- } else if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_LOCAL {
- if err := copyDirToExportWriter(writer, teamDir, EXPORT_LOCAL_STORAGE_FOLDER); err != nil {
- return err
- }
- }
-
- return nil
-}
diff --git a/api/file.go b/api/file.go
index 5b08804da..ea07f16f8 100644
--- a/api/file.go
+++ b/api/file.go
@@ -65,7 +65,6 @@ func InitFile() {
BaseRoutes.Files.Handle("/get/{channel_id:[A-Za-z0-9]+}/{user_id:[A-Za-z0-9]+}/{filename:([A-Za-z0-9]+/)?.+(\\.[A-Za-z0-9]{3,})?}", ApiUserRequiredTrustRequester(getFile)).Methods("GET")
BaseRoutes.Files.Handle("/get_info/{channel_id:[A-Za-z0-9]+}/{user_id:[A-Za-z0-9]+}/{filename:([A-Za-z0-9]+/)?.+(\\.[A-Za-z0-9]{3,})?}", ApiUserRequired(getFileInfo)).Methods("GET")
BaseRoutes.Files.Handle("/get_public_link", ApiUserRequired(getPublicLink)).Methods("POST")
- BaseRoutes.Files.Handle("/get_export", ApiUserRequired(getExport)).Methods("GET")
BaseRoutes.Public.Handle("/files/get/{team_id:[A-Za-z0-9]+}/{channel_id:[A-Za-z0-9]+}/{user_id:[A-Za-z0-9]+}/{filename:([A-Za-z0-9]+/)?.+(\\.[A-Za-z0-9]{3,})?}", ApiAppHandlerTrustRequesterIndependent(getPublicFile)).Methods("GET")
}
@@ -528,23 +527,6 @@ func getPublicLink(c *Context, w http.ResponseWriter, r *http.Request) {
w.Write([]byte(model.StringToJson(url)))
}
-func getExport(c *Context, w http.ResponseWriter, r *http.Request) {
- if !c.HasPermissionsToTeam(c.TeamId, "export") || !c.IsTeamAdmin() {
- c.Err = model.NewLocAppError("getExport", "api.file.get_export.team_admin.app_error", nil, "userId="+c.Session.UserId)
- c.Err.StatusCode = http.StatusForbidden
- return
- }
- data, err := ReadFile(EXPORT_PATH + EXPORT_FILENAME)
- if err != nil {
- c.Err = model.NewLocAppError("getExport", "api.file.get_export.retrieve.app_error", nil, err.Error())
- return
- }
-
- w.Header().Set("Content-Disposition", "attachment; filename="+EXPORT_FILENAME)
- w.Header().Set("Content-Type", "application/octet-stream")
- w.Write(data)
-}
-
func WriteFile(f []byte, path string) *model.AppError {
if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 {
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/preference.go b/api/preference.go
index d9ddb1a21..240ead571 100644
--- a/api/preference.go
+++ b/api/preference.go
@@ -16,6 +16,7 @@ func InitPreference() {
BaseRoutes.Preferences.Handle("/", ApiUserRequired(getAllPreferences)).Methods("GET")
BaseRoutes.Preferences.Handle("/save", ApiUserRequired(savePreferences)).Methods("POST")
+ BaseRoutes.Preferences.Handle("/delete", ApiUserRequired(deletePreferences)).Methods("POST")
BaseRoutes.Preferences.Handle("/{category:[A-Za-z0-9_]+}", ApiUserRequired(getPreferenceCategory)).Methods("GET")
BaseRoutes.Preferences.Handle("/{category:[A-Za-z0-9_]+}/{name:[A-Za-z0-9_]+}", ApiUserRequired(getPreference)).Methods("GET")
}
@@ -81,3 +82,30 @@ func getPreference(c *Context, w http.ResponseWriter, r *http.Request) {
w.Write([]byte(data.ToJson()))
}
}
+
+func deletePreferences(c *Context, w http.ResponseWriter, r *http.Request) {
+ preferences, err := model.PreferencesFromJson(r.Body)
+ if err != nil {
+ c.Err = model.NewLocAppError("savePreferences", "api.preference.delete_preferences.decode.app_error", nil, err.Error())
+ c.Err.StatusCode = http.StatusBadRequest
+ return
+ }
+
+ for _, preference := range preferences {
+ if c.Session.UserId != preference.UserId {
+ c.Err = model.NewLocAppError("deletePreferences", "api.preference.delete_preferences.user_id.app_error",
+ nil, "session.user_id="+c.Session.UserId+",preference.user_id="+preference.UserId)
+ c.Err.StatusCode = http.StatusForbidden
+ return
+ }
+ }
+
+ for _, preference := range preferences {
+ if result := <-Srv.Store.Preference().Delete(c.Session.UserId, preference.Category, preference.Name); result.Err != nil {
+ c.Err = result.Err
+ return
+ }
+ }
+
+ ReturnStatusOK(w)
+}
diff --git a/api/preference_test.go b/api/preference_test.go
index 082f02527..3e41c884f 100644
--- a/api/preference_test.go
+++ b/api/preference_test.go
@@ -161,3 +161,49 @@ func TestGetPreference(t *testing.T) {
t.Fatal("preference updated incorrectly")
}
}
+
+func TestDeletePreferences(t *testing.T) {
+ th := Setup().InitBasic()
+ Client := th.BasicClient
+ user1 := th.BasicUser
+
+ var originalCount int
+ if result, err := Client.GetAllPreferences(); err != nil {
+ t.Fatal(err)
+ } else {
+ originalCount = len(result.Data.(model.Preferences))
+ }
+
+ // save 10 preferences
+ var preferences model.Preferences
+ for i := 0; i < 10; i++ {
+ preference := model.Preference{
+ UserId: user1.Id,
+ Category: model.PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW,
+ Name: model.NewId(),
+ }
+ preferences = append(preferences, preference)
+ }
+
+ if _, err := Client.SetPreferences(&preferences); err != nil {
+ t.Fatal(err)
+ }
+
+ // delete 10 preferences
+ th.LoginBasic2()
+
+ if _, err := Client.DeletePreferences(&preferences); err == nil {
+ t.Fatal("shouldn't have been able to delete another user's preferences")
+ }
+
+ th.LoginBasic()
+ if _, err := Client.DeletePreferences(&preferences); err != nil {
+ t.Fatal(err)
+ }
+
+ if result, err := Client.GetAllPreferences(); err != nil {
+ t.Fatal(err)
+ } else if data := result.Data.(model.Preferences); len(data) != originalCount {
+ t.Fatal("should've deleted preferences")
+ }
+}
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 a3c2faf6a..7d2eb85bf 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
}
@@ -347,17 +349,19 @@ func CreateOAuthUser(c *Context, w http.ResponseWriter, r *http.Request, service
}
func sendWelcomeEmail(c *Context, userId string, email string, siteURL string, verified bool) {
+ rawUrl, _ := url.Parse(siteURL)
+
subjectPage := utils.NewHTMLTemplate("welcome_subject", c.Locale)
- subjectPage.Props["Subject"] = c.T("api.templates.welcome_subject", map[string]interface{}{"TeamDisplayName": siteURL})
+ subjectPage.Props["Subject"] = c.T("api.templates.welcome_subject", map[string]interface{}{"ServerURL": rawUrl.Host})
bodyPage := utils.NewHTMLTemplate("welcome_body", c.Locale)
bodyPage.Props["SiteURL"] = siteURL
- bodyPage.Props["Title"] = c.T("api.templates.welcome_body.title", map[string]interface{}{"TeamDisplayName": siteURL})
+ bodyPage.Props["Title"] = c.T("api.templates.welcome_body.title", map[string]interface{}{"ServerURL": rawUrl.Host})
bodyPage.Props["Info"] = c.T("api.templates.welcome_body.info")
bodyPage.Props["Button"] = c.T("api.templates.welcome_body.button")
bodyPage.Props["Info2"] = c.T("api.templates.welcome_body.info2")
bodyPage.Props["Info3"] = c.T("api.templates.welcome_body.info3")
- bodyPage.Props["TeamURL"] = siteURL
+ bodyPage.Props["SiteURL"] = siteURL
if !verified {
link := fmt.Sprintf("%s/do_verify_email?uid=%s&hid=%s&email=%s", siteURL, userId, model.HashPassword(userId), url.QueryEscape(email))
@@ -409,13 +413,15 @@ func addDirectChannels(teamId string, user *model.User) {
func SendVerifyEmail(c *Context, userId, userEmail, siteURL string) {
link := fmt.Sprintf("%s/do_verify_email?uid=%s&hid=%s&email=%s", siteURL, userId, model.HashPassword(userId), url.QueryEscape(userEmail))
+ url, _ := url.Parse(siteURL)
+
subjectPage := utils.NewHTMLTemplate("verify_subject", c.Locale)
subjectPage.Props["Subject"] = c.T("api.templates.verify_subject",
- map[string]interface{}{"TeamDisplayName": utils.ClientCfg["SiteName"], "SiteName": utils.ClientCfg["SiteName"]})
+ map[string]interface{}{"SiteName": utils.ClientCfg["SiteName"]})
bodyPage := utils.NewHTMLTemplate("verify_body", c.Locale)
bodyPage.Props["SiteURL"] = siteURL
- bodyPage.Props["Title"] = c.T("api.templates.verify_body.title", map[string]interface{}{"TeamDisplayName": utils.ClientCfg["SiteName"]})
+ bodyPage.Props["Title"] = c.T("api.templates.verify_body.title", map[string]interface{}{"ServerURL": url.Host})
bodyPage.Props["Info"] = c.T("api.templates.verify_body.info")
bodyPage.Props["VerifyUrl"] = link
bodyPage.Props["Button"] = c.T("api.templates.verify_body.button")
@@ -2531,7 +2537,9 @@ func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) {
switch action {
case model.OAUTH_ACTION_SIGNUP:
teamId := relayProps["team_id"]
- go addDirectChannels(teamId, user)
+ if len(teamId) > 0 {
+ go addDirectChannels(teamId, user)
+ }
break
case model.OAUTH_ACTION_EMAIL_TO_SSO:
RevokeAllSession(c, user.Id)
@@ -2542,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..fcb2c4f00 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(1000 * 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/config/config.json b/config/config.json
index 0ff4cd7e3..874af146f 100644
--- a/config/config.json
+++ b/config/config.json
@@ -195,4 +195,4 @@
"LocaleAttribute": "",
"LoginButtonText": "With SAML"
}
-}
+} \ No newline at end of file
diff --git a/glide.lock b/glide.lock
index 7dfec68b4..b1bde2847 100644
--- a/glide.lock
+++ b/glide.lock
@@ -42,8 +42,6 @@ imports:
version: 9c19ed558d5df4da88e2ade9c8940d742aef0e7e
- name: github.com/gorilla/websocket
version: 1f512fc3f05332ba7117626cdfb4e07474e58e60
- - name: github.com/kardianos/osext
- version: 29ae4ffbc9a6fe9fb2bc5029050ce6996ea1d3bc
- name: github.com/lib/pq
version: ee1442bda7bd1b6a84e913bdb421cb1874ec629d
subpackages:
diff --git a/glide.yaml b/glide.yaml
index b8879b2e5..81335ef38 100644
--- a/glide.yaml
+++ b/glide.yaml
@@ -39,4 +39,3 @@ import:
- package: gopkg.in/throttled/throttled.v1
subpackages:
- store
-- package: github.com/kardianos/osext
diff --git a/i18n/en.json b/i18n/en.json
index 04305774a..245ae8d83 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -556,8 +556,12 @@
"translation": "Displays a list of keyboard shortcuts"
},
{
+ "id": "api.command_shortcuts.list_mac",
+ "translation": "### Keyboard Shortcuts\n\n#### Navigation\n\nALT+UP: Previous channel or direct message in left hand sidebar\nALT+DOWN: Next channel or direct message in left hand sidebar\nALT+SHIFT+UP: Previous channel or direct message in left hand sidebar with unread messages\nALT+SHIFT+DOWN: Next channel or direct message in left hand sidebar with unread messages\nCMD+K: Open a quick channel switcher dialog\nCMD+SHIFT+A: Open account settings\nCMD+SHIFT+M: Open recent mentions\n\n#### Files\n\nCMD+U: Upload file(s)\n\n#### Messages\n\nCMD+UP (in empty input field): Reprint the previous message or slash command you entered\nCMD+DOWN (in empty input field): Reprint the next message or slash command you entered\nUP (in empty input field): Edit your last message in the current channel\n@[character]+TAB: Autocomplete @username beginning with [character]\n:[character]+TAB: Autocomplete emoji beginning with [character]\n\n#### Built-in Browser Commands\n\nCMD+[: Previous channel in your history\nCMD+]: Next channel in your history\nCMD+PLUS: Increase font size (zoom in)\nCMD+MINUS: Decrease font size (zoom out)\nSHIFT+UP (in input field): Highlight text to the previous line\nSHIFT+DOWN (in input field): Highlight text to the next line\nSHIFT+ENTER (in input field): Create a new line\n"
+ },
+ {
"id": "api.command_shortcuts.list",
- "translation": "### Keyboard Shortcuts\n\n#### Navigation\n\nALT+UP: Previous channel or direct message in left hand sidebar\nALT+DOWN: Next channel or direct message in left hand sidebar\nALT+SHIFT+UP: Previous channel or direct message in left hand sidebar with unread messages\nALT+SHIFT+DOWN: Next channel or direct message in left hand sidebar with unread messages\nCTRL/CMD+K: Open a quick channel switcher dialog\nCTRL/CMD+SHIFT+A: Open account settings\nCTRL/CMD+SHIFT+M: Open recent mentions\n\n#### Files\n\nCTRL/CMD+U: Upload file(s)\n\n#### Messages\n\nCTRL/CMD+UP (in empty input field): Reprint the previous message or slash command you entered\nCTRL/CMD+DOWN (in empty input field): Reprint the next message or slash command you entered\nUP (in empty input field): Edit your last message in the current channel\n@[character]+TAB: Autocomplete @username beginning with [character]\n:[character]+TAB: Autocomplete emoji beginning with [character]\n\n#### Built-in Browser Commands\n\nALT+LEFT/CMD+[: Previous channel in your history\nALT+RIGHT/CMD+]: Next channel in your history\nCTRL/CMD+PLUS: Increase font size (zoom in)\nCTRL/CMD+MINUS: Decrease font size (zoom out)\nSHIFT+UP (in input field): Highlight text to the previous line\nSHIFT+DOWN (in input field): Highlight text to the next line\nSHIFT+ENTER (in input field): Create a new line\n"
+ "translation": "### Keyboard Shortcuts\n\n#### Navigation\n\nALT+UP: Previous channel or direct message in left hand sidebar\nALT+DOWN: Next channel or direct message in left hand sidebar\nALT+SHIFT+UP: Previous channel or direct message in left hand sidebar with unread messages\nALT+SHIFT+DOWN: Next channel or direct message in left hand sidebar with unread messages\nCTRL+K: Open a quick channel switcher dialog\nCTRL+SHIFT+A: Open account settings\nCTRL+SHIFT+M: Open recent mentions\n\n#### Files\n\nCTRL+U: Upload file(s)\n\n#### Messages\n\nCTRL+UP (in empty input field): Reprint the previous message or slash command you entered\nCTRL+DOWN (in empty input field): Reprint the next message or slash command you entered\nUP (in empty input field): Edit your last message in the current channel\n@[character]+TAB: Autocomplete @username beginning with [character]\n:[character]+TAB: Autocomplete emoji beginning with [character]\n\n#### Built-in Browser Commands\n\nALT+LEFT: Previous channel in your history\nALT+RIGHT: Next channel in your history\nCTRL+PLUS: Increase font size (zoom in)\nCTRL+MINUS: Decrease font size (zoom out)\nSHIFT+UP (in input field): Highlight text to the previous line\nSHIFT+DOWN (in input field): Highlight text to the next line\nSHIFT+ENTER (in input field): Create a new line\n"
},
{
"id": "api.command_shortcuts.name",
@@ -600,6 +604,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"
},
@@ -668,38 +684,6 @@
"translation": "Unable to create emoji. Image must be at most 128 by 128 pixels."
},
{
- "id": "api.export.json.app_error",
- "translation": "Unable to convert to json"
- },
- {
- "id": "api.export.open.app_error",
- "translation": "Unable to open file"
- },
- {
- "id": "api.export.open_dir.app_error",
- "translation": "Unable to open directory"
- },
- {
- "id": "api.export.open_file.app_error",
- "translation": "Unable to open file for export"
- },
- {
- "id": "api.export.options.create.app_error",
- "translation": "Unable to create options file"
- },
- {
- "id": "api.export.options.write.app_error",
- "translation": "Unable to write to options file"
- },
- {
- "id": "api.export.read_dir.app_error",
- "translation": "Unable to read directory"
- },
- {
- "id": "api.export.s3.app_error",
- "translation": "S3 is not supported for local storage export."
- },
- {
"id": "api.file.file_upload.exceeds",
"translation": "File exceeds max image size."
},
@@ -708,14 +692,6 @@
"translation": "File exceeds max image size."
},
{
- "id": "api.file.get_export.retrieve.app_error",
- "translation": "Unable to retrieve exported file. Please re-export"
- },
- {
- "id": "api.file.get_export.team_admin.app_error",
- "translation": "Only a team admin can retrieve exported data."
- },
- {
"id": "api.file.get_file.not_found.app_error",
"translation": "Could not find file."
},
@@ -1128,6 +1104,14 @@
"translation": "Unable to get post"
},
{
+ "id": "api.preference.delete_preferences.decode.app_error",
+ "translation": "Unable to decode preferences from request"
+ },
+ {
+ "id": "api.preference.delete_preferences.user_id.app_error",
+ "translation": "Unable to delete preferences for other user"
+ },
+ {
"id": "api.preference.init.debug",
"translation": "Initializing preference api routes"
},
@@ -1292,10 +1276,6 @@
"translation": "An error occurred while sending an email in emailTeams err=%v"
},
{
- "id": "api.team.export_team.admin.app_error",
- "translation": "Only a team admin can export data."
- },
- {
"id": "api.team.get_invite_info.not_open_team",
"translation": "Invite is invalid because this is not an open team."
},
@@ -1577,11 +1557,11 @@
},
{
"id": "api.templates.verify_body.title",
- "translation": "You've joined the {{ .TeamDisplayName }} team"
+ "translation": "You've joined {{ .ServerURL }}"
},
{
"id": "api.templates.verify_subject",
- "translation": "[{{ .TeamDisplayName }} {{ .SiteName }}] Email Verification"
+ "translation": "[{{ .SiteName }}] Email Verification"
},
{
"id": "api.templates.welcome_body.button",
@@ -1593,7 +1573,7 @@
},
{
"id": "api.templates.welcome_body.info2",
- "translation": "You can sign-in to your new team from the web address:"
+ "translation": "You can sign in from:"
},
{
"id": "api.templates.welcome_body.info3",
@@ -1601,11 +1581,11 @@
},
{
"id": "api.templates.welcome_body.title",
- "translation": "You've joined the {{ .TeamDisplayName }} team"
+ "translation": "You've joined {{ .ServerURL }}"
},
{
"id": "api.templates.welcome_subject",
- "translation": "You joined {{ .TeamDisplayName }}"
+ "translation": "You joined {{ .ServerURL }}"
},
{
"id": "api.user.activate_mfa.email_and_ldap_only.app_error",
@@ -2177,7 +2157,7 @@
},
{
"id": "ent.ldap.do_login.user_filtered.app_error",
- "translation": "User is not permitted to use Mattermost. (LDAP user filter)"
+ "translation": "Your LDAP account does not have permission to use this Mattermost server. Please ask your System Administrator to check the LDAP user filter."
},
{
"id": "ent.ldap.do_login.user_not_registered.app_error",
@@ -2321,7 +2301,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",
@@ -2692,6 +2672,42 @@
"translation": "Invalid sync interval time. Must be at least one minute."
},
{
+ "id": "model.config.is_valid.ldap_server",
+ "translation": "LDAP field \"LDAP Server\" is required."
+ },
+ {
+ "id": "model.config.is_valid.ldap_basedn",
+ "translation": "LDAP field \"BaseDN\" is required."
+ },
+ {
+ "id": "model.config.is_valid.ldap_bind_username",
+ "translation": "LDAP field \"Bind Username\" is required."
+ },
+ {
+ "id": "model.config.is_valid.ldap_bind_password",
+ "translation": "LDAP field \"Bind Password\" is required."
+ },
+ {
+ "id": "model.config.is_valid.ldap_firstname",
+ "translation": "LDAP field \"First Name Attribute\" is required."
+ },
+ {
+ "id": "model.config.is_valid.ldap_lastname",
+ "translation": "LDAP field \"Last Name Attribute\" is required."
+ },
+ {
+ "id": "model.config.is_valid.ldap_email",
+ "translation": "LDAP field \"Email Attribute\" is required."
+ },
+ {
+ "id": "model.config.is_valid.ldap_username",
+ "translation": "LDAP field \"Username Attribute\" is required."
+ },
+ {
+ "id": "model.config.is_valid.ldap_id",
+ "translation": "LDAP field \"ID Attribute\" is required."
+ },
+ {
"id": "model.config.is_valid.listen_address.app_error",
"translation": "Invalid listen address for service settings Must be set."
},
@@ -2768,6 +2784,10 @@
"translation": "Invalid Username attribute. Must be set."
},
{
+ "id": "model.config.is_valid.sitename_length.app_error",
+ "translation": "Site name must be less than or equal to {{.MaxLength}} characters."
+ },
+ {
"id": "model.config.is_valid.sql_data_src.app_error",
"translation": "Invalid data source for SQL settings. Must be set."
},
@@ -2996,6 +3016,10 @@
"translation": "Invalid name"
},
{
+ "id": "model.preference.is_valid.theme.app_error",
+ "translation": "Invalid theme"
+ },
+ {
"id": "model.preference.is_valid.value.app_error",
"translation": "Value is too long"
},
@@ -3160,10 +3184,6 @@
"translation": "Invalid team id"
},
{
- "id": "model.user.is_valid.theme.app_error",
- "translation": "Invalid theme"
- },
- {
"id": "model.user.is_valid.update_at.app_error",
"translation": "Update at must be a valid time"
},
@@ -3392,10 +3412,6 @@
"translation": "We couldn't get the extra info for channel members"
},
{
- "id": "store.sql_channel.get_for_export.app_error",
- "translation": "We couldn't get all the channels"
- },
- {
"id": "store.sql_channel.get_member.app_error",
"translation": "We couldn't get the channel member"
},
@@ -3500,6 +3516,10 @@
"translation": "We couldn't save the channel member"
},
{
+ "id": "store.sql_channel.set_last_viewed_at.app_error",
+ "translation": "We couldn't set the last viewed at time"
+ },
+ {
"id": "store.sql_channel.update.app_error",
"translation": "We couldn't update the channel"
},
@@ -3684,10 +3704,6 @@
"translation": "We couldn't get user counts with posts"
},
{
- "id": "store.sql_post.compliance_export.app_error",
- "translation": "We couldn't get posts for compliance export"
- },
- {
"id": "store.sql_post.delete.app_error",
"translation": "We couldn't delete the post"
},
@@ -3696,10 +3712,6 @@
"translation": "We couldn't get the post"
},
{
- "id": "store.sql_post.get_for_export.app_error",
- "translation": "We couldn't get the posts for the channel"
- },
- {
"id": "store.sql_post.get_parents_posts.app_error",
"translation": "We couldn't get the parent post for the channel"
},
@@ -3756,6 +3768,10 @@
"translation": "We couldn't update the Post"
},
{
+ "id": "store.sql_preference.delete.app_error",
+ "translation": "We encountered an error while deleting preferences"
+ },
+ {
"id": "store.sql_preference.delete_unused_features.debug",
"translation": "Deleting any unused pre-release features"
},
@@ -4004,10 +4020,6 @@
"translation": "We couldn't find an existing account matching your username for this team. This team may require an invite from the team owner to join."
},
{
- "id": "store.sql_user.get_for_export.app_error",
- "translation": "We encountered an error while finding user profiles"
- },
- {
"id": "store.sql_user.get_for_login.app_error",
"translation": "We couldn't find an existing account matching your credentials. This team may require an invite from the team owner to join."
},
@@ -4036,6 +4048,10 @@
"translation": "We could not get the unread message count for the user"
},
{
+ "id": "store.sql_user.migrate_theme.critical",
+ "translation": "Failed to migrate User.ThemeProps to Preferences table %v"
+ },
+ {
"id": "store.sql_user.missing_account.const",
"translation": "We couldn't find an existing account matching your email address for this team. This team may require an invite from the team owner to join."
},
diff --git a/model/channel.go b/model/channel.go
index e7002e3cb..3da9f4feb 100644
--- a/model/channel.go
+++ b/model/channel.go
@@ -124,9 +124,6 @@ func (o *Channel) ExtraUpdated() {
o.ExtraUpdateAt = GetMillis()
}
-func (o *Channel) PreExport() {
-}
-
func GetDMNameFromIds(userId1, userId2 string) string {
if userId1 > userId2 {
return userId2 + "__" + userId1
diff --git a/model/client.go b/model/client.go
index 2f1e846c2..e12cd595d 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"
@@ -276,6 +277,9 @@ func (c *Client) GetPing() (map[string]string, *AppError) {
// Team Routes Section
+// SignupTeam sends an email with a team sign-up link to the provided address if email
+// verification is enabled, otherwise it returns a map with a "follow_link" entry
+// containing the team sign-up link.
func (c *Client) SignupTeam(email string, displayName string) (*Result, *AppError) {
m := make(map[string]string)
m["email"] = email
@@ -289,6 +293,8 @@ func (c *Client) SignupTeam(email string, displayName string) (*Result, *AppErro
}
}
+// CreateTeamFromSignup creates a team based on the provided TeamSignup struct. On success
+// it returns the TeamSignup struct.
func (c *Client) CreateTeamFromSignup(teamSignup *TeamSignup) (*Result, *AppError) {
if r, err := c.DoApiPost("/teams/create_from_signup", teamSignup.ToJson()); err != nil {
return nil, err
@@ -299,6 +305,8 @@ func (c *Client) CreateTeamFromSignup(teamSignup *TeamSignup) (*Result, *AppErro
}
}
+// CreateTeam creates a team based on the provided Team struct. On success it returns
+// the Team struct with the Id, CreateAt and other server-decided fields populated.
func (c *Client) CreateTeam(team *Team) (*Result, *AppError) {
if r, err := c.DoApiPost("/teams/create", team.ToJson()); err != nil {
return nil, err
@@ -309,6 +317,7 @@ func (c *Client) CreateTeam(team *Team) (*Result, *AppError) {
}
}
+// GetAllTeams returns a map of all teams using team ids as the key.
func (c *Client) GetAllTeams() (*Result, *AppError) {
if r, err := c.DoApiGet("/teams/all", "", ""); err != nil {
return nil, err
@@ -319,6 +328,8 @@ func (c *Client) GetAllTeams() (*Result, *AppError) {
}
}
+// GetAllTeamListings returns a map of all teams that are available to join
+// using team ids as the key. Must be authenticated.
func (c *Client) GetAllTeamListings() (*Result, *AppError) {
if r, err := c.DoApiGet("/teams/all_team_listings", "", ""); err != nil {
return nil, err
@@ -329,6 +340,8 @@ func (c *Client) GetAllTeamListings() (*Result, *AppError) {
}
}
+// FindTeamByName returns the strings "true" or "false" depending on if a team
+// with the provided name was found.
func (c *Client) FindTeamByName(name string) (*Result, *AppError) {
m := make(map[string]string)
m["name"] = name
@@ -365,6 +378,8 @@ func (c *Client) AddUserToTeam(teamId string, userId string) (*Result, *AppError
}
}
+// AddUserToTeamFromInvite adds a user to a team based off data provided in an invite link.
+// Either hash and dataToHash are required or inviteId is required.
func (c *Client) AddUserToTeamFromInvite(hash, dataToHash, inviteId string) (*Result, *AppError) {
data := make(map[string]string)
data["hash"] = hash
@@ -409,6 +424,9 @@ func (c *Client) InviteMembers(invites *Invites) (*Result, *AppError) {
}
}
+// UpdateTeam updates a team based on the changes in the provided team struct. On success
+// it returns a sanitized version of the updated team. Must be authenticated as a team admin
+// for that team or a system admin.
func (c *Client) UpdateTeam(team *Team) (*Result, *AppError) {
if r, err := c.DoApiPost(c.GetTeamRoute()+"/update", team.ToJson()); err != nil {
return nil, err
@@ -419,6 +437,9 @@ func (c *Client) UpdateTeam(team *Team) (*Result, *AppError) {
}
}
+// User Routes Section
+
+// CreateUser creates a user in the system based on the provided user struct.
func (c *Client) CreateUser(user *User, hash string) (*Result, *AppError) {
if r, err := c.DoApiPost("/users/create", user.ToJson()); err != nil {
return nil, err
@@ -429,6 +450,8 @@ func (c *Client) CreateUser(user *User, hash string) (*Result, *AppError) {
}
}
+// CreateUserWithInvite creates a user based on the provided user struct. Either the hash and
+// data strings or the inviteId is required from the invite.
func (c *Client) CreateUserWithInvite(user *User, hash string, data string, inviteId string) (*Result, *AppError) {
url := "/users/create?d=" + url.QueryEscape(data) + "&h=" + url.QueryEscape(hash) + "&iid=" + url.QueryEscape(inviteId)
@@ -452,6 +475,7 @@ func (c *Client) CreateUserFromSignup(user *User, data string, hash string) (*Re
}
}
+// GetUser returns a user based on a provided user id string. Must be authenticated.
func (c *Client) GetUser(id string, etag string) (*Result, *AppError) {
if r, err := c.DoApiGet("/users/"+id+"/get", "", etag); err != nil {
return nil, err
@@ -462,6 +486,7 @@ func (c *Client) GetUser(id string, etag string) (*Result, *AppError) {
}
}
+// GetMe returns the current user.
func (c *Client) GetMe(etag string) (*Result, *AppError) {
if r, err := c.DoApiGet("/users/me", "", etag); err != nil {
return nil, err
@@ -472,6 +497,8 @@ func (c *Client) GetMe(etag string) (*Result, *AppError) {
}
}
+// GetProfilesForDirectMessageList returns a map of users for a team that can be direct
+// messaged, using user id as the key. Must be authenticated.
func (c *Client) GetProfilesForDirectMessageList(teamId string) (*Result, *AppError) {
if r, err := c.DoApiGet("/users/profiles_for_dm_list/"+teamId, "", ""); err != nil {
return nil, err
@@ -482,6 +509,8 @@ func (c *Client) GetProfilesForDirectMessageList(teamId string) (*Result, *AppEr
}
}
+// GetProfiles returns a map of users for a team using user id as the key. Must
+// be authenticated.
func (c *Client) GetProfiles(teamId string, etag string) (*Result, *AppError) {
if r, err := c.DoApiGet("/users/profiles/"+teamId, "", etag); err != nil {
return nil, err
@@ -492,6 +521,8 @@ func (c *Client) GetProfiles(teamId string, etag string) (*Result, *AppError) {
}
}
+// GetDirectProfiles gets a map of users that are currently shown in the sidebar,
+// using user id as the key. Must be authenticated.
func (c *Client) GetDirectProfiles(etag string) (*Result, *AppError) {
if r, err := c.DoApiGet("/users/direct_profiles", "", etag); err != nil {
return nil, err
@@ -502,6 +533,7 @@ func (c *Client) GetDirectProfiles(etag string) (*Result, *AppError) {
}
}
+// LoginById authenticates a user by user id and password.
func (c *Client) LoginById(id string, password string) (*Result, *AppError) {
m := make(map[string]string)
m["id"] = id
@@ -509,6 +541,8 @@ func (c *Client) LoginById(id string, password string) (*Result, *AppError) {
return c.login(m)
}
+// Login authenticates a user by login id, which can be username, email or some sort
+// of SSO identifier based on configuration, and a password.
func (c *Client) Login(loginId string, password string) (*Result, *AppError) {
m := make(map[string]string)
m["login_id"] = loginId
@@ -516,6 +550,7 @@ func (c *Client) Login(loginId string, password string) (*Result, *AppError) {
return c.login(m)
}
+// LoginByLdap authenticates a user by LDAP id and password.
func (c *Client) LoginByLdap(loginId string, password string) (*Result, *AppError) {
m := make(map[string]string)
m["login_id"] = loginId
@@ -524,6 +559,9 @@ func (c *Client) LoginByLdap(loginId string, password string) (*Result, *AppErro
return c.login(m)
}
+// LoginWithDevice authenticates a user by login id (username, email or some sort
+// of SSO identifier based on configuration), password and attaches a device id to
+// the session.
func (c *Client) LoginWithDevice(loginId string, password string, deviceId string) (*Result, *AppError) {
m := make(map[string]string)
m["login_id"] = loginId
@@ -550,6 +588,7 @@ func (c *Client) login(m map[string]string) (*Result, *AppError) {
}
}
+// Logout terminates the current user's session.
func (c *Client) Logout() (*Result, *AppError) {
if r, err := c.DoApiPost("/users/logout", ""); err != nil {
return nil, err
@@ -564,6 +603,9 @@ func (c *Client) Logout() (*Result, *AppError) {
}
}
+// CheckMfa returns a map with key "mfa_required" with the string value "true" or "false",
+// indicating whether MFA is required to log the user in, based on a provided login id
+// (username, email or some sort of SSO identifier based on configuration).
func (c *Client) CheckMfa(loginId string) (*Result, *AppError) {
m := make(map[string]string)
m["login_id"] = loginId
@@ -577,6 +619,8 @@ func (c *Client) CheckMfa(loginId string) (*Result, *AppError) {
}
}
+// GenerateMfaQrCode returns a QR code imagem containing the secret, to be scanned
+// by a multi-factor authentication mobile application. Must be authenticated.
func (c *Client) GenerateMfaQrCode() (*Result, *AppError) {
if r, err := c.DoApiGet("/users/generate_mfa_qr", "", ""); err != nil {
return nil, err
@@ -587,6 +631,9 @@ func (c *Client) GenerateMfaQrCode() (*Result, *AppError) {
}
}
+// UpdateMfa activates multi-factor authenticates for the current user if activate
+// is true and a valid token is provided. If activate is false, then token is not
+// required and multi-factor authentication is disabled for the current user.
func (c *Client) UpdateMfa(activate bool, token string) (*Result, *AppError) {
m := make(map[string]interface{})
m["activate"] = activate
@@ -1509,6 +1556,16 @@ func (c *Client) GetPreferenceCategory(category string) (*Result, *AppError) {
}
}
+// DeletePreferences deletes a list of preferences owned by the current user. If successful,
+// it will return status=ok. Otherwise, an error will be returned.
+func (c *Client) DeletePreferences(preferences *Preferences) (bool, *AppError) {
+ if r, err := c.DoApiPost("/preferences/delete", preferences.ToJson()); err != nil {
+ return false, err
+ } else {
+ return c.CheckStatusOK(r), nil
+ }
+}
+
func (c *Client) CreateOutgoingWebhook(hook *OutgoingWebhook) (*Result, *AppError) {
if r, err := c.DoApiPost(c.GetTeamRoute()+"/hooks/outgoing/create", hook.ToJson()); err != nil {
return nil, err
@@ -1648,3 +1705,36 @@ func (c *Client) DeleteEmoji(id string) (bool, *AppError) {
func (c *Client) GetCustomEmojiImageUrl(id string) string {
return c.GetEmojiRoute() + "/" + id
}
+
+// Uploads a x509 base64 Certificate or Private Key file to be used with SAML.
+// data byte array is required and needs to be a Multi-Part with 'certificate' as the field name
+// contentType is also required. Returns nil if succesful, otherwise returns an AppError
+func (c *Client) UploadCertificateFile(data []byte, contentType string) *AppError {
+ url := c.ApiUrl + "/admin/add_certificate"
+ rq, _ := http.NewRequest("POST", url, bytes.NewReader(data))
+ rq.Header.Set("Content-Type", contentType)
+
+ if len(c.AuthToken) > 0 {
+ rq.Header.Set(HEADER_AUTH, "BEARER "+c.AuthToken)
+ }
+
+ if rp, err := c.HttpClient.Do(rq); err != nil {
+ return NewLocAppError(url, "model.client.connecting.app_error", nil, err.Error())
+ } else if rp.StatusCode >= 300 {
+ return AppErrorFromJson(rp.Body)
+ } else {
+ defer closeBody(rp)
+ return nil
+ }
+}
+
+// Removes a x509 base64 Certificate or Private Key file used with SAML.
+// filename is required. Returns nil if succesful, otherwise returns an AppError
+func (c *Client) RemoveCertificateFile(filename string) *AppError {
+ if r, err := c.DoApiPost("/admin/remove_certificate", MapToJson(map[string]string{"filename": filename})); err != nil {
+ return err
+ } else {
+ defer closeBody(r)
+ return nil
+ }
+}
diff --git a/model/config.go b/model/config.go
index 3a0d7f976..532584a1a 100644
--- a/model/config.go
+++ b/model/config.go
@@ -44,6 +44,8 @@ const (
RESTRICT_EMOJI_CREATION_ALL = "all"
RESTRICT_EMOJI_CREATION_ADMIN = "admin"
RESTRICT_EMOJI_CREATION_SYSTEM_ADMIN = "system_admin"
+
+ SITENAME_MAX_LENGTH = 30
)
type ServiceSettings struct {
@@ -893,21 +895,45 @@ func (o *Config) IsValid() *AppError {
}
if *o.LdapSettings.Enable {
- if *o.LdapSettings.LdapServer == "" ||
- *o.LdapSettings.BaseDN == "" ||
- *o.LdapSettings.BindUsername == "" ||
- *o.LdapSettings.BindPassword == "" ||
- *o.LdapSettings.FirstNameAttribute == "" ||
- *o.LdapSettings.LastNameAttribute == "" ||
- *o.LdapSettings.EmailAttribute == "" ||
- *o.LdapSettings.UsernameAttribute == "" ||
- *o.LdapSettings.IdAttribute == "" {
- return NewLocAppError("Config.IsValid", "Required LDAP field missing", nil, "")
+ if *o.LdapSettings.LdapServer == "" {
+ return NewLocAppError("Config.IsValid", "model.config.is_valid.ldap_server", nil, "")
+ }
+
+ if *o.LdapSettings.BaseDN == "" {
+ return NewLocAppError("Config.IsValid", "model.config.is_valid.ldap_basedn", nil, "")
+ }
+
+ if *o.LdapSettings.BindUsername == "" {
+ return NewLocAppError("Config.IsValid", "model.config.is_valid.ldap_bind_username", nil, "")
+ }
+
+ if *o.LdapSettings.BindPassword == "" {
+ return NewLocAppError("Config.IsValid", "model.config.is_valid.ldap_bind_password", nil, "")
+ }
+
+ if *o.LdapSettings.FirstNameAttribute == "" {
+ return NewLocAppError("Config.IsValid", "model.config.is_valid.ldap_firstname", nil, "")
+ }
+
+ if *o.LdapSettings.LastNameAttribute == "" {
+ return NewLocAppError("Config.IsValid", "model.config.is_valid.ldap_lastname", nil, "")
+ }
+
+ if *o.LdapSettings.EmailAttribute == "" {
+ return NewLocAppError("Config.IsValid", "model.config.is_valid.ldap_email", nil, "")
+ }
+
+ if *o.LdapSettings.UsernameAttribute == "" {
+ return NewLocAppError("Config.IsValid", "model.config.is_valid.ldap_username", nil, "")
+ }
+
+ if *o.LdapSettings.IdAttribute == "" {
+ return NewLocAppError("Config.IsValid", "model.config.is_valid.ldap_id", nil, "")
}
}
if *o.SamlSettings.Enable {
- if len(*o.SamlSettings.IdpUrl) == 0 {
+ if len(*o.SamlSettings.IdpUrl) == 0 || !IsValidHttpUrl(*o.SamlSettings.IdpDescriptorUrl) {
return NewLocAppError("Config.IsValid", "model.config.is_valid.saml_idp_url.app_error", nil, "")
}
@@ -960,6 +986,10 @@ func (o *Config) IsValid() *AppError {
return NewLocAppError("Config.IsValid", "model.config.is_valid.password_length.app_error", map[string]interface{}{"MinLength": PASSWORD_MINIMUM_LENGTH, "MaxLength": PASSWORD_MAXIMUM_LENGTH}, "")
}
+ if len(o.TeamSettings.SiteName) > SITENAME_MAX_LENGTH {
+ return NewLocAppError("Config.IsValid", "model.config.is_valid.sitename_length.app_error", map[string]interface{}{"MaxLength": SITENAME_MAX_LENGTH}, "")
+ }
+
return nil
}
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/post.go b/model/post.go
index 173c99e37..175aecdd7 100644
--- a/model/post.go
+++ b/model/post.go
@@ -162,9 +162,6 @@ func (o *Post) AddProp(key string, value interface{}) {
o.Props[key] = value
}
-func (o *Post) PreExport() {
-}
-
func (o *Post) IsSystemMessage() bool {
return len(o.Type) >= len(POST_SYSTEM_MESSAGE_PREFIX) && o.Type[:len(POST_SYSTEM_MESSAGE_PREFIX)] == POST_SYSTEM_MESSAGE_PREFIX
}
diff --git a/model/preference.go b/model/preference.go
index 22858e043..779c41e50 100644
--- a/model/preference.go
+++ b/model/preference.go
@@ -6,6 +6,8 @@ package model
import (
"encoding/json"
"io"
+ "regexp"
+ "strings"
"unicode/utf8"
)
@@ -17,6 +19,9 @@ const (
PREFERENCE_CATEGORY_DISPLAY_SETTINGS = "display_settings"
PREFERENCE_NAME_COLLAPSE_SETTING = "collapse_previews"
+ PREFERENCE_CATEGORY_THEME = "theme"
+ // the name for theme props is the team id
+
PREFERENCE_CATEGORY_LAST = "last"
PREFERENCE_NAME_LAST_CHANNEL = "channel"
)
@@ -57,13 +62,48 @@ func (o *Preference) IsValid() *AppError {
return NewLocAppError("Preference.IsValid", "model.preference.is_valid.category.app_error", nil, "category="+o.Category)
}
- if len(o.Name) == 0 || len(o.Name) > 32 {
+ if len(o.Name) > 32 {
return NewLocAppError("Preference.IsValid", "model.preference.is_valid.name.app_error", nil, "name="+o.Name)
}
- if utf8.RuneCountInString(o.Value) > 128 {
+ if utf8.RuneCountInString(o.Value) > 2000 {
return NewLocAppError("Preference.IsValid", "model.preference.is_valid.value.app_error", nil, "value="+o.Value)
}
+ if o.Category == PREFERENCE_CATEGORY_THEME {
+ var unused map[string]string
+ if err := json.NewDecoder(strings.NewReader(o.Value)).Decode(&unused); err != nil {
+ return NewLocAppError("Preference.IsValid", "model.preference.is_valid.theme.app_error", nil, "value="+o.Value)
+ }
+ }
+
return nil
}
+
+func (o *Preference) PreUpdate() {
+ if o.Category == PREFERENCE_CATEGORY_THEME {
+ // decode the value of theme (a map of strings to string) and eliminate any invalid values
+ var props map[string]string
+ if err := json.NewDecoder(strings.NewReader(o.Value)).Decode(&props); err != nil {
+ // just continue, the invalid preference value should get caught by IsValid before saving
+ return
+ }
+
+ colorPattern := regexp.MustCompile(`^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$`)
+
+ // blank out any invalid theme values
+ for name, value := range props {
+ if name == "image" || name == "type" || name == "codeTheme" {
+ continue
+ }
+
+ if !colorPattern.MatchString(value) {
+ props[name] = "#ffffff"
+ }
+ }
+
+ if b, err := json.Marshal(props); err == nil {
+ o.Value = string(b)
+ }
+ }
+}
diff --git a/model/preference_test.go b/model/preference_test.go
index e29250bba..df7fe612d 100644
--- a/model/preference_test.go
+++ b/model/preference_test.go
@@ -4,6 +4,7 @@
package model
import (
+ "encoding/json"
"strings"
"testing"
)
@@ -31,7 +32,7 @@ func TestPreferenceIsValid(t *testing.T) {
preference.Category = PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW
if err := preference.IsValid(); err != nil {
- t.Fatal()
+ t.Fatal(err)
}
preference.Name = strings.Repeat("01234567890", 20)
@@ -41,16 +42,48 @@ func TestPreferenceIsValid(t *testing.T) {
preference.Name = NewId()
if err := preference.IsValid(); err != nil {
- t.Fatal()
+ t.Fatal(err)
}
- preference.Value = strings.Repeat("01234567890", 20)
+ preference.Value = strings.Repeat("01234567890", 201)
if err := preference.IsValid(); err == nil {
t.Fatal()
}
preference.Value = "1234garbage"
if err := preference.IsValid(); err != nil {
+ t.Fatal(err)
+ }
+
+ preference.Category = PREFERENCE_CATEGORY_THEME
+ if err := preference.IsValid(); err == nil {
t.Fatal()
}
+
+ preference.Value = `{"color": "#ff0000", "color2": "#faf"}`
+ if err := preference.IsValid(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestPreferencePreUpdate(t *testing.T) {
+ preference := Preference{
+ Category: PREFERENCE_CATEGORY_THEME,
+ Value: `{"color": "#ff0000", "color2": "#faf", "codeTheme": "github", "invalid": "invalid"}`,
+ }
+
+ preference.PreUpdate()
+
+ var props map[string]string
+ if err := json.NewDecoder(strings.NewReader(preference.Value)).Decode(&props); err != nil {
+ t.Fatal(err)
+ }
+
+ if props["color"] != "#ff0000" || props["color2"] != "#faf" || props["codeTheme"] != "github" {
+ t.Fatal("shouldn't have changed valid props")
+ }
+
+ if props["invalid"] == "invalid" {
+ t.Fatal("should have changed invalid prop")
+ }
}
diff --git a/model/team.go b/model/team.go
index 072e0a8c0..dccc0219e 100644
--- a/model/team.go
+++ b/model/team.go
@@ -224,9 +224,6 @@ func CleanTeamName(s string) string {
return s
}
-func (o *Team) PreExport() {
-}
-
func (o *Team) Sanitize() {
o.Email = ""
o.AllowedDomains = ""
diff --git a/model/user.go b/model/user.go
index c792f80d1..bf6866b27 100644
--- a/model/user.go
+++ b/model/user.go
@@ -49,7 +49,6 @@ type User struct {
AllowMarketing bool `json:"allow_marketing,omitempty"`
Props StringMap `json:"props,omitempty"`
NotifyProps StringMap `json:"notify_props,omitempty"`
- ThemeProps StringMap `json:"theme_props,omitempty"`
LastPasswordUpdate int64 `json:"last_password_update,omitempty"`
LastPictureUpdate int64 `json:"last_picture_update,omitempty"`
FailedAttempts int `json:"failed_attempts,omitempty"`
@@ -106,10 +105,6 @@ func (u *User) IsValid() *AppError {
return NewLocAppError("User.IsValid", "model.user.is_valid.auth_data_pwd.app_error", nil, "user_id="+u.Id)
}
- if len(u.ThemeProps) > 2000 {
- return NewLocAppError("User.IsValid", "model.user.is_valid.theme.app_error", nil, "user_id="+u.Id)
- }
-
return nil
}
@@ -179,21 +174,6 @@ func (u *User) PreUpdate() {
}
u.NotifyProps["mention_keys"] = strings.Join(goodKeys, ",")
}
-
- if u.ThemeProps != nil {
- colorPattern := regexp.MustCompile(`^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$`)
-
- // blank out any invalid theme values
- for name, value := range u.ThemeProps {
- if name == "image" || name == "type" || name == "codeTheme" {
- continue
- }
-
- if !colorPattern.MatchString(value) {
- u.ThemeProps[name] = "#ffffff"
- }
- }
- }
}
func (u *User) SetDefaultNotifications() {
@@ -282,7 +262,6 @@ func (u *User) ClearNonProfileFields() {
u.AllowMarketing = false
u.Props = StringMap{}
u.NotifyProps = StringMap{}
- u.ThemeProps = StringMap{}
u.LastPasswordUpdate = 0
u.LastPictureUpdate = 0
u.FailedAttempts = 0
@@ -392,17 +371,6 @@ func (u *User) IsLDAPUser() bool {
return false
}
-func (u *User) PreExport() {
- u.Password = ""
- u.AuthData = new(string)
- *u.AuthData = ""
- u.LastActivityAt = 0
- u.LastPingAt = 0
- u.LastPasswordUpdate = 0
- u.LastPictureUpdate = 0
- u.FailedAttempts = 0
-}
-
// UserFromJson will decode the input and return a User
func UserFromJson(data io.Reader) *User {
decoder := json.NewDecoder(data)
diff --git a/model/user_test.go b/model/user_test.go
index 899542a05..16ac2583b 100644
--- a/model/user_test.go
+++ b/model/user_test.go
@@ -39,19 +39,6 @@ func TestUserPreSave(t *testing.T) {
func TestUserPreUpdate(t *testing.T) {
user := User{Password: "test"}
user.PreUpdate()
-
- user.ThemeProps = StringMap{
- "codeTheme": "github",
- "awayIndicator": "#cdbd4e",
- "buttonColor": "invalid",
- }
- user.PreUpdate()
-
- if user.ThemeProps["codeTheme"] != "github" || user.ThemeProps["awayIndicator"] != "#cdbd4e" {
- t.Fatal("shouldn't have changed valid theme props")
- } else if user.ThemeProps["buttonColor"] != "#ffffff" {
- t.Fatal("should've changed invalid theme prop")
- }
}
func TestUserUpdateMentionKeysFromUsername(t *testing.T) {
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/store/sql_channel_store.go b/store/sql_channel_store.go
index e5e0aa8ba..463bc0678 100644
--- a/store/sql_channel_store.go
+++ b/store/sql_channel_store.go
@@ -856,6 +856,58 @@ func (s SqlChannelStore) CheckOpenChannelPermissions(teamId string, channelId st
return storeChannel
}
+func (s SqlChannelStore) SetLastViewedAt(channelId string, userId string, newLastViewedAt int64) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ var query string
+
+ if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES {
+ query = `UPDATE
+ ChannelMembers
+ SET
+ MentionCount = 0,
+ MsgCount = Channels.TotalMsgCount - (SELECT COUNT(*)
+ FROM Posts
+ WHERE ChannelId = :ChannelId
+ AND CreateAt > :NewLastViewedAt),
+ LastViewedAt = :NewLastViewedAt
+ FROM
+ Channels
+ WHERE
+ Channels.Id = ChannelMembers.ChannelId
+ AND UserId = :UserId
+ AND ChannelId = :ChannelId`
+ } else if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_MYSQL {
+ query = `UPDATE
+ ChannelMembers, Channels
+ SET
+ ChannelMembers.MentionCount = 0,
+ ChannelMembers.MsgCount = Channels.TotalMsgCount - (SELECT COUNT(*)
+ FROM Posts
+ WHERE ChannelId = :ChannelId
+ AND CreateAt > :NewLastViewedAt),
+ ChannelMembers.LastViewedAt = :NewLastViewedAt
+ WHERE
+ Channels.Id = ChannelMembers.ChannelId
+ AND UserId = :UserId
+ AND ChannelId = :ChannelId`
+ }
+
+ _, err := s.GetMaster().Exec(query, map[string]interface{}{"ChannelId": channelId, "UserId": userId, "NewLastViewedAt": newLastViewedAt})
+ if err != nil {
+ result.Err = model.NewLocAppError("SqlChannelStore.SetLastViewedAt", "store.sql_channel.set_last_viewed_at.app_error", nil, "channel_id="+channelId+", user_id="+userId+", "+err.Error())
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
func (s SqlChannelStore) UpdateLastViewedAt(channelId string, userId string) StoreChannel {
storeChannel := make(StoreChannel)
@@ -930,28 +982,6 @@ func (s SqlChannelStore) IncrementMentionCount(channelId string, userId string)
return storeChannel
}
-func (s SqlChannelStore) GetForExport(teamId string) StoreChannel {
- storeChannel := make(StoreChannel)
-
- go func() {
- result := StoreResult{}
-
- var data []*model.Channel
- _, err := s.GetReplica().Select(&data, "SELECT * FROM Channels WHERE TeamId = :TeamId AND DeleteAt = 0 AND Type = 'O'", map[string]interface{}{"TeamId": teamId})
-
- if err != nil {
- result.Err = model.NewLocAppError("SqlChannelStore.GetAllChannels", "store.sql_channel.get_for_export.app_error", nil, "teamId="+teamId+", err="+err.Error())
- } else {
- result.Data = data
- }
-
- storeChannel <- result
- close(storeChannel)
- }()
-
- return storeChannel
-}
-
func (s SqlChannelStore) GetAll(teamId string) StoreChannel {
storeChannel := make(StoreChannel)
diff --git a/store/sql_post_store.go b/store/sql_post_store.go
index e6291687e..57bb2a512 100644
--- a/store/sql_post_store.go
+++ b/store/sql_post_store.go
@@ -784,30 +784,6 @@ func (s SqlPostStore) Search(teamId string, userId string, params *model.SearchP
return storeChannel
}
-func (s SqlPostStore) GetForExport(channelId string) StoreChannel {
- storeChannel := make(StoreChannel)
-
- go func() {
- result := StoreResult{}
-
- var posts []*model.Post
- _, err := s.GetReplica().Select(
- &posts,
- "SELECT * FROM Posts WHERE ChannelId = :ChannelId AND DeleteAt = 0",
- map[string]interface{}{"ChannelId": channelId})
- if err != nil {
- result.Err = model.NewLocAppError("SqlPostStore.GetForExport", "store.sql_post.get_for_export.app_error", nil, "channelId="+channelId+err.Error())
- } else {
- result.Data = posts
- }
-
- storeChannel <- result
- close(storeChannel)
- }()
-
- return storeChannel
-}
-
func (s SqlPostStore) AnalyticsUserCountsWithPostsByDay(teamId string) StoreChannel {
storeChannel := make(StoreChannel)
diff --git a/store/sql_preference_store.go b/store/sql_preference_store.go
index 83bf92ead..a701c3cb8 100644
--- a/store/sql_preference_store.go
+++ b/store/sql_preference_store.go
@@ -26,7 +26,7 @@ func NewSqlPreferenceStore(sqlStore *SqlStore) PreferenceStore {
table.ColMap("UserId").SetMaxSize(26)
table.ColMap("Category").SetMaxSize(32)
table.ColMap("Name").SetMaxSize(32)
- table.ColMap("Value").SetMaxSize(128)
+ table.ColMap("Value").SetMaxSize(2000)
}
return s
@@ -100,6 +100,8 @@ func (s SqlPreferenceStore) Save(preferences *model.Preferences) StoreChannel {
func (s SqlPreferenceStore) save(transaction *gorp.Transaction, preference *model.Preference) StoreResult {
result := StoreResult{}
+ preference.PreUpdate()
+
if result.Err = preference.IsValid(); result.Err != nil {
return result
}
@@ -304,3 +306,26 @@ func (s SqlPreferenceStore) IsFeatureEnabled(feature, userId string) StoreChanne
return storeChannel
}
+
+func (s SqlPreferenceStore) Delete(userId, category, name string) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ if _, err := s.GetMaster().Exec(
+ `DELETE FROM
+ Preferences
+ WHERE
+ UserId = :UserId
+ AND Category = :Category
+ AND Name = :Name`, map[string]interface{}{"UserId": userId, "Category": category, "Name": name}); err != nil {
+ result.Err = model.NewLocAppError("SqlPreferenceStore.Delete", "store.sql_preference.delete.app_error", nil, err.Error())
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
diff --git a/store/sql_preference_store_test.go b/store/sql_preference_store_test.go
index ec9d1df6c..8c6a2b6af 100644
--- a/store/sql_preference_store_test.go
+++ b/store/sql_preference_store_test.go
@@ -193,7 +193,7 @@ func TestPreferenceGetAll(t *testing.T) {
}
}
-func TestPreferenceDelete(t *testing.T) {
+func TestPreferenceDeleteByUser(t *testing.T) {
Setup()
userId := model.NewId()
@@ -367,3 +367,28 @@ func TestDeleteUnusedFeatures(t *testing.T) {
t.Fatalf("Found %d features with value 'true', expected to find at least %d features", val, 2)
}
}
+
+func TestPreferenceDelete(t *testing.T) {
+ Setup()
+
+ preference := model.Preference{
+ UserId: model.NewId(),
+ Category: model.PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW,
+ Name: model.NewId(),
+ Value: "value1a",
+ }
+
+ Must(store.Preference().Save(&model.Preferences{preference}))
+
+ if prefs := Must(store.Preference().GetAll(preference.UserId)).(model.Preferences); len([]model.Preference(prefs)) != 1 {
+ t.Fatal("should've returned 1 preference")
+ }
+
+ if result := <-store.Preference().Delete(preference.UserId, preference.Category, preference.Name); result.Err != nil {
+ t.Fatal(result.Err)
+ }
+
+ if prefs := Must(store.Preference().GetAll(preference.UserId)).(model.Preferences); len([]model.Preference(prefs)) != 0 {
+ t.Fatal("should've returned no preferences")
+ }
+}
diff --git a/store/sql_team_store.go b/store/sql_team_store.go
index ddcaa7896..002d2a3ea 100644
--- a/store/sql_team_store.go
+++ b/store/sql_team_store.go
@@ -265,7 +265,7 @@ func (s SqlTeamStore) GetTeamsByUserId(userId string) StoreChannel {
result := StoreResult{}
var data []*model.Team
- if _, err := s.GetReplica().Select(&data, "SELECT Teams.* FROM Teams, TeamMembers WHERE TeamMembers.TeamId = Teams.Id AND TeamMembers.UserId = :UserId", map[string]interface{}{"UserId": userId}); err != nil {
+ if _, err := s.GetReplica().Select(&data, "SELECT Teams.* FROM Teams, TeamMembers WHERE TeamMembers.TeamId = Teams.Id AND TeamMembers.UserId = :UserId AND TeamMembers.DeleteAt = 0 AND Teams.DeleteAt = 0", map[string]interface{}{"UserId": userId}); err != nil {
result.Err = model.NewLocAppError("SqlTeamStore.GetTeamsByUserId", "store.sql_team.get_all.app_error", nil, err.Error())
}
diff --git a/store/sql_user_store.go b/store/sql_user_store.go
index df132ab42..325008670 100644
--- a/store/sql_user_store.go
+++ b/store/sql_user_store.go
@@ -4,11 +4,14 @@
package store
import (
+ "crypto/md5"
"database/sql"
"fmt"
"strconv"
"strings"
+ "time"
+ l4g "github.com/alecthomas/log4go"
"github.com/mattermost/platform/model"
"github.com/mattermost/platform/utils"
)
@@ -39,7 +42,6 @@ func NewSqlUserStore(sqlStore *SqlStore) UserStore {
table.ColMap("Roles").SetMaxSize(64)
table.ColMap("Props").SetMaxSize(4000)
table.ColMap("NotifyProps").SetMaxSize(2000)
- table.ColMap("ThemeProps").SetMaxSize(2000)
table.ColMap("Locale").SetMaxSize(5)
table.ColMap("MfaSecret").SetMaxSize(128)
}
@@ -52,27 +54,68 @@ func (us SqlUserStore) UpgradeSchemaIfNeeded() {
us.CreateColumnIfNotExists("Users", "Locale", "varchar(5)", "character varying(5)", model.DEFAULT_LOCALE)
// ADDED for 3.2 REMOVE for 3.6
- var data []*model.User
- if _, err := us.GetReplica().Select(&data, "SELECT * FROM Users WHERE ThemeProps LIKE '%solarized%'"); err == nil {
- for _, user := range data {
- shouldUpdate := false
- if user.ThemeProps["codeTheme"] == "solarized_dark" {
- user.ThemeProps["codeTheme"] = "solarized-dark"
- shouldUpdate = true
- } else if user.ThemeProps["codeTheme"] == "solarized_light" {
- user.ThemeProps["codeTheme"] = "solarized-light"
- shouldUpdate = true
+ if us.DoesColumnExist("Users", "ThemeProps") {
+ params := map[string]interface{}{
+ "Category": model.PREFERENCE_CATEGORY_THEME,
+ "Name": "",
+ }
+
+ transaction, err := us.GetMaster().Begin()
+ if err != nil {
+ themeMigrationFailed(err)
+ }
+
+ // increase size of Value column of Preferences table to match the size of the ThemeProps column
+ if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES {
+ if _, err := transaction.Exec("ALTER TABLE Preferences ALTER COLUMN Value TYPE varchar(2000)"); err != nil {
+ themeMigrationFailed(err)
+ }
+ } else if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_MYSQL {
+ if _, err := transaction.Exec("ALTER TABLE Preferences MODIFY Value text"); err != nil {
+ themeMigrationFailed(err)
}
+ }
- if shouldUpdate {
- if result := <-us.Update(user, true); result.Err != nil {
- return
- }
+ // copy data across
+ if _, err := transaction.Exec(
+ `INSERT INTO
+ Preferences(UserId, Category, Name, Value)
+ SELECT
+ Id, '`+model.PREFERENCE_CATEGORY_THEME+`', '', ThemeProps
+ FROM
+ Users
+ WHERE
+ Users.ThemeProps != 'null'`, params); err != nil {
+ themeMigrationFailed(err)
+ }
+
+ // delete old data
+ if _, err := transaction.Exec("ALTER TABLE Users DROP COLUMN ThemeProps"); err != nil {
+ themeMigrationFailed(err)
+ }
+
+ if err := transaction.Commit(); err != nil {
+ themeMigrationFailed(err)
+ }
+
+ // rename solarized_* code themes to solarized-* to match client changes in 3.0
+ var data model.Preferences
+ if _, err := us.GetReplica().Select(&data, "SELECT * FROM Preferences WHERE Category = '"+model.PREFERENCE_CATEGORY_THEME+"' AND Value LIKE '%solarized_%'"); err == nil {
+ for i := range data {
+ data[i].Value = strings.Replace(data[i].Value, "solarized_", "solarized-", -1)
}
+
+ us.Preference().Save(&data)
}
}
}
+func themeMigrationFailed(err error) {
+ l4g.Critical(utils.T("store.sql_user.migrate_theme.critical"), err)
+ time.Sleep(time.Second)
+ panic(fmt.Sprintf(utils.T("store.sql_user.migrate_theme.critical"), err.Error()))
+}
+
func (us SqlUserStore) CreateIndexesIfNotExists() {
us.CreateIndexIfNotExists("idx_users_email", "Users", "Email")
}
@@ -480,9 +523,10 @@ func (s SqlUserStore) GetEtagForDirectProfiles(userId string) StoreChannel {
go func() {
result := StoreResult{}
- updateAt, err := s.GetReplica().SelectInt(`
+ var ids []string
+ _, err := s.GetReplica().Select(ids, `
SELECT
- UpdateAt
+ Id
FROM
Users
WHERE
@@ -508,12 +552,14 @@ func (s SqlUserStore) GetEtagForDirectProfiles(userId string) StoreChannel {
WHERE
UserId = :UserId
AND Category = 'direct_channel_show')
- ORDER BY UpdateAt DESC LIMIT 1
+ ORDER BY UpdateAt DESC
`, map[string]interface{}{"UserId": userId})
- if err != nil {
- result.Data = fmt.Sprintf("%v.%v.%v.%v", model.CurrentVersion, model.GetMillis(), utils.Cfg.PrivacySettings.ShowFullName, utils.Cfg.PrivacySettings.ShowEmailAddress)
+
+ if err != nil || len(ids) == 0 {
+ result.Data = fmt.Sprintf("%v.%v.0.%v.%v", model.CurrentVersion, model.GetMillis(), utils.Cfg.PrivacySettings.ShowFullName, utils.Cfg.PrivacySettings.ShowEmailAddress)
} else {
- result.Data = fmt.Sprintf("%v.%v.%v.%v", model.CurrentVersion, updateAt, utils.Cfg.PrivacySettings.ShowFullName, utils.Cfg.PrivacySettings.ShowEmailAddress)
+ allIds := strings.Join(ids, "")
+ result.Data = fmt.Sprintf("%v.%v.%v.%v.%v", model.CurrentVersion, md5.Sum([]byte(allIds)), len(ids), utils.Cfg.PrivacySettings.ShowFullName, utils.Cfg.PrivacySettings.ShowEmailAddress)
}
storeChannel <- result
@@ -923,34 +969,6 @@ func (us SqlUserStore) VerifyEmail(userId string) StoreChannel {
return storeChannel
}
-func (us SqlUserStore) GetForExport(teamId string) StoreChannel {
-
- storeChannel := make(StoreChannel)
-
- go func() {
- result := StoreResult{}
-
- var users []*model.User
-
- if _, err := us.GetReplica().Select(&users, "SELECT Users.* FROM Users, TeamMembers WHERE TeamMembers.TeamId = :TeamId AND Users.Id = TeamMembers.UserId", map[string]interface{}{"TeamId": teamId}); err != nil {
- result.Err = model.NewLocAppError("SqlUserStore.GetForExport", "store.sql_user.get_for_export.app_error", nil, err.Error())
- } else {
- for _, u := range users {
- u.Password = ""
- u.AuthData = new(string)
- *u.AuthData = ""
- }
-
- result.Data = users
- }
-
- storeChannel <- result
- close(storeChannel)
- }()
-
- return storeChannel
-}
-
func (us SqlUserStore) GetTotalUsersCount() StoreChannel {
storeChannel := make(StoreChannel)
diff --git a/store/store.go b/store/store.go
index f576cc2ab..4b5c0e8cd 100644
--- a/store/store.go
+++ b/store/store.go
@@ -83,7 +83,6 @@ type ChannelStore interface {
GetChannels(teamId string, userId string) StoreChannel
GetMoreChannels(teamId string, userId string) StoreChannel
GetChannelCounts(teamId string, userId string) StoreChannel
- GetForExport(teamId string) StoreChannel
GetAll(teamId string) StoreChannel
SaveMember(member *model.ChannelMember) StoreChannel
@@ -99,6 +98,7 @@ type ChannelStore interface {
CheckOpenChannelPermissions(teamId string, channelId string) StoreChannel
CheckPermissionsToByName(teamId string, channelName string, userId string) StoreChannel
UpdateLastViewedAt(channelId string, userId string) StoreChannel
+ SetLastViewedAt(channelId string, userId string, newLastViewedAt int64) StoreChannel
IncrementMentionCount(channelId string, userId string) StoreChannel
AnalyticsTypeCount(teamId string, channelType string) StoreChannel
ExtraUpdateByUser(userId string, time int64) StoreChannel
@@ -116,7 +116,6 @@ type PostStore interface {
GetPostsSince(channelId string, time int64) StoreChannel
GetEtag(channelId string) StoreChannel
Search(teamId string, userId string, params *model.SearchParams) StoreChannel
- GetForExport(channelId string) StoreChannel
AnalyticsUserCountsWithPostsByDay(teamId string) StoreChannel
AnalyticsPostCountsByDay(teamId string) StoreChannel
AnalyticsPostCount(teamId string, mustHaveFile bool, mustHaveHashtag bool) StoreChannel
@@ -150,7 +149,6 @@ type UserStore interface {
GetEtagForProfiles(teamId string) StoreChannel
GetEtagForDirectProfiles(userId string) StoreChannel
UpdateFailedPasswordAttempts(userId string, attempts int) StoreChannel
- GetForExport(teamId string) StoreChannel
GetTotalUsersCount() StoreChannel
GetTotalActiveUsersCount() StoreChannel
GetSystemAdminProfiles() StoreChannel
@@ -242,6 +240,7 @@ type PreferenceStore interface {
Get(userId string, category string, name string) StoreChannel
GetCategory(userId string, category string) StoreChannel
GetAll(userId string) StoreChannel
+ Delete(userId, category, name string) StoreChannel
PermanentDeleteByUser(userId string) StoreChannel
IsFeatureEnabled(feature, userId string) StoreChannel
}
diff --git a/templates/welcome_body.html b/templates/welcome_body.html
index b5ca9beb3..8dee99cf5 100644
--- a/templates/welcome_body.html
+++ b/templates/welcome_body.html
@@ -29,7 +29,7 @@
<tr>
<td style="border-bottom: 1px solid #ddd; padding: 0 0 20px;">
<h2 style="font-weight: normal; margin-top: 25px; line-height: 1.5;">{{.Props.Info2}}</h2>
- <a href="{{.Props.TeamURL}}">{{.Props.TeamURL}}</a>
+ <a href="{{.Props.SiteURL}}">{{.Props.SiteURL}}</a>
<p>{{.Props.Info3}}</p>
</td>
</tr>
diff --git a/vendor/github.com/kardianos/osext/LICENSE b/vendor/github.com/kardianos/osext/LICENSE
deleted file mode 100644
index 744875676..000000000
--- a/vendor/github.com/kardianos/osext/LICENSE
+++ /dev/null
@@ -1,27 +0,0 @@
-Copyright (c) 2012 The Go Authors. All rights reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
-
- * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
- * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
- * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/vendor/github.com/kardianos/osext/README.md b/vendor/github.com/kardianos/osext/README.md
deleted file mode 100644
index 61350baba..000000000
--- a/vendor/github.com/kardianos/osext/README.md
+++ /dev/null
@@ -1,16 +0,0 @@
-### Extensions to the "os" package.
-
-## Find the current Executable and ExecutableFolder.
-
-There is sometimes utility in finding the current executable file
-that is running. This can be used for upgrading the current executable
-or finding resources located relative to the executable file. Both
-working directory and the os.Args[0] value are arbitrary and cannot
-be relied on; os.Args[0] can be "faked".
-
-Multi-platform and supports:
- * Linux
- * OS X
- * Windows
- * Plan 9
- * BSDs.
diff --git a/vendor/github.com/kardianos/osext/osext.go b/vendor/github.com/kardianos/osext/osext.go
deleted file mode 100644
index 17f380f0e..000000000
--- a/vendor/github.com/kardianos/osext/osext.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright 2012 The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-// Extensions to the standard "os" package.
-package osext // import "github.com/kardianos/osext"
-
-import "path/filepath"
-
-var cx, ce = executableClean()
-
-func executableClean() (string, error) {
- p, err := executable()
- return filepath.Clean(p), err
-}
-
-// Executable returns an absolute path that can be used to
-// re-invoke the current program.
-// It may not be valid after the current program exits.
-func Executable() (string, error) {
- return cx, ce
-}
-
-// Returns same path as Executable, returns just the folder
-// path. Excludes the executable name and any trailing slash.
-func ExecutableFolder() (string, error) {
- p, err := Executable()
- if err != nil {
- return "", err
- }
-
- return filepath.Dir(p), nil
-}
diff --git a/vendor/github.com/kardianos/osext/osext_plan9.go b/vendor/github.com/kardianos/osext/osext_plan9.go
deleted file mode 100644
index 655750c54..000000000
--- a/vendor/github.com/kardianos/osext/osext_plan9.go
+++ /dev/null
@@ -1,20 +0,0 @@
-// Copyright 2012 The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package osext
-
-import (
- "os"
- "strconv"
- "syscall"
-)
-
-func executable() (string, error) {
- f, err := os.Open("/proc/" + strconv.Itoa(os.Getpid()) + "/text")
- if err != nil {
- return "", err
- }
- defer f.Close()
- return syscall.Fd2path(int(f.Fd()))
-}
diff --git a/vendor/github.com/kardianos/osext/osext_procfs.go b/vendor/github.com/kardianos/osext/osext_procfs.go
deleted file mode 100644
index d59847ee5..000000000
--- a/vendor/github.com/kardianos/osext/osext_procfs.go
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright 2012 The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-// +build linux netbsd solaris dragonfly
-
-package osext
-
-import (
- "errors"
- "fmt"
- "os"
- "runtime"
- "strings"
-)
-
-func executable() (string, error) {
- switch runtime.GOOS {
- case "linux":
- const deletedTag = " (deleted)"
- execpath, err := os.Readlink("/proc/self/exe")
- if err != nil {
- return execpath, err
- }
- execpath = strings.TrimSuffix(execpath, deletedTag)
- execpath = strings.TrimPrefix(execpath, deletedTag)
- return execpath, nil
- case "netbsd":
- return os.Readlink("/proc/curproc/exe")
- case "dragonfly":
- return os.Readlink("/proc/curproc/file")
- case "solaris":
- return os.Readlink(fmt.Sprintf("/proc/%d/path/a.out", os.Getpid()))
- }
- return "", errors.New("ExecPath not implemented for " + runtime.GOOS)
-}
diff --git a/vendor/github.com/kardianos/osext/osext_sysctl.go b/vendor/github.com/kardianos/osext/osext_sysctl.go
deleted file mode 100644
index 66da0bcf9..000000000
--- a/vendor/github.com/kardianos/osext/osext_sysctl.go
+++ /dev/null
@@ -1,126 +0,0 @@
-// Copyright 2012 The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-// +build darwin freebsd openbsd
-
-package osext
-
-import (
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "syscall"
- "unsafe"
-)
-
-var initCwd, initCwdErr = os.Getwd()
-
-func executable() (string, error) {
- var mib [4]int32
- switch runtime.GOOS {
- case "freebsd":
- mib = [4]int32{1 /* CTL_KERN */, 14 /* KERN_PROC */, 12 /* KERN_PROC_PATHNAME */, -1}
- case "darwin":
- mib = [4]int32{1 /* CTL_KERN */, 38 /* KERN_PROCARGS */, int32(os.Getpid()), -1}
- case "openbsd":
- mib = [4]int32{1 /* CTL_KERN */, 55 /* KERN_PROC_ARGS */, int32(os.Getpid()), 1 /* KERN_PROC_ARGV */}
- }
-
- n := uintptr(0)
- // Get length.
- _, _, errNum := syscall.Syscall6(syscall.SYS___SYSCTL, uintptr(unsafe.Pointer(&mib[0])), 4, 0, uintptr(unsafe.Pointer(&n)), 0, 0)
- if errNum != 0 {
- return "", errNum
- }
- if n == 0 { // This shouldn't happen.
- return "", nil
- }
- buf := make([]byte, n)
- _, _, errNum = syscall.Syscall6(syscall.SYS___SYSCTL, uintptr(unsafe.Pointer(&mib[0])), 4, uintptr(unsafe.Pointer(&buf[0])), uintptr(unsafe.Pointer(&n)), 0, 0)
- if errNum != 0 {
- return "", errNum
- }
- if n == 0 { // This shouldn't happen.
- return "", nil
- }
-
- var execPath string
- switch runtime.GOOS {
- case "openbsd":
- // buf now contains **argv, with pointers to each of the C-style
- // NULL terminated arguments.
- var args []string
- argv := uintptr(unsafe.Pointer(&buf[0]))
- Loop:
- for {
- argp := *(**[1 << 20]byte)(unsafe.Pointer(argv))
- if argp == nil {
- break
- }
- for i := 0; uintptr(i) < n; i++ {
- // we don't want the full arguments list
- if string(argp[i]) == " " {
- break Loop
- }
- if argp[i] != 0 {
- continue
- }
- args = append(args, string(argp[:i]))
- n -= uintptr(i)
- break
- }
- if n < unsafe.Sizeof(argv) {
- break
- }
- argv += unsafe.Sizeof(argv)
- n -= unsafe.Sizeof(argv)
- }
- execPath = args[0]
- // There is no canonical way to get an executable path on
- // OpenBSD, so check PATH in case we are called directly
- if execPath[0] != '/' && execPath[0] != '.' {
- execIsInPath, err := exec.LookPath(execPath)
- if err == nil {
- execPath = execIsInPath
- }
- }
- default:
- for i, v := range buf {
- if v == 0 {
- buf = buf[:i]
- break
- }
- }
- execPath = string(buf)
- }
-
- var err error
- // execPath will not be empty due to above checks.
- // Try to get the absolute path if the execPath is not rooted.
- if execPath[0] != '/' {
- execPath, err = getAbs(execPath)
- if err != nil {
- return execPath, err
- }
- }
- // For darwin KERN_PROCARGS may return the path to a symlink rather than the
- // actual executable.
- if runtime.GOOS == "darwin" {
- if execPath, err = filepath.EvalSymlinks(execPath); err != nil {
- return execPath, err
- }
- }
- return execPath, nil
-}
-
-func getAbs(execPath string) (string, error) {
- if initCwdErr != nil {
- return execPath, initCwdErr
- }
- // The execPath may begin with a "../" or a "./" so clean it first.
- // Join the two paths, trailing and starting slashes undetermined, so use
- // the generic Join function.
- return filepath.Join(initCwd, filepath.Clean(execPath)), nil
-}
diff --git a/vendor/github.com/kardianos/osext/osext_test.go b/vendor/github.com/kardianos/osext/osext_test.go
deleted file mode 100644
index eb18236c0..000000000
--- a/vendor/github.com/kardianos/osext/osext_test.go
+++ /dev/null
@@ -1,203 +0,0 @@
-// Copyright 2012 The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-// +build darwin linux freebsd netbsd windows openbsd
-
-package osext
-
-import (
- "bytes"
- "fmt"
- "io"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "testing"
-)
-
-const (
- executableEnvVar = "OSTEST_OUTPUT_EXECUTABLE"
-
- executableEnvValueMatch = "match"
- executableEnvValueDelete = "delete"
-)
-
-func TestPrintExecutable(t *testing.T) {
- ef, err := Executable()
- if err != nil {
- t.Fatalf("Executable failed: %v", err)
- }
- t.Log("Executable:", ef)
-}
-func TestPrintExecutableFolder(t *testing.T) {
- ef, err := ExecutableFolder()
- if err != nil {
- t.Fatalf("ExecutableFolder failed: %v", err)
- }
- t.Log("Executable Folder:", ef)
-}
-func TestExecutableFolder(t *testing.T) {
- ef, err := ExecutableFolder()
- if err != nil {
- t.Fatalf("ExecutableFolder failed: %v", err)
- }
- if ef[len(ef)-1] == filepath.Separator {
- t.Fatal("ExecutableFolder ends with a trailing slash.")
- }
-}
-func TestExecutableMatch(t *testing.T) {
- ep, err := Executable()
- if err != nil {
- t.Fatalf("Executable failed: %v", err)
- }
-
- // fullpath to be of the form "dir/prog".
- dir := filepath.Dir(filepath.Dir(ep))
- fullpath, err := filepath.Rel(dir, ep)
- if err != nil {
- t.Fatalf("filepath.Rel: %v", err)
- }
- // Make child start with a relative program path.
- // Alter argv[0] for child to verify getting real path without argv[0].
- cmd := &exec.Cmd{
- Dir: dir,
- Path: fullpath,
- Env: []string{fmt.Sprintf("%s=%s", executableEnvVar, executableEnvValueMatch)},
- }
- out, err := cmd.CombinedOutput()
- if err != nil {
- t.Fatalf("exec(self) failed: %v", err)
- }
- outs := string(out)
- if !filepath.IsAbs(outs) {
- t.Fatalf("Child returned %q, want an absolute path", out)
- }
- if !sameFile(outs, ep) {
- t.Fatalf("Child returned %q, not the same file as %q", out, ep)
- }
-}
-
-func TestExecutableDelete(t *testing.T) {
- if runtime.GOOS != "linux" {
- t.Skip()
- }
- fpath, err := Executable()
- if err != nil {
- t.Fatalf("Executable failed: %v", err)
- }
-
- r, w := io.Pipe()
- stderrBuff := &bytes.Buffer{}
- stdoutBuff := &bytes.Buffer{}
- cmd := &exec.Cmd{
- Path: fpath,
- Env: []string{fmt.Sprintf("%s=%s", executableEnvVar, executableEnvValueDelete)},
- Stdin: r,
- Stderr: stderrBuff,
- Stdout: stdoutBuff,
- }
- err = cmd.Start()
- if err != nil {
- t.Fatalf("exec(self) start failed: %v", err)
- }
-
- tempPath := fpath + "_copy"
- _ = os.Remove(tempPath)
-
- err = copyFile(tempPath, fpath)
- if err != nil {
- t.Fatalf("copy file failed: %v", err)
- }
- err = os.Remove(fpath)
- if err != nil {
- t.Fatalf("remove running test file failed: %v", err)
- }
- err = os.Rename(tempPath, fpath)
- if err != nil {
- t.Fatalf("rename copy to previous name failed: %v", err)
- }
-
- w.Write([]byte{0})
- w.Close()
-
- err = cmd.Wait()
- if err != nil {
- t.Fatalf("exec wait failed: %v", err)
- }
-
- childPath := stderrBuff.String()
- if !filepath.IsAbs(childPath) {
- t.Fatalf("Child returned %q, want an absolute path", childPath)
- }
- if !sameFile(childPath, fpath) {
- t.Fatalf("Child returned %q, not the same file as %q", childPath, fpath)
- }
-}
-
-func sameFile(fn1, fn2 string) bool {
- fi1, err := os.Stat(fn1)
- if err != nil {
- return false
- }
- fi2, err := os.Stat(fn2)
- if err != nil {
- return false
- }
- return os.SameFile(fi1, fi2)
-}
-func copyFile(dest, src string) error {
- df, err := os.Create(dest)
- if err != nil {
- return err
- }
- defer df.Close()
-
- sf, err := os.Open(src)
- if err != nil {
- return err
- }
- defer sf.Close()
-
- _, err = io.Copy(df, sf)
- return err
-}
-
-func TestMain(m *testing.M) {
- env := os.Getenv(executableEnvVar)
- switch env {
- case "":
- os.Exit(m.Run())
- case executableEnvValueMatch:
- // First chdir to another path.
- dir := "/"
- if runtime.GOOS == "windows" {
- dir = filepath.VolumeName(".")
- }
- os.Chdir(dir)
- if ep, err := Executable(); err != nil {
- fmt.Fprint(os.Stderr, "ERROR: ", err)
- } else {
- fmt.Fprint(os.Stderr, ep)
- }
- case executableEnvValueDelete:
- bb := make([]byte, 1)
- var err error
- n, err := os.Stdin.Read(bb)
- if err != nil {
- fmt.Fprint(os.Stderr, "ERROR: ", err)
- os.Exit(2)
- }
- if n != 1 {
- fmt.Fprint(os.Stderr, "ERROR: n != 1, n == ", n)
- os.Exit(2)
- }
- if ep, err := Executable(); err != nil {
- fmt.Fprint(os.Stderr, "ERROR: ", err)
- } else {
- fmt.Fprint(os.Stderr, ep)
- }
- }
- os.Exit(0)
-}
diff --git a/vendor/github.com/kardianos/osext/osext_windows.go b/vendor/github.com/kardianos/osext/osext_windows.go
deleted file mode 100644
index 72d282cf8..000000000
--- a/vendor/github.com/kardianos/osext/osext_windows.go
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright 2012 The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package osext
-
-import (
- "syscall"
- "unicode/utf16"
- "unsafe"
-)
-
-var (
- kernel = syscall.MustLoadDLL("kernel32.dll")
- getModuleFileNameProc = kernel.MustFindProc("GetModuleFileNameW")
-)
-
-// GetModuleFileName() with hModule = NULL
-func executable() (exePath string, err error) {
- return getModuleFileName()
-}
-
-func getModuleFileName() (string, error) {
- var n uint32
- b := make([]uint16, syscall.MAX_PATH)
- size := uint32(len(b))
-
- r0, _, e1 := getModuleFileNameProc.Call(0, uintptr(unsafe.Pointer(&b[0])), uintptr(size))
- n = uint32(r0)
- if n == 0 {
- return "", e1
- }
- return string(utf16.Decode(b[0:n])), nil
-}
diff --git a/web/web.go b/web/web.go
index 8e96edd69..1b50bf474 100644
--- a/web/web.go
+++ b/web/web.go
@@ -29,15 +29,22 @@ func InitWeb() {
staticDir := utils.FindDir(CLIENT_DIR)
l4g.Debug("Using client directory at %v", staticDir)
if *utils.Cfg.ServiceSettings.WebserverMode == "gzip" {
- mainrouter.PathPrefix("/static/").Handler(gziphandler.GzipHandler(http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))))
+ mainrouter.PathPrefix("/static/").Handler(gziphandler.GzipHandler(staticHandler(http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))))))
} else {
- mainrouter.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))))
+ mainrouter.PathPrefix("/static/").Handler(staticHandler(http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))))
}
mainrouter.Handle("/{anything:.*}", api.AppHandlerIndependent(root)).Methods("GET")
}
}
+func staticHandler(handler http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "max-age=31556926, public")
+ handler.ServeHTTP(w, r)
+ })
+}
+
var browsersNotSupported string = "MSIE/8;MSIE/9;MSIE/10;Internet Explorer/8;Internet Explorer/9;Internet Explorer/10;Safari/7;Safari/8"
func CheckBrowserCompatability(c *api.Context, r *http.Request) bool {
@@ -68,5 +75,6 @@ func root(c *api.Context, w http.ResponseWriter, r *http.Request) {
return
}
+ w.Header().Set("Cache-Control", "no-cache, max-age=31556926, public")
http.ServeFile(w, r, utils.FindDir(CLIENT_DIR)+"root.html")
}
diff --git a/webapp/.eslintrc.json b/webapp/.eslintrc.json
index dada7f164..ad28561f1 100644
--- a/webapp/.eslintrc.json
+++ b/webapp/.eslintrc.json
@@ -59,6 +59,7 @@
"keyword-spacing": [2, {"before": true, "after": true, "overrides": {}}],
"linebreak-style": 2,
"lines-around-comment": [2, { "beforeBlockComment": true, "beforeLineComment": true, "allowBlockStart": true, "allowBlockEnd": true }],
+ "max-lines": [1, {"max": 450, "skipBlankLines": true, "skipComments": false}],
"max-nested-callbacks": [1, {"max":1}],
"max-nested-callbacks": [2, {"max":2}],
"max-statements-per-line": [2, {"max": 1}],
@@ -92,6 +93,7 @@
"no-extend-native": 2,
"no-extra-bind": 2,
"no-extra-label": 2,
+ "no-extra-parens": 0,
"no-extra-semi": 2,
"no-fallthrough": 2,
"no-floating-decimal": 2,
@@ -107,6 +109,7 @@
"no-lonely-if": 2,
"no-loop-func": 2,
"no-magic-numbers": [1, { "ignore": [-1, 0, 1, 2], "enforceConst": true, "detectObjects": true } ],
+ "no-mixed-operators": [2, {"allowSamePrecedence": false}],
"no-mixed-spaces-and-tabs": 2,
"no-multi-spaces": [2, { "exceptions": { "Property": false } }],
"no-multi-str": 0,
@@ -152,13 +155,16 @@
"no-useless-concat": 2,
"no-useless-constructor": 2,
"no-useless-escape": 2,
+ "no-useless-rename": 2,
"no-var": 0,
"no-void": 2,
"no-warning-comments": 1,
"no-whitespace-before-property": 2,
"no-with": 2,
+ "object-curly-newline": 0,
"object-curly-spacing": [2, "never"],
- "object-shorthand": [1, "always"],
+ "object-property-newline": [2, {"allowMultiplePropertiesPerLine": true}],
+ "object-shorthand": [2, "always"],
"one-var": [2, "never"],
"one-var-declaration-per-line": 0,
"operator-linebreak": [2, "after"],
@@ -189,9 +195,11 @@
"react/jsx-no-target-blank": 2,
"react/jsx-no-undef": 2,
"react/jsx-pascal-case": 2,
+ "react/jsx-filename-extension": 2,
"react/jsx-space-before-closing": [2, "never"],
"react/jsx-uses-react": 2,
"react/jsx-uses-vars": 2,
+ "react/no-comment-textnodes": 2,
"react/no-danger": 0,
"react/no-deprecated": 2,
"react/no-did-mount-set-state": 2,
@@ -199,17 +207,20 @@
"react/no-direct-mutation-state": 2,
"react/no-is-mounted": 2,
"react/no-multi-comp": [2, { "ignoreStateless": true }],
+ "react/no-render-return-value": 2,
"react/no-set-state": 0,
"react/no-string-refs": 0,
"react/no-unknown-property": 2,
"react/prefer-es6-class": 2,
"react/prefer-stateless-function": 0,
"react/prop-types": 2,
+ "react/require-optimization": 1,
"react/require-render-return": 2,
"react/self-closing-comp": 2,
"react/sort-comp": 0,
"react/wrap-multilines": 2,
"require-yield": 2,
+ "rest-spread-spacing": [2, "never"],
"semi": [2, "always"],
"semi-spacing": [2, {"before": false, "after": true}],
"sort-imports": 0,
diff --git a/webapp/Makefile b/webapp/Makefile
index 81e00aec6..10712deff 100644
--- a/webapp/Makefile
+++ b/webapp/Makefile
@@ -20,6 +20,8 @@ test: .npminstall
build: .npminstall
@echo Building mattermost Webapp
+ rm -rf dist
+
npm run build
run: .npminstall
diff --git a/webapp/actions/channel_actions.jsx b/webapp/actions/channel_actions.jsx
index 9e5ecb03b..a590f9a9b 100644
--- a/webapp/actions/channel_actions.jsx
+++ b/webapp/actions/channel_actions.jsx
@@ -5,6 +5,8 @@ import {browserHistory} from 'react-router/es6';
import * as Utils from 'utils/utils.jsx';
import TeamStore from 'stores/team_store.jsx';
import UserStore from 'stores/user_store.jsx';
+import ChannelStore from 'stores/channel_store.jsx';
+import * as AsyncClient from 'utils/async_client.jsx';
import Client from 'utils/web_client.jsx';
export function goToChannel(channel) {
@@ -22,5 +24,27 @@ export function goToChannel(channel) {
}
export function executeCommand(channelId, message, suggest, success, error) {
- Client.executeCommand(channelId, message, suggest, success, error);
+ let msg = message;
+
+ msg = msg.substring(0, msg.indexOf(' ')).toLowerCase() + msg.substring(msg.indexOf(' '), msg.length);
+
+ if (message.indexOf('/shortcuts') !== -1 && Utils.isMac()) {
+ msg += ' mac';
+ }
+
+ if (!Utils.isMac() && message.indexOf('/shortcuts') !== -1 && message.indexOf('mac') !== -1) {
+ msg = '/shortcuts';
+ }
+
+ Client.executeCommand(channelId, msg, suggest, success, error);
+}
+
+export function setChannelAsRead(channelIdParam) {
+ const channelId = channelIdParam || ChannelStore.getCurrentId();
+ AsyncClient.updateLastViewedAt();
+ ChannelStore.resetCounts(channelId);
+ ChannelStore.emitChange();
+ if (channelId === ChannelStore.getCurrentId()) {
+ ChannelStore.emitLastViewed(Number.MAX_VALUE, false);
+ }
}
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/post_actions.jsx b/webapp/actions/post_actions.jsx
index 2b55e31ef..a6b464a24 100644
--- a/webapp/actions/post_actions.jsx
+++ b/webapp/actions/post_actions.jsx
@@ -6,7 +6,9 @@ import AppDispatcher from 'dispatcher/app_dispatcher.jsx';
import ChannelStore from 'stores/channel_store.jsx';
import PostStore from 'stores/post_store.jsx';
import TeamStore from 'stores/team_store.jsx';
+import UserStore from 'stores/user_store.jsx';
+import * as PostUtils from 'utils/post_utils.jsx';
import Constants from 'utils/constants.jsx';
const ActionTypes = Constants.ActionTypes;
@@ -26,7 +28,7 @@ export function handleNewPost(post, msg) {
var websocketMessageProps = null;
if (msg) {
- websocketMessageProps = msg.props;
+ websocketMessageProps = msg.data;
}
if (post.root_id && PostStore.getPost(post.channel_id, post.root_id) == null) {
@@ -62,3 +64,53 @@ export function handleNewPost(post, msg) {
websocketMessageProps
});
}
+
+export function setUnreadPost(channelId, postId) {
+ let lastViewed = 0;
+ let ownNewMessage = false;
+ const post = PostStore.getPost(channelId, postId);
+ const posts = PostStore.getVisiblePosts(channelId).posts;
+ var currentUsedId = UserStore.getCurrentId();
+ if (currentUsedId === post.user_id || PostUtils.isSystemMessage(post)) {
+ for (const otherPostId in posts) {
+ if (lastViewed < posts[otherPostId].create_at && currentUsedId !== posts[otherPostId].user_id && !PostUtils.isSystemMessage(posts[otherPostId])) {
+ lastViewed = posts[otherPostId].create_at;
+ }
+ }
+ if (lastViewed === 0) {
+ lastViewed = Number.MAX_VALUE;
+ } else if (lastViewed > post.create_at) {
+ lastViewed = post.create_at - 1;
+ ownNewMessage = true;
+ } else {
+ lastViewed -= 1;
+ }
+ } else {
+ lastViewed = post.create_at - 1;
+ }
+
+ if (lastViewed === Number.MAX_VALUE) {
+ AsyncClient.updateLastViewedAt();
+ ChannelStore.resetCounts(ChannelStore.getCurrentId());
+ ChannelStore.emitChange();
+ } else {
+ let unreadPosts = 0;
+ for (const otherPostId in posts) {
+ if (posts[otherPostId].create_at > lastViewed) {
+ unreadPosts += 1;
+ }
+ }
+ const member = ChannelStore.getMember(channelId);
+ const channel = ChannelStore.get(channelId);
+ member.last_viewed_at = lastViewed;
+ member.msg_count = channel.total_msg_count - unreadPosts;
+ member.mention_count = 0;
+ ChannelStore.setChannelMember(member);
+ ChannelStore.setUnreadCount(channelId);
+ AsyncClient.setLastViewedAt(lastViewed, channelId);
+ }
+
+ if (channelId === ChannelStore.getCurrentId()) {
+ ChannelStore.emitLastViewed(lastViewed, ownNewMessage);
+ }
+}
diff --git a/webapp/actions/user_actions.jsx b/webapp/actions/user_actions.jsx
index 2f6eb9942..6d14e9fba 100644
--- a/webapp/actions/user_actions.jsx
+++ b/webapp/actions/user_actions.jsx
@@ -1,10 +1,15 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-import Client from 'utils/web_client.jsx';
+import AppDispatcher from 'dispatcher/app_dispatcher.jsx';
import * as AsyncClient from 'utils/async_client.jsx';
+import Client from 'utils/web_client.jsx';
+import PreferenceStore from 'stores/preference_store.jsx';
import TeamStore from 'stores/team_store.jsx';
+import UserStore from 'stores/user_store.jsx';
+
+import {ActionTypes, Preferences} from 'utils/constants.jsx';
export function switchFromLdapToEmail(email, password, ldapPassword, onSuccess, onError) {
Client.ldapToEmail(
@@ -28,3 +33,52 @@ export function getMoreDmList() {
AsyncClient.getProfilesForDirectMessageList();
AsyncClient.getTeamMembers(TeamStore.getCurrentId());
}
+
+export function saveTheme(teamId, theme, onSuccess, onError) {
+ AsyncClient.savePreference(
+ Preferences.CATEGORY_THEME,
+ teamId,
+ JSON.stringify(theme),
+ () => {
+ onThemeSaved(teamId, theme, onSuccess);
+ },
+ (err) => {
+ onError(err);
+ }
+ );
+}
+
+function onThemeSaved(teamId, theme, onSuccess) {
+ const themePreferences = PreferenceStore.getCategory(Preferences.CATEGORY_THEME);
+
+ if (teamId !== '' && themePreferences.size > 1) {
+ // no extra handling to be done to delete team-specific themes
+ onSuccess();
+ return;
+ }
+
+ const toDelete = [];
+
+ for (const [name] of themePreferences) {
+ if (name === '') {
+ continue;
+ }
+
+ toDelete.push({
+ user_id: UserStore.getCurrentId(),
+ category: Preferences.CATEGORY_THEME,
+ name
+ });
+ }
+
+ // we're saving a new global theme so delete any team-specific ones
+ AsyncClient.deletePreferences(toDelete);
+
+ // delete them locally before we hear from the server so that the UI flow is smoother
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.DELETED_PREFERENCES,
+ preferences: toDelete
+ });
+
+ onSuccess();
+} \ No newline at end of file
diff --git a/webapp/actions/websocket_actions.jsx b/webapp/actions/websocket_actions.jsx
index 7be9d84f3..f7e6adf5d 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);
}
@@ -234,7 +156,6 @@ function handleLeaveTeamEvent(msg) {
}
} else if (TeamStore.getCurrentId() === msg.team_id) {
UserActions.getMoreDmList();
- GlobalActions.emitProfilesForDmList();
}
}
@@ -257,12 +178,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 +211,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/about_build_modal.jsx b/webapp/components/about_build_modal.jsx
index 197179191..1f41d76f9 100644
--- a/webapp/components/about_build_modal.jsx
+++ b/webapp/components/about_build_modal.jsx
@@ -104,6 +104,11 @@ export default class AboutBuildModal extends React.Component {
}
}
+ let version = '\u00a0' + config.Version;
+ if (config.BuildNumber !== config.Version) {
+ version += '\u00a0 (' + config.BuildNumber + ')';
+ }
+
return (
<Modal
dialogClassName='about-modal'
@@ -135,7 +140,7 @@ export default class AboutBuildModal extends React.Component {
id='about.version'
defaultMessage='Version:'
/>
- {'\u00a0' + config.Version + '\u00a0 (' + config.BuildNumber + ')'}
+ {version}
</div>
<div>
<FormattedMessage
diff --git a/webapp/components/admin_console/admin_settings.jsx b/webapp/components/admin_console/admin_settings.jsx
index e11d843a7..e29be33d1 100644
--- a/webapp/components/admin_console/admin_settings.jsx
+++ b/webapp/components/admin_console/admin_settings.jsx
@@ -106,11 +106,6 @@ export default class AdminSettings extends React.Component {
}
render() {
- let saveClass = 'btn';
- if (this.state.saveNeeded) {
- saveClass += 'btn-primary';
- }
-
return (
<div className='wrapper--fixed'>
{this.renderTitle()}
diff --git a/webapp/components/admin_console/custom_brand_settings.jsx b/webapp/components/admin_console/custom_brand_settings.jsx
index 193889ea9..b4026c4a9 100644
--- a/webapp/components/admin_console/custom_brand_settings.jsx
+++ b/webapp/components/admin_console/custom_brand_settings.jsx
@@ -11,6 +11,7 @@ import BrandImageSetting from './brand_image_setting.jsx';
import {FormattedMessage} from 'react-intl';
import SettingsGroup from './settings_group.jsx';
import TextSetting from './text_setting.jsx';
+import Constants from 'utils/constants.jsx';
export default class CustomBrandSettings extends AdminSettings {
constructor(props) {
@@ -115,6 +116,7 @@ export default class CustomBrandSettings extends AdminSettings {
defaultMessage='Site Name:'
/>
}
+ maxLength={Constants.MAX_SITENAME_LENGTH}
placeholder={Utils.localizeMessage('admin.team.siteNameExample', 'Ex "Mattermost"')}
helpText={
<FormattedMessage
diff --git a/webapp/components/admin_console/external_service_settings.jsx b/webapp/components/admin_console/external_service_settings.jsx
index ebeb78332..59a129fc0 100644
--- a/webapp/components/admin_console/external_service_settings.jsx
+++ b/webapp/components/admin_console/external_service_settings.jsx
@@ -77,7 +77,7 @@ export default class ExternalServiceSettings extends AdminSettings {
helpText={
<FormattedHTMLMessage
id='admin.service.googleDescription'
- defaultMessage='Set this key to enable embedding of YouTube video previews based on hyperlinks appearing in messages or comments. Instructions to obtain a key available at <a href="https://www.youtube.com/watch?v=Im69kzhpR3I" target="_blank">https://www.youtube.com/watch?v=Im69kzhpR3I</a>. Leaving the field blank disables the automatic generation of YouTube video previews from links.'
+ defaultMessage='Set this key to enable the display of titles for embedded YouTube video previews. Without the key, YouTube previews will still be created based on hyperlinks appearing in messages or comments but they will not show the video title. View a <a href="https://www.youtube.com/watch?v=Im69kzhpR3I" target="_blank">Google Developers Tutorial</a> for instructions on how to obtain a key.'
/>
}
value={this.state.googleDeveloperKey}
diff --git a/webapp/components/admin_console/team_users.jsx b/webapp/components/admin_console/team_users.jsx
index 3ec375627..f82c20a86 100644
--- a/webapp/components/admin_console/team_users.jsx
+++ b/webapp/components/admin_console/team_users.jsx
@@ -186,7 +186,7 @@ export default class UserList extends React.Component {
var memberList = this.state.users.map((user) => {
var teamMember = this.getTeamMemberForUser(user.id);
- if (teamMember.delete_at > 0) {
+ if (!teamMember || teamMember.delete_at > 0) {
return null;
}
diff --git a/webapp/components/admin_console/text_setting.jsx b/webapp/components/admin_console/text_setting.jsx
index bb37f8e29..a5844aca7 100644
--- a/webapp/components/admin_console/text_setting.jsx
+++ b/webapp/components/admin_console/text_setting.jsx
@@ -4,6 +4,7 @@
import React from 'react';
import Setting from './setting.jsx';
+import Constants from 'utils/constants.jsx';
export default class TextSetting extends React.Component {
static get propTypes() {
@@ -16,6 +17,7 @@ export default class TextSetting extends React.Component {
React.PropTypes.string,
React.PropTypes.number
]).isRequired,
+ maxLength: React.PropTypes.number,
onChange: React.PropTypes.func.isRequired,
disabled: React.PropTypes.bool,
type: React.PropTypes.oneOf([
@@ -27,7 +29,8 @@ export default class TextSetting extends React.Component {
static get defaultProps() {
return {
- type: 'input'
+ type: 'input',
+ maxLength: Constants.MAX_TEXTSETTING_LENGTH
};
}
@@ -51,6 +54,7 @@ export default class TextSetting extends React.Component {
type='text'
placeholder={this.props.placeholder}
value={this.props.value}
+ maxLength={this.props.maxLength}
onChange={this.handleChange}
disabled={this.props.disabled}
/>
diff --git a/webapp/components/admin_console/user_item.jsx b/webapp/components/admin_console/user_item.jsx
index e6c4f637c..79dbd5639 100644
--- a/webapp/components/admin_console/user_item.jsx
+++ b/webapp/components/admin_console/user_item.jsx
@@ -505,6 +505,11 @@ export default class UserItem extends React.Component {
);
}
+ let displayedName = Utils.getDisplayName(user);
+ if (displayedName !== user.username) {
+ displayedName += ' (@' + user.username + ')';
+ }
+
return (
<div className='more-modal__row'>
<img
@@ -514,7 +519,7 @@ export default class UserItem extends React.Component {
width='36'
/>
<div className='more-modal__details'>
- <div className='more-modal__name'>{Utils.getDisplayName(user)}</div>
+ <div className='more-modal__name'>{displayedName}</div>
<div className='more-modal__description'>
<FormattedHTMLMessage
id='admin.user_item.emailTitle'
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/channel_info_modal.jsx b/webapp/components/channel_info_modal.jsx
index 7bd004411..b0e2c63fa 100644
--- a/webapp/components/channel_info_modal.jsx
+++ b/webapp/components/channel_info_modal.jsx
@@ -5,6 +5,7 @@ import * as Utils from 'utils/utils.jsx';
import {FormattedMessage} from 'react-intl';
import {Modal} from 'react-bootstrap';
+import * as TextFormatting from 'utils/text_formatting.jsx';
import React from 'react';
@@ -32,6 +33,7 @@ export default class ChannelInfoModal extends React.Component {
display_name: notFound,
name: notFound,
purpose: notFound,
+ header: notFound,
id: notFound
};
}
@@ -44,6 +46,39 @@ export default class ChannelInfoModal extends React.Component {
const channelURL = Utils.getTeamURLFromAddressBar() + '/channels/' + channel.name;
+ let channelPurpose = null;
+ if (channel.purpose) {
+ channelPurpose = (
+ <div className='form-group'>
+ <div className='info__label'>
+ <FormattedMessage
+ id='channel_info.purpose'
+ defaultMessage='Purpose:'
+ />
+ </div>
+ <div className='info__value'>{channel.purpose}</div>
+ </div>
+ );
+ }
+
+ let channelHeader = null;
+ if (channel.header) {
+ channelHeader = (
+ <div className='form-group'>
+ <div className='info__label'>
+ <FormattedMessage
+ id='channel_info.header'
+ defaultMessage='Header:'
+ />
+ </div>
+ <div
+ className='info__value'
+ dangerouslySetInnerHTML={{__html: TextFormatting.formatText(channel.header, {singleline: false, mentionHighlight: false})}}
+ />
+ </div>
+ );
+ }
+
return (
<Modal
dialogClassName='about-modal'
@@ -60,15 +95,8 @@ export default class ChannelInfoModal extends React.Component {
</Modal.Title>
</Modal.Header>
<Modal.Body ref='modalBody'>
- <div className='form-group'>
- <div className='info__label'>
- <FormattedMessage
- id='channel_info.purpose'
- defaultMessage='Purpose:'
- />
- </div>
- <div className='info__value'>{channel.purpose}</div>
- </div>
+ {channelPurpose}
+ {channelHeader}
<div className='form-group'>
<div className='info__label'>
<FormattedMessage
diff --git a/webapp/components/channel_switch_modal.jsx b/webapp/components/channel_switch_modal.jsx
index 9bb98d74d..18e0f9f59 100644
--- a/webapp/components/channel_switch_modal.jsx
+++ b/webapp/components/channel_switch_modal.jsx
@@ -21,7 +21,7 @@ export default class SwitchChannelModal extends React.Component {
constructor() {
super();
- this.onUserInput = this.onUserInput.bind(this);
+ this.onInput = this.onInput.bind(this);
this.onShow = this.onShow.bind(this);
this.onHide = this.onHide.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
@@ -57,8 +57,8 @@ export default class SwitchChannelModal extends React.Component {
this.props.onHide();
}
- onUserInput(message) {
- this.setState({text: message});
+ onInput(e) {
+ this.setState({text: e.target.value});
}
handleKeyDown(e) {
@@ -122,7 +122,7 @@ export default class SwitchChannelModal extends React.Component {
ref='search'
className='form-control focused'
type='input'
- onUserInput={this.onUserInput}
+ onInput={this.onInput}
value={this.state.text}
onKeyDown={this.handleKeyDown}
listComponent={SuggestionList}
diff --git a/webapp/components/create_comment.jsx b/webapp/components/create_comment.jsx
index f7564f396..bf23f7b44 100644
--- a/webapp/components/create_comment.jsx
+++ b/webapp/components/create_comment.jsx
@@ -19,33 +19,14 @@ import * as GlobalActions from 'actions/global_actions.jsx';
import Constants from 'utils/constants.jsx';
-import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'react-intl';
+import {FormattedMessage} from 'react-intl';
const ActionTypes = Constants.ActionTypes;
const KeyCodes = Constants.KeyCodes;
-const holders = defineMessages({
- commentLength: {
- id: 'create_comment.commentLength',
- defaultMessage: 'Comment length must be less than {max} characters.'
- },
- comment: {
- id: 'create_comment.comment',
- defaultMessage: 'Add Comment'
- },
- addComment: {
- id: 'create_comment.addComment',
- defaultMessage: 'Add a comment...'
- },
- commentTitle: {
- id: 'create_comment.commentTitle',
- defaultMessage: 'Comment'
- }
-});
-
import React from 'react';
-class CreateComment extends React.Component {
+export default class CreateComment extends React.Component {
constructor(props) {
super(props);
@@ -53,7 +34,7 @@ class CreateComment extends React.Component {
this.handleSubmit = this.handleSubmit.bind(this);
this.commentMsgKeyPress = this.commentMsgKeyPress.bind(this);
- this.handleUserInput = this.handleUserInput.bind(this);
+ this.handleInput = this.handleInput.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleUploadClick = this.handleUploadClick.bind(this);
this.handleUploadStart = this.handleUploadStart.bind(this);
@@ -76,8 +57,7 @@ class CreateComment extends React.Component {
previews: draft.previews,
submitting: false,
ctrlSend: PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter'),
- showPostDeletedModal: false,
- typing: false
+ showPostDeletedModal: false
};
}
@@ -126,7 +106,15 @@ class CreateComment extends React.Component {
}
if (post.message.length > Constants.CHARACTER_LIMIT) {
- this.setState({postError: this.props.intl.formatMessage(holders.commentLength, {max: Constants.CHARACTER_LIMIT})});
+ this.setState({
+ postError: (
+ <FormattedMessage
+ id='create_comment.commentLength'
+ defaultMessage='Comment length must be less than {max} characters.'
+ values={{max: Constants.CHARACTER_LIMIT}}
+ />
+ )
+ });
return;
}
@@ -175,13 +163,12 @@ class CreateComment extends React.Component {
submitting: false,
postError: null,
previews: [],
- serverError: null,
- typing: false
+ serverError: null
});
}
commentMsgKeyPress(e) {
- if (this.state.ctrlSend && e.ctrlKey || !this.state.ctrlSend) {
+ if ((this.state.ctrlSend && e.ctrlKey) || !this.state.ctrlSend) {
if (e.which === KeyCodes.ENTER && !e.shiftKey && !e.altKey) {
e.preventDefault();
ReactDOM.findDOMNode(this.refs.textbox).blur();
@@ -192,15 +179,16 @@ class CreateComment extends React.Component {
GlobalActions.emitLocalUserTypingEvent(this.props.channelId, this.props.rootId);
}
- handleUserInput(messageText) {
+ handleInput(e) {
+ const messageText = e.target.value;
+
const draft = PostStore.getCommentDraft(this.props.rootId);
draft.message = messageText;
PostStore.storeCommentDraft(this.props.rootId, draft);
$('.post-right__scroll').parent().scrollTop($('.post-right__scroll')[0].scrollHeight);
- const typing = messageText !== '';
- this.setState({messageText, typing});
+ this.setState({messageText});
}
handleKeyDown(e) {
@@ -220,7 +208,7 @@ class CreateComment extends React.Component {
AppDispatcher.handleViewAction({
type: ActionTypes.RECEIVED_EDIT_POST,
refocusId: '#reply_textbox',
- title: this.props.intl.formatMessage(holders.commentTitle),
+ title: Utils.localizeMessage('create_comment.commentTitle', 'Comment'),
message: lastPost.message,
postId: lastPost.id,
channelId: lastPost.channel_id,
@@ -313,13 +301,13 @@ class CreateComment extends React.Component {
draft.uploadsInProgress = uploadsInProgress;
PostStore.storeCommentDraft(this.props.rootId, draft);
- this.setState({previews: previews, uploadsInProgress: uploadsInProgress});
+ this.setState({previews, uploadsInProgress});
}
componentWillReceiveProps(newProps) {
if (newProps.rootId !== this.props.rootId) {
const draft = PostStore.getCommentDraft(newProps.rootId);
- this.setState({messageText: draft.message, uploadsInProgress: draft.uploadsInProgress, previews: draft.previews, typing: false});
+ this.setState({messageText: draft.message, uploadsInProgress: draft.uploadsInProgress, previews: draft.previews});
}
}
@@ -395,7 +383,6 @@ class CreateComment extends React.Component {
);
}
- const {formatMessage} = this.props.intl;
return (
<form onSubmit={this.handleSubmit}>
<div className='post-create'>
@@ -405,12 +392,11 @@ class CreateComment extends React.Component {
>
<div className='post-body__cell'>
<Textbox
- onUserInput={this.handleUserInput}
+ onInput={this.handleInput}
onKeyPress={this.commentMsgKeyPress}
onKeyDown={this.handleKeyDown}
messageText={this.state.messageText}
- typing={this.state.typing}
- createMessage={formatMessage(holders.addComment)}
+ createMessage={Utils.localizeMessage('create_comment.addComment', 'Add a comment...')}
initialText=''
supportsCommands={false}
id='reply_textbox'
@@ -436,7 +422,7 @@ class CreateComment extends React.Component {
<input
type='button'
className='btn btn-primary comment-btn pull-right'
- value={formatMessage(holders.comment)}
+ value={Utils.localizeMessage('create_comment.comment', 'Add Comment')}
onClick={this.handleSubmit}
/>
{uploadsInProgressText}
@@ -455,9 +441,6 @@ class CreateComment extends React.Component {
}
CreateComment.propTypes = {
- intl: intlShape.isRequired,
channelId: React.PropTypes.string.isRequired,
rootId: React.PropTypes.string.isRequired
-};
-
-export default injectIntl(CreateComment);
+}; \ No newline at end of file
diff --git a/webapp/components/create_post.jsx b/webapp/components/create_post.jsx
index 508fb36cb..9b61cca24 100644
--- a/webapp/components/create_post.jsx
+++ b/webapp/components/create_post.jsx
@@ -23,7 +23,7 @@ import PreferenceStore from 'stores/preference_store.jsx';
import Constants from 'utils/constants.jsx';
-import {intlShape, injectIntl, defineMessages, FormattedHTMLMessage} from 'react-intl';
+import {FormattedHTMLMessage} from 'react-intl';
import {browserHistory} from 'react-router/es6';
const Preferences = Constants.Preferences;
@@ -31,24 +31,9 @@ const TutorialSteps = Constants.TutorialSteps;
const ActionTypes = Constants.ActionTypes;
const KeyCodes = Constants.KeyCodes;
-const holders = defineMessages({
- comment: {
- id: 'create_post.comment',
- defaultMessage: 'Comment'
- },
- post: {
- id: 'create_post.post',
- defaultMessage: 'Post'
- },
- write: {
- id: 'create_post.write',
- defaultMessage: 'Write a message...'
- }
-});
-
import React from 'react';
-class CreatePost extends React.Component {
+export default class CreatePost extends React.Component {
constructor(props) {
super(props);
@@ -57,7 +42,7 @@ class CreatePost extends React.Component {
this.getCurrentDraft = this.getCurrentDraft.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.postMsgKeyPress = this.postMsgKeyPress.bind(this);
- this.handleUserInput = this.handleUserInput.bind(this);
+ this.handleInput = this.handleInput.bind(this);
this.handleUploadClick = this.handleUploadClick.bind(this);
this.handleUploadStart = this.handleUploadStart.bind(this);
this.handleFileUploadComplete = this.handleFileUploadComplete.bind(this);
@@ -87,8 +72,7 @@ class CreatePost extends React.Component {
ctrlSend: PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter'),
fullWidthTextBox: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CHANNEL_DISPLAY_MODE, Preferences.CHANNEL_DISPLAY_MODE_DEFAULT) === Preferences.CHANNEL_DISPLAY_MODE_FULL_SCREEN,
showTutorialTip: false,
- showPostDeletedModal: false,
- typing: false
+ showPostDeletedModal: false
};
}
@@ -133,7 +117,7 @@ class CreatePost extends React.Component {
MessageHistoryStore.storeMessageInHistory(this.state.messageText);
- this.setState({submitting: true, serverError: null, typing: false});
+ this.setState({submitting: true, serverError: null});
if (post.message.indexOf('/') === 0) {
ChannelActions.executeCommand(
@@ -212,7 +196,7 @@ class CreatePost extends React.Component {
}
postMsgKeyPress(e) {
- if (this.state.ctrlSend && e.ctrlKey || !this.state.ctrlSend) {
+ if ((this.state.ctrlSend && e.ctrlKey) || !this.state.ctrlSend) {
if (e.which === KeyCodes.ENTER && !e.shiftKey && !e.altKey) {
e.preventDefault();
ReactDOM.findDOMNode(this.refs.textbox).blur();
@@ -223,9 +207,9 @@ class CreatePost extends React.Component {
GlobalActions.emitLocalUserTypingEvent(this.state.channelId, '');
}
- handleUserInput(messageText) {
- const typing = messageText !== '';
- this.setState({messageText, typing});
+ handleInput(e) {
+ const messageText = e.target.value;
+ this.setState({messageText});
const draft = PostStore.getCurrentDraft();
draft.message = messageText;
@@ -372,7 +356,7 @@ class CreatePost extends React.Component {
if (this.state.channelId !== channelId) {
const draft = this.getCurrentDraft();
- this.setState({channelId, messageText: draft.messageText, initialText: draft.messageText, submitting: false, typing: false, serverError: null, postError: null, previews: draft.previews, uploadsInProgress: draft.uploadsInProgress});
+ this.setState({channelId, messageText: draft.messageText, initialText: draft.messageText, submitting: false, serverError: null, postError: null, previews: draft.previews, uploadsInProgress: draft.uploadsInProgress});
}
}
@@ -408,8 +392,13 @@ class CreatePost extends React.Component {
if (!lastPost) {
return;
}
- const {formatMessage} = this.props.intl;
- var type = (lastPost.root_id && lastPost.root_id.length > 0) ? formatMessage(holders.comment) : formatMessage(holders.post);
+
+ let type;
+ if (lastPost.root_id && lastPost.root_id.length > 0) {
+ type = Utils.localizeMessage('create_post.comment', 'Comment');
+ } else {
+ type = Utils.localizeMessage('create_post.post', 'Post');
+ }
AppDispatcher.handleViewAction({
type: ActionTypes.RECEIVED_EDIT_POST,
@@ -519,12 +508,11 @@ class CreatePost extends React.Component {
<div className='post-create-body'>
<div className='post-body__cell'>
<Textbox
- onUserInput={this.handleUserInput}
+ onInput={this.handleInput}
onKeyPress={this.postMsgKeyPress}
onKeyDown={this.handleKeyDown}
messageText={this.state.messageText}
- typing={this.state.typing}
- createMessage={this.props.intl.formatMessage(holders.write)}
+ createMessage={Utils.localizeMessage('create_post.write', 'Write a message...')}
channelId={this.state.channelId}
id='post_textbox'
ref='textbox'
@@ -565,10 +553,4 @@ class CreatePost extends React.Component {
</form>
);
}
-}
-
-CreatePost.propTypes = {
- intl: intlShape.isRequired
-};
-
-export default injectIntl(CreatePost);
+} \ No newline at end of file
diff --git a/webapp/components/edit_post_modal.jsx b/webapp/components/edit_post_modal.jsx
index 4bd23a26d..8be0ba243 100644
--- a/webapp/components/edit_post_modal.jsx
+++ b/webapp/components/edit_post_modal.jsx
@@ -11,35 +11,35 @@ import BrowserStore from 'stores/browser_store.jsx';
import PostStore from 'stores/post_store.jsx';
import MessageHistoryStore from 'stores/message_history_store.jsx';
import PreferenceStore from 'stores/preference_store.jsx';
+import * as Utils from 'utils/utils.jsx';
import Constants from 'utils/constants.jsx';
-import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'react-intl';
+import {FormattedMessage} from 'react-intl';
var KeyCodes = Constants.KeyCodes;
-const holders = defineMessages({
- editPost: {
- id: 'edit_post.editPost',
- defaultMessage: 'Edit the post...'
- }
-});
-
import React from 'react';
-class EditPostModal extends React.Component {
+export default class EditPostModal extends React.Component {
constructor(props) {
super(props);
this.handleEdit = this.handleEdit.bind(this);
- this.handleEditInput = this.handleEditInput.bind(this);
this.handleEditKeyPress = this.handleEditKeyPress.bind(this);
this.handleEditPostEvent = this.handleEditPostEvent.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
+ this.handleInput = this.handleInput.bind(this);
this.onPreferenceChange = this.onPreferenceChange.bind(this);
+ this.onModalHidden = this.onModalHidden.bind(this);
+ this.onModalShow = this.onModalShow.bind(this);
+ this.onModalShown = this.onModalShown.bind(this);
+ this.onModalHide = this.onModalHide.bind(this);
+ this.onModalKeyDown = this.onModalKeyDown.bind(this);
- this.state = {editText: '', originalText: '', title: '', post_id: '', channel_id: '', comments: 0, refocusId: '', typing: false};
+ this.state = {editText: '', originalText: '', title: '', post_id: '', channel_id: '', comments: 0, refocusId: ''};
}
+
handleEdit() {
var updatedPost = {};
updatedPost.message = this.state.editText.trim();
@@ -77,10 +77,13 @@ class EditPostModal extends React.Component {
$('#edit_post').modal('hide');
}
- handleEditInput(editMessage) {
- const typing = editMessage !== '';
- this.setState({editText: editMessage, typing});
+
+ handleInput(e) {
+ this.setState({
+ editText: e.target.value
+ });
}
+
handleEditKeyPress(e) {
if (!this.state.ctrlSend && e.which === KeyCodes.ENTER && !e.shiftKey && !e.altKey) {
e.preventDefault();
@@ -92,6 +95,7 @@ class EditPostModal extends React.Component {
this.handleSubmit(e);
}
}
+
handleEditPostEvent(options) {
this.setState({
editText: options.message || '',
@@ -100,65 +104,83 @@ class EditPostModal extends React.Component {
post_id: options.postId || '',
channel_id: options.channelId || '',
comments: options.comments || 0,
- refocusId: options.refocusId || '',
- typing: false
+ refocusId: options.refocusId || ''
});
$(ReactDOM.findDOMNode(this.refs.modal)).modal('show');
}
+
handleKeyDown(e) {
if (this.state.ctrlSend && e.keyCode === KeyCodes.ENTER && e.ctrlKey === true) {
this.handleEdit(e);
}
}
+
onPreferenceChange() {
this.setState({
ctrlSend: PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter')
});
}
- componentDidMount() {
- var self = this;
- $(ReactDOM.findDOMNode(this.refs.modal)).on('hidden.bs.modal', () => {
- self.setState({editText: '', originalText: '', title: '', channel_id: '', post_id: '', comments: 0, refocusId: '', error: '', typing: false});
- });
+ onModalHidden() {
+ this.setState({editText: '', originalText: '', title: '', channel_id: '', post_id: '', comments: 0, refocusId: '', error: '', typing: false});
+ }
- $(ReactDOM.findDOMNode(this.refs.modal)).on('show.bs.modal', (e) => {
- var button = e.relatedTarget;
- if (!button) {
- return;
- }
- self.setState({
- editText: $(button).attr('data-message'),
- originalText: $(button).attr('data-message'),
- title: $(button).attr('data-title'),
- channel_id: $(button).attr('data-channelid'),
- post_id: $(button).attr('data-postid'),
- comments: $(button).attr('data-comments'),
- refocusId: $(button).attr('data-refocusid'),
- typing: false
- });
+ onModalShow(e) {
+ var button = e.relatedTarget;
+ if (!button) {
+ return;
+ }
+ this.setState({
+ editText: $(button).attr('data-message'),
+ originalText: $(button).attr('data-message'),
+ title: $(button).attr('data-title'),
+ channel_id: $(button).attr('data-channelid'),
+ post_id: $(button).attr('data-postid'),
+ comments: $(button).attr('data-comments'),
+ refocusId: $(button).attr('data-refocusid'),
+ typing: false
});
+ }
- $(ReactDOM.findDOMNode(this.refs.modal)).on('shown.bs.modal', () => {
- self.refs.editbox.focus();
- });
+ onModalShown() {
+ this.refs.editbox.focus();
+ }
- $(ReactDOM.findDOMNode(this.refs.modal)).on('hide.bs.modal', () => {
- if (self.state.refocusId !== '') {
- setTimeout(() => {
- $(self.state.refocusId).get(0).focus();
- });
- }
- });
+ onModalHide() {
+ if (this.state.refocusId !== '') {
+ setTimeout(() => {
+ $(this.state.refocusId).get(0).focus();
+ });
+ }
+ }
+ onModalKeyDown(e) {
+ if (e.which === Constants.KeyCodes.ESCAPE) {
+ e.stopPropagation();
+ }
+ }
+
+ componentDidMount() {
+ $(this.refs.modal).on('hidden.bs.modal', this.onModalHidden);
+ $(this.refs.modal).on('show.bs.modal', this.onModalShow);
+ $(this.refs.modal).on('shown.bs.modal', this.onModalShown);
+ $(this.refs.modal).on('hide.bs.modal', this.onModalHide);
+ $(this.refs.modal).on('keydown', this.onModalKeyDown);
PostStore.addEditPostListener(this.handleEditPostEvent);
PreferenceStore.addChangeListener(this.onPreferenceChange);
}
+
componentWillUnmount() {
+ $(this.refs.modal).off('hidden.bs.modal', this.onModalHidden);
+ $(this.refs.modal).off('show.bs.modal', this.onModalShow);
+ $(this.refs.modal).off('shown.bs.modal', this.onModalShown);
+ $(this.refs.modal).off('hide.bs.modal', this.onModalHide);
+ $(this.refs.modal).off('keydown', this.onModalKeyDown);
PostStore.removeEditPostListner(this.handleEditPostEvent);
PreferenceStore.removeChangeListener(this.onPreferenceChange);
}
+
render() {
var error = (<div className='form-group'><br/></div>);
if (this.state.error) {
@@ -198,12 +220,11 @@ class EditPostModal extends React.Component {
</div>
<div className='edit-modal-body modal-body'>
<Textbox
- onUserInput={this.handleEditInput}
+ onInput={this.handleInput}
onKeyPress={this.handleEditKeyPress}
onKeyDown={this.handleKeyDown}
messageText={this.state.editText}
- typing={this.state.typing}
- createMessage={this.props.intl.formatMessage(holders.editPost)}
+ createMessage={Utils.localizeMessage('edit_post.editPost', 'Edit the post...')}
supportsCommands={false}
id='edit_textbox'
ref='editbox'
@@ -238,9 +259,3 @@ class EditPostModal extends React.Component {
);
}
}
-
-EditPostModal.propTypes = {
- intl: intlShape.isRequired
-};
-
-export default injectIntl(EditPostModal);
diff --git a/webapp/components/emoji/components/add_emoji.jsx b/webapp/components/emoji/components/add_emoji.jsx
index 46f345476..c3d61d32c 100644
--- a/webapp/components/emoji/components/add_emoji.jsx
+++ b/webapp/components/emoji/components/add_emoji.jsx
@@ -277,7 +277,10 @@ export default class AddEmoji extends React.Component {
</div>
{preview}
<div className='backstage-form__footer'>
- <FormError error={this.state.error}/>
+ <FormError
+ type='backstage'
+ error={this.state.error}
+ />
<Link
className='btn btn-sm'
to={'/' + this.props.team.name + '/emoji'}
diff --git a/webapp/components/file_upload.jsx b/webapp/components/file_upload.jsx
index 2f485d4d3..1a3c6eadc 100644
--- a/webapp/components/file_upload.jsx
+++ b/webapp/components/file_upload.jsx
@@ -282,7 +282,8 @@ class FileUpload extends React.Component {
keyUpload(e) {
if (Utils.cmdOrCtrlPressed(e) && e.keyCode === Constants.KeyCodes.U) {
e.preventDefault();
- if (this.props.postType === 'post' && document.activeElement.id === 'post_textbox' || this.props.postType === 'comment' && document.activeElement.id === 'reply_textbox') {
+ if ((this.props.postType === 'post' && document.activeElement.id === 'post_textbox') ||
+ (this.props.postType === 'comment' && document.activeElement.id === 'reply_textbox')) {
$(this.refs.fileInput).focus().trigger('click');
}
}
diff --git a/webapp/components/form_error.jsx b/webapp/components/form_error.jsx
index 047595ef2..df6fa3ab0 100644
--- a/webapp/components/form_error.jsx
+++ b/webapp/components/form_error.jsx
@@ -7,6 +7,7 @@ export default class FormError extends React.Component {
static get propTypes() {
// accepts either a single error or an array of errors
return {
+ type: React.PropTypes.node,
error: React.PropTypes.node,
margin: React.PropTypes.node,
errors: React.PropTypes.arrayOf(React.PropTypes.node)
@@ -40,6 +41,16 @@ export default class FormError extends React.Component {
return null;
}
+ if (this.props.type === 'backstage') {
+ return (
+ <div className='pull-left has-error'>
+ <label className='control-label'>
+ {message}
+ </label>
+ </div>
+ );
+ }
+
if (this.props.margin) {
return (
<div className='form-group has-error'>
diff --git a/webapp/components/integrations/components/add_command.jsx b/webapp/components/integrations/components/add_command.jsx
index e72670e47..d24acd70d 100644
--- a/webapp/components/integrations/components/add_command.jsx
+++ b/webapp/components/integrations/components/add_command.jsx
@@ -72,7 +72,7 @@ export default class AddCommand extends React.Component {
const command = {
display_name: this.state.displayName,
description: this.state.description,
- trigger: this.state.trigger.trim(),
+ trigger: this.state.trigger.trim().toLowerCase(),
url: this.state.url.trim(),
method: this.state.method,
username: this.state.username,
@@ -537,7 +537,10 @@ export default class AddCommand extends React.Component {
</div>
{autocompleteFields}
<div className='backstage-form__footer'>
- <FormError errors={[this.state.serverError, this.state.clientError]}/>
+ <FormError
+ type='backstage'
+ errors={[this.state.serverError, this.state.clientError]}
+ />
<Link
className='btn btn-sm'
to={'/' + this.props.team.name + '/integrations/commands'}
diff --git a/webapp/components/integrations/components/add_incoming_webhook.jsx b/webapp/components/integrations/components/add_incoming_webhook.jsx
index 122600c90..a213a805f 100644
--- a/webapp/components/integrations/components/add_incoming_webhook.jsx
+++ b/webapp/components/integrations/components/add_incoming_webhook.jsx
@@ -186,7 +186,10 @@ export default class AddIncomingWebhook extends React.Component {
</div>
</div>
<div className='backstage-form__footer'>
- <FormError errors={[this.state.serverError, this.state.clientError]}/>
+ <FormError
+ type='backstage'
+ errors={[this.state.serverError, this.state.clientError]}
+ />
<Link
className='btn btn-sm'
to={'/' + this.props.team.name + '/integrations/incoming_webhooks'}
diff --git a/webapp/components/integrations/components/add_outgoing_webhook.jsx b/webapp/components/integrations/components/add_outgoing_webhook.jsx
index bd49fedc9..d6c0242a5 100644
--- a/webapp/components/integrations/components/add_outgoing_webhook.jsx
+++ b/webapp/components/integrations/components/add_outgoing_webhook.jsx
@@ -319,7 +319,10 @@ export default class AddOutgoingWebhook extends React.Component {
</div>
</div>
<div className='backstage-form__footer'>
- <FormError errors={[this.state.serverError, this.state.clientError]}/>
+ <FormError
+ type='backstage'
+ errors={[this.state.serverError, this.state.clientError]}
+ />
<Link
className='btn btn-sm'
to={'/' + this.props.team.name + '/integrations/outgoing_webhooks'}
diff --git a/webapp/components/invite_member_modal.jsx b/webapp/components/invite_member_modal.jsx
index 265a421b6..68a7b7b15 100644
--- a/webapp/components/invite_member_modal.jsx
+++ b/webapp/components/invite_member_modal.jsx
@@ -131,7 +131,7 @@ class InviteMemberModal extends React.Component {
invites.push(invite);
}
- this.setState({emailErrors: emailErrors, firstNameErrors: firstNameErrors, lastNameErrors: lastNameErrors});
+ this.setState({emailErrors, firstNameErrors, lastNameErrors});
if (!valid || invites.length === 0) {
return;
@@ -151,7 +151,7 @@ class InviteMemberModal extends React.Component {
(err) => {
if (err.id === 'api.team.invite_members.already.app_error') {
emailErrors[err.detailed_error] = err.message;
- this.setState({emailErrors: emailErrors});
+ this.setState({emailErrors});
} else {
this.setState({serverError: err.message});
}
@@ -193,7 +193,7 @@ class InviteMemberModal extends React.Component {
var count = this.state.idCount + 1;
var inviteIds = this.state.inviteIds;
inviteIds.push(count);
- this.setState({inviteIds: inviteIds, idCount: count});
+ this.setState({inviteIds, idCount: count});
}
clearFields() {
@@ -225,7 +225,7 @@ class InviteMemberModal extends React.Component {
if (!inviteIds.length) {
inviteIds.push(++count);
}
- this.setState({inviteIds: inviteIds, idCount: count});
+ this.setState({inviteIds, idCount: count});
}
showGetTeamInviteLinkModal() {
@@ -435,7 +435,7 @@ class InviteMemberModal extends React.Component {
id='invite_member.teamInviteLink'
defaultMessage='You can also invite people using the {link}.'
values={{
- link: (link)
+ link
}}
/>
</p>
diff --git a/webapp/components/logged_in.jsx b/webapp/components/logged_in.jsx
index 484164e56..2ac858dfb 100644
--- a/webapp/components/logged_in.jsx
+++ b/webapp/components/logged_in.jsx
@@ -9,6 +9,7 @@ import BrowserStore from 'stores/browser_store.jsx';
import PreferenceStore from 'stores/preference_store.jsx';
import * as Utils from 'utils/utils.jsx';
import * as Websockets from 'actions/websocket_actions.jsx';
+import * as GlobalActions from 'actions/global_actions.jsx';
import Constants from 'utils/constants.jsx';
import {browserHistory} from 'react-router/es6';
@@ -71,6 +72,8 @@ export default class LoggedIn extends React.Component {
if (this.state.user) {
this.setupUser(this.state.user);
+ } else {
+ GlobalActions.emitUserLoggedOutEvent('/login');
}
}
@@ -89,15 +92,6 @@ export default class LoggedIn extends React.Component {
id: user.id
});
}
-
- // Update CSS classes to match user theme
- if (user) {
- if ($.isPlainObject(user.theme_props) && !$.isEmptyObject(user.theme_props)) {
- Utils.applyTheme(user.theme_props);
- } else {
- Utils.applyTheme(Constants.THEMES.default);
- }
- }
}
onUserChanged() {
diff --git a/webapp/components/msg_typing.jsx b/webapp/components/msg_typing.jsx
index 631eea78d..f6a6d12b2 100644
--- a/webapp/components/msg_typing.jsx
+++ b/webapp/components/msg_typing.jsx
@@ -71,7 +71,7 @@ class MsgTyping extends React.Component {
defaultMessage='{users} and {last} are typing...'
values={{
users: (users.join(', ')),
- last: (last)
+ last
}}
/>
);
diff --git a/webapp/components/navbar.jsx b/webapp/components/navbar.jsx
index 44730f4e2..c2d262819 100644
--- a/webapp/components/navbar.jsx
+++ b/webapp/components/navbar.jsx
@@ -636,7 +636,7 @@ export default class Navbar extends React.Component {
defaultMessage='No channel header yet.{newline}{link} to add one.'
values={{
newline: (<br/>),
- link: (link)
+ link
}}
/>
</div>
diff --git a/webapp/components/needs_team.jsx b/webapp/components/needs_team.jsx
index 07b90636d..a8c7b3508 100644
--- a/webapp/components/needs_team.jsx
+++ b/webapp/components/needs_team.jsx
@@ -41,19 +41,34 @@ export default class NeedsTeam extends React.Component {
constructor(params) {
super(params);
- this.onChanged = this.onChanged.bind(this);
+ this.onTeamChanged = this.onTeamChanged.bind(this);
+ this.onPreferencesChanged = this.onPreferencesChanged.bind(this);
+
+ const team = TeamStore.getCurrent();
this.state = {
- team: TeamStore.getCurrent()
+ team,
+ theme: PreferenceStore.getTheme(team.id)
};
}
- onChanged() {
+ onTeamChanged() {
+ const team = TeamStore.getCurrent();
+
this.setState({
- team: TeamStore.getCurrent()
+ team,
+ theme: PreferenceStore.getTheme(team.id)
});
}
+ onPreferencesChanged(category) {
+ if (!category || category === Preferences.CATEGORY_THEME) {
+ this.setState({
+ theme: PreferenceStore.getTheme(this.state.team.id)
+ });
+ }
+ }
+
componentWillMount() {
// Go to tutorial if we are first arriving
const tutorialStep = PreferenceStore.getInt(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), 999);
@@ -63,7 +78,8 @@ export default class NeedsTeam extends React.Component {
}
componentDidMount() {
- TeamStore.addChangeListener(this.onChanged);
+ TeamStore.addChangeListener(this.onTeamChanged);
+ PreferenceStore.addChangeListener(this.onPreferencesChanged);
// Emit view action
GlobalActions.viewLoggedIn();
@@ -80,10 +96,19 @@ export default class NeedsTeam extends React.Component {
$(window).on('blur', () => {
window.isActive = false;
});
+
+ Utils.applyTheme(this.state.theme);
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (!Utils.areObjectsEqual(prevState.theme, this.state.theme)) {
+ Utils.applyTheme(this.state.theme);
+ }
}
componentWillUnmount() {
- TeamStore.removeChangeListener(this.onChanged);
+ TeamStore.removeChangeListener(this.onTeamChanged);
+ PreferenceStore.removeChangeListener(this.onPreferencesChanged);
$(window).off('focus');
$(window).off('blur');
}
diff --git a/webapp/components/new_channel_flow.jsx b/webapp/components/new_channel_flow.jsx
index db06cf0be..f6e91afc4 100644
--- a/webapp/components/new_channel_flow.jsx
+++ b/webapp/components/new_channel_flow.jsx
@@ -65,6 +65,7 @@ class NewChannelFlow extends React.Component {
channelDisplayName: '',
channelName: '',
channelPurpose: '',
+ channelHeader: '',
nameModified: false
};
}
@@ -78,6 +79,7 @@ class NewChannelFlow extends React.Component {
channelDisplayName: '',
channelName: '',
channelPurpose: '',
+ channelHeader: '',
nameModified: false
});
}
@@ -99,6 +101,7 @@ class NewChannelFlow extends React.Component {
name: this.state.channelName,
display_name: this.state.channelDisplayName,
purpose: this.state.channelPurpose,
+ header: this.state.channelHeader,
type: this.state.channelType
};
Client.createChannel(
@@ -153,7 +156,8 @@ class NewChannelFlow extends React.Component {
channelDataChanged(data) {
this.setState({
channelDisplayName: data.displayName,
- channelPurpose: data.purpose
+ channelPurpose: data.purpose,
+ channelHeader: data.header
});
if (!this.state.nameModified) {
this.setState({channelName: Utils.cleanUpUrlable(data.displayName.trim())});
diff --git a/webapp/components/new_channel_modal.jsx b/webapp/components/new_channel_modal.jsx
index 23eee625d..1198335ca 100644
--- a/webapp/components/new_channel_modal.jsx
+++ b/webapp/components/new_channel_modal.jsx
@@ -89,8 +89,9 @@ class NewChannelModal extends React.Component {
handleChange() {
const newData = {
- displayName: ReactDOM.findDOMNode(this.refs.display_name).value,
- purpose: ReactDOM.findDOMNode(this.refs.channel_purpose).value
+ displayName: this.refs.display_name.value,
+ header: this.refs.channel_header.value,
+ purpose: this.refs.channel_purpose.value
};
this.props.onDataChanged(newData);
}
@@ -258,7 +259,7 @@ class NewChannelModal extends React.Component {
</p>
</div>
</div>
- <div className='form-group less'>
+ <div className='form-group'>
<div className='col-sm-3'>
<label className='form__label control-label'>
<FormattedMessage
@@ -293,6 +294,43 @@ class NewChannelModal extends React.Component {
}}
/>
</p>
+ </div>
+ </div>
+ <div className='form-group less'>
+ <div className='col-sm-3'>
+ <label className='form__label control-label'>
+ <FormattedMessage
+ id='channel_modal.header'
+ defaultMessage='Header'
+ />
+ </label>
+ <label className='form__label light'>
+ <FormattedMessage
+ id='channel_modal.optional'
+ defaultMessage='(optional)'
+ />
+ </label>
+ </div>
+ <div className='col-sm-9'>
+ <textarea
+ className='form-control no-resize'
+ ref='channel_header'
+ rows='4'
+ placeholder={this.props.intl.formatMessage({id: 'channel_modal.header'})}
+ maxLength='128'
+ value={this.props.channelData.header}
+ onChange={this.handleChange}
+ tabIndex='2'
+ />
+ <p className='input__help'>
+ <FormattedMessage
+ id='channel_modal.headerHelp'
+ defaultMessage='Set text that will appear in the header of the {term} beside the {term} name. For example, include frequently used links by typing [Link Title](http://example.com).'
+ values={{
+ term: (channelTerm)
+ }}
+ />
+ </p>
{serverError}
</div>
</div>
diff --git a/webapp/components/post_view/components/post.jsx b/webapp/components/post_view/components/post.jsx
index 21d335a51..ff443e355 100644
--- a/webapp/components/post_view/components/post.jsx
+++ b/webapp/components/post_view/components/post.jsx
@@ -10,6 +10,7 @@ const ActionTypes = Constants.ActionTypes;
import * as Utils from 'utils/utils.jsx';
import * as PostUtils from 'utils/post_utils.jsx';
import AppDispatcher from 'dispatcher/app_dispatcher.jsx';
+import * as PostActions from 'actions/post_actions.jsx';
import React from 'react';
@@ -20,6 +21,7 @@ export default class Post extends React.Component {
this.handleCommentClick = this.handleCommentClick.bind(this);
this.handleDropdownOpened = this.handleDropdownOpened.bind(this);
this.forceUpdateInfo = this.forceUpdateInfo.bind(this);
+ this.handlePostClick = this.handlePostClick.bind(this);
this.state = {
dropdownOpened: false
@@ -47,6 +49,12 @@ export default class Post extends React.Component {
this.refs.info.forceUpdate();
this.refs.header.forceUpdate();
}
+ handlePostClick(e) {
+ if (e.altKey) {
+ e.preventDefault();
+ PostActions.setUnreadPost(this.props.post.channel_id, this.props.post.id);
+ }
+ }
shouldComponentUpdate(nextProps, nextState) {
if (!Utils.areObjectsEqual(nextProps.post, this.props.post)) {
return true;
@@ -213,6 +221,7 @@ export default class Post extends React.Component {
<div
id={'post_' + post.id}
className={'post ' + sameUserClass + ' ' + compactClass + ' ' + rootUser + ' ' + postType + ' ' + currentUserCss + ' ' + shouldHighlightClass + ' ' + systemMessageClass + ' ' + hideControls + ' ' + dropdownOpenedClass}
+ onClick={this.handlePostClick}
>
<div className={'post__content ' + centerClass}>
{profilePicContainer}
diff --git a/webapp/components/post_view/components/post_list.jsx b/webapp/components/post_view/components/post_list.jsx
index 17e29da2e..70107c838 100644
--- a/webapp/components/post_view/components/post_list.jsx
+++ b/webapp/components/post_view/components/post_list.jsx
@@ -15,14 +15,20 @@ import * as Utils from 'utils/utils.jsx';
import * as PostUtils from 'utils/post_utils.jsx';
import DelayedAction from 'utils/delayed_action.jsx';
+import * as ChannelActions from 'actions/channel_actions.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);
@@ -37,6 +43,7 @@ export default class PostList extends React.Component {
this.handleResize = this.handleResize.bind(this);
this.scrollToBottom = this.scrollToBottom.bind(this);
this.scrollToBottomAnimated = this.scrollToBottomAnimated.bind(this);
+ this.handleKeyDown = this.handleKeyDown.bind(this);
this.jumpToPostNode = null;
this.wasAtBottom = true;
@@ -44,16 +51,24 @@ export default class PostList extends React.Component {
this.scrollStopAction = new DelayedAction(this.handleScrollStop);
+ 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.introText = createChannelIntroMessage(props.channel, this.state.fullWidthIntro);
} else {
this.introText = this.getArchivesIntroMessage();
}
+ }
- this.state = {
- isScrolling: false,
- topPostId: null
- };
+ handleKeyDown(e) {
+ if (e.which === Constants.KeyCodes.ESCAPE && $('.popover.in,.modal.in').length === 0) {
+ e.preventDefault();
+ ChannelActions.setChannelAsRead();
+ }
}
isAtBottom() {
@@ -292,7 +307,7 @@ export default class PostList extends React.Component {
);
}
- if (postUserId !== userId &&
+ if ((postUserId !== userId || this.props.ownNewMessage) &&
this.props.lastViewed !== 0 &&
post.create_at > this.props.lastViewed &&
!renderedLastViewed) {
@@ -395,7 +410,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'
@@ -412,10 +427,12 @@ export default class PostList extends React.Component {
}
window.addEventListener('resize', this.handleResize);
+ window.addEventListener('keydown', this.handleKeyDown);
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
+ window.removeEventListener('keydown', this.handleKeyDown);
this.scrollStopAction.cancel();
}
@@ -510,7 +527,8 @@ export default class PostList extends React.Component {
}
PostList.defaultProps = {
- lastViewed: 0
+ lastViewed: 0,
+ ownNewMessage: false
};
PostList.propTypes = {
@@ -524,6 +542,7 @@ PostList.propTypes = {
showMoreMessagesTop: React.PropTypes.bool,
showMoreMessagesBottom: React.PropTypes.bool,
lastViewed: React.PropTypes.number,
+ ownNewMessage: React.PropTypes.bool,
postsToHighlight: React.PropTypes.object,
displayNameType: React.PropTypes.string,
displayPostsInCenter: React.PropTypes.bool,
diff --git a/webapp/components/post_view/post_view_controller.jsx b/webapp/components/post_view/post_view_controller.jsx
index e5743e657..a7583fa38 100644
--- a/webapp/components/post_view/post_view_controller.jsx
+++ b/webapp/components/post_view/post_view_controller.jsx
@@ -27,6 +27,7 @@ export default class PostViewController extends React.Component {
this.onPostsChange = this.onPostsChange.bind(this);
this.onEmojisChange = this.onEmojisChange.bind(this);
this.onPostsViewJumpRequest = this.onPostsViewJumpRequest.bind(this);
+ this.onSetNewMessageIndicator = this.onSetNewMessageIndicator.bind(this);
this.onPostListScroll = this.onPostListScroll.bind(this);
this.onActivate = this.onActivate.bind(this);
this.onDeactivate = this.onDeactivate.bind(this);
@@ -50,6 +51,7 @@ export default class PostViewController extends React.Component {
profiles,
atTop: PostStore.getVisibilityAtTop(channel.id),
lastViewed,
+ ownNewMessage: false,
scrollType: ScrollTypes.NEW_MESSAGE,
displayNameType: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', 'false'),
displayPostsInCenter: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CHANNEL_DISPLAY_MODE, Preferences.CHANNEL_DISPLAY_MODE_DEFAULT) === Preferences.CHANNEL_DISPLAY_MODE_CENTERED,
@@ -117,6 +119,7 @@ export default class PostViewController extends React.Component {
PostStore.addChangeListener(this.onPostsChange);
PostStore.addPostsViewJumpListener(this.onPostsViewJumpRequest);
EmojiStore.addChangeListener(this.onEmojisChange);
+ ChannelStore.addLastViewedListener(this.onSetNewMessageIndicator);
}
onDeactivate() {
@@ -125,6 +128,7 @@ export default class PostViewController extends React.Component {
PostStore.removeChangeListener(this.onPostsChange);
PostStore.removePostsViewJumpListener(this.onPostsViewJumpRequest);
EmojiStore.removeChangeListener(this.onEmojisChange);
+ ChannelStore.removeLastViewedListener(this.onSetNewMessageIndicator);
}
componentWillReceiveProps(nextProps) {
@@ -149,6 +153,7 @@ export default class PostViewController extends React.Component {
this.setState({
channel,
lastViewed,
+ ownNewMessage: false,
profiles: JSON.parse(JSON.stringify(profiles)),
postList: JSON.parse(JSON.stringify(PostStore.getVisiblePosts(channel.id))),
displayNameType: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', 'false'),
@@ -178,6 +183,10 @@ export default class PostViewController extends React.Component {
}
}
+ onSetNewMessageIndicator(lastViewed, ownNewMessage) {
+ this.setState({lastViewed, ownNewMessage});
+ }
+
onPostListScroll(atBottom) {
if (atBottom) {
this.setState({scrollType: ScrollTypes.BOTTOM});
@@ -219,6 +228,10 @@ export default class PostViewController extends React.Component {
return true;
}
+ if (nextState.ownNewMessage !== this.state.ownNewMessage) {
+ return true;
+ }
+
if (nextState.showMoreMessagesTop !== this.state.showMoreMessagesTop) {
return true;
}
@@ -281,6 +294,7 @@ export default class PostViewController extends React.Component {
useMilitaryTime={this.state.useMilitaryTime}
lastViewed={this.state.lastViewed}
emojis={this.state.emojis}
+ ownNewMessage={this.state.ownNewMessage}
/>
);
}
diff --git a/webapp/components/removed_from_channel_modal.jsx b/webapp/components/removed_from_channel_modal.jsx
index 3164e4e3f..3bdceadf7 100644
--- a/webapp/components/removed_from_channel_modal.jsx
+++ b/webapp/components/removed_from_channel_modal.jsx
@@ -116,7 +116,7 @@ export default class RemovedFromChannelModal extends React.Component {
id='removed_channel.remover'
defaultMessage='{remover} removed you from {channel}'
values={{
- remover: (remover),
+ remover,
channel: (channelName)
}}
/>
diff --git a/webapp/components/rename_channel_modal.jsx b/webapp/components/rename_channel_modal.jsx
index df08bdbc6..4dc84d971 100644
--- a/webapp/components/rename_channel_modal.jsx
+++ b/webapp/components/rename_channel_modal.jsx
@@ -203,7 +203,7 @@ export default class RenameChannelModal extends React.Component {
const displayName = ReactDOM.findDOMNode(this.refs.displayName).value.trim();
const channelName = Utils.cleanUpUrlable(displayName);
ReactDOM.findDOMNode(this.refs.channelName).value = channelName;
- this.setState({channelName: channelName});
+ this.setState({channelName});
}
}
diff --git a/webapp/components/search_bar.jsx b/webapp/components/search_bar.jsx
index d8725a7aa..290572612 100644
--- a/webapp/components/search_bar.jsx
+++ b/webapp/components/search_bar.jsx
@@ -3,7 +3,7 @@
import $ from 'jquery';
import ReactDOM from 'react-dom';
-import client from 'utils/web_client.jsx';
+import Client from 'utils/web_client.jsx';
import * as AsyncClient from 'utils/async_client.jsx';
import SearchStore from 'stores/search_store.jsx';
import AppDispatcher from '../dispatcher/app_dispatcher.jsx';
@@ -11,30 +11,23 @@ import SuggestionBox from './suggestion/suggestion_box.jsx';
import SearchChannelProvider from './suggestion/search_channel_provider.jsx';
import SearchSuggestionList from './suggestion/search_suggestion_list.jsx';
import SearchUserProvider from './suggestion/search_user_provider.jsx';
-import * as utils from 'utils/utils.jsx';
+import * as Utils from 'utils/utils.jsx';
import Constants from 'utils/constants.jsx';
-import {intlShape, injectIntl, defineMessages, FormattedMessage, FormattedHTMLMessage} from 'react-intl';
+import {FormattedMessage, FormattedHTMLMessage} from 'react-intl';
var ActionTypes = Constants.ActionTypes;
import {Popover} from 'react-bootstrap';
-const holders = defineMessages({
- search: {
- id: 'search_bar.search',
- defaultMessage: 'Search'
- }
-});
-
import React from 'react';
-class SearchBar extends React.Component {
+export default class SearchBar extends React.Component {
constructor() {
super();
this.mounted = false;
this.onListenerChange = this.onListenerChange.bind(this);
- this.handleUserInput = this.handleUserInput.bind(this);
+ this.handleInput = this.handleInput.bind(this);
this.handleUserFocus = this.handleUserFocus.bind(this);
this.handleUserBlur = this.handleUserBlur.bind(this);
this.performSearch = this.performSearch.bind(this);
@@ -46,24 +39,28 @@ class SearchBar extends React.Component {
this.suggestionProviders = [new SearchChannelProvider(), new SearchUserProvider()];
}
+
getSearchTermStateFromStores() {
var term = SearchStore.getSearchTerm() || '';
return {
searchTerm: term
};
}
+
componentDidMount() {
SearchStore.addSearchTermChangeListener(this.onListenerChange);
this.mounted = true;
}
+
componentWillUnmount() {
SearchStore.removeSearchTermChangeListener(this.onListenerChange);
this.mounted = false;
}
+
onListenerChange(doSearch, isMentionSearch) {
if (this.mounted) {
var newState = this.getSearchTermStateFromStores();
- if (!utils.areObjectsEqual(newState, this.state)) {
+ if (!Utils.areObjectsEqual(newState, this.state)) {
this.setState(newState);
}
if (doSearch) {
@@ -71,9 +68,11 @@ class SearchBar extends React.Component {
}
}
}
+
clearFocus() {
$('.search-bar__container').removeClass('focused');
}
+
handleClose(e) {
e.preventDefault();
@@ -94,30 +93,34 @@ class SearchBar extends React.Component {
postId: null
});
}
- handleUserInput(text) {
- var term = text;
+
+ handleInput(e) {
+ var term = e.target.value;
SearchStore.storeSearchTerm(term);
SearchStore.emitSearchTermChange(false);
this.setState({searchTerm: term});
}
+
handleUserBlur() {
this.setState({focused: false});
}
+
handleUserFocus() {
$('.search-bar__container').addClass('focused');
this.setState({focused: true});
}
+
performSearch(terms, isMentionSearch) {
if (terms.length) {
this.setState({isSearching: true});
- client.search(
+ Client.search(
terms,
isMentionSearch,
(data) => {
this.setState({isSearching: false});
- if (utils.isMobile()) {
+ if (Utils.isMobile()) {
ReactDOM.findDOMNode(this.refs.search).value = '';
}
@@ -134,6 +137,7 @@ class SearchBar extends React.Component {
);
}
}
+
handleSubmit(e) {
e.preventDefault();
this.performSearch(this.state.searchTerm.trim());
@@ -178,11 +182,11 @@ class SearchBar extends React.Component {
<SuggestionBox
ref='search'
className='form-control search-bar'
- placeholder={this.props.intl.formatMessage(holders.search)}
+ placeholder={Utils.localizeMessage('search_bar.search', 'Search')}
value={this.state.searchTerm}
onFocus={this.handleUserFocus}
onBlur={this.handleUserBlur}
- onUserInput={this.handleUserInput}
+ onInput={this.handleInput}
listComponent={SearchSuggestionList}
providers={this.suggestionProviders}
type='search'
@@ -202,11 +206,4 @@ class SearchBar extends React.Component {
</div>
);
}
-}
-
-SearchBar.propTypes = {
- intl: intlShape.isRequired
-};
-
-export default injectIntl(SearchBar);
-
+} \ No newline at end of file
diff --git a/webapp/components/setting_item_max.jsx b/webapp/components/setting_item_max.jsx
index ec496a765..ad765a7d6 100644
--- a/webapp/components/setting_item_max.jsx
+++ b/webapp/components/setting_item_max.jsx
@@ -84,6 +84,7 @@ export default class SettingItemMax extends React.Component {
</li>
<li className='setting-list-item'>
<hr/>
+ {this.props.submitExtra}
{serverError}
{clientError}
{submit}
@@ -113,5 +114,6 @@ SettingItemMax.propTypes = {
updateSection: React.PropTypes.func,
submit: React.PropTypes.func,
title: React.PropTypes.node,
- width: React.PropTypes.string
+ width: React.PropTypes.string,
+ submitExtra: React.PropTypes.node
};
diff --git a/webapp/components/suggestion/suggestion_box.jsx b/webapp/components/suggestion/suggestion_box.jsx
index 2184b9fab..d4b150787 100644
--- a/webapp/components/suggestion/suggestion_box.jsx
+++ b/webapp/components/suggestion/suggestion_box.jsx
@@ -21,8 +21,8 @@ export default class SuggestionBox extends React.Component {
this.handleDocumentClick = this.handleDocumentClick.bind(this);
- this.handleChange = this.handleChange.bind(this);
this.handleCompleteWord = this.handleCompleteWord.bind(this);
+ this.handleInput = this.handleInput.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handlePretextChanged = this.handlePretextChanged.bind(this);
@@ -70,27 +70,24 @@ export default class SuggestionBox extends React.Component {
}
}
- handleChange(e) {
+ handleInput(e) {
const textbox = ReactDOM.findDOMNode(this.refs.textbox);
const caret = Utils.getCaretPosition(textbox);
const pretext = textbox.value.substring(0, caret);
GlobalActions.emitSuggestionPretextChanged(this.suggestionId, pretext);
- if (this.props.onUserInput) {
- this.props.onUserInput(textbox.value);
- }
-
- if (this.props.onChange) {
- this.props.onChange(e);
+ if (this.props.onInput) {
+ this.props.onInput(e);
}
}
handleCompleteWord(term, matchedPretext) {
- const textbox = ReactDOM.findDOMNode(this.refs.textbox);
+ const textbox = this.refs.textbox;
const caret = Utils.getCaretPosition(textbox);
const text = textbox.value;
const pretext = text.substring(0, caret);
+
let prefix;
if (pretext.endsWith(matchedPretext)) {
prefix = pretext.substring(0, pretext.length - matchedPretext.length);
@@ -104,10 +101,17 @@ export default class SuggestionBox extends React.Component {
const suffix = text.substring(caret);
- if (this.props.onUserInput) {
- this.props.onUserInput(prefix + term + ' ' + suffix);
+ this.refs.textbox.value = prefix + term + ' ' + suffix;
+
+ if (this.props.onInput) {
+ // fake an input event to send back to parent components
+ const e = {
+ target: this.refs.textbox
+ };
+
+ // don't call handleInput or we'll get into an event loop
+ this.props.onInput(e);
}
- this.refs.textbox.value = (prefix + term + ' ' + suffix);
// set the caret position after the next rendering
window.requestAnimationFrame(() => {
@@ -128,6 +132,7 @@ export default class SuggestionBox extends React.Component {
e.preventDefault();
} else if (e.which === KeyCodes.ESCAPE) {
GlobalActions.emitClearSuggestions(this.suggestionId);
+ e.stopPropagation();
} else if (this.props.onKeyDown) {
this.props.onKeyDown(e);
}
@@ -143,18 +148,15 @@ export default class SuggestionBox extends React.Component {
}
render() {
- const newProps = Object.assign({}, this.props, {
- onChange: this.handleChange,
- onKeyDown: this.handleKeyDown
- });
-
let textbox = null;
if (this.props.type === 'input') {
textbox = (
<input
ref='textbox'
type='text'
- {...newProps}
+ {...this.props}
+ onInput={this.handleInput}
+ onKeyDown={this.handleKeyDown}
/>
);
} else if (this.props.type === 'search') {
@@ -162,7 +164,9 @@ export default class SuggestionBox extends React.Component {
<input
ref='textbox'
type='search'
- {...newProps}
+ {...this.props}
+ onInput={this.handleInput}
+ onKeyDown={this.handleKeyDown}
/>
);
} else if (this.props.type === 'textarea') {
@@ -170,7 +174,9 @@ export default class SuggestionBox extends React.Component {
<TextareaAutosize
id={this.suggestionId}
ref='textbox'
- {...newProps}
+ {...this.props}
+ onInput={this.handleInput}
+ onKeyDown={this.handleKeyDown}
/>
);
}
@@ -212,12 +218,10 @@ SuggestionBox.propTypes = {
listComponent: React.PropTypes.func.isRequired,
type: React.PropTypes.oneOf(['input', 'textarea', 'search']).isRequired,
value: React.PropTypes.string.isRequired,
- onUserInput: React.PropTypes.func,
providers: React.PropTypes.arrayOf(React.PropTypes.object),
listStyle: React.PropTypes.string,
// explicitly name any input event handlers we override and need to manually call
- onChange: React.PropTypes.func,
- onKeyDown: React.PropTypes.func,
- onHeightChange: React.PropTypes.func
+ onInput: React.PropTypes.func,
+ onKeyDown: React.PropTypes.func
};
diff --git a/webapp/components/suggestion/suggestion_list.jsx b/webapp/components/suggestion/suggestion_list.jsx
index f1cccf8aa..52b85b2f5 100644
--- a/webapp/components/suggestion/suggestion_list.jsx
+++ b/webapp/components/suggestion/suggestion_list.jsx
@@ -87,7 +87,7 @@ export default class SuggestionList extends React.Component {
content.scrollTop(itemTop - contentTopPadding);
} else if (itemBottom + contentTopPadding + contentBottomPadding > contentTop + visibleContentHeight) {
// the item has gone off the bottom of the visible space
- content.scrollTop(itemBottom - visibleContentHeight + contentTopPadding + contentBottomPadding);
+ content.scrollTop((itemBottom - visibleContentHeight) + contentTopPadding + contentBottomPadding);
}
}
}
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_export_tab.jsx b/webapp/components/team_export_tab.jsx
deleted file mode 100644
index 15c131489..000000000
--- a/webapp/components/team_export_tab.jsx
+++ /dev/null
@@ -1,127 +0,0 @@
-// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
-// See License.txt for license information.
-
-import Client from 'utils/web_client.jsx';
-
-import {FormattedMessage} from 'react-intl';
-
-import React from 'react';
-import {Link} from 'react-router/es6';
-
-export default class TeamExportTab extends React.Component {
- constructor(props) {
- super(props);
- this.state = {status: 'request', link: '', err: ''};
-
- this.onExportSuccess = this.onExportSuccess.bind(this);
- this.onExportFailure = this.onExportFailure.bind(this);
- this.doExport = this.doExport.bind(this);
- }
- onExportSuccess(data) {
- this.setState({status: 'ready', link: data.link, err: ''});
- }
- onExportFailure(e) {
- this.setState({status: 'failure', link: '', err: e.message});
- }
- doExport() {
- if (this.state.status === 'in-progress') {
- return;
- }
- this.setState({status: 'in-progress'});
- Client.exportTeam(this.onExportSuccess, this.onExportFailure);
- }
- render() {
- var messageSection = '';
- switch (this.state.status) {
- case 'request':
- messageSection = '';
- break;
- case 'in-progress':
- messageSection = (
- <p className='confirm-import alert alert-warning'>
- <i className='fa fa-spinner fa-pulse'/>
- <FormattedMessage
- id='team_export_tab.exporting'
- defaultMessage=' Exporting...'
- />
- </p>
- );
- break;
- case 'ready':
- messageSection = (
- <p className='confirm-import alert alert-success'>
- <i className='fa fa-check'/>
- <FormattedMessage
- id='team_export_tab.ready'
- defaultMessage=' Ready for '
- />
- <Link
- to={this.state.link}
- download={true}
- >
- <FormattedMessage
- id='team_export_tab.download'
- defaultMessage='download'
- />
- </Link>
- </p>
- );
- break;
- case 'failure':
- messageSection = (
- <p className='confirm-import alert alert-warning'>
- <i className='fa fa-warning'/>
- <FormattedMessage
- id='team_export_tab.unable'
- defaultMessage=' Unable to export: {error}'
- values={{
- error: this.state.err
- }}
- />
- </p>
- );
- break;
- }
-
- return (
- <div
- ref='wrapper'
- className='user-settings'
- >
- <h3 className='tab-header'>
- <FormattedMessage
- id='team_export_tab.export'
- defaultMessage='Export'
- />
- </h3>
- <div className='divider-dark first'/>
- <ul className='section-max'>
- <li className='col-xs-12 section-title'>
- <FormattedMessage
- id='team_export_tab.exportTeam'
- defaultMessage='Export your team'
- />
- </li>
- <li className='col-xs-offset-3 col-xs-8'>
- <ul className='setting-list'>
- <li className='setting-list-item'>
- <a
- className='btn btn-sm btn-primary btn-file sel-btn'
- href='#'
- onClick={this.doExport}
- >
- <FormattedMessage
- id='team_export_tab.export'
- defaultMessage='Export'
- />
- </a>
- </li>
- </ul>
- </li>
- </ul>
- <div className='divider-dark'/>
- {messageSection}
- </div>
- );
- }
-}
diff --git a/webapp/components/team_general_tab.jsx b/webapp/components/team_general_tab.jsx
index 2814119c6..ac50c69a0 100644
--- a/webapp/components/team_general_tab.jsx
+++ b/webapp/components/team_general_tab.jsx
@@ -255,12 +255,10 @@ class GeneralTab extends React.Component {
}
updateName(e) {
- e.preventDefault();
this.setState({name: e.target.value});
}
updateInviteId(e) {
- e.preventDefault();
this.setState({invite_id: e.target.value});
}
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/components/team_settings.jsx b/webapp/components/team_settings.jsx
index 210d1f541..0725f9fe5 100644
--- a/webapp/components/team_settings.jsx
+++ b/webapp/components/team_settings.jsx
@@ -3,7 +3,6 @@
import TeamStore from 'stores/team_store.jsx';
import ImportTab from './team_import_tab.jsx';
-import ExportTab from './team_export_tab.jsx';
import GeneralTab from './team_general_tab.jsx';
import * as Utils from 'utils/utils.jsx';
@@ -58,13 +57,6 @@ export default class TeamSettings extends React.Component {
</div>
);
break;
- case 'export':
- result = (
- <div>
- <ExportTab/>
- </div>
- );
- break;
default:
result = (
<div/>
diff --git a/webapp/components/team_settings_modal.jsx b/webapp/components/team_settings_modal.jsx
index 8ac924cf8..aa7b0831e 100644
--- a/webapp/components/team_settings_modal.jsx
+++ b/webapp/components/team_settings_modal.jsx
@@ -17,10 +17,6 @@ const holders = defineMessages({
importTab: {
id: 'team_settings_modal.importTab',
defaultMessage: 'Import'
- },
- exportTab: {
- id: 'team_settings_modal.exportTab',
- defaultMessage: 'Export'
}
});
@@ -71,9 +67,6 @@ class TeamSettingsModal extends React.Component {
tabs.push({name: 'general', uiName: formatMessage(holders.generalTab), icon: 'icon fa fa-cog'});
tabs.push({name: 'import', uiName: formatMessage(holders.importTab), icon: 'icon fa fa-upload'});
- // To enable export uncomment this line
- //tabs.push({name: 'export', uiName: formatMessage(holders.exportTab), icon: 'fa fa-download'});
-
return (
<div
className='modal fade'
diff --git a/webapp/components/textbox.jsx b/webapp/components/textbox.jsx
index 40e6aec4a..24f58f43e 100644
--- a/webapp/components/textbox.jsx
+++ b/webapp/components/textbox.jsx
@@ -176,11 +176,6 @@ export default class Textbox extends React.Component {
</div>
);
- const otherProps = {};
- if (!this.props.typing) {
- otherProps.value = this.props.messageText;
- }
-
return (
<div
ref='wrapper'
@@ -194,7 +189,7 @@ export default class Textbox extends React.Component {
spellCheck='true'
maxLength={Constants.MAX_POST_LEN}
placeholder={this.props.createMessage}
- onUserInput={this.props.onUserInput}
+ onInput={this.props.onInput}
onKeyPress={this.handleKeyPress}
onKeyDown={this.handleKeyDown}
onHeightChange={this.handleHeightChange}
@@ -202,7 +197,7 @@ export default class Textbox extends React.Component {
listComponent={SuggestionList}
providers={this.suggestionProviders}
channelId={this.props.channelId}
- {...otherProps}
+ value={this.props.messageText}
/>
<div
ref='preview'
@@ -239,10 +234,9 @@ Textbox.propTypes = {
id: React.PropTypes.string.isRequired,
channelId: React.PropTypes.string,
messageText: React.PropTypes.string.isRequired,
- onUserInput: React.PropTypes.func.isRequired,
+ onInput: React.PropTypes.func.isRequired,
onKeyPress: React.PropTypes.func.isRequired,
createMessage: React.PropTypes.string.isRequired,
onKeyDown: React.PropTypes.func,
- supportsCommands: React.PropTypes.bool.isRequired,
- typing: React.PropTypes.bool.isRequired
+ supportsCommands: React.PropTypes.bool.isRequired
};
diff --git a/webapp/components/user_settings/import_theme_modal.jsx b/webapp/components/user_settings/import_theme_modal.jsx
index 552659c4c..32c6837e8 100644
--- a/webapp/components/user_settings/import_theme_modal.jsx
+++ b/webapp/components/user_settings/import_theme_modal.jsx
@@ -1,30 +1,18 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-import ReactDOM from 'react-dom';
import ModalStore from 'stores/modal_store.jsx';
-import UserStore from 'stores/user_store.jsx';
-import * as Utils from 'utils/utils.jsx';
-import Client from 'utils/web_client.jsx';
import {Modal} from 'react-bootstrap';
-import AppDispatcher from '../../dispatcher/app_dispatcher.jsx';
import Constants from 'utils/constants.jsx';
-import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'react-intl';
-
-const holders = defineMessages({
- submitError: {
- id: 'user.settings.import_theme.submitError',
- defaultMessage: 'Invalid format, please try copying and pasting in again.'
- }
-});
+import {FormattedMessage} from 'react-intl';
const ActionTypes = Constants.ActionTypes;
import React from 'react';
-class ImportThemeModal extends React.Component {
+export default class ImportThemeModal extends React.Component {
constructor(props) {
super(props);
@@ -33,26 +21,42 @@ class ImportThemeModal extends React.Component {
this.handleChange = this.handleChange.bind(this);
this.state = {
+ value: '',
inputError: '',
- show: false
+ show: false,
+ callback: null
};
}
+
componentDidMount() {
ModalStore.addModalListener(ActionTypes.TOGGLE_IMPORT_THEME_MODAL, this.updateShow);
}
+
componentWillUnmount() {
ModalStore.removeModalListener(ActionTypes.TOGGLE_IMPORT_THEME_MODAL, this.updateShow);
}
- updateShow(show) {
- this.setState({show});
+
+ updateShow(show, args) {
+ this.setState({
+ show,
+ callback: args.callback
+ });
}
+
handleSubmit(e) {
e.preventDefault();
- const text = ReactDOM.findDOMNode(this.refs.input).value;
+ const text = this.state.value;
if (!this.isInputValid(text)) {
- this.setState({inputError: this.props.intl.formatMessage(holders.submitError)});
+ this.setState({
+ inputError: (
+ <FormattedMessage
+ id='user.settings.import_theme.submitError'
+ defaultMessage='Invalid format, please try copying and pasting in again.'
+ />
+ )
+ });
return;
}
@@ -81,26 +85,13 @@ class ImportThemeModal extends React.Component {
theme.mentionHighlightLink = '#2f81b7';
theme.codeTheme = 'github';
- const user = UserStore.getCurrentUser();
- user.theme_props = theme;
-
- Client.updateUser(user, Constants.UserUpdateEvents.THEME,
- (data) => {
- AppDispatcher.handleServerAction({
- type: ActionTypes.RECEIVED_ME,
- me: data
- });
-
- this.setState({show: false});
- Utils.applyTheme(theme);
- },
- (err) => {
- var state = this.getStateFromStores();
- state.serverError = err;
- this.setState(state);
- }
- );
+ this.state.callback(theme);
+ this.setState({
+ show: false,
+ callback: null
+ });
}
+
isInputValid(text) {
if (text.length === 0) {
return false;
@@ -134,13 +125,25 @@ class ImportThemeModal extends React.Component {
return true;
}
+
handleChange(e) {
- if (this.isInputValid(e.target.value)) {
+ const value = e.target.value;
+ this.setState({value});
+
+ if (this.isInputValid(value)) {
this.setState({inputError: null});
} else {
- this.setState({inputError: this.props.intl.formatMessage(holders.submitError)});
+ this.setState({
+ inputError: (
+ <FormattedMessage
+ id='user.settings.import_theme.submitError'
+ defaultMessage='Invalid format, please try copying and pasting in again.'
+ />
+ )
+ });
}
}
+
render() {
return (
<span>
@@ -170,9 +173,9 @@ class ImportThemeModal extends React.Component {
<div className='form-group less'>
<div className='col-sm-9'>
<input
- ref='input'
type='text'
className='form-control'
+ value={this.state.value}
onChange={this.handleChange}
/>
<div className='input__help'>
@@ -210,9 +213,3 @@ class ImportThemeModal extends React.Component {
);
}
}
-
-ImportThemeModal.propTypes = {
- intl: intlShape.isRequired
-};
-
-export default injectIntl(ImportThemeModal);
diff --git a/webapp/components/user_settings/premade_theme_chooser.jsx b/webapp/components/user_settings/premade_theme_chooser.jsx
index 9552c686d..03ea56449 100644
--- a/webapp/components/user_settings/premade_theme_chooser.jsx
+++ b/webapp/components/user_settings/premade_theme_chooser.jsx
@@ -7,8 +7,6 @@ import Constants from 'utils/constants.jsx';
import React from 'react';
-import {FormattedMessage} from 'react-intl';
-
export default class PremadeThemeChooser extends React.Component {
constructor(props) {
super(props);
@@ -54,20 +52,6 @@ export default class PremadeThemeChooser extends React.Component {
<div className='clearfix'>
{premadeThemes}
</div>
- <div className='clearfix'>
- <div className='col-sm-12 padding-bottom x2'>
- <a
- href='http://docs.mattermost.com/help/settings/theme-colors.html#custom-theme-examples'
- target='_blank'
- rel='noopener noreferrer'
- >
- <FormattedMessage
- id='user.settings.display.theme.otherThemes'
- defaultMessage='See other themes'
- />
- </a>
- </div>
- </div>
</div>
);
}
diff --git a/webapp/components/user_settings/user_settings_general.jsx b/webapp/components/user_settings/user_settings_general.jsx
index a449c7d01..9b0e6a204 100644
--- a/webapp/components/user_settings/user_settings_general.jsx
+++ b/webapp/components/user_settings/user_settings_general.jsx
@@ -295,8 +295,17 @@ class UserSettingsGeneralTab extends React.Component {
setupInitialState(props) {
const user = props.user;
- return {username: user.username, firstName: user.first_name, lastName: user.last_name, nickname: user.nickname,
- email: user.email, confirmEmail: '', picture: null, loadingPicture: false, emailChangeInProgress: false};
+ return {
+ username: user.username,
+ firstName: user.first_name,
+ lastName: user.last_name,
+ nickname: user.nickname,
+ email: user.email,
+ confirmEmail: '',
+ picture: null,
+ loadingPicture: false,
+ emailChangeInProgress: false
+ };
}
createEmailSection() {
diff --git a/webapp/components/user_settings/user_settings_theme.jsx b/webapp/components/user_settings/user_settings_theme.jsx
index 94516ec8c..d12a7689a 100644
--- a/webapp/components/user_settings/user_settings_theme.jsx
+++ b/webapp/components/user_settings/user_settings_theme.jsx
@@ -8,28 +8,18 @@ import PremadeThemeChooser from './premade_theme_chooser.jsx';
import SettingItemMin from '../setting_item_min.jsx';
import SettingItemMax from '../setting_item_max.jsx';
+import PreferenceStore from 'stores/preference_store.jsx';
+import TeamStore from 'stores/team_store.jsx';
import UserStore from 'stores/user_store.jsx';
import AppDispatcher from '../../dispatcher/app_dispatcher.jsx';
-import Client from 'utils/web_client.jsx';
-import * as Utils from 'utils/utils.jsx';
-
-import Constants from 'utils/constants.jsx';
+import * as UserActions from 'actions/user_actions.jsx';
-import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'react-intl';
+import * as Utils from 'utils/utils.jsx';
-const ActionTypes = Constants.ActionTypes;
+import {FormattedMessage} from 'react-intl';
-const holders = defineMessages({
- themeTitle: {
- id: 'user.settings.display.theme.title',
- defaultMessage: 'Theme'
- },
- themeDescribe: {
- id: 'user.settings.display.theme.describe',
- defaultMessage: 'Open to manage your theme'
- }
-});
+import {ActionTypes, Constants, Preferences} from 'utils/constants.jsx';
import React from 'react';
@@ -47,6 +37,7 @@ export default class ThemeSetting extends React.Component {
this.originalTheme = Object.assign({}, this.state.theme);
}
+
componentDidMount() {
UserStore.addChangeListener(this.onChange);
@@ -54,17 +45,20 @@ export default class ThemeSetting extends React.Component {
$(ReactDOM.findDOMNode(this.refs[this.state.theme])).addClass('active-border');
}
}
+
componentDidUpdate() {
if (this.props.selected) {
$('.color-btn').removeClass('active-border');
$(ReactDOM.findDOMNode(this.refs[this.state.theme])).addClass('active-border');
}
}
+
componentWillReceiveProps(nextProps) {
if (this.props.selected && !nextProps.selected) {
this.resetFields();
}
}
+
componentWillUnmount() {
UserStore.removeChangeListener(this.onChange);
@@ -73,27 +67,35 @@ export default class ThemeSetting extends React.Component {
Utils.applyTheme(state.theme);
}
}
+
getStateFromStores() {
- const user = UserStore.getCurrentUser();
- let theme = null;
+ const teamId = TeamStore.getCurrentId();
- if ($.isPlainObject(user.theme_props) && !$.isEmptyObject(user.theme_props)) {
- theme = Object.assign({}, user.theme_props);
- } else {
- theme = $.extend(true, {}, Constants.THEMES.default);
+ const theme = PreferenceStore.getTheme(teamId);
+ if (!theme.codeTheme) {
+ theme.codeTheme = Constants.DEFAULT_CODE_THEME;
}
- let type = 'premade';
- if (theme.type === 'custom') {
- type = 'custom';
- }
+ let showAllTeamsCheckbox = false;
+ let applyToAllTeams = true;
- if (!theme.codeTheme) {
- theme.codeTheme = Constants.DEFAULT_CODE_THEME;
+ if (global.window.mm_license.IsLicensed === 'true' && global.window.mm_license.LDAP === 'true') {
+ // show the "apply to all teams" checkbox if the user is on more than one team
+ showAllTeamsCheckbox = Object.keys(TeamStore.getAll()).length > 1;
+
+ // check the "apply to all teams" checkbox by default if the user has any team-specific themes
+ applyToAllTeams = PreferenceStore.getCategory(Preferences.CATEGORY_THEME).size <= 1;
}
- return {theme, type};
+ return {
+ teamId: TeamStore.getCurrentId(),
+ theme,
+ type: theme.type || 'premade',
+ showAllTeamsCheckbox,
+ applyToAllTeams
+ };
}
+
onChange() {
const newState = this.getStateFromStores();
@@ -103,21 +105,20 @@ export default class ThemeSetting extends React.Component {
this.props.setEnforceFocus(true);
}
+
scrollToTop() {
$('.ps-container.modal-body').scrollTop(0);
}
+
submitTheme(e) {
e.preventDefault();
- var user = UserStore.getCurrentUser();
- user.theme_props = this.state.theme;
- Client.updateUser(user, Constants.UserUpdateEvents.THEME,
- (data) => {
- AppDispatcher.handleServerAction({
- type: ActionTypes.RECEIVED_ME,
- me: data
- });
+ const teamId = this.state.applyToAllTeams ? '' : this.state.teamId;
+ UserActions.saveTheme(
+ teamId,
+ this.state.theme,
+ () => {
this.props.setRequireConfirm(false);
this.originalTheme = Object.assign({}, this.state.theme);
this.scrollToTop();
@@ -130,6 +131,7 @@ export default class ThemeSetting extends React.Component {
}
);
}
+
updateTheme(theme) {
let themeChanged = this.state.theme.length === theme.length;
if (!themeChanged) {
@@ -148,9 +150,11 @@ export default class ThemeSetting extends React.Component {
this.setState({theme});
Utils.applyTheme(theme);
}
+
updateType(type) {
this.setState({type});
}
+
resetFields() {
const state = this.getStateFromStores();
state.serverError = null;
@@ -161,17 +165,18 @@ export default class ThemeSetting extends React.Component {
this.props.setRequireConfirm(false);
}
+
handleImportModal() {
AppDispatcher.handleViewAction({
type: ActionTypes.TOGGLE_IMPORT_THEME_MODAL,
- value: true
+ value: true,
+ callback: this.updateTheme
});
this.props.setEnforceFocus(false);
}
- render() {
- const {formatMessage} = this.props.intl;
+ render() {
var serverError;
if (this.state.serverError) {
serverError = this.state.serverError;
@@ -252,9 +257,27 @@ export default class ThemeSetting extends React.Component {
inputs.push(custom);
inputs.push(
- <div key='importSlackThemeButton'>
+ <div>
<br/>
<a
+ href='http://docs.mattermost.com/help/settings/theme-colors.html#custom-theme-examples'
+ target='_blank'
+ rel='noopener noreferrer'
+ >
+ <FormattedMessage
+ id='user.settings.display.theme.otherThemes'
+ defaultMessage='See other themes'
+ />
+ </a>
+ </div>
+ );
+
+ inputs.push(
+ <div
+ key='importSlackThemeButton'
+ className='padding-top'
+ >
+ <a
className='theme'
onClick={this.handleImportModal}
>
@@ -266,9 +289,29 @@ export default class ThemeSetting extends React.Component {
</div>
);
+ let allTeamsCheckbox = null;
+ if (this.state.showAllTeamsCheckbox) {
+ allTeamsCheckbox = (
+ <div className='checkbox user-settings__submit-checkbox'>
+ <label>
+ <input
+ type='checkbox'
+ checked={this.state.applyToAllTeams}
+ onChange={(e) => this.setState({applyToAllTeams: e.target.checked})}
+ />
+ <FormattedMessage
+ id='user.settings.display.theme.applyToAllTeams'
+ defaultMessage='Apply New Theme to All Teams'
+ />
+ </label>
+ </div>
+ );
+ }
+
themeUI = (
<SettingItemMax
inputs={inputs}
+ submitExtra={allTeamsCheckbox}
submit={this.submitTheme}
server_error={serverError}
width='full'
@@ -281,8 +324,18 @@ export default class ThemeSetting extends React.Component {
} else {
themeUI = (
<SettingItemMin
- title={formatMessage(holders.themeTitle)}
- describe={formatMessage(holders.themeDescribe)}
+ title={
+ <FormattedMessage
+ id='user.settings.display.theme.title'
+ defaultMessage='Theme'
+ />
+ }
+ describe={
+ <FormattedMessage
+ id='user.settings.display.theme.describe'
+ defaultMessage='Open to manage your theme'
+ />
+ }
updateSection={() => {
this.props.updateSection('theme');
}}
@@ -295,11 +348,8 @@ export default class ThemeSetting extends React.Component {
}
ThemeSetting.propTypes = {
- intl: intlShape.isRequired,
selected: React.PropTypes.bool.isRequired,
updateSection: React.PropTypes.func.isRequired,
setRequireConfirm: React.PropTypes.func.isRequired,
setEnforceFocus: React.PropTypes.func.isRequired
};
-
-export default injectIntl(ThemeSetting);
diff --git a/webapp/dispatcher/app_dispatcher.jsx b/webapp/dispatcher/app_dispatcher.jsx
index 5e43d3ad7..8ab38563b 100644
--- a/webapp/dispatcher/app_dispatcher.jsx
+++ b/webapp/dispatcher/app_dispatcher.jsx
@@ -9,7 +9,7 @@ const PayloadSources = Constants.PayloadSources;
const AppDispatcher = Object.assign(new Flux.Dispatcher(), {
handleServerAction: function performServerAction(action) {
if (!action.type) {
- console.warning('handleServerAction called with undefined action type'); // eslint-disable-line no-console
+ console.warn('handleServerAction called with undefined action type'); // eslint-disable-line no-console
}
var payload = {
@@ -21,7 +21,7 @@ const AppDispatcher = Object.assign(new Flux.Dispatcher(), {
handleViewAction: function performViewAction(action) {
if (!action.type) {
- console.warning('handleViewAction called with undefined action type'); // eslint-disable-line no-console
+ console.warn('handleViewAction called with undefined action type'); // eslint-disable-line no-console
}
var payload = {
diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json
index 0d3080d03..27c5b7da4 100644
--- a/webapp/i18n/en.json
+++ b/webapp/i18n/en.json
@@ -521,14 +521,14 @@
"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",
"admin.service.corsTitle": "Enable cross-origin requests from:",
"admin.service.developerDesc": "When true, Javascript errors are shown in a red bar at the top of the user interface. Not recommended for use in production. ",
"admin.service.developerTitle": "Enable Developer Mode: ",
- "admin.service.googleDescription": "Set this key to enable embedding of YouTube video previews based on hyperlinks appearing in messages or comments. Instructions to obtain a key available at <a href=\"https://www.youtube.com/watch?v=Im69kzhpR3I\" target=\"_blank\">https://www.youtube.com/watch?v=Im69kzhpR3I</a>. Leaving the field blank disables the automatic generation of YouTube video previews from links.",
+ "admin.service.googleDescription": "Set this key to enable the display of titles for embedded YouTube video previews. Without the key, YouTube previews will still be created based on hyperlinks appearing in messages or comments but they will not show the video title. View a <a href=\"https://www.youtube.com/watch?v=Im69kzhpR3I\" target=\"_blank\">Google Developers Tutorial</a> for instructions on how to obtain a key.",
"admin.service.googleExample": "Ex \"7rAh6iwQCkV4cA1Gsg3fgGOXJAQ43QV\"",
"admin.service.googleTitle": "Google API Key:",
"admin.service.iconDescription": "When true, webhooks and slash commands will be allowed to change the icon they post with. Note, combined with allowing username overriding, this could open users up to phishing attacks.",
@@ -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",
@@ -842,6 +842,7 @@
"channel_header.viewInfo": "View Info",
"channel_info.about": "About",
"channel_info.close": "Close",
+ "channel_info.header": "Header:",
"channel_info.id": "ID: ",
"channel_info.name": "Name:",
"channel_info.notFound": "No Channel Found",
@@ -870,6 +871,8 @@
"channel_modal.displayNameError": "This field is required",
"channel_modal.edit": "Edit",
"channel_modal.group": "Group",
+ "channel_modal.header": "Header",
+ "channel_modal.headerHelp": "Set text that will appear in the header of the {term} beside the {term} name. For example, include frequently used links by typing [Link Title](http://example.com).",
"channel_modal.modalTitle": "New ",
"channel_modal.name": "Name",
"channel_modal.nameEx": "E.g.: \"Bugs\", \"Marketing\", \"客户支持\"",
diff --git a/webapp/i18n/i18n.jsx b/webapp/i18n/i18n.jsx
index 118aa0ee2..1a6efd000 100644
--- a/webapp/i18n/i18n.jsx
+++ b/webapp/i18n/i18n.jsx
@@ -1,11 +1,11 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-const de = require('!!file?name=i18n/[name].[ext]!./de.json');
-const es = require('!!file?name=i18n/[name].[ext]!./es.json');
-const fr = require('!!file?name=i18n/[name].[ext]!./fr.json');
-const ja = require('!!file?name=i18n/[name].[ext]!./ja.json');
-const pt_BR = require('!!file?name=i18n/[name].[ext]!./pt-BR.json'); //eslint-disable-line camelcase
+const de = require('!!file?name=i18n/[name].[hash].[ext]!./de.json');
+const es = require('!!file?name=i18n/[name].[hash].[ext]!./es.json');
+const fr = require('!!file?name=i18n/[name].[hash].[ext]!./fr.json');
+const ja = require('!!file?name=i18n/[name].[hash].[ext]!./ja.json');
+const pt_BR = require('!!file?name=i18n/[name].[hash].[ext]!./pt-BR.json'); //eslint-disable-line camelcase
import {addLocaleData} from 'react-intl';
import deLocaleData from 'react-intl/locale-data/de';
diff --git a/webapp/package.json b/webapp/package.json
index 3db9d0794..f16def242 100644
--- a/webapp/package.json
+++ b/webapp/package.json
@@ -3,66 +3,68 @@
"version": "0.0.1",
"private": true,
"dependencies": {
- "autolinker": "mattermost/Autolinker.js#9689831109e104d7b545318e54199e6de8fd9b87",
+ "autolinker": "0.27.0",
"bootstrap": "3.3.6",
"bootstrap-colorpicker": "2.3.3",
- "chart.js": "2.1.2",
- "compass-mixins": "0.12.7",
+ "chart.js": "2.1.6",
+ "compass-mixins": "0.12.10",
"fastclick": "1.0.6",
"flux": "2.1.1",
- "font-awesome": "4.6.1",
- "highlight.js": "9.3.0",
- "intl": "1.1.0",
+ "font-awesome": "4.6.3",
+ "highlight.js": "9.5.0",
+ "intl": "1.2.4",
"jasny-bootstrap": "3.1.3",
- "jquery": "2.2.3",
+ "jquery": "3.1.0",
"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",
- "react-addons-pure-render-mixin": "15.0.2",
- "react-bootstrap": "0.29.3",
- "react-custom-scrollbars": "4.0.0-beta.1",
- "react-dom": "15.0.2",
- "react-intl": "2.1.2",
- "react-router": "2.4.0",
+ "perfect-scrollbar": "0.6.12",
+ "react": "15.2.1",
+ "react-addons-pure-render-mixin": "15.2.1",
+ "react-bootstrap": "0.29.5",
+ "react-custom-scrollbars": "4.0.0",
+ "react-dom": "15.2.1",
+ "react-intl": "2.1.3",
+ "react-router": "2.5.2",
"react-select": "1.0.0-beta13",
- "react-textarea-autosize": "4.0.1",
- "superagent": "1.8.3",
+ "react-textarea-autosize": "4.0.3",
+ "superagent": "2.1.0",
"twemoji": "2.0.5",
"velocity-animate": "1.2.3"
},
"devDependencies": {
- "babel-eslint": "6.0.4",
+ "babel-core": "6.10.4",
+ "babel-eslint": "6.1.2",
"babel-loader": "6.2.4",
- "babel-plugin-transform-runtime": "6.8.0",
- "babel-polyfill": "6.8.0",
+ "babel-plugin-transform-runtime": "6.9.0",
+ "babel-polyfill": "6.9.1",
"babel-preset-es2015-webpack": "6.4.1",
- "babel-preset-react": "6.5.0",
+ "babel-preset-react": "6.11.1",
"babel-preset-stage-0": "6.5.0",
- "copy-webpack-plugin": "2.1.3",
+ "copy-webpack-plugin": "3.0.1",
"css-loader": "0.23.1",
- "eslint": "2.9.0",
- "eslint-plugin-react": "5.1.1",
+ "eslint": "3.0.1",
+ "eslint-plugin-react": "5.2.2",
"exports-loader": "0.6.3",
"extract-text-webpack-plugin": "1.0.1",
- "file-loader": "0.8.5",
+ "file-loader": "0.9.0",
"html-loader": "0.4.3",
+ "html-webpack-plugin": "2.22.0",
"imports-loader": "0.6.5",
- "image-webpack-loader": "1.8.0",
+ "image-webpack-loader": "2.0.0",
"jquery-deferred": "0.3.0",
- "jsdom": "9.0.0",
- "jsdom-global": "1.7.0",
+ "jsdom": "9.4.1",
+ "jsdom-global": "2.0.0",
"json-loader": "0.5.4",
- "mocha": "2.4.5",
+ "mocha": "2.5.3",
"mocha-jsdom": "1.1.0",
- "mocha-webpack": "0.3.0",
- "node-sass": "3.4.2",
+ "mocha-webpack": "0.4.0",
+ "node-sass": "3.8.0",
"raw-loader": "0.5.1",
- "react-addons-test-utils": "15.0.2",
- "sass-loader": "3.2.0",
+ "react-addons-test-utils": "15.2.1",
+ "sass-loader": "4.0.0",
"style-loader": "0.13.1",
"url-loader": "0.5.7",
"webpack": "2.1.0-beta.13",
diff --git a/webapp/root.html b/webapp/root.html
index d3c569361..b48712e46 100644
--- a/webapp/root.html
+++ b/webapp/root.html
@@ -34,11 +34,8 @@
<!-- CSS Should always go first -->
<link rel='stylesheet' class='code_theme'>
- <!--<link rel='stylesheet' href='/static/css/styles.css'>-->
<style id='antiClickjack'>body{display:none !important;}</style>
- <script src='/static/bundle.js'></script>
-
<script type='text/javascript'>
if (self === top) {
var blocker = document.getElementById('antiClickjack');
diff --git a/webapp/sass/components/_modal.scss b/webapp/sass/components/_modal.scss
index 73651a320..6e5ff5d06 100644
--- a/webapp/sass/components/_modal.scss
+++ b/webapp/sass/components/_modal.scss
@@ -384,7 +384,7 @@
.loader-percent {
bottom: 0;
- color: $dark-gray;
+ color: alpha-color($white, 0.8);
height: 20px;
left: 0;
margin: auto;
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/sass/layout/_post-right.scss b/webapp/sass/layout/_post-right.scss
index f1fe0cac3..3ee4fe025 100644
--- a/webapp/sass/layout/_post-right.scss
+++ b/webapp/sass/layout/_post-right.scss
@@ -1,156 +1,154 @@
@charset 'UTF-8';
-.app__body {
- .post-right__container {
- @include display-flex;
- @include flex-direction(column);
- height: 100%;
-
- .post-right-root-message {
- padding: 1em 1em 0;
- }
-
- .post-right-comments-container {
- position: relative;
+.post-right__container {
+ @include display-flex;
+ @include flex-direction(column);
+ height: 100%;
- .post {
- &:first-child {
- padding-top: 15px;
- }
- }
- }
+ .post-right-root-message {
+ padding: 1em 1em 0;
+ }
+ .post-right-comments-container {
+ position: relative;
- .help_format_text {
- bottom: -63px;
- right: auto;
+ .post {
+ &:first-child {
+ padding-top: 15px;
+ }
}
+ }
- .post {
- &.post--root {
- border-bottom: 1px solid #ddd;
- padding-bottom: 1em;
- .post__body {
- background: transparent !important;
- }
- }
+ .help_format_text {
+ bottom: -63px;
+ right: auto;
+ }
- .post__header {
- .col__reply {
- background: transparent !important;
- border: none !important;
- text-align: right;
- top: 0;
- }
- }
+ .post {
+ &.post--root {
+ border-bottom: 1px solid #ddd;
+ padding-bottom: 1em;
.post__body {
- width: 100%;
+ background: transparent !important;
}
}
- hr {
- border: none;
- margin-bottom: 0;
+ .post__header {
+ .col__reply {
+ background: transparent !important;
+ border: none !important;
+ text-align: right;
+ top: 0;
+ }
}
- .post-create__container {
+ .post__body {
width: 100%;
+ }
+ }
- .textarea-wrapper {
- min-height: 100px;
- }
-
- .btn {
- margin-bottom: 10px;
- }
+ hr {
+ border: none;
+ margin-bottom: 0;
+ }
- .custom-textarea {
- min-height: 100px;
- }
+ .post-create__container {
+ width: 100%;
- .msg-typing {
- @include opacity(.7);
- display: block;
- float: left;
- font-size: 13px;
- height: 20px;
- line-height: 20px;
- margin-top: 3px;
- max-width: 230px;
- min-width: 1px;
- }
+ .textarea-wrapper {
+ min-height: 100px;
+ }
- .post-create-footer {
- padding: 5px 0 10px;
- width: 100%;
- }
+ .btn {
+ margin-bottom: 10px;
+ }
- .post-right-comments-upload-in-progress {
- color: #a8adb7;
- margin-right: 10px;
- padding: 6px 0;
- }
+ .custom-textarea {
+ min-height: 100px;
}
- }
- .post-right-header {
- border-bottom: $border-gray;
- color: #999;
- font-size: 1em;
- font-weight: 400;
- height: 39px;
- padding: 10px 10px 0 15px;
- text-transform: uppercase;
- }
+ .msg-typing {
+ @include opacity(.7);
+ display: block;
+ float: left;
+ font-size: 13px;
+ height: 20px;
+ line-height: 20px;
+ margin-top: 3px;
+ max-width: 230px;
+ min-width: 1px;
+ }
- .post-right-root-container {
- border-bottom: $border-gray;
- padding: 5px 10px;
+ .post-create-footer {
+ padding: 5px 0 10px;
+ width: 100%;
+ }
- ul {
- margin-bottom: 2px;
- padding-left: 0;
+ .post-right-comments-upload-in-progress {
+ color: #a8adb7;
+ margin-right: 10px;
+ padding: 6px 0;
}
}
+}
- .post-right-channel__name {
- font-weight: 600;
- margin: 0 0 15px;
- }
+.post-right-header {
+ border-bottom: $border-gray;
+ color: #999;
+ font-size: 1em;
+ font-weight: 400;
+ height: 39px;
+ padding: 10px 10px 0 15px;
+ text-transform: uppercase;
+}
- .post-right-root-container li {
- display: inline;
- list-style-type: none;
- padding-right: 3px;
- }
+.post-right-root-container {
+ border-bottom: $border-gray;
+ padding: 5px 10px;
- .post-right-root-time {
- color: #a8adb7;
+ ul {
+ margin-bottom: 2px;
+ padding-left: 0;
}
+}
- .post-right-create-comment-container {
- bottom: 0;
- left: 0;
- margin-bottom: 18px;
- padding: 5px;
- position: absolute;
- width: 100%;
- }
+.post-right-channel__name {
+ font-weight: 600;
+ margin: 0 0 15px;
+}
- .post-right__scroll {
- @include flex(1 1 auto);
- -webkit-overflow-scrolling: touch;
- overflow: auto;
- position: relative;
+.post-right-root-container li {
+ display: inline;
+ list-style-type: none;
+ padding-right: 3px;
+}
- .file-preview__container {
- margin-top: 5px;
- }
- }
+.post-right-root-time {
+ color: #a8adb7;
+}
+
+.post-right-create-comment-container {
+ bottom: 0;
+ left: 0;
+ margin-bottom: 18px;
+ padding: 5px;
+ position: absolute;
+ width: 100%;
+}
- .post-right-comment-time {
- color: #a8adb7;
+.post-right__scroll {
+ -webkit-overflow-scrolling: touch;
+ @include flex(1 1 auto);
+ overflow: auto;
+ position: relative;
+
+ .file-preview__container {
+ margin-top: 5px;
}
}
+
+.post-right-comment-time {
+ color: #a8adb7;
+}
diff --git a/webapp/sass/layout/_post.scss b/webapp/sass/layout/_post.scss
index 50158ccc2..f95bb3e59 100644
--- a/webapp/sass/layout/_post.scss
+++ b/webapp/sass/layout/_post.scss
@@ -458,7 +458,7 @@ body.ios {
.post-list__table {
display: table;
height: 100%;
- min-height: 100%;
+ min-height: 350px;
table-layout: fixed;
width: 100%;
diff --git a/webapp/sass/layout/_sidebar-left.scss b/webapp/sass/layout/_sidebar-left.scss
index d4d01c865..4c718327e 100644
--- a/webapp/sass/layout/_sidebar-left.scss
+++ b/webapp/sass/layout/_sidebar-left.scss
@@ -178,6 +178,10 @@
border-radius: 0;
font-weight: 400;
position: relative;
+
+ &.unread-title {
+ font-weight: 600;
+ }
}
}
}
diff --git a/webapp/sass/responsive/_mobile.scss b/webapp/sass/responsive/_mobile.scss
index 9db962ec1..fcbf23b1c 100644
--- a/webapp/sass/responsive/_mobile.scss
+++ b/webapp/sass/responsive/_mobile.scss
@@ -493,14 +493,6 @@
}
.post-create__container {
- .post-right__container & {
- padding: 0 1em;
-
- .msg-typing {
- display: none;
- }
- }
-
form {
padding: .5em 0;
}
@@ -524,6 +516,7 @@
.post-body__cell {
display: table-cell;
padding-left: 45px;
+
.sidebar--right & {
padding-left: 0;
}
@@ -531,12 +524,12 @@
.app__content & {
.btn-file {
- width: 45px;
- padding: 0;
- line-height: 36px;
bottom: -2px;
left: 0;
+ line-height: 36px;
+ padding: 0;
top: auto;
+ width: 45px;
}
}
@@ -576,6 +569,24 @@
}
}
+ // Since system console is not responsive we're overriding bootstrap styles for it
+ .admin-sidebar {
+ .navbar-nav {
+ margin-top: 0;
+
+ > li {
+ float: left;
+ }
+
+ .dropdown-menu {
+ background: $white;
+ left: auto;
+ position: absolute;
+ right: 0;
+ }
+ }
+ }
+
#navbar {
.navbar-default {
.navbar-header {
@@ -802,6 +813,25 @@
right: 0;
width: 100%;
+ .post-create__container {
+ form {
+ padding: .5em 1em;
+ }
+
+ .msg-typing:empty {
+ display: none;
+ }
+
+ .post-create-footer {
+ padding: 1em 0;
+
+ .control-label {
+ margin: .5em 0;
+ top: 0;
+ }
+ }
+ }
+
.sidebar__collapse,
.sidebar__search-icon {
display: block;
@@ -871,8 +901,8 @@
}
.app__content {
- padding-top: 45px;
margin: 0;
+ padding-top: 45px;
.channel__wrap & {
padding-top: 45px;
diff --git a/webapp/sass/routes/_settings.scss b/webapp/sass/routes/_settings.scss
index f67d1b49b..36a1acf76 100644
--- a/webapp/sass/routes/_settings.scss
+++ b/webapp/sass/routes/_settings.scss
@@ -171,6 +171,9 @@
.theme-label {
font-weight: 400;
margin-top: 5px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
img {
@@ -343,7 +346,10 @@
a {
border-radius: 0;
color: $gray;
- padding: 8px 5px 8px 15px;
+ overflow: hidden;
+ padding: 8px 15px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.icon {
@@ -382,10 +388,7 @@
@include alpha-property(background-color, $black, .1);
border-radius: 0;
font-weight: 400;
- overflow: hidden;
position: relative;
- text-overflow: ellipsis;
- white-space: nowrap;
}
}
}
@@ -475,3 +478,8 @@
.no-resize {
resize: none;
}
+
+.user-settings__submit-checkbox {
+ padding-top: 0px;
+ padding-bottom: 20px;
+}
diff --git a/webapp/stores/channel_store.jsx b/webapp/stores/channel_store.jsx
index dc2577811..542fbdf6b 100644
--- a/webapp/stores/channel_store.jsx
+++ b/webapp/stores/channel_store.jsx
@@ -13,6 +13,7 @@ const CHANGE_EVENT = 'change';
const LEAVE_EVENT = 'leave';
const MORE_CHANGE_EVENT = 'change';
const EXTRA_INFO_EVENT = 'extra_info';
+const LAST_VIEVED_EVENT = 'last_viewed';
class ChannelStoreClass extends EventEmitter {
constructor(props) {
@@ -32,6 +33,9 @@ class ChannelStoreClass extends EventEmitter {
this.emitLeave = this.emitLeave.bind(this);
this.addLeaveListener = this.addLeaveListener.bind(this);
this.removeLeaveListener = this.removeLeaveListener.bind(this);
+ this.emitLastViewed = this.emitLastViewed.bind(this);
+ this.addLastViewedListener = this.addLastViewedListener.bind(this);
+ this.removeLastViewedListener = this.removeLastViewedListener.bind(this);
this.findFirstBy = this.findFirstBy.bind(this);
this.get = this.get.bind(this);
this.getMember = this.getMember.bind(this);
@@ -109,6 +113,18 @@ class ChannelStoreClass extends EventEmitter {
this.removeListener(LEAVE_EVENT, callback);
}
+ emitLastViewed(lastViewed, ownNewMessage) {
+ this.emit(LAST_VIEVED_EVENT, lastViewed, ownNewMessage);
+ }
+
+ addLastViewedListener(callback) {
+ this.on(LAST_VIEVED_EVENT, callback);
+ }
+
+ removeLastViewedListener(callback) {
+ this.removeListener(LAST_VIEVED_EVENT, callback);
+ }
+
findFirstBy(field, value) {
return this.doFindFirst(field, value, this.getChannels());
}
diff --git a/webapp/stores/preference_store.jsx b/webapp/stores/preference_store.jsx
index 324ec4864..654036ae8 100644
--- a/webapp/stores/preference_store.jsx
+++ b/webapp/stores/preference_store.jsx
@@ -54,6 +54,16 @@ class PreferenceStoreClass extends EventEmitter {
return parseInt(this.preferences.get(key), 10);
}
+ getObject(category, name, defaultValue = null) {
+ const key = this.getKey(category, name);
+
+ if (!this.preferences.has(key)) {
+ return defaultValue;
+ }
+
+ return JSON.parse(this.preferences.get(key));
+ }
+
getCategory(category) {
const prefix = category + '--';
@@ -78,6 +88,10 @@ class PreferenceStoreClass extends EventEmitter {
}
}
+ deletePreference(preference) {
+ this.preferences.delete(this.getKey(preference.category, preference.name));
+ }
+
clear() {
this.preferences.clear();
}
@@ -94,6 +108,18 @@ class PreferenceStoreClass extends EventEmitter {
this.removeListener(CHANGE_EVENT, callback);
}
+ getTheme(teamId) {
+ if (this.preferences.has(this.getKey(Constants.Preferences.CATEGORY_THEME, teamId))) {
+ return this.getObject(Constants.Preferences.CATEGORY_THEME, teamId);
+ }
+
+ if (this.preferences.has(this.getKey(Constants.Preferences.CATEGORY_THEME, ''))) {
+ return this.getObject(Constants.Preferences.CATEGORY_THEME, '');
+ }
+
+ return Constants.THEMES.default;
+ }
+
handleEventPayload(payload) {
const action = payload.action;
@@ -108,6 +134,12 @@ class PreferenceStoreClass extends EventEmitter {
this.setPreferencesFromServer(action.preferences);
this.emitChange();
break;
+ case ActionTypes.DELETED_PREFERENCES:
+ for (const preference of action.preferences) {
+ this.deletePreference(preference);
+ }
+ this.emitChange();
+ break;
}
}
}
diff --git a/webapp/utils/async_client.jsx b/webapp/utils/async_client.jsx
index e55742140..b31a2a6b9 100644
--- a/webapp/utils/async_client.jsx
+++ b/webapp/utils/async_client.jsx
@@ -145,6 +145,43 @@ export function updateLastViewedAt(id) {
);
}
+export function setLastViewedAt(lastViewedAt, id) {
+ let channelId;
+ if (id) {
+ channelId = id;
+ } else {
+ channelId = ChannelStore.getCurrentId();
+ }
+
+ if (channelId == null) {
+ return;
+ }
+
+ if (lastViewedAt == null) {
+ return;
+ }
+
+ if (isCallInProgress(`setLastViewedAt${channelId}${lastViewedAt}`)) {
+ return;
+ }
+
+ callTracker[`setLastViewedAt${channelId}${lastViewedAt}`] = utils.getTimestamp();
+ Client.setLastViewedAt(
+ channelId,
+ lastViewedAt,
+ () => {
+ callTracker.setLastViewedAt = 0;
+ ErrorStore.clearLastError();
+ },
+ (err) => {
+ callTracker.setLastViewedAt = 0;
+ var count = ErrorStore.getConnectionErrorCount();
+ ErrorStore.setConnectionErrorCount(count + 1);
+ dispatchError(err, 'setLastViewedAt');
+ }
+ );
+}
+
export function getMoreChannels(force) {
if (isCallInProgress('getMoreChannels')) {
return;
@@ -815,6 +852,29 @@ export function savePreferences(preferences, success, error) {
);
}
+export function deletePreferences(preferences, success, error) {
+ Client.deletePreferences(
+ preferences,
+ (data) => {
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.DELETED_PREFERENCES,
+ preferences
+ });
+
+ if (success) {
+ success(data);
+ }
+ },
+ (err) => {
+ dispatchError(err, 'deletePreferences');
+
+ if (error) {
+ error();
+ }
+ }
+ );
+}
+
export function getSuggestedCommands(command, suggestionId, component) {
Client.listCommands(
(data) => {
diff --git a/webapp/utils/channel_intro_messages.jsx b/webapp/utils/channel_intro_messages.jsx
index 50d12ed42..6418615a4 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 = '';
@@ -189,7 +194,7 @@ export function createStandardIntroMessage(channel) {
values={{
name: (uiName),
type: (uiType),
- date: (date)
+ date
}}
/>
);
@@ -202,7 +207,7 @@ export function createStandardIntroMessage(channel) {
values={{
name: (uiName),
type: (uiType),
- date: (date),
+ date,
creator: 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/constants.jsx b/webapp/utils/constants.jsx
index 52fb23d51..d780efe30 100644
--- a/webapp/utils/constants.jsx
+++ b/webapp/utils/constants.jsx
@@ -33,100 +33,125 @@ import mattermostDarkThemeImage from 'images/themes/mattermost_dark.png';
import mattermostThemeImage from 'images/themes/mattermost.png';
import windows10ThemeImage from 'images/themes/windows_dark.png';
-export default {
- ActionTypes: keyMirror({
- RECEIVED_ERROR: null,
-
- CLICK_CHANNEL: null,
- CREATE_CHANNEL: null,
- LEAVE_CHANNEL: null,
- CREATE_POST: null,
- CREATE_COMMENT: null,
- POST_DELETED: null,
- REMOVE_POST: null,
-
- RECEIVED_CHANNELS: null,
- RECEIVED_CHANNEL: null,
- RECEIVED_MORE_CHANNELS: null,
- RECEIVED_CHANNEL_EXTRA_INFO: null,
-
- FOCUS_POST: null,
- RECEIVED_POSTS: null,
- RECEIVED_FOCUSED_POST: null,
- RECEIVED_POST: null,
- RECEIVED_EDIT_POST: null,
- RECEIVED_SEARCH: null,
- RECEIVED_SEARCH_TERM: null,
- RECEIVED_POST_SELECTED: null,
- RECEIVED_MENTION_DATA: null,
- RECEIVED_ADD_MENTION: null,
-
- RECEIVED_PROFILES_FOR_DM_LIST: null,
- RECEIVED_PROFILES: null,
- RECEIVED_DIRECT_PROFILES: null,
- RECEIVED_ME: null,
- RECEIVED_SESSIONS: null,
- RECEIVED_AUDITS: null,
- RECEIVED_TEAMS: null,
- RECEIVED_STATUSES: null,
- RECEIVED_PREFERENCE: null,
- RECEIVED_PREFERENCES: null,
- RECEIVED_FILE_INFO: null,
- RECEIVED_ANALYTICS: null,
-
- RECEIVED_INCOMING_WEBHOOKS: null,
- RECEIVED_INCOMING_WEBHOOK: null,
- REMOVED_INCOMING_WEBHOOK: null,
- RECEIVED_OUTGOING_WEBHOOKS: null,
- RECEIVED_OUTGOING_WEBHOOK: null,
- UPDATED_OUTGOING_WEBHOOK: null,
- REMOVED_OUTGOING_WEBHOOK: null,
- RECEIVED_COMMANDS: null,
- RECEIVED_COMMAND: null,
- UPDATED_COMMAND: null,
- REMOVED_COMMAND: null,
-
- RECEIVED_CUSTOM_EMOJIS: null,
- RECEIVED_CUSTOM_EMOJI: null,
- UPDATED_CUSTOM_EMOJI: null,
- REMOVED_CUSTOM_EMOJI: null,
-
- RECEIVED_MSG: null,
-
- RECEIVED_MY_TEAM: null,
- CREATED_TEAM: null,
-
- RECEIVED_CONFIG: null,
- RECEIVED_LOGS: null,
- RECEIVED_SERVER_AUDITS: null,
- RECEIVED_SERVER_COMPLIANCE_REPORTS: null,
- RECEIVED_ALL_TEAMS: null,
- RECEIVED_ALL_TEAM_LISTINGS: null,
- RECEIVED_TEAM_MEMBERS: null,
- RECEIVED_MEMBERS_FOR_TEAM: null,
-
- RECEIVED_LOCALE: null,
-
- SHOW_SEARCH: null,
-
- USER_TYPING: null,
-
- TOGGLE_IMPORT_THEME_MODAL: null,
- TOGGLE_INVITE_MEMBER_MODAL: null,
- TOGGLE_LEAVE_TEAM_MODAL: null,
- TOGGLE_DELETE_POST_MODAL: null,
- TOGGLE_GET_POST_LINK_MODAL: null,
- TOGGLE_GET_TEAM_INVITE_LINK_MODAL: null,
- TOGGLE_REGISTER_APP_MODAL: null,
- TOGGLE_GET_PUBLIC_LINK_MODAL: null,
-
- SUGGESTION_PRETEXT_CHANGED: null,
- SUGGESTION_RECEIVED_SUGGESTIONS: null,
- SUGGESTION_CLEAR_SUGGESTIONS: null,
- SUGGESTION_COMPLETE_WORD: null,
- SUGGESTION_SELECT_NEXT: null,
- SUGGESTION_SELECT_PREVIOUS: null
- }),
+export const Preferences = {
+ CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show',
+ CATEGORY_DISPLAY_SETTINGS: 'display_settings',
+ DISPLAY_PREFER_NICKNAME: 'nickname_full_name',
+ DISPLAY_PREFER_FULL_NAME: 'full_name',
+ CATEGORY_ADVANCED_SETTINGS: 'advanced_settings',
+ TUTORIAL_STEP: 'tutorial_step',
+ CHANNEL_DISPLAY_MODE: 'channel_display_mode',
+ CHANNEL_DISPLAY_MODE_CENTERED: 'centered',
+ CHANNEL_DISPLAY_MODE_FULL_SCREEN: 'full',
+ CHANNEL_DISPLAY_MODE_DEFAULT: 'centered',
+ MESSAGE_DISPLAY: 'message_display',
+ MESSAGE_DISPLAY_CLEAN: 'clean',
+ MESSAGE_DISPLAY_COMPACT: 'compact',
+ MESSAGE_DISPLAY_DEFAULT: 'clean',
+ COLLAPSE_DISPLAY: 'collapse_previews',
+ COLLAPSE_DISPLAY_DEFAULT: 'false',
+ USE_MILITARY_TIME: 'use_military_time',
+ CATEGORY_THEME: 'theme'
+};
+
+export const ActionTypes = keyMirror({
+ RECEIVED_ERROR: null,
+
+ CLICK_CHANNEL: null,
+ CREATE_CHANNEL: null,
+ LEAVE_CHANNEL: null,
+ CREATE_POST: null,
+ CREATE_COMMENT: null,
+ POST_DELETED: null,
+ REMOVE_POST: null,
+
+ RECEIVED_CHANNELS: null,
+ RECEIVED_CHANNEL: null,
+ RECEIVED_MORE_CHANNELS: null,
+ RECEIVED_CHANNEL_EXTRA_INFO: null,
+
+ FOCUS_POST: null,
+ RECEIVED_POSTS: null,
+ RECEIVED_FOCUSED_POST: null,
+ RECEIVED_POST: null,
+ RECEIVED_EDIT_POST: null,
+ RECEIVED_SEARCH: null,
+ RECEIVED_SEARCH_TERM: null,
+ RECEIVED_POST_SELECTED: null,
+ RECEIVED_MENTION_DATA: null,
+ RECEIVED_ADD_MENTION: null,
+
+ RECEIVED_PROFILES_FOR_DM_LIST: null,
+ RECEIVED_PROFILES: null,
+ RECEIVED_DIRECT_PROFILES: null,
+ RECEIVED_ME: null,
+ RECEIVED_SESSIONS: null,
+ RECEIVED_AUDITS: null,
+ RECEIVED_TEAMS: null,
+ RECEIVED_STATUSES: null,
+ RECEIVED_PREFERENCE: null,
+ RECEIVED_PREFERENCES: null,
+ DELETED_PREFERENCES: null,
+ RECEIVED_FILE_INFO: null,
+ RECEIVED_ANALYTICS: null,
+
+ RECEIVED_INCOMING_WEBHOOKS: null,
+ RECEIVED_INCOMING_WEBHOOK: null,
+ REMOVED_INCOMING_WEBHOOK: null,
+ RECEIVED_OUTGOING_WEBHOOKS: null,
+ RECEIVED_OUTGOING_WEBHOOK: null,
+ UPDATED_OUTGOING_WEBHOOK: null,
+ REMOVED_OUTGOING_WEBHOOK: null,
+ RECEIVED_COMMANDS: null,
+ RECEIVED_COMMAND: null,
+ UPDATED_COMMAND: null,
+ REMOVED_COMMAND: null,
+
+ RECEIVED_CUSTOM_EMOJIS: null,
+ RECEIVED_CUSTOM_EMOJI: null,
+ UPDATED_CUSTOM_EMOJI: null,
+ REMOVED_CUSTOM_EMOJI: null,
+
+ RECEIVED_MSG: null,
+
+ RECEIVED_MY_TEAM: null,
+ CREATED_TEAM: null,
+
+ RECEIVED_CONFIG: null,
+ RECEIVED_LOGS: null,
+ RECEIVED_SERVER_AUDITS: null,
+ RECEIVED_SERVER_COMPLIANCE_REPORTS: null,
+ RECEIVED_ALL_TEAMS: null,
+ RECEIVED_ALL_TEAM_LISTINGS: null,
+ RECEIVED_TEAM_MEMBERS: null,
+ RECEIVED_MEMBERS_FOR_TEAM: null,
+
+ RECEIVED_LOCALE: null,
+
+ SHOW_SEARCH: null,
+
+ USER_TYPING: null,
+
+ TOGGLE_IMPORT_THEME_MODAL: null,
+ TOGGLE_INVITE_MEMBER_MODAL: null,
+ TOGGLE_LEAVE_TEAM_MODAL: null,
+ TOGGLE_DELETE_POST_MODAL: null,
+ TOGGLE_GET_POST_LINK_MODAL: null,
+ TOGGLE_GET_TEAM_INVITE_LINK_MODAL: null,
+ TOGGLE_REGISTER_APP_MODAL: null,
+ TOGGLE_GET_PUBLIC_LINK_MODAL: null,
+
+ SUGGESTION_PRETEXT_CHANGED: null,
+ SUGGESTION_RECEIVED_SUGGESTIONS: null,
+ SUGGESTION_CLEAR_SUGGESTIONS: null,
+ SUGGESTION_COMPLETE_WORD: null,
+ SUGGESTION_SELECT_NEXT: null,
+ SUGGESTION_SELECT_PREVIOUS: null
+});
+
+export const Constants = {
+ Preferences,
+ ActionTypes,
PayloadSources: keyMirror({
SERVER_ACTION: null,
@@ -174,7 +199,6 @@ export default {
FULLNAME: 'fullname',
NICKNAME: 'nickname',
EMAIL: 'email',
- THEME: 'theme',
LANGUAGE: 'language'
},
@@ -551,25 +575,6 @@ export default {
Ubuntu: 'font--ubuntu'
},
DEFAULT_FONT: 'Open Sans',
- Preferences: {
- CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show',
- CATEGORY_DISPLAY_SETTINGS: 'display_settings',
- DISPLAY_PREFER_NICKNAME: 'nickname_full_name',
- DISPLAY_PREFER_FULL_NAME: 'full_name',
- CATEGORY_ADVANCED_SETTINGS: 'advanced_settings',
- TUTORIAL_STEP: 'tutorial_step',
- CHANNEL_DISPLAY_MODE: 'channel_display_mode',
- CHANNEL_DISPLAY_MODE_CENTERED: 'centered',
- CHANNEL_DISPLAY_MODE_FULL_SCREEN: 'full',
- CHANNEL_DISPLAY_MODE_DEFAULT: 'full',
- MESSAGE_DISPLAY: 'message_display',
- MESSAGE_DISPLAY_CLEAN: 'clean',
- MESSAGE_DISPLAY_COMPACT: 'compact',
- MESSAGE_DISPLAY_DEFAULT: 'clean',
- COLLAPSE_DISPLAY: 'collapse_previews',
- COLLAPSE_DISPLAY_DEFAULT: 'false',
- USE_MILITARY_TIME: 'use_military_time'
- },
TutorialSteps: {
INTRO_SCREENS: 0,
POST_POPOVER: 1,
@@ -761,6 +766,8 @@ export default {
MAX_PASSWORD_LENGTH: 64,
MIN_TRIGGER_LENGTH: 1,
MAX_TRIGGER_LENGTH: 128,
+ MAX_TEXTSETTING_LENGTH: 1024,
+ MAX_SITENAME_LENGTH: 30,
TIME_SINCE_UPDATE_INTERVAL: 30000,
MIN_HASHTAG_LINK_LENGTH: 3,
CHANNEL_SCROLL_ADJUSTMENT: 100,
@@ -777,3 +784,5 @@ export default {
PERMISSIONS_TEAM_ADMIN: 'team_admin',
PERMISSIONS_SYSTEM_ADMIN: 'system_admin'
};
+
+export default Constants;
diff --git a/webapp/utils/text_formatting.jsx b/webapp/utils/text_formatting.jsx
index 0b46edaeb..5ada7727f 100644
--- a/webapp/utils/text_formatting.jsx
+++ b/webapp/utils/text_formatting.jsx
@@ -263,7 +263,8 @@ function autolinkHashtags(text, tokens) {
newTokens.set(newAlias, {
value: `<a class='mention-link' href='#' data-hashtag='${token.originalText}'>${token.originalText}</a>`,
- originalText: token.originalText
+ originalText: token.originalText,
+ hashtag: token.originalText.substring(1)
});
output = output.replace(alias, newAlias);
@@ -276,19 +277,19 @@ function autolinkHashtags(text, tokens) {
}
// look for hashtags in the text
- function replaceHashtagWithToken(fullMatch, prefix, hashtag) {
+ function replaceHashtagWithToken(fullMatch, prefix, originalText) {
const index = tokens.size;
const alias = `MM_HASHTAG${index}`;
- let value = hashtag;
-
- if (hashtag.length > Constants.MIN_HASHTAG_LINK_LENGTH) {
- value = `<a class='mention-link' href='#' data-hashtag='${hashtag}'>${hashtag}</a>`;
+ if (text.length < Constants.MIN_HASHTAG_LINK_LENGTH + 1) {
+ // too short to be a hashtag
+ return fullMatch;
}
tokens.set(alias, {
- value,
- originalText: hashtag
+ value: `<a class='mention-link' href='#' data-hashtag='${originalText}'>${originalText}</a>`,
+ originalText,
+ hashtag: originalText.substring(1)
});
return prefix + alias;
@@ -393,9 +394,11 @@ export function highlightSearchTerms(text, tokens, searchTerm) {
for (const term of terms) {
// highlight existing tokens matching search terms
+ const trimmedTerm = term.replace(/\*$/, '').toLowerCase();
var newTokens = new Map();
for (const [alias, token] of tokens) {
- if (token.originalText.toLowerCase() === term.replace(/\*$/, '').toLowerCase()) {
+ if (token.originalText.toLowerCase() === trimmedTerm ||
+ (token.hashtag && token.hashtag.toLowerCase() === trimmedTerm)) {
const index = tokens.size + newTokens.size;
const newAlias = `MM_SEARCHTERM${index}`;
diff --git a/webapp/utils/utils.jsx b/webapp/utils/utils.jsx
index bb17c2fdc..907c01229 100644
--- a/webapp/utils/utils.jsx
+++ b/webapp/utils/utils.jsx
@@ -139,7 +139,7 @@ export function notifyMe(title, body, channel, teamId) {
Notification.requestPermission((permission) => {
if (permission === 'granted') {
try {
- var notification = new Notification(title, {body: body, tag: body, icon: icon50});
+ var notification = new Notification(title, {body, tag: body, icon: icon50});
notification.onclick = () => {
window.focus();
if (channel) {
@@ -413,7 +413,7 @@ export function insertHtmlEntities(text) {
export function searchForTerm(term) {
AppDispatcher.handleServerAction({
type: ActionTypes.RECEIVED_SEARCH_TERM,
- term: term,
+ term,
do_search: true
});
}
@@ -494,7 +494,7 @@ export function splitFileLocation(fileLocation) {
var filePath = fileSplit.join('.');
var filename = filePath.split('/')[filePath.split('/').length - 1];
- return {ext: ext, name: filename, path: filePath};
+ return {ext, name: filename, path: filePath};
}
export function getPreviewImagePath(filename) {
@@ -1079,7 +1079,7 @@ export function generateId() {
if (c === 'x') {
v = r;
} else {
- v = r & 0x3 | 0x8;
+ v = (r & 0x3) | 0x8;
}
return v.toString(16);
@@ -1297,7 +1297,7 @@ export function fillArray(value, length) {
// Checks if a data transfer contains files not text, folders, etc..
// Slightly modified from http://stackoverflow.com/questions/6848043/how-do-i-detect-a-file-is-being-dragged-rather-than-a-draggable-element-on-my-pa
export function isFileTransfer(files) {
- if (isBrowserIE()) {
+ if (isBrowserIE() || isBrowserEdge()) {
return files.types != null && files.types.contains('Files');
}
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..da9ed9600 100644
--- a/webapp/webpack.config.js
+++ b/webapp/webpack.config.js
@@ -4,7 +4,7 @@ const ExtractTextPlugin = require('extract-text-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const nodeExternals = require('webpack-node-externals');
-const htmlExtract = new ExtractTextPlugin('html', 'root.html');
+const HtmlWebpackPlugin = require('html-webpack-plugin');
const NPM_TARGET = process.env.npm_lifecycle_event; //eslint-disable-line no-process-env
@@ -28,8 +28,8 @@ var config = {
output: {
path: 'dist',
publicPath: '/static/',
- filename: 'bundle.js',
- chunkFilename: '[name].[hash].[chunkhash].js'
+ filename: '[name].[hash].js',
+ chunkFilename: '[name].[chunkhash].js'
},
module: {
loaders: [
@@ -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'
},
@@ -81,7 +90,7 @@ var config = {
},
{
test: /\.html$/,
- loader: htmlExtract.extract('html?attrs=link:href')
+ loader: 'html?attrs=link:href'
}
]
},
@@ -92,7 +101,6 @@ var config = {
new webpack.ProvidePlugin({
'window.jQuery': 'jquery'
}),
- htmlExtract,
new CopyWebpackPlugin([
{from: 'images/emoji', to: 'emoji'},
{from: 'images/logo-email.png', to: 'images'},
@@ -159,7 +167,18 @@ if (!DEV) {
// Test mode configuration
if (TEST) {
+ config.entry = ['babel-polyfill', './root.jsx'];
+ config.target = 'node';
config.externals = [nodeExternals()];
+} else {
+ // For some reason this breaks mocha. So it goes here.
+ config.plugins.push(
+ new HtmlWebpackPlugin({
+ filename: 'root.html',
+ inject: 'head',
+ template: 'root.html'
+ })
+ );
}
module.exports = config;