summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md6
-rw-r--r--README.md10
-rw-r--r--api/channel.go7
-rw-r--r--api/command.go2
-rw-r--r--api/file.go10
-rw-r--r--api/post.go216
-rw-r--r--api/user.go20
-rw-r--r--api/web_conn.go25
-rw-r--r--api/web_team_hub.go31
-rw-r--r--api/webhook.go169
-rw-r--r--api/webhook_test.go181
-rw-r--r--config/config.json1
-rw-r--r--doc/developer/Setup.md8
-rw-r--r--doc/install/Docker-Single-Container.md7
-rw-r--r--doc/install/Production-Ubuntu.md3
-rw-r--r--doc/install/Troubleshooting.md5
-rw-r--r--doc/integrations/services/Gitlab-Integration-Service-for-Mattermost.md9
-rw-r--r--doc/integrations/webhooks/Incoming-Webhooks.md18
-rw-r--r--docker/dev/config_docker.json1
-rw-r--r--docker/local/config_docker.json1
-rw-r--r--mattermost.go4
-rw-r--r--model/client.go36
-rw-r--r--model/config.go1
-rw-r--r--model/incoming_webhook.go (renamed from model/webhook.go)5
-rw-r--r--model/incoming_webhook_test.go (renamed from model/webhook_test.go)0
-rw-r--r--model/message.go16
-rw-r--r--model/outgoing_webhook.go135
-rw-r--r--model/outgoing_webhook_test.go97
-rw-r--r--store/sql_store.go15
-rw-r--r--store/sql_webhook_store.go171
-rw-r--r--store/sql_webhook_store_test.go206
-rw-r--r--store/store.go7
-rw-r--r--utils/apns.go2
-rw-r--r--utils/config.go1
-rw-r--r--utils/mail.go14
-rw-r--r--web/react/components/activity_log_modal.jsx10
-rw-r--r--web/react/components/admin_console/admin_controller.jsx12
-rw-r--r--web/react/components/admin_console/admin_sidebar.jsx4
-rw-r--r--web/react/components/admin_console/email_settings.jsx8
-rw-r--r--web/react/components/admin_console/log_settings.jsx18
-rw-r--r--web/react/components/admin_console/service_settings.jsx36
-rw-r--r--web/react/components/channel_header.jsx16
-rw-r--r--web/react/components/edit_post_modal.jsx4
-rw-r--r--web/react/components/more_channels.jsx6
-rw-r--r--web/react/components/more_direct_channels.jsx77
-rw-r--r--web/react/components/msg_typing.jsx15
-rw-r--r--web/react/components/popover_list_members.jsx24
-rw-r--r--web/react/components/post_body.jsx8
-rw-r--r--web/react/components/post_info.jsx2
-rw-r--r--web/react/components/post_list.jsx60
-rw-r--r--web/react/components/rhs_comment.jsx26
-rw-r--r--web/react/components/sidebar.jsx116
-rw-r--r--web/react/components/team_signup_username_page.jsx7
-rw-r--r--web/react/components/user_profile.jsx41
-rw-r--r--web/react/components/user_settings/manage_outgoing_hooks.jsx262
-rw-r--r--web/react/components/user_settings/user_settings_appearance.jsx10
-rw-r--r--web/react/components/user_settings/user_settings_display.jsx21
-rw-r--r--web/react/components/user_settings/user_settings_integrations.jsx89
-rw-r--r--web/react/components/user_settings/user_settings_modal.jsx2
-rw-r--r--web/react/components/user_settings/user_settings_notifications.jsx65
-rw-r--r--web/react/components/view_image.jsx7
-rw-r--r--web/react/pages/admin_console.jsx7
-rw-r--r--web/react/stores/socket_store.jsx187
-rw-r--r--web/react/utils/client.jsx58
-rw-r--r--web/react/utils/constants.jsx13
-rw-r--r--web/react/utils/utils.jsx7
-rw-r--r--web/sass-files/sass/partials/_command-box.scss1
-rw-r--r--web/sass-files/sass/partials/_modal.scss78
-rw-r--r--web/sass-files/sass/partials/_responsive.scss10
-rw-r--r--web/sass-files/sass/partials/_settings.scss11
-rw-r--r--web/sass-files/sass/partials/_videos.scss7
-rw-r--r--web/static/images/webhook_icon.jpgbin0 -> 68190 bytes
-rw-r--r--web/templates/admin_console.html2
-rw-r--r--web/web.go45
74 files changed, 2270 insertions, 542 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 425902cf0..0d550f4d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,8 +4,14 @@
The "UNDER DEVELOPMENT" section of the Mattermost changelog appears in the product's `master` branch to note key changes committed to master and are on their way to the next stable release. When a stable release is pushed the "UNDER DEVELOPMENT" heading is removed from the final changelog of the release.
+- **Release candidate anticipated:** 2015-11-10
- **Final release anticipated:** 2015-11-16
+### Changes
+
+- IE 10 no longer supported since global share of IE 10 fell below 5%
+
+
## Release v1.1.0
Released: 2015-10-16
diff --git a/README.md b/README.md
index 08f01f717..eb273b7ec 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,12 @@ Please see the [features pages of the Mattermost website](http://www.mattermost.
- Attach sound, video and image files from mobile devices
- Define team-specific branding and color themes across your devices
+#### Self-Host Ready
+
+- Host and manage dozens of teams from a single Mattermost server
+- Easily manage your Mattermost server using a web-based System Console
+- Script setup and maintenance using Mattermost command line tools
+
## Learn More
- [Product Vision and Target Audiences](http://www.mattermost.org/vision/) - What we're solving and for whom are we building
@@ -38,7 +44,9 @@ Follow us on Twitter at [@MattermostHQ](https://twitter.com/mattermosthq).
## Installing Mattermost
-There are multiple ways to install Mattermost depending on your needs.
+Latest stable release of Mattermost is available from http://www.mattermost.org/download/, including binary distribution, and from install guides below.
+
+If you use Docker, you can [install Mattermost in a single-container preview in one line](https://github.com/mattermost/platform/blob/master/doc/install/Docker-Single-Container.md#one-line-docker-install).
#### Quick Start Install for Product Evaluation
diff --git a/api/channel.go b/api/channel.go
index adf125378..70f7eba4b 100644
--- a/api/channel.go
+++ b/api/channel.go
@@ -568,7 +568,7 @@ func updateLastViewedAt(c *Context, w http.ResponseWriter, r *http.Request) {
Srv.Store.Channel().UpdateLastViewedAt(id, c.Session.UserId)
- message := model.NewMessage(c.Session.TeamId, id, c.Session.UserId, model.ACTION_VIEWED)
+ message := model.NewMessage(c.Session.TeamId, id, c.Session.UserId, model.ACTION_CHANNEL_VIEWED)
message.Add("channel_id", id)
PublishAndForget(message)
@@ -777,9 +777,8 @@ func RemoveUserFromChannel(userIdToRemove string, removerUserId string, channel
UpdateChannelAccessCacheAndForget(channel.TeamId, userIdToRemove, channel.Id)
- message := model.NewMessage(channel.TeamId, "", userIdToRemove, model.ACTION_USER_REMOVED)
- message.Add("channel_id", channel.Id)
- message.Add("remover", removerUserId)
+ message := model.NewMessage(channel.TeamId, channel.Id, userIdToRemove, model.ACTION_USER_REMOVED)
+ message.Add("remover_id", removerUserId)
PublishAndForget(message)
return nil
diff --git a/api/command.go b/api/command.go
index 94b2cd2f8..52ff8fffd 100644
--- a/api/command.go
+++ b/api/command.go
@@ -145,7 +145,7 @@ func echoCommand(c *Context, command *model.Command) bool {
time.Sleep(time.Duration(delay) * time.Second)
- if _, err := CreatePost(c, post, false); err != nil {
+ if _, err := CreatePost(c, post, true); err != nil {
l4g.Error("Unable to create /echo post, err=%v", err)
}
}()
diff --git a/api/file.go b/api/file.go
index 429347596..142ef7ac7 100644
--- a/api/file.go
+++ b/api/file.go
@@ -146,12 +146,12 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
resStruct.ClientIds = append(resStruct.ClientIds, clientId)
}
- fireAndForgetHandleImages(imageNameList, imageDataList, c.Session.TeamId, channelId, c.Session.UserId)
+ handleImagesAndForget(imageNameList, imageDataList, c.Session.TeamId, channelId, c.Session.UserId)
w.Write([]byte(resStruct.ToJson()))
}
-func fireAndForgetHandleImages(filenames []string, fileData [][]byte, teamId, channelId, userId string) {
+func handleImagesAndForget(filenames []string, fileData [][]byte, teamId, channelId, userId string) {
go func() {
dest := "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/"
@@ -311,7 +311,7 @@ func getFileInfo(c *Context, w http.ResponseWriter, r *http.Request) {
} else {
fileData := make(chan []byte)
- asyncGetFile(path, fileData)
+ getFileAndForget(path, fileData)
f := <-fileData
@@ -378,7 +378,7 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
}
fileData := make(chan []byte)
- asyncGetFile(path, fileData)
+ getFileAndForget(path, fileData)
if len(hash) > 0 && len(data) > 0 && len(teamId) == 26 {
if !model.ComparePassword(hash, fmt.Sprintf("%v:%v", data, utils.Cfg.FileSettings.PublicLinkSalt)) {
@@ -423,7 +423,7 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
w.Write(f)
}
-func asyncGetFile(path string, fileData chan []byte) {
+func getFileAndForget(path string, fileData chan []byte) {
go func() {
data, getErr := readFile(path)
if getErr != nil {
diff --git a/api/post.go b/api/post.go
index ecc319a91..c5bcd4f5a 100644
--- a/api/post.go
+++ b/api/post.go
@@ -13,6 +13,7 @@ import (
"net/http"
"net/url"
"path/filepath"
+ "regexp"
"strconv"
"strings"
"time"
@@ -55,11 +56,15 @@ func createPost(c *Context, w http.ResponseWriter, r *http.Request) {
return
} else {
+ if result := <-Srv.Store.Channel().UpdateLastViewedAt(post.ChannelId, c.Session.UserId); result.Err != nil {
+ l4g.Error("Encountered error updating last viewed, channel_id=%s, user_id=%s, err=%v", post.ChannelId, c.Session.UserId, result.Err)
+ }
+
w.Write([]byte(rp.ToJson()))
}
}
-func CreatePost(c *Context, post *model.Post, doUpdateLastViewed bool) (*model.Post, *model.AppError) {
+func CreatePost(c *Context, post *model.Post, triggerWebhooks bool) (*model.Post, *model.AppError) {
var pchan store.StoreChannel
if len(post.RootId) > 0 {
pchan = Srv.Store.Post().Get(post.RootId)
@@ -130,50 +135,193 @@ func CreatePost(c *Context, post *model.Post, doUpdateLastViewed bool) (*model.P
var rpost *model.Post
if result := <-Srv.Store.Post().Save(post); result.Err != nil {
return nil, result.Err
- } else if doUpdateLastViewed && (<-Srv.Store.Channel().UpdateLastViewedAt(post.ChannelId, c.Session.UserId)).Err != nil {
- return nil, result.Err
} else {
rpost = result.Data.(*model.Post)
- fireAndForgetNotifications(rpost, c.Session.TeamId, c.GetSiteURL())
+ handlePostEventsAndForget(c, rpost, triggerWebhooks)
}
return rpost, nil
}
-func fireAndForgetNotifications(post *model.Post, teamId, siteURL string) {
+func CreateWebhookPost(c *Context, channelId, text, overrideUsername, overrideIconUrl string) (*model.Post, *model.AppError) {
+ // 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}")
+
+ post := &model.Post{UserId: c.Session.UserId, ChannelId: channelId, Message: text}
+ post.AddProp("from_webhook", "true")
+
+ if utils.Cfg.ServiceSettings.EnablePostUsernameOverride {
+ if len(overrideUsername) != 0 {
+ post.AddProp("override_username", overrideUsername)
+ } else {
+ post.AddProp("override_username", model.DEFAULT_WEBHOOK_USERNAME)
+ }
+ }
+ if utils.Cfg.ServiceSettings.EnablePostIconOverride {
+ if len(overrideIconUrl) != 0 {
+ post.AddProp("override_icon_url", overrideIconUrl)
+ } else {
+ post.AddProp("override_icon_url", model.DEFAULT_WEBHOOK_ICON)
+ }
+ }
+
+ if _, err := CreatePost(c, post, false); err != nil {
+ return nil, model.NewAppError("CreateWebhookPost", "Error creating post", "err="+err.Message)
+ }
+
+ return post, nil
+}
+
+func handlePostEventsAndForget(c *Context, post *model.Post, triggerWebhooks bool) {
go func() {
- // Get a list of user names (to be used as keywords) and ids for the given team
- uchan := Srv.Store.User().GetProfiles(teamId)
- echan := Srv.Store.Channel().GetMembers(post.ChannelId)
+ tchan := Srv.Store.Team().Get(c.Session.TeamId)
cchan := Srv.Store.Channel().Get(post.ChannelId)
- tchan := Srv.Store.Team().Get(teamId)
+ uchan := Srv.Store.User().Get(post.UserId)
+
+ var team *model.Team
+ if result := <-tchan; result.Err != nil {
+ l4g.Error("Encountered error getting team, team_id=%s, err=%v", c.Session.TeamId, result.Err)
+ return
+ } else {
+ team = result.Data.(*model.Team)
+ }
var channel *model.Channel
- var channelName string
- var bodyText string
- var subjectText string
if result := <-cchan; result.Err != nil {
- l4g.Error("Failed to retrieve channel channel_id=%v, err=%v", post.ChannelId, result.Err)
+ l4g.Error("Encountered error getting channel, channel_id=%s, err=%v", post.ChannelId, result.Err)
return
} else {
channel = result.Data.(*model.Channel)
- if channel.Type == model.CHANNEL_DIRECT {
- bodyText = "You have one new message."
- subjectText = "New Direct Message"
- } else {
- bodyText = "You have one new mention."
- subjectText = "New Mention"
- channelName = channel.DisplayName
+ }
+
+ sendNotificationsAndForget(c, post, team, channel)
+
+ var user *model.User
+ if result := <-uchan; result.Err != nil {
+ l4g.Error("Encountered error getting user, user_id=%s, err=%v", post.UserId, result.Err)
+ return
+ } else {
+ user = result.Data.(*model.User)
+ }
+
+ if triggerWebhooks {
+ handleWebhookEventsAndForget(c, post, team, channel, user)
+ }
+ }()
+}
+
+func handleWebhookEventsAndForget(c *Context, post *model.Post, team *model.Team, channel *model.Channel, user *model.User) {
+ go func() {
+ hchan := Srv.Store.Webhook().GetOutgoingByTeam(c.Session.TeamId)
+
+ hooks := []*model.OutgoingWebhook{}
+
+ if result := <-hchan; result.Err != nil {
+ l4g.Error("Encountered error getting webhooks by team, err=%v", result.Err)
+ return
+ } else {
+ hooks = result.Data.([]*model.OutgoingWebhook)
+ }
+
+ if len(hooks) == 0 {
+ return
+ }
+
+ firstWord := strings.Split(post.Message, " ")[0]
+
+ relevantHooks := []*model.OutgoingWebhook{}
+
+ for _, hook := range hooks {
+ if hook.ChannelId == post.ChannelId {
+ if len(hook.TriggerWords) == 0 || hook.HasTriggerWord(firstWord) {
+ relevantHooks = append(relevantHooks, hook)
+ }
+ } else if len(hook.ChannelId) == 0 && hook.HasTriggerWord(firstWord) {
+ relevantHooks = append(relevantHooks, hook)
}
}
+ for _, hook := range relevantHooks {
+ go func() {
+ p := url.Values{}
+ p.Set("token", hook.Token)
+
+ p.Set("team_id", hook.TeamId)
+ p.Set("team_domain", team.Name)
+
+ p.Set("channel_id", post.ChannelId)
+ p.Set("channel_name", channel.Name)
+
+ p.Set("timestamp", strconv.FormatInt(post.CreateAt/1000, 10))
+
+ p.Set("user_id", post.UserId)
+ p.Set("user_name", user.Username)
+
+ p.Set("text", post.Message)
+ p.Set("trigger_word", firstWord)
+
+ client := &http.Client{}
+
+ for _, url := range hook.CallbackURLs {
+ go func() {
+ req, _ := http.NewRequest("POST", url, strings.NewReader(p.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Accept", "application/json")
+ if resp, err := client.Do(req); err != nil {
+ l4g.Error("Event POST failed, err=%s", err.Error())
+ } else {
+ respProps := model.MapFromJson(resp.Body)
+
+ // copy the context and create a mock session for posting the message
+ mockSession := model.Session{UserId: hook.CreatorId, TeamId: hook.TeamId, IsOAuth: false}
+ newContext := &Context{mockSession, model.NewId(), "", c.Path, nil, c.teamURLValid, c.teamURL, c.siteURL}
+
+ if text, ok := respProps["text"]; ok {
+ if _, err := CreateWebhookPost(newContext, post.ChannelId, text, respProps["username"], respProps["icon_url"]); err != nil {
+ l4g.Error("Failed to create response post, err=%v", err)
+ }
+ }
+ }
+ }()
+ }
+
+ }()
+ }
+
+ }()
+
+}
+
+func sendNotificationsAndForget(c *Context, post *model.Post, team *model.Team, channel *model.Channel) {
+
+ go func() {
+ // Get a list of user names (to be used as keywords) and ids for the given team
+ uchan := Srv.Store.User().GetProfiles(c.Session.TeamId)
+ echan := Srv.Store.Channel().GetMembers(post.ChannelId)
+
+ var channelName string
+ var bodyText string
+ var subjectText string
+ if channel.Type == model.CHANNEL_DIRECT {
+ bodyText = "You have one new message."
+ subjectText = "New Direct Message"
+ } else {
+ bodyText = "You have one new mention."
+ subjectText = "New Mention"
+ channelName = channel.DisplayName
+ }
+
var mentionedUsers []string
if result := <-uchan; result.Err != nil {
- l4g.Error("Failed to retrieve user profiles team_id=%v, err=%v", teamId, result.Err)
+ l4g.Error("Failed to retrieve user profiles team_id=%v, err=%v", c.Session.TeamId, result.Err)
return
} else {
profileMap := result.Data.(map[string]*model.User)
@@ -286,7 +434,7 @@ func fireAndForgetNotifications(post *model.Post, teamId, siteURL string) {
}
for id := range toEmailMap {
- fireAndForgetMentionUpdate(post.ChannelId, id)
+ updateMentionCountAndForget(post.ChannelId, id)
}
}
@@ -296,23 +444,15 @@ func fireAndForgetNotifications(post *model.Post, teamId, siteURL string) {
mentionedUsers = append(mentionedUsers, k)
}
- var teamDisplayName string
- var teamURL string
- if result := <-tchan; result.Err != nil {
- l4g.Error("Failed to retrieve team team_id=%v, err=%v", teamId, result.Err)
- return
- } else {
- teamDisplayName = result.Data.(*model.Team).DisplayName
- teamURL = siteURL + "/" + result.Data.(*model.Team).Name
- }
+ teamURL := c.GetSiteURL() + "/" + team.Name
// Build and send the emails
location, _ := time.LoadLocation("UTC")
tm := time.Unix(post.CreateAt/1000, 0).In(location)
subjectPage := NewServerTemplatePage("post_subject")
- subjectPage.Props["SiteURL"] = siteURL
- subjectPage.Props["TeamDisplayName"] = teamDisplayName
+ subjectPage.Props["SiteURL"] = c.GetSiteURL()
+ subjectPage.Props["TeamDisplayName"] = team.DisplayName
subjectPage.Props["SubjectText"] = subjectText
subjectPage.Props["Month"] = tm.Month().String()[:3]
subjectPage.Props["Day"] = fmt.Sprintf("%d", tm.Day())
@@ -330,9 +470,9 @@ func fireAndForgetNotifications(post *model.Post, teamId, siteURL string) {
}
bodyPage := NewServerTemplatePage("post_body")
- bodyPage.Props["SiteURL"] = siteURL
+ bodyPage.Props["SiteURL"] = c.GetSiteURL()
bodyPage.Props["Nickname"] = profileMap[id].FirstName
- bodyPage.Props["TeamDisplayName"] = teamDisplayName
+ bodyPage.Props["TeamDisplayName"] = team.DisplayName
bodyPage.Props["ChannelName"] = channelName
bodyPage.Props["BodyText"] = bodyText
bodyPage.Props["SenderName"] = senderName
@@ -390,7 +530,7 @@ func fireAndForgetNotifications(post *model.Post, teamId, siteURL string) {
alreadySeen[session.DeviceId] = session.DeviceId
- utils.FireAndForgetSendAppleNotify(session.DeviceId, subjectPage.Render(), 1)
+ utils.SendAppleNotifyAndForget(session.DeviceId, subjectPage.Render(), 1)
}
}
}
@@ -399,7 +539,7 @@ func fireAndForgetNotifications(post *model.Post, teamId, siteURL string) {
}
}
- message := model.NewMessage(teamId, post.ChannelId, post.UserId, model.ACTION_POSTED)
+ message := model.NewMessage(c.Session.TeamId, post.ChannelId, post.UserId, model.ACTION_POSTED)
message.Add("post", post.ToJson())
if len(post.Filenames) != 0 {
@@ -422,7 +562,7 @@ func fireAndForgetNotifications(post *model.Post, teamId, siteURL string) {
}()
}
-func fireAndForgetMentionUpdate(channelId, userId string) {
+func updateMentionCountAndForget(channelId, userId string) {
go func() {
if result := <-Srv.Store.Channel().IncrementMentionCount(channelId, userId); result.Err != nil {
l4g.Error("Failed to update mention count for user_id=%v on channel_id=%v err=%v", userId, channelId, result.Err)
diff --git a/api/user.go b/api/user.go
index ac33e81a1..0c7278711 100644
--- a/api/user.go
+++ b/api/user.go
@@ -198,7 +198,7 @@ func CreateUser(c *Context, team *model.Team, user *model.User) *model.User {
l4g.Error("Encountered an issue joining default channels user_id=%s, team_id=%s, err=%v", ruser.Id, ruser.TeamId, err)
}
- fireAndForgetWelcomeEmail(ruser.Id, ruser.Email, team.Name, team.DisplayName, c.GetSiteURL(), c.GetTeamURLFromTeam(team), user.EmailVerified)
+ sendWelcomeEmailAndForget(ruser.Id, ruser.Email, team.Name, team.DisplayName, c.GetSiteURL(), c.GetTeamURLFromTeam(team), user.EmailVerified)
addDirectChannelsAndForget(ruser)
@@ -219,7 +219,7 @@ func CreateUser(c *Context, team *model.Team, user *model.User) *model.User {
}
}
-func fireAndForgetWelcomeEmail(userId, email, teamName, teamDisplayName, siteURL, teamURL string, verified bool) {
+func sendWelcomeEmailAndForget(userId, email, teamName, teamDisplayName, siteURL, teamURL string, verified bool) {
go func() {
subjectPage := NewServerTemplatePage("welcome_subject")
@@ -278,7 +278,7 @@ func addDirectChannelsAndForget(user *model.User) {
}()
}
-func FireAndForgetVerifyEmail(userId, userEmail, teamName, teamDisplayName, siteURL, teamURL string) {
+func SendVerifyEmailAndForget(userId, userEmail, teamName, teamDisplayName, siteURL, teamURL string) {
go func() {
link := fmt.Sprintf("%s/verify_email?uid=%s&hid=%s&teamname=%s&email=%s", siteURL, userId, model.HashPassword(userId), teamName, userEmail)
@@ -931,10 +931,10 @@ func updateUser(c *Context, w http.ResponseWriter, r *http.Request) {
l4g.Error(tresult.Err.Message)
} else {
team := tresult.Data.(*model.Team)
- fireAndForgetEmailChangeEmail(rusers[1].Email, rusers[0].Email, team.DisplayName, c.GetTeamURLFromTeam(team), c.GetSiteURL())
+ sendEmailChangeEmailAndForget(rusers[1].Email, rusers[0].Email, team.DisplayName, c.GetTeamURLFromTeam(team), c.GetSiteURL())
if utils.Cfg.EmailSettings.RequireEmailVerification {
- FireAndForgetEmailChangeVerifyEmail(rusers[0].Id, rusers[0].Email, team.Name, team.DisplayName, c.GetSiteURL(), c.GetTeamURLFromTeam(team))
+ SendEmailChangeVerifyEmailAndForget(rusers[0].Id, rusers[0].Email, team.Name, team.DisplayName, c.GetSiteURL(), c.GetTeamURLFromTeam(team))
}
}
}
@@ -1014,7 +1014,7 @@ func updatePassword(c *Context, w http.ResponseWriter, r *http.Request) {
l4g.Error(tresult.Err.Message)
} else {
team := tresult.Data.(*model.Team)
- fireAndForgetPasswordChangeEmail(user.Email, team.DisplayName, c.GetTeamURLFromTeam(team), c.GetSiteURL(), "using the settings menu")
+ sendPasswordChangeEmailAndForget(user.Email, team.DisplayName, c.GetTeamURLFromTeam(team), c.GetSiteURL(), "using the settings menu")
}
data := make(map[string]string)
@@ -1351,13 +1351,13 @@ func resetPassword(c *Context, w http.ResponseWriter, r *http.Request) {
c.LogAuditWithUserId(userId, "success")
}
- fireAndForgetPasswordChangeEmail(user.Email, team.DisplayName, c.GetTeamURLFromTeam(team), c.GetSiteURL(), "using a reset password link")
+ sendPasswordChangeEmailAndForget(user.Email, team.DisplayName, c.GetTeamURLFromTeam(team), c.GetSiteURL(), "using a reset password link")
props["new_password"] = ""
w.Write([]byte(model.MapToJson(props)))
}
-func fireAndForgetPasswordChangeEmail(email, teamDisplayName, teamURL, siteURL, method string) {
+func sendPasswordChangeEmailAndForget(email, teamDisplayName, teamURL, siteURL, method string) {
go func() {
subjectPage := NewServerTemplatePage("password_change_subject")
@@ -1376,7 +1376,7 @@ func fireAndForgetPasswordChangeEmail(email, teamDisplayName, teamURL, siteURL,
}()
}
-func fireAndForgetEmailChangeEmail(oldEmail, newEmail, teamDisplayName, teamURL, siteURL string) {
+func sendEmailChangeEmailAndForget(oldEmail, newEmail, teamDisplayName, teamURL, siteURL string) {
go func() {
subjectPage := NewServerTemplatePage("email_change_subject")
@@ -1395,7 +1395,7 @@ func fireAndForgetEmailChangeEmail(oldEmail, newEmail, teamDisplayName, teamURL,
}()
}
-func FireAndForgetEmailChangeVerifyEmail(userId, newUserEmail, teamName, teamDisplayName, siteURL, teamURL string) {
+func SendEmailChangeVerifyEmailAndForget(userId, newUserEmail, teamName, teamDisplayName, siteURL, teamURL string) {
go func() {
link := fmt.Sprintf("%s/verify_email?uid=%s&hid=%s&teamname=%s&email=%s", siteURL, userId, model.HashPassword(userId), teamName, newUserEmail)
diff --git a/api/web_conn.go b/api/web_conn.go
index a5099e520..50a003ace 100644
--- a/api/web_conn.go
+++ b/api/web_conn.go
@@ -92,24 +92,9 @@ func (c *WebConn) writePump() {
return
}
- if len(msg.ChannelId) > 0 {
- allowed, ok := c.ChannelAccessCache[msg.ChannelId]
- if !ok {
- allowed = hasPermissionsToChannel(Srv.Store.Channel().CheckPermissionsTo(c.TeamId, msg.ChannelId, c.UserId))
- c.ChannelAccessCache[msg.ChannelId] = allowed
- }
-
- if allowed {
- c.WebSocket.SetWriteDeadline(time.Now().Add(WRITE_WAIT))
- if err := c.WebSocket.WriteJSON(msg); err != nil {
- return
- }
- }
- } else {
- c.WebSocket.SetWriteDeadline(time.Now().Add(WRITE_WAIT))
- if err := c.WebSocket.WriteJSON(msg); err != nil {
- return
- }
+ c.WebSocket.SetWriteDeadline(time.Now().Add(WRITE_WAIT))
+ if err := c.WebSocket.WriteJSON(msg); err != nil {
+ return
}
case <-ticker.C:
@@ -121,9 +106,11 @@ func (c *WebConn) writePump() {
}
}
-func (c *WebConn) updateChannelAccessCache(channelId string) {
+func (c *WebConn) updateChannelAccessCache(channelId string) bool {
allowed := hasPermissionsToChannel(Srv.Store.Channel().CheckPermissionsTo(c.TeamId, channelId, c.UserId))
c.ChannelAccessCache[channelId] = allowed
+
+ return allowed
}
func hasPermissionsToChannel(sc store.StoreChannel) bool {
diff --git a/api/web_team_hub.go b/api/web_team_hub.go
index c57de550f..6a25b7d3d 100644
--- a/api/web_team_hub.go
+++ b/api/web_team_hub.go
@@ -53,7 +53,7 @@ func (h *TeamHub) Start() {
}
case msg := <-h.broadcast:
for webCon := range h.connections {
- if !(webCon.UserId == msg.UserId && msg.Action == model.ACTION_TYPING) {
+ if ShouldSendEvent(webCon, msg) {
select {
case webCon.Send <- msg:
default:
@@ -86,3 +86,32 @@ func (h *TeamHub) UpdateChannelAccessCache(userId string, channelId string) {
}
}
}
+
+func ShouldSendEvent(webCon *WebConn, msg *model.Message) bool {
+
+ if webCon.UserId == msg.UserId {
+ // Don't need to tell the user they are typing
+ if msg.Action == model.ACTION_TYPING {
+ return false
+ }
+ } else {
+ // Don't share a user's view events with other users
+ if msg.Action == model.ACTION_CHANNEL_VIEWED {
+ return false
+ }
+
+ // Only report events to a user who is the subject of the event, or is in the channel of the event
+ if len(msg.ChannelId) > 0 {
+ allowed, ok := webCon.ChannelAccessCache[msg.ChannelId]
+ if !ok {
+ allowed = webCon.updateChannelAccessCache(msg.ChannelId)
+ }
+
+ if !allowed {
+ return false
+ }
+ }
+ }
+
+ return true
+}
diff --git a/api/webhook.go b/api/webhook.go
index de4ba6691..34c308879 100644
--- a/api/webhook.go
+++ b/api/webhook.go
@@ -18,6 +18,11 @@ func InitWebhook(r *mux.Router) {
sr.Handle("/incoming/create", ApiUserRequired(createIncomingHook)).Methods("POST")
sr.Handle("/incoming/delete", ApiUserRequired(deleteIncomingHook)).Methods("POST")
sr.Handle("/incoming/list", ApiUserRequired(getIncomingHooks)).Methods("GET")
+
+ sr.Handle("/outgoing/create", ApiUserRequired(createOutgoingHook)).Methods("POST")
+ sr.Handle("/outgoing/regen_token", ApiUserRequired(regenOutgoingHookToken)).Methods("POST")
+ sr.Handle("/outgoing/delete", ApiUserRequired(deleteOutgoingHook)).Methods("POST")
+ sr.Handle("/outgoing/list", ApiUserRequired(getOutgoingHooks)).Methods("GET")
}
func createIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) {
@@ -50,9 +55,11 @@ func createIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) {
channel = result.Data.(*model.Channel)
}
- if !c.HasPermissionsToChannel(pchan, "createIncomingHook") && channel.Type != model.CHANNEL_OPEN {
- c.LogAudit("fail - bad channel permissions")
- return
+ if !c.HasPermissionsToChannel(pchan, "createIncomingHook") {
+ if channel.Type != model.CHANNEL_OPEN || channel.TeamId != c.Session.TeamId {
+ c.LogAudit("fail - bad channel permissions")
+ return
+ }
}
if result := <-Srv.Store.Webhook().SaveIncoming(hook); result.Err != nil {
@@ -67,7 +74,7 @@ func createIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) {
func deleteIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) {
if !utils.Cfg.ServiceSettings.EnableIncomingWebhooks {
- c.Err = model.NewAppError("createIncomingHook", "Incoming webhooks have been disabled by the system admin.", "")
+ c.Err = model.NewAppError("deleteIncomingHook", "Incoming webhooks have been disabled by the system admin.", "")
c.Err.StatusCode = http.StatusNotImplemented
return
}
@@ -87,7 +94,7 @@ func deleteIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) {
return
} else {
if c.Session.UserId != result.Data.(*model.IncomingWebhook).UserId && !c.IsTeamAdmin() {
- c.LogAudit("fail - inappropriate conditions")
+ c.LogAudit("fail - inappropriate permissions")
c.Err = model.NewAppError("deleteIncomingHook", "Inappropriate permissions to delete incoming webhook", "user_id="+c.Session.UserId)
return
}
@@ -104,7 +111,7 @@ func deleteIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) {
func getIncomingHooks(c *Context, w http.ResponseWriter, r *http.Request) {
if !utils.Cfg.ServiceSettings.EnableIncomingWebhooks {
- c.Err = model.NewAppError("createIncomingHook", "Incoming webhooks have been disabled by the system admin.", "")
+ c.Err = model.NewAppError("getIncomingHooks", "Incoming webhooks have been disabled by the system admin.", "")
c.Err.StatusCode = http.StatusNotImplemented
return
}
@@ -117,3 +124,153 @@ func getIncomingHooks(c *Context, w http.ResponseWriter, r *http.Request) {
w.Write([]byte(model.IncomingWebhookListToJson(hooks)))
}
}
+
+func createOutgoingHook(c *Context, w http.ResponseWriter, r *http.Request) {
+ if !utils.Cfg.ServiceSettings.EnableOutgoingWebhooks {
+ c.Err = model.NewAppError("createOutgoingHook", "Outgoing webhooks have been disabled by the system admin.", "")
+ c.Err.StatusCode = http.StatusNotImplemented
+ return
+ }
+
+ c.LogAudit("attempt")
+
+ hook := model.OutgoingWebhookFromJson(r.Body)
+
+ if hook == nil {
+ c.SetInvalidParam("createOutgoingHook", "webhook")
+ return
+ }
+
+ hook.CreatorId = c.Session.UserId
+ hook.TeamId = c.Session.TeamId
+
+ if len(hook.ChannelId) != 0 {
+ cchan := Srv.Store.Channel().Get(hook.ChannelId)
+ pchan := Srv.Store.Channel().CheckPermissionsTo(c.Session.TeamId, hook.ChannelId, c.Session.UserId)
+
+ var channel *model.Channel
+ if result := <-cchan; result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ channel = result.Data.(*model.Channel)
+ }
+
+ if channel.Type != model.CHANNEL_OPEN {
+ c.LogAudit("fail - not open channel")
+ }
+
+ if !c.HasPermissionsToChannel(pchan, "createOutgoingHook") {
+ if channel.Type != model.CHANNEL_OPEN || channel.TeamId != c.Session.TeamId {
+ c.LogAudit("fail - bad channel permissions")
+ return
+ }
+ }
+ } else if len(hook.TriggerWords) == 0 {
+ c.Err = model.NewAppError("createOutgoingHook", "Either trigger_words or channel_id must be set", "")
+ return
+ }
+
+ if result := <-Srv.Store.Webhook().SaveOutgoing(hook); result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ c.LogAudit("success")
+ rhook := result.Data.(*model.OutgoingWebhook)
+ w.Write([]byte(rhook.ToJson()))
+ }
+}
+
+func getOutgoingHooks(c *Context, w http.ResponseWriter, r *http.Request) {
+ if !utils.Cfg.ServiceSettings.EnableOutgoingWebhooks {
+ c.Err = model.NewAppError("getOutgoingHooks", "Outgoing webhooks have been disabled by the system admin.", "")
+ c.Err.StatusCode = http.StatusNotImplemented
+ return
+ }
+
+ if result := <-Srv.Store.Webhook().GetOutgoingByCreator(c.Session.UserId); result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ hooks := result.Data.([]*model.OutgoingWebhook)
+ w.Write([]byte(model.OutgoingWebhookListToJson(hooks)))
+ }
+}
+
+func deleteOutgoingHook(c *Context, w http.ResponseWriter, r *http.Request) {
+ if !utils.Cfg.ServiceSettings.EnableIncomingWebhooks {
+ c.Err = model.NewAppError("deleteOutgoingHook", "Outgoing 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().GetOutgoing(id); result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ if c.Session.UserId != result.Data.(*model.OutgoingWebhook).CreatorId && !c.IsTeamAdmin() {
+ c.LogAudit("fail - inappropriate permissions")
+ c.Err = model.NewAppError("deleteOutgoingHook", "Inappropriate permissions to delete outcoming webhook", "user_id="+c.Session.UserId)
+ return
+ }
+ }
+
+ if err := (<-Srv.Store.Webhook().DeleteOutgoing(id, model.GetMillis())).Err; err != nil {
+ c.Err = err
+ return
+ }
+
+ c.LogAudit("success")
+ w.Write([]byte(model.MapToJson(props)))
+}
+
+func regenOutgoingHookToken(c *Context, w http.ResponseWriter, r *http.Request) {
+ if !utils.Cfg.ServiceSettings.EnableIncomingWebhooks {
+ c.Err = model.NewAppError("regenOutgoingHookToken", "Outgoing 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("regenOutgoingHookToken", "id")
+ return
+ }
+
+ var hook *model.OutgoingWebhook
+ if result := <-Srv.Store.Webhook().GetOutgoing(id); result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ hook = result.Data.(*model.OutgoingWebhook)
+
+ if c.Session.UserId != hook.CreatorId && !c.IsTeamAdmin() {
+ c.LogAudit("fail - inappropriate permissions")
+ c.Err = model.NewAppError("regenOutgoingHookToken", "Inappropriate permissions to regenerate outcoming webhook token", "user_id="+c.Session.UserId)
+ return
+ }
+ }
+
+ hook.Token = model.NewId()
+
+ if result := <-Srv.Store.Webhook().UpdateOutgoing(hook); result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ w.Write([]byte(result.Data.(*model.OutgoingWebhook).ToJson()))
+ }
+}
diff --git a/api/webhook_test.go b/api/webhook_test.go
index 16b9c9529..4c04a9922 100644
--- a/api/webhook_test.go
+++ b/api/webhook_test.go
@@ -152,6 +152,187 @@ func TestDeleteIncomingHook(t *testing.T) {
}
}
+func TestCreateOutgoingHook(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.OutgoingWebhook{ChannelId: channel1.Id, CallbackURLs: []string{"http://nowhere.com"}}
+
+ if utils.Cfg.ServiceSettings.EnableOutgoingWebhooks {
+ var rhook *model.OutgoingWebhook
+ if result, err := Client.CreateOutgoingWebhook(hook); err != nil {
+ t.Fatal(err)
+ } else {
+ rhook = result.Data.(*model.OutgoingWebhook)
+ }
+
+ if hook.ChannelId != rhook.ChannelId {
+ t.Fatal("channel ids didn't match")
+ }
+
+ if rhook.CreatorId != user.Id {
+ t.Fatal("user ids didn't match")
+ }
+
+ if rhook.TeamId != team.Id {
+ t.Fatal("team ids didn't match")
+ }
+
+ hook = &model.OutgoingWebhook{ChannelId: "junk", CallbackURLs: []string{"http://nowhere.com"}}
+ if _, err := Client.CreateOutgoingWebhook(hook); err == nil {
+ t.Fatal("should have failed - bad channel id")
+ }
+
+ hook = &model.OutgoingWebhook{ChannelId: channel2.Id, CreatorId: "123", TeamId: "456", CallbackURLs: []string{"http://nowhere.com"}}
+ if result, err := Client.CreateOutgoingWebhook(hook); err != nil {
+ t.Fatal(err)
+ } else {
+ if result.Data.(*model.OutgoingWebhook).CreatorId != user.Id {
+ t.Fatal("bad user id wasn't overwritten")
+ }
+ if result.Data.(*model.OutgoingWebhook).TeamId != team.Id {
+ t.Fatal("bad team id wasn't overwritten")
+ }
+ }
+ } else {
+ if _, err := Client.CreateOutgoingWebhook(hook); err == nil {
+ t.Fatal("should have errored - webhooks turned off")
+ }
+ }
+}
+
+func TestListOutgoingHooks(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.EnableOutgoingWebhooks {
+ hook1 := &model.OutgoingWebhook{ChannelId: channel1.Id, CallbackURLs: []string{"http://nowhere.com"}}
+ hook1 = Client.Must(Client.CreateOutgoingWebhook(hook1)).Data.(*model.OutgoingWebhook)
+
+ hook2 := &model.OutgoingWebhook{TriggerWords: []string{"trigger"}, CallbackURLs: []string{"http://nowhere.com"}}
+ hook2 = Client.Must(Client.CreateOutgoingWebhook(hook2)).Data.(*model.OutgoingWebhook)
+
+ if result, err := Client.ListOutgoingWebhooks(); err != nil {
+ t.Fatal(err)
+ } else {
+ hooks := result.Data.([]*model.OutgoingWebhook)
+
+ if len(hooks) != 2 {
+ t.Fatal("incorrect number of hooks")
+ }
+ }
+ } else {
+ if _, err := Client.ListOutgoingWebhooks(); err == nil {
+ t.Fatal("should have errored - webhooks turned off")
+ }
+ }
+}
+
+func TestDeleteOutgoingHook(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.EnableOutgoingWebhooks {
+ hook := &model.OutgoingWebhook{ChannelId: channel1.Id, CallbackURLs: []string{"http://nowhere.com"}}
+ hook = Client.Must(Client.CreateOutgoingWebhook(hook)).Data.(*model.OutgoingWebhook)
+
+ data := make(map[string]string)
+ data["id"] = hook.Id
+
+ if _, err := Client.DeleteOutgoingWebhook(data); err != nil {
+ t.Fatal(err)
+ }
+
+ hooks := Client.Must(Client.ListOutgoingWebhooks()).Data.([]*model.OutgoingWebhook)
+ if len(hooks) != 0 {
+ t.Fatal("delete didn't work properly")
+ }
+ } else {
+ data := make(map[string]string)
+ data["id"] = "123"
+
+ if _, err := Client.DeleteOutgoingWebhook(data); err == nil {
+ t.Fatal("should have errored - webhooks turned off")
+ }
+ }
+}
+
+func TestRegenOutgoingHookToken(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.EnableOutgoingWebhooks {
+ hook := &model.OutgoingWebhook{ChannelId: channel1.Id, CallbackURLs: []string{"http://nowhere.com"}}
+ hook = Client.Must(Client.CreateOutgoingWebhook(hook)).Data.(*model.OutgoingWebhook)
+
+ data := make(map[string]string)
+ data["id"] = hook.Id
+
+ if result, err := Client.RegenOutgoingWebhookToken(data); err != nil {
+ t.Fatal(err)
+ } else {
+ if result.Data.(*model.OutgoingWebhook).Token == hook.Token {
+ t.Fatal("regen didn't work properly")
+ }
+ }
+
+ } else {
+ data := make(map[string]string)
+ data["id"] = "123"
+
+ if _, err := Client.RegenOutgoingWebhookToken(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
diff --git a/config/config.json b/config/config.json
index 8ef151350..37109428d 100644
--- a/config/config.json
+++ b/config/config.json
@@ -6,6 +6,7 @@
"GoogleDeveloperKey": "",
"EnableOAuthServiceProvider": false,
"EnableIncomingWebhooks": true,
+ "EnableOutgoingWebhooks": true,
"EnablePostUsernameOverride": false,
"EnablePostIconOverride": false,
"EnableTesting": false,
diff --git a/doc/developer/Setup.md b/doc/developer/Setup.md
index d806b7a9b..e78d4dff2 100644
--- a/doc/developer/Setup.md
+++ b/doc/developer/Setup.md
@@ -16,7 +16,9 @@ Developer Machine Setup
1. `mkdir ~/go`
2. Add the following to your ~/.bash_profile
`export GOPATH=$HOME/go`
- `export PATH=$PATH:$GOPATH/bin`
+ `export PATH=$PATH:$GOPATH/bin`
+ `ulimit -n 8096`
+ If you don't increase the file handle limit you may see some weird build issues with browserify or npm.
3. Reload your bash profile
`source ~/.bash_profile`
4. Install Node.js using Homebrew
@@ -57,7 +59,9 @@ Any issues? Please let us know on our forums at: http://forum.mattermost.org
2. Add the following to your ~/.bashrc
`export GOPATH=$HOME/go`
`export GOROOT=/usr/local/go`
- `export PATH=$PATH:$GOROOT/bin`
+ `export PATH=$PATH:$GOROOT/bin`
+ `ulimit -n 8096`
+ If you don't increase the file handle limit you may see some weird build issues with browserify or npm.
3. Reload your bashrc
`source ~/.bashrc`
6. Install Node.js
diff --git a/doc/install/Docker-Single-Container.md b/doc/install/Docker-Single-Container.md
index 4b952cd71..7c0784ad0 100644
--- a/doc/install/Docker-Single-Container.md
+++ b/doc/install/Docker-Single-Container.md
@@ -2,6 +2,13 @@
The following install instructions are for single-container installs of Mattermost using Docker for exploring product functionality and upgrading to newer versions.
+### One-line Docker Install ###
+
+If you have Docker set up, Mattermost installs in one-line:
+`docker run --name mattermost-dev -d --publish 8065:80 mattermost/platform`
+
+Otherwise, see step-by-step available:
+
### Mac OSX ###
1. Install Docker Toolbox using instructions at: http://docs.docker.com/installation/mac/
diff --git a/doc/install/Production-Ubuntu.md b/doc/install/Production-Ubuntu.md
index 87f8edb84..836af3995 100644
--- a/doc/install/Production-Ubuntu.md
+++ b/doc/install/Production-Ubuntu.md
@@ -160,10 +160,11 @@ exec bin/platform
proxy_set_header X-Forwarded-Ssl on;
```
## Finish Mattermost Server setup
+
1. Navigate to https://mattermost.example.com and create a team and user.
1. The first user in the system is automatically granted the `system_admin` role, which gives you access to the System Console.
1. From the `town-square` channel click the dropdown and choose the `System Console` option
-1. Update Email Settings. We recommend using an email sending service. The example below assumes AmazonSES.
+1. Update Email Settings. We recommend using an email sending service. The example shows how an Amazon SES setup might look (sample credentials shown below are not real).
* Set *Send Email Notifications* to true
* Set *Require Email Verification* to true
* Set *Feedback Name* to `No-Reply`
diff --git a/doc/install/Troubleshooting.md b/doc/install/Troubleshooting.md
index b87663ab3..6a7260ddf 100644
--- a/doc/install/Troubleshooting.md
+++ b/doc/install/Troubleshooting.md
@@ -5,3 +5,8 @@
1. **DO NOT manipulate the Mattermost database**
- In particular, DO NOT delete data from the database, as Mattermost is designed to stop working if data integrity has been compromised. The system is designed to archive content continously and generally assumes data is never deleted.
+
+#### Common Issues
+
+1. Error message in logs when attempting to sign-up: `x509: certificate signed by unknown authority`
+ - This error may appear when attempt to use a self-signed certificate to setup SSL, which is not yet supported by Mattermost. You can resolve this issue by setting up a load balancer like Ngnix. A ticket exists to [add support for self-signed certificates in future](x509: certificate signed by unknown authority).
diff --git a/doc/integrations/services/Gitlab-Integration-Service-for-Mattermost.md b/doc/integrations/services/Gitlab-Integration-Service-for-Mattermost.md
new file mode 100644
index 000000000..2ce56bb72
--- /dev/null
+++ b/doc/integrations/services/Gitlab-Integration-Service-for-Mattermost.md
@@ -0,0 +1,9 @@
+# [GitLab Integration Service for Mattermost](https://github.com/mattermost/mattermost-integration-gitlab)
+
+This [open source integration service](https://github.com/mattermost/mattermost-integration-gitlab) let you configure real-time notifications on GitLab issues, merge requests and comments to be delivered to selected Mattermost channels.
+
+The service can be installed on any Linux-based web server and instructions for **Heroku** and **Ubuntu 14.04** are included. Please see [Mattermost incoming webhooks documentation](https://github.com/mattermost/platform/blob/master/doc/integrations/webhooks/Incoming-Webhooks.md) for details on formatting options within the service.
+
+The Mattermost community is invited to fork, extend and repurpose this service for other applications. If you'd like your integration featured on http://mattermost.org/webhooks, please mail info@mattermost.org or tweet to us at @mattermosthq.
+
+![webhooks](https://gitlab.com/gitlab-org/omnibus-gitlab/uploads/677b0aa055693c4dcabad0ee580c61b8/730_gitlab_feature_request.png)
diff --git a/doc/integrations/webhooks/Incoming-Webhooks.md b/doc/integrations/webhooks/Incoming-Webhooks.md
index c6323a24a..be17d6a8e 100644
--- a/doc/integrations/webhooks/Incoming-Webhooks.md
+++ b/doc/integrations/webhooks/Incoming-Webhooks.md
@@ -13,26 +13,26 @@ Suppose you wanted to create a notification of the status of a daily build, with
```
payload={"text": "
-***
+---
##### Build Break - Project X - December 12, 2015 - 15:32 GMT +0
| Component | Tests Run | Tests Failed |
|:-----------|:------------|:-----------------------------------------------|
| Server | 948 | :white_check_mark: 0 |
| Web Client | 123 | :warning: [2 (see details)](http://linktologs) |
| iOS Client | 78 | :warning: [3 (see details)](http://linktologs) |
-***
+---
"}
```
Which would render in a Mattermost message as follows:
-***
+---
##### Build Break - Project X - December 12, 2015 - 15:32 GMT +0
-| Component | Tests Run | Tests Failed |
-|:------------ |:---------------|:-----|
-| Server | 948 | :white_check_mark: 0 |
-| Web Client | 123 | :warning: [2 (see details)](http://linktologs) |
-| iOS Client | 78 | :warning: [3 (see details)](http://linktologs) |
-***
+| Component | Tests Run | Tests Failed |
+|:-----------|:------------|:-----------------------------------------------|
+| Server | 948 | :white_check_mark: 0 |
+| Web Client | 123 | :warning: [2 (see details)](http://linktologs) |
+| iOS Client | 78 | :warning: [3 (see details)](http://linktologs) |
+---
### Enabling Incoming Webhooks
Incoming webhooks should be enabled on your Mattermost instance by default, but if they are not you'll need to get your system administrator to enable them. If you are the system administrator you can enable them by doing the following:
diff --git a/docker/dev/config_docker.json b/docker/dev/config_docker.json
index 653b6ffd7..00729395e 100644
--- a/docker/dev/config_docker.json
+++ b/docker/dev/config_docker.json
@@ -6,6 +6,7 @@
"GoogleDeveloperKey": "",
"EnableOAuthServiceProvider": false,
"EnableIncomingWebhooks": true,
+ "EnableOutgoingWebhooks": true,
"EnablePostUsernameOverride": false,
"EnablePostIconOverride": false,
"EnableTesting": false,
diff --git a/docker/local/config_docker.json b/docker/local/config_docker.json
index 653b6ffd7..00729395e 100644
--- a/docker/local/config_docker.json
+++ b/docker/local/config_docker.json
@@ -6,6 +6,7 @@
"GoogleDeveloperKey": "",
"EnableOAuthServiceProvider": false,
"EnableIncomingWebhooks": true,
+ "EnableOutgoingWebhooks": true,
"EnablePostUsernameOverride": false,
"EnablePostIconOverride": false,
"EnableTesting": false,
diff --git a/mattermost.go b/mattermost.go
index 48487ee73..e1ae58904 100644
--- a/mattermost.go
+++ b/mattermost.go
@@ -66,7 +66,7 @@ func main() {
manualtesting.InitManualTesting()
}
- securityAndDiagnosticsJob()
+ runSecurityAndDiagnosticsJobAndForget()
// wait for kill signal before attempting to gracefully shutdown
// the running service
@@ -78,7 +78,7 @@ func main() {
}
}
-func securityAndDiagnosticsJob() {
+func runSecurityAndDiagnosticsJobAndForget() {
go func() {
for {
if *utils.Cfg.ServiceSettings.EnableSecurityFixAlert {
diff --git a/model/client.go b/model/client.go
index eea65c50e..9183dcacb 100644
--- a/model/client.go
+++ b/model/client.go
@@ -879,6 +879,42 @@ func (c *Client) GetPreferenceCategory(category string) (*Result, *AppError) {
}
}
+func (c *Client) CreateOutgoingWebhook(hook *OutgoingWebhook) (*Result, *AppError) {
+ if r, err := c.DoApiPost("/hooks/outgoing/create", hook.ToJson()); err != nil {
+ return nil, err
+ } else {
+ return &Result{r.Header.Get(HEADER_REQUEST_ID),
+ r.Header.Get(HEADER_ETAG_SERVER), OutgoingWebhookFromJson(r.Body)}, nil
+ }
+}
+
+func (c *Client) DeleteOutgoingWebhook(data map[string]string) (*Result, *AppError) {
+ if r, err := c.DoApiPost("/hooks/outgoing/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) ListOutgoingWebhooks() (*Result, *AppError) {
+ if r, err := c.DoApiGet("/hooks/outgoing/list", "", ""); err != nil {
+ return nil, err
+ } else {
+ return &Result{r.Header.Get(HEADER_REQUEST_ID),
+ r.Header.Get(HEADER_ETAG_SERVER), OutgoingWebhookListFromJson(r.Body)}, nil
+ }
+}
+
+func (c *Client) RegenOutgoingWebhookToken(data map[string]string) (*Result, *AppError) {
+ if r, err := c.DoApiPost("/hooks/outgoing/regen_token", MapToJson(data)); err != nil {
+ return nil, err
+ } else {
+ return &Result{r.Header.Get(HEADER_REQUEST_ID),
+ r.Header.Get(HEADER_ETAG_SERVER), OutgoingWebhookFromJson(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 8a11b7bb7..ef76877c2 100644
--- a/model/config.go
+++ b/model/config.go
@@ -29,6 +29,7 @@ type ServiceSettings struct {
GoogleDeveloperKey string
EnableOAuthServiceProvider bool
EnableIncomingWebhooks bool
+ EnableOutgoingWebhooks bool
EnablePostUsernameOverride bool
EnablePostIconOverride bool
EnableTesting bool
diff --git a/model/webhook.go b/model/incoming_webhook.go
index 3bf034908..9b9969b96 100644
--- a/model/webhook.go
+++ b/model/incoming_webhook.go
@@ -8,6 +8,11 @@ import (
"io"
)
+const (
+ DEFAULT_WEBHOOK_USERNAME = "webhook"
+ DEFAULT_WEBHOOK_ICON = "/static/images/webhook_icon.jpg"
+)
+
type IncomingWebhook struct {
Id string `json:"id"`
CreateAt int64 `json:"create_at"`
diff --git a/model/webhook_test.go b/model/incoming_webhook_test.go
index 5297d7d90..5297d7d90 100644
--- a/model/webhook_test.go
+++ b/model/incoming_webhook_test.go
diff --git a/model/message.go b/model/message.go
index 122af4d9c..2725353ac 100644
--- a/model/message.go
+++ b/model/message.go
@@ -9,14 +9,14 @@ import (
)
const (
- ACTION_TYPING = "typing"
- ACTION_POSTED = "posted"
- ACTION_POST_EDITED = "post_edited"
- ACTION_POST_DELETED = "post_deleted"
- ACTION_VIEWED = "viewed"
- ACTION_NEW_USER = "new_user"
- ACTION_USER_ADDED = "user_added"
- ACTION_USER_REMOVED = "user_removed"
+ ACTION_TYPING = "typing"
+ ACTION_POSTED = "posted"
+ ACTION_POST_EDITED = "post_edited"
+ ACTION_POST_DELETED = "post_deleted"
+ ACTION_CHANNEL_VIEWED = "channel_viewed"
+ ACTION_NEW_USER = "new_user"
+ ACTION_USER_ADDED = "user_added"
+ ACTION_USER_REMOVED = "user_removed"
)
type Message struct {
diff --git a/model/outgoing_webhook.go b/model/outgoing_webhook.go
new file mode 100644
index 000000000..8958dd5b0
--- /dev/null
+++ b/model/outgoing_webhook.go
@@ -0,0 +1,135 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package model
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+)
+
+type OutgoingWebhook struct {
+ Id string `json:"id"`
+ Token string `json:"token"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+ DeleteAt int64 `json:"delete_at"`
+ CreatorId string `json:"creator_id"`
+ ChannelId string `json:"channel_id"`
+ TeamId string `json:"team_id"`
+ TriggerWords StringArray `json:"trigger_words"`
+ CallbackURLs StringArray `json:"callback_urls"`
+}
+
+func (o *OutgoingWebhook) ToJson() string {
+ b, err := json.Marshal(o)
+ if err != nil {
+ return ""
+ } else {
+ return string(b)
+ }
+}
+
+func OutgoingWebhookFromJson(data io.Reader) *OutgoingWebhook {
+ decoder := json.NewDecoder(data)
+ var o OutgoingWebhook
+ err := decoder.Decode(&o)
+ if err == nil {
+ return &o
+ } else {
+ return nil
+ }
+}
+
+func OutgoingWebhookListToJson(l []*OutgoingWebhook) string {
+ b, err := json.Marshal(l)
+ if err != nil {
+ return ""
+ } else {
+ return string(b)
+ }
+}
+
+func OutgoingWebhookListFromJson(data io.Reader) []*OutgoingWebhook {
+ decoder := json.NewDecoder(data)
+ var o []*OutgoingWebhook
+ err := decoder.Decode(&o)
+ if err == nil {
+ return o
+ } else {
+ return nil
+ }
+}
+
+func (o *OutgoingWebhook) IsValid() *AppError {
+
+ if len(o.Id) != 26 {
+ return NewAppError("OutgoingWebhook.IsValid", "Invalid Id", "")
+ }
+
+ if len(o.Token) != 26 {
+ return NewAppError("OutgoingWebhook.IsValid", "Invalid token", "")
+ }
+
+ if o.CreateAt == 0 {
+ return NewAppError("OutgoingWebhook.IsValid", "Create at must be a valid time", "id="+o.Id)
+ }
+
+ if o.UpdateAt == 0 {
+ return NewAppError("OutgoingWebhook.IsValid", "Update at must be a valid time", "id="+o.Id)
+ }
+
+ if len(o.CreatorId) != 26 {
+ return NewAppError("OutgoingWebhook.IsValid", "Invalid user id", "")
+ }
+
+ if len(o.ChannelId) != 0 && len(o.ChannelId) != 26 {
+ return NewAppError("OutgoingWebhook.IsValid", "Invalid channel id", "")
+ }
+
+ if len(o.TeamId) != 26 {
+ return NewAppError("OutgoingWebhook.IsValid", "Invalid team id", "")
+ }
+
+ if len(fmt.Sprintf("%s", o.TriggerWords)) > 1024 {
+ return NewAppError("OutgoingWebhook.IsValid", "Invalid trigger words", "")
+ }
+
+ if len(o.CallbackURLs) == 0 || len(fmt.Sprintf("%s", o.CallbackURLs)) > 1024 {
+ return NewAppError("OutgoingWebhook.IsValid", "Invalid callback urls", "")
+ }
+
+ return nil
+}
+
+func (o *OutgoingWebhook) PreSave() {
+ if o.Id == "" {
+ o.Id = NewId()
+ }
+
+ if o.Token == "" {
+ o.Token = NewId()
+ }
+
+ o.CreateAt = GetMillis()
+ o.UpdateAt = o.CreateAt
+}
+
+func (o *OutgoingWebhook) PreUpdate() {
+ o.UpdateAt = GetMillis()
+}
+
+func (o *OutgoingWebhook) HasTriggerWord(word string) bool {
+ if len(o.TriggerWords) == 0 || len(word) == 0 {
+ return false
+ }
+
+ for _, trigger := range o.TriggerWords {
+ if trigger == word {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/model/outgoing_webhook_test.go b/model/outgoing_webhook_test.go
new file mode 100644
index 000000000..2ca48c291
--- /dev/null
+++ b/model/outgoing_webhook_test.go
@@ -0,0 +1,97 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package model
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestOutgoingWebhookJson(t *testing.T) {
+ o := OutgoingWebhook{Id: NewId()}
+ json := o.ToJson()
+ ro := OutgoingWebhookFromJson(strings.NewReader(json))
+
+ if o.Id != ro.Id {
+ t.Fatal("Ids do not match")
+ }
+}
+
+func TestOutgoingWebhookIsValid(t *testing.T) {
+ o := OutgoingWebhook{}
+
+ 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.CreatorId = "123"
+ if err := o.IsValid(); err == nil {
+ t.Fatal("should be invalid")
+ }
+
+ o.CreatorId = NewId()
+ if err := o.IsValid(); err == nil {
+ t.Fatal("should be invalid")
+ }
+
+ o.Token = "123"
+ if err := o.IsValid(); err == nil {
+ t.Fatal("should be invalid")
+ }
+
+ o.Token = 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("should be invalid")
+ }
+
+ o.CallbackURLs = []string{"http://nowhere.com/"}
+ if err := o.IsValid(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestOutgoingWebhookPreSave(t *testing.T) {
+ o := OutgoingWebhook{}
+ o.PreSave()
+}
+
+func TestOutgoingWebhookPreUpdate(t *testing.T) {
+ o := OutgoingWebhook{}
+ o.PreUpdate()
+}
diff --git a/store/sql_store.go b/store/sql_store.go
index 692ac2664..c5bf840a1 100644
--- a/store/sql_store.go
+++ b/store/sql_store.go
@@ -30,6 +30,11 @@ import (
"github.com/mattermost/platform/utils"
)
+const (
+ INDEX_TYPE_FULL_TEXT = "full_text"
+ INDEX_TYPE_DEFAULT = "default"
+)
+
type SqlStore struct {
master *gorp.DbMap
replicas []*gorp.DbMap
@@ -363,14 +368,14 @@ func (ss SqlStore) RemoveColumnIfExists(tableName string, columnName string) boo
// }
func (ss SqlStore) CreateIndexIfNotExists(indexName string, tableName string, columnName string) {
- ss.createIndexIfNotExists(indexName, tableName, columnName, false)
+ ss.createIndexIfNotExists(indexName, tableName, columnName, INDEX_TYPE_DEFAULT)
}
func (ss SqlStore) CreateFullTextIndexIfNotExists(indexName string, tableName string, columnName string) {
- ss.createIndexIfNotExists(indexName, tableName, columnName, true)
+ ss.createIndexIfNotExists(indexName, tableName, columnName, INDEX_TYPE_FULL_TEXT)
}
-func (ss SqlStore) createIndexIfNotExists(indexName string, tableName string, columnName string, fullText bool) {
+func (ss SqlStore) createIndexIfNotExists(indexName string, tableName string, columnName string, indexType string) {
if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES {
_, err := ss.GetMaster().SelectStr("SELECT $1::regclass", indexName)
@@ -380,7 +385,7 @@ func (ss SqlStore) createIndexIfNotExists(indexName string, tableName string, co
}
query := ""
- if fullText {
+ if indexType == INDEX_TYPE_FULL_TEXT {
query = "CREATE INDEX " + indexName + " ON " + tableName + " USING gin(to_tsvector('english', " + columnName + "))"
} else {
query = "CREATE INDEX " + indexName + " ON " + tableName + " (" + columnName + ")"
@@ -406,7 +411,7 @@ func (ss SqlStore) createIndexIfNotExists(indexName string, tableName string, co
}
fullTextIndex := ""
- if fullText {
+ if indexType == INDEX_TYPE_FULL_TEXT {
fullTextIndex = " FULLTEXT "
}
diff --git a/store/sql_webhook_store.go b/store/sql_webhook_store.go
index 42a91a80e..1910984f0 100644
--- a/store/sql_webhook_store.go
+++ b/store/sql_webhook_store.go
@@ -20,6 +20,15 @@ func NewSqlWebhookStore(sqlStore *SqlStore) WebhookStore {
table.ColMap("UserId").SetMaxSize(26)
table.ColMap("ChannelId").SetMaxSize(26)
table.ColMap("TeamId").SetMaxSize(26)
+
+ tableo := db.AddTableWithName(model.OutgoingWebhook{}, "OutgoingWebhooks").SetKeys(false, "Id")
+ tableo.ColMap("Id").SetMaxSize(26)
+ tableo.ColMap("Token").SetMaxSize(26)
+ tableo.ColMap("CreatorId").SetMaxSize(26)
+ tableo.ColMap("ChannelId").SetMaxSize(26)
+ tableo.ColMap("TeamId").SetMaxSize(26)
+ tableo.ColMap("TriggerWords").SetMaxSize(1024)
+ tableo.ColMap("CallbackURLs").SetMaxSize(1024)
}
return s
@@ -29,8 +38,9 @@ func (s SqlWebhookStore) UpgradeSchemaIfNeeded() {
}
func (s SqlWebhookStore) CreateIndexesIfNotExists() {
- s.CreateIndexIfNotExists("idx_webhook_user_id", "IncomingWebhooks", "UserId")
- s.CreateIndexIfNotExists("idx_webhook_team_id", "IncomingWebhooks", "TeamId")
+ s.CreateIndexIfNotExists("idx_incoming_webhook_user_id", "IncomingWebhooks", "UserId")
+ s.CreateIndexIfNotExists("idx_incoming_webhook_team_id", "IncomingWebhooks", "TeamId")
+ s.CreateIndexIfNotExists("idx_outgoing_webhook_team_id", "OutgoingWebhooks", "TeamId")
}
func (s SqlWebhookStore) SaveIncoming(webhook *model.IncomingWebhook) StoreChannel {
@@ -126,3 +136,160 @@ func (s SqlWebhookStore) GetIncomingByUser(userId string) StoreChannel {
return storeChannel
}
+
+func (s SqlWebhookStore) SaveOutgoing(webhook *model.OutgoingWebhook) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ if len(webhook.Id) > 0 {
+ result.Err = model.NewAppError("SqlWebhookStore.SaveOutgoing",
+ "You cannot overwrite an existing OutgoingWebhook", "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.SaveOutgoing", "We couldn't save the OutgoingWebhook", "id="+webhook.Id+", "+err.Error())
+ } else {
+ result.Data = webhook
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
+func (s SqlWebhookStore) GetOutgoing(id string) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ var webhook model.OutgoingWebhook
+
+ if err := s.GetReplica().SelectOne(&webhook, "SELECT * FROM OutgoingWebhooks WHERE Id = :Id AND DeleteAt = 0", map[string]interface{}{"Id": id}); err != nil {
+ result.Err = model.NewAppError("SqlWebhookStore.GetOutgoing", "We couldn't get the webhook", "id="+id+", err="+err.Error())
+ }
+
+ result.Data = &webhook
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
+func (s SqlWebhookStore) GetOutgoingByCreator(userId string) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ var webhooks []*model.OutgoingWebhook
+
+ if _, err := s.GetReplica().Select(&webhooks, "SELECT * FROM OutgoingWebhooks WHERE CreatorId = :UserId AND DeleteAt = 0", map[string]interface{}{"UserId": userId}); err != nil {
+ result.Err = model.NewAppError("SqlWebhookStore.GetOutgoingByCreator", "We couldn't get the webhooks", "userId="+userId+", err="+err.Error())
+ }
+
+ result.Data = webhooks
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
+func (s SqlWebhookStore) GetOutgoingByChannel(channelId string) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ var webhooks []*model.OutgoingWebhook
+
+ if _, err := s.GetReplica().Select(&webhooks, "SELECT * FROM OutgoingWebhooks WHERE ChannelId = :ChannelId AND DeleteAt = 0", map[string]interface{}{"ChannelId": channelId}); err != nil {
+ result.Err = model.NewAppError("SqlWebhookStore.GetOutgoingByChannel", "We couldn't get the webhooks", "channelId="+channelId+", err="+err.Error())
+ }
+
+ result.Data = webhooks
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
+func (s SqlWebhookStore) GetOutgoingByTeam(teamId string) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ var webhooks []*model.OutgoingWebhook
+
+ if _, err := s.GetReplica().Select(&webhooks, "SELECT * FROM OutgoingWebhooks WHERE TeamId = :TeamId AND DeleteAt = 0", map[string]interface{}{"TeamId": teamId}); err != nil {
+ result.Err = model.NewAppError("SqlWebhookStore.GetOutgoingByTeam", "We couldn't get the webhooks", "teamId="+teamId+", err="+err.Error())
+ }
+
+ result.Data = webhooks
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
+func (s SqlWebhookStore) DeleteOutgoing(webhookId string, time int64) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ _, err := s.GetMaster().Exec("Update OutgoingWebhooks 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.DeleteOutgoing", "We couldn't delete the webhook", "id="+webhookId+", err="+err.Error())
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
+func (s SqlWebhookStore) UpdateOutgoing(hook *model.OutgoingWebhook) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ hook.UpdateAt = model.GetMillis()
+
+ if _, err := s.GetMaster().Update(hook); err != nil {
+ result.Err = model.NewAppError("SqlWebhookStore.UpdateOutgoing", "We couldn't update the webhook", "id="+hook.Id+", "+err.Error())
+ } else {
+ result.Data = hook
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
diff --git a/store/sql_webhook_store_test.go b/store/sql_webhook_store_test.go
index 6f4ef4354..1fb990f3e 100644
--- a/store/sql_webhook_store_test.go
+++ b/store/sql_webhook_store_test.go
@@ -8,7 +8,7 @@ import (
"testing"
)
-func TestIncomingWebhookStoreSaveIncoming(t *testing.T) {
+func TestWebhookStoreSaveIncoming(t *testing.T) {
Setup()
o1 := model.IncomingWebhook{}
@@ -25,7 +25,7 @@ func TestIncomingWebhookStoreSaveIncoming(t *testing.T) {
}
}
-func TestIncomingWebhookStoreGetIncoming(t *testing.T) {
+func TestWebhookStoreGetIncoming(t *testing.T) {
Setup()
o1 := &model.IncomingWebhook{}
@@ -48,7 +48,34 @@ func TestIncomingWebhookStoreGetIncoming(t *testing.T) {
}
}
-func TestIncomingWebhookStoreDelete(t *testing.T) {
+func TestWebhookStoreGetIncomingByUser(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().GetIncomingByUser(o1.UserId); r1.Err != nil {
+ t.Fatal(r1.Err)
+ } else {
+ if r1.Data.([]*model.IncomingWebhook)[0].CreateAt != o1.CreateAt {
+ t.Fatal("invalid returned webhook")
+ }
+ }
+
+ if result := <-store.Webhook().GetIncomingByUser("123"); result.Err != nil {
+ t.Fatal(result.Err)
+ } else {
+ if len(result.Data.([]*model.IncomingWebhook)) != 0 {
+ t.Fatal("no webhooks should have returned")
+ }
+ }
+}
+
+func TestWebhookStoreDeleteIncoming(t *testing.T) {
Setup()
o1 := &model.IncomingWebhook{}
@@ -75,3 +102,176 @@ func TestIncomingWebhookStoreDelete(t *testing.T) {
t.Fatal("Missing id should have failed")
}
}
+
+func TestWebhookStoreSaveOutgoing(t *testing.T) {
+ Setup()
+
+ o1 := model.OutgoingWebhook{}
+ o1.ChannelId = model.NewId()
+ o1.CreatorId = model.NewId()
+ o1.TeamId = model.NewId()
+ o1.CallbackURLs = []string{"http://nowhere.com/"}
+
+ if err := (<-store.Webhook().SaveOutgoing(&o1)).Err; err != nil {
+ t.Fatal("couldn't save item", err)
+ }
+
+ if err := (<-store.Webhook().SaveOutgoing(&o1)).Err; err == nil {
+ t.Fatal("shouldn't be able to update from save")
+ }
+}
+
+func TestWebhookStoreGetOutgoing(t *testing.T) {
+ Setup()
+
+ o1 := &model.OutgoingWebhook{}
+ o1.ChannelId = model.NewId()
+ o1.CreatorId = model.NewId()
+ o1.TeamId = model.NewId()
+ o1.CallbackURLs = []string{"http://nowhere.com/"}
+
+ o1 = (<-store.Webhook().SaveOutgoing(o1)).Data.(*model.OutgoingWebhook)
+
+ if r1 := <-store.Webhook().GetOutgoing(o1.Id); r1.Err != nil {
+ t.Fatal(r1.Err)
+ } else {
+ if r1.Data.(*model.OutgoingWebhook).CreateAt != o1.CreateAt {
+ t.Fatal("invalid returned webhook")
+ }
+ }
+
+ if err := (<-store.Webhook().GetOutgoing("123")).Err; err == nil {
+ t.Fatal("Missing id should have failed")
+ }
+}
+
+func TestWebhookStoreGetOutgoingByChannel(t *testing.T) {
+ Setup()
+
+ o1 := &model.OutgoingWebhook{}
+ o1.ChannelId = model.NewId()
+ o1.CreatorId = model.NewId()
+ o1.TeamId = model.NewId()
+ o1.CallbackURLs = []string{"http://nowhere.com/"}
+
+ o1 = (<-store.Webhook().SaveOutgoing(o1)).Data.(*model.OutgoingWebhook)
+
+ if r1 := <-store.Webhook().GetOutgoingByChannel(o1.ChannelId); r1.Err != nil {
+ t.Fatal(r1.Err)
+ } else {
+ if r1.Data.([]*model.OutgoingWebhook)[0].CreateAt != o1.CreateAt {
+ t.Fatal("invalid returned webhook")
+ }
+ }
+
+ if result := <-store.Webhook().GetOutgoingByChannel("123"); result.Err != nil {
+ t.Fatal(result.Err)
+ } else {
+ if len(result.Data.([]*model.OutgoingWebhook)) != 0 {
+ t.Fatal("no webhooks should have returned")
+ }
+ }
+}
+
+func TestWebhookStoreGetOutgoingByCreator(t *testing.T) {
+ Setup()
+
+ o1 := &model.OutgoingWebhook{}
+ o1.ChannelId = model.NewId()
+ o1.CreatorId = model.NewId()
+ o1.TeamId = model.NewId()
+ o1.CallbackURLs = []string{"http://nowhere.com/"}
+
+ o1 = (<-store.Webhook().SaveOutgoing(o1)).Data.(*model.OutgoingWebhook)
+
+ if r1 := <-store.Webhook().GetOutgoingByCreator(o1.CreatorId); r1.Err != nil {
+ t.Fatal(r1.Err)
+ } else {
+ if r1.Data.([]*model.OutgoingWebhook)[0].CreateAt != o1.CreateAt {
+ t.Fatal("invalid returned webhook")
+ }
+ }
+
+ if result := <-store.Webhook().GetOutgoingByCreator("123"); result.Err != nil {
+ t.Fatal(result.Err)
+ } else {
+ if len(result.Data.([]*model.OutgoingWebhook)) != 0 {
+ t.Fatal("no webhooks should have returned")
+ }
+ }
+}
+
+func TestWebhookStoreGetOutgoingByTeam(t *testing.T) {
+ Setup()
+
+ o1 := &model.OutgoingWebhook{}
+ o1.ChannelId = model.NewId()
+ o1.CreatorId = model.NewId()
+ o1.TeamId = model.NewId()
+ o1.CallbackURLs = []string{"http://nowhere.com/"}
+
+ o1 = (<-store.Webhook().SaveOutgoing(o1)).Data.(*model.OutgoingWebhook)
+
+ if r1 := <-store.Webhook().GetOutgoingByTeam(o1.TeamId); r1.Err != nil {
+ t.Fatal(r1.Err)
+ } else {
+ if r1.Data.([]*model.OutgoingWebhook)[0].CreateAt != o1.CreateAt {
+ t.Fatal("invalid returned webhook")
+ }
+ }
+
+ if result := <-store.Webhook().GetOutgoingByTeam("123"); result.Err != nil {
+ t.Fatal(result.Err)
+ } else {
+ if len(result.Data.([]*model.OutgoingWebhook)) != 0 {
+ t.Fatal("no webhooks should have returned")
+ }
+ }
+}
+
+func TestWebhookStoreDeleteOutgoing(t *testing.T) {
+ Setup()
+
+ o1 := &model.OutgoingWebhook{}
+ o1.ChannelId = model.NewId()
+ o1.CreatorId = model.NewId()
+ o1.TeamId = model.NewId()
+ o1.CallbackURLs = []string{"http://nowhere.com/"}
+
+ o1 = (<-store.Webhook().SaveOutgoing(o1)).Data.(*model.OutgoingWebhook)
+
+ if r1 := <-store.Webhook().GetOutgoing(o1.Id); r1.Err != nil {
+ t.Fatal(r1.Err)
+ } else {
+ if r1.Data.(*model.OutgoingWebhook).CreateAt != o1.CreateAt {
+ t.Fatal("invalid returned webhook")
+ }
+ }
+
+ if r2 := <-store.Webhook().DeleteOutgoing(o1.Id, model.GetMillis()); r2.Err != nil {
+ t.Fatal(r2.Err)
+ }
+
+ if r3 := (<-store.Webhook().GetOutgoing(o1.Id)); r3.Err == nil {
+ t.Log(r3.Data)
+ t.Fatal("Missing id should have failed")
+ }
+}
+
+func TestWebhookStoreUpdateOutgoing(t *testing.T) {
+ Setup()
+
+ o1 := &model.OutgoingWebhook{}
+ o1.ChannelId = model.NewId()
+ o1.CreatorId = model.NewId()
+ o1.TeamId = model.NewId()
+ o1.CallbackURLs = []string{"http://nowhere.com/"}
+
+ o1 = (<-store.Webhook().SaveOutgoing(o1)).Data.(*model.OutgoingWebhook)
+
+ o1.Token = model.NewId()
+
+ if r2 := <-store.Webhook().UpdateOutgoing(o1); r2.Err != nil {
+ t.Fatal(r2.Err)
+ }
+}
diff --git a/store/store.go b/store/store.go
index 7f31325fd..27731cee1 100644
--- a/store/store.go
+++ b/store/store.go
@@ -150,6 +150,13 @@ type WebhookStore interface {
GetIncoming(id string) StoreChannel
GetIncomingByUser(userId string) StoreChannel
DeleteIncoming(webhookId string, time int64) StoreChannel
+ SaveOutgoing(webhook *model.OutgoingWebhook) StoreChannel
+ GetOutgoing(id string) StoreChannel
+ GetOutgoingByCreator(userId string) StoreChannel
+ GetOutgoingByChannel(channelId string) StoreChannel
+ GetOutgoingByTeam(teamId string) StoreChannel
+ DeleteOutgoing(webhookId string, time int64) StoreChannel
+ UpdateOutgoing(hook *model.OutgoingWebhook) StoreChannel
}
type PreferenceStore interface {
diff --git a/utils/apns.go b/utils/apns.go
index 3d07f17ec..06e8ce6ef 100644
--- a/utils/apns.go
+++ b/utils/apns.go
@@ -10,7 +10,7 @@ import (
"github.com/mattermost/platform/model"
)
-func FireAndForgetSendAppleNotify(deviceId string, message string, badge int) {
+func SendAppleNotifyAndForget(deviceId string, message string, badge int) {
go func() {
if err := SendAppleNotify(deviceId, message, badge); err != nil {
l4g.Error(fmt.Sprintf("%v %v", err.Message, err.DetailedError))
diff --git a/utils/config.go b/utils/config.go
index 2c6f30bf0..e3349650b 100644
--- a/utils/config.go
+++ b/utils/config.go
@@ -188,6 +188,7 @@ func getClientProperties(c *model.Config) map[string]string {
props["SegmentDeveloperKey"] = c.ServiceSettings.SegmentDeveloperKey
props["GoogleDeveloperKey"] = c.ServiceSettings.GoogleDeveloperKey
props["EnableIncomingWebhooks"] = strconv.FormatBool(c.ServiceSettings.EnableIncomingWebhooks)
+ props["EnableOutgoingWebhooks"] = strconv.FormatBool(c.ServiceSettings.EnableOutgoingWebhooks)
props["EnablePostUsernameOverride"] = strconv.FormatBool(c.ServiceSettings.EnablePostUsernameOverride)
props["EnablePostIconOverride"] = strconv.FormatBool(c.ServiceSettings.EnablePostIconOverride)
diff --git a/utils/mail.go b/utils/mail.go
index c91c15a6a..07a79eeb2 100644
--- a/utils/mail.go
+++ b/utils/mail.go
@@ -6,15 +6,22 @@ package utils
import (
l4g "code.google.com/p/log4go"
"crypto/tls"
+ "encoding/base64"
"fmt"
"github.com/mattermost/platform/model"
- "html"
"net"
"net/mail"
"net/smtp"
"time"
)
+func encodeRFC2047Word(s string) string {
+ // TODO: use `mime.BEncoding.Encode` instead when `go` >= 1.5
+ // return mime.BEncoding.Encode("utf-8", s)
+ dst := base64.StdEncoding.EncodeToString([]byte(s))
+ return "=?utf-8?b?" + dst + "?="
+}
+
func connectToSMTPServer(config *model.Config) (net.Conn, *model.AppError) {
var conn net.Conn
var err error
@@ -102,9 +109,10 @@ func SendMailUsingConfig(to, subject, body string, config *model.Config) *model.
headers := make(map[string]string)
headers["From"] = fromMail.String()
headers["To"] = toMail.String()
- headers["Subject"] = html.UnescapeString(subject)
+ headers["Subject"] = encodeRFC2047Word(subject)
headers["MIME-version"] = "1.0"
- headers["Content-Type"] = "text/html"
+ headers["Content-Type"] = "text/html; charset=\"utf-8\""
+ headers["Content-Transfer-Encoding"] = "8bit"
headers["Date"] = time.Now().Format(time.RFC1123Z)
message := ""
diff --git a/web/react/components/activity_log_modal.jsx b/web/react/components/activity_log_modal.jsx
index 74d6c64e3..2c944913f 100644
--- a/web/react/components/activity_log_modal.jsx
+++ b/web/react/components/activity_log_modal.jsx
@@ -81,6 +81,7 @@ export default class ActivityLogModal extends React.Component {
const currentSession = this.state.sessions[i];
const lastAccessTime = new Date(currentSession.last_activity_at);
const firstAccessTime = new Date(currentSession.create_at);
+ let devicePlatform = currentSession.props.platform;
let devicePicture = '';
if (currentSession.props.platform === 'Windows') {
@@ -88,7 +89,12 @@ export default class ActivityLogModal extends React.Component {
} else if (currentSession.props.platform === 'Macintosh' || currentSession.props.platform === 'iPhone') {
devicePicture = 'fa fa-apple';
} else if (currentSession.props.platform === 'Linux') {
- devicePicture = 'fa fa-linux';
+ if (currentSession.props.os.indexOf('Android') >= 0) {
+ devicePlatform = 'Android';
+ devicePicture = 'fa fa-android';
+ } else {
+ devicePicture = 'fa fa-linux';
+ }
}
let moreInfo;
@@ -119,7 +125,7 @@ export default class ActivityLogModal extends React.Component {
className='activity-log__table'
>
<div className='activity-log__report'>
- <div className='report__platform'><i className={devicePicture} />{currentSession.props.platform}</div>
+ <div className='report__platform'><i className={devicePicture} />{devicePlatform}</div>
<div className='report__info'>
<div>{`Last activity: ${lastAccessTime.toDateString()}, ${lastAccessTime.toLocaleTimeString()}`}</div>
{moreInfo}
diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx
index f2fb8ac78..f770d166c 100644
--- a/web/react/components/admin_console/admin_controller.jsx
+++ b/web/react/components/admin_console/admin_controller.jsx
@@ -40,9 +40,13 @@ export default class AdminController extends React.Component {
config: AdminStore.getConfig(),
teams: AdminStore.getAllTeams(),
selectedTeams,
- selected: 'service_settings',
- selectedTeam: null
+ selected: props.tab || 'service_settings',
+ selectedTeam: props.teamId || null
};
+
+ if (!props.tab) {
+ history.replaceState(null, null, `/admin_console/${this.state.selected}`);
+ }
}
componentDidMount() {
@@ -142,7 +146,9 @@ export default class AdminController extends React.Component {
} else if (this.state.selected === 'service_settings') {
tab = <ServiceSettingsTab config={this.state.config} />;
} else if (this.state.selected === 'team_users') {
- tab = <TeamUsersTab team={this.state.teams[this.state.selectedTeam]} />;
+ if (this.state.teams) {
+ tab = <TeamUsersTab team={this.state.teams[this.state.selectedTeam]} />;
+ }
}
}
diff --git a/web/react/components/admin_console/admin_sidebar.jsx b/web/react/components/admin_console/admin_sidebar.jsx
index 4c2a473b6..b0e01ff17 100644
--- a/web/react/components/admin_console/admin_sidebar.jsx
+++ b/web/react/components/admin_console/admin_sidebar.jsx
@@ -24,6 +24,7 @@ export default class AdminSidebar extends React.Component {
handleClick(name, teamId, e) {
e.preventDefault();
this.props.selectTab(name, teamId);
+ history.pushState({name: name, teamId: teamId}, null, `/admin_console/${name}/${teamId || ''}`);
}
isSelected(name, teamId) {
@@ -53,6 +54,9 @@ export default class AdminSidebar extends React.Component {
}
componentDidMount() {
+ if ($(window).width() > 768) {
+ $('.nav-pills__container').perfectScrollbar();
+ }
}
showTeamSelect(e) {
diff --git a/web/react/components/admin_console/email_settings.jsx b/web/react/components/admin_console/email_settings.jsx
index 01759b222..40e00ff04 100644
--- a/web/react/components/admin_console/email_settings.jsx
+++ b/web/react/components/admin_console/email_settings.jsx
@@ -440,9 +440,11 @@ export default class EmailSettings extends React.Component {
className='table table-bordered'
cellPadding='5'
>
- <tr><td className='help-text'>{'None'}</td><td className='help-text'>{'Mattermost will send email over an unsecure connection.'}</td></tr>
- <tr><td className='help-text'>{'TLS'}</td><td className='help-text'>{'Encrypts the communication between Mattermost and your email server.'}</td></tr>
- <tr><td className='help-text'>{'STARTTLS'}</td><td className='help-text'>{'Takes an existing insecure connection and attempts to upgrade it to a secure connection using TLS.'}</td></tr>
+ <tbody>
+ <tr><td className='help-text'>{'None'}</td><td className='help-text'>{'Mattermost will send email over an unsecure connection.'}</td></tr>
+ <tr><td className='help-text'>{'TLS'}</td><td className='help-text'>{'Encrypts the communication between Mattermost and your email server.'}</td></tr>
+ <tr><td className='help-text'>{'STARTTLS'}</td><td className='help-text'>{'Takes an existing insecure connection and attempts to upgrade it to a secure connection using TLS.'}</td></tr>
+ </tbody>
</table>
</div>
<div className='help-text'>
diff --git a/web/react/components/admin_console/log_settings.jsx b/web/react/components/admin_console/log_settings.jsx
index 931818bb8..7e9eda89b 100644
--- a/web/react/components/admin_console/log_settings.jsx
+++ b/web/react/components/admin_console/log_settings.jsx
@@ -249,22 +249,24 @@ export default class LogSettings extends React.Component {
onChange={this.handleChange}
disabled={!this.state.fileEnable}
/>
- <p className='help-text'>
+ <div className='help-text'>
{'Format of log message output. If blank will be set to "[%D %T] [%L] %M", where:'}
<div className='help-text'>
<table
className='table table-bordered'
cellPadding='5'
>
- <tr><td className='help-text'>{'%T'}</td><td className='help-text'>{'Time (15:04:05 MST)'}</td></tr>
- <tr><td className='help-text'>{'%D'}</td><td className='help-text'>{'Date (2006/01/02)'}</td></tr>
- <tr><td className='help-text'>{'%d'}</td><td className='help-text'>{'Date (01/02/06)'}</td></tr>
- <tr><td className='help-text'>{'%L'}</td><td className='help-text'>{'Level (DEBG, INFO, EROR)'}</td></tr>
- <tr><td className='help-text'>{'%S'}</td><td className='help-text'>{'Source'}</td></tr>
- <tr><td className='help-text'>{'%M'}</td><td className='help-text'>{'Message'}</td></tr>
+ <tbody>
+ <tr><td className='help-text'>{'%T'}</td><td className='help-text'>{'Time (15:04:05 MST)'}</td></tr>
+ <tr><td className='help-text'>{'%D'}</td><td className='help-text'>{'Date (2006/01/02)'}</td></tr>
+ <tr><td className='help-text'>{'%d'}</td><td className='help-text'>{'Date (01/02/06)'}</td></tr>
+ <tr><td className='help-text'>{'%L'}</td><td className='help-text'>{'Level (DEBG, INFO, EROR)'}</td></tr>
+ <tr><td className='help-text'>{'%S'}</td><td className='help-text'>{'Source'}</td></tr>
+ <tr><td className='help-text'>{'%M'}</td><td className='help-text'>{'Message'}</td></tr>
+ </tbody>
</table>
</div>
- </p>
+ </div>
</div>
</div>
diff --git a/web/react/components/admin_console/service_settings.jsx b/web/react/components/admin_console/service_settings.jsx
index 4105ba6da..53c89a942 100644
--- a/web/react/components/admin_console/service_settings.jsx
+++ b/web/react/components/admin_console/service_settings.jsx
@@ -36,6 +36,7 @@ export default class ServiceSettings extends React.Component {
config.ServiceSettings.SegmentDeveloperKey = ReactDOM.findDOMNode(this.refs.SegmentDeveloperKey).value.trim();
config.ServiceSettings.GoogleDeveloperKey = ReactDOM.findDOMNode(this.refs.GoogleDeveloperKey).value.trim();
config.ServiceSettings.EnableIncomingWebhooks = ReactDOM.findDOMNode(this.refs.EnableIncomingWebhooks).checked;
+ config.ServiceSettings.EnableOutgoingWebhooks = React.findDOMNode(this.refs.EnableOutgoingWebhooks).checked;
config.ServiceSettings.EnablePostUsernameOverride = ReactDOM.findDOMNode(this.refs.EnablePostUsernameOverride).checked;
config.ServiceSettings.EnablePostIconOverride = ReactDOM.findDOMNode(this.refs.EnablePostIconOverride).checked;
config.ServiceSettings.EnableTesting = ReactDOM.findDOMNode(this.refs.EnableTesting).checked;
@@ -207,7 +208,40 @@ export default class ServiceSettings extends React.Component {
</div>
</div>
- <div className='form-group'>
+ <div className='form-group'>
+ <label
+ className='control-label col-sm-4'
+ htmlFor='EnableOutgoingWebhooks'
+ >
+ {'Enable Outgoing Webhooks: '}
+ </label>
+ <div className='col-sm-8'>
+ <label className='radio-inline'>
+ <input
+ type='radio'
+ name='EnableOutgoingWebhooks'
+ value='true'
+ ref='EnableOutgoingWebhooks'
+ defaultChecked={this.props.config.ServiceSettings.EnableOutgoingWebhooks}
+ onChange={this.handleChange}
+ />
+ {'true'}
+ </label>
+ <label className='radio-inline'>
+ <input
+ type='radio'
+ name='EnableOutgoingWebhooks'
+ value='false'
+ defaultChecked={!this.props.config.ServiceSettings.EnableOutgoingWebhooks}
+ onChange={this.handleChange}
+ />
+ {'false'}
+ </label>
+ <p className='help-text'>{'When true, outgoing webhooks will be allowed.'}</p>
+ </div>
+ </div>
+
+ <div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='EnablePostUsernameOverride'
diff --git a/web/react/components/channel_header.jsx b/web/react/components/channel_header.jsx
index 7582de6c4..1b709336f 100644
--- a/web/react/components/channel_header.jsx
+++ b/web/react/components/channel_header.jsx
@@ -4,7 +4,6 @@
const ChannelStore = require('../stores/channel_store.jsx');
const UserStore = require('../stores/user_store.jsx');
const PostStore = require('../stores/post_store.jsx');
-const SocketStore = require('../stores/socket_store.jsx');
const NavbarSearchBox = require('./search_bar.jsx');
const AsyncClient = require('../utils/async_client.jsx');
const Client = require('../utils/client.jsx');
@@ -25,7 +24,6 @@ export default class ChannelHeader extends React.Component {
super(props);
this.onListenerChange = this.onListenerChange.bind(this);
- this.onSocketChange = this.onSocketChange.bind(this);
this.handleLeave = this.handleLeave.bind(this);
this.searchMentions = this.searchMentions.bind(this);
@@ -45,7 +43,6 @@ export default class ChannelHeader extends React.Component {
ChannelStore.addExtraInfoChangeListener(this.onListenerChange);
PostStore.addSearchChangeListener(this.onListenerChange);
UserStore.addChangeListener(this.onListenerChange);
- SocketStore.addChangeListener(this.onSocketChange);
}
componentWillUnmount() {
ChannelStore.removeChangeListener(this.onListenerChange);
@@ -60,16 +57,9 @@ export default class ChannelHeader extends React.Component {
}
$('.channel-header__info .description').popover({placement: 'bottom', trigger: 'hover', html: true, delay: {show: 500, hide: 500}});
}
- onSocketChange(msg) {
- if (msg.action === 'new_user' ||
- msg.action === 'user_added' ||
- (msg.action === 'user_removed' && msg.user_id !== UserStore.getCurrentId())) {
- AsyncClient.getChannelExtraInfo(true);
- }
- }
handleLeave() {
Client.leaveChannel(this.state.channel.id,
- function handleLeaveSuccess() {
+ () => {
AppDispatcher.handleViewAction({
type: ActionTypes.LEAVE_CHANNEL,
id: this.state.channel.id
@@ -77,8 +67,8 @@ export default class ChannelHeader extends React.Component {
const townsquare = ChannelStore.getByName('town-square');
Utils.switchChannel(townsquare);
- }.bind(this),
- function handleLeaveError(err) {
+ },
+ (err) => {
AsyncClient.dispatchError(err, 'handleLeave');
}
);
diff --git a/web/react/components/edit_post_modal.jsx b/web/react/components/edit_post_modal.jsx
index 90d9696e7..b259b3c18 100644
--- a/web/react/components/edit_post_modal.jsx
+++ b/web/react/components/edit_post_modal.jsx
@@ -70,7 +70,7 @@ export default class EditPostModal extends React.Component {
refocusId: options.refocusId || ''
});
- $(React.findDOMNode(this.refs.modal)).modal('show');
+ $(ReactDOM.findDOMNode(this.refs.modal)).modal('show');
}
componentDidMount() {
var self = this;
@@ -92,7 +92,7 @@ export default class EditPostModal extends React.Component {
$('#edit_textbox').get(0).focus();
});
- $(React.findDOMNode(this.refs.modal)).on('hide.bs.modal', function onShown() {
+ $(ReactDOM.findDOMNode(this.refs.modal)).on('hide.bs.modal', function onShown() {
if (self.state.refocusId !== '') {
setTimeout(() => {
$(self.state.refocusId).get(0).focus();
diff --git a/web/react/components/more_channels.jsx b/web/react/components/more_channels.jsx
index a20c5cad5..a0084ad30 100644
--- a/web/react/components/more_channels.jsx
+++ b/web/react/components/more_channels.jsx
@@ -83,7 +83,7 @@ export default class MoreChannels extends React.Component {
moreChannels = <LoadingScreen />;
} else if (channels.length) {
moreChannels = (
- <table className='more-channel-table table'>
+ <table className='more-table table'>
<tbody>
{channels.map(function cMap(channel, index) {
var joinButton;
@@ -108,8 +108,8 @@ export default class MoreChannels extends React.Component {
return (
<tr key={channel.id}>
<td>
- <p className='more-channel-name'>{channel.display_name}</p>
- <p className='more-channel-description'>{channel.description}</p>
+ <p className='more-name'>{channel.display_name}</p>
+ <p className='more-description'>{channel.description}</p>
</td>
<td className='td--action'>
{joinButton}
diff --git a/web/react/components/more_direct_channels.jsx b/web/react/components/more_direct_channels.jsx
index 08b64de8b..d5b44d86b 100644
--- a/web/react/components/more_direct_channels.jsx
+++ b/web/react/components/more_direct_channels.jsx
@@ -142,7 +142,6 @@ export default class MoreDirectChannels extends React.Component {
details.push(
<span
key={`${user.nickname}__nickname`}
- className='nickname'
>
{separator + user.nickname}
</span>
@@ -170,31 +169,40 @@ export default class MoreDirectChannels extends React.Component {
}
return (
- <li
- key={user.id}
- className='direct-channel'
- >
- <div className='col-xs-1 image-div'>
+ <tr>
+ <td
+ key={user.id}
+ className='direct-channel'
+ >
<img
- className='profile-image'
+ className='profile-img pull-left'
+ width='38'
+ height='38'
src={`/api/v1/users/${user.id}/image?time=${user.update_at}`}
/>
- </div>
- <div className='col-xs-9'>
- <div className='username'>
+ <div className='more-name'>
{user.username}
</div>
- <div>
+ <div className='more-description'>
{details}
</div>
- </div>
- <div className='col-xs-2 btn-div'>
+ </td>
+ <td className='td--action lg'>
{joinButton}
- </div>
- </li>
+ </td>
+ </tr>
);
}
+ componentDidUpdate(prevProps) {
+ if (!prevProps.show && this.props.show) {
+ $(ReactDOM.findDOMNode(this.refs.userList)).css('max-height', $(window).height() - 300);
+ if ($(window).width() > 768) {
+ $(ReactDOM.findDOMNode(this.refs.userList)).perfectScrollbar();
+ }
+ }
+ }
+
render() {
if (!this.props.show) {
return null;
@@ -213,7 +221,7 @@ export default class MoreDirectChannels extends React.Component {
const userEntries = users.map(this.createRowForUser);
if (userEntries.length === 0) {
- userEntries.push(<li key='no-users-found'>{'No users found :('}</li>);
+ userEntries.push(<tr key='no-users-found'><td>{'No users found :('}</td></tr>);
}
let memberString = 'Member';
@@ -232,26 +240,35 @@ export default class MoreDirectChannels extends React.Component {
<Modal
className='modal-direct-channels'
show={this.props.show}
- bsSize='large'
onHide={this.handleHide}
>
<Modal.Header closeButton={true}>
- <Modal.Title>{'More Direct Messages'}</Modal.Title>
+ <Modal.Title>{'Direct Messages'}</Modal.Title>
</Modal.Header>
<Modal.Body>
- <div>
- <input
- ref='filter'
- className='form-control filter-textbox'
- placeholder='Search members'
- onInput={this.handleFilterChange}
- style={{width: '200px', display: 'inline'}}
- />
- <span className='member-count pull-right'>{count}</span>
+ <div className='row filter-row'>
+ <div className='col-sm-6'>
+ <input
+ ref='filter'
+ className='form-control filter-textbox'
+ placeholder='Search members'
+ onInput={this.handleFilterChange}
+ />
+ </div>
+ <div className='col-sm-6'>
+ <span className='member-count'>{count}</span>
+ </div>
+ </div>
+ <div
+ ref='userList'
+ className='user-list'
+ >
+ <table className='more-table table'>
+ <tbody>
+ {userEntries}
+ </tbody>
+ </table>
</div>
- <ul className='user-list'>
- {userEntries}
- </ul>
</Modal.Body>
<Modal.Footer>
<button
diff --git a/web/react/components/msg_typing.jsx b/web/react/components/msg_typing.jsx
index 569942390..1bd23c55c 100644
--- a/web/react/components/msg_typing.jsx
+++ b/web/react/components/msg_typing.jsx
@@ -1,8 +1,11 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-var SocketStore = require('../stores/socket_store.jsx');
-var UserStore = require('../stores/user_store.jsx');
+const SocketStore = require('../stores/socket_store.jsx');
+const UserStore = require('../stores/user_store.jsx');
+
+const Constants = require('../utils/constants.jsx');
+const SocketEvents = Constants.SocketEvents;
export default class MsgTyping extends React.Component {
constructor(props) {
@@ -33,9 +36,9 @@ export default class MsgTyping extends React.Component {
}
onChange(msg) {
- if (msg.action === 'typing' &&
- this.props.channelId === msg.channel_id &&
- this.props.parentId === msg.props.parent_id) {
+ if (msg.action === SocketEvents.TYPING &&
+ this.props.channelId === msg.channel_id &&
+ this.props.parentId === msg.props.parent_id) {
this.lastTime = new Date().getTime();
var username = 'Someone';
@@ -52,7 +55,7 @@ export default class MsgTyping extends React.Component {
}
}.bind(this), 3000);
}
- } else if (msg.action === 'posted' && msg.channel_id === this.props.channelId) {
+ } else if (msg.action === SocketEvents.POSTED && msg.channel_id === this.props.channelId) {
this.setState({text: ''});
}
}
diff --git a/web/react/components/popover_list_members.jsx b/web/react/components/popover_list_members.jsx
index 16ae693fa..155e88600 100644
--- a/web/react/components/popover_list_members.jsx
+++ b/web/react/components/popover_list_members.jsx
@@ -35,13 +35,20 @@ export default class PopoverListMembers extends React.Component {
const teamMembers = UserStore.getProfilesUsernameMap();
if (members && teamMembers) {
- members.sort(function compareByLocal(a, b) {
+ members.sort((a, b) => {
return a.username.localeCompare(b.username);
});
- members.forEach(function addMemberElement(m) {
+ members.forEach((m, i) => {
if (teamMembers[m.username] && teamMembers[m.username].delete_at <= 0) {
- popoverHtml.push(<div className='text--nowrap'>{m.username}</div>);
+ popoverHtml.push(
+ <div
+ className='text--nowrap'
+ key={'popover-member-' + i}
+ >
+ {m.username}
+ </div>
+ );
count++;
}
});
@@ -57,8 +64,15 @@ export default class PopoverListMembers extends React.Component {
<OverlayTrigger
trigger='click'
placement='bottom'
- rootClose='true'
- overlay={<Popover title='Members'>{popoverHtml}</Popover>}
+ rootClose={true}
+ overlay={
+ <Popover
+ title='Members'
+ id='member-list-popover'
+ >
+ {popoverHtml}
+ </Popover>
+ }
>
<div id='member_popover'>
<div>
diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx
index 1db0b12e7..fb838b736 100644
--- a/web/react/components/post_body.jsx
+++ b/web/react/components/post_body.jsx
@@ -116,7 +116,7 @@ export default class PostBody extends React.Component {
}
var metadata = data.items[0].snippet;
this.receivedYoutubeData = true;
- this.setState({youtubeUploader: metadata.channelTitle, youtubeTitle: metadata.title});
+ this.setState({youtubeTitle: metadata.title});
}
if (global.window.config.GoogleDeveloperKey && !this.receivedYoutubeData) {
@@ -134,18 +134,12 @@ export default class PostBody extends React.Component {
header = header + ' - ';
}
- let uploader = this.state.youtubeUploader;
- if (!uploader) {
- uploader = 'unknown';
- }
-
return (
<div className='post-comment'>
<h4>
<span className='video-type'>{header}</span>
<span className='video-title'><a href={link}>{this.state.youtubeTitle}</a></span>
</h4>
- <h4 className='video-uploader'>{uploader}</h4>
<div
className='video-div embed-responsive-item'
id={youtubeId}
diff --git a/web/react/components/post_info.jsx b/web/react/components/post_info.jsx
index a95095ff6..36260d77c 100644
--- a/web/react/components/post_info.jsx
+++ b/web/react/components/post_info.jsx
@@ -150,7 +150,7 @@ export default class PostInfo extends React.Component {
<ul className='post-header post-info'>
<li className='post-header-col'>
<OverlayTrigger
- delayShow='500'
+ delayShow={500}
container={this}
placement='top'
overlay={tooltip}
diff --git a/web/react/components/post_list.jsx b/web/react/components/post_list.jsx
index 29728d368..4402745e1 100644
--- a/web/react/components/post_list.jsx
+++ b/web/react/components/post_list.jsx
@@ -1,20 +1,24 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-var PostStore = require('../stores/post_store.jsx');
-var ChannelStore = require('../stores/channel_store.jsx');
-var UserStore = require('../stores/user_store.jsx');
-var PreferenceStore = require('../stores/preference_store.jsx');
-var UserProfile = require('./user_profile.jsx');
-var AsyncClient = require('../utils/async_client.jsx');
-var Post = require('./post.jsx');
-var LoadingScreen = require('./loading_screen.jsx');
-var SocketStore = require('../stores/socket_store.jsx');
-var utils = require('../utils/utils.jsx');
-var Client = require('../utils/client.jsx');
-var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
-var Constants = require('../utils/constants.jsx');
-var ActionTypes = Constants.ActionTypes;
+const Post = require('./post.jsx');
+const UserProfile = require('./user_profile.jsx');
+const AsyncClient = require('../utils/async_client.jsx');
+const LoadingScreen = require('./loading_screen.jsx');
+
+const PostStore = require('../stores/post_store.jsx');
+const ChannelStore = require('../stores/channel_store.jsx');
+const UserStore = require('../stores/user_store.jsx');
+const SocketStore = require('../stores/socket_store.jsx');
+const PreferenceStore = require('../stores/preference_store.jsx');
+
+const utils = require('../utils/utils.jsx');
+const Client = require('../utils/client.jsx');
+const Constants = require('../utils/constants.jsx');
+const ActionTypes = Constants.ActionTypes;
+const SocketEvents = Constants.SocketEvents;
+
+const AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
export default class PostList extends React.Component {
constructor(props) {
@@ -58,7 +62,7 @@ export default class PostList extends React.Component {
}
}
- postList.order.sort(function postSort(a, b) {
+ postList.order.sort((a, b) => {
if (postList.posts[a].create_at > postList.posts[b].create_at) {
return -1;
}
@@ -82,7 +86,7 @@ export default class PostList extends React.Component {
}
return {
- postList: postList
+ postList
};
}
componentDidMount() {
@@ -263,14 +267,14 @@ export default class PostList extends React.Component {
Client.getPosts(
id,
PostStore.getLatestUpdate(id),
- function success() {
+ () => {
this.loadInProgress = false;
this.setState({isFirstLoadComplete: true});
- }.bind(this),
- function fail() {
+ },
+ () => {
this.loadInProgress = false;
this.setState({isFirstLoadComplete: true});
- }.bind(this)
+ }
);
}
onChange() {
@@ -281,28 +285,16 @@ export default class PostList extends React.Component {
}
}
onSocketChange(msg) {
- var post;
- if (msg.action === 'posted' || msg.action === 'post_edited') {
- post = JSON.parse(msg.props.post);
- PostStore.storePost(post);
- } else if (msg.action === 'post_deleted') {
+ if (msg.action === SocketEvents.POST_DELETED) {
var activeRoot = $(document.activeElement).closest('.comment-create-body')[0];
var activeRootPostId = '';
if (activeRoot && activeRoot.id.length > 0) {
activeRootPostId = activeRoot.id;
}
- post = JSON.parse(msg.props.post);
-
- PostStore.storeUnseenDeletedPost(post);
- PostStore.removePost(post, true);
- PostStore.emitChange();
-
if (activeRootPostId === msg.props.post_id && UserStore.getCurrentId() !== msg.user_id) {
$('#post_deleted').modal('show');
}
- } else if (msg.action === 'new_user') {
- AsyncClient.getProfiles();
}
}
onTimeChange() {
@@ -352,7 +344,7 @@ export default class PostList extends React.Component {
data-title={channel.display_name}
data-channelid={channel.id}
>
- <i className='fa fa-pencil'></i>Set a description
+ <i className='fa fa-pencil'></i>{'Set a description'}
</a>
</div>
);
diff --git a/web/react/components/rhs_comment.jsx b/web/react/components/rhs_comment.jsx
index 402e64080..d3a4cfaeb 100644
--- a/web/react/components/rhs_comment.jsx
+++ b/web/react/components/rhs_comment.jsx
@@ -29,7 +29,7 @@ export default class RhsComment extends React.Component {
var post = this.props.post;
Client.createPost(post, post.channel_id,
- function success(data) {
+ (data) => {
AsyncClient.getPosts(post.channel_id);
var channel = ChannelStore.get(post.channel_id);
@@ -43,11 +43,11 @@ export default class RhsComment extends React.Component {
post: data
});
},
- function fail() {
+ () => {
post.state = Constants.POST_FAILED;
PostStore.updatePendingPost(post);
this.forceUpdate();
- }.bind(this)
+ }
);
post.state = Constants.POST_LOADING;
@@ -84,7 +84,10 @@ export default class RhsComment extends React.Component {
if (isOwner) {
dropdownContents.push(
- <li role='presentation'>
+ <li
+ role='presentation'
+ key='edit-button'
+ >
<a
href='#'
role='menuitem'
@@ -95,7 +98,7 @@ export default class RhsComment extends React.Component {
data-postid={post.id}
data-channelid={post.channel_id}
>
- Edit
+ {'Edit'}
</a>
</li>
);
@@ -103,7 +106,10 @@ export default class RhsComment extends React.Component {
if (isOwner || isAdmin) {
dropdownContents.push(
- <li role='presentation'>
+ <li
+ role='presentation'
+ key='delete-button'
+ >
<a
href='#'
role='menuitem'
@@ -114,7 +120,7 @@ export default class RhsComment extends React.Component {
data-channelid={post.channel_id}
data-comments={0}
>
- Delete
+ {'Delete'}
</a>
</li>
);
@@ -162,7 +168,7 @@ export default class RhsComment extends React.Component {
href='#'
onClick={this.retryComment}
>
- Retry
+ {'Retry'}
</a>
);
} else if (post.state === Constants.POST_LOADING) {
@@ -213,14 +219,14 @@ export default class RhsComment extends React.Component {
</li>
</ul>
<div className='post-body'>
- <p className={postClass}>
+ <div className={postClass}>
{loading}
<div
ref='message_holder'
onClick={TextFormatting.handleClick}
dangerouslySetInnerHTML={{__html: TextFormatting.formatText(post.message)}}
/>
- </p>
+ </div>
{fileAttachment}
</div>
</div>
diff --git a/web/react/components/sidebar.jsx b/web/react/components/sidebar.jsx
index 89506c028..f88522fb9 100644
--- a/web/react/components/sidebar.jsx
+++ b/web/react/components/sidebar.jsx
@@ -2,7 +2,6 @@
// See License.txt for license information.
const AsyncClient = require('../utils/async_client.jsx');
-const BrowserStore = require('../stores/browser_store.jsx');
const ChannelStore = require('../stores/channel_store.jsx');
const Client = require('../utils/client.jsx');
const Constants = require('../utils/constants.jsx');
@@ -11,7 +10,6 @@ const NewChannelFlow = require('./new_channel_flow.jsx');
const MoreDirectChannels = require('./more_direct_channels.jsx');
const SearchBox = require('./search_bar.jsx');
const SidebarHeader = require('./sidebar_header.jsx');
-const SocketStore = require('../stores/socket_store.jsx');
const TeamStore = require('../stores/team_store.jsx');
const UnreadChannelIndicator = require('./unread_channel_indicator.jsx');
const UserStore = require('../stores/user_store.jsx');
@@ -46,7 +44,7 @@ export default class Sidebar extends React.Component {
const state = this.getStateFromStores();
state.newChannelModalType = '';
- state.showMoreDirectChannelsModal = false;
+ state.showDirectChannelsModal = false;
state.loadingDMChannel = -1;
this.state = state;
@@ -129,10 +127,11 @@ export default class Sidebar extends React.Component {
UserStore.addChangeListener(this.onChange);
UserStore.addStatusesChangeListener(this.onChange);
TeamStore.addChangeListener(this.onChange);
- SocketStore.addChangeListener(this.onSocketChange);
PreferenceStore.addChangeListener(this.onChange);
- $('.nav-pills__container').perfectScrollbar();
+ if ($(window).width() > 768) {
+ $('.nav-pills__container').perfectScrollbar();
+ }
this.updateTitle();
this.updateUnreadIndicators();
@@ -160,7 +159,6 @@ export default class Sidebar extends React.Component {
UserStore.removeChangeListener(this.onChange);
UserStore.removeStatusesChangeListener(this.onChange);
TeamStore.removeChangeListener(this.onChange);
- SocketStore.removeChangeListener(this.onSocketChange);
PreferenceStore.removeChangeListener(this.onChange);
}
onChange() {
@@ -169,94 +167,6 @@ export default class Sidebar extends React.Component {
this.setState(newState);
}
}
- onSocketChange(msg) {
- if (msg.action === 'posted') {
- if (ChannelStore.getCurrentId() === msg.channel_id) {
- if (window.isActive) {
- AsyncClient.updateLastViewedAt();
- }
- } else {
- AsyncClient.getChannels();
- }
-
- if (UserStore.getCurrentId() !== msg.user_id) {
- var mentions = [];
- if (msg.props.mentions) {
- mentions = JSON.parse(msg.props.mentions);
- }
- var channel = ChannelStore.get(msg.channel_id);
-
- const user = UserStore.getCurrentUser();
- const member = ChannelStore.getMember(msg.channel_id);
-
- var notifyLevel = member && member.notify_props ? member.notify_props.desktop : 'default';
- if (notifyLevel === 'default') {
- notifyLevel = user.notify_props.desktop;
- }
-
- if (notifyLevel === 'none') {
- return;
- } else if (notifyLevel === 'mention' && mentions.indexOf(user.id) === -1 && channel.type !== 'D') {
- return;
- }
-
- var username = 'Someone';
- if (UserStore.hasProfile(msg.user_id)) {
- username = UserStore.getProfile(msg.user_id).username;
- }
-
- var title = 'Posted';
- if (channel) {
- title = channel.display_name;
- }
-
- var repRegex = new RegExp('<br>', 'g');
- var post = JSON.parse(msg.props.post);
- var msgProps = msg.props;
- var notifyText = post.message.replace(repRegex, '\n').replace(/\n+/g, ' ').replace('<mention>', '').replace('</mention>', '');
-
- if (notifyText.length > 50) {
- notifyText = notifyText.substring(0, 49) + '...';
- }
-
- if (notifyText.length === 0) {
- if (msgProps.image) {
- Utils.notifyMe(title, username + ' uploaded an image', channel);
- } else if (msgProps.otherFile) {
- Utils.notifyMe(title, username + ' uploaded a file', channel);
- } else {
- Utils.notifyMe(title, username + ' did something new', channel);
- }
- } else {
- Utils.notifyMe(title, username + ' wrote: ' + notifyText, channel);
- }
- if (!user.notify_props || user.notify_props.desktop_sound === 'true') {
- Utils.ding();
- }
- }
- } else if (msg.action === 'viewed') {
- if (ChannelStore.getCurrentId() !== msg.channel_id && UserStore.getCurrentId() === msg.user_id) {
- AsyncClient.getChannel(msg.channel_id);
- }
- } else if (msg.action === 'user_added') {
- if (UserStore.getCurrentId() === msg.user_id) {
- AsyncClient.getChannel(msg.channel_id);
- }
- } else if (msg.action === 'user_removed') {
- if (msg.user_id === UserStore.getCurrentId()) {
- AsyncClient.getChannels(true);
-
- if (msg.props.remover !== msg.user_id && msg.props.channel_id === ChannelStore.getCurrentId() && $('#removed_from_channel').length > 0) {
- var sentState = {};
- sentState.channelName = ChannelStore.getCurrent().display_name;
- sentState.remover = UserStore.getProfile(msg.props.remover).username;
-
- BrowserStore.setItem('channel-removed-state', sentState);
- $('#removed_from_channel').modal('show');
- }
- }
- }
- }
updateTitle() {
const channel = ChannelStore.getCurrent();
if (channel) {
@@ -471,11 +381,13 @@ export default class Sidebar extends React.Component {
}
let closeButton = null;
- const removeTooltip = <Tooltip>{'Remove from list'}</Tooltip>;
+ const removeTooltip = (
+ <Tooltip id='remove-dm-tooltip'>{'Remove from list'}</Tooltip>
+ );
if (handleClose && !badge) {
closeButton = (
<OverlayTrigger
- delayShow='1000'
+ delayShow={1000}
placement='top'
overlay={removeTooltip}
>
@@ -564,8 +476,12 @@ export default class Sidebar extends React.Component {
showChannelModal = true;
}
- const createChannelTootlip = <Tooltip>{'Create new channel'}</Tooltip>;
- const createGroupTootlip = <Tooltip>{'Create new group'}</Tooltip>;
+ const createChannelTootlip = (
+ <Tooltip id='new-channel-tooltip' >{'Create new channel'}</Tooltip>
+ );
+ const createGroupTootlip = (
+ <Tooltip id='new-group-tooltip'>{'Create new group'}</Tooltip>
+ );
return (
<div>
@@ -607,7 +523,7 @@ export default class Sidebar extends React.Component {
<h4>
{'Channels'}
<OverlayTrigger
- delayShow='500'
+ delayShow={500}
placement='top'
overlay={createChannelTootlip}
>
@@ -640,7 +556,7 @@ export default class Sidebar extends React.Component {
<h4>
{'Private Groups'}
<OverlayTrigger
- delayShow='500'
+ delayShow={500}
placement='top'
overlay={createGroupTootlip}
>
diff --git a/web/react/components/team_signup_username_page.jsx b/web/react/components/team_signup_username_page.jsx
index fa8a031a0..21e76e2b8 100644
--- a/web/react/components/team_signup_username_page.jsx
+++ b/web/react/components/team_signup_username_page.jsx
@@ -15,7 +15,12 @@ export default class TeamSignupUsernamePage extends React.Component {
}
submitBack(e) {
e.preventDefault();
- this.props.state.wizard = 'send_invites';
+ if (global.window.config.SendEmailNotifications === 'true') {
+ this.props.state.wizard = 'send_invites';
+ } else {
+ this.props.state.wizard = 'team_url';
+ }
+
this.props.updateParent(this.props.state);
}
submitNext(e) {
diff --git a/web/react/components/user_profile.jsx b/web/react/components/user_profile.jsx
index 715161b4f..540331663 100644
--- a/web/react/components/user_profile.jsx
+++ b/web/react/components/user_profile.jsx
@@ -65,22 +65,32 @@ export default class UserProfile extends React.Component {
var dataContent = [];
dataContent.push(
- <img className='user-popover__image'
+ <img
+ className='user-popover__image'
src={'/api/v1/users/' + this.state.profile.id + '/image?time=' + this.state.profile.update_at}
height='128'
width='128'
+ key='user-popover-image'
/>
);
if (!global.window.config.ShowEmailAddress === 'true') {
- dataContent.push(<div className='text-nowrap'>{'Email not shared'}</div>);
+ dataContent.push(
+ <div
+ className='text-nowrap'
+ key='user-popover-no-email'
+ >
+ {'Email not shared'}
+ </div>
+ );
} else {
dataContent.push(
<div
data-toggle='tooltip'
- title="' + this.state.profile.email + '"
+ title={this.state.profile.email}
+ key='user-popover-email'
>
<a
- href="mailto:' + this.state.profile.email + '"
+ href={'mailto:' + this.state.profile.email}
className='text-nowrap text-lowercase user-popover__email'
>
{this.state.profile.email}
@@ -93,15 +103,22 @@ export default class UserProfile extends React.Component {
<OverlayTrigger
trigger='click'
placement='right'
- rootClose='true'
- overlay={<Popover title={this.state.profile.username}>{dataContent}</Popover>}
- >
- <div
- className='user-popover'
- id={'profile_' + this.uniqueId}
+ rootClose={true}
+ overlay={
+ <Popover
+ title={this.state.profile.username}
+ id='user-profile-popover'
+ >
+ {dataContent}
+ </Popover>
+ }
>
- {name}
- </div>
+ <div
+ className='user-popover'
+ id={'profile_' + this.uniqueId}
+ >
+ {name}
+ </div>
</OverlayTrigger>
);
}
diff --git a/web/react/components/user_settings/manage_outgoing_hooks.jsx b/web/react/components/user_settings/manage_outgoing_hooks.jsx
new file mode 100644
index 000000000..e83ae3bd6
--- /dev/null
+++ b/web/react/components/user_settings/manage_outgoing_hooks.jsx
@@ -0,0 +1,262 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var Client = require('../../utils/client.jsx');
+var Constants = require('../../utils/constants.jsx');
+var ChannelStore = require('../../stores/channel_store.jsx');
+var LoadingScreen = require('../loading_screen.jsx');
+
+export default class ManageOutgoingHooks extends React.Component {
+ constructor() {
+ super();
+
+ this.getHooks = this.getHooks.bind(this);
+ this.addNewHook = this.addNewHook.bind(this);
+ this.updateChannelId = this.updateChannelId.bind(this);
+ this.updateTriggerWords = this.updateTriggerWords.bind(this);
+ this.updateCallbackURLs = this.updateCallbackURLs.bind(this);
+
+ this.state = {hooks: [], channelId: '', triggerWords: '', callbackURLs: '', getHooksComplete: false};
+ }
+ componentDidMount() {
+ this.getHooks();
+ }
+ addNewHook(e) {
+ e.preventDefault();
+
+ if ((this.state.channelId === '' && this.state.triggerWords === '') ||
+ this.state.callbackURLs === '') {
+ return;
+ }
+
+ const hook = {};
+ hook.channel_id = this.state.channelId;
+ if (this.state.triggerWords.length !== 0) {
+ hook.trigger_words = this.state.triggerWords.trim().split(',');
+ }
+ hook.callback_urls = this.state.callbackURLs.split('\n');
+
+ Client.addOutgoingHook(
+ hook,
+ (data) => {
+ let hooks = Object.assign([], this.state.hooks);
+ if (!hooks) {
+ hooks = [];
+ }
+ hooks.push(data);
+ this.setState({hooks, serverError: null, channelId: '', triggerWords: '', callbackURLs: ''});
+ },
+ (err) => {
+ this.setState({serverError: err});
+ }
+ );
+ }
+ removeHook(id) {
+ const data = {};
+ data.id = id;
+
+ Client.deleteOutgoingHook(
+ data,
+ () => {
+ const hooks = this.state.hooks;
+ 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});
+ }
+ );
+ }
+ regenToken(id) {
+ const regenData = {};
+ regenData.id = id;
+
+ Client.regenOutgoingHookToken(
+ regenData,
+ (data) => {
+ const hooks = Object.assign([], this.state.hooks);
+ for (let i = 0; i < hooks.length; i++) {
+ if (hooks[i].id === id) {
+ hooks[i] = data;
+ break;
+ }
+ }
+
+ this.setState({hooks, serverError: null});
+ },
+ (err) => {
+ this.setState({serverError: err});
+ }
+ );
+ }
+ getHooks() {
+ Client.listOutgoingHooks(
+ (data) => {
+ if (data) {
+ this.setState({hooks: data, getHooksComplete: true, serverError: null});
+ }
+ },
+ (err) => {
+ this.setState({serverError: err});
+ }
+ );
+ }
+ updateChannelId(e) {
+ this.setState({channelId: e.target.value});
+ }
+ updateTriggerWords(e) {
+ this.setState({triggerWords: e.target.value});
+ }
+ updateCallbackURLs(e) {
+ this.setState({callbackURLs: e.target.value});
+ }
+ render() {
+ let serverError;
+ if (this.state.serverError) {
+ serverError = <label className='has-error'>{this.state.serverError}</label>;
+ }
+
+ const channels = ChannelStore.getAll();
+ const options = [<option value=''>{'--- Select a channel ---'}</option>];
+ channels.forEach((channel) => {
+ if (channel.type === Constants.OPEN_CHANNEL) {
+ options.push(<option value={channel.id}>{channel.name}</option>);
+ }
+ });
+
+ const hooks = [];
+ this.state.hooks.forEach((hook) => {
+ const c = ChannelStore.get(hook.channel_id);
+ let channelDiv;
+ if (c) {
+ channelDiv = (
+ <div className='padding-top'>
+ <strong>{'Channel: '}</strong>{c.name}
+ </div>
+ );
+ }
+
+ let triggerDiv;
+ if (hook.trigger_words && hook.trigger_words.length !== 0) {
+ triggerDiv = (
+ <div className='padding-top'>
+ <strong>{'Trigger Words: '}</strong>{hook.trigger_words.join(', ')}
+ </div>
+ );
+ }
+
+ hooks.push(
+ <div className='font--small'>
+ <div className='padding-top x2 divider-light'></div>
+ <div className='padding-top x2'>
+ <strong>{'URLs: '}</strong><span className='word-break--all'>{hook.callback_urls.join(', ')}</span>
+ </div>
+ {channelDiv}
+ {triggerDiv}
+ <div className='padding-top'>
+ <strong>{'Token: '}</strong>{hook.token}
+ </div>
+ <div className='padding-top'>
+ <a
+ className='text-danger'
+ href='#'
+ onClick={this.regenToken.bind(this, hook.id)}
+ >
+ {'Regen Token'}
+ </a>
+ <span>{' - '}</span>
+ <a
+ className='text-danger'
+ href='#'
+ onClick={this.removeHook.bind(this, hook.id)}
+ >
+ {'Remove'}
+ </a>
+ </div>
+ </div>
+ );
+ });
+
+ let displayHooks;
+ if (!this.state.getHooksComplete) {
+ displayHooks = <LoadingScreen/>;
+ } else if (hooks.length > 0) {
+ displayHooks = hooks;
+ } else {
+ displayHooks = <label>{': None'}</label>;
+ }
+
+ const existingHooks = (
+ <div className='padding-top x2'>
+ <label className='control-label padding-top x2'>{'Existing outgoing webhooks'}</label>
+ {displayHooks}
+ </div>
+ );
+
+ const disableButton = (this.state.channelId === '' && this.state.triggerWords === '') || this.state.callbackURLs === '';
+
+ return (
+ <div key='addOutgoingHook'>
+ <label className='control-label'>{'Add a new outgoing webhook'}</label>
+ <div className='padding-top'>
+ <strong>{'Channel:'}</strong>
+ <select
+ ref='channelName'
+ className='form-control'
+ value={this.state.channelId}
+ onChange={this.updateChannelId}
+ >
+ {options}
+ </select>
+ <span>{'Only public channels can be used'}</span>
+ <br/>
+ <br/>
+ <strong>{'Trigger Words:'}</strong>
+ <input
+ ref='triggerWords'
+ className='form-control'
+ value={this.state.triggerWords}
+ onChange={this.updateTriggerWords}
+ placeholder='Optional if channel selected'
+ />
+ <span>{'Comma separated words to trigger on'}</span>
+ <br/>
+ <br/>
+ <strong>{'Callback URLs:'}</strong>
+ <textarea
+ ref='callbackURLs'
+ className='form-control no-resize'
+ value={this.state.callbackURLs}
+ resize={false}
+ rows={3}
+ onChange={this.updateCallbackURLs}
+ />
+ <span>{'New line separated URLs that will receive the HTTP POST event'}</span>
+ {serverError}
+ <div className='padding-top'>
+ <a
+ className={'btn btn-sm btn-primary'}
+ href='#'
+ disabled={disableButton}
+ onClick={this.addNewHook}
+ >
+ {'Add'}
+ </a>
+ </div>
+ </div>
+ {existingHooks}
+ </div>
+ );
+ }
+}
diff --git a/web/react/components/user_settings/user_settings_appearance.jsx b/web/react/components/user_settings/user_settings_appearance.jsx
index 7f363e92e..8c62a189d 100644
--- a/web/react/components/user_settings/user_settings_appearance.jsx
+++ b/web/react/components/user_settings/user_settings_appearance.jsx
@@ -152,9 +152,8 @@ export default class UserSettingsAppearance extends React.Component {
<input type='radio'
checked={!displayCustom}
onChange={this.updateType.bind(this, 'premade')}
- >
- {'Theme Colors'}
- </input>
+ />
+ {'Theme Colors'}
</label>
<br/>
</div>
@@ -164,9 +163,8 @@ export default class UserSettingsAppearance extends React.Component {
<input type='radio'
checked={displayCustom}
onChange={this.updateType.bind(this, 'custom')}
- >
- {'Custom Theme'}
- </input>
+ />
+ {'Custom Theme'}
</label>
<br/>
</div>
diff --git a/web/react/components/user_settings/user_settings_display.jsx b/web/react/components/user_settings/user_settings_display.jsx
index ec209c218..22a62273c 100644
--- a/web/react/components/user_settings/user_settings_display.jsx
+++ b/web/react/components/user_settings/user_settings_display.jsx
@@ -1,7 +1,7 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-import { savePreferences } from '../../utils/client.jsx';
+import {savePreferences} from '../../utils/client.jsx';
import SettingItemMin from '../setting_item_min.jsx';
import SettingItemMax from '../setting_item_max.jsx';
import Constants from '../../utils/constants.jsx';
@@ -38,7 +38,7 @@ export default class UserSettingsDisplay extends React.Component {
);
}
handleClockRadio(militaryTime) {
- this.setState({militaryTime: militaryTime});
+ this.setState({militaryTime});
}
updateSection(section) {
this.setState(getDisplayStateFromStores());
@@ -57,7 +57,7 @@ export default class UserSettingsDisplay extends React.Component {
const serverError = this.state.serverError || null;
let clockSection;
if (this.props.activeSection === 'clock') {
- let clockFormat = [false, false];
+ const clockFormat = [false, false];
if (this.state.militaryTime === 'true') {
clockFormat[1] = true;
} else {
@@ -77,9 +77,8 @@ export default class UserSettingsDisplay extends React.Component {
type='radio'
checked={clockFormat[0]}
onChange={this.handleClockRadio.bind(this, 'false')}
- >
- 12-hour clock (example: 4:00 PM)
- </input>
+ />
+ {'12-hour clock (example: 4:00 PM)'}
</label>
<br/>
</div>
@@ -89,9 +88,8 @@ export default class UserSettingsDisplay extends React.Component {
type='radio'
checked={clockFormat[1]}
onChange={this.handleClockRadio.bind(this, 'true')}
- >
- 24-hour clock (example: 16:00)
- </input>
+ />
+ {'24-hour clock (example: 16:00)'}
</label>
<br/>
</div>
@@ -99,7 +97,6 @@ export default class UserSettingsDisplay extends React.Component {
</div>
];
-
clockSection = (
<SettingItemMax
title='Clock Display'
@@ -138,13 +135,13 @@ export default class UserSettingsDisplay extends React.Component {
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>
{'Display Settings'}
</h4>
diff --git a/web/react/components/user_settings/user_settings_integrations.jsx b/web/react/components/user_settings/user_settings_integrations.jsx
index 3be062ad3..231580cc3 100644
--- a/web/react/components/user_settings/user_settings_integrations.jsx
+++ b/web/react/components/user_settings/user_settings_integrations.jsx
@@ -4,6 +4,7 @@
var SettingItemMin = require('../setting_item_min.jsx');
var SettingItemMax = require('../setting_item_max.jsx');
var ManageIncomingHooks = require('./manage_incoming_hooks.jsx');
+var ManageOutgoingHooks = require('./manage_outgoing_hooks.jsx');
export default class UserSettingsIntegrationsTab extends React.Component {
constructor(props) {
@@ -19,6 +20,8 @@ export default class UserSettingsIntegrationsTab extends React.Component {
}
handleClose() {
this.updateSection('');
+ $('.ps-container.modal-body').scrollTop(0);
+ $('.ps-container.modal-body').perfectScrollbar('update');
}
componentDidMount() {
$('#user_settings').on('hidden.bs.modal', this.handleClose);
@@ -28,35 +31,67 @@ export default class UserSettingsIntegrationsTab extends React.Component {
}
render() {
let incomingHooksSection;
+ let outgoingHooksSection;
var inputs = [];
- if (this.props.activeSection === 'incoming-hooks') {
- inputs.push(
- <ManageIncomingHooks />
- );
+ if (global.window.config.EnableIncomingWebhooks === 'true') {
+ if (this.props.activeSection === 'incoming-hooks') {
+ inputs.push(
+ <ManageIncomingHooks />
+ );
- incomingHooksSection = (
- <SettingItemMax
- title='Incoming Webhooks'
- width = 'full'
- inputs={inputs}
- updateSection={function clearSection(e) {
- this.updateSection('');
- e.preventDefault();
- }.bind(this)}
- />
- );
- } else {
- incomingHooksSection = (
- <SettingItemMin
- title='Incoming Webhooks'
- width = 'full'
- describe='Manage your incoming webhooks (Developer feature)'
- updateSection={function updateNameSection() {
- this.updateSection('incoming-hooks');
- }.bind(this)}
- />
- );
+ incomingHooksSection = (
+ <SettingItemMax
+ title='Incoming Webhooks'
+ width = 'full'
+ inputs={inputs}
+ updateSection={(e) => {
+ this.updateSection('');
+ e.preventDefault();
+ }}
+ />
+ );
+ } else {
+ incomingHooksSection = (
+ <SettingItemMin
+ title='Incoming Webhooks'
+ width = 'full'
+ describe='Manage your incoming webhooks (Developer feature)'
+ updateSection={() => {
+ this.updateSection('incoming-hooks');
+ }}
+ />
+ );
+ }
+ }
+
+ if (global.window.config.EnableOutgoingWebhooks === 'true') {
+ if (this.props.activeSection === 'outgoing-hooks') {
+ inputs.push(
+ <ManageOutgoingHooks />
+ );
+
+ outgoingHooksSection = (
+ <SettingItemMax
+ title='Outgoing Webhooks'
+ inputs={inputs}
+ updateSection={(e) => {
+ this.updateSection('');
+ e.preventDefault();
+ }}
+ />
+ );
+ } else {
+ outgoingHooksSection = (
+ <SettingItemMin
+ title='Outgoing Webhooks'
+ describe='Manage your outgoing webhooks'
+ updateSection={() => {
+ this.updateSection('outgoing-hooks');
+ }}
+ />
+ );
+ }
}
return (
@@ -82,6 +117,8 @@ export default class UserSettingsIntegrationsTab extends React.Component {
<h3 className='tab-header'>{'Integration Settings'}</h3>
<div className='divider-dark first'/>
{incomingHooksSection}
+ <div className='divider-light'/>
+ {outgoingHooksSection}
<div className='divider-dark'/>
</div>
</div>
diff --git a/web/react/components/user_settings/user_settings_modal.jsx b/web/react/components/user_settings/user_settings_modal.jsx
index 692fb26ee..44cd423b5 100644
--- a/web/react/components/user_settings/user_settings_modal.jsx
+++ b/web/react/components/user_settings/user_settings_modal.jsx
@@ -38,7 +38,7 @@ 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.EnableIncomingWebhooks === 'true') {
+ if (global.window.config.EnableIncomingWebhooks === 'true' || global.window.config.EnableOutgoingWebhooks === 'true') {
tabs.push({name: 'integrations', uiName: 'Integrations', icon: 'glyphicon glyphicon-transfer'});
}
tabs.push({name: 'display', uiName: 'Display', icon: 'glyphicon glyphicon-eye-open'});
diff --git a/web/react/components/user_settings/user_settings_notifications.jsx b/web/react/components/user_settings/user_settings_notifications.jsx
index 4dbb9b96f..8693af494 100644
--- a/web/react/components/user_settings/user_settings_notifications.jsx
+++ b/web/react/components/user_settings/user_settings_notifications.jsx
@@ -228,9 +228,8 @@ export default class NotificationsTab extends React.Component {
<input type='radio'
checked={notifyActive[0]}
onChange={this.handleNotifyRadio.bind(this, 'all')}
- >
- For all activity
- </input>
+ />
+ {'For all activity'}
</label>
<br/>
</div>
@@ -240,9 +239,8 @@ export default class NotificationsTab extends React.Component {
type='radio'
checked={notifyActive[1]}
onChange={this.handleNotifyRadio.bind(this, 'mention')}
- >
- Only for mentions and direct messages
- </input>
+ />
+ {'Only for mentions and direct messages'}
</label>
<br/>
</div>
@@ -252,9 +250,8 @@ export default class NotificationsTab extends React.Component {
type='radio'
checked={notifyActive[2]}
onChange={this.handleNotifyRadio.bind(this, 'none')}
- >
- Never
- </input>
+ />
+ {'Never'}
</label>
</div>
</div>
@@ -320,9 +317,8 @@ export default class NotificationsTab extends React.Component {
type='radio'
checked={soundActive[0]}
onChange={this.handleSoundRadio.bind(this, 'true')}
- >
- On
- </input>
+ />
+ {'On'}
</label>
<br/>
</div>
@@ -332,9 +328,8 @@ export default class NotificationsTab extends React.Component {
type='radio'
checked={soundActive[1]}
onChange={this.handleSoundRadio.bind(this, 'false')}
- >
- Off
- </input>
+ />
+ {'Off'}
</label>
<br/>
</div>
@@ -402,9 +397,8 @@ export default class NotificationsTab extends React.Component {
type='radio'
checked={emailActive[0]}
onChange={this.handleEmailRadio.bind(this, 'true')}
- >
- On
- </input>
+ />
+ {'On'}
</label>
<br/>
</div>
@@ -414,9 +408,8 @@ export default class NotificationsTab extends React.Component {
type='radio'
checked={emailActive[1]}
onChange={this.handleEmailRadio.bind(this, 'false')}
- >
- Off
- </input>
+ />
+ {'Off'}
</label>
<br/>
</div>
@@ -482,9 +475,8 @@ export default class NotificationsTab extends React.Component {
type='checkbox'
checked={this.state.firstNameKey}
onChange={handleUpdateFirstNameKey}
- >
- {'Your case sensitive first name "' + user.first_name + '"'}
- </input>
+ />
+ {'Your case sensitive first name "' + user.first_name + '"'}
</label>
</div>
</div>
@@ -502,9 +494,8 @@ export default class NotificationsTab extends React.Component {
type='checkbox'
checked={this.state.usernameKey}
onChange={handleUpdateUsernameKey}
- >
- {'Your non-case sensitive username "' + user.username + '"'}
- </input>
+ />
+ {'Your non-case sensitive username "' + user.username + '"'}
</label>
</div>
</div>
@@ -521,9 +512,8 @@ export default class NotificationsTab extends React.Component {
type='checkbox'
checked={this.state.mentionKey}
onChange={handleUpdateMentionKey}
- >
- {'Your username mentioned "@' + user.username + '"'}
- </input>
+ />
+ {'Your username mentioned "@' + user.username + '"'}
</label>
</div>
</div>
@@ -540,9 +530,8 @@ export default class NotificationsTab extends React.Component {
type='checkbox'
checked={this.state.allKey}
onChange={handleUpdateAllKey}
- >
- {'Team-wide mentions "@all"'}
- </input>
+ />
+ {'Team-wide mentions "@all"'}
</label>
</div>
</div>
@@ -559,9 +548,8 @@ export default class NotificationsTab extends React.Component {
type='checkbox'
checked={this.state.channelKey}
onChange={handleUpdateChannelKey}
- >
- {'Channel-wide mentions "@channel"'}
- </input>
+ />
+ {'Channel-wide mentions "@channel"'}
</label>
</div>
</div>
@@ -576,9 +564,8 @@ export default class NotificationsTab extends React.Component {
type='checkbox'
checked={this.state.customKeysChecked}
onChange={this.updateCustomMentionKeys}
- >
- {'Other non-case sensitive words, separated by commas:'}
- </input>
+ />
+ {'Other non-case sensitive words, separated by commas:'}
</label>
</div>
<input
diff --git a/web/react/components/view_image.jsx b/web/react/components/view_image.jsx
index f75693470..322e68c17 100644
--- a/web/react/components/view_image.jsx
+++ b/web/react/components/view_image.jsx
@@ -6,6 +6,7 @@ const Utils = require('../utils/utils.jsx');
const Constants = require('../utils/constants.jsx');
const ViewImagePopoverBar = require('./view_image_popover_bar.jsx');
const Modal = ReactBootstrap.Modal;
+const KeyCodes = Constants.KeyCodes;
export default class ViewImageModal extends React.Component {
constructor(props) {
@@ -63,11 +64,11 @@ export default class ViewImageModal extends React.Component {
this.loadImage(id);
}
handleKeyPress(e) {
- if (!e) {
+ if (!e || !this.props.show) {
return;
- } else if (e.keyCode === 39) {
+ } else if (e.keyCode === KeyCodes.RIGHT) {
this.handleNext();
- } else if (e.keyCode === 37) {
+ } else if (e.keyCode === KeyCodes.LEFT) {
this.handlePrev();
}
}
diff --git a/web/react/pages/admin_console.jsx b/web/react/pages/admin_console.jsx
index c89cb4edc..ea9ae06f4 100644
--- a/web/react/pages/admin_console.jsx
+++ b/web/react/pages/admin_console.jsx
@@ -5,9 +5,12 @@ var ErrorBar = require('../components/error_bar.jsx');
var SelectTeamModal = require('../components/admin_console/select_team_modal.jsx');
var AdminController = require('../components/admin_console/admin_controller.jsx');
-export function setupAdminConsolePage() {
+export function setupAdminConsolePage(props) {
ReactDOM.render(
- <AdminController />,
+ <AdminController
+ tab={props.ActiveTab}
+ teamId={props.TeamId}
+ />,
document.getElementById('admin_controller')
);
diff --git a/web/react/stores/socket_store.jsx b/web/react/stores/socket_store.jsx
index 77e7067ad..77951f214 100644
--- a/web/react/stores/socket_store.jsx
+++ b/web/react/stores/socket_store.jsx
@@ -1,15 +1,22 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
-var UserStore = require('./user_store.jsx');
-var ErrorStore = require('./error_store.jsx');
-var EventEmitter = require('events').EventEmitter;
+const AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+const UserStore = require('./user_store.jsx');
+const PostStore = require('./post_store.jsx');
+const ChannelStore = require('./channel_store.jsx');
+const BrowserStore = require('./browser_store.jsx');
+const ErrorStore = require('./error_store.jsx');
+const EventEmitter = require('events').EventEmitter;
-var Constants = require('../utils/constants.jsx');
-var ActionTypes = Constants.ActionTypes;
+const Utils = require('../utils/utils.jsx');
+const AsyncClient = require('../utils/async_client.jsx');
-var CHANGE_EVENT = 'change';
+const Constants = require('../utils/constants.jsx');
+const ActionTypes = Constants.ActionTypes;
+const SocketEvents = Constants.SocketEvents;
+
+const CHANGE_EVENT = 'change';
var conn;
@@ -94,6 +101,39 @@ class SocketStoreClass extends EventEmitter {
removeChangeListener(callback) {
this.removeListener(CHANGE_EVENT, callback);
}
+ handleMessage(msg) {
+ switch (msg.action) {
+ case SocketEvents.POSTED:
+ handleNewPostEvent(msg);
+ break;
+
+ case SocketEvents.POST_EDITED:
+ handlePostEditEvent(msg);
+ break;
+
+ case SocketEvents.POST_DELETED:
+ handlePostDeleteEvent(msg);
+ break;
+
+ case SocketEvents.NEW_USER:
+ handleNewUserEvent();
+ break;
+
+ case SocketEvents.USER_ADDED:
+ handleUserAddedEvent(msg);
+ break;
+
+ case SocketEvents.USER_REMOVED:
+ handleUserRemovedEvent(msg);
+ break;
+
+ case SocketEvents.CHANNEL_VIEWED:
+ handleChannelViewedEvent(msg);
+ break;
+
+ default:
+ }
+ }
sendMessage(msg) {
if (conn && conn.readyState === WebSocket.OPEN) {
conn.send(JSON.stringify(msg));
@@ -104,6 +144,138 @@ class SocketStoreClass extends EventEmitter {
}
}
+function handleNewPostEvent(msg) {
+ // Store post
+ const post = JSON.parse(msg.props.post);
+ PostStore.storePost(post);
+
+ // Update channel state
+ if (ChannelStore.getCurrentId() === msg.channel_id) {
+ if (window.isActive) {
+ AsyncClient.updateLastViewedAt();
+ }
+ } else {
+ AsyncClient.getChannel(msg.channel_id);
+ }
+
+ // Send desktop notification
+ if (UserStore.getCurrentId() !== msg.user_id) {
+ const msgProps = msg.props;
+
+ let mentions = [];
+ if (msgProps.mentions) {
+ mentions = JSON.parse(msg.props.mentions);
+ }
+
+ const channel = ChannelStore.get(msg.channel_id);
+ const user = UserStore.getCurrentUser();
+ const member = ChannelStore.getMember(msg.channel_id);
+
+ let notifyLevel = member && member.notify_props ? member.notify_props.desktop : 'default';
+ if (notifyLevel === 'default') {
+ notifyLevel = user.notify_props.desktop;
+ }
+
+ if (notifyLevel === 'none') {
+ return;
+ } else if (notifyLevel === 'mention' && mentions.indexOf(user.id) === -1 && channel.type !== 'D') {
+ return;
+ }
+
+ let username = 'Someone';
+ if (UserStore.hasProfile(msg.user_id)) {
+ username = UserStore.getProfile(msg.user_id).username;
+ }
+
+ let title = 'Posted';
+ if (channel) {
+ title = channel.display_name;
+ }
+
+ let notifyText = post.message.replace(/\n+/g, ' ');
+ if (notifyText.length > 50) {
+ notifyText = notifyText.substring(0, 49) + '...';
+ }
+
+ if (notifyText.length === 0) {
+ if (msgProps.image) {
+ Utils.notifyMe(title, username + ' uploaded an image', channel);
+ } else if (msgProps.otherFile) {
+ Utils.notifyMe(title, username + ' uploaded a file', channel);
+ } else {
+ Utils.notifyMe(title, username + ' did something new', channel);
+ }
+ } else {
+ Utils.notifyMe(title, username + ' wrote: ' + notifyText, channel);
+ }
+ if (!user.notify_props || user.notify_props.desktop_sound === 'true') {
+ Utils.ding();
+ }
+ }
+}
+
+function handlePostEditEvent(msg) {
+ // Store post
+ const post = JSON.parse(msg.props.post);
+ PostStore.storePost(post);
+
+ // Update channel state
+ if (ChannelStore.getCurrentId() === msg.channel_id) {
+ if (window.isActive) {
+ AsyncClient.updateLastViewedAt();
+ }
+ }
+}
+
+function handlePostDeleteEvent(msg) {
+ const post = JSON.parse(msg.props.post);
+
+ PostStore.storeUnseenDeletedPost(post);
+ PostStore.removePost(post, true);
+ PostStore.emitChange();
+}
+
+function handleNewUserEvent() {
+ AsyncClient.getProfiles();
+ AsyncClient.getChannelExtraInfo(true);
+}
+
+function handleUserAddedEvent(msg) {
+ if (ChannelStore.getCurrentId() === msg.channel_id) {
+ AsyncClient.getChannelExtraInfo(true);
+ }
+
+ if (UserStore.getCurrentId() === msg.user_id) {
+ AsyncClient.getChannel(msg.channel_id);
+ }
+}
+
+function handleUserRemovedEvent(msg) {
+ if (UserStore.getCurrentId() === msg.user_id) {
+ AsyncClient.getChannels();
+
+ if (msg.props.remover_id !== msg.user_id &&
+ msg.channel_id === ChannelStore.getCurrentId() &&
+ $('#removed_from_channel').length > 0) {
+ var sentState = {};
+ sentState.channelName = ChannelStore.getCurrent().display_name;
+ sentState.remover = UserStore.getProfile(msg.props.remover_id).username;
+
+ BrowserStore.setItem('channel-removed-state', sentState);
+ $('#removed_from_channel').modal('show');
+ }
+ } else if (ChannelStore.getCurrentId() === msg.channel_id) {
+ AsyncClient.getChannelExtraInfo(true);
+ }
+}
+
+function handleChannelViewedEvent(msg) {
+ // Useful for when multiple devices have the app open to different channels
+ if (ChannelStore.getCurrentId() !== msg.channel_id && UserStore.getCurrentId() === msg.user_id) {
+ AsyncClient.getChannel(msg.channel_id);
+ }
+}
+
var SocketStore = new SocketStoreClass();
SocketStore.dispatchToken = AppDispatcher.register((payload) => {
@@ -111,6 +283,7 @@ SocketStore.dispatchToken = AppDispatcher.register((payload) => {
switch (action.type) {
case ActionTypes.RECIEVED_MSG:
+ SocketStore.handleMessage(action.msg);
SocketStore.emitChange(action.msg);
break;
diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx
index f6aee362c..f92633439 100644
--- a/web/react/utils/client.jsx
+++ b/web/react/utils/client.jsx
@@ -1182,3 +1182,61 @@ export function savePreferences(preferences, success, error) {
}
});
}
+
+export function addOutgoingHook(hook, success, error) {
+ $.ajax({
+ url: '/api/v1/hooks/outgoing/create',
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(hook),
+ success,
+ error: (xhr, status, err) => {
+ var e = handleError('addOutgoingHook', xhr, status, err);
+ error(e);
+ }
+ });
+}
+
+export function deleteOutgoingHook(data, success, error) {
+ $.ajax({
+ url: '/api/v1/hooks/outgoing/delete',
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(data),
+ success,
+ error: (xhr, status, err) => {
+ var e = handleError('deleteOutgoingHook', xhr, status, err);
+ error(e);
+ }
+ });
+}
+
+export function listOutgoingHooks(success, error) {
+ $.ajax({
+ url: '/api/v1/hooks/outgoing/list',
+ dataType: 'json',
+ type: 'GET',
+ success,
+ error: (xhr, status, err) => {
+ var e = handleError('listOutgoingHooks', xhr, status, err);
+ error(e);
+ }
+ });
+}
+
+export function regenOutgoingHookToken(data, success, error) {
+ $.ajax({
+ url: '/api/v1/hooks/outgoing/regen_token',
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(data),
+ success,
+ error: (xhr, status, err) => {
+ var e = handleError('regenOutgoingHookToken', xhr, status, err);
+ error(e);
+ }
+ });
+}
diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx
index b7b8d3c60..1d856e067 100644
--- a/web/react/utils/constants.jsx
+++ b/web/react/utils/constants.jsx
@@ -47,6 +47,18 @@ module.exports = {
SERVER_ACTION: null,
VIEW_ACTION: null
}),
+
+ SocketEvents: {
+ POSTED: 'posted',
+ POST_EDITED: 'post_edited',
+ POST_DELETED: 'post_deleted',
+ CHANNEL_VIEWED: 'channel_viewed',
+ NEW_USER: 'new_user',
+ USER_ADDED: 'user_added',
+ USER_REMOVED: 'user_removed',
+ TYPING: 'typing'
+ },
+
SPECIAL_MENTIONS: ['all', 'channel'],
CHARACTER_LIMIT: 4000,
IMAGE_TYPES: ['jpg', 'gif', 'bmp', 'png', 'jpeg'],
@@ -114,6 +126,7 @@ module.exports = {
MONTHS: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
MAX_DMS: 20,
DM_CHANNEL: 'D',
+ OPEN_CHANNEL: 'O',
MAX_POST_LEN: 4000,
EMOJI_SIZE: 16,
ONLINE_ICON_SVG: "<svg version='1.1' id='Layer_1' xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:cc='http://creativecommons.org/ns#' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:svg='http://www.w3.org/2000/svg' xmlns:sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd' xmlns:inkscape='http://www.inkscape.org/namespaces/inkscape' sodipodi:docname='TRASH_1_4.svg' inkscape:version='0.48.4 r9939' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='12px' height='12px' viewBox='0 0 12 12' enable-background='new 0 0 12 12' xml:space='preserve'><sodipodi:namedview inkscape:cy='139.7898' inkscape:cx='26.358185' inkscape:zoom='1.18' showguides='true' showgrid='false' id='namedview6' guidetolerance='10' gridtolerance='10' objecttolerance='10' borderopacity='1' bordercolor='#666666' pagecolor='#ffffff' inkscape:current-layer='Layer_1' inkscape:window-maximized='1' inkscape:window-y='-8' inkscape:window-x='-8' inkscape:window-height='705' inkscape:window-width='1366' inkscape:guide-bbox='true' inkscape:pageshadow='2' inkscape:pageopacity='0'><sodipodi:guide position='50.036793,85.991376' orientation='1,0' id='guide2986'></sodipodi:guide><sodipodi:guide position='58.426196,66.216355' orientation='0,1' id='guide3047'></sodipodi:guide></sodipodi:namedview><g><g><path class='online--icon' d='M6,5.487c1.371,0,2.482-1.116,2.482-2.493c0-1.378-1.111-2.495-2.482-2.495S3.518,1.616,3.518,2.994C3.518,4.371,4.629,5.487,6,5.487z M10.452,8.545c-0.101-0.829-0.36-1.968-0.726-2.541C9.475,5.606,8.5,5.5,8.5,5.5S8.43,7.521,6,7.521C3.507,7.521,3.5,5.5,3.5,5.5S2.527,5.606,2.273,6.004C1.908,6.577,1.648,7.716,1.547,8.545C1.521,8.688,1.49,9.082,1.498,9.142c0.161,1.295,2.238,2.322,4.375,2.358C5.916,11.501,5.958,11.501,6,11.501c0.043,0,0.084,0,0.127-0.001c2.076-0.026,4.214-1.063,4.375-2.358C10.509,9.082,10.471,8.696,10.452,8.545z'/></g></g></svg>",
diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx
index 561c2c4c4..fb0690d66 100644
--- a/web/react/utils/utils.jsx
+++ b/web/react/utils/utils.jsx
@@ -13,7 +13,8 @@ var client = require('./client.jsx');
var Autolinker = require('autolinker');
export function isEmail(email) {
- var regex = /^([a-zA-Z0-9_.+-])+\@(([a-zA-Z0-9-])+\.)+([a-zA-Z0-9]{2,4})+$/;
+ //var regex = /^([a-zA-Z0-9_.+-])+\@(([a-zA-Z0-9-])+\.)+([a-zA-Z0-9]{2,4})+$/;
+ var regex = /^[-a-z0-9~!$%^&*_=+}{\'?]+(\.[-a-z0-9~!$%^&*_=+}{\'?]+)*@([a-z0-9_][-a-z0-9_]*(\.[-a-z0-9_]+)*\.(aero|arpa|biz|com|coop|edu|gov|info|int|mil|museum|name|net|org|pro|travel|mobi|[a-z][a-z])|([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}))(:[0-9]{1,5})?$/i;
return regex.test(email);
}
@@ -518,11 +519,11 @@ export function applyTheme(theme) {
changeCss('.modal .custom-textarea:focus', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.3), 1);
changeCss('.channel-intro, .settings-modal .settings-table .settings-content .divider-dark, hr, .settings-modal .settings-table .settings-links', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1);
changeCss('.post.current--user .post-body, .post.post--comment.other--root.current--user .post-comment, pre', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1);
- changeCss('.post.current--user .post-body, .post.post--comment.other--root.current--user .post-comment, .post.post--comment.other--root .post-comment, .post.same--root .post-body, .modal .more-channel-table tbody>tr td, .member-div:first-child, .member-div, .access-history__table .access__report, .activity-log__table', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.1), 2);
+ changeCss('.post.current--user .post-body, .post.post--comment.other--root.current--user .post-comment, .post.post--comment.other--root .post-comment, .post.same--root .post-body, .modal .more-table tbody>tr td, .member-div:first-child, .member-div, .access-history__table .access__report, .activity-log__table', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.1), 2);
changeCss('@media(max-width: 1440px){.post.same--root', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.07), 2);
changeCss('@media(max-width: 1440px){.post.same--root', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.07), 2);
changeCss('@media(max-width: 1800px){.inner__wrap.move--left .post.post--comment.same--root', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.07), 2);
- changeCss('.post:hover, .modal .more-channel-table tbody>tr:hover td, .sidebar--right .sidebar--right__header, .settings-modal .settings-table .settings-content .section-min:hover', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1);
+ changeCss('.post:hover, .modal .more-table tbody>tr:hover td, .sidebar--right .sidebar--right__header, .settings-modal .settings-table .settings-content .section-min:hover', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1);
changeCss('.date-separator.hovered--before:after, .date-separator.hovered--after:before, .new-separator.hovered--after:before, .new-separator.hovered--before:after', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1);
changeCss('.command-name:hover, .mentions-name:hover, .mentions-focus, .dropdown-menu>li>a:focus, .dropdown-menu>li>a:hover', 'background:' + changeOpacity(theme.centerChannelColor, 0.15), 1);
changeCss('code', 'background:' + changeOpacity(theme.centerChannelColor, 0.1), 1);
diff --git a/web/sass-files/sass/partials/_command-box.scss b/web/sass-files/sass/partials/_command-box.scss
index f1aa4dca2..184fb55eb 100644
--- a/web/sass-files/sass/partials/_command-box.scss
+++ b/web/sass-files/sass/partials/_command-box.scss
@@ -5,6 +5,7 @@
border: $border-gray;
bottom: 38px;
overflow: auto;
+ z-index: 100;
@extend %popover-box-shadow;
.sidebar--right & {
bottom: 100px;
diff --git a/web/sass-files/sass/partials/_modal.scss b/web/sass-files/sass/partials/_modal.scss
index b942a5a40..5570b5ce4 100644
--- a/web/sass-files/sass/partials/_modal.scss
+++ b/web/sass-files/sass/partials/_modal.scss
@@ -140,7 +140,7 @@
padding: 0;
}
}
- .more-channel-table {
+ .more-table {
margin: 0;
table-layout: fixed;
p {
@@ -150,9 +150,11 @@
@include opacity(0.8);
margin: 5px 0;
}
- .more-channel-name {
+ .more-name {
font-weight: 600;
font-size: 0.95em;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
tbody {
> tr {
@@ -175,6 +177,9 @@
padding: 8px 15px 8px 8px;
width: 80px;
vertical-align: middle;
+ &.lg {
+ width: 110px;
+ }
}
}
}
@@ -189,7 +194,7 @@
position: relative;
max-width: 90%;
min-height: 100px;
- min-width: 240px;
+ min-width: 320px;
@include border-radius(3px);
display: table;
margin: 0 auto;
@@ -331,47 +336,42 @@
}
.modal-direct-channels {
- .user-list {
- list-style-type: none;
- margin: 15px 0px 0px;
- max-height: 600px;
- padding: 0px;
- overflow: auto;
- li {
- border-bottom: 1px solid #ddd;
- height: 60px;
- padding: 10px 0px;
+ .user-list {
+ margin-top: 10px;
+ overflow: auto;
+ -webkit-overflow-scrolling: touch;
+ max-height: 500px;
+ position: relative;
+ }
- .image-div {
- padding: 0px;
+ .table {
+ margin-top: 10px;
+ }
- .profile-image {
- width: 40px;
- height: 40px;
- @include border-radius(20px);
- }
- }
+ .modal-body {
+ padding: 20px 0 0;
+ @include clearfix;
+ }
- .username {
- font-weight: bold;
- }
+ .filter-row {
+ padding: 0 15px;
+ }
- .nickname {
- color: #888;
- }
+ .member-count {
+ margin-top: 5px;
+ float: right;
+ @include opacity(0.8);
+ }
- .btn-div {
- padding: 0px;
- .btn-message {
- position: relative;
- top: 5px;
- }
- }
+ .more-description {
+ @include opacity(0.7);
+ }
- &:last-child {
- border-bottom: 0px;
- }
- }
- }
+ .profile-img {
+ -moz-border-radius: 50px;
+ -webkit-border-radius: 50px;
+ border-radius: 50px;
+ margin-right: 8px;
+ }
}
diff --git a/web/sass-files/sass/partials/_responsive.scss b/web/sass-files/sass/partials/_responsive.scss
index 09f2c179e..09ac2047c 100644
--- a/web/sass-files/sass/partials/_responsive.scss
+++ b/web/sass-files/sass/partials/_responsive.scss
@@ -267,9 +267,17 @@
}
}
.file-details {
+ width: 100%;
height: auto;
}
}
+ .modal-direct-channels {
+ .member-count {
+ float: none;
+ margin-top: 10px;
+ display: block;
+ }
+ }
.center-file-overlay {
font-size: 1.3em;
}
@@ -690,7 +698,7 @@
.modal-image {
.image-wrapper {
font-size: 12px;
- min-width: 280px;
+ min-width: 250px;
.modal-close {
@include opacity(1);
}
diff --git a/web/sass-files/sass/partials/_settings.scss b/web/sass-files/sass/partials/_settings.scss
index 0c2f25eab..c4591d7b6 100644
--- a/web/sass-files/sass/partials/_settings.scss
+++ b/web/sass-files/sass/partials/_settings.scss
@@ -230,13 +230,6 @@
font-weight:500;
}
-.profile-img {
- width:128px;
- height:128px;
- margin-bottom: 10px;
- @include border-radius(128px);
-}
-
.sel-btn {
margin-right:5px;
}
@@ -298,3 +291,7 @@
.color-btn {
margin:4px;
}
+
+.no-resize {
+ resize:none;
+}
diff --git a/web/sass-files/sass/partials/_videos.scss b/web/sass-files/sass/partials/_videos.scss
index 9e1ce29b7..6ae5b488b 100644
--- a/web/sass-files/sass/partials/_videos.scss
+++ b/web/sass-files/sass/partials/_videos.scss
@@ -26,11 +26,6 @@
padding:0px;
}
-.video-uploader {
- font-size: 13px;
- margin: 0 0 15px;
-}
-
.video-title {
font-size:15px;
margin-top:3px;
@@ -54,4 +49,4 @@
border-top:36px solid transparent;
border-bottom:36px solid transparent;
border-left:60px solid rgba(255,255,255,0.4);
-} \ No newline at end of file
+}
diff --git a/web/static/images/webhook_icon.jpg b/web/static/images/webhook_icon.jpg
new file mode 100644
index 000000000..af5303421
--- /dev/null
+++ b/web/static/images/webhook_icon.jpg
Binary files differ
diff --git a/web/templates/admin_console.html b/web/templates/admin_console.html
index a046478f6..574caf730 100644
--- a/web/templates/admin_console.html
+++ b/web/templates/admin_console.html
@@ -12,7 +12,7 @@
<div id='select_team_modal'></div>
<script>
- window.setup_admin_console_page();
+ window.setup_admin_console_page({{ .Props }});
$(document).ready(function(){
$('[data-toggle="tooltip"]').tooltip();
diff --git a/web/web.go b/web/web.go
index 7ab50a073..3bfed371b 100644
--- a/web/web.go
+++ b/web/web.go
@@ -15,7 +15,6 @@ import (
"gopkg.in/fsnotify.v1"
"html/template"
"net/http"
- "regexp"
"strconv"
"strings"
)
@@ -64,6 +63,9 @@ func InitWeb() {
mainrouter.Handle("/signup/{service:[A-Za-z]+}/complete", api.AppHandlerIndependent(signupCompleteOAuth)).Methods("GET")
mainrouter.Handle("/admin_console", api.UserRequired(adminConsole)).Methods("GET")
+ mainrouter.Handle("/admin_console/", api.UserRequired(adminConsole)).Methods("GET")
+ mainrouter.Handle("/admin_console/{tab:[A-Za-z0-9-_]+}", api.UserRequired(adminConsole)).Methods("GET")
+ mainrouter.Handle("/admin_console/{tab:[A-Za-z0-9-_]+}/{team:[A-Za-z0-9-]*}", api.UserRequired(adminConsole)).Methods("GET")
mainrouter.Handle("/hooks/{id:[A-Za-z0-9]+}", api.ApiAppHandler(incomingWebhook)).Methods("POST")
@@ -427,9 +429,9 @@ func verifyEmail(c *api.Context, w http.ResponseWriter, r *http.Request) {
user := result.Data.(*model.User)
if user.LastActivityAt > 0 {
- api.FireAndForgetEmailChangeVerifyEmail(user.Id, user.Email, team.Name, team.DisplayName, c.GetSiteURL(), c.GetTeamURLFromTeam(team))
+ api.SendEmailChangeVerifyEmailAndForget(user.Id, user.Email, team.Name, team.DisplayName, c.GetSiteURL(), c.GetTeamURLFromTeam(team))
} else {
- api.FireAndForgetVerifyEmail(user.Id, user.Email, team.Name, team.DisplayName, c.GetSiteURL(), c.GetTeamURLFromTeam(team))
+ api.SendVerifyEmailAndForget(user.Id, user.Email, team.Name, team.DisplayName, c.GetSiteURL(), c.GetTeamURLFromTeam(team))
}
newAddress := strings.Replace(r.URL.String(), "&resend=true", "&resend_success=true", -1)
@@ -699,7 +701,14 @@ func adminConsole(c *api.Context, w http.ResponseWriter, r *http.Request) {
return
}
+ params := mux.Vars(r)
+ activeTab := params["tab"]
+ teamId := params["team"]
+
page := NewHtmlTemplatePage("admin_console", "Admin Console")
+
+ page.Props["ActiveTab"] = activeTab
+ page.Props["TeamId"] = teamId
page.Render(c, w)
}
@@ -921,9 +930,6 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) {
channelName := props["channel"]
- overrideUsername := props["username"]
- overrideIconUrl := props["icon_url"]
-
var hook *model.IncomingWebhook
if result := <-hchan; result.Err != nil {
c.Err = model.NewAppError("incomingWebhook", "Invalid webhook", "err="+result.Err.Message)
@@ -952,12 +958,8 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) {
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}")
+ overrideUsername := props["username"]
+ overrideIconUrl := props["icon_url"]
if result := <-cchan; result.Err != nil {
c.Err = model.NewAppError("incomingWebhook", "Couldn't find the channel", "err="+result.Err.Message)
@@ -968,27 +970,16 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) {
pchan := api.Srv.Store.Channel().CheckPermissionsTo(hook.TeamId, channel.Id, hook.UserId)
- post := &model.Post{UserId: hook.UserId, ChannelId: channel.Id, Message: text}
- post.AddProp("from_webhook", "true")
-
- if len(overrideUsername) != 0 && utils.Cfg.ServiceSettings.EnablePostUsernameOverride {
- post.AddProp("override_username", overrideUsername)
- }
-
- if len(overrideIconUrl) != 0 && utils.Cfg.ServiceSettings.EnablePostIconOverride {
- post.AddProp("override_icon_url", overrideIconUrl)
- }
+ // create a mock session
+ c.Session = model.Session{UserId: hook.UserId, TeamId: hook.TeamId, IsOAuth: false}
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)
+ if _, err := api.CreateWebhookPost(c, channel.Id, text, overrideUsername, overrideIconUrl); err != nil {
+ c.Err = err
return
}