summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md2
-rw-r--r--api/api.go1
-rw-r--r--api/channel.go6
-rw-r--r--api/file.go3
-rw-r--r--api/web_socket_test.go9
-rw-r--r--api/webhook.go119
-rw-r--r--api/webhook_test.go162
-rw-r--r--config/config.json3
-rw-r--r--doc/developer/code-contribution.md14
-rw-r--r--docker/dev/config_docker.json3
-rw-r--r--docker/local/config_docker.json3
-rw-r--r--model/channel.go8
-rw-r--r--model/client.go38
-rw-r--r--model/config.go1
-rw-r--r--model/webhook.go101
-rw-r--r--model/webhook_test.go82
-rw-r--r--store/sql_store.go8
-rw-r--r--store/sql_webhook_store.go128
-rw-r--r--store/sql_webhook_store_test.go77
-rw-r--r--store/store.go8
-rw-r--r--utils/config.go1
-rw-r--r--web/react/components/create_post.jsx50
-rw-r--r--web/react/components/email_verify.jsx14
-rw-r--r--web/react/components/new_channel_modal.jsx2
-rw-r--r--web/react/components/password_reset_send_link.jsx7
-rw-r--r--web/react/components/post_body.jsx1
-rw-r--r--web/react/components/post_list_container.jsx1
-rw-r--r--web/react/components/rhs_comment.jsx10
-rw-r--r--web/react/components/rhs_root_post.jsx1
-rw-r--r--web/react/components/signup_user_complete.jsx23
-rw-r--r--web/react/components/user_settings/manage_incoming_hooks.jsx177
-rw-r--r--web/react/components/user_settings/user_settings.jsx (renamed from web/react/components/user_settings.jsx)16
-rw-r--r--web/react/components/user_settings/user_settings_appearance.jsx (renamed from web/react/components/user_settings_appearance.jsx)10
-rw-r--r--web/react/components/user_settings/user_settings_developer.jsx (renamed from web/react/components/user_settings_developer.jsx)4
-rw-r--r--web/react/components/user_settings/user_settings_general.jsx (renamed from web/react/components/user_settings_general.jsx)14
-rw-r--r--web/react/components/user_settings/user_settings_integrations.jsx95
-rw-r--r--web/react/components/user_settings/user_settings_modal.jsx (renamed from web/react/components/user_settings_modal.jsx)5
-rw-r--r--web/react/components/user_settings/user_settings_notifications.jsx (renamed from web/react/components/user_settings_notifications.jsx)12
-rw-r--r--web/react/components/user_settings/user_settings_security.jsx (renamed from web/react/components/user_settings_security.jsx)10
-rw-r--r--web/react/pages/channel.jsx2
-rw-r--r--web/react/pages/verify.jsx1
-rw-r--r--web/react/utils/client.jsx153
-rw-r--r--web/react/utils/emoticons.jsx159
-rw-r--r--web/react/utils/markdown.jsx13
-rw-r--r--web/react/utils/text_formatting.jsx81
-rwxr-xr-xweb/static/js/emojify.min.js4
-rw-r--r--web/templates/head.html5
-rw-r--r--web/web.go91
-rw-r--r--web/web_test.go45
49 files changed, 1555 insertions, 228 deletions
diff --git a/README.md b/README.md
index d6b721dd1..58f4887f6 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ Please see the [features pages of the Mattermost website](http://www.mattermost.
- [Mattermost Forum](http://forum.mattermost.org/) - For technical questions and answers
- [Issue Tracker](http://www.mattermost.org/filing-issues/) - For reporting bugs
- [Feature Ideas Forum](http://www.mattermost.org/feature-requests/) - For sharing ideas for future versions
-- [Contributuion Guidelines](http://www.mattermost.org/contribute-to-mattermost/) - For contributing code or feedback to the project
+- [Contribution Guidelines](http://www.mattermost.org/contribute-to-mattermost/) - For contributing code or feedback to the project
Follow us on Twitter at [@MattermostHQ](https://twitter.com/mattermosthq).
diff --git a/api/api.go b/api/api.go
index c8f97c5af..a50cce946 100644
--- a/api/api.go
+++ b/api/api.go
@@ -44,6 +44,7 @@ func InitApi() {
InitCommand(r)
InitAdmin(r)
InitOAuth(r)
+ InitWebhook(r)
templatesDir := utils.FindDir("api/templates")
l4g.Debug("Parsing server templates at %v", templatesDir)
diff --git a/api/channel.go b/api/channel.go
index 63acaa8d1..896e22793 100644
--- a/api/channel.go
+++ b/api/channel.go
@@ -121,11 +121,7 @@ func CreateDirectChannel(c *Context, otherUserId string) (*model.Channel, *model
channel := new(model.Channel)
channel.DisplayName = ""
- if otherUserId > c.Session.UserId {
- channel.Name = c.Session.UserId + "__" + otherUserId
- } else {
- channel.Name = otherUserId + "__" + c.Session.UserId
- }
+ channel.Name = model.GetDMNameFromIds(otherUserId, c.Session.UserId)
channel.TeamId = c.Session.TeamId
channel.Description = ""
diff --git a/api/file.go b/api/file.go
index 467bf5338..3602b5ed4 100644
--- a/api/file.go
+++ b/api/file.go
@@ -24,6 +24,7 @@ import (
"io"
"io/ioutil"
"math"
+ "mime"
"net/http"
"net/url"
"os"
@@ -424,7 +425,7 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "max-age=2592000, public")
w.Header().Set("Content-Length", strconv.Itoa(len(f)))
- w.Header().Set("Content-Type", "") // need to provide proper Content-Type in the future
+ w.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(filename)))
w.Write(f)
}
diff --git a/api/web_socket_test.go b/api/web_socket_test.go
index 161274ff7..7f9ce024b 100644
--- a/api/web_socket_test.go
+++ b/api/web_socket_test.go
@@ -116,12 +116,3 @@ func TestSocket(t *testing.T) {
time.Sleep(2 * time.Second)
}
-
-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/webhook.go b/api/webhook.go
new file mode 100644
index 000000000..9ca725d45
--- /dev/null
+++ b/api/webhook.go
@@ -0,0 +1,119 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package api
+
+import (
+ l4g "code.google.com/p/log4go"
+ "github.com/gorilla/mux"
+ "github.com/mattermost/platform/model"
+ "github.com/mattermost/platform/utils"
+ "net/http"
+)
+
+func InitWebhook(r *mux.Router) {
+ l4g.Debug("Initializing webhook api routes")
+
+ sr := r.PathPrefix("/hooks").Subrouter()
+ sr.Handle("/incoming/create", ApiUserRequired(createIncomingHook)).Methods("POST")
+ sr.Handle("/incoming/delete", ApiUserRequired(deleteIncomingHook)).Methods("POST")
+ sr.Handle("/incoming/list", ApiUserRequired(getIncomingHooks)).Methods("GET")
+}
+
+func createIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) {
+ if !utils.Cfg.ServiceSettings.AllowIncomingWebhooks {
+ c.Err = model.NewAppError("createIncomingHook", "Incoming webhooks have been disabled by the system admin.", "")
+ c.Err.StatusCode = http.StatusNotImplemented
+ return
+ }
+
+ c.LogAudit("attempt")
+
+ hook := model.IncomingWebhookFromJson(r.Body)
+
+ if hook == nil {
+ c.SetInvalidParam("createIncomingHook", "webhook")
+ return
+ }
+
+ cchan := Srv.Store.Channel().Get(hook.ChannelId)
+ pchan := Srv.Store.Channel().CheckPermissionsTo(c.Session.TeamId, hook.ChannelId, c.Session.UserId)
+
+ hook.UserId = c.Session.UserId
+ hook.TeamId = c.Session.TeamId
+
+ var channel *model.Channel
+ if result := <-cchan; result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ channel = result.Data.(*model.Channel)
+ }
+
+ if !c.HasPermissionsToChannel(pchan, "createIncomingHook") && channel.Type != model.CHANNEL_OPEN {
+ c.LogAudit("fail - bad channel permissions")
+ return
+ }
+
+ if result := <-Srv.Store.Webhook().SaveIncoming(hook); result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ c.LogAudit("success")
+ rhook := result.Data.(*model.IncomingWebhook)
+ w.Write([]byte(rhook.ToJson()))
+ }
+}
+
+func deleteIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) {
+ if !utils.Cfg.ServiceSettings.AllowIncomingWebhooks {
+ c.Err = model.NewAppError("createIncomingHook", "Incoming webhooks have been disabled by the system admin.", "")
+ c.Err.StatusCode = http.StatusNotImplemented
+ return
+ }
+
+ c.LogAudit("attempt")
+
+ props := model.MapFromJson(r.Body)
+
+ id := props["id"]
+ if len(id) == 0 {
+ c.SetInvalidParam("deleteIncomingHook", "id")
+ return
+ }
+
+ if result := <-Srv.Store.Webhook().GetIncoming(id); result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ if c.Session.UserId != result.Data.(*model.IncomingWebhook).UserId && !model.IsInRole(c.Session.Roles, model.ROLE_TEAM_ADMIN) {
+ c.LogAudit("fail - inappropriate conditions")
+ c.Err = model.NewAppError("deleteIncomingHook", "Inappropriate permissions to delete incoming webhook", "user_id="+c.Session.UserId)
+ return
+ }
+ }
+
+ if err := (<-Srv.Store.Webhook().DeleteIncoming(id, model.GetMillis())).Err; err != nil {
+ c.Err = err
+ return
+ }
+
+ c.LogAudit("success")
+ w.Write([]byte(model.MapToJson(props)))
+}
+
+func getIncomingHooks(c *Context, w http.ResponseWriter, r *http.Request) {
+ if !utils.Cfg.ServiceSettings.AllowIncomingWebhooks {
+ c.Err = model.NewAppError("createIncomingHook", "Incoming webhooks have been disabled by the system admin.", "")
+ c.Err.StatusCode = http.StatusNotImplemented
+ return
+ }
+
+ if result := <-Srv.Store.Webhook().GetIncomingByUser(c.Session.UserId); result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ hooks := result.Data.([]*model.IncomingWebhook)
+ w.Write([]byte(model.IncomingWebhookListToJson(hooks)))
+ }
+}
diff --git a/api/webhook_test.go b/api/webhook_test.go
new file mode 100644
index 000000000..fd4c723b7
--- /dev/null
+++ b/api/webhook_test.go
@@ -0,0 +1,162 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package api
+
+import (
+ "github.com/mattermost/platform/model"
+ "github.com/mattermost/platform/store"
+ "github.com/mattermost/platform/utils"
+ "testing"
+ "time"
+)
+
+func TestCreateIncomingHook(t *testing.T) {
+ Setup()
+
+ team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN}
+ team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team)
+
+ user := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"}
+ user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User)
+ store.Must(Srv.Store.User().VerifyEmail(user.Id))
+
+ Client.LoginByEmail(team.Name, user.Email, "pwd")
+
+ channel1 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
+ channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel)
+
+ channel2 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
+ channel2 = Client.Must(Client.CreateChannel(channel2)).Data.(*model.Channel)
+
+ hook := &model.IncomingWebhook{ChannelId: channel1.Id}
+
+ if utils.Cfg.ServiceSettings.AllowIncomingWebhooks {
+ var rhook *model.IncomingWebhook
+ if result, err := Client.CreateIncomingWebhook(hook); err != nil {
+ t.Fatal(err)
+ } else {
+ rhook = result.Data.(*model.IncomingWebhook)
+ }
+
+ if hook.ChannelId != rhook.ChannelId {
+ t.Fatal("channel ids didn't match")
+ }
+
+ if rhook.UserId != user.Id {
+ t.Fatal("user ids didn't match")
+ }
+
+ if rhook.TeamId != team.Id {
+ t.Fatal("team ids didn't match")
+ }
+
+ hook = &model.IncomingWebhook{ChannelId: "junk"}
+ if _, err := Client.CreateIncomingWebhook(hook); err == nil {
+ t.Fatal("should have failed - bad channel id")
+ }
+
+ hook = &model.IncomingWebhook{ChannelId: channel2.Id, UserId: "123", TeamId: "456"}
+ if result, err := Client.CreateIncomingWebhook(hook); err != nil {
+ t.Fatal(err)
+ } else {
+ if result.Data.(*model.IncomingWebhook).UserId != user.Id {
+ t.Fatal("bad user id wasn't overwritten")
+ }
+ if result.Data.(*model.IncomingWebhook).TeamId != team.Id {
+ t.Fatal("bad team id wasn't overwritten")
+ }
+ }
+ } else {
+ if _, err := Client.CreateIncomingWebhook(hook); err == nil {
+ t.Fatal("should have errored - webhooks turned off")
+ }
+ }
+}
+
+func TestListIncomingHooks(t *testing.T) {
+ Setup()
+
+ team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN}
+ team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team)
+
+ user := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"}
+ user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User)
+ store.Must(Srv.Store.User().VerifyEmail(user.Id))
+
+ Client.LoginByEmail(team.Name, user.Email, "pwd")
+
+ channel1 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
+ channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel)
+
+ if utils.Cfg.ServiceSettings.AllowIncomingWebhooks {
+ hook1 := &model.IncomingWebhook{ChannelId: channel1.Id}
+ hook1 = Client.Must(Client.CreateIncomingWebhook(hook1)).Data.(*model.IncomingWebhook)
+
+ hook2 := &model.IncomingWebhook{ChannelId: channel1.Id}
+ hook2 = Client.Must(Client.CreateIncomingWebhook(hook2)).Data.(*model.IncomingWebhook)
+
+ if result, err := Client.ListIncomingWebhooks(); err != nil {
+ t.Fatal(err)
+ } else {
+ hooks := result.Data.([]*model.IncomingWebhook)
+
+ if len(hooks) != 2 {
+ t.Fatal("incorrect number of hooks")
+ }
+ }
+ } else {
+ if _, err := Client.ListIncomingWebhooks(); err == nil {
+ t.Fatal("should have errored - webhooks turned off")
+ }
+ }
+}
+
+func TestDeleteIncomingHook(t *testing.T) {
+ Setup()
+
+ team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN}
+ team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team)
+
+ user := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"}
+ user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User)
+ store.Must(Srv.Store.User().VerifyEmail(user.Id))
+
+ Client.LoginByEmail(team.Name, user.Email, "pwd")
+
+ channel1 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
+ channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel)
+
+ if utils.Cfg.ServiceSettings.AllowIncomingWebhooks {
+ hook := &model.IncomingWebhook{ChannelId: channel1.Id}
+ hook = Client.Must(Client.CreateIncomingWebhook(hook)).Data.(*model.IncomingWebhook)
+
+ data := make(map[string]string)
+ data["id"] = hook.Id
+
+ if _, err := Client.DeleteIncomingWebhook(data); err != nil {
+ t.Fatal(err)
+ }
+
+ hooks := Client.Must(Client.ListIncomingWebhooks()).Data.([]*model.IncomingWebhook)
+ if len(hooks) != 0 {
+ t.Fatal("delete didn't work properly")
+ }
+ } else {
+ data := make(map[string]string)
+ data["id"] = "123"
+
+ if _, err := Client.DeleteIncomingWebhook(data); err == nil {
+ t.Fatal("should have errored - 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/config/config.json b/config/config.json
index 38948641c..c573a299f 100644
--- a/config/config.json
+++ b/config/config.json
@@ -22,7 +22,8 @@
"StorageDirectory": "./data/",
"AllowedLoginAttempts": 10,
"DisableEmailSignUp": false,
- "EnableOAuthServiceProvider": false
+ "EnableOAuthServiceProvider": false,
+ "AllowIncomingWebhooks": false
},
"SqlSettings": {
"DriverName": "mysql",
diff --git a/doc/developer/code-contribution.md b/doc/developer/code-contribution.md
index 325a67546..51521be1b 100644
--- a/doc/developer/code-contribution.md
+++ b/doc/developer/code-contribution.md
@@ -33,20 +33,6 @@ git checkout -b <branch name>
1. Please add yourself to the Mattermost [approved contributor list](https://docs.google.com/spreadsheets/d/1NTCeG-iL_VS9bFqtmHSfwETo5f-8MQ7oMDE5IUYJi_Y/pubhtml?gid=0&single=true) prior to submitting by completing the [contributor license agreement](http://www.mattermost.org/mattermost-contributor-agreement/).
- For pull requests made by contributors not yet added to the approved contributor list, a reviewer may respond:
-
- ```
- Thanks @[username] for the pull request!
-
- Before we can review, we need to add you to the list of [approved contributors](https://docs.google.com/spreadsheets/d/1NTCeG-iL_VS9bFqtmHSfwETo5f-8MQ7oMDE5IUYJi_Y/pubhtml?gid=0&single=true).
- Please help complete the Mattermost [contribution license agreement](http://www.mattermost.org/mattermost-contributor-agreement/), which is a standard procedure for many open source projects. More information is in the above link.
-
- Please let us know if you have any questions.
-
- We are very happy to have you join our growing community!
-```
-
-
2. When you submit your pull request please include the Ticket ID at the beginning of your pull request comment, followed by a colon.
For example, for a ticket ID `PLT-394` start your comment with: `PLT-394:`. See [previously closed pull requests](https://github.com/mattermost/platform/pulls?q=is%3Apr+is%3Aclosed) for examples.
diff --git a/docker/dev/config_docker.json b/docker/dev/config_docker.json
index aceeb95b4..d50439f2c 100644
--- a/docker/dev/config_docker.json
+++ b/docker/dev/config_docker.json
@@ -24,7 +24,8 @@
"StorageDirectory": "/mattermost/data/",
"AllowedLoginAttempts": 10,
"DisableEmailSignUp": false,
- "EnableOAuthServiceProvider": false
+ "EnableOAuthServiceProvider": false,
+ "AllowIncomingWebhooks": false
},
"SSOSettings": {
"gitlab": {
diff --git a/docker/local/config_docker.json b/docker/local/config_docker.json
index bc42951b8..b6e02635c 100644
--- a/docker/local/config_docker.json
+++ b/docker/local/config_docker.json
@@ -24,7 +24,8 @@
"StorageDirectory": "/mattermost/data/",
"AllowedLoginAttempts": 10,
"DisableEmailSignUp": false,
- "EnableOAuthServiceProvider": false
+ "EnableOAuthServiceProvider": false,
+ "AllowIncomingWebhooks": false
},
"SSOSettings": {
"gitlab": {
diff --git a/model/channel.go b/model/channel.go
index 7d8edeee7..a7f007960 100644
--- a/model/channel.go
+++ b/model/channel.go
@@ -120,3 +120,11 @@ func (o *Channel) ExtraUpdated() {
func (o *Channel) PreExport() {
}
+
+func GetDMNameFromIds(userId1, userId2 string) string {
+ if userId1 > userId2 {
+ return userId2 + "__" + userId1
+ } else {
+ return userId1 + "__" + userId2
+ }
+}
diff --git a/model/client.go b/model/client.go
index f9127719f..6817a80f6 100644
--- a/model/client.go
+++ b/model/client.go
@@ -48,7 +48,7 @@ func NewClient(url string) *Client {
return &Client{url, url + API_URL_SUFFIX, &http.Client{}, "", ""}
}
-func (c *Client) DoPost(url string, data, contentType string) (*http.Response, *AppError) {
+func (c *Client) DoPost(url, data, contentType string) (*http.Response, *AppError) {
rq, _ := http.NewRequest("POST", c.Url+url, strings.NewReader(data))
rq.Header.Set("Content-Type", contentType)
@@ -806,6 +806,42 @@ func (c *Client) GetAccessToken(data url.Values) (*Result, *AppError) {
}
}
+func (c *Client) CreateIncomingWebhook(hook *IncomingWebhook) (*Result, *AppError) {
+ if r, err := c.DoApiPost("/hooks/incoming/create", hook.ToJson()); err != nil {
+ return nil, err
+ } else {
+ return &Result{r.Header.Get(HEADER_REQUEST_ID),
+ r.Header.Get(HEADER_ETAG_SERVER), IncomingWebhookFromJson(r.Body)}, nil
+ }
+}
+
+func (c *Client) PostToWebhook(id, payload string) (*Result, *AppError) {
+ if r, err := c.DoPost("/hooks/"+id, payload, "application/x-www-form-urlencoded"); err != nil {
+ return nil, err
+ } else {
+ return &Result{r.Header.Get(HEADER_REQUEST_ID),
+ r.Header.Get(HEADER_ETAG_SERVER), nil}, nil
+ }
+}
+
+func (c *Client) DeleteIncomingWebhook(data map[string]string) (*Result, *AppError) {
+ if r, err := c.DoApiPost("/hooks/incoming/delete", MapToJson(data)); err != nil {
+ return nil, err
+ } else {
+ return &Result{r.Header.Get(HEADER_REQUEST_ID),
+ r.Header.Get(HEADER_ETAG_SERVER), MapFromJson(r.Body)}, nil
+ }
+}
+
+func (c *Client) ListIncomingWebhooks() (*Result, *AppError) {
+ if r, err := c.DoApiGet("/hooks/incoming/list", "", ""); err != nil {
+ return nil, err
+ } else {
+ return &Result{r.Header.Get(HEADER_REQUEST_ID),
+ r.Header.Get(HEADER_ETAG_SERVER), IncomingWebhookListFromJson(r.Body)}, nil
+ }
+}
+
func (c *Client) MockSession(sessionToken string) {
c.AuthToken = sessionToken
c.AuthType = HEADER_BEARER
diff --git a/model/config.go b/model/config.go
index 3b333dbe1..436f063c8 100644
--- a/model/config.go
+++ b/model/config.go
@@ -24,6 +24,7 @@ type ServiceSettings struct {
AllowedLoginAttempts int
DisableEmailSignUp bool
EnableOAuthServiceProvider bool
+ AllowIncomingWebhooks bool
}
type SSOSetting struct {
diff --git a/model/webhook.go b/model/webhook.go
new file mode 100644
index 000000000..9b4db3246
--- /dev/null
+++ b/model/webhook.go
@@ -0,0 +1,101 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package model
+
+import (
+ "encoding/json"
+ "io"
+)
+
+type IncomingWebhook struct {
+ Id string `json:"id"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+ DeleteAt int64 `json:"delete_at"`
+ UserId string `json:"user_id"`
+ ChannelId string `json:"channel_id"`
+ TeamId string `json:"team_id"`
+}
+
+func (o *IncomingWebhook) ToJson() string {
+ b, err := json.Marshal(o)
+ if err != nil {
+ return ""
+ } else {
+ return string(b)
+ }
+}
+
+func IncomingWebhookFromJson(data io.Reader) *IncomingWebhook {
+ decoder := json.NewDecoder(data)
+ var o IncomingWebhook
+ err := decoder.Decode(&o)
+ if err == nil {
+ return &o
+ } else {
+ return nil
+ }
+}
+
+func IncomingWebhookListToJson(l []*IncomingWebhook) string {
+ b, err := json.Marshal(l)
+ if err != nil {
+ return ""
+ } else {
+ return string(b)
+ }
+}
+
+func IncomingWebhookListFromJson(data io.Reader) []*IncomingWebhook {
+ decoder := json.NewDecoder(data)
+ var o []*IncomingWebhook
+ err := decoder.Decode(&o)
+ if err == nil {
+ return o
+ } else {
+ return nil
+ }
+}
+
+func (o *IncomingWebhook) IsValid() *AppError {
+
+ if len(o.Id) != 26 {
+ return NewAppError("IncomingWebhook.IsValid", "Invalid Id", "")
+ }
+
+ if o.CreateAt == 0 {
+ return NewAppError("IncomingWebhook.IsValid", "Create at must be a valid time", "id="+o.Id)
+ }
+
+ if o.UpdateAt == 0 {
+ return NewAppError("IncomingWebhook.IsValid", "Update at must be a valid time", "id="+o.Id)
+ }
+
+ if len(o.UserId) != 26 {
+ return NewAppError("IncomingWebhook.IsValid", "Invalid user id", "")
+ }
+
+ if len(o.ChannelId) != 26 {
+ return NewAppError("IncomingWebhook.IsValid", "Invalid channel id", "")
+ }
+
+ if len(o.TeamId) != 26 {
+ return NewAppError("IncomingWebhook.IsValid", "Invalid channel id", "")
+ }
+
+ return nil
+}
+
+func (o *IncomingWebhook) PreSave() {
+ if o.Id == "" {
+ o.Id = NewId()
+ }
+
+ o.CreateAt = GetMillis()
+ o.UpdateAt = o.CreateAt
+}
+
+func (o *IncomingWebhook) PreUpdate() {
+ o.UpdateAt = GetMillis()
+}
diff --git a/model/webhook_test.go b/model/webhook_test.go
new file mode 100644
index 000000000..ddbe18cd3
--- /dev/null
+++ b/model/webhook_test.go
@@ -0,0 +1,82 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package model
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestIncomingWebhookJson(t *testing.T) {
+ o := IncomingWebhook{Id: NewId()}
+ json := o.ToJson()
+ ro := IncomingWebhookFromJson(strings.NewReader(json))
+
+ if o.Id != ro.Id {
+ t.Fatal("Ids do not match")
+ }
+}
+
+func TestIncomingWebhookIsValid(t *testing.T) {
+ o := IncomingWebhook{}
+
+ if err := o.IsValid(); err == nil {
+ t.Fatal("should be invalid")
+ }
+
+ o.Id = NewId()
+ if err := o.IsValid(); err == nil {
+ t.Fatal("should be invalid")
+ }
+
+ o.CreateAt = GetMillis()
+ if err := o.IsValid(); err == nil {
+ t.Fatal("should be invalid")
+ }
+
+ o.UpdateAt = GetMillis()
+ if err := o.IsValid(); err == nil {
+ t.Fatal("should be invalid")
+ }
+
+ o.UserId = "123"
+ if err := o.IsValid(); err == nil {
+ t.Fatal("should be invalid")
+ }
+
+ o.UserId = NewId()
+ if err := o.IsValid(); err == nil {
+ t.Fatal("should be invalid")
+ }
+
+ o.ChannelId = "123"
+ if err := o.IsValid(); err == nil {
+ t.Fatal("should be invalid")
+ }
+
+ o.ChannelId = NewId()
+ if err := o.IsValid(); err == nil {
+ t.Fatal("should be invalid")
+ }
+
+ o.TeamId = "123"
+ if err := o.IsValid(); err == nil {
+ t.Fatal("should be invalid")
+ }
+
+ o.TeamId = NewId()
+ if err := o.IsValid(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestIncomingWebhookPreSave(t *testing.T) {
+ o := IncomingWebhook{}
+ o.PreSave()
+}
+
+func TestIncomingWebhookPreUpdate(t *testing.T) {
+ o := IncomingWebhook{}
+ o.PreUpdate()
+}
diff --git a/store/sql_store.go b/store/sql_store.go
index adac47b4d..98703841a 100644
--- a/store/sql_store.go
+++ b/store/sql_store.go
@@ -40,6 +40,7 @@ type SqlStore struct {
session SessionStore
oauth OAuthStore
system SystemStore
+ webhook WebhookStore
}
func NewSqlStore() Store {
@@ -91,6 +92,7 @@ func NewSqlStore() Store {
sqlStore.session = NewSqlSessionStore(sqlStore)
sqlStore.oauth = NewSqlOAuthStore(sqlStore)
sqlStore.system = NewSqlSystemStore(sqlStore)
+ sqlStore.webhook = NewSqlWebhookStore(sqlStore)
sqlStore.master.CreateTablesIfNotExists()
@@ -102,6 +104,7 @@ func NewSqlStore() Store {
sqlStore.session.(*SqlSessionStore).UpgradeSchemaIfNeeded()
sqlStore.oauth.(*SqlOAuthStore).UpgradeSchemaIfNeeded()
sqlStore.system.(*SqlSystemStore).UpgradeSchemaIfNeeded()
+ sqlStore.webhook.(*SqlWebhookStore).UpgradeSchemaIfNeeded()
sqlStore.team.(*SqlTeamStore).CreateIndexesIfNotExists()
sqlStore.channel.(*SqlChannelStore).CreateIndexesIfNotExists()
@@ -111,6 +114,7 @@ func NewSqlStore() Store {
sqlStore.session.(*SqlSessionStore).CreateIndexesIfNotExists()
sqlStore.oauth.(*SqlOAuthStore).CreateIndexesIfNotExists()
sqlStore.system.(*SqlSystemStore).CreateIndexesIfNotExists()
+ sqlStore.webhook.(*SqlWebhookStore).CreateIndexesIfNotExists()
if model.IsPreviousVersion(schemaVersion) {
sqlStore.system.Update(&model.System{Name: "Version", Value: model.CurrentVersion})
@@ -469,6 +473,10 @@ func (ss SqlStore) System() SystemStore {
return ss.system
}
+func (ss SqlStore) Webhook() WebhookStore {
+ return ss.webhook
+}
+
type mattermConverter struct{}
func (me mattermConverter) ToDb(val interface{}) (interface{}, error) {
diff --git a/store/sql_webhook_store.go b/store/sql_webhook_store.go
new file mode 100644
index 000000000..e309f79e4
--- /dev/null
+++ b/store/sql_webhook_store.go
@@ -0,0 +1,128 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package store
+
+import (
+ "github.com/mattermost/platform/model"
+)
+
+type SqlWebhookStore struct {
+ *SqlStore
+}
+
+func NewSqlWebhookStore(sqlStore *SqlStore) WebhookStore {
+ s := &SqlWebhookStore{sqlStore}
+
+ for _, db := range sqlStore.GetAllConns() {
+ table := db.AddTableWithName(model.IncomingWebhook{}, "IncomingWebhooks").SetKeys(false, "Id")
+ table.ColMap("Id").SetMaxSize(26)
+ table.ColMap("UserId").SetMaxSize(26)
+ table.ColMap("ChannelId").SetMaxSize(26)
+ table.ColMap("TeamId").SetMaxSize(26)
+ }
+
+ return s
+}
+
+func (s SqlWebhookStore) UpgradeSchemaIfNeeded() {
+}
+
+func (s SqlWebhookStore) CreateIndexesIfNotExists() {
+ s.CreateIndexIfNotExists("idx_webhook_user_id", "IncomingWebhooks", "UserId")
+ s.CreateIndexIfNotExists("idx_webhook_team_id", "IncomingWebhooks", "TeamId")
+}
+
+func (s SqlWebhookStore) SaveIncoming(webhook *model.IncomingWebhook) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ if len(webhook.Id) > 0 {
+ result.Err = model.NewAppError("SqlWebhookStore.SaveIncoming",
+ "You cannot overwrite an existing IncomingWebhook", "id="+webhook.Id)
+ storeChannel <- result
+ close(storeChannel)
+ return
+ }
+
+ webhook.PreSave()
+ if result.Err = webhook.IsValid(); result.Err != nil {
+ storeChannel <- result
+ close(storeChannel)
+ return
+ }
+
+ if err := s.GetMaster().Insert(webhook); err != nil {
+ result.Err = model.NewAppError("SqlWebhookStore.SaveIncoming", "We couldn't save the IncomingWebhook", "id="+webhook.Id+", "+err.Error())
+ } else {
+ result.Data = webhook
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
+func (s SqlWebhookStore) GetIncoming(id string) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ var webhook model.IncomingWebhook
+
+ if err := s.GetReplica().SelectOne(&webhook, "SELECT * FROM IncomingWebhooks WHERE Id = :Id AND DeleteAt = 0", map[string]interface{}{"Id": id}); err != nil {
+ result.Err = model.NewAppError("SqlWebhookStore.GetIncoming", "We couldn't get the webhook", "id="+id+", err="+err.Error())
+ }
+
+ result.Data = &webhook
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
+func (s SqlWebhookStore) DeleteIncoming(webhookId string, time int64) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ _, err := s.GetMaster().Exec("Update IncomingWebhooks SET DeleteAt = :DeleteAt, UpdateAt = :UpdateAt WHERE Id = :Id", map[string]interface{}{"DeleteAt": time, "UpdateAt": time, "Id": webhookId})
+ if err != nil {
+ result.Err = model.NewAppError("SqlWebhookStore.DeleteIncoming", "We couldn't delete the webhook", "id="+webhookId+", err="+err.Error())
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
+func (s SqlWebhookStore) GetIncomingByUser(userId string) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ var webhooks []*model.IncomingWebhook
+
+ if _, err := s.GetReplica().Select(&webhooks, "SELECT * FROM IncomingWebhooks WHERE UserId = :UserId AND DeleteAt = 0", map[string]interface{}{"UserId": userId}); err != nil {
+ result.Err = model.NewAppError("SqlWebhookStore.GetIncomingByUser", "We couldn't get the webhook", "userId="+userId+", err="+err.Error())
+ }
+
+ result.Data = webhooks
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
diff --git a/store/sql_webhook_store_test.go b/store/sql_webhook_store_test.go
new file mode 100644
index 000000000..0a015eaf9
--- /dev/null
+++ b/store/sql_webhook_store_test.go
@@ -0,0 +1,77 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package store
+
+import (
+ "github.com/mattermost/platform/model"
+ "testing"
+)
+
+func TestIncomingWebhookStoreSaveIncoming(t *testing.T) {
+ Setup()
+
+ o1 := model.IncomingWebhook{}
+ o1.ChannelId = model.NewId()
+ o1.UserId = model.NewId()
+ o1.TeamId = model.NewId()
+
+ if err := (<-store.Webhook().SaveIncoming(&o1)).Err; err != nil {
+ t.Fatal("couldn't save item", err)
+ }
+
+ if err := (<-store.Webhook().SaveIncoming(&o1)).Err; err == nil {
+ t.Fatal("shouldn't be able to update from save")
+ }
+}
+
+func TestIncomingWebhookStoreGetIncoming(t *testing.T) {
+ Setup()
+
+ o1 := &model.IncomingWebhook{}
+ o1.ChannelId = model.NewId()
+ o1.UserId = model.NewId()
+ o1.TeamId = model.NewId()
+
+ o1 = (<-store.Webhook().SaveIncoming(o1)).Data.(*model.IncomingWebhook)
+
+ if r1 := <-store.Webhook().GetIncoming(o1.Id); r1.Err != nil {
+ t.Fatal(r1.Err)
+ } else {
+ if r1.Data.(*model.IncomingWebhook).CreateAt != o1.CreateAt {
+ t.Fatal("invalid returned webhook")
+ }
+ }
+
+ if err := (<-store.Webhook().GetIncoming("123")).Err; err == nil {
+ t.Fatal("Missing id should have failed")
+ }
+}
+
+func TestIncomingWebhookStoreDelete(t *testing.T) {
+ Setup()
+
+ o1 := &model.IncomingWebhook{}
+ o1.ChannelId = model.NewId()
+ o1.UserId = model.NewId()
+ o1.TeamId = model.NewId()
+
+ o1 = (<-store.Webhook().SaveIncoming(o1)).Data.(*model.IncomingWebhook)
+
+ if r1 := <-store.Webhook().GetIncoming(o1.Id); r1.Err != nil {
+ t.Fatal(r1.Err)
+ } else {
+ if r1.Data.(*model.IncomingWebhook).CreateAt != o1.CreateAt {
+ t.Fatal("invalid returned webhook")
+ }
+ }
+
+ if r2 := <-store.Webhook().DeleteIncoming(o1.Id, model.GetMillis()); r2.Err != nil {
+ t.Fatal(r2.Err)
+ }
+
+ if r3 := (<-store.Webhook().GetIncoming(o1.Id)); r3.Err == nil {
+ t.Log(r3.Data)
+ t.Fatal("Missing id should have failed")
+ }
+}
diff --git a/store/store.go b/store/store.go
index 1344c4ebe..c9d40cfa5 100644
--- a/store/store.go
+++ b/store/store.go
@@ -36,6 +36,7 @@ type Store interface {
Session() SessionStore
OAuth() OAuthStore
System() SystemStore
+ Webhook() WebhookStore
Close()
}
@@ -137,3 +138,10 @@ type SystemStore interface {
Update(system *model.System) StoreChannel
Get() StoreChannel
}
+
+type WebhookStore interface {
+ SaveIncoming(webhook *model.IncomingWebhook) StoreChannel
+ GetIncoming(id string) StoreChannel
+ GetIncomingByUser(userId string) StoreChannel
+ DeleteIncoming(webhookId string, time int64) StoreChannel
+}
diff --git a/utils/config.go b/utils/config.go
index dd2c17977..35631358b 100644
--- a/utils/config.go
+++ b/utils/config.go
@@ -188,6 +188,7 @@ func getClientProperties(c *model.Config) map[string]string {
props["ProfileWidth"] = fmt.Sprintf("%v", c.ImageSettings.ProfileWidth)
props["ProfileWidth"] = fmt.Sprintf("%v", c.ImageSettings.ProfileWidth)
props["EnableOAuthServiceProvider"] = strconv.FormatBool(c.ServiceSettings.EnableOAuthServiceProvider)
+ props["AllowIncomingWebhooks"] = strconv.FormatBool(c.ServiceSettings.AllowIncomingWebhooks)
return props
}
diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx
index d9e67836d..abad60154 100644
--- a/web/react/components/create_post.jsx
+++ b/web/react/components/create_post.jsx
@@ -23,6 +23,7 @@ export default class CreatePost extends React.Component {
this.lastTime = 0;
+ this.getCurrentDraft = this.getCurrentDraft.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.postMsgKeyPress = this.postMsgKeyPress.bind(this);
this.handleUserInput = this.handleUserInput.bind(this);
@@ -36,23 +37,15 @@ export default class CreatePost extends React.Component {
PostStore.clearDraftUploads();
- const draft = PostStore.getCurrentDraft();
- let previews = [];
- let messageText = '';
- let uploadsInProgress = [];
- if (draft && draft.previews && draft.message) {
- previews = draft.previews;
- messageText = draft.message;
- uploadsInProgress = draft.uploadsInProgress;
- }
+ const draft = this.getCurrentDraft();
this.state = {
channelId: ChannelStore.getCurrentId(),
- messageText: messageText,
- uploadsInProgress: uploadsInProgress,
- previews: previews,
+ messageText: draft.messageText,
+ uploadsInProgress: draft.uploadsInProgress,
+ previews: draft.previews,
submitting: false,
- initialText: messageText
+ initialText: draft.messageText
};
}
componentDidUpdate(prevProps, prevState) {
@@ -60,6 +53,24 @@ export default class CreatePost extends React.Component {
this.resizePostHolder();
}
}
+ getCurrentDraft() {
+ const draft = PostStore.getCurrentDraft();
+ const safeDraft = {previews: [], messageText: '', uploadsInProgress: []};
+
+ if (draft) {
+ if (draft.message) {
+ safeDraft.messageText = draft.message;
+ }
+ if (draft.previews) {
+ safeDraft.previews = draft.previews;
+ }
+ if (draft.uploadsInProgress) {
+ safeDraft.uploadsInProgress = draft.uploadsInProgress;
+ }
+ }
+
+ return safeDraft;
+ }
handleSubmit(e) {
e.preventDefault();
@@ -253,18 +264,9 @@ export default class CreatePost extends React.Component {
onChange() {
const channelId = ChannelStore.getCurrentId();
if (this.state.channelId !== channelId) {
- let draft = PostStore.getCurrentDraft();
-
- let previews = [];
- let messageText = '';
- let uploadsInProgress = [];
- if (draft && draft.previews && draft.message) {
- previews = draft.previews;
- messageText = draft.message;
- uploadsInProgress = draft.uploadsInProgress;
- }
+ const draft = this.getCurrentDraft();
- this.setState({channelId: channelId, messageText: messageText, initialText: messageText, submitting: false, serverError: null, postError: null, previews: previews, uploadsInProgress: uploadsInProgress});
+ this.setState({channelId: channelId, messageText: draft.messageText, initialText: draft.messageText, submitting: false, serverError: null, postError: null, previews: draft.previews, uploadsInProgress: draft.uploadsInProgress});
}
}
getFileCount(channelId) {
diff --git a/web/react/components/email_verify.jsx b/web/react/components/email_verify.jsx
index 92123956f..8d3f15525 100644
--- a/web/react/components/email_verify.jsx
+++ b/web/react/components/email_verify.jsx
@@ -10,12 +10,14 @@ export default class EmailVerify extends React.Component {
this.state = {};
}
handleResend() {
- window.location.href = window.location.href + '&resend=true';
+ const newAddress = window.location.href.replace('&resend_success=true', '');
+ window.location.href = newAddress + '&resend=true';
}
render() {
var title = '';
var body = '';
var resend = '';
+ var resendConfirm = '';
if (this.props.isVerified === 'true') {
title = global.window.config.SiteName + ' Email Verified';
body = <p>Your email has been verified! Click <a href={this.props.teamURL + '?email=' + this.props.userEmail}>here</a> to log in.</p>;
@@ -30,6 +32,9 @@ export default class EmailVerify extends React.Component {
Resend Email
</button>
);
+ if (this.props.resendSuccess) {
+ resendConfirm = <div><br /><p className='alert alert-success'><i className='fa fa-check'></i>{' Verification email sent.'}</p></div>;
+ }
}
return (
@@ -41,6 +46,7 @@ export default class EmailVerify extends React.Component {
<div className='panel-body'>
{body}
{resend}
+ {resendConfirm}
</div>
</div>
</div>
@@ -51,10 +57,12 @@ export default class EmailVerify extends React.Component {
EmailVerify.defaultProps = {
isVerified: 'false',
teamURL: '',
- userEmail: ''
+ userEmail: '',
+ resendSuccess: 'false'
};
EmailVerify.propTypes = {
isVerified: React.PropTypes.string,
teamURL: React.PropTypes.string,
- userEmail: React.PropTypes.string
+ userEmail: React.PropTypes.string,
+ resendSuccess: React.PropTypes.string
};
diff --git a/web/react/components/new_channel_modal.jsx b/web/react/components/new_channel_modal.jsx
index 99d5b49e9..c8ef59b4a 100644
--- a/web/react/components/new_channel_modal.jsx
+++ b/web/react/components/new_channel_modal.jsx
@@ -151,7 +151,7 @@ export default class NewChannelModal extends React.Component {
tabIndex='2'
/>
<p className='input__help'>
- {'This is the purpose of your channel and helps others decide whether to join.'}
+ {'Description helps others decide whether to join this channel.'}
</p>
{serverError}
</div>
diff --git a/web/react/components/password_reset_send_link.jsx b/web/react/components/password_reset_send_link.jsx
index 1e6cc3607..37d4a58cb 100644
--- a/web/react/components/password_reset_send_link.jsx
+++ b/web/react/components/password_reset_send_link.jsx
@@ -1,6 +1,7 @@
// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
// See License.txt for license information.
+const Utils = require('../utils/utils.jsx');
var client = require('../utils/client.jsx');
export default class PasswordResetSendLink extends React.Component {
@@ -15,8 +16,8 @@ export default class PasswordResetSendLink extends React.Component {
e.preventDefault();
var state = {};
- var email = React.findDOMNode(this.refs.email).value.trim();
- if (!email) {
+ var email = React.findDOMNode(this.refs.email).value.trim().toLowerCase();
+ if (!email || !Utils.isEmail(email)) {
state.error = 'Please enter a valid email address.';
this.setState(state);
return;
@@ -67,7 +68,7 @@ export default class PasswordResetSendLink extends React.Component {
<p>{'To reset your password, enter the email address you used to sign up for ' + this.props.teamDisplayName + '.'}</p>
<div className={formClass}>
<input
- type='text'
+ type='email'
className='form-control'
name='email'
ref='email'
diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx
index e0682e997..dbbcdc409 100644
--- a/web/react/components/post_body.jsx
+++ b/web/react/components/post_body.jsx
@@ -35,7 +35,6 @@ export default class PostBody extends React.Component {
parseEmojis() {
twemoji.parse(React.findDOMNode(this), {size: Constants.EMOJI_SIZE});
- global.window.emojify.run(React.findDOMNode(this.refs.message_span));
}
componentDidMount() {
diff --git a/web/react/components/post_list_container.jsx b/web/react/components/post_list_container.jsx
index 0815ac883..e59d85d41 100644
--- a/web/react/components/post_list_container.jsx
+++ b/web/react/components/post_list_container.jsx
@@ -49,6 +49,7 @@ export default class PostListContainer extends React.Component {
for (let i = 0; i <= this.state.postLists.length - 1; i++) {
postListCtls.push(
<PostList
+ key={'postlistkey' + i}
channelId={postLists[i]}
isActive={postLists[i] === channelId}
/>
diff --git a/web/react/components/rhs_comment.jsx b/web/react/components/rhs_comment.jsx
index fe31ac381..4d1892a69 100644
--- a/web/react/components/rhs_comment.jsx
+++ b/web/react/components/rhs_comment.jsx
@@ -56,7 +56,6 @@ export default class RhsComment extends React.Component {
}
parseEmojis() {
twemoji.parse(React.findDOMNode(this), {size: Constants.EMOJI_SIZE});
- global.window.emojify.run(React.findDOMNode(this.refs.message_holder));
}
componentDidMount() {
this.parseEmojis();
@@ -114,14 +113,7 @@ export default class RhsComment extends React.Component {
var ownerOptions;
if (isOwner && post.state !== Constants.POST_FAILED && post.state !== Constants.POST_LOADING) {
ownerOptions = (
- <div
- className='dropdown'
- onClick={
- function scroll() {
- $('.post-list-holder-by-time').scrollTop($('.post-list-holder-by-time').scrollTop() + 50);
- }
- }
- >
+ <div className='dropdown'>
<a
href='#'
className='dropdown-toggle theme'
diff --git a/web/react/components/rhs_root_post.jsx b/web/react/components/rhs_root_post.jsx
index 2ea697c5b..86620a499 100644
--- a/web/react/components/rhs_root_post.jsx
+++ b/web/react/components/rhs_root_post.jsx
@@ -20,7 +20,6 @@ export default class RhsRootPost extends React.Component {
}
parseEmojis() {
twemoji.parse(React.findDOMNode(this), {size: Constants.EMOJI_SIZE});
- global.window.emojify.run(React.findDOMNode(this.refs.message_holder));
}
componentDidMount() {
this.parseEmojis();
diff --git a/web/react/components/signup_user_complete.jsx b/web/react/components/signup_user_complete.jsx
index 19c3b2d22..e77bde861 100644
--- a/web/react/components/signup_user_complete.jsx
+++ b/web/react/components/signup_user_complete.jsx
@@ -1,7 +1,7 @@
// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
// See License.txt for license information.
-var utils = require('../utils/utils.jsx');
+var Utils = require('../utils/utils.jsx');
var client = require('../utils/client.jsx');
var UserStore = require('../stores/user_store.jsx');
var BrowserStore = require('../stores/browser_store.jsx');
@@ -31,13 +31,26 @@ export default class SignupUserComplete extends React.Component {
handleSubmit(e) {
e.preventDefault();
+ const providedEmail = React.findDOMNode(this.refs.email).value.trim();
+ if (!providedEmail) {
+ this.setState({nameError: '', emailError: 'This field is required', passwordError: ''});
+ return;
+ }
+
+ if (!Utils.isEmail(providedEmail)) {
+ this.setState({nameError: '', emailError: 'Please enter a valid email address', passwordError: ''});
+ return;
+ }
+
+ this.state.user.email = providedEmail;
+
this.state.user.username = React.findDOMNode(this.refs.name).value.trim().toLowerCase();
if (!this.state.user.username) {
this.setState({nameError: 'This field is required', emailError: '', passwordError: '', serverError: ''});
return;
}
- var usernameError = utils.isValidUsername(this.state.user.username);
+ var usernameError = Utils.isValidUsername(this.state.user.username);
if (usernameError === 'Cannot use a reserved word as a username.') {
this.setState({nameError: 'This username is reserved, please choose a new one.', emailError: '', passwordError: '', serverError: ''});
return;
@@ -51,12 +64,6 @@ export default class SignupUserComplete extends React.Component {
return;
}
- this.state.user.email = React.findDOMNode(this.refs.email).value.trim();
- if (!this.state.user.email) {
- this.setState({nameError: '', emailError: 'This field is required', passwordError: ''});
- return;
- }
-
this.state.user.password = React.findDOMNode(this.refs.password).value.trim();
if (!this.state.user.password || this.state.user.password .length < 5) {
this.setState({nameError: '', emailError: '', passwordError: 'Please enter at least 5 characters', serverError: ''});
diff --git a/web/react/components/user_settings/manage_incoming_hooks.jsx b/web/react/components/user_settings/manage_incoming_hooks.jsx
new file mode 100644
index 000000000..df089a403
--- /dev/null
+++ b/web/react/components/user_settings/manage_incoming_hooks.jsx
@@ -0,0 +1,177 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var Client = require('../../utils/client.jsx');
+var Utils = require('../../utils/utils.jsx');
+var Constants = require('../../utils/constants.jsx');
+var ChannelStore = require('../../stores/channel_store.jsx');
+var LoadingScreen = require('../loading_screen.jsx');
+
+export default class ManageIncomingHooks extends React.Component {
+ constructor() {
+ super();
+
+ this.getHooks = this.getHooks.bind(this);
+ this.addNewHook = this.addNewHook.bind(this);
+ this.updateChannelId = this.updateChannelId.bind(this);
+
+ this.state = {hooks: [], channelId: ChannelStore.getByName(Constants.DEFAULT_CHANNEL).id, getHooksComplete: false};
+ }
+ componentDidMount() {
+ this.getHooks();
+ }
+ addNewHook() {
+ let hook = {}; //eslint-disable-line prefer-const
+ hook.channel_id = this.state.channelId;
+
+ Client.addIncomingHook(
+ hook,
+ (data) => {
+ let hooks = this.state.hooks;
+ if (!hooks) {
+ hooks = [];
+ }
+ hooks.push(data);
+ this.setState({hooks});
+ },
+ (err) => {
+ this.setState({serverError: err});
+ }
+ );
+ }
+ removeHook(id) {
+ let data = {}; //eslint-disable-line prefer-const
+ data.id = id;
+
+ Client.deleteIncomingHook(
+ data,
+ () => {
+ let hooks = this.state.hooks; //eslint-disable-line prefer-const
+ let index = -1;
+ for (let i = 0; i < hooks.length; i++) {
+ if (hooks[i].id === id) {
+ index = i;
+ break;
+ }
+ }
+
+ if (index !== -1) {
+ hooks.splice(index, 1);
+ }
+
+ this.setState({hooks});
+ },
+ (err) => {
+ this.setState({serverError: err});
+ }
+ );
+ }
+ getHooks() {
+ Client.listIncomingHooks(
+ (data) => {
+ let state = this.state; //eslint-disable-line prefer-const
+
+ if (data) {
+ state.hooks = data;
+ }
+
+ state.getHooksComplete = true;
+ this.setState(state);
+ },
+ (err) => {
+ this.setState({serverError: err});
+ }
+ );
+ }
+ updateChannelId(e) {
+ this.setState({channelId: e.target.value});
+ }
+ render() {
+ let serverError;
+ if (this.state.serverError) {
+ serverError = <label className='has-error'>{this.state.serverError}</label>;
+ }
+
+ const channels = ChannelStore.getAll();
+ let options = []; //eslint-disable-line prefer-const
+ channels.forEach((channel) => {
+ options.push(<option value={channel.id}>{channel.name}</option>);
+ });
+
+ let disableButton = '';
+ if (this.state.channelId === '') {
+ disableButton = ' disable';
+ }
+
+ let hooks = []; //eslint-disable-line prefer-const
+ this.state.hooks.forEach((hook) => {
+ const c = ChannelStore.get(hook.channel_id);
+ hooks.push(
+ <div>
+ <div className='divider-light'></div>
+ <span>
+ <strong>{'URL: '}</strong>{Utils.getWindowLocationOrigin() + '/hooks/' + hook.id}
+ </span>
+ <br/>
+ <span>
+ <strong>{'Channel: '}</strong>{c.name}
+ </span>
+ <br/>
+ <a
+ className={'btn btn-sm btn-primary'}
+ href='#'
+ onClick={this.removeHook.bind(this, hook.id)}
+ >
+ {'Remove'}
+ </a>
+ </div>
+ );
+ });
+
+ let displayHooks;
+ if (!this.state.getHooksComplete) {
+ displayHooks = <LoadingScreen/>;
+ } else if (hooks.length > 0) {
+ displayHooks = hooks;
+ } else {
+ displayHooks = <label>{'None'}</label>;
+ }
+
+ const existingHooks = (
+ <div>
+ <label className='control-label'>{'Existing incoming webhooks'}</label>
+ <br/>
+ {displayHooks}
+ </div>
+ );
+
+ return (
+ <div
+ key='addIncomingHook'
+ className='form-group'
+ >
+ <label className='control-label'>{'Add a new incoming webhook'}</label>
+ <br/>
+ <div>
+ <select
+ ref='channelName'
+ value={this.state.channelId}
+ onChange={this.updateChannelId}
+ >
+ {options}
+ </select>
+ <br/>
+ {serverError}
+ <a
+ className={'btn btn-sm btn-primary' + disableButton}
+ href='#'
+ onClick={this.addNewHook}
+ >
+ {'Add'}
+ </a>
+ </div>
+ {existingHooks}
+ </div>
+ );
+ }
+}
diff --git a/web/react/components/user_settings.jsx b/web/react/components/user_settings/user_settings.jsx
index 48b499068..0eab333c4 100644
--- a/web/react/components/user_settings.jsx
+++ b/web/react/components/user_settings/user_settings.jsx
@@ -1,13 +1,14 @@
// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
// See License.txt for license information.
-var UserStore = require('../stores/user_store.jsx');
-var utils = require('../utils/utils.jsx');
+var UserStore = require('../../stores/user_store.jsx');
+var utils = require('../../utils/utils.jsx');
var NotificationsTab = require('./user_settings_notifications.jsx');
var SecurityTab = require('./user_settings_security.jsx');
var GeneralTab = require('./user_settings_general.jsx');
var AppearanceTab = require('./user_settings_appearance.jsx');
var DeveloperTab = require('./user_settings_developer.jsx');
+var IntegrationsTab = require('./user_settings_integrations.jsx');
export default class UserSettings extends React.Component {
constructor(props) {
@@ -86,6 +87,17 @@ export default class UserSettings extends React.Component {
/>
</div>
);
+ } else if (this.props.activeTab === 'integrations') {
+ return (
+ <div>
+ <IntegrationsTab
+ user={this.state.user}
+ activeSection={this.props.activeSection}
+ updateSection={this.props.updateSection}
+ updateTab={this.props.updateTab}
+ />
+ </div>
+ );
}
return <div/>;
diff --git a/web/react/components/user_settings_appearance.jsx b/web/react/components/user_settings/user_settings_appearance.jsx
index 3df013d03..aec3b319d 100644
--- a/web/react/components/user_settings_appearance.jsx
+++ b/web/react/components/user_settings/user_settings_appearance.jsx
@@ -1,11 +1,11 @@
// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
// See License.txt for license information.
-var UserStore = require('../stores/user_store.jsx');
-var SettingItemMin = require('./setting_item_min.jsx');
-var SettingItemMax = require('./setting_item_max.jsx');
-var Client = require('../utils/client.jsx');
-var Utils = require('../utils/utils.jsx');
+var UserStore = require('../../stores/user_store.jsx');
+var SettingItemMin = require('../setting_item_min.jsx');
+var SettingItemMax = require('../setting_item_max.jsx');
+var Client = require('../../utils/client.jsx');
+var Utils = require('../../utils/utils.jsx');
var ThemeColors = ['#2389d7', '#008a17', '#dc4fad', '#ac193d', '#0072c6', '#d24726', '#ff8f32', '#82ba00', '#03b3b2', '#008299', '#4617b4', '#8c0095', '#004b8b', '#004b8b', '#570000', '#380000', '#585858', '#000000'];
diff --git a/web/react/components/user_settings_developer.jsx b/web/react/components/user_settings/user_settings_developer.jsx
index 1b04149dc..1694aaa79 100644
--- a/web/react/components/user_settings_developer.jsx
+++ b/web/react/components/user_settings/user_settings_developer.jsx
@@ -1,8 +1,8 @@
// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
// See License.txt for license information.
-var SettingItemMin = require('./setting_item_min.jsx');
-var SettingItemMax = require('./setting_item_max.jsx');
+var SettingItemMin = require('../setting_item_min.jsx');
+var SettingItemMax = require('../setting_item_max.jsx');
export default class DeveloperTab extends React.Component {
constructor(props) {
diff --git a/web/react/components/user_settings_general.jsx b/web/react/components/user_settings/user_settings_general.jsx
index 66cde6ca2..5d9d9bfde 100644
--- a/web/react/components/user_settings_general.jsx
+++ b/web/react/components/user_settings/user_settings_general.jsx
@@ -1,13 +1,13 @@
// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
// See License.txt for license information.
-var UserStore = require('../stores/user_store.jsx');
-var SettingItemMin = require('./setting_item_min.jsx');
-var SettingItemMax = require('./setting_item_max.jsx');
-var SettingPicture = require('./setting_picture.jsx');
-var client = require('../utils/client.jsx');
-var AsyncClient = require('../utils/async_client.jsx');
-var utils = require('../utils/utils.jsx');
+var UserStore = require('../../stores/user_store.jsx');
+var SettingItemMin = require('../setting_item_min.jsx');
+var SettingItemMax = require('../setting_item_max.jsx');
+var SettingPicture = require('../setting_picture.jsx');
+var client = require('../../utils/client.jsx');
+var AsyncClient = require('../../utils/async_client.jsx');
+var utils = require('../../utils/utils.jsx');
var assign = require('object-assign');
export default class UserSettingsGeneralTab extends React.Component {
diff --git a/web/react/components/user_settings/user_settings_integrations.jsx b/web/react/components/user_settings/user_settings_integrations.jsx
new file mode 100644
index 000000000..cb45c5178
--- /dev/null
+++ b/web/react/components/user_settings/user_settings_integrations.jsx
@@ -0,0 +1,95 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var SettingItemMin = require('../setting_item_min.jsx');
+var SettingItemMax = require('../setting_item_max.jsx');
+var ManageIncomingHooks = require('./manage_incoming_hooks.jsx');
+
+export default class UserSettingsIntegrationsTab extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.updateSection = this.updateSection.bind(this);
+ this.handleClose = this.handleClose.bind(this);
+
+ this.state = {};
+ }
+ updateSection(section) {
+ this.props.updateSection(section);
+ }
+ handleClose() {
+ this.updateSection('');
+ }
+ componentDidMount() {
+ $('#user_settings').on('hidden.bs.modal', this.handleClose);
+ }
+ componentWillUnmount() {
+ $('#user_settings').off('hidden.bs.modal', this.handleClose);
+ }
+ render() {
+ let incomingHooksSection;
+ var inputs = [];
+
+ if (this.props.activeSection === 'incoming-hooks') {
+ inputs.push(
+ <ManageIncomingHooks />
+ );
+
+ incomingHooksSection = (
+ <SettingItemMax
+ title='Incoming Webhooks'
+ inputs={inputs}
+ updateSection={function clearSection(e) {
+ this.updateSection('');
+ e.preventDefault();
+ }.bind(this)}
+ />
+ );
+ } else {
+ incomingHooksSection = (
+ <SettingItemMin
+ title='Incoming Webhooks'
+ describe='Manage your incoming webhooks'
+ updateSection={function updateNameSection() {
+ this.updateSection('incoming-hooks');
+ }.bind(this)}
+ />
+ );
+ }
+
+ return (
+ <div>
+ <div className='modal-header'>
+ <button
+ type='button'
+ className='close'
+ data-dismiss='modal'
+ aria-label='Close'
+ >
+ <span aria-hidden='true'>{'×'}</span>
+ </button>
+ <h4
+ className='modal-title'
+ ref='title'
+ >
+ <i className='modal-back'></i>
+ {'Integration Settings'}
+ </h4>
+ </div>
+ <div className='user-settings'>
+ <h3 className='tab-header'>{'Integration Settings'}</h3>
+ <div className='divider-dark first'/>
+ {incomingHooksSection}
+ <div className='divider-dark'/>
+ </div>
+ </div>
+ );
+ }
+}
+
+UserSettingsIntegrationsTab.propTypes = {
+ user: React.PropTypes.object,
+ updateSection: React.PropTypes.func,
+ updateTab: React.PropTypes.func,
+ activeSection: React.PropTypes.string
+};
diff --git a/web/react/components/user_settings_modal.jsx b/web/react/components/user_settings/user_settings_modal.jsx
index 67a4d0041..1b22e6045 100644
--- a/web/react/components/user_settings_modal.jsx
+++ b/web/react/components/user_settings/user_settings_modal.jsx
@@ -1,7 +1,7 @@
// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
// See License.txt for license information.
-var SettingsSidebar = require('./settings_sidebar.jsx');
+var SettingsSidebar = require('../settings_sidebar.jsx');
var UserSettings = require('./user_settings.jsx');
export default class UserSettingsModal extends React.Component {
@@ -38,6 +38,9 @@ export default class UserSettingsModal extends React.Component {
if (global.window.config.EnableOAuthServiceProvider === 'true') {
tabs.push({name: 'developer', uiName: 'Developer', icon: 'glyphicon glyphicon-th'});
}
+ if (global.window.config.AllowIncomingWebhooks === 'true') {
+ tabs.push({name: 'integrations', uiName: 'Integrations', icon: 'glyphicon glyphicon-transfer'});
+ }
return (
<div
diff --git a/web/react/components/user_settings_notifications.jsx b/web/react/components/user_settings/user_settings_notifications.jsx
index dadbb669b..fde4970ce 100644
--- a/web/react/components/user_settings_notifications.jsx
+++ b/web/react/components/user_settings/user_settings_notifications.jsx
@@ -1,12 +1,12 @@
// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
// See License.txt for license information.
-var UserStore = require('../stores/user_store.jsx');
-var SettingItemMin = require('./setting_item_min.jsx');
-var SettingItemMax = require('./setting_item_max.jsx');
-var client = require('../utils/client.jsx');
-var AsyncClient = require('../utils/async_client.jsx');
-var utils = require('../utils/utils.jsx');
+var UserStore = require('../../stores/user_store.jsx');
+var SettingItemMin = require('../setting_item_min.jsx');
+var SettingItemMax = require('../setting_item_max.jsx');
+var client = require('../../utils/client.jsx');
+var AsyncClient = require('../../utils/async_client.jsx');
+var utils = require('../../utils/utils.jsx');
var assign = require('object-assign');
function getNotificationsStateFromStores() {
diff --git a/web/react/components/user_settings_security.jsx b/web/react/components/user_settings/user_settings_security.jsx
index c10d790ae..b59c08af0 100644
--- a/web/react/components/user_settings_security.jsx
+++ b/web/react/components/user_settings/user_settings_security.jsx
@@ -1,11 +1,11 @@
// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
// See License.txt for license information.
-var SettingItemMin = require('./setting_item_min.jsx');
-var SettingItemMax = require('./setting_item_max.jsx');
-var Client = require('../utils/client.jsx');
-var AsyncClient = require('../utils/async_client.jsx');
-var Constants = require('../utils/constants.jsx');
+var SettingItemMin = require('../setting_item_min.jsx');
+var SettingItemMax = require('../setting_item_max.jsx');
+var Client = require('../../utils/client.jsx');
+var AsyncClient = require('../../utils/async_client.jsx');
+var Constants = require('../../utils/constants.jsx');
export default class SecurityTab extends React.Component {
constructor(props) {
diff --git a/web/react/pages/channel.jsx b/web/react/pages/channel.jsx
index 43493de45..d24fe0b98 100644
--- a/web/react/pages/channel.jsx
+++ b/web/react/pages/channel.jsx
@@ -19,7 +19,7 @@ var DeletePostModal = require('../components/delete_post_modal.jsx');
var MoreChannelsModal = require('../components/more_channels.jsx');
var PostDeletedModal = require('../components/post_deleted_modal.jsx');
var ChannelNotificationsModal = require('../components/channel_notifications.jsx');
-var UserSettingsModal = require('../components/user_settings_modal.jsx');
+var UserSettingsModal = require('../components/user_settings/user_settings_modal.jsx');
var TeamSettingsModal = require('../components/team_settings_modal.jsx');
var ChannelMembersModal = require('../components/channel_members.jsx');
var ChannelInviteModal = require('../components/channel_invite_modal.jsx');
diff --git a/web/react/pages/verify.jsx b/web/react/pages/verify.jsx
index e48471bbd..16a9846e5 100644
--- a/web/react/pages/verify.jsx
+++ b/web/react/pages/verify.jsx
@@ -9,6 +9,7 @@ global.window.setupVerifyPage = function setupVerifyPage(props) {
isVerified={props.IsVerified}
teamURL={props.TeamURL}
userEmail={props.UserEmail}
+ resendSuccess={props.ResendSuccess}
/>,
document.getElementById('verify')
);
diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx
index c9eb09c00..531e4fdae 100644
--- a/web/react/utils/client.jsx
+++ b/web/react/utils/client.jsx
@@ -59,7 +59,7 @@ export function createTeamFromSignup(teamSignup, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(teamSignup),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('createTeamFromSignup', xhr, status, err);
error(e);
@@ -74,7 +74,7 @@ export function createTeamWithSSO(team, service, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(team),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('createTeamWithSSO', xhr, status, err);
error(e);
@@ -89,7 +89,7 @@ export function createUser(user, data, emailHash, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(user),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('createUser', xhr, status, err);
error(e);
@@ -106,7 +106,7 @@ export function updateUser(user, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(user),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('updateUser', xhr, status, err);
error(e);
@@ -123,7 +123,7 @@ export function updatePassword(data, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(data),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('newPassword', xhr, status, err);
error(e);
@@ -140,7 +140,7 @@ export function updateUserNotifyProps(data, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(data),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('updateUserNotifyProps', xhr, status, err);
error(e);
@@ -155,7 +155,7 @@ export function updateRoles(data, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(data),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('updateRoles', xhr, status, err);
error(e);
@@ -176,7 +176,7 @@ export function updateActive(userId, active, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(data),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('updateActive', xhr, status, err);
error(e);
@@ -193,7 +193,7 @@ export function sendPasswordReset(data, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(data),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('sendPasswordReset', xhr, status, err);
error(e);
@@ -210,7 +210,7 @@ export function resetPassword(data, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(data),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('resetPassword', xhr, status, err);
error(e);
@@ -254,7 +254,7 @@ export function revokeSession(altId, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify({id: altId}),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('revokeSession', xhr, status, err);
error(e);
@@ -269,7 +269,7 @@ export function getSessions(userId, success, error) {
dataType: 'json',
contentType: 'application/json',
type: 'GET',
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('getSessions', xhr, status, err);
error(e);
@@ -283,7 +283,7 @@ export function getAudits(userId, success, error) {
dataType: 'json',
contentType: 'application/json',
type: 'GET',
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('getAudits', xhr, status, err);
error(e);
@@ -367,7 +367,7 @@ export function inviteMembers(data, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(data),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('inviteMembers', xhr, status, err);
error(e);
@@ -384,7 +384,7 @@ export function updateTeamDisplayName(data, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(data),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('updateTeamDisplayName', xhr, status, err);
error(e);
@@ -401,7 +401,7 @@ export function signupTeam(email, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify({email: email}),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('singupTeam', xhr, status, err);
error(e);
@@ -418,7 +418,7 @@ export function createTeam(team, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(team),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('createTeam', xhr, status, err);
error(e);
@@ -433,7 +433,7 @@ export function findTeamByName(teamName, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify({name: teamName}),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('findTeamByName', xhr, status, err);
error(e);
@@ -448,7 +448,7 @@ export function findTeamsSendEmail(email, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify({email: email}),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('findTeamsSendEmail', xhr, status, err);
error(e);
@@ -465,7 +465,7 @@ export function findTeams(email, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify({email: email}),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('findTeams', xhr, status, err);
error(e);
@@ -480,7 +480,7 @@ export function createChannel(channel, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(channel),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('createChannel', xhr, status, err);
error(e);
@@ -497,7 +497,7 @@ export function createDirectChannel(channel, userId, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify({user_id: userId}),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('createDirectChannel', xhr, status, err);
error(e);
@@ -514,7 +514,7 @@ export function updateChannel(channel, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(channel),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('updateChannel', xhr, status, err);
error(e);
@@ -531,7 +531,7 @@ export function updateChannelDesc(data, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(data),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('updateChannelDesc', xhr, status, err);
error(e);
@@ -548,7 +548,7 @@ export function updateNotifyLevel(data, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(data),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('updateNotifyLevel', xhr, status, err);
error(e);
@@ -562,7 +562,7 @@ export function joinChannel(id, success, error) {
dataType: 'json',
contentType: 'application/json',
type: 'POST',
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('joinChannel', xhr, status, err);
error(e);
@@ -578,7 +578,7 @@ export function leaveChannel(id, success, error) {
dataType: 'json',
contentType: 'application/json',
type: 'POST',
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('leaveChannel', xhr, status, err);
error(e);
@@ -594,7 +594,7 @@ export function deleteChannel(id, success, error) {
dataType: 'json',
contentType: 'application/json',
type: 'POST',
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('deleteChannel', xhr, status, err);
error(e);
@@ -610,7 +610,7 @@ export function updateLastViewedAt(channelId, success, error) {
dataType: 'json',
contentType: 'application/json',
type: 'POST',
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('updateLastViewedAt', xhr, status, err);
error(e);
@@ -624,7 +624,7 @@ export function getChannels(success, error) {
url: '/api/v1/channels/',
dataType: 'json',
type: 'GET',
- success: success,
+ success,
ifModified: true,
error: function onError(xhr, status, err) {
var e = handleError('getChannels', xhr, status, err);
@@ -639,7 +639,7 @@ export function getChannel(id, success, error) {
url: '/api/v1/channels/' + id + '/',
dataType: 'json',
type: 'GET',
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('getChannel', xhr, status, err);
error(e);
@@ -654,7 +654,7 @@ export function getMoreChannels(success, error) {
url: '/api/v1/channels/more',
dataType: 'json',
type: 'GET',
- success: success,
+ success,
ifModified: true,
error: function onError(xhr, status, err) {
var e = handleError('getMoreChannels', xhr, status, err);
@@ -669,7 +669,7 @@ export function getChannelCounts(success, error) {
url: '/api/v1/channels/counts',
dataType: 'json',
type: 'GET',
- success: success,
+ success,
ifModified: true,
error: function onError(xhr, status, err) {
var e = handleError('getChannelCounts', xhr, status, err);
@@ -683,7 +683,7 @@ export function getChannelExtraInfo(id, success, error) {
url: '/api/v1/channels/' + id + '/extra_info',
dataType: 'json',
type: 'GET',
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('getChannelExtraInfo', xhr, status, err);
error(e);
@@ -698,7 +698,7 @@ export function executeCommand(channelId, command, suggest, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify({channelId: channelId, command: command, suggest: '' + suggest}),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('executeCommand', xhr, status, err);
error(e);
@@ -713,7 +713,7 @@ export function getPostsPage(channelId, offset, limit, success, error, complete)
dataType: 'json',
type: 'GET',
ifModified: true,
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('getPosts', xhr, status, err);
error(e);
@@ -728,7 +728,7 @@ export function getPosts(channelId, since, success, error, complete) {
dataType: 'json',
type: 'GET',
ifModified: true,
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('getPosts', xhr, status, err);
error(e);
@@ -744,7 +744,7 @@ export function getPost(channelId, postId, success, error) {
dataType: 'json',
type: 'GET',
ifModified: false,
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('getPost', xhr, status, err);
error(e);
@@ -758,7 +758,7 @@ export function search(terms, success, error) {
dataType: 'json',
type: 'GET',
data: {terms: terms},
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('search', xhr, status, err);
error(e);
@@ -774,7 +774,7 @@ export function deletePost(channelId, id, success, error) {
dataType: 'json',
contentType: 'application/json',
type: 'POST',
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('deletePost', xhr, status, err);
error(e);
@@ -791,7 +791,7 @@ export function createPost(post, channel, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(post),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('createPost', xhr, status, err);
error(e);
@@ -817,7 +817,7 @@ export function updatePost(post, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(post),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('updatePost', xhr, status, err);
error(e);
@@ -834,7 +834,7 @@ export function addChannelMember(id, data, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(data),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('addChannelMember', xhr, status, err);
error(e);
@@ -851,7 +851,7 @@ export function removeChannelMember(id, data, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(data),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('removeChannelMember', xhr, status, err);
error(e);
@@ -868,7 +868,7 @@ export function getProfiles(success, error) {
dataType: 'json',
contentType: 'application/json',
type: 'GET',
- success: success,
+ success,
ifModified: true,
error: function onError(xhr, status, err) {
var e = handleError('getProfiles', xhr, status, err);
@@ -885,7 +885,7 @@ export function uploadFile(formData, success, error) {
cache: false,
contentType: false,
processData: false,
- success: success,
+ success,
error: function onError(xhr, status, err) {
if (err !== 'abort') {
var e = handleError('uploadFile', xhr, status, err);
@@ -905,7 +905,7 @@ export function getFileInfo(filename, success, error) {
dataType: 'json',
contentType: 'application/json',
type: 'GET',
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('getFileInfo', xhr, status, err);
error(e);
@@ -919,7 +919,7 @@ export function getPublicLink(data, success, error) {
dataType: 'json',
type: 'POST',
data: JSON.stringify(data),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('getPublicLink', xhr, status, err);
error(e);
@@ -935,7 +935,7 @@ export function uploadProfileImage(imageData, success, error) {
cache: false,
contentType: false,
processData: false,
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('uploadProfileImage', xhr, status, err);
error(e);
@@ -951,7 +951,7 @@ export function importSlack(fileData, success, error) {
cache: false,
contentType: false,
processData: false,
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('importTeam', xhr, status, err);
error(e);
@@ -964,7 +964,7 @@ export function exportTeam(success, error) {
url: '/api/v1/teams/export_team',
type: 'GET',
dataType: 'json',
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('exportTeam', xhr, status, err);
error(e);
@@ -978,7 +978,7 @@ export function getStatuses(success, error) {
dataType: 'json',
contentType: 'application/json',
type: 'GET',
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('getStatuses', xhr, status, err);
error(e);
@@ -991,7 +991,7 @@ export function getMyTeam(success, error) {
url: '/api/v1/teams/me',
dataType: 'json',
type: 'GET',
- success: success,
+ success,
ifModified: true,
error: function onError(xhr, status, err) {
var e = handleError('getMyTeam', xhr, status, err);
@@ -1007,7 +1007,7 @@ export function updateValetFeature(data, success, error) {
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(data),
- success: success,
+ success,
error: function onError(xhr, status, err) {
var e = handleError('updateValetFeature', xhr, status, err);
error(e);
@@ -1040,7 +1040,7 @@ export function allowOAuth2(responseType, clientId, redirectUri, state, scope, s
dataType: 'json',
contentType: 'application/json',
type: 'GET',
- success: success,
+ success,
error: (xhr, status, err) => {
const e = handleError('allowOAuth2', xhr, status, err);
error(e);
@@ -1049,3 +1049,46 @@ export function allowOAuth2(responseType, clientId, redirectUri, state, scope, s
module.exports.track('api', 'api_users_allow_oauth2');
}
+
+export function addIncomingHook(hook, success, error) {
+ $.ajax({
+ url: '/api/v1/hooks/incoming/create',
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(hook),
+ success,
+ error: (xhr, status, err) => {
+ var e = handleError('addIncomingHook', xhr, status, err);
+ error(e);
+ }
+ });
+}
+
+export function deleteIncomingHook(data, success, error) {
+ $.ajax({
+ url: '/api/v1/hooks/incoming/delete',
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(data),
+ success,
+ error: (xhr, status, err) => {
+ var e = handleError('deleteIncomingHook', xhr, status, err);
+ error(e);
+ }
+ });
+}
+
+export function listIncomingHooks(success, error) {
+ $.ajax({
+ url: '/api/v1/hooks/incoming/list',
+ dataType: 'json',
+ type: 'GET',
+ success,
+ error: (xhr, status, err) => {
+ var e = handleError('listIncomingHooks', xhr, status, err);
+ error(e);
+ }
+ });
+}
diff --git a/web/react/utils/emoticons.jsx b/web/react/utils/emoticons.jsx
new file mode 100644
index 000000000..7210201ff
--- /dev/null
+++ b/web/react/utils/emoticons.jsx
@@ -0,0 +1,159 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+const emoticonPatterns = {
+ smile: /:-?\)/g, // :)
+ open_mouth: /:o/gi, // :o
+ scream: /:-o/gi, // :-o
+ smirk: /[:;]-?]/g, // :]
+ grinning: /[:;]-?d/gi, // :D
+ stuck_out_tongue_closed_eyes: /x-d/gi, // x-d
+ stuck_out_tongue_winking_eye: /[:;]-?p/gi, // ;p
+ rage: /:-?[\[@]/g, // :@
+ frowning: /:-?\(/g, // :(
+ sob: /:['’]-?\(|:&#x27;\(/g, // :`(
+ kissing_heart: /:-?\*/g, // :*
+ wink: /;-?\)/g, // ;)
+ pensive: /:-?\//g, // :/
+ confounded: /:-?s/gi, // :s
+ flushed: /:-?\|/g, // :|
+ relaxed: /:-?\$/g, // :$
+ mask: /:-x/gi, // :-x
+ heart: /<3|&lt;3/g, // <3
+ broken_heart: /<\/3|&lt;&#x2F;3/g, // </3
+ thumbsup: /:\+1:/g, // :+1:
+ thumbsdown: /:\-1:/g // :-1:
+};
+
+function initializeEmoticonMap() {
+ const emoticonNames =
+ ('+1,-1,100,1234,8ball,a,ab,abc,abcd,accept,aerial_tramway,airplane,alarm_clock,alien,ambulance,anchor,angel,' +
+ 'anger,angry,anguished,ant,apple,aquarius,aries,arrow_backward,arrow_double_down,arrow_double_up,arrow_down,' +
+ 'arrow_down_small,arrow_forward,arrow_heading_down,arrow_heading_up,arrow_left,arrow_lower_left,' +
+ 'arrow_lower_right,arrow_right,arrow_right_hook,arrow_up,arrow_up_down,arrow_up_small,arrow_upper_left,' +
+ 'arrow_upper_right,arrows_clockwise,arrows_counterclockwise,art,articulated_lorry,astonished,atm,b,baby,' +
+ 'baby_bottle,baby_chick,baby_symbol,back,baggage_claim,balloon,ballot_box_with_check,bamboo,banana,bangbang,' +
+ 'bank,bar_chart,barber,baseball,basketball,bath,bathtub,battery,bear,bee,beer,beers,beetle,beginner,bell,bento,' +
+ 'bicyclist,bike,bikini,bird,birthday,black_circle,black_joker,black_medium_small_square,black_medium_square,' +
+ 'black_nib,black_small_square,black_square,black_square_button,blossom,blowfish,blue_book,blue_car,blue_heart,' +
+ 'blush,boar,boat,bomb,book,bookmark,bookmark_tabs,books,boom,boot,bouquet,bow,bowling,bowtie,boy,bread,' +
+ 'bride_with_veil,bridge_at_night,briefcase,broken_heart,bug,bulb,bullettrain_front,bullettrain_side,bus,busstop,' +
+ 'bust_in_silhouette,busts_in_silhouette,cactus,cake,calendar,calling,camel,camera,cancer,candy,capital_abcd,' +
+ 'capricorn,car,card_index,carousel_horse,cat,cat2,cd,chart,chart_with_downwards_trend,chart_with_upwards_trend,' +
+ 'checkered_flag,cherries,cherry_blossom,chestnut,chicken,children_crossing,chocolate_bar,christmas_tree,church,' +
+ 'cinema,circus_tent,city_sunrise,city_sunset,cl,clap,clapper,clipboard,clock1,clock10,clock1030,clock11,' +
+ 'clock1130,clock12,clock1230,clock130,clock2,clock230,clock3,clock330,clock4,clock430,clock5,clock530,clock6,' +
+ 'clock630,clock7,clock730,clock8,clock830,clock9,clock930,closed_book,closed_lock_with_key,closed_umbrella,cloud,' +
+ 'clubs,cn,cocktail,coffee,cold_sweat,collision,computer,confetti_ball,confounded,confused,congratulations,' +
+ 'construction,construction_worker,convenience_store,cookie,cool,cop,copyright,corn,couple,couple_with_heart,' +
+ 'couplekiss,cow,cow2,credit_card,crescent_moon,crocodile,crossed_flags,crown,cry,crying_cat_face,crystal_ball,' +
+ 'cupid,curly_loop,currency_exchange,curry,custard,customs,cyclone,dancer,dancers,dango,dart,dash,date,de,' +
+ 'deciduous_tree,department_store,diamond_shape_with_a_dot_inside,diamonds,disappointed,disappointed_relieved,' +
+ 'dizzy,dizzy_face,do_not_litter,dog,dog2,dollar,dolls,dolphin,donut,door,doughnut,dragon,dragon_face,dress,' +
+ 'dromedary_camel,droplet,dvd,e-mail,ear,ear_of_rice,earth_africa,earth_americas,earth_asia,egg,eggplant,eight,' +
+ 'eight_pointed_black_star,eight_spoked_asterisk,electric_plug,elephant,email,end,envelope,es,euro,' +
+ 'european_castle,european_post_office,evergreen_tree,exclamation,expressionless,eyeglasses,eyes,facepunch,' +
+ 'factory,fallen_leaf,family,fast_forward,fax,fearful,feelsgood,feet,ferris_wheel,file_folder,finnadie,fire,' +
+ 'fire_engine,fireworks,first_quarter_moon,first_quarter_moon_with_face,fish,fish_cake,fishing_pole_and_fish,fist,' +
+ 'five,flags,flashlight,floppy_disk,flower_playing_cards,flushed,foggy,football,fork_and_knife,fountain,four,' +
+ 'four_leaf_clover,fr,free,fried_shrimp,fries,frog,frowning,fu,fuelpump,full_moon,full_moon_with_face,game_die,gb,' +
+ 'gem,gemini,ghost,gift,gift_heart,girl,globe_with_meridians,goat,goberserk,godmode,golf,grapes,green_apple,' +
+ 'green_book,green_heart,grey_exclamation,grey_question,grimacing,grin,grinning,guardsman,guitar,gun,haircut,' +
+ 'hamburger,hammer,hamster,hand,handbag,hankey,hash,hatched_chick,hatching_chick,headphones,hear_no_evil,heart,' +
+ 'heart_decoration,heart_eyes,heart_eyes_cat,heartbeat,heartpulse,hearts,heavy_check_mark,heavy_division_sign,' +
+ 'heavy_dollar_sign,heavy_exclamation_mark,heavy_minus_sign,heavy_multiplication_x,heavy_plus_sign,helicopter,' +
+ 'herb,hibiscus,high_brightness,high_heel,hocho,honey_pot,honeybee,horse,horse_racing,hospital,hotel,hotsprings,' +
+ 'hourglass,hourglass_flowing_sand,house,house_with_garden,hurtrealbad,hushed,ice_cream,icecream,id,' +
+ 'ideograph_advantage,imp,inbox_tray,incoming_envelope,information_desk_person,information_source,innocent,' +
+ 'interrobang,iphone,it,izakaya_lantern,jack_o_lantern,japan,japanese_castle,japanese_goblin,japanese_ogre,jeans,' +
+ 'joy,joy_cat,jp,key,keycap_ten,kimono,kiss,kissing,kissing_cat,kissing_closed_eyes,kissing_face,kissing_heart,' +
+ 'kissing_smiling_eyes,koala,koko,kr,large_blue_circle,large_blue_diamond,large_orange_diamond,last_quarter_moon,' +
+ 'last_quarter_moon_with_face,laughing,leaves,ledger,left_luggage,left_right_arrow,leftwards_arrow_with_hook,' +
+ 'lemon,leo,leopard,libra,light_rail,link,lips,lipstick,lock,lock_with_ink_pen,lollipop,loop,loudspeaker,' +
+ 'love_hotel,love_letter,low_brightness,m,mag,mag_right,mahjong,mailbox,mailbox_closed,mailbox_with_mail,' +
+ 'mailbox_with_no_mail,man,man_with_gua_pi_mao,man_with_turban,mans_shoe,maple_leaf,mask,massage,meat_on_bone,' +
+ 'mega,melon,memo,mens,metal,metro,microphone,microscope,milky_way,minibus,minidisc,mobile_phone_off,' +
+ 'money_with_wings,moneybag,monkey,monkey_face,monorail,mortar_board,mount_fuji,mountain_bicyclist,' +
+ 'mountain_cableway,mountain_railway,mouse,mouse2,movie_camera,moyai,muscle,mushroom,musical_keyboard,' +
+ 'musical_note,musical_score,mute,nail_care,name_badge,neckbeard,necktie,negative_squared_cross_mark,' +
+ 'neutral_face,new,new_moon,new_moon_with_face,newspaper,ng,nine,no_bell,no_bicycles,no_entry,no_entry_sign,' +
+ 'no_good,no_mobile_phones,no_mouth,no_pedestrians,no_smoking,non-potable_water,nose,notebook,' +
+ 'notebook_with_decorative_cover,notes,nut_and_bolt,o,o2,ocean,octocat,octopus,oden,office,ok,ok_hand,' +
+ 'ok_woman,older_man,older_woman,on,oncoming_automobile,oncoming_bus,oncoming_police_car,oncoming_taxi,one,' +
+ 'open_file_folder,open_hands,open_mouth,ophiuchus,orange_book,outbox_tray,ox,package,page_facing_up,' +
+ 'page_with_curl,pager,palm_tree,panda_face,paperclip,parking,part_alternation_mark,partly_sunny,' +
+ 'passport_control,paw_prints,peach,pear,pencil,pencil2,penguin,pensive,performing_arts,persevere,' +
+ 'person_frowning,person_with_blond_hair,person_with_pouting_face,phone,pig,pig2,pig_nose,pill,pineapple,pisces,' +
+ 'pizza,plus1,point_down,point_left,point_right,point_up,point_up_2,police_car,poodle,poop,post_office,' +
+ 'postal_horn,postbox,potable_water,pouch,poultry_leg,pound,pouting_cat,pray,princess,punch,purple_heart,purse,' +
+ 'pushpin,put_litter_in_its_place,question,rabbit,rabbit2,racehorse,radio,radio_button,rage,rage1,rage2,rage3,' +
+ 'rage4,railway_car,rainbow,raised_hand,raised_hands,raising_hand,ram,ramen,rat,recycle,red_car,red_circle,' +
+ 'registered,relaxed,relieved,repeat,repeat_one,restroom,revolving_hearts,rewind,ribbon,rice,rice_ball,' +
+ 'rice_cracker,rice_scene,ring,rocket,roller_coaster,rooster,rose,rotating_light,round_pushpin,rowboat,ru,' +
+ 'rugby_football,runner,running,running_shirt_with_sash,sa,sagittarius,sailboat,sake,sandal,santa,satellite,' +
+ 'satisfied,saxophone,school,school_satchel,scissors,scorpius,scream,scream_cat,scroll,seat,secret,see_no_evil,' +
+ 'seedling,seven,shaved_ice,sheep,shell,ship,shipit,shirt,shit,shoe,shower,signal_strength,six,six_pointed_star,' +
+ 'ski,skull,sleeping,sleepy,slot_machine,small_blue_diamond,small_orange_diamond,small_red_triangle,' +
+ 'small_red_triangle_down,smile,smile_cat,smiley,smiley_cat,smiling_imp,smirk,smirk_cat,smoking,snail,snake,' +
+ 'snowboarder,snowflake,snowman,sob,soccer,soon,sos,sound,space_invader,spades,spaghetti,sparkle,sparkler,' +
+ 'sparkles,sparkling_heart,speak_no_evil,speaker,speech_balloon,speedboat,squirrel,star,star2,stars,station,' +
+ 'statue_of_liberty,steam_locomotive,stew,straight_ruler,strawberry,stuck_out_tongue,stuck_out_tongue_closed_eyes,' +
+ 'stuck_out_tongue_winking_eye,sun_with_face,sunflower,sunglasses,sunny,sunrise,sunrise_over_mountains,surfer,' +
+ 'sushi,suspect,suspension_railway,sweat,sweat_drops,sweat_smile,sweet_potato,swimmer,symbols,syringe,tada,' +
+ 'tanabata_tree,tangerine,taurus,taxi,tea,telephone,telephone_receiver,telescope,tennis,tent,thought_balloon,' +
+ 'three,thumbsdown,thumbsup,ticket,tiger,tiger2,tired_face,tm,toilet,tokyo_tower,tomato,tongue,top,tophat,' +
+ 'tractor,traffic_light,train,train2,tram,triangular_flag_on_post,triangular_ruler,trident,triumph,trolleybus,' +
+ 'trollface,trophy,tropical_drink,tropical_fish,truck,trumpet,tshirt,tulip,turtle,tv,twisted_rightwards_arrows,' +
+ 'two,two_hearts,two_men_holding_hands,two_women_holding_hands,u5272,u5408,u55b6,u6307,u6708,u6709,u6e80,u7121,' +
+ 'u7533,u7981,u7a7a,uk,umbrella,unamused,underage,unlock,up,us,v,vertical_traffic_light,vhs,vibration_mode,' +
+ 'video_camera,video_game,violin,virgo,volcano,vs,walking,waning_crescent_moon,waning_gibbous_moon,warning,watch,' +
+ 'water_buffalo,watermelon,wave,wavy_dash,waxing_crescent_moon,waxing_gibbous_moon,wc,weary,wedding,whale,whale2,' +
+ 'wheelchair,white_check_mark,white_circle,white_flower,white_large_square,white_medium_small_square,' +
+ 'white_medium_square,white_small_square,white_square_button,wind_chime,wine_glass,wink,wolf,woman,' +
+ 'womans_clothes,womans_hat,womens,worried,wrench,x,yellow_heart,yen,yum,zap,zero,zzz').split(',');
+
+ // use a map to help make lookups faster instead of having to use indexOf on an array
+ const out = new Map();
+
+ for (let i = 0; i < emoticonNames.length; i++) {
+ out[emoticonNames[i]] = true;
+ }
+
+ return out;
+}
+
+const emoticonMap = initializeEmoticonMap();
+
+export function handleEmoticons(text, tokens) {
+ let output = text;
+
+ function replaceEmoticonWithToken(match, name) {
+ if (emoticonMap[name]) {
+ const index = tokens.size;
+ const alias = `MM_EMOTICON${index}`;
+
+ tokens.set(alias, {
+ value: `<img align="absmiddle" alt=${match} class="emoji" src=${getImagePathForEmoticon(name)} title=${match} />`,
+ originalText: match
+ });
+
+ return alias;
+ }
+
+ return match;
+ }
+
+ output = output.replace(/:([a-zA-Z0-9_-]+):/g, replaceEmoticonWithToken);
+
+ $.each(emoticonPatterns, (name, pattern) => {
+ // this might look a bit funny, but since the name isn't contained in the actual match
+ // like with the named emoticons, we need to add it in manually
+ output = output.replace(pattern, (match) => replaceEmoticonWithToken(match, name));
+ });
+
+ return output;
+}
+
+function getImagePathForEmoticon(name) {
+ return `/static/images/emoji/${name}.png`;
+}
diff --git a/web/react/utils/markdown.jsx b/web/react/utils/markdown.jsx
index 96da54217..0a876a3e3 100644
--- a/web/react/utils/markdown.jsx
+++ b/web/react/utils/markdown.jsx
@@ -1,9 +1,18 @@
// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
// See License.txt for license information.
+const TextFormatting = require('./text_formatting.jsx');
+
const marked = require('marked');
export class MattermostMarkdownRenderer extends marked.Renderer {
+ constructor(options, formattingOptions = {}) {
+ super(options);
+
+ this.text = this.text.bind(this);
+
+ this.formattingOptions = formattingOptions;
+ }
link(href, title, text) {
let outHref = href;
@@ -19,4 +28,8 @@ export class MattermostMarkdownRenderer extends marked.Renderer {
return output;
}
+
+ text(text) {
+ return TextFormatting.doFormatText(text, this.formattingOptions);
+ }
}
diff --git a/web/react/utils/text_formatting.jsx b/web/react/utils/text_formatting.jsx
index 4e390f708..56bf49c3f 100644
--- a/web/react/utils/text_formatting.jsx
+++ b/web/react/utils/text_formatting.jsx
@@ -3,41 +3,58 @@
const Autolinker = require('autolinker');
const Constants = require('./constants.jsx');
+const Emoticons = require('./emoticons.jsx');
const Markdown = require('./markdown.jsx');
const UserStore = require('../stores/user_store.jsx');
const Utils = require('./utils.jsx');
const marked = require('marked');
-const markdownRenderer = new Markdown.MattermostMarkdownRenderer();
-
// Performs formatting of user posts including highlighting mentions and search terms and converting urls, hashtags, and
// @mentions to links by taking a user's message and returning a string of formatted html. Also takes a number of options
// as part of the second parameter:
// - searchTerm - If specified, this word is highlighted in the resulting html. Defaults to nothing.
// - mentionHighlight - Specifies whether or not to highlight mentions of the current user. Defaults to true.
// - singleline - Specifies whether or not to remove newlines. Defaults to false.
+// - emoticons - Enables emoticon parsing. Defaults to true.
// - markdown - Enables markdown parsing. Defaults to true.
export function formatText(text, options = {}) {
- if (!('markdown' in options)) {
- options.markdown = true;
- }
-
- // wait until marked can sanitize the html so that we don't break markdown block quotes
let output;
- if (!options.markdown) {
- output = sanitizeHtml(text);
+
+ if (!('markdown' in options) || options.markdown) {
+ // the markdown renderer will call doFormatText as necessary so just call marked
+ output = marked(text, {
+ renderer: new Markdown.MattermostMarkdownRenderer(null, options),
+ sanitize: true
+ });
} else {
- output = text;
+ output = sanitizeHtml(text);
+ output = doFormatText(output, options);
+ }
+
+ // replace newlines with spaces if necessary
+ if (options.singleline) {
+ output = replaceNewlines(output);
}
+ return output;
+}
+
+// Performs most of the actual formatting work for formatText. Not intended to be called normally.
+export function doFormatText(text, options) {
+ let output = text;
+
const tokens = new Map();
// replace important words and phrases with tokens
- output = autolinkUrls(output, tokens, !!options.markdown);
+ output = autolinkUrls(output, tokens);
output = autolinkAtMentions(output, tokens);
output = autolinkHashtags(output, tokens);
+ if (!('emoticons' in options) || options.emoticon) {
+ output = Emoticons.handleEmoticons(output, tokens);
+ }
+
if (options.searchTerm) {
output = highlightSearchTerm(output, tokens, options.searchTerm);
}
@@ -46,22 +63,9 @@ export function formatText(text, options = {}) {
output = highlightCurrentMentions(output, tokens);
}
- // perform markdown parsing while we have an html-free input string
- if (options.markdown) {
- output = marked(output, {
- renderer: markdownRenderer,
- sanitize: true
- });
- }
-
// reinsert tokens with formatted versions of the important words and phrases
output = replaceTokens(output, tokens);
- // replace newlines with html line breaks
- if (options.singleline) {
- output = replaceNewlines(output);
- }
-
return output;
}
@@ -78,7 +82,7 @@ export function sanitizeHtml(text) {
return output;
}
-function autolinkUrls(text, tokens, markdown) {
+function autolinkUrls(text, tokens) {
function replaceUrlWithToken(autolinker, match) {
const linkText = match.getMatchedText();
let url = linkText;
@@ -108,30 +112,7 @@ function autolinkUrls(text, tokens, markdown) {
replaceFn: replaceUrlWithToken
});
- let output = text;
-
- // temporarily replace markdown links if markdown is enabled so that we don't accidentally parse them twice
- const markdownLinkTokens = new Map();
- if (markdown) {
- function replaceMarkdownLinkWithToken(markdownLink) {
- const index = markdownLinkTokens.size;
- const alias = `MM_MARKDOWNLINK${index}`;
-
- markdownLinkTokens.set(alias, {value: markdownLink});
-
- return alias;
- }
-
- output = output.replace(/\]\([^\)]*\)/g, replaceMarkdownLinkWithToken);
- }
-
- output = autolinker.link(output);
-
- if (markdown) {
- output = replaceTokens(output, markdownLinkTokens);
- }
-
- return output;
+ return autolinker.link(text);
}
function autolinkAtMentions(text, tokens) {
@@ -241,7 +222,7 @@ function autolinkHashtags(text, tokens) {
return prefix + alias;
}
- return output.replace(/(^|\W)(#[a-zA-Z0-9.\-_]+)\b/g, replaceHashtagWithToken);
+ return output.replace(/(^|\W)(#[a-zA-Z][a-zA-Z0-9.\-_]*)\b/g, replaceHashtagWithToken);
}
function highlightSearchTerm(text, tokens, searchTerm) {
diff --git a/web/static/js/emojify.min.js b/web/static/js/emojify.min.js
deleted file mode 100755
index 4fedf3205..000000000
--- a/web/static/js/emojify.min.js
+++ /dev/null
@@ -1,4 +0,0 @@
-/*! emojify.js - v1.0.5 -
- * Copyright (c) Hassan Khan 2015
- */
-!function(e,a){"use strict";"function"==typeof define&&define.amd?define([],a):"object"==typeof exports?module.exports=a():e.emojify=a()}(this,function(){"use strict";var e=function(){function e(){var e={named:/:([a-z0-9A-Z_-]+):/,smile:/:-?\)/g,open_mouth:/:o/gi,scream:/:-o/gi,smirk:/[:;]-?]/g,grinning:/[:;]-?d/gi,stuck_out_tongue_closed_eyes:/x-d/gi,stuck_out_tongue_winking_eye:/[:;]-?p/gi,rage:/:-?[\[@]/g,frowning:/:-?\(/g,sob:/:['’]-?\(|:&#x27;\(/g,kissing_heart:/:-?\*/g,wink:/;-?\)/g,pensive:/:-?\//g,confounded:/:-?s/gi,flushed:/:-?\|/g,relaxed:/:-?\$/g,mask:/:-x/gi,heart:/<3|&lt;3/g,broken_heart:/<\/3|&lt;&#x2F;3/g,thumbsup:/:\+1:/g,thumbsdown:/:\-1:/g};return d.ignore_emoticons&&(e={named:/:([a-z0-9A-Z_-]+):/,thumbsup:/:\+1:/g,thumbsdown:/:\-1:/g}),Object.keys(e).map(function(a){return[e[a],a]})}function a(){var e=_.map(function(e){var a=e[0],o=a.source||a;return o=o.replace(/(^|[^\[])\^/g,"$1"),"("+o+")"}).join("|");return new RegExp(e,"gi")}function o(e){return" "===e||" "===e||"\r"===e||"\n"===e||""===e||e===String.fromCharCode(160)}function r(e){var a=null;if(e.replacer)a=e.replacer.apply({config:d},[":"+e.emojiName+":",e.emojiName]);else{var o=d.tag_type||h[d.mode];a=e.win.document.createElement(o),"img"!==o?a.setAttribute("class","emoji emoji-"+e.emojiName):(a.setAttribute("align","absmiddle"),a.setAttribute("alt",":"+e.emojiName+":"),a.setAttribute("class","emoji"),a.setAttribute("src",d.img_dir+"/"+e.emojiName+".png")),a.setAttribute("title",":"+e.emojiName+":")}e.node.splitText(e.match.index),e.node.nextSibling.nodeValue=e.node.nextSibling.nodeValue.substr(e.match[0].length,e.node.nextSibling.nodeValue.length),a.appendChild(e.node.splitText(e.match.index)),e.node.parentNode.insertBefore(a,e.node.nextSibling)}function t(e){if(e[1]&&e[2]){var a=e[2];if(m[a])return a}else for(var o=3;o<e.length-1;o++)if(e[o])return _[o-2][1]}function i(e,a){var o=this.config.tag_type||h[this.config.mode];return"img"!==o?"<"+o+" class='emoji emoji-"+a+"' title=':"+a+":'></"+o+">":"<img align='absmiddle' alt=':"+a+":' class='emoji' src='"+this.config.img_dir+"/"+a+".png' title=':"+a+":' />"}function n(){this.lastEmojiTerminatedAt=-1}function s(o,r){if(!o)return o;r||(r=i),_=e(),c=a();var t=new n;return o.replace(c,function(){var e=Array.prototype.slice.call(arguments,0,-2),a=arguments[arguments.length-2],o=arguments[arguments.length-1],i=t.validate(e,a,o);return i?r.apply({config:d},[arguments[0],i]):arguments[0]})}function l(o,i){"undefined"==typeof o&&(o=d.only_crawl_id?document.getElementById(d.only_crawl_id):document.body);var s=o.ownerDocument,l=s.defaultView||s.parentWindow,u=function(e,a){var o;if(e.hasChildNodes())for(o=e.firstChild;o;)a(o)&&u(o,a),o=o.nextSibling},g=function(e){for(var a,o=[],s=new n;null!==(a=c.exec(e.data));)s.validate(a,a.index,a.input)&&o.push(a);for(var _=o.length;_-->0;){var u=t(o[_]);r({node:e,match:o[_],emojiName:u,replacer:i,win:l})}};_=e(),c=a();var m=[],h=new RegExp(d.blacklist.elements.join("|"),"i"),p=new RegExp(d.blacklist.classes.join("|"),"i");if("undefined"!=typeof l.document.createTreeWalker)for(var b,f=l.document.createTreeWalker(o,l.NodeFilter.SHOW_TEXT|l.NodeFilter.SHOW_ELEMENT,function(e){return 1!==e.nodeType?l.NodeFilter.FILTER_ACCEPT:e.tagName.match(h)||"svg"===e.tagName||e.className.match(p)?l.NodeFilter.FILTER_REJECT:l.NodeFilter.FILTER_SKIP},!1);null!==(b=f.nextNode());)m.push(b);else u(o,function(e){return"undefined"!=typeof e.tagName&&e.tagName.match(h)||"undefined"!=typeof e.className&&e.className.match(p)?!1:1===e.nodeType?!0:(m.push(e),!0)});m.forEach(g)}var _,c,u="+1,-1,100,1234,8ball,a,ab,abc,abcd,accept,aerial_tramway,airplane,alarm_clock,alien,ambulance,anchor,angel,anger,angry,anguished,ant,apple,aquarius,aries,arrow_backward,arrow_double_down,arrow_double_up,arrow_down,arrow_down_small,arrow_forward,arrow_heading_down,arrow_heading_up,arrow_left,arrow_lower_left,arrow_lower_right,arrow_right,arrow_right_hook,arrow_up,arrow_up_down,arrow_up_small,arrow_upper_left,arrow_upper_right,arrows_clockwise,arrows_counterclockwise,art,articulated_lorry,astonished,atm,b,baby,baby_bottle,baby_chick,baby_symbol,back,baggage_claim,balloon,ballot_box_with_check,bamboo,banana,bangbang,bank,bar_chart,barber,baseball,basketball,bath,bathtub,battery,bear,bee,beer,beers,beetle,beginner,bell,bento,bicyclist,bike,bikini,bird,birthday,black_circle,black_joker,black_medium_small_square,black_medium_square,black_nib,black_small_square,black_square,black_square_button,blossom,blowfish,blue_book,blue_car,blue_heart,blush,boar,boat,bomb,book,bookmark,bookmark_tabs,books,boom,boot,bouquet,bow,bowling,bowtie,boy,bread,bride_with_veil,bridge_at_night,briefcase,broken_heart,bug,bulb,bullettrain_front,bullettrain_side,bus,busstop,bust_in_silhouette,busts_in_silhouette,cactus,cake,calendar,calling,camel,camera,cancer,candy,capital_abcd,capricorn,car,card_index,carousel_horse,cat,cat2,cd,chart,chart_with_downwards_trend,chart_with_upwards_trend,checkered_flag,cherries,cherry_blossom,chestnut,chicken,children_crossing,chocolate_bar,christmas_tree,church,cinema,circus_tent,city_sunrise,city_sunset,cl,clap,clapper,clipboard,clock1,clock10,clock1030,clock11,clock1130,clock12,clock1230,clock130,clock2,clock230,clock3,clock330,clock4,clock430,clock5,clock530,clock6,clock630,clock7,clock730,clock8,clock830,clock9,clock930,closed_book,closed_lock_with_key,closed_umbrella,cloud,clubs,cn,cocktail,coffee,cold_sweat,collision,computer,confetti_ball,confounded,confused,congratulations,construction,construction_worker,convenience_store,cookie,cool,cop,copyright,corn,couple,couple_with_heart,couplekiss,cow,cow2,credit_card,crescent_moon,crocodile,crossed_flags,crown,cry,crying_cat_face,crystal_ball,cupid,curly_loop,currency_exchange,curry,custard,customs,cyclone,dancer,dancers,dango,dart,dash,date,de,deciduous_tree,department_store,diamond_shape_with_a_dot_inside,diamonds,disappointed,disappointed_relieved,dizzy,dizzy_face,do_not_litter,dog,dog2,dollar,dolls,dolphin,donut,door,doughnut,dragon,dragon_face,dress,dromedary_camel,droplet,dvd,e-mail,ear,ear_of_rice,earth_africa,earth_americas,earth_asia,egg,eggplant,eight,eight_pointed_black_star,eight_spoked_asterisk,electric_plug,elephant,email,end,envelope,es,euro,european_castle,european_post_office,evergreen_tree,exclamation,expressionless,eyeglasses,eyes,facepunch,factory,fallen_leaf,family,fast_forward,fax,fearful,feelsgood,feet,ferris_wheel,file_folder,finnadie,fire,fire_engine,fireworks,first_quarter_moon,first_quarter_moon_with_face,fish,fish_cake,fishing_pole_and_fish,fist,five,flags,flashlight,floppy_disk,flower_playing_cards,flushed,foggy,football,fork_and_knife,fountain,four,four_leaf_clover,fr,free,fried_shrimp,fries,frog,frowning,fu,fuelpump,full_moon,full_moon_with_face,game_die,gb,gem,gemini,ghost,gift,gift_heart,girl,globe_with_meridians,goat,goberserk,godmode,golf,grapes,green_apple,green_book,green_heart,grey_exclamation,grey_question,grimacing,grin,grinning,guardsman,guitar,gun,haircut,hamburger,hammer,hamster,hand,handbag,hankey,hash,hatched_chick,hatching_chick,headphones,hear_no_evil,heart,heart_decoration,heart_eyes,heart_eyes_cat,heartbeat,heartpulse,hearts,heavy_check_mark,heavy_division_sign,heavy_dollar_sign,heavy_exclamation_mark,heavy_minus_sign,heavy_multiplication_x,heavy_plus_sign,helicopter,herb,hibiscus,high_brightness,high_heel,hocho,honey_pot,honeybee,horse,horse_racing,hospital,hotel,hotsprings,hourglass,hourglass_flowing_sand,house,house_with_garden,hurtrealbad,hushed,ice_cream,icecream,id,ideograph_advantage,imp,inbox_tray,incoming_envelope,information_desk_person,information_source,innocent,interrobang,iphone,it,izakaya_lantern,jack_o_lantern,japan,japanese_castle,japanese_goblin,japanese_ogre,jeans,joy,joy_cat,jp,key,keycap_ten,kimono,kiss,kissing,kissing_cat,kissing_closed_eyes,kissing_face,kissing_heart,kissing_smiling_eyes,koala,koko,kr,large_blue_circle,large_blue_diamond,large_orange_diamond,last_quarter_moon,last_quarter_moon_with_face,laughing,leaves,ledger,left_luggage,left_right_arrow,leftwards_arrow_with_hook,lemon,leo,leopard,libra,light_rail,link,lips,lipstick,lock,lock_with_ink_pen,lollipop,loop,loudspeaker,love_hotel,love_letter,low_brightness,m,mag,mag_right,mahjong,mailbox,mailbox_closed,mailbox_with_mail,mailbox_with_no_mail,man,man_with_gua_pi_mao,man_with_turban,mans_shoe,maple_leaf,mask,massage,meat_on_bone,mega,melon,memo,mens,metal,metro,microphone,microscope,milky_way,minibus,minidisc,mobile_phone_off,money_with_wings,moneybag,monkey,monkey_face,monorail,mortar_board,mount_fuji,mountain_bicyclist,mountain_cableway,mountain_railway,mouse,mouse2,movie_camera,moyai,muscle,mushroom,musical_keyboard,musical_note,musical_score,mute,nail_care,name_badge,neckbeard,necktie,negative_squared_cross_mark,neutral_face,new,new_moon,new_moon_with_face,newspaper,ng,nine,no_bell,no_bicycles,no_entry,no_entry_sign,no_good,no_mobile_phones,no_mouth,no_pedestrians,no_smoking,non-potable_water,nose,notebook,notebook_with_decorative_cover,notes,nut_and_bolt,o,o2,ocean,octocat,octopus,oden,office,ok,ok_hand,ok_woman,older_man,older_woman,on,oncoming_automobile,oncoming_bus,oncoming_police_car,oncoming_taxi,one,open_file_folder,open_hands,open_mouth,ophiuchus,orange_book,outbox_tray,ox,package,page_facing_up,page_with_curl,pager,palm_tree,panda_face,paperclip,parking,part_alternation_mark,partly_sunny,passport_control,paw_prints,peach,pear,pencil,pencil2,penguin,pensive,performing_arts,persevere,person_frowning,person_with_blond_hair,person_with_pouting_face,phone,pig,pig2,pig_nose,pill,pineapple,pisces,pizza,plus1,point_down,point_left,point_right,point_up,point_up_2,police_car,poodle,poop,post_office,postal_horn,postbox,potable_water,pouch,poultry_leg,pound,pouting_cat,pray,princess,punch,purple_heart,purse,pushpin,put_litter_in_its_place,question,rabbit,rabbit2,racehorse,radio,radio_button,rage,rage1,rage2,rage3,rage4,railway_car,rainbow,raised_hand,raised_hands,raising_hand,ram,ramen,rat,recycle,red_car,red_circle,registered,relaxed,relieved,repeat,repeat_one,restroom,revolving_hearts,rewind,ribbon,rice,rice_ball,rice_cracker,rice_scene,ring,rocket,roller_coaster,rooster,rose,rotating_light,round_pushpin,rowboat,ru,rugby_football,runner,running,running_shirt_with_sash,sa,sagittarius,sailboat,sake,sandal,santa,satellite,satisfied,saxophone,school,school_satchel,scissors,scorpius,scream,scream_cat,scroll,seat,secret,see_no_evil,seedling,seven,shaved_ice,sheep,shell,ship,shipit,shirt,shit,shoe,shower,signal_strength,six,six_pointed_star,ski,skull,sleeping,sleepy,slot_machine,small_blue_diamond,small_orange_diamond,small_red_triangle,small_red_triangle_down,smile,smile_cat,smiley,smiley_cat,smiling_imp,smirk,smirk_cat,smoking,snail,snake,snowboarder,snowflake,snowman,sob,soccer,soon,sos,sound,space_invader,spades,spaghetti,sparkle,sparkler,sparkles,sparkling_heart,speak_no_evil,speaker,speech_balloon,speedboat,squirrel,star,star2,stars,station,statue_of_liberty,steam_locomotive,stew,straight_ruler,strawberry,stuck_out_tongue,stuck_out_tongue_closed_eyes,stuck_out_tongue_winking_eye,sun_with_face,sunflower,sunglasses,sunny,sunrise,sunrise_over_mountains,surfer,sushi,suspect,suspension_railway,sweat,sweat_drops,sweat_smile,sweet_potato,swimmer,symbols,syringe,tada,tanabata_tree,tangerine,taurus,taxi,tea,telephone,telephone_receiver,telescope,tennis,tent,thought_balloon,three,thumbsdown,thumbsup,ticket,tiger,tiger2,tired_face,tm,toilet,tokyo_tower,tomato,tongue,top,tophat,tractor,traffic_light,train,train2,tram,triangular_flag_on_post,triangular_ruler,trident,triumph,trolleybus,trollface,trophy,tropical_drink,tropical_fish,truck,trumpet,tshirt,tulip,turtle,tv,twisted_rightwards_arrows,two,two_hearts,two_men_holding_hands,two_women_holding_hands,u5272,u5408,u55b6,u6307,u6708,u6709,u6e80,u7121,u7533,u7981,u7a7a,uk,umbrella,unamused,underage,unlock,up,us,v,vertical_traffic_light,vhs,vibration_mode,video_camera,video_game,violin,virgo,volcano,vs,walking,waning_crescent_moon,waning_gibbous_moon,warning,watch,water_buffalo,watermelon,wave,wavy_dash,waxing_crescent_moon,waxing_gibbous_moon,wc,weary,wedding,whale,whale2,wheelchair,white_check_mark,white_circle,white_flower,white_large_square,white_medium_small_square,white_medium_square,white_small_square,white_square_button,wind_chime,wine_glass,wink,wolf,woman,womans_clothes,womans_hat,womens,worried,wrench,x,yellow_heart,yen,yum,zap,zero,zzz",g=u.split(/,/),m=g.reduce(function(e,a){return e[a]=!0,e},{}),d={blacklist:{ids:[],classes:["no-emojify"],elements:["script","textarea","a","pre","code"]},tag_type:null,only_crawl_id:null,img_dir:"images/emoji",ignore_emoticons:!1,mode:"img"},h={img:"img",sprite:"span","data-uri":"span"};return n.prototype={validate:function(e,a,r){function i(){return n.lastEmojiTerminatedAt=_+a,s}var n=this,s=t(e);if(s){var l=e[0],_=l.length;if(0===a)return i();if(r.length===l.length+a)return i();var c=this.lastEmojiTerminatedAt===a;if(c)return i();if(o(r.charAt(a-1)))return i();var u=o(r.charAt(l.length+a));return u&&c?i():void 0}}},{defaultConfig:d,emojiNames:g,setConfig:function(e){Object.keys(d).forEach(function(a){a in e&&(d[a]=e[a])})},replace:s,run:l}}();return e}); \ No newline at end of file
diff --git a/web/templates/head.html b/web/templates/head.html
index af5c86bba..a3ac930e9 100644
--- a/web/templates/head.html
+++ b/web/templates/head.html
@@ -40,11 +40,6 @@
<script src="/static/js/jquery-dragster/jquery.dragster.js"></script>
- <script src="/static/js/emojify.min.js"></script>
- <script>
- emojify.setConfig({img_dir: '/static/images/emoji'});
- </script>
-
<style id="antiClickjack">body{display:none !important;}</style>
<script src="/static/js/bundle.js"></script>
<script type="text/javascript">
diff --git a/web/web.go b/web/web.go
index 305e4f199..0cf45766e 100644
--- a/web/web.go
+++ b/web/web.go
@@ -9,11 +9,13 @@ import (
"github.com/gorilla/mux"
"github.com/mattermost/platform/api"
"github.com/mattermost/platform/model"
+ "github.com/mattermost/platform/store"
"github.com/mattermost/platform/utils"
"github.com/mssola/user_agent"
"gopkg.in/fsnotify.v1"
"html/template"
"net/http"
+ "regexp"
"strconv"
"strings"
)
@@ -63,6 +65,8 @@ func InitWeb() {
mainrouter.Handle("/admin_console", api.UserRequired(adminConsole)).Methods("GET")
+ mainrouter.Handle("/hooks/{id:[A-Za-z0-9]+}", api.ApiAppHandler(incomingWebhook)).Methods("POST")
+
// ----------------------------------------------------------------------------------------------
// *ANYTHING* team specific should go below this line
// ----------------------------------------------------------------------------------------------
@@ -355,6 +359,7 @@ func getChannel(c *api.Context, w http.ResponseWriter, r *http.Request) {
func verifyEmail(c *api.Context, w http.ResponseWriter, r *http.Request) {
resend := r.URL.Query().Get("resend")
+ resendSuccess := r.URL.Query().Get("resend_success")
name := r.URL.Query().Get("teamname")
email := r.URL.Query().Get("email")
hashedId := r.URL.Query().Get("hid")
@@ -375,7 +380,9 @@ func verifyEmail(c *api.Context, w http.ResponseWriter, r *http.Request) {
} else {
user := result.Data.(*model.User)
api.FireAndForgetVerifyEmail(user.Id, user.Email, team.Name, team.DisplayName, c.GetSiteURL(), c.GetTeamURLFromTeam(team))
- http.Redirect(w, r, "/", http.StatusFound)
+
+ newAddress := strings.Replace(r.URL.String(), "&resend=true", "&resend_success=true", -1)
+ http.Redirect(w, r, newAddress, http.StatusFound)
return
}
}
@@ -400,6 +407,7 @@ func verifyEmail(c *api.Context, w http.ResponseWriter, r *http.Request) {
page.Props["IsVerified"] = isVerified
page.Props["TeamURL"] = c.GetTeamURLFromTeam(team)
page.Props["UserEmail"] = email
+ page.Props["ResendSuccess"] = resendSuccess
page.Render(c, w)
}
@@ -838,3 +846,84 @@ func getAccessToken(c *api.Context, w http.ResponseWriter, r *http.Request) {
w.Write([]byte(accessRsp.ToJson()))
}
+
+func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) {
+ params := mux.Vars(r)
+ id := params["id"]
+
+ hchan := api.Srv.Store.Webhook().GetIncoming(id)
+
+ r.ParseForm()
+
+ props := model.MapFromJson(strings.NewReader(r.FormValue("payload")))
+
+ text := props["text"]
+ if len(text) == 0 {
+ c.Err = model.NewAppError("incomingWebhook", "No text specified", "")
+ return
+ }
+
+ channelName := props["channel"]
+
+ var hook *model.IncomingWebhook
+ if result := <-hchan; result.Err != nil {
+ c.Err = model.NewAppError("incomingWebhook", "Invalid webhook", "err="+result.Err.Message)
+ return
+ } else {
+ hook = result.Data.(*model.IncomingWebhook)
+ }
+
+ var channel *model.Channel
+ var cchan store.StoreChannel
+
+ if len(channelName) != 0 {
+ if channelName[0] == '@' {
+ if result := <-api.Srv.Store.User().GetByUsername(hook.TeamId, channelName[1:]); result.Err != nil {
+ c.Err = model.NewAppError("incomingWebhook", "Couldn't find the user", "err="+result.Err.Message)
+ return
+ } else {
+ channelName = model.GetDMNameFromIds(result.Data.(*model.User).Id, hook.UserId)
+ }
+ } else if channelName[0] == '#' {
+ channelName = channelName[1:]
+ }
+
+ cchan = api.Srv.Store.Channel().GetByName(hook.TeamId, channelName)
+ } else {
+ cchan = api.Srv.Store.Channel().Get(hook.ChannelId)
+ }
+
+ // parse links into Markdown format
+ linkWithTextRegex := regexp.MustCompile(`<([^<\|]+)\|([^>]+)>`)
+ text = linkWithTextRegex.ReplaceAllString(text, "[${2}](${1})")
+
+ linkRegex := regexp.MustCompile(`<\s*(\S*)\s*>`)
+ text = linkRegex.ReplaceAllString(text, "${1}")
+
+ if result := <-cchan; result.Err != nil {
+ c.Err = model.NewAppError("incomingWebhook", "Couldn't find the channel", "err="+result.Err.Message)
+ return
+ } else {
+ channel = result.Data.(*model.Channel)
+ }
+
+ pchan := api.Srv.Store.Channel().CheckPermissionsTo(hook.TeamId, channel.Id, hook.UserId)
+
+ post := &model.Post{UserId: hook.UserId, ChannelId: channel.Id, Message: text}
+
+ if !c.HasPermissionsToChannel(pchan, "createIncomingHook") && channel.Type != model.CHANNEL_OPEN {
+ c.Err = model.NewAppError("incomingWebhook", "Inappropriate channel permissions", "")
+ return
+ }
+
+ // create a mock session
+ c.Session = model.Session{UserId: hook.UserId, TeamId: hook.TeamId, IsOAuth: false}
+
+ if _, err := api.CreatePost(c, post, false); err != nil {
+ c.Err = model.NewAppError("incomingWebhook", "Error creating post", "err="+err.Message)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/plain")
+ w.Write([]byte("ok"))
+}
diff --git a/web/web_test.go b/web/web_test.go
index 3da7eb2dc..1cb1c0a34 100644
--- a/web/web_test.go
+++ b/web/web_test.go
@@ -180,6 +180,51 @@ func TestGetAccessToken(t *testing.T) {
}
}
+func TestIncomingWebhook(t *testing.T) {
+ Setup()
+
+ team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN}
+ team = ApiClient.Must(ApiClient.CreateTeam(team)).Data.(*model.Team)
+
+ user := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"}
+ user = ApiClient.Must(ApiClient.CreateUser(user, "")).Data.(*model.User)
+ store.Must(api.Srv.Store.User().VerifyEmail(user.Id))
+
+ ApiClient.LoginByEmail(team.Name, user.Email, "pwd")
+
+ channel1 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
+ channel1 = ApiClient.Must(ApiClient.CreateChannel(channel1)).Data.(*model.Channel)
+
+ if utils.Cfg.ServiceSettings.AllowIncomingWebhooks {
+ hook1 := &model.IncomingWebhook{ChannelId: channel1.Id}
+ hook1 = ApiClient.Must(ApiClient.CreateIncomingWebhook(hook1)).Data.(*model.IncomingWebhook)
+
+ payload := "payload={\"text\": \"test text\"}"
+ if _, err := ApiClient.PostToWebhook(hook1.Id, payload); err != nil {
+ t.Fatal(err)
+ }
+
+ payload = "payload={\"text\": \"\"}"
+ if _, err := ApiClient.PostToWebhook(hook1.Id, payload); err == nil {
+ t.Fatal("should have errored - no text to post")
+ }
+
+ payload = "payload={\"text\": \"test text\", \"channel\": \"junk\"}"
+ if _, err := ApiClient.PostToWebhook(hook1.Id, payload); err == nil {
+ t.Fatal("should have errored - bad channel")
+ }
+
+ payload = "payload={\"text\": \"test text\"}"
+ if _, err := ApiClient.PostToWebhook("abc123", payload); err == nil {
+ t.Fatal("should have errored - bad hook")
+ }
+ } else {
+ if _, err := ApiClient.PostToWebhook("123", "123"); err == nil {
+ t.Fatal("should have failed - webhooks turned off")
+ }
+ }
+}
+
func TestZZWebTearDown(t *testing.T) {
// *IMPORTANT*
// This should be the last function in any test file