summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Godeps/Godeps.json4
-rw-r--r--Godeps/_workspace/src/github.com/NYTimes/gziphandler/LICENSE.md13
-rw-r--r--Godeps/_workspace/src/github.com/NYTimes/gziphandler/README.md52
-rw-r--r--Godeps/_workspace/src/github.com/NYTimes/gziphandler/gzip.go140
-rw-r--r--Makefile17
-rw-r--r--api/command.go94
-rw-r--r--api/command_test.go5
-rw-r--r--config/config.json5
-rwxr-xr-xdocker/2.1/docker-entry.sh2
-rw-r--r--model/client.go7
-rw-r--r--model/command.go33
-rw-r--r--model/config.go10
-rw-r--r--store/sql_command_store.go1
-rw-r--r--web/sass-files/sass/routes/_print.scssbin4314 -> 0 bytes
-rw-r--r--web/web.go16
-rw-r--r--webapp/Makefile19
-rw-r--r--webapp/components/suggestion/command_provider.jsx4
-rw-r--r--webapp/components/suggestion/suggestion_box.jsx3
-rw-r--r--webapp/components/textbox.jsx1
-rw-r--r--webapp/components/user_settings/manage_command_hooks.jsx313
-rw-r--r--webapp/components/user_settings/user_settings_advanced.jsx4
-rw-r--r--webapp/i18n/en.json2
-rw-r--r--webapp/package.json6
-rw-r--r--webapp/utils/async_client.jsx6
-rw-r--r--webapp/utils/client.jsx5
-rw-r--r--webapp/utils/constants.jsx4
-rw-r--r--webapp/utils/text_formatting.jsx5
-rw-r--r--webapp/webpack.config.js30
28 files changed, 623 insertions, 178 deletions
diff --git a/Godeps/Godeps.json b/Godeps/Godeps.json
index 808d8dab1..f94cafc1c 100644
--- a/Godeps/Godeps.json
+++ b/Godeps/Godeps.json
@@ -6,6 +6,10 @@
],
"Deps": [
{
+ "ImportPath": "github.com/NYTimes/gziphandler",
+ "Rev": "a88790d49798560db24af70fb6a10a66e2549a72"
+ },
+ {
"ImportPath": "github.com/alecthomas/log4go",
"Rev": "8e9057c3b25c409a34c0b9737cdc82cbcafeabce"
},
diff --git a/Godeps/_workspace/src/github.com/NYTimes/gziphandler/LICENSE.md b/Godeps/_workspace/src/github.com/NYTimes/gziphandler/LICENSE.md
new file mode 100644
index 000000000..b7e2ecb63
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/NYTimes/gziphandler/LICENSE.md
@@ -0,0 +1,13 @@
+Copyright (c) 2015 The New York Times Company
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this library except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
diff --git a/Godeps/_workspace/src/github.com/NYTimes/gziphandler/README.md b/Godeps/_workspace/src/github.com/NYTimes/gziphandler/README.md
new file mode 100644
index 000000000..b1d55e26e
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/NYTimes/gziphandler/README.md
@@ -0,0 +1,52 @@
+Gzip Handler
+============
+
+This is a tiny Go package which wraps HTTP handlers to transparently gzip the
+response body, for clients which support it. Although it's usually simpler to
+leave that to a reverse proxy (like nginx or Varnish), this package is useful
+when that's undesirable.
+
+
+## Usage
+
+Call `GzipHandler` with any handler (an object which implements the
+`http.Handler` interface), and it'll return a new handler which gzips the
+response. For example:
+
+```go
+package main
+
+import (
+ "io"
+ "net/http"
+ "github.com/NYTimes/gziphandler"
+)
+
+func main() {
+ withoutGz := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain")
+ io.WriteString(w, "Hello, World")
+ })
+
+ withGz := gziphandler.GzipHandler(withoutGz)
+
+ http.Handle("/", withGz)
+ http.ListenAndServe("0.0.0.0:8000", nil)
+}
+```
+
+
+## Documentation
+
+The docs can be found at [godoc.org] [docs], as usual.
+
+
+## License
+
+[Apache 2.0] [license].
+
+
+
+
+[docs]: https://godoc.org/github.com/nytimes/gziphandler
+[license]: https://github.com/nytimes/gziphandler/blob/master/LICENSE.md
diff --git a/Godeps/_workspace/src/github.com/NYTimes/gziphandler/gzip.go b/Godeps/_workspace/src/github.com/NYTimes/gziphandler/gzip.go
new file mode 100644
index 000000000..d0c85c6d3
--- /dev/null
+++ b/Godeps/_workspace/src/github.com/NYTimes/gziphandler/gzip.go
@@ -0,0 +1,140 @@
+package gziphandler
+
+import (
+ "compress/gzip"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "sync"
+)
+
+const (
+ vary = "Vary"
+ acceptEncoding = "Accept-Encoding"
+ contentEncoding = "Content-Encoding"
+)
+
+type codings map[string]float64
+
+// The default qvalue to assign to an encoding if no explicit qvalue is set.
+// This is actually kind of ambiguous in RFC 2616, so hopefully it's correct.
+// The examples seem to indicate that it is.
+const DEFAULT_QVALUE = 1.0
+
+var gzipWriterPool = sync.Pool{
+ New: func() interface{} { return gzip.NewWriter(nil) },
+}
+
+// GzipResponseWriter provides an http.ResponseWriter interface, which gzips
+// bytes before writing them to the underlying response. This doesn't set the
+// Content-Encoding header, nor close the writers, so don't forget to do that.
+type GzipResponseWriter struct {
+ gw *gzip.Writer
+ http.ResponseWriter
+}
+
+// Write appends data to the gzip writer.
+func (w GzipResponseWriter) Write(b []byte) (int, error) {
+ return w.gw.Write(b)
+}
+
+// Flush flushes the underlying *gzip.Writer and then the underlying
+// http.ResponseWriter if it is an http.Flusher. This makes GzipResponseWriter
+// an http.Flusher.
+func (w GzipResponseWriter) Flush() {
+ w.gw.Flush()
+ if fw, ok := w.ResponseWriter.(http.Flusher); ok {
+ fw.Flush()
+ }
+}
+
+// GzipHandler wraps an HTTP handler, to transparently gzip the response body if
+// the client supports it (via the Accept-Encoding header).
+func GzipHandler(h http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add(vary, acceptEncoding)
+
+ if acceptsGzip(r) {
+ // Bytes written during ServeHTTP are redirected to this gzip writer
+ // before being written to the underlying response.
+ gzw := gzipWriterPool.Get().(*gzip.Writer)
+ defer gzipWriterPool.Put(gzw)
+ gzw.Reset(w)
+ defer gzw.Close()
+
+ w.Header().Set(contentEncoding, "gzip")
+ h.ServeHTTP(GzipResponseWriter{gzw, w}, r)
+ } else {
+ h.ServeHTTP(w, r)
+ }
+ })
+}
+
+// acceptsGzip returns true if the given HTTP request indicates that it will
+// accept a gzippped response.
+func acceptsGzip(r *http.Request) bool {
+ acceptedEncodings, _ := parseEncodings(r.Header.Get(acceptEncoding))
+ return acceptedEncodings["gzip"] > 0.0
+}
+
+// parseEncodings attempts to parse a list of codings, per RFC 2616, as might
+// appear in an Accept-Encoding header. It returns a map of content-codings to
+// quality values, and an error containing the errors encounted. It's probably
+// safe to ignore those, because silently ignoring errors is how the internet
+// works.
+//
+// See: http://tools.ietf.org/html/rfc2616#section-14.3
+func parseEncodings(s string) (codings, error) {
+ c := make(codings)
+ e := make([]string, 0)
+
+ for _, ss := range strings.Split(s, ",") {
+ coding, qvalue, err := parseCoding(ss)
+
+ if err != nil {
+ e = append(e, err.Error())
+
+ } else {
+ c[coding] = qvalue
+ }
+ }
+
+ // TODO (adammck): Use a proper multi-error struct, so the individual errors
+ // can be extracted if anyone cares.
+ if len(e) > 0 {
+ return c, fmt.Errorf("errors while parsing encodings: %s", strings.Join(e, ", "))
+ }
+
+ return c, nil
+}
+
+// parseCoding parses a single conding (content-coding with an optional qvalue),
+// as might appear in an Accept-Encoding header. It attempts to forgive minor
+// formatting errors.
+func parseCoding(s string) (coding string, qvalue float64, err error) {
+ for n, part := range strings.Split(s, ";") {
+ part = strings.TrimSpace(part)
+ qvalue = DEFAULT_QVALUE
+
+ if n == 0 {
+ coding = strings.ToLower(part)
+
+ } else if strings.HasPrefix(part, "q=") {
+ qvalue, err = strconv.ParseFloat(strings.TrimPrefix(part, "q="), 64)
+
+ if qvalue < 0.0 {
+ qvalue = 0.0
+
+ } else if qvalue > 1.0 {
+ qvalue = 1.0
+ }
+ }
+ }
+
+ if coding == "" {
+ err = fmt.Errorf("empty content-coding")
+ }
+
+ return
+}
diff --git a/Makefile b/Makefile
index 4ae2c3b1f..ce12dbaf6 100644
--- a/Makefile
+++ b/Makefile
@@ -136,6 +136,7 @@ package:
tar -C dist -czf $(DIST_PATH).tar.gz mattermost
build-client:
+ mkdir -p webapp/dist/files
cd webapp && make build
go-test:
@@ -196,15 +197,10 @@ clean: stop-docker
rm -Rf $(DIST_ROOT)
go clean $(GOFLAGS) -i ./...
- rm -rf web/react/node_modules
- rm -f web/static/js/bundle*.js
- rm -f web/static/js/bundle*.js.map
- rm -f web/static/js/libs*.js
- rm -f web/static/css/styles.css
+ cd webapp && make clean
rm -rf api/data
rm -rf logs
- rm -rf web/sass-files/.sass-cache
rm -rf Godeps/_workspace/pkg/
@@ -220,14 +216,17 @@ nuke: | clean clean-docker
touch $@
-run: start-docker run-server run-client
+run: | start-docker run-client run-server
run-server: .prepare-go
@echo Starting go web server
$(GO) run $(GOFLAGS) mattermost.go -config=config.json &
-run-client: build-client
- @echo Starting react processo
+run-client:
+ @echo Starting client
+
+ mkdir -p webapp/dist/files
+ cd webapp && make run
@if [ "$(BUILD_ENTERPRISE)" = "true" ] && [ -d "$(ENTERPRISE_DIR)" ]; then \
cp ./config/config.json ./config/config.json.bak; \
diff --git a/api/command.go b/api/command.go
index 99fd05d7a..29cee070e 100644
--- a/api/command.go
+++ b/api/command.go
@@ -44,7 +44,7 @@ func InitCommand(r *mux.Router) {
sr := r.PathPrefix("/commands").Subrouter()
sr.Handle("/execute", ApiUserRequired(executeCommand)).Methods("POST")
- sr.Handle("/list", ApiUserRequired(listCommands)).Methods("GET")
+ sr.Handle("/list", ApiUserRequired(listCommands)).Methods("POST")
sr.Handle("/create", ApiUserRequired(createCommand)).Methods("POST")
sr.Handle("/list_team_commands", ApiUserRequired(listTeamCommands)).Methods("GET")
@@ -76,7 +76,9 @@ func listCommands(c *Context, w http.ResponseWriter, r *http.Request) {
} else {
teamCmds := result.Data.([]*model.Command)
for _, cmd := range teamCmds {
- if cmd.AutoComplete && !seen[cmd.Id] {
+ if cmd.ExternalManagement {
+ commands = append(commands, autocompleteCommands(c, cmd, r)...)
+ } else if cmd.AutoComplete && !seen[cmd.Id] {
cmd.Sanitize()
seen[cmd.Trigger] = true
commands = append(commands, cmd)
@@ -88,6 +90,92 @@ func listCommands(c *Context, w http.ResponseWriter, r *http.Request) {
w.Write([]byte(model.CommandListToJson(commands)))
}
+func autocompleteCommands(c *Context, cmd *model.Command, r *http.Request) []*model.Command {
+ props := model.MapFromJson(r.Body)
+ command := strings.TrimSpace(props["command"])
+ channelId := strings.TrimSpace(props["channelId"])
+ parts := strings.Split(command, " ")
+ trigger := parts[0][1:]
+ message := strings.Join(parts[1:], " ")
+
+ chanChan := Srv.Store.Channel().Get(channelId)
+ teamChan := Srv.Store.Team().Get(c.Session.TeamId)
+ userChan := Srv.Store.User().Get(c.Session.UserId)
+
+ var team *model.Team
+ if tr := <-teamChan; tr.Err != nil {
+ c.Err = tr.Err
+ return make([]*model.Command, 0, 32)
+ } else {
+ team = tr.Data.(*model.Team)
+ }
+
+ var user *model.User
+ if ur := <-userChan; ur.Err != nil {
+ c.Err = ur.Err
+ return make([]*model.Command, 0, 32)
+ } else {
+ user = ur.Data.(*model.User)
+ }
+
+ var channel *model.Channel
+ if cr := <-chanChan; cr.Err != nil {
+ c.Err = cr.Err
+ return make([]*model.Command, 0, 32)
+ } else {
+ channel = cr.Data.(*model.Channel)
+ }
+
+ l4g.Debug(fmt.Sprintf(utils.T("api.command.execute_command.debug"), trigger, c.Session.UserId))
+ p := url.Values{}
+ p.Set("token", cmd.Token)
+
+ p.Set("team_id", cmd.TeamId)
+ p.Set("team_domain", team.Name)
+
+ p.Set("channel_id", channelId)
+ p.Set("channel_name", channel.Name)
+
+ p.Set("user_id", c.Session.UserId)
+ p.Set("user_name", user.Username)
+
+ p.Set("command", "/"+trigger)
+ p.Set("text", message)
+ p.Set("response_url", "not supported yet")
+ p.Set("suggest", "true")
+
+ method := "POST"
+ if cmd.Method == model.COMMAND_METHOD_GET {
+ method = "GET"
+ }
+
+ tr := &http.Transport{
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: *utils.Cfg.ServiceSettings.EnableInsecureOutgoingConnections},
+ }
+ client := &http.Client{Transport: tr}
+
+ req, _ := http.NewRequest(method, cmd.URL, strings.NewReader(p.Encode()))
+ req.Header.Set("Accept", "application/json")
+ if cmd.Method == model.COMMAND_METHOD_POST {
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ }
+
+ if resp, err := client.Do(req); err != nil {
+ c.Err = model.NewLocAppError("command", "api.command.execute_command.failed.app_error", map[string]interface{}{"Trigger": trigger}, err.Error())
+ } else {
+ if resp.StatusCode == http.StatusOK {
+ response := model.CommandListFromJson(resp.Body)
+
+ return response
+
+ } else {
+ body, _ := ioutil.ReadAll(resp.Body)
+ c.Err = model.NewLocAppError("command", "api.command.execute_command.failed_resp.app_error", map[string]interface{}{"Trigger": trigger, "Status": resp.Status}, string(body))
+ }
+ }
+ return make([]*model.Command, 0, 32)
+}
+
func executeCommand(c *Context, w http.ResponseWriter, r *http.Request) {
props := model.MapFromJson(r.Body)
command := strings.TrimSpace(props["command"])
@@ -159,7 +247,7 @@ func executeCommand(c *Context, w http.ResponseWriter, r *http.Request) {
teamCmds := result.Data.([]*model.Command)
for _, cmd := range teamCmds {
- if trigger == cmd.Trigger {
+ if trigger == cmd.Trigger || cmd.ExternalManagement {
l4g.Debug(fmt.Sprintf(utils.T("api.command.execute_command.debug"), trigger, c.Session.UserId))
p := url.Values{}
diff --git a/api/command_test.go b/api/command_test.go
index 22e2bd666..8ca8b65b1 100644
--- a/api/command_test.go
+++ b/api/command_test.go
@@ -24,7 +24,10 @@ func TestListCommands(t *testing.T) {
Client.LoginByEmail(team.Name, user1.Email, "pwd")
- if results, err := Client.ListCommands(); err != nil {
+ channel1 := &model.Channel{DisplayName: "AA", Name: "aa" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
+ channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel)
+
+ if results, err := Client.ListCommands(channel1.Id, "/test"); err != nil {
t.Fatal(err)
} else {
commands := results.Data.([]*model.Command)
diff --git a/config/config.json b/config/config.json
index 57f455b32..449ed74ec 100644
--- a/config/config.json
+++ b/config/config.json
@@ -21,7 +21,8 @@
"SessionLengthSSOInDays": 30,
"SessionCacheInMinutes": 10,
"WebsocketSecurePort": 443,
- "WebsocketPort": 80
+ "WebsocketPort": 80,
+ "WebserverMode": "regular"
},
"TeamSettings": {
"SiteName": "Mattermost",
@@ -139,4 +140,4 @@
"IdAttribute": null,
"QueryTimeout": 60
}
-} \ No newline at end of file
+}
diff --git a/docker/2.1/docker-entry.sh b/docker/2.1/docker-entry.sh
index 6bd2a1263..14b9ccd9d 100755
--- a/docker/2.1/docker-entry.sh
+++ b/docker/2.1/docker-entry.sh
@@ -107,5 +107,5 @@ sleep 5
# ------------------------
echo starting platform
-cd /mattermost/bin
+cd /mattermost/mattermost/bin
./platform -config=/config_docker.json
diff --git a/model/client.go b/model/client.go
index 3adcb980d..14c175fc1 100644
--- a/model/client.go
+++ b/model/client.go
@@ -363,8 +363,11 @@ func (c *Client) Command(channelId string, command string, suggest bool) (*Resul
}
}
-func (c *Client) ListCommands() (*Result, *AppError) {
- if r, err := c.DoApiGet("/commands/list", "", ""); err != nil {
+func (c *Client) ListCommands(channelId string, command string) (*Result, *AppError) {
+ m := make(map[string]string)
+ m["command"] = command
+ m["channelId"] = channelId
+ if r, err := c.DoApiPost("/commands/list", MapToJson(m)); err != nil {
return nil, err
} else {
return &Result{r.Header.Get(HEADER_REQUEST_ID),
diff --git a/model/command.go b/model/command.go
index 56d88f13c..8e0b31583 100644
--- a/model/command.go
+++ b/model/command.go
@@ -14,22 +14,23 @@ const (
)
type Command struct {
- Id string `json:"id"`
- Token string `json:"token"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
- DeleteAt int64 `json:"delete_at"`
- CreatorId string `json:"creator_id"`
- TeamId string `json:"team_id"`
- Trigger string `json:"trigger"`
- Method string `json:"method"`
- Username string `json:"username"`
- IconURL string `json:"icon_url"`
- AutoComplete bool `json:"auto_complete"`
- AutoCompleteDesc string `json:"auto_complete_desc"`
- AutoCompleteHint string `json:"auto_complete_hint"`
- DisplayName string `json:"display_name"`
- URL string `json:"url"`
+ Id string `json:"id"`
+ Token string `json:"token"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+ DeleteAt int64 `json:"delete_at"`
+ CreatorId string `json:"creator_id"`
+ TeamId string `json:"team_id"`
+ ExternalManagement bool `json:"external_management"`
+ Trigger string `json:"trigger"`
+ Method string `json:"method"`
+ Username string `json:"username"`
+ IconURL string `json:"icon_url"`
+ AutoComplete bool `json:"auto_complete"`
+ AutoCompleteDesc string `json:"auto_complete_desc"`
+ AutoCompleteHint string `json:"auto_complete_hint"`
+ DisplayName string `json:"display_name"`
+ URL string `json:"url"`
}
func (o *Command) ToJson() string {
diff --git a/model/config.go b/model/config.go
index 82c51224e..11ce260ee 100644
--- a/model/config.go
+++ b/model/config.go
@@ -21,6 +21,10 @@ const (
SERVICE_GITLAB = "gitlab"
SERVICE_GOOGLE = "google"
+
+ WEBSERVER_MODE_REGULAR = "regular"
+ WEBSERVER_MODE_GZIP = "gzip"
+ WEBSERVER_MODE_DISABLED = "disabled"
)
type ServiceSettings struct {
@@ -46,6 +50,7 @@ type ServiceSettings struct {
SessionCacheInMinutes *int
WebsocketSecurePort *int
WebsocketPort *int
+ WebserverMode *string
}
type SSOSettings struct {
@@ -383,6 +388,11 @@ func (o *Config) SetDefaults() {
o.ServiceSettings.AllowCorsFrom = new(string)
*o.ServiceSettings.AllowCorsFrom = ""
}
+
+ if o.ServiceSettings.WebserverMode == nil {
+ o.ServiceSettings.WebserverMode = new(string)
+ *o.ServiceSettings.WebserverMode = "regular"
+ }
}
func (o *Config) IsValid() *AppError {
diff --git a/store/sql_command_store.go b/store/sql_command_store.go
index 074a6e588..a35737bd7 100644
--- a/store/sql_command_store.go
+++ b/store/sql_command_store.go
@@ -34,6 +34,7 @@ func NewSqlCommandStore(sqlStore *SqlStore) CommandStore {
}
func (s SqlCommandStore) UpgradeSchemaIfNeeded() {
+ s.CreateColumnIfNotExists("Commands", "ExternalManagement", "tinyint(1)", "boolean", "0")
}
func (s SqlCommandStore) CreateIndexesIfNotExists() {
diff --git a/web/sass-files/sass/routes/_print.scss b/web/sass-files/sass/routes/_print.scss
deleted file mode 100644
index b03a549fb..000000000
--- a/web/sass-files/sass/routes/_print.scss
+++ /dev/null
Binary files differ
diff --git a/web/web.go b/web/web.go
index 86b642f3b..ff5040a4b 100644
--- a/web/web.go
+++ b/web/web.go
@@ -7,6 +7,8 @@ import (
"net/http"
"strings"
+ "github.com/NYTimes/gziphandler"
+
l4g "github.com/alecthomas/log4go"
"github.com/mattermost/platform/api"
"github.com/mattermost/platform/model"
@@ -23,11 +25,17 @@ func InitWeb() {
mainrouter := api.Srv.Router
- staticDir := utils.FindDir(CLIENT_DIR)
- l4g.Debug("Using client directory at %v", staticDir)
- mainrouter.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))))
+ if *utils.Cfg.ServiceSettings.WebserverMode != "disabled" {
+ 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)))))
+ } else {
+ mainrouter.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))))
+ }
- mainrouter.Handle("/{anything:.*}", api.AppHandlerIndependent(root)).Methods("GET")
+ mainrouter.Handle("/{anything:.*}", api.AppHandlerIndependent(root)).Methods("GET")
+ }
}
var browsersNotSupported string = "MSIE/8;MSIE/9;MSIE/10;Internet Explorer/8;Internet Explorer/9;Internet Explorer/10;Safari/7;Safari/8"
diff --git a/webapp/Makefile b/webapp/Makefile
index 99f896e53..88ee625a4 100644
--- a/webapp/Makefile
+++ b/webapp/Makefile
@@ -1,4 +1,4 @@
-.PHONY: build test
+.PHONY: build test run clean
test:
@echo Checking for style guide compliance
@@ -12,7 +12,20 @@ test:
touch $@
-build: .npminstall
- @echo Building mattermost web client
+build: | .npminstall test
+ @echo Building mattermost Webapp
npm run build
+
+run: .npminstall
+ @echo Running mattermost Webapp for development
+
+ npm run run
+
+
+clean:
+ @echo Cleaning Webapp
+
+ rm -rf dist
+ rm -rf node_modules
+ rm .npminstall
diff --git a/webapp/components/suggestion/command_provider.jsx b/webapp/components/suggestion/command_provider.jsx
index 36860fa66..204f52483 100644
--- a/webapp/components/suggestion/command_provider.jsx
+++ b/webapp/components/suggestion/command_provider.jsx
@@ -37,9 +37,9 @@ CommandSuggestion.propTypes = {
};
export default class CommandProvider {
- handlePretextChanged(suggestionId, pretext) {
+ handlePretextChanged(suggestionId, pretext, channelId) {
if (pretext.startsWith('/')) {
- AsyncClient.getSuggestedCommands(pretext, suggestionId, CommandSuggestion);
+ AsyncClient.getSuggestedCommands(pretext, channelId, suggestionId, CommandSuggestion);
}
}
}
diff --git a/webapp/components/suggestion/suggestion_box.jsx b/webapp/components/suggestion/suggestion_box.jsx
index e3ec63194..97c6c6cd9 100644
--- a/webapp/components/suggestion/suggestion_box.jsx
+++ b/webapp/components/suggestion/suggestion_box.jsx
@@ -111,7 +111,7 @@ export default class SuggestionBox extends React.Component {
handlePretextChanged(pretext) {
for (const provider of this.props.providers) {
- provider.handlePretextChanged(this.suggestionId, pretext);
+ provider.handlePretextChanged(this.suggestionId, pretext, this.props.channelId);
}
}
@@ -160,6 +160,7 @@ SuggestionBox.propTypes = {
value: React.PropTypes.string.isRequired,
onUserInput: React.PropTypes.func,
providers: React.PropTypes.arrayOf(React.PropTypes.object),
+ channelId: React.PropTypes.string,
// explicitly name any input event handlers we override and need to manually call
onChange: React.PropTypes.func,
diff --git a/webapp/components/textbox.jsx b/webapp/components/textbox.jsx
index 1a395072e..952026ed5 100644
--- a/webapp/components/textbox.jsx
+++ b/webapp/components/textbox.jsx
@@ -224,6 +224,7 @@ export default class Textbox extends React.Component {
style={{visibility: this.state.preview ? 'hidden' : 'visible'}}
listComponent={SuggestionList}
providers={this.suggestionProviders}
+ channelId={this.props.channelId}
/>
<div
ref='preview'
diff --git a/webapp/components/user_settings/manage_command_hooks.jsx b/webapp/components/user_settings/manage_command_hooks.jsx
index ce353ad64..9703664cc 100644
--- a/webapp/components/user_settings/manage_command_hooks.jsx
+++ b/webapp/components/user_settings/manage_command_hooks.jsx
@@ -4,9 +4,13 @@
import LoadingScreen from '../loading_screen.jsx';
import * as Client from 'utils/client.jsx';
+import * as Utils from 'utils/utils.jsx';
+import Constants from 'utils/constants.jsx';
import {intlShape, injectIntl, defineMessages, FormattedMessage, FormattedHTMLMessage} from 'react-intl';
+const PreReleaseFeatures = Constants.PRE_RELEASE_FEATURES;
+
const holders = defineMessages({
requestTypePost: {
id: 'user.settings.cmds.request_type_post',
@@ -59,6 +63,7 @@ export default class ManageCommandCmds extends React.Component {
this.getCmds = this.getCmds.bind(this);
this.addNewCmd = this.addNewCmd.bind(this);
this.emptyCmd = this.emptyCmd.bind(this);
+ this.updateExternalManagement = this.updateExternalManagement.bind(this);
this.updateTrigger = this.updateTrigger.bind(this);
this.updateURL = this.updateURL.bind(this);
this.updateMethod = this.updateMethod.bind(this);
@@ -99,7 +104,7 @@ export default class ManageCommandCmds extends React.Component {
addNewCmd(e) {
e.preventDefault();
- if (this.state.cmd.trigger === '' || this.state.cmd.url === '') {
+ if (this.state.cmd.url === '' || (this.state.cmd.trigger === '' && !this.state.external_management)) {
return;
}
@@ -189,6 +194,12 @@ export default class ManageCommandCmds extends React.Component {
);
}
+ updateExternalManagement(e) {
+ var cmd = this.state.cmd;
+ cmd.external_management = e.target.checked;
+ this.setState(cmd);
+ }
+
updateTrigger(e) {
var cmd = this.state.cmd;
cmd.trigger = e.target.value;
@@ -270,11 +281,26 @@ export default class ManageCommandCmds extends React.Component {
);
}
+ let slashCommandAutocompleteDiv;
+ if (Utils.isFeatureEnabled(PreReleaseFeatures.SLASHCMD_AUTOCMP)) {
+ slashCommandAutocompleteDiv = (
+ <div className='padding-top x2'>
+ <strong>
+ <FormattedMessage
+ id='user.settings.cmds.external_management'
+ defaultMessage='External management: '
+ />
+ </strong><span className='word-break--all'>{cmd.external_management ? this.props.intl.formatMessage(holders.autocompleteYes) : this.props.intl.formatMessage(holders.autocompleteNo)}</span>
+ </div>
+ );
+ }
+
cmds.push(
<div
key={cmd.id}
className='webhook__item webcmd__item'
>
+ {slashCommandAutocompleteDiv}
{triggerDiv}
<div className='padding-top x2 webcmd__url'>
<strong>
@@ -416,43 +442,115 @@ export default class ManageCommandCmds extends React.Component {
</div>
);
- const disableButton = this.state.cmd.trigger === '' || this.state.cmd.url === '';
+ const disableButton = this.state.cmd.url === '' || (this.state.cmd.trigger === '' && !this.state.external_management);
- return (
- <div key='addCommandCmd'>
- <FormattedHTMLMessage
- id='user.settings.cmds.add_desc'
- defaultMessage='Create slash commands to send events to external integrations and receive a response. For example typing `/patient Joe Smith` could bring back search results from your internal health records management system for the name “Joe Smith”. Please see <a href="http://docs.mattermost.com/developer/slash-commands.html">Slash commands documentation</a> for detailed instructions. View all slash commands configured on this team below.'
- />
- <div><label className='control-label padding-top x2'>
- <FormattedMessage
- id='user.settings.cmds.add_new'
- defaultMessage='Add a new command'
- />
- </label></div>
- <div className='padding-top divider-light'></div>
- <div className='padding-top'>
+ let triggerInput;
+ if (!this.state.cmd.external_management) {
+ triggerInput = (
+ <div className='padding-top x2'>
+ <label className='control-label'>
+ <FormattedMessage
+ id='user.settings.cmds.trigger'
+ defaultMessage='Command Trigger Word: '
+ />
+ </label>
+ <div className='padding-top'>
+ <input
+ ref='trigger'
+ className='form-control'
+ value={this.state.cmd.trigger}
+ onChange={this.updateTrigger}
+ placeholder={this.props.intl.formatMessage(holders.addTriggerPlaceholder)}
+ />
+ </div>
+ <div className='padding-top'>
+ <FormattedMessage
+ id='user.settings.cmds.trigger_desc'
+ defaultMessage='Examples: /patient, /client, /employee Reserved: /echo, /join, /logout, /me, /shrug'
+ />
+ </div>
+ </div>
+ );
+ }
+ let slashCommandAutocompleteCheckbox;
+ if (Utils.isFeatureEnabled(PreReleaseFeatures.SLASHCMD_AUTOCMP)) {
+ slashCommandAutocompleteCheckbox = (
+ <div className='padding-top x2'>
+ <label className='control-label'>
+ <FormattedMessage
+ id='user.settings.cmds.external_management'
+ defaultMessage='External management: '
+ />
+ </label>
+ <div className='padding-top'>
+ <div className='checkbox'>
+ <label>
+ <input
+ type='checkbox'
+ checked={this.state.cmd.external_management}
+ onChange={this.updateExternalManagement}
+ />
+ <FormattedMessage
+ id='user.settings.cmds.slashCmd_autocmp'
+ defaultMessage='Enable external application to offer autocomplete'
+ />
+ </label>
+ </div>
+ </div>
+ </div>
+
+ );
+ }
+
+ let autoCompleteSettings;
+ if (!this.state.cmd.external_management) {
+ autoCompleteSettings = (
+ <div>
<div className='padding-top x2'>
<label className='control-label'>
<FormattedMessage
- id='user.settings.cmds.trigger'
- defaultMessage='Command Trigger Word: '
+ id='user.settings.cmds.auto_complete'
+ defaultMessage='Autocomplete: '
+ />
+ </label>
+ <div className='padding-top'>
+ <div className='checkbox'>
+ <label>
+ <input
+ type='checkbox'
+ checked={this.state.cmd.auto_complete}
+ onChange={this.updateAutoComplete}
+ />
+ <FormattedMessage
+ id='user.settings.cmds.auto_complete_help'
+ defaultMessage=' Show this command in the autocomplete list.'
+ />
+ </label>
+ </div>
+ </div>
+ </div>
+
+ <div className='padding-top x2'>
+ <label className='control-label'>
+ <FormattedMessage
+ id='user.settings.cmds.auto_complete_hint'
+ defaultMessage='Autocomplete Hint: '
/>
</label>
<div className='padding-top'>
<input
- ref='trigger'
+ ref='autoCompleteHint'
className='form-control'
- value={this.state.cmd.trigger}
- onChange={this.updateTrigger}
- placeholder={this.props.intl.formatMessage(holders.addTriggerPlaceholder)}
+ value={this.state.cmd.auto_complete_hint}
+ onChange={this.updateAutoCompleteHint}
+ placeholder={this.props.intl.formatMessage(holders.addAutoCompleteHintPlaceholder)}
/>
</div>
<div className='padding-top'>
<FormattedMessage
- id='user.settings.cmds.trigger_desc'
- defaultMessage='Examples: /patient, /client, /employee Reserved: /echo, /join, /logout, /me, /shrug'
+ id='user.settings.cmds.auto_complete_hint_desc'
+ defaultMessage='Optional hint in the autocomplete list about parameters needed for command.'
/>
</div>
</div>
@@ -460,6 +558,76 @@ export default class ManageCommandCmds extends React.Component {
<div className='padding-top x2'>
<label className='control-label'>
<FormattedMessage
+ id='user.settings.cmds.auto_complete_desc'
+ defaultMessage='Autocomplete Description: '
+ />
+ </label>
+ <div className='padding-top'>
+ <input
+ ref='autoCompleteDesc'
+ className='form-control'
+ value={this.state.cmd.auto_complete_desc}
+ onChange={this.updateAutoCompleteDesc}
+ placeholder={this.props.intl.formatMessage(holders.addAutoCompleteDescPlaceholder)}
+ />
+ </div>
+ <div className='padding-top'>
+ <FormattedMessage
+ id='user.settings.cmds.auto_complete_desc_desc'
+ defaultMessage='Optional short description of slash command for the autocomplete list.'
+ />
+ </div>
+ </div>
+
+ <div className='padding-top x2'>
+ <label className='control-label'>
+ <FormattedMessage
+ id='user.settings.cmds.display_name'
+ defaultMessage='Descriptive Label: '
+ />
+ </label>
+ <div className='padding-top'>
+ <input
+ ref='displayName'
+ className='form-control'
+ value={this.state.cmd.display_name}
+ onChange={this.updateDisplayName}
+ placeholder={this.props.intl.formatMessage(holders.addDisplayNamePlaceholder)}
+ />
+ </div>
+ <div className='padding-top'>
+ <FormattedMessage
+ id='user.settings.cmds.cmd_display_name'
+ defaultMessage='Brief description of slash command to show in listings.'
+ />
+ </div>
+ {addError}
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div key='addCommandCmd'>
+ <FormattedHTMLMessage
+ id='user.settings.cmds.add_desc'
+ defaultMessage='Create slash commands to send events to external integrations and receive a response. For example typing `/patient Joe Smith` could bring back search results from your internal health records management system for the name “Joe Smith”. Please see <a href="http://docs.mattermost.com/developer/slash-commands.html">Slash commands documentation</a> for detailed instructions. View all slash commands configured on this team below.'
+ />
+ <div><label className='control-label padding-top x2'>
+ <FormattedMessage
+ id='user.settings.cmds.add_new'
+ defaultMessage='Add a new command'
+ />
+ </label></div>
+ <div className='padding-top divider-light'></div>
+ <div className='padding-top'>
+
+ {slashCommandAutocompleteCheckbox}
+ {triggerInput}
+
+ <div className='padding-top x2'>
+ <label className='control-label'>
+ <FormattedMessage
id='user.settings.cmds.url'
defaultMessage='Request URL: '
/>
@@ -560,102 +728,7 @@ export default class ManageCommandCmds extends React.Component {
</div>
</div>
- <div className='padding-top x2'>
- <label className='control-label'>
- <FormattedMessage
- id='user.settings.cmds.auto_complete'
- defaultMessage='Autocomplete: '
- />
- </label>
- <div className='padding-top'>
- <div className='checkbox'>
- <label>
- <input
- type='checkbox'
- checked={this.state.cmd.auto_complete}
- onChange={this.updateAutoComplete}
- />
- <FormattedMessage
- id='user.settings.cmds.auto_complete_help'
- defaultMessage=' Show this command in the autocomplete list.'
- />
- </label>
- </div>
- </div>
- </div>
-
- <div className='padding-top x2'>
- <label className='control-label'>
- <FormattedMessage
- id='user.settings.cmds.auto_complete_hint'
- defaultMessage='Autocomplete Hint: '
- />
- </label>
- <div className='padding-top'>
- <input
- ref='autoCompleteHint'
- className='form-control'
- value={this.state.cmd.auto_complete_hint}
- onChange={this.updateAutoCompleteHint}
- placeholder={this.props.intl.formatMessage(holders.addAutoCompleteHintPlaceholder)}
- />
- </div>
- <div className='padding-top'>
- <FormattedMessage
- id='user.settings.cmds.auto_complete_hint_desc'
- defaultMessage='Optional hint in the autocomplete list about parameters needed for command.'
- />
- </div>
- </div>
-
- <div className='padding-top x2'>
- <label className='control-label'>
- <FormattedMessage
- id='user.settings.cmds.auto_complete_desc'
- defaultMessage='Autocomplete Description: '
- />
- </label>
- <div className='padding-top'>
- <input
- ref='autoCompleteDesc'
- className='form-control'
- value={this.state.cmd.auto_complete_desc}
- onChange={this.updateAutoCompleteDesc}
- placeholder={this.props.intl.formatMessage(holders.addAutoCompleteDescPlaceholder)}
- />
- </div>
- <div className='padding-top'>
- <FormattedMessage
- id='user.settings.cmds.auto_complete_desc_desc'
- defaultMessage='Optional short description of slash command for the autocomplete list.'
- />
- </div>
- </div>
-
- <div className='padding-top x2'>
- <label className='control-label'>
- <FormattedMessage
- id='user.settings.cmds.display_name'
- defaultMessage='Descriptive Label: '
- />
- </label>
- <div className='padding-top'>
- <input
- ref='displayName'
- className='form-control'
- value={this.state.cmd.display_name}
- onChange={this.updateDisplayName}
- placeholder={this.props.intl.formatMessage(holders.addDisplayNamePlaceholder)}
- />
- </div>
- <div className='padding-top'>
- <FormattedMessage
- id='user.settings.cmds.cmd_display_name'
- defaultMessage='Brief description of slash command to show in listings.'
- />
- </div>
- {addError}
- </div>
+ {autoCompleteSettings}
<div className='padding-top x2 padding-bottom'>
<a
diff --git a/webapp/components/user_settings/user_settings_advanced.jsx b/webapp/components/user_settings/user_settings_advanced.jsx
index 7c496f57b..40897e8c9 100644
--- a/webapp/components/user_settings/user_settings_advanced.jsx
+++ b/webapp/components/user_settings/user_settings_advanced.jsx
@@ -51,6 +51,10 @@ const holders = defineMessages({
EMBED_TOGGLE: {
id: 'user.settings.advance.embed_toggle',
defaultMessage: 'Show toggle for all embed previews'
+ },
+ SLASHCMD_AUTOCMP: {
+ id: 'user.settings.advance.slashCmd_autocmp',
+ defaultMessage: 'Enable external application to offer slash command autocomplete'
}
});
diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json
index 44c2636ba..cdce75760 100644
--- a/webapp/i18n/en.json
+++ b/webapp/i18n/en.json
@@ -1091,6 +1091,7 @@
"tutorial_tip.seen": "Seen this before? ",
"upload_overlay.info": "Drop a file to upload it.",
"user.settings.advance.embed_preview": "Show preview snippet of links below message",
+ "user.settings.advance.slashCmd_autocmp": "Enable external application to offer slash command autocomplete",
"user.settings.advance.embed_toggle": "Show toggle for all embed previews",
"user.settings.advance.enabled": "enabled",
"user.settings.advance.feature": " Feature ",
@@ -1138,6 +1139,7 @@
"user.settings.cmds.url_desc": "The callback URL to receive the HTTP POST or GET event request when the slash command is run.",
"user.settings.cmds.username": "Response Username: ",
"user.settings.cmds.username_desc": "Choose a username override for responses for this slash command. Usernames can consist of up to 22 characters consisting of lowercase letters, numbers and they symbols \"-\", \"_\", and \".\" .",
+ "user.settings.cmds.slashCmd_autocmp": "Enable external application to offer autocomplete",
"user.settings.custom_theme.awayIndicator": "Away Indicator",
"user.settings.custom_theme.buttonBg": "Button BG",
"user.settings.custom_theme.buttonColor": "Button Text",
diff --git a/webapp/package.json b/webapp/package.json
index 0d88a6212..25003114e 100644
--- a/webapp/package.json
+++ b/webapp/package.json
@@ -31,7 +31,7 @@
"babel-loader": "6.2.4",
"babel-plugin-transform-runtime": "6.6.0",
"babel-polyfill": "6.7.2",
- "babel-preset-es2015": "6.6.0",
+ "babel-preset-es2015-webpack": "6.4.0",
"babel-preset-react": "6.5.0",
"babel-preset-stage-0": "6.5.0",
"eslint": "2.2.0",
@@ -39,6 +39,7 @@
"exports-loader": "0.6.3",
"extract-text-webpack-plugin": "1.0.1",
"file-loader": "0.8.5",
+ "url-loader": "0.5.7",
"html-loader": "0.4.3",
"copy-webpack-plugin": "1.1.1",
"css-loader": "0.23.1",
@@ -52,6 +53,7 @@
},
"scripts": {
"check": "eslint --ext \".jsx\" --ignore-pattern node_modules --quiet .",
- "build": "webpack --progress"
+ "build": "webpack --optimize-dedupe",
+ "run": "webpack --progress"
}
}
diff --git a/webapp/utils/async_client.jsx b/webapp/utils/async_client.jsx
index 9c40311cf..9a5869f9a 100644
--- a/webapp/utils/async_client.jsx
+++ b/webapp/utils/async_client.jsx
@@ -755,12 +755,12 @@ export function savePreferences(preferences, success, error) {
);
}
-export function getSuggestedCommands(command, suggestionId, component) {
- client.listCommands(
+export function getSuggestedCommands(command, channelId, suggestionId, component) {
+ client.listCommands({command: command, channelId: channelId},
(data) => {
var matches = [];
data.forEach((cmd) => {
- if (('/' + cmd.trigger).indexOf(command) === 0) {
+ if (('/' + cmd.trigger).indexOf(command) === 0 || cmd.external_management) {
let s = '/' + cmd.trigger;
let hint = '';
if (cmd.auto_complete_hint && cmd.auto_complete_hint.length !== 0) {
diff --git a/webapp/utils/client.jsx b/webapp/utils/client.jsx
index 9bd62e22d..ef6d496a2 100644
--- a/webapp/utils/client.jsx
+++ b/webapp/utils/client.jsx
@@ -1002,12 +1002,13 @@ export function regenCommandToken(data, success, error) {
});
}
-export function listCommands(success, error) {
+export function listCommands(data, success, error) {
$.ajax({
url: '/api/v1/commands/list',
dataType: 'json',
contentType: 'application/json',
- type: 'GET',
+ type: 'POST',
+ data: JSON.stringify(data),
success,
error: function onError(xhr, status, err) {
var e = handleError('listCommands', xhr, status, err);
diff --git a/webapp/utils/constants.jsx b/webapp/utils/constants.jsx
index 32123e369..c1e527702 100644
--- a/webapp/utils/constants.jsx
+++ b/webapp/utils/constants.jsx
@@ -560,6 +560,10 @@ export default {
EMBED_TOGGLE: {
label: 'embed_toggle',
description: 'Show toggle for all embed previews'
+ },
+ SLASHCMD_AUTOCMP: {
+ label: 'slashCmd_autocmp',
+ description: 'Enable external application to offer slash command autocomplete'
}
},
OVERLAY_TIME_DELAY: 400,
diff --git a/webapp/utils/text_formatting.jsx b/webapp/utils/text_formatting.jsx
index 9833b995c..4c8b5e24c 100644
--- a/webapp/utils/text_formatting.jsx
+++ b/webapp/utils/text_formatting.jsx
@@ -213,6 +213,11 @@ function highlightCurrentMentions(text, tokens) {
}
for (const mention of UserStore.getCurrentMentionKeys()) {
+ // occasionally we get an empty mention which matches a bunch of empty strings
+ if (!mention) {
+ continue;
+ }
+
output = output.replace(new RegExp(`(^|\\W)(${escapeRegex(mention)})\\b`, 'gi'), replaceCurrentMentionWithToken);
}
diff --git a/webapp/webpack.config.js b/webapp/webpack.config.js
index 14abf6ffa..5e1df9bfe 100644
--- a/webapp/webpack.config.js
+++ b/webapp/webpack.config.js
@@ -17,20 +17,21 @@ module.exports = {
loaders: [
{
test: /\.jsx?$/,
- loader: 'babel-loader',
+ loader: 'babel',
exclude: /(node_modules|non_npm_dependencies)/,
query: {
- presets: ['react', 'es2015', 'stage-0'],
- plugins: ['transform-runtime']
+ presets: ['react', 'es2015-webpack', 'stage-0'],
+ plugins: ['transform-runtime'],
+ cacheDirectory: true
}
},
{
test: /\.json$/,
- loader: 'json-loader'
+ loader: 'json'
},
{
test: /(node_modules|non_npm_dependencies)\/.+\.(js|jsx)$/,
- loader: 'imports-loader',
+ loader: 'imports',
query: {
$: 'jquery',
jQuery: 'jquery'
@@ -46,7 +47,7 @@ module.exports = {
},
{
test: /\.(png|eot|tiff|svg|woff2|woff|ttf|gif)$/,
- loader: 'file-loader',
+ loader: 'file',
query: {
name: 'files/[hash].[ext]'
}
@@ -67,7 +68,22 @@ module.exports = {
htmlExtract,
new CopyWebpackPlugin([
{from: 'images/emoji', to: 'emoji'}
- ])
+ ]),
+ new webpack.optimize.UglifyJsPlugin({
+ 'screw-ie8': true,
+ mangle: {
+ toplevel: false
+ },
+ compress: {
+ warnings: false
+ },
+ comments: false
+ }),
+ new webpack.optimize.AggressiveMergingPlugin(),
+ new webpack.LoaderOptionsPlugin({
+ minimize: true,
+ debug: false
+ })
],
resolve: {
alias: {