summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md171
-rw-r--r--README.md10
-rw-r--r--api/channel.go7
-rw-r--r--api/command.go2
-rw-r--r--api/file.go20
-rw-r--r--api/post.go226
-rw-r--r--api/post_test.go122
-rw-r--r--api/team.go9
-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.json4
-rw-r--r--doc/developer/Setup.md8
-rw-r--r--doc/help/Search.md11
-rw-r--r--doc/install/Production-Ubuntu.md3
-rw-r--r--doc/install/Troubleshooting.md5
-rw-r--r--doc/install/Upgrade-Guide.md18
-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/access.go2
-rw-r--r--model/client.go36
-rw-r--r--model/config.go23
-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--model/search_params.go130
-rw-r--r--model/search_params_test.go70
-rw-r--r--model/team.go4
-rw-r--r--model/team_test.go16
-rw-r--r--model/utils.go4
-rw-r--r--store/sql_post_store.go128
-rw-r--r--store/sql_post_store_test.go22
-rw-r--r--store/sql_store.go31
-rw-r--r--store/sql_team_store.go7
-rw-r--r--store/sql_user_store.go2
-rw-r--r--store/sql_webhook_store.go171
-rw-r--r--store/sql_webhook_store_test.go206
-rw-r--r--store/store.go9
-rw-r--r--utils/apns.go2
-rw-r--r--utils/config.go2
-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/admin_console/team_settings.jsx34
-rw-r--r--web/react/components/channel_header.jsx16
-rw-r--r--web/react/components/create_comment.jsx39
-rw-r--r--web/react/components/create_post.jsx23
-rw-r--r--web/react/components/edit_post_modal.jsx4
-rw-r--r--web/react/components/file_attachment.jsx158
-rw-r--r--web/react/components/file_upload_overlay.jsx16
-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/rhs_thread.jsx27
-rw-r--r--web/react/components/search_results.jsx27
-rw-r--r--web/react/components/sidebar.jsx139
-rw-r--r--web/react/components/team_signup_url_page.jsx10
-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.jsx96
-rw-r--r--web/react/pages/admin_console.jsx7
-rw-r--r--web/react/stores/post_store.jsx13
-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/emoticons.jsx1
-rw-r--r--web/react/utils/utils.jsx16
-rw-r--r--web/sass-files/sass/partials/_command-box.scss1
-rw-r--r--web/sass-files/sass/partials/_files.scss39
-rw-r--r--web/sass-files/sass/partials/_modal.scss91
-rw-r--r--web/sass-files/sass/partials/_post.scss48
-rw-r--r--web/sass-files/sass/partials/_responsive.scss33
-rw-r--r--web/sass-files/sass/partials/_settings.scss14
-rw-r--r--web/sass-files/sass/partials/_videos.scss7
-rw-r--r--web/static/images/filesOverlay.pngbin0 -> 8392 bytes
-rw-r--r--web/static/images/logoWhite.pngbin0 -> 5876 bytes
-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
101 files changed, 3457 insertions, 717 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 425902cf0..54d9122c0 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
@@ -53,6 +59,26 @@ Messaging and Notifications
- Slack import is unstable due to change in Slack export format
- Uploading a .flac file breaks the file previewer on iOS
+### Compatibility
+
+#### Config.json Changes from v1.0 to v1.1
+
+##### Service Settings
+
+Multiple settings were added to [`config.json`](./config/config.json) and System Console UI. Prior to upgrading the Mattermost binaries from the previous versions, these options would need to be manually updated in existing config.json file. This is a list of changes and their new default values in a fresh install:
+- Under `ServiceSettings` in `config.json`:
+ - Added: `"EnablePostIconOverride": false` to control whether webhooks can override profile pictures
+ - Added: `"EnablePostUsernameOverride": false` to control whether webhooks can override profile pictures
+ - Added: `"EnableSecurityFixAlert": true` to control whether the system is alerted to security updates
+
+#### Database Changes from v1.0 to v1.1
+
+The following is for informational purposes only, no action needed. Mattermost automatically upgrades database tables from the previous version's schema using only additions. Sessions table is dropped and rebuilt, no team data is affected by this.
+
+##### ChannelMembers Table
+1. Removed `NotifyLevel` column
+2. Added `NotifyProps` column with type `varchar(2000)` and default value `{}`
+
### Contributors
Many thanks to our external contributors. In no particular order:
@@ -119,7 +145,6 @@ Messaging, Comments and Notifications
- Full markdown support in messages, comments, and channel description
- Support for emoji codes rendering to image files
-
Files and Images
- Added ability to play video and audio files
@@ -172,7 +197,149 @@ Licensing
### Bug Fixes
-- Fixed issue so that SSO option automatically set EmailVerified=true (it was false previously)
+- Fixed issue so that SSO option automatically set `EmailVerified=true` (it was false previously)
+
+### Compatibility
+
+A large number of settings were changed in [`config.json`](./config/config.json) and a System Console UI was added. This is a very large change due to Mattermost releasing as v1.0 and it's unlikely a change of this size would happen again.
+
+Prior to upgrading the Mattermost binaries from the previous versions, the below options would need to be manually updated in your existing config.json file to migrate successfully. This is a list of changes and their new default values in a fresh install:
+#### Config.json Changes from v0.7 to v1.0
+
+##### Service Settings
+
+- Under `ServiceSettings` in [`config.json`](./config/config.json):
+ - Moved: `"SiteName": "Mattermost"` which was added to `TeamSettings`
+ - Removed: `"Mode" : "dev"` which deprecates a high level dev mode, now replaced by granular controls
+ - Renamed: `"AllowTesting" : false` to `"EnableTesting": false` which allows the use of `/loadtest` slash commands during development
+ - Removed: `"UseSSL": false` boolean replaced by `"ConnectionSecurity": ""` under `Security` with new options: _None_ (`""`), _TLS_ (`"TLS"`) and _StartTLS_ ('"StartTLS"`)
+ - Renamed: `"Port": "8065"` to `"ListenAddress": ":8065"` to define address on which to listen. Must be prepended with a colon.
+ - Removed: `"Version": "developer"` removed and version information now stored in `model/version.go`
+ - Removed: `"Shards": {}` which was not used
+ - Moved: `"InviteSalt": "gxHVDcKUyP2y1eiyW8S8na1UYQAfq6J6"` to `EmailSettings`
+ - Moved: `"PublicLinkSalt": "TO3pTyXIZzwHiwyZgGql7lM7DG3zeId4"` to `FileSettings`
+ - Renamed and Moved `"ResetSalt": "IPxFzSfnDFsNsRafZxz8NaYqFKhf9y2t"` to `"PasswordResetSalt": "vZ4DcKyVVRlKHHJpexcuXzojkE5PZ5eL"` and moved to `EmailSettings`
+ - Removed: `"AnalyticsUrl": ""` which was not used
+ - Removed: `"UseLocalStorage": true` which is replaced by `"DriverName": "local"` in `FileSettings`
+ - Renamed and Moved: `"StorageDirectory": "./data/"` to `Directory` and moved to `FileSettings`
+ - Renamed: `"AllowedLoginAttempts": 10` to `"MaximumLoginAttempts": 10`
+ - Renamed, Reversed and Moved: `"DisableEmailSignUp": false` renamed `"EnableSignUpWithEmail": true`, reversed meaning of `true`, and moved to `EmailSettings`
+ - Added: `"EnableOAuthServiceProvider": false` to enable OAuth2 service provider functionality
+ - Added: `"EnableIncomingWebhooks": false` to enable incoming webhooks feature
+
+##### Team Settings
+
+- Under `TeamSettings` in [`config.json`](./config/config.json):
+ - Renamed: `"AllowPublicLink": true` renamed to `"EnablePublicLink": true` and moved to `FileSettings`
+ - Removed: `AllowValetDefault` which was a guest account feature that is deprecated
+ - Removed: `"TermsLink": "/static/help/configure_links.html"` removed since option didn't need configuration
+ - Removed: `"PrivacyLink": "/static/help/configure_links.html"` removed since option didn't need configuration
+ - Removed: `"AboutLink": "/static/help/configure_links.html"` removed since option didn't need configuration
+ - Removed: `"HelpLink": "/static/help/configure_links.html"` removed since option didn't need configuration
+ - Removed: `"ReportProblemLink": "/static/help/configure_links.html"` removed since option didn't need configuration
+ - Removed: `"TourLink": "/static/help/configure_links.html"` removed since option didn't need configuration
+ - Removed: `"DefaultThemeColor": "#2389D7"` removed since theme colors changed from 1 to 18, default theme color option may be added back later after theme color design stablizes
+ - Renamed: `"DisableTeamCreation": false` to `"EnableUserCreation": true` and reversed
+ - Added: ` "EnableUserCreation": true` added to disable ability to create new user accounts in the system
+
+##### SSO Settings
+
+- Under `SSOSettings` in [`config.json`](./config/config.json):
+ - Renamed Category: `SSOSettings` to `GitLabSettings`
+ - Renamed: `"Allow": false` to `"Enable": false` to enable GitLab SSO
+
+##### AWS Settings
+
+- Under `AWSSettings` in [`config.json`](./config/config.json):
+ - This section was removed and settings moved to `FileSettings`
+ - Renamed and Moved: `"S3AccessKeyId": ""` renamed `"AmazonS3AccessKeyId": "",` and moved to `FileSettings`
+ - Renamed and Moved: `"S3SecretAccessKey": ""` renamed `"AmazonS3SecretAccessKey": "",` and moved to `FileSettings`
+ - Renamed and Moved: `"S3Bucket": ""` renamed `"AmazonS3Bucket": "",` and moved to `FileSettings`
+ - Renamed and Moved: `"S3Region": ""` renamed `"AmazonS3Region": "",` and moved to `FileSettings`
+
+##### Image Settings
+
+- Under `ImageSettings` in [`config.json`](./config/config.json):
+ - Renamed: `"ImageSettings"` section to `"FileSettings"`
+ - Added: `"DriverName" : "local"` to specify the file storage method, `amazons3` can also be used to setup S3
+
+##### EmailSettings
+
+- Under `EmailSettings` in [`config.json`](./config/config.json):
+ - Removed: `"ByPassEmail": "true"` which is replaced with `SendEmailNotifications` and `RequireEmailVerification`
+ - Added: `"SendEmailNotifications" : "false"` to control whether email notifications are sent
+ - Added: `"RequireEmailVerification" : "false"` to control if users need to verify their emails
+ - Replaced: `"UseTLS": "false"` with `"ConnectionSecurity": ""` with options: _None_ (`""`), _TLS_ (`"TLS"`) and _StartTLS_ (`"StartTLS"`)
+ - Replaced: `"UseStartTLS": "false"` with `"ConnectionSecurity": ""` with options: _None_ (`""`), _TLS_ (`"TLS"`) and _StartTLS_ (`"StartTLS"`)
+
+##### Privacy Settings
+
+- Under `PrivacySettings` in [`config.json`](./config/config.json):
+ - Removed: `"ShowPhoneNumber": "true"` which was not used
+ - Removed: `"ShowSkypeId" : "true"` which was not used
+
+### Database Changes from v0.7 to v1.0
+
+The following is for informational purposes only, no action needed. Mattermost automatically upgrades database tables from the previous version's schema using only additions. Sessions table is dropped and rebuilt, no team data is affected by this.
+
+##### Users Table
+1. Added `ThemeProps` column with type `varchar(2000)` and default value `{}`
+
+##### Teams Table
+1. Removed `AllowValet` column
+
+##### Sessions Table
+1. Renamed `Id` column `Token`
+2. Renamed `AltId` column `Id`
+3. Added `IsOAuth` column with type `tinyint(1)` and default value `0`
+
+##### OAuthAccessData Table
+1. Added new table `OAuthAccessData`
+2. Added `AuthCode` column with type `varchar(128)`
+3. Added `Token` column with type `varchar(26)` as the primary key
+4. Added `RefreshToken` column with type `varchar(26)`
+5. Added `RedirectUri` column with type `varchar(256)`
+6. Added index on `AuthCode` column
+
+##### OAuthApps Table
+1. Added new table `OAuthApps`
+2. Added `Id` column with type `varchar(26)` as primary key
+2. Added `CreatorId` column with type `varchar(26)`
+2. Added `CreateAt` column with type `bigint(20)`
+2. Added `UpdateAt` column with type `bigint(20)`
+2. Added `ClientSecret` column with type `varchar(128)`
+2. Added `Name` column with type `varchar(64)`
+2. Added `Description` column with type `varchar(512)`
+2. Added `CallbackUrls` column with type `varchar(1024)`
+2. Added `Homepage` column with type `varchar(256)`
+3. Added index on `CreatorId` column
+
+##### OAuthAuthData Table
+1. Added new table `OAuthAuthData`
+2. Added `ClientId` column with type `varchar(26)`
+2. Added `UserId` column with type `varchar(26)`
+2. Added `Code` column with type `varchar(128)` as primary key
+2. Added `ExpiresIn` column with type `int(11)`
+2. Added `CreateAt` column with type `bigint(20)`
+2. Added `State` column with type `varchar(128)`
+2. Added `Scope` column with type `varchar(128)`
+
+##### IncomingWebhooks Table
+1. Added new table `IncomingWebhooks`
+2. Added `Id` column with type `varchar(26)` as primary key
+2. Added `CreateAt` column with type `bigint(20)`
+2. Added `UpdateAt` column with type `bigint(20)`
+2. Added `DeleteAt` column with type `bigint(20)`
+2. Added `UserId` column with type `varchar(26)`
+2. Added `ChannelId` column with type `varchar(26)`
+2. Added `TeamId` column with type `varchar(26)`
+3. Added index on `UserId` column
+3. Added index on `TeamId` column
+
+##### Systems Table
+1. Added new table `Systems`
+2. Added `Name` column with type `varchar(64)` as primary key
+3. Added `Value column with type `varchar(1024)`
### Contributors
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 956c8c723..54f863c48 100644
--- a/api/command.go
+++ b/api/command.go
@@ -146,7 +146,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..94eea516a 100644
--- a/api/file.go
+++ b/api/file.go
@@ -23,6 +23,7 @@ import (
"image/jpeg"
"io"
"io/ioutil"
+ "mime"
"net/http"
"net/url"
"os"
@@ -146,12 +147,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 +312,7 @@ func getFileInfo(c *Context, w http.ResponseWriter, r *http.Request) {
} else {
fileData := make(chan []byte)
- asyncGetFile(path, fileData)
+ getFileAndForget(path, fileData)
f := <-fileData
@@ -331,9 +332,18 @@ func getFileInfo(c *Context, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "max-age=2592000, public")
+ var mimeType string
+ ext := filepath.Ext(filename)
+ if model.IsFileExtImage(ext) {
+ mimeType = model.GetImageMimeType(ext)
+ } else {
+ mimeType = mime.TypeByExtension(ext)
+ }
+
result := make(map[string]string)
result["filename"] = filename
result["size"] = size
+ result["mime"] = mimeType
w.Write([]byte(model.MapToJson(result)))
}
@@ -378,7 +388,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 +433,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 58fd3488a..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)
@@ -680,16 +820,16 @@ func searchPosts(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
- hashtagTerms, plainTerms := model.ParseHashtags(terms)
+ plainSearchParams, hashtagSearchParams := model.ParseSearchParams(terms)
var hchan store.StoreChannel
- if len(hashtagTerms) != 0 {
- hchan = Srv.Store.Post().Search(c.Session.TeamId, c.Session.UserId, hashtagTerms, true)
+ if hashtagSearchParams != nil {
+ hchan = Srv.Store.Post().Search(c.Session.TeamId, c.Session.UserId, hashtagSearchParams)
}
var pchan store.StoreChannel
- if len(plainTerms) != 0 {
- pchan = Srv.Store.Post().Search(c.Session.TeamId, c.Session.UserId, terms, false)
+ if plainSearchParams != nil {
+ pchan = Srv.Store.Post().Search(c.Session.TeamId, c.Session.UserId, plainSearchParams)
}
mainList := &model.PostList{}
diff --git a/api/post_test.go b/api/post_test.go
index 1971b6114..ac9d5668b 100644
--- a/api/post_test.go
+++ b/api/post_test.go
@@ -406,6 +406,128 @@ func TestSearchHashtagPosts(t *testing.T) {
}
}
+func TestSearchPostsInChannel(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)
+
+ user1 := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"}
+ user1 = Client.Must(Client.CreateUser(user1, "")).Data.(*model.User)
+ store.Must(Srv.Store.User().VerifyEmail(user1.Id))
+
+ Client.LoginByEmail(team.Name, user1.Email, "pwd")
+
+ channel1 := &model.Channel{DisplayName: "TestGetPosts", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
+ channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel)
+
+ post1 := &model.Post{ChannelId: channel1.Id, Message: "sgtitlereview with space"}
+ post1 = Client.Must(Client.CreatePost(post1)).Data.(*model.Post)
+
+ channel2 := &model.Channel{DisplayName: "TestGetPosts", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
+ channel2 = Client.Must(Client.CreateChannel(channel2)).Data.(*model.Channel)
+
+ post2 := &model.Post{ChannelId: channel2.Id, Message: "sgtitlereview\n with return"}
+ post2 = Client.Must(Client.CreatePost(post2)).Data.(*model.Post)
+
+ post3 := &model.Post{ChannelId: channel2.Id, Message: "other message with no return"}
+ post3 = Client.Must(Client.CreatePost(post3)).Data.(*model.Post)
+
+ if result := Client.Must(Client.SearchPosts("channel:")).Data.(*model.PostList); len(result.Order) != 0 {
+ t.Fatalf("wrong number of posts returned %v", len(result.Order))
+ }
+
+ if result := Client.Must(Client.SearchPosts("in:")).Data.(*model.PostList); len(result.Order) != 0 {
+ t.Fatalf("wrong number of posts returned %v", len(result.Order))
+ }
+
+ if result := Client.Must(Client.SearchPosts("channel:" + channel1.Name)).Data.(*model.PostList); len(result.Order) != 1 {
+ t.Fatalf("wrong number of posts returned %v", len(result.Order))
+ }
+
+ if result := Client.Must(Client.SearchPosts("in: " + channel2.Name)).Data.(*model.PostList); len(result.Order) != 2 {
+ t.Fatalf("wrong number of posts returned %v", len(result.Order))
+ }
+
+ if result := Client.Must(Client.SearchPosts("channel: " + channel2.Name)).Data.(*model.PostList); len(result.Order) != 2 {
+ t.Fatalf("wrong number of posts returned %v", len(result.Order))
+ }
+
+ if result := Client.Must(Client.SearchPosts("ChAnNeL: " + channel2.Name)).Data.(*model.PostList); len(result.Order) != 2 {
+ t.Fatalf("wrong number of posts returned %v", len(result.Order))
+ }
+
+ if result := Client.Must(Client.SearchPosts("sgtitlereview")).Data.(*model.PostList); len(result.Order) != 2 {
+ t.Fatalf("wrong number of posts returned %v", len(result.Order))
+ }
+
+ if result := Client.Must(Client.SearchPosts("sgtitlereview in:")).Data.(*model.PostList); len(result.Order) != 2 {
+ t.Fatalf("wrong number of posts returned %v", len(result.Order))
+ }
+
+ if result := Client.Must(Client.SearchPosts("sgtitlereview channel:" + channel1.Name)).Data.(*model.PostList); len(result.Order) != 1 {
+ t.Fatalf("wrong number of posts returned %v", len(result.Order))
+ }
+
+ if result := Client.Must(Client.SearchPosts("sgtitlereview in: " + channel2.Name)).Data.(*model.PostList); len(result.Order) != 1 {
+ t.Fatalf("wrong number of posts returned %v", len(result.Order))
+ }
+
+ if result := Client.Must(Client.SearchPosts("sgtitlereview channel: " + channel2.Name)).Data.(*model.PostList); len(result.Order) != 1 {
+ t.Fatalf("wrong number of posts returned %v", len(result.Order))
+ }
+}
+
+func TestSearchPostsFromUser(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)
+
+ user1 := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"}
+ user1 = Client.Must(Client.CreateUser(user1, "")).Data.(*model.User)
+ store.Must(Srv.Store.User().VerifyEmail(user1.Id))
+
+ Client.LoginByEmail(team.Name, user1.Email, "pwd")
+
+ channel1 := &model.Channel{DisplayName: "TestGetPosts", 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: "TestGetPosts", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
+ channel2 = Client.Must(Client.CreateChannel(channel2)).Data.(*model.Channel)
+
+ post1 := &model.Post{ChannelId: channel1.Id, Message: "sgtitlereview with space"}
+ post1 = Client.Must(Client.CreatePost(post1)).Data.(*model.Post)
+
+ user2 := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"}
+ user2 = Client.Must(Client.CreateUser(user2, "")).Data.(*model.User)
+ store.Must(Srv.Store.User().VerifyEmail(user2.Id))
+
+ Client.LoginByEmail(team.Name, user2.Email, "pwd")
+ Client.Must(Client.JoinChannel(channel1.Id))
+ Client.Must(Client.JoinChannel(channel2.Id))
+
+ post2 := &model.Post{ChannelId: channel2.Id, Message: "sgtitlereview\n with return"}
+ post2 = Client.Must(Client.CreatePost(post2)).Data.(*model.Post)
+
+ if result := Client.Must(Client.SearchPosts("from: " + user1.Username)).Data.(*model.PostList); len(result.Order) != 1 {
+ t.Fatalf("wrong number of posts returned %v", len(result.Order))
+ }
+
+ // note that this includes the "User2 has joined the channel" system messages
+ if result := Client.Must(Client.SearchPosts("from: " + user2.Username)).Data.(*model.PostList); len(result.Order) != 3 {
+ t.Fatalf("wrong number of posts returned %v", len(result.Order))
+ }
+
+ if result := Client.Must(Client.SearchPosts("from: " + user2.Username + " sgtitlereview")).Data.(*model.PostList); len(result.Order) != 1 {
+ t.Fatalf("wrong number of posts returned %v", len(result.Order))
+ }
+
+ if result := Client.Must(Client.SearchPosts("from: " + user2.Username + " in:" + channel1.Name)).Data.(*model.PostList); len(result.Order) != 1 {
+ t.Fatalf("wrong number of posts returned %v", len(result.Order))
+ }
+}
+
func TestGetPostsCache(t *testing.T) {
Setup()
diff --git a/api/team.go b/api/team.go
index f6038566a..2d7b05ff6 100644
--- a/api/team.go
+++ b/api/team.go
@@ -108,7 +108,7 @@ func createTeamFromSSO(c *Context, w http.ResponseWriter, r *http.Request) {
team.Name = model.CleanTeamName(team.Name)
- if err := team.IsValid(); err != nil {
+ if err := team.IsValid(*utils.Cfg.TeamSettings.RestrictTeamNames); err != nil {
c.Err = err
return
}
@@ -164,7 +164,7 @@ func createTeamFromSignup(c *Context, w http.ResponseWriter, r *http.Request) {
teamSignup.Team.PreSave()
- if err := teamSignup.Team.IsValid(); err != nil {
+ if err := teamSignup.Team.IsValid(*utils.Cfg.TeamSettings.RestrictTeamNames); err != nil {
c.Err = err
return
}
@@ -379,11 +379,6 @@ func FindTeamByName(c *Context, name string, all string) bool {
return false
}
- if model.IsReservedTeamName(name) {
- c.Err = model.NewAppError("findTeamByName", "This URL is unavailable. Please try another.", "name="+name)
- return false
- }
-
if result := <-Srv.Store.Team().GetByName(name); result.Err != nil {
return false
} else {
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..7bac58df7 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,
@@ -16,7 +17,8 @@
"MaxUsersPerTeam": 50,
"EnableTeamCreation": true,
"EnableUserCreation": true,
- "RestrictCreationToDomains": ""
+ "RestrictCreationToDomains": "",
+ "RestrictTeamNames": true
},
"SqlSettings": {
"DriverName": "mysql",
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/help/Search.md b/doc/help/Search.md
new file mode 100644
index 000000000..f36e079bd
--- /dev/null
+++ b/doc/help/Search.md
@@ -0,0 +1,11 @@
+# Search
+
+The search box in Mattermost brings back results from any channel of which you’re a member. No results are returned from channels where you are not a member - even if they are open channels.
+
+Some things to know about search:
+
+- Multiple search terms are connected with “OR” by default. Typing in `Mattermost website` returns results containing “Mattermost” or “website”
+- You can use quotes to return search results for exact terms, like `"Mattermost website"` will only return messages containing the entire phrase `"Mattermost website"` and not return messages with only `Mattermost` or `website`
+- You can use the `*` character for wildcard searches that match within words. For example: Searching for `rea*` brings back messages containing `reach`, `reason` and other words starting with `rea`.
+
+Search in Mattermost uses the full text search features in MySQL and Postgres databases. Special cases that are not supported in default full text search, such as searching for IP addresses like `10.100.200.101`, can be added in future as the search feature evolves.
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/install/Upgrade-Guide.md b/doc/install/Upgrade-Guide.md
new file mode 100644
index 000000000..cecd45353
--- /dev/null
+++ b/doc/install/Upgrade-Guide.md
@@ -0,0 +1,18 @@
+# Mattermost Upgrade Guide
+
+### Upgrading Mattermost v0.7 to v1.1.1
+
+_Note: [Mattermost v1.1.1](https://github.com/mattermost/platform/releases/tag/v1.1.1) is a special release of Mattermost v1.1 that upgrades the database to Mattermost v1.1 from EITHER Mattermost v0.7 or Mattermost v1.0. The following instructions are for upgrading from Mattermost v0.7 to v1.1.1 and skipping the upgrade to Mattermost v1.0._
+
+If you've manually changed Mattermost v0.7 configuration by updating the `config.json` file, you'll need to port those changes to Mattermost v1.1.1:
+
+1. Go to the `config.json` file that you manually updated and note any differences from the [default `config.json` file in Mattermost 0.7](https://github.com/mattermost/platform/blob/v0.7.0/config/config.json).
+
+2. For each setting that you changed, check [the changelog documentation](https://github.com/mattermost/platform/blob/master/CHANGELOG.md#configjson-changes-from-v07-to-v10) on whether the configuration setting has changed between v0.7 and v1.1.1
+
+3. Update your new [`config.json` file in Mattermost v1.1](https://github.com/mattermost/platform/blob/v1.1.0/config/config.json), based on your preferences and the changelog documentation above.
+
+Optionally, you can use the new [System Console user interface](https://github.com/mattermost/platform/blob/master/doc/install/Configuration-Settings.md) to make changes to your new `config.json` file.
+
+
+
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/access.go b/model/access.go
index 89a1271c1..6c9254004 100644
--- a/model/access.go
+++ b/model/access.go
@@ -16,7 +16,7 @@ const (
type AccessData struct {
AuthCode string `json:"auth_code"`
- Token string `json"token"`
+ Token string `json:"token"`
RefreshToken string `json:"refresh_token"`
RedirectUri string `json:"redirect_uri"`
}
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..216b1de86 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
@@ -121,6 +122,7 @@ type TeamSettings struct {
EnableTeamCreation bool
EnableUserCreation bool
RestrictCreationToDomains string
+ RestrictTeamNames *bool
}
type Config struct {
@@ -168,6 +170,11 @@ func (o *Config) SetDefaults() {
o.ServiceSettings.EnableSecurityFixAlert = new(bool)
*o.ServiceSettings.EnableSecurityFixAlert = true
}
+
+ if o.TeamSettings.RestrictTeamNames == nil {
+ o.TeamSettings.RestrictTeamNames = new(bool)
+ *o.TeamSettings.RestrictTeamNames = true
+ }
}
func (o *Config) IsValid() *AppError {
@@ -184,8 +191,8 @@ func (o *Config) IsValid() *AppError {
return NewAppError("Config.IsValid", "Invalid maximum users per team for team settings. Must be a positive number.", "")
}
- if len(o.SqlSettings.AtRestEncryptKey) != 32 {
- return NewAppError("Config.IsValid", "Invalid at rest encrypt key for SQL settings. Must be 32 chars.", "")
+ if len(o.SqlSettings.AtRestEncryptKey) < 32 {
+ return NewAppError("Config.IsValid", "Invalid at rest encrypt key for SQL settings. Must be 32 chars or more.", "")
}
if !(o.SqlSettings.DriverName == DATABASE_DRIVER_MYSQL || o.SqlSettings.DriverName == DATABASE_DRIVER_POSTGRES) {
@@ -232,20 +239,20 @@ func (o *Config) IsValid() *AppError {
return NewAppError("Config.IsValid", "Invalid thumbnail width for file settings. Must be a positive number.", "")
}
- if len(o.FileSettings.PublicLinkSalt) != 32 {
- return NewAppError("Config.IsValid", "Invalid public link salt for file settings. Must be 32 chars.", "")
+ if len(o.FileSettings.PublicLinkSalt) < 32 {
+ return NewAppError("Config.IsValid", "Invalid public link salt for file settings. Must be 32 chars or more.", "")
}
if !(o.EmailSettings.ConnectionSecurity == CONN_SECURITY_NONE || o.EmailSettings.ConnectionSecurity == CONN_SECURITY_TLS || o.EmailSettings.ConnectionSecurity == CONN_SECURITY_STARTTLS) {
return NewAppError("Config.IsValid", "Invalid connection security for email settings. Must be '', 'TLS', or 'STARTTLS'", "")
}
- if len(o.EmailSettings.InviteSalt) != 32 {
- return NewAppError("Config.IsValid", "Invalid invite salt for email settings. Must be 32 chars.", "")
+ if len(o.EmailSettings.InviteSalt) < 32 {
+ return NewAppError("Config.IsValid", "Invalid invite salt for email settings. Must be 32 chars or more.", "")
}
- if len(o.EmailSettings.PasswordResetSalt) != 32 {
- return NewAppError("Config.IsValid", "Invalid password reset salt for email settings. Must be 32 chars.", "")
+ if len(o.EmailSettings.PasswordResetSalt) < 32 {
+ return NewAppError("Config.IsValid", "Invalid password reset salt for email settings. Must be 32 chars or more.", "")
}
if o.RateLimitSettings.MemoryStoreSize <= 0 {
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/model/search_params.go b/model/search_params.go
new file mode 100644
index 000000000..7eeeed10f
--- /dev/null
+++ b/model/search_params.go
@@ -0,0 +1,130 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package model
+
+import (
+ "strings"
+)
+
+type SearchParams struct {
+ Terms string
+ IsHashtag bool
+ InChannel string
+ FromUser string
+}
+
+var searchFlags = [...]string{"from", "channel", "in"}
+
+func splitWords(text string) []string {
+ words := []string{}
+
+ for _, word := range strings.Fields(text) {
+ word = puncStart.ReplaceAllString(word, "")
+ word = puncEnd.ReplaceAllString(word, "")
+
+ if len(word) != 0 {
+ words = append(words, word)
+ }
+ }
+
+ return words
+}
+
+func parseSearchFlags(input []string) ([]string, map[string]string) {
+ words := []string{}
+ flags := make(map[string]string)
+
+ skipNextWord := false
+ for i, word := range input {
+ if skipNextWord {
+ skipNextWord = false
+ continue
+ }
+
+ isFlag := false
+
+ if colon := strings.Index(word, ":"); colon != -1 {
+ flag := word[:colon]
+ value := word[colon+1:]
+
+ for _, searchFlag := range searchFlags {
+ // check for case insensitive equality
+ if strings.EqualFold(flag, searchFlag) {
+ if value != "" {
+ flags[searchFlag] = value
+ isFlag = true
+ } else if i < len(input)-1 {
+ flags[searchFlag] = input[i+1]
+ skipNextWord = true
+ isFlag = true
+ }
+
+ if isFlag {
+ break
+ }
+ }
+ }
+ }
+
+ if !isFlag {
+ words = append(words, word)
+ }
+ }
+
+ return words, flags
+}
+
+func ParseSearchParams(text string) (*SearchParams, *SearchParams) {
+ words, flags := parseSearchFlags(splitWords(text))
+
+ hashtagTerms := []string{}
+ plainTerms := []string{}
+
+ for _, word := range words {
+ if validHashtag.MatchString(word) {
+ hashtagTerms = append(hashtagTerms, word)
+ } else {
+ plainTerms = append(plainTerms, word)
+ }
+ }
+
+ inChannel := flags["channel"]
+ if inChannel == "" {
+ inChannel = flags["in"]
+ }
+
+ fromUser := flags["from"]
+
+ var plainParams *SearchParams
+ if len(plainTerms) > 0 {
+ plainParams = &SearchParams{
+ Terms: strings.Join(plainTerms, " "),
+ IsHashtag: false,
+ InChannel: inChannel,
+ FromUser: fromUser,
+ }
+ }
+
+ var hashtagParams *SearchParams
+ if len(hashtagTerms) > 0 {
+ hashtagParams = &SearchParams{
+ Terms: strings.Join(hashtagTerms, " "),
+ IsHashtag: true,
+ InChannel: inChannel,
+ FromUser: fromUser,
+ }
+ }
+
+ // special case for when no terms are specified but we still have a filter
+ if plainParams == nil && hashtagParams == nil && (inChannel != "" || fromUser != "") {
+ plainParams = &SearchParams{
+ Terms: "",
+ IsHashtag: false,
+ InChannel: inChannel,
+ FromUser: fromUser,
+ }
+ }
+
+ return plainParams, hashtagParams
+}
diff --git a/model/search_params_test.go b/model/search_params_test.go
new file mode 100644
index 000000000..2eba20f4c
--- /dev/null
+++ b/model/search_params_test.go
@@ -0,0 +1,70 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package model
+
+import (
+ "testing"
+)
+
+func TestParseSearchFlags(t *testing.T) {
+ if words, flags := parseSearchFlags(splitWords("")); len(words) != 0 {
+ t.Fatal("got words from empty input")
+ } else if len(flags) != 0 {
+ t.Fatal("got flags from empty input")
+ }
+
+ if words, flags := parseSearchFlags(splitWords("word")); len(words) != 1 || words[0] != "word" {
+ t.Fatalf("got incorrect words %v", words)
+ } else if len(flags) != 0 {
+ t.Fatalf("got incorrect flags %v", flags)
+ }
+
+ if words, flags := parseSearchFlags(splitWords("apple banana cherry")); len(words) != 3 || words[0] != "apple" || words[1] != "banana" || words[2] != "cherry" {
+ t.Fatalf("got incorrect words %v", words)
+ } else if len(flags) != 0 {
+ t.Fatalf("got incorrect flags %v", flags)
+ }
+
+ if words, flags := parseSearchFlags(splitWords("apple banana from:chan")); len(words) != 2 || words[0] != "apple" || words[1] != "banana" {
+ t.Fatalf("got incorrect words %v", words)
+ } else if len(flags) != 1 || flags["from"] != "chan" {
+ t.Fatalf("got incorrect flags %v", flags)
+ }
+
+ if words, flags := parseSearchFlags(splitWords("apple banana from: chan")); len(words) != 2 || words[0] != "apple" || words[1] != "banana" {
+ t.Fatalf("got incorrect words %v", words)
+ } else if len(flags) != 1 || flags["from"] != "chan" {
+ t.Fatalf("got incorrect flags %v", flags)
+ }
+
+ if words, flags := parseSearchFlags(splitWords("apple banana in: chan")); len(words) != 2 || words[0] != "apple" || words[1] != "banana" {
+ t.Fatalf("got incorrect words %v", words)
+ } else if len(flags) != 1 || flags["in"] != "chan" {
+ t.Fatalf("got incorrect flags %v", flags)
+ }
+
+ if words, flags := parseSearchFlags(splitWords("apple banana channel:chan")); len(words) != 2 || words[0] != "apple" || words[1] != "banana" {
+ t.Fatalf("got incorrect words %v", words)
+ } else if len(flags) != 1 || flags["channel"] != "chan" {
+ t.Fatalf("got incorrect flags %v", flags)
+ }
+
+ if words, flags := parseSearchFlags(splitWords("fruit: cherry")); len(words) != 2 || words[0] != "fruit:" || words[1] != "cherry" {
+ t.Fatalf("got incorrect words %v", words)
+ } else if len(flags) != 0 {
+ t.Fatalf("got incorrect flags %v", flags)
+ }
+
+ if words, flags := parseSearchFlags(splitWords("channel:")); len(words) != 1 || words[0] != "channel:" {
+ t.Fatalf("got incorrect words %v", words)
+ } else if len(flags) != 0 {
+ t.Fatalf("got incorrect flags %v", flags)
+ }
+
+ if words, flags := parseSearchFlags(splitWords("channel: first in: second from:")); len(words) != 1 || words[0] != "from:" {
+ t.Fatalf("got incorrect words %v", words)
+ } else if len(flags) != 2 || flags["channel"] != "first" || flags["in"] != "second" {
+ t.Fatalf("got incorrect flags %v", flags)
+ }
+}
diff --git a/model/team.go b/model/team.go
index 584c78f8d..9da2cd5b2 100644
--- a/model/team.go
+++ b/model/team.go
@@ -97,7 +97,7 @@ func (o *Team) Etag() string {
return Etag(o.Id, o.UpdateAt)
}
-func (o *Team) IsValid() *AppError {
+func (o *Team) IsValid(restrictTeamNames bool) *AppError {
if len(o.Id) != 26 {
return NewAppError("Team.IsValid", "Invalid Id", "")
@@ -127,7 +127,7 @@ func (o *Team) IsValid() *AppError {
return NewAppError("Team.IsValid", "Invalid URL Identifier", "id="+o.Id)
}
- if IsReservedTeamName(o.Name) {
+ if restrictTeamNames && IsReservedTeamName(o.Name) {
return NewAppError("Team.IsValid", "This URL is unavailable. Please try another.", "id="+o.Id)
}
diff --git a/model/team_test.go b/model/team_test.go
index fd2428f03..112d48a9d 100644
--- a/model/team_test.go
+++ b/model/team_test.go
@@ -21,45 +21,45 @@ func TestTeamJson(t *testing.T) {
func TestTeamIsValid(t *testing.T) {
o := Team{}
- if err := o.IsValid(); err == nil {
+ if err := o.IsValid(true); err == nil {
t.Fatal("should be invalid")
}
o.Id = NewId()
- if err := o.IsValid(); err == nil {
+ if err := o.IsValid(true); err == nil {
t.Fatal("should be invalid")
}
o.CreateAt = GetMillis()
- if err := o.IsValid(); err == nil {
+ if err := o.IsValid(true); err == nil {
t.Fatal("should be invalid")
}
o.UpdateAt = GetMillis()
- if err := o.IsValid(); err == nil {
+ if err := o.IsValid(true); err == nil {
t.Fatal("should be invalid")
}
o.Email = strings.Repeat("01234567890", 20)
- if err := o.IsValid(); err == nil {
+ if err := o.IsValid(true); err == nil {
t.Fatal("should be invalid")
}
o.Email = "corey@hulen.com"
o.DisplayName = strings.Repeat("01234567890", 20)
- if err := o.IsValid(); err == nil {
+ if err := o.IsValid(true); err == nil {
t.Fatal("should be invalid")
}
o.DisplayName = "1234"
o.Name = "ZZZZZZZ"
- if err := o.IsValid(); err == nil {
+ if err := o.IsValid(true); err == nil {
t.Fatal("should be invalid")
}
o.Name = "zzzzz"
o.Type = TEAM_OPEN
- if err := o.IsValid(); err != nil {
+ if err := o.IsValid(true); err != nil {
t.Fatal(err)
}
}
diff --git a/model/utils.go b/model/utils.go
index 269144afc..bb0669df7 100644
--- a/model/utils.go
+++ b/model/utils.go
@@ -242,10 +242,10 @@ func Etag(parts ...interface{}) string {
var validHashtag = regexp.MustCompile(`^(#[A-Za-z]+[A-Za-z0-9_\-]*[A-Za-z0-9])$`)
var puncStart = regexp.MustCompile(`^[.,()&$!\[\]{}"':;\\]+`)
-var puncEnd = regexp.MustCompile(`[.,()&$#!\[\]{}"':;\\]+$`)
+var puncEnd = regexp.MustCompile(`[.,()&$#!\[\]{}"';\\]+$`)
func ParseHashtags(text string) (string, string) {
- words := strings.Split(strings.Replace(text, "\n", " ", -1), " ")
+ words := strings.Fields(text)
hashtagString := ""
plainString := ""
diff --git a/store/sql_post_store.go b/store/sql_post_store.go
index 07077bd64..6971de9d7 100644
--- a/store/sql_post_store.go
+++ b/store/sql_post_store.go
@@ -407,15 +407,23 @@ var specialSearchChar = []string{
"@",
}
-func (s SqlPostStore) Search(teamId string, userId string, terms string, isHashtagSearch bool) StoreChannel {
+func (s SqlPostStore) Search(teamId string, userId string, params *model.SearchParams) StoreChannel {
storeChannel := make(StoreChannel)
go func() {
result := StoreResult{}
+
termMap := map[string]bool{}
+ terms := params.Terms
+
+ if terms == "" && params.InChannel == "" && params.FromUser == "" {
+ result.Data = []*model.Post{}
+ storeChannel <- result
+ return
+ }
searchType := "Message"
- if isHashtagSearch {
+ if params.IsHashtag {
searchType = "Hashtags"
for _, term := range strings.Split(terms, " ") {
termMap[term] = true
@@ -430,63 +438,85 @@ func (s SqlPostStore) Search(teamId string, userId string, terms string, isHasht
var posts []*model.Post
if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES {
+ // Parse text for wildcards
+ if wildcard, err := regexp.Compile("\\*($| )"); err == nil {
+ terms = wildcard.ReplaceAllLiteralString(terms, "* ")
+ }
+ }
+ searchQuery := `
+ SELECT
+ *
+ FROM
+ Posts
+ WHERE
+ DeleteAt = 0
+ POST_FILTER
+ AND ChannelId IN (
+ SELECT
+ Id
+ FROM
+ Channels,
+ ChannelMembers
+ WHERE
+ Id = ChannelId
+ AND TeamId = :TeamId
+ AND UserId = :UserId
+ AND DeleteAt = 0
+ CHANNEL_FILTER)
+ SEARCH_CLAUSE
+ ORDER BY CreateAt DESC
+ LIMIT 100`
+
+ if params.InChannel != "" {
+ searchQuery = strings.Replace(searchQuery, "CHANNEL_FILTER", "AND Name = :InChannel", 1)
+ } else {
+ searchQuery = strings.Replace(searchQuery, "CHANNEL_FILTER", "", 1)
+ }
+
+ if params.FromUser != "" {
+ searchQuery = strings.Replace(searchQuery, "POST_FILTER", `
+ AND UserId IN (
+ SELECT
+ Id
+ FROM
+ Users
+ WHERE
+ TeamId = :TeamId
+ AND Username = :FromUser)`, 1)
+ } else {
+ searchQuery = strings.Replace(searchQuery, "POST_FILTER", "", 1)
+ }
+
+ if terms == "" {
+ // we've already confirmed that we have a channel or user to search for
+ searchQuery = strings.Replace(searchQuery, "SEARCH_CLAUSE", "", 1)
+ } else if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES {
// Parse text for wildcards
if wildcard, err := regexp.Compile("\\*($| )"); err == nil {
terms = wildcard.ReplaceAllLiteralString(terms, ":* ")
}
- searchQuery := fmt.Sprintf(`SELECT
- *
- FROM
- Posts
- WHERE
- DeleteAt = 0
- AND ChannelId IN (SELECT
- Id
- FROM
- Channels,
- ChannelMembers
- WHERE
- Id = ChannelId AND TeamId = $1
- AND UserId = $2
- AND DeleteAt = 0)
- AND %s @@ to_tsquery($3)
- ORDER BY CreateAt DESC
- LIMIT 100`, searchType)
-
terms = strings.Join(strings.Fields(terms), " | ")
- _, err := s.GetReplica().Select(&posts, searchQuery, teamId, userId, terms)
- if err != nil {
- result.Err = model.NewAppError("SqlPostStore.Search", "We encounted an error while searching for posts", "teamId="+teamId+", err="+err.Error())
-
- }
+ searchClause := fmt.Sprintf("AND %s @@ to_tsquery(:Terms)", searchType)
+ searchQuery = strings.Replace(searchQuery, "SEARCH_CLAUSE", searchClause, 1)
} else if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_MYSQL {
- searchQuery := fmt.Sprintf(`SELECT
- *
- FROM
- Posts
- WHERE
- DeleteAt = 0
- AND ChannelId IN (SELECT
- Id
- FROM
- Channels,
- ChannelMembers
- WHERE
- Id = ChannelId AND TeamId = ?
- AND UserId = ?
- AND DeleteAt = 0)
- AND MATCH (%s) AGAINST (? IN BOOLEAN MODE)
- ORDER BY CreateAt DESC
- LIMIT 100`, searchType)
-
- _, err := s.GetReplica().Select(&posts, searchQuery, teamId, userId, terms)
- if err != nil {
- result.Err = model.NewAppError("SqlPostStore.Search", "We encounted an error while searching for posts", "teamId="+teamId+", err="+err.Error())
+ searchClause := fmt.Sprintf("AND MATCH (%s) AGAINST (:Terms IN BOOLEAN MODE)", searchType)
+ searchQuery = strings.Replace(searchQuery, "SEARCH_CLAUSE", searchClause, 1)
+ }
- }
+ queryParams := map[string]interface{}{
+ "TeamId": teamId,
+ "UserId": userId,
+ "Terms": terms,
+ "InChannel": params.InChannel,
+ "FromUser": params.FromUser,
+ }
+
+ _, err := s.GetReplica().Select(&posts, searchQuery, queryParams)
+ if err != nil {
+ result.Err = model.NewAppError("SqlPostStore.Search", "We encounted an error while searching for posts", "teamId="+teamId+", err="+err.Error())
}
list := &model.PostList{Order: make([]string, 0, len(posts))}
diff --git a/store/sql_post_store_test.go b/store/sql_post_store_test.go
index 9a7679454..b2256417e 100644
--- a/store/sql_post_store_test.go
+++ b/store/sql_post_store_test.go
@@ -525,57 +525,57 @@ func TestPostStoreSearch(t *testing.T) {
o5.Hashtags = "#secret #howdy"
o5 = (<-store.Post().Save(o5)).Data.(*model.Post)
- r1 := (<-store.Post().Search(teamId, userId, "corey", false)).Data.(*model.PostList)
+ r1 := (<-store.Post().Search(teamId, userId, &model.SearchParams{Terms: "corey", IsHashtag: false})).Data.(*model.PostList)
if len(r1.Order) != 1 && r1.Order[0] != o1.Id {
t.Fatal("returned wrong search result")
}
- r3 := (<-store.Post().Search(teamId, userId, "new", false)).Data.(*model.PostList)
+ r3 := (<-store.Post().Search(teamId, userId, &model.SearchParams{Terms: "new", IsHashtag: false})).Data.(*model.PostList)
if len(r3.Order) != 2 && r3.Order[0] != o1.Id {
t.Fatal("returned wrong search result")
}
- r4 := (<-store.Post().Search(teamId, userId, "john", false)).Data.(*model.PostList)
+ r4 := (<-store.Post().Search(teamId, userId, &model.SearchParams{Terms: "john", IsHashtag: false})).Data.(*model.PostList)
if len(r4.Order) != 1 && r4.Order[0] != o2.Id {
t.Fatal("returned wrong search result")
}
- r5 := (<-store.Post().Search(teamId, userId, "matter*", false)).Data.(*model.PostList)
+ r5 := (<-store.Post().Search(teamId, userId, &model.SearchParams{Terms: "matter*", IsHashtag: false})).Data.(*model.PostList)
if len(r5.Order) != 1 && r5.Order[0] != o1.Id {
t.Fatal("returned wrong search result")
}
- r6 := (<-store.Post().Search(teamId, userId, "#hashtag", true)).Data.(*model.PostList)
+ r6 := (<-store.Post().Search(teamId, userId, &model.SearchParams{Terms: "#hashtag", IsHashtag: true})).Data.(*model.PostList)
if len(r6.Order) != 1 && r6.Order[0] != o4.Id {
t.Fatal("returned wrong search result")
}
- r7 := (<-store.Post().Search(teamId, userId, "#secret", true)).Data.(*model.PostList)
+ r7 := (<-store.Post().Search(teamId, userId, &model.SearchParams{Terms: "#secret", IsHashtag: true})).Data.(*model.PostList)
if len(r7.Order) != 1 && r7.Order[0] != o5.Id {
t.Fatal("returned wrong search result")
}
- r8 := (<-store.Post().Search(teamId, userId, "@thisshouldmatchnothing", true)).Data.(*model.PostList)
+ r8 := (<-store.Post().Search(teamId, userId, &model.SearchParams{Terms: "@thisshouldmatchnothing", IsHashtag: true})).Data.(*model.PostList)
if len(r8.Order) != 0 {
t.Fatal("returned wrong search result")
}
- r9 := (<-store.Post().Search(teamId, userId, "mattermost jersey", false)).Data.(*model.PostList)
+ r9 := (<-store.Post().Search(teamId, userId, &model.SearchParams{Terms: "mattermost jersey", IsHashtag: false})).Data.(*model.PostList)
if len(r9.Order) != 2 {
t.Fatal("returned wrong search result")
}
- r10 := (<-store.Post().Search(teamId, userId, "matter* jer*", false)).Data.(*model.PostList)
+ r10 := (<-store.Post().Search(teamId, userId, &model.SearchParams{Terms: "matter* jer*", IsHashtag: false})).Data.(*model.PostList)
if len(r10.Order) != 2 {
t.Fatal("returned wrong search result")
}
- r11 := (<-store.Post().Search(teamId, userId, "message blargh", false)).Data.(*model.PostList)
+ r11 := (<-store.Post().Search(teamId, userId, &model.SearchParams{Terms: "message blargh", IsHashtag: false})).Data.(*model.PostList)
if len(r11.Order) != 1 {
t.Fatal("returned wrong search result")
}
- r12 := (<-store.Post().Search(teamId, userId, "blargh>", false)).Data.(*model.PostList)
+ r12 := (<-store.Post().Search(teamId, userId, &model.SearchParams{Terms: "blargh>", IsHashtag: false})).Data.(*model.PostList)
if len(r12.Order) != 1 {
t.Fatal("returned wrong search result")
}
diff --git a/store/sql_store.go b/store/sql_store.go
index 692ac2664..d4d8fdf73 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
@@ -74,7 +79,14 @@ func NewSqlStore() Store {
// Check to see if it's the most current database schema version
if !model.IsCurrentVersion(schemaVersion) {
// If we are upgrading from the previous version then print a warning and continue
- if model.IsPreviousVersion(schemaVersion) {
+
+ // Special case
+ isSchemaVersion07 := false
+ if schemaVersion == "0.7.1" || schemaVersion == "0.7.0" {
+ isSchemaVersion07 = true
+ }
+
+ if model.IsPreviousVersion(schemaVersion) || isSchemaVersion07 {
l4g.Warn("The database schema version of " + schemaVersion + " appears to be out of date")
l4g.Warn("Attempting to upgrade the database schema version to " + model.CurrentVersion)
} else {
@@ -86,6 +98,13 @@ func NewSqlStore() Store {
}
}
+ // REMOVE in 1.2
+ if sqlStore.DoesTableExist("Sessions") {
+ if sqlStore.DoesColumnExist("Sessions", "AltId") {
+ sqlStore.GetMaster().Exec("DROP TABLE IF EXISTS Sessions")
+ }
+ }
+
sqlStore.team = NewSqlTeamStore(sqlStore)
sqlStore.channel = NewSqlChannelStore(sqlStore)
sqlStore.post = NewSqlPostStore(sqlStore)
@@ -363,14 +382,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 +399,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 +425,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_team_store.go b/store/sql_team_store.go
index de44782cf..380d979bd 100644
--- a/store/sql_team_store.go
+++ b/store/sql_team_store.go
@@ -5,6 +5,7 @@ package store
import (
"github.com/mattermost/platform/model"
+ "github.com/mattermost/platform/utils"
)
type SqlTeamStore struct {
@@ -28,6 +29,8 @@ func NewSqlTeamStore(sqlStore *SqlStore) TeamStore {
}
func (s SqlTeamStore) UpgradeSchemaIfNeeded() {
+ // REMOVE in 1.2
+ s.RemoveColumnIfExists("Teams", "AllowValet")
}
func (s SqlTeamStore) CreateIndexesIfNotExists() {
@@ -50,7 +53,7 @@ func (s SqlTeamStore) Save(team *model.Team) StoreChannel {
team.PreSave()
- if result.Err = team.IsValid(); result.Err != nil {
+ if result.Err = team.IsValid(*utils.Cfg.TeamSettings.RestrictTeamNames); result.Err != nil {
storeChannel <- result
close(storeChannel)
return
@@ -82,7 +85,7 @@ func (s SqlTeamStore) Update(team *model.Team) StoreChannel {
team.PreUpdate()
- if result.Err = team.IsValid(); result.Err != nil {
+ if result.Err = team.IsValid(*utils.Cfg.TeamSettings.RestrictTeamNames); result.Err != nil {
storeChannel <- result
close(storeChannel)
return
diff --git a/store/sql_user_store.go b/store/sql_user_store.go
index dc6b07a16..a2b317afa 100644
--- a/store/sql_user_store.go
+++ b/store/sql_user_store.go
@@ -41,6 +41,8 @@ func NewSqlUserStore(sqlStore *SqlStore) UserStore {
}
func (us SqlUserStore) UpgradeSchemaIfNeeded() {
+ // REMOVE in 1.2
+ us.CreateColumnIfNotExists("Users", "ThemeProps", "varchar(2000)", "character varying(2000)", "{}")
}
func (us SqlUserStore) CreateIndexesIfNotExists() {
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 de335cc2b..27731cee1 100644
--- a/store/store.go
+++ b/store/store.go
@@ -84,7 +84,7 @@ type PostStore interface {
GetPosts(channelId string, offset int, limit int) StoreChannel
GetPostsSince(channelId string, time int64) StoreChannel
GetEtag(channelId string) StoreChannel
- Search(teamId string, userId string, terms string, isHashtagSearch bool) StoreChannel
+ Search(teamId string, userId string, params *model.SearchParams) StoreChannel
GetForExport(channelId string) StoreChannel
}
@@ -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..15d6b217c 100644
--- a/utils/config.go
+++ b/utils/config.go
@@ -182,12 +182,14 @@ func getClientProperties(c *model.Config) map[string]string {
props["SiteName"] = c.TeamSettings.SiteName
props["EnableTeamCreation"] = strconv.FormatBool(c.TeamSettings.EnableTeamCreation)
+ props["RestrictTeamNames"] = strconv.FormatBool(*c.TeamSettings.RestrictTeamNames)
props["EnableOAuthServiceProvider"] = strconv.FormatBool(c.ServiceSettings.EnableOAuthServiceProvider)
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/admin_console/team_settings.jsx b/web/react/components/admin_console/team_settings.jsx
index da4299714..9ecd14a1e 100644
--- a/web/react/components/admin_console/team_settings.jsx
+++ b/web/react/components/admin_console/team_settings.jsx
@@ -31,6 +31,7 @@ export default class TeamSettings extends React.Component {
config.TeamSettings.RestrictCreationToDomains = ReactDOM.findDOMNode(this.refs.RestrictCreationToDomains).value.trim();
config.TeamSettings.EnableTeamCreation = ReactDOM.findDOMNode(this.refs.EnableTeamCreation).checked;
config.TeamSettings.EnableUserCreation = ReactDOM.findDOMNode(this.refs.EnableUserCreation).checked;
+ config.TeamSettings.RestrictTeamNames = ReactDOM.findDOMNode(this.refs.RestrictTeamNames).checked;
var MaxUsersPerTeam = 50;
if (!isNaN(parseInt(ReactDOM.findDOMNode(this.refs.MaxUsersPerTeam).value, 10))) {
@@ -209,6 +210,39 @@ export default class TeamSettings extends React.Component {
</div>
<div className='form-group'>
+ <label
+ className='control-label col-sm-4'
+ htmlFor='RestrictTeamNames'
+ >
+ {'Restrict Team Names: '}
+ </label>
+ <div className='col-sm-8'>
+ <label className='radio-inline'>
+ <input
+ type='radio'
+ name='RestrictTeamNames'
+ value='true'
+ ref='RestrictTeamNames'
+ defaultChecked={this.props.config.TeamSettings.RestrictTeamNames}
+ onChange={this.handleChange}
+ />
+ {'true'}
+ </label>
+ <label className='radio-inline'>
+ <input
+ type='radio'
+ name='RestrictTeamNames'
+ value='false'
+ defaultChecked={!this.props.config.TeamSettings.RestrictTeamNames}
+ onChange={this.handleChange}
+ />
+ {'false'}
+ </label>
+ <p className='help-text'>{'When true, You cannot create a team name with reserved words like www, admin, support, test, channel, etc'}</p>
+ </div>
+ </div>
+
+ <div className='form-group'>
<div className='col-sm-12'>
{serverError}
<button
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/create_comment.jsx b/web/react/components/create_comment.jsx
index 2df3dc40f..435c7d542 100644
--- a/web/react/components/create_comment.jsx
+++ b/web/react/components/create_comment.jsx
@@ -13,8 +13,10 @@ const MsgTyping = require('./msg_typing.jsx');
const FileUpload = require('./file_upload.jsx');
const FilePreview = require('./file_preview.jsx');
const Utils = require('../utils/utils.jsx');
+
const Constants = require('../utils/constants.jsx');
const ActionTypes = Constants.ActionTypes;
+const KeyCodes = Constants.KeyCodes;
export default class CreateComment extends React.Component {
constructor(props) {
@@ -25,6 +27,7 @@ export default class CreateComment extends React.Component {
this.handleSubmit = this.handleSubmit.bind(this);
this.commentMsgKeyPress = this.commentMsgKeyPress.bind(this);
this.handleUserInput = this.handleUserInput.bind(this);
+ this.handleArrowUp = this.handleArrowUp.bind(this);
this.handleUploadStart = this.handleUploadStart.bind(this);
this.handleFileUploadComplete = this.handleFileUploadComplete.bind(this);
this.handleUploadError = this.handleUploadError.bind(this);
@@ -32,6 +35,7 @@ export default class CreateComment extends React.Component {
this.removePreview = this.removePreview.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.getFileCount = this.getFileCount.bind(this);
+ this.handleResize = this.handleResize.bind(this);
PostStore.clearCommentDraftUploads();
@@ -40,13 +44,23 @@ export default class CreateComment extends React.Component {
messageText: draft.message,
uploadsInProgress: draft.uploadsInProgress,
previews: draft.previews,
- submitting: false
+ submitting: false,
+ windowWidth: Utils.windowWidth()
};
}
+ componentDidMount() {
+ window.addEventListener('resize', this.handleResize);
+ }
+ componentWillUnmount() {
+ window.removeEventListener('resize', this.handleResize);
+ }
+ handleResize() {
+ this.setState({windowWidth: Utils.windowWidth()});
+ }
componentDidUpdate(prevProps, prevState) {
if (prevState.uploadsInProgress < this.state.uploadsInProgress) {
$('.post-right__scroll').scrollTop($('.post-right__scroll')[0].scrollHeight);
- if ($(window).width() > 768) {
+ if (this.state.windowWidth > 768) {
$('.post-right__scroll').perfectScrollbar('update');
}
}
@@ -147,6 +161,26 @@ export default class CreateComment extends React.Component {
$('.post-right__scroll').perfectScrollbar('update');
this.setState({messageText: messageText});
}
+ handleArrowUp(e) {
+ if (e.keyCode === KeyCodes.UP && this.state.messageText === '') {
+ e.preventDefault();
+
+ const channelId = ChannelStore.getCurrentId();
+ const lastPost = PostStore.getCurrentUsersLatestPost(channelId, this.props.rootId);
+ if (!lastPost) {
+ return;
+ }
+
+ AppDispatcher.handleViewAction({
+ type: ActionTypes.RECIEVED_EDIT_POST,
+ refocusId: '#reply_textbox',
+ title: 'Comment',
+ message: lastPost.message,
+ postId: lastPost.id,
+ channelId: lastPost.channel_id
+ });
+ }
+ }
handleUploadStart(clientIds) {
let draft = PostStore.getCommentDraft(this.props.rootId);
@@ -279,6 +313,7 @@ export default class CreateComment extends React.Component {
<Textbox
onUserInput={this.handleUserInput}
onKeyPress={this.commentMsgKeyPress}
+ onKeyDown={this.handleArrowUp}
messageText={this.state.messageText}
createMessage='Add a comment...'
initialText=''
diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx
index 2581bdcca..035899592 100644
--- a/web/react/components/create_post.jsx
+++ b/web/react/components/create_post.jsx
@@ -37,6 +37,7 @@ export default class CreatePost extends React.Component {
this.onChange = this.onChange.bind(this);
this.getFileCount = this.getFileCount.bind(this);
this.handleArrowUp = this.handleArrowUp.bind(this);
+ this.handleResize = this.handleResize.bind(this);
PostStore.clearDraftUploads();
@@ -48,9 +49,17 @@ export default class CreatePost extends React.Component {
uploadsInProgress: draft.uploadsInProgress,
previews: draft.previews,
submitting: false,
- initialText: draft.messageText
+ initialText: draft.messageText,
+ windowWidth: Utils.windowWidth(),
+ windowHeigth: Utils.windowHeight()
};
}
+ handleResize() {
+ this.setState({
+ windowWidth: Utils.windowWidth(),
+ windowHeight: Utils.windowHeight()
+ });
+ }
componentDidUpdate(prevProps, prevState) {
if (prevState.previews.length !== this.state.previews.length) {
this.resizePostHolder();
@@ -61,6 +70,11 @@ export default class CreatePost extends React.Component {
this.resizePostHolder();
return;
}
+
+ if (prevState.windowWidth !== this.state.windowWidth || prevState.windowHeight !== this.state.windowHeigth) {
+ this.resizePostHolder();
+ return;
+ }
}
getCurrentDraft() {
const draft = PostStore.getCurrentDraft();
@@ -194,10 +208,9 @@ export default class CreatePost extends React.Component {
PostStore.storeCurrentDraft(draft);
}
resizePostHolder() {
- const height = $(window).height() - $(ReactDOM.findDOMNode(this.refs.topDiv)).height() - 50;
+ const height = this.state.windowHeigth - $(ReactDOM.findDOMNode(this.refs.topDiv)).height() - 50;
$('.post-list-holder-by-time').css('height', `${height}px`);
- $(window).trigger('resize');
- if ($(window).width() > 960) {
+ if (this.state.windowWidth > 960) {
$('#post_textbox').focus();
}
}
@@ -274,9 +287,11 @@ export default class CreatePost extends React.Component {
componentDidMount() {
ChannelStore.addChangeListener(this.onChange);
this.resizePostHolder();
+ window.addEventListener('resize', this.handleResize);
}
componentWillUnmount() {
ChannelStore.removeChangeListener(this.onChange);
+ window.removeEventListener('resize', this.handleResize);
}
onChange() {
const channelId = ChannelStore.getCurrentId();
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/file_attachment.jsx b/web/react/components/file_attachment.jsx
index c6dff6550..57cccc4e0 100644
--- a/web/react/components/file_attachment.jsx
+++ b/web/react/components/file_attachment.jsx
@@ -10,9 +10,12 @@ export default class FileAttachment extends React.Component {
super(props);
this.loadFiles = this.loadFiles.bind(this);
+ this.playGif = this.playGif.bind(this);
+ this.stopGif = this.stopGif.bind(this);
+ this.addBackgroundImage = this.addBackgroundImage.bind(this);
this.canSetState = false;
- this.state = {fileSize: -1};
+ this.state = {fileSize: -1, mime: '', playing: false, loading: false, format: ''};
}
componentDidMount() {
this.loadFiles();
@@ -28,15 +31,9 @@ export default class FileAttachment extends React.Component {
var filename = this.props.filename;
if (filename) {
- var fileInfo = utils.splitFileLocation(filename);
+ var fileInfo = this.getFileInfoFromName(filename);
var type = utils.getFileType(fileInfo.ext);
- // This is a temporary patch to fix issue with old files using absolute paths
- if (fileInfo.path.indexOf('/api/v1/files/get') !== -1) {
- fileInfo.path = fileInfo.path.split('/api/v1/files/get')[1];
- }
- fileInfo.path = utils.getWindowLocationOrigin() + '/api/v1/files/get' + fileInfo.path;
-
if (type === 'image') {
var self = this; // Need this reference since we use the given "this"
$('<img/>').attr('src', fileInfo.path + '_thumb.jpg').load(function loadWrapper(path, name) {
@@ -58,11 +55,7 @@ export default class FileAttachment extends React.Component {
$(imgDiv).addClass('normal');
}
- var re1 = new RegExp(' ', 'g');
- var re2 = new RegExp('\\(', 'g');
- var re3 = new RegExp('\\)', 'g');
- var url = path.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29');
- $(imgDiv).css('background-image', 'url(' + url + '_thumb.jpg)');
+ self.addBackgroundImage(name, path);
}
};
}(fileInfo.path, filename));
@@ -93,6 +86,75 @@ export default class FileAttachment extends React.Component {
return true;
}
+ playGif(e, filename) {
+ var img = new Image();
+ var fileUrl = utils.getFileUrl(filename);
+
+ this.setState({loading: true});
+ img.load(fileUrl);
+ img.onload = () => {
+ var state = {playing: true, loading: false};
+
+ switch (true) {
+ case img.width > img.height:
+ state.format = 'landscape';
+ break;
+ case img.height > img.width:
+ state.format = 'portrait';
+ break;
+ default:
+ state.format = 'quadrat';
+ break;
+ }
+
+ this.setState(state);
+
+ // keep displaying background image for a short moment while browser is
+ // loading gif, to prevent white background flashing through
+ setTimeout(() => this.removeBackgroundImage.bind(this)(filename), 100);
+ };
+ img.onError = () => this.setState({loading: false});
+
+ e.stopPropagation();
+ }
+ stopGif(e, filename) {
+ this.setState({playing: false});
+ this.addBackgroundImage(filename);
+ e.stopPropagation();
+ }
+ getFileInfoFromName(name) {
+ var fileInfo = utils.splitFileLocation(name);
+
+ // This is a temporary patch to fix issue with old files using absolute paths
+ if (fileInfo.path.indexOf('/api/v1/files/get') !== -1) {
+ fileInfo.path = fileInfo.path.split('/api/v1/files/get')[1];
+ }
+ fileInfo.path = utils.getWindowLocationOrigin() + '/api/v1/files/get' + fileInfo.path;
+
+ return fileInfo;
+ }
+ addBackgroundImage(name, path) {
+ var fileUrl = path;
+
+ if (name in this.refs) {
+ if (!path) {
+ fileUrl = this.getFileInfoFromName(name).path;
+ }
+
+ var imgDiv = ReactDOM.findDOMNode(this.refs[name]);
+ var re1 = new RegExp(' ', 'g');
+ var re2 = new RegExp('\\(', 'g');
+ var re3 = new RegExp('\\)', 'g');
+ var url = fileUrl.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29');
+
+ $(imgDiv).css('background-image', 'url(' + url + '_thumb.jpg)');
+ }
+ }
+ removeBackgroundImage(name) {
+ if (name in this.refs) {
+ $(ReactDOM.findDOMNode(this.refs[name])).css('background-image', 'initial');
+ }
+ }
render() {
var filename = this.props.filename;
@@ -100,15 +162,71 @@ export default class FileAttachment extends React.Component {
var fileUrl = utils.getFileUrl(filename);
var type = utils.getFileType(fileInfo.ext);
- var thumbnail;
- if (type === 'image') {
- thumbnail = (
+ var playbackControls = '';
+ var loadedFile = '';
+ var loadingIndicator = '';
+ if (this.state.mime === 'image/gif') {
+ playbackControls = (
<div
- ref={filename}
- className='post__load'
- style={{backgroundImage: 'url(/static/images/load.gif)'}}
+ className='file-playback-controls play'
+ onClick={(e) => this.playGif(e, filename)}
+ >
+ {"►"}
+ </div>
+ );
+ }
+ if (this.state.playing) {
+ loadedFile = (
+ <img
+ className={'file__loaded ' + this.state.format}
+ src={fileUrl}
+ />
+ );
+ playbackControls = (
+ <div
+ className='file-playback-controls stop'
+ onClick={(e) => this.stopGif(e, filename)}
+ >
+ {"■"}
+ </div>
+ );
+ }
+ if (this.state.loading) {
+ loadingIndicator = (
+ <img
+ className='spinner file__loading'
+ src='/static/images/load.gif'
/>
);
+ playbackControls = '';
+ }
+
+ var thumbnail;
+ if (type === 'image') {
+ if (this.state.playing) {
+ thumbnail = (
+ <div
+ ref={filename}
+ className='post__load'
+ style={{backgroundImage: 'url(/static/images/load.gif)'}}
+ >
+ {playbackControls}
+ {loadedFile}
+ </div>
+ );
+ } else {
+ thumbnail = (
+ <div
+ ref={filename}
+ className='post__load'
+ style={{backgroundImage: 'url(/static/images/load.gif)'}}
+ >
+ {loadingIndicator}
+ {playbackControls}
+ {loadedFile}
+ </div>
+ );
+ }
} else {
thumbnail = <div className={'file-icon ' + utils.getIconClassName(type)}/>;
}
@@ -119,7 +237,7 @@ export default class FileAttachment extends React.Component {
filename,
function success(data) {
if (this.canSetState) {
- this.setState({fileSize: parseInt(data.size, 10)});
+ this.setState({fileSize: parseInt(data.size, 10), mime: data.mime});
}
}.bind(this),
function error() {}
diff --git a/web/react/components/file_upload_overlay.jsx b/web/react/components/file_upload_overlay.jsx
index 4fcee6cb0..d991dd625 100644
--- a/web/react/components/file_upload_overlay.jsx
+++ b/web/react/components/file_upload_overlay.jsx
@@ -12,9 +12,19 @@ export default class FileUploadOverlay extends React.Component {
return (
<div className={overlayClass}>
- <div>
- <i className='fa fa-upload'></i>
- <span>Drop a file to upload it.</span>
+ <div className='overlay__circle'>
+ <img
+ className='overlay__files'
+ src='/static/images/filesOverlay.png'
+ alt='Files'
+ />
+ <span><i className='fa fa-upload'></i>{'Drop a file to upload it.'}</span>
+ <img
+ className='overlay__logo'
+ src='/static/images/logoWhite.png'
+ width='100'
+ alt='Logo'
+ />
</div>
</div>
);
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/rhs_thread.jsx b/web/react/components/rhs_thread.jsx
index 467d74681..bcdec2870 100644
--- a/web/react/components/rhs_thread.jsx
+++ b/web/react/components/rhs_thread.jsx
@@ -4,7 +4,7 @@
var PostStore = require('../stores/post_store.jsx');
var UserStore = require('../stores/user_store.jsx');
var PreferenceStore = require('../stores/preference_store.jsx');
-var utils = require('../utils/utils.jsx');
+var Utils = require('../utils/utils.jsx');
var SearchBox = require('./search_bar.jsx');
var CreateComment = require('./create_comment.jsx');
var RhsHeaderPost = require('./rhs_header_post.jsx');
@@ -20,8 +20,12 @@ export default class RhsThread extends React.Component {
this.onChange = this.onChange.bind(this);
this.onChangeAll = this.onChangeAll.bind(this);
this.forceUpdateInfo = this.forceUpdateInfo.bind(this);
+ this.handleResize = this.handleResize.bind(this);
- this.state = this.getStateFromStores();
+ const state = this.getStateFromStores();
+ state.windowWidth = Utils.windowWidth();
+ state.windowHeight = Utils.windowHeight();
+ this.state = state;
}
getStateFromStores() {
var postList = PostStore.getSelectedPost();
@@ -47,9 +51,7 @@ export default class RhsThread extends React.Component {
PostStore.addChangeListener(this.onChangeAll);
PreferenceStore.addChangeListener(this.forceUpdateInfo);
this.resize();
- $(window).resize(function resize() {
- this.resize();
- }.bind(this));
+ window.addEventListener('resize', this.handleResize);
}
componentDidUpdate() {
if ($('.post-right__scroll')[0]) {
@@ -61,6 +63,7 @@ export default class RhsThread extends React.Component {
PostStore.removeSelectedPostChangeListener(this.onChange);
PostStore.removeChangeListener(this.onChangeAll);
PreferenceStore.removeChangeListener(this.forceUpdateInfo);
+ window.removeEventListener('resize', this.handleResize);
}
forceUpdateInfo() {
if (this.state.postList) {
@@ -71,9 +74,15 @@ export default class RhsThread extends React.Component {
}
}
}
+ handleResize() {
+ this.setState({
+ windowWidth: Utils.windowWidth(),
+ windowHeight: Utils.windowHeight()
+ });
+ }
onChange() {
var newState = this.getStateFromStores();
- if (!utils.areStatesEqual(newState, this.state)) {
+ if (!Utils.areStatesEqual(newState, this.state)) {
this.setState(newState);
}
}
@@ -103,15 +112,15 @@ export default class RhsThread extends React.Component {
}
var newState = this.getStateFromStores();
- if (!utils.areStatesEqual(newState, this.state)) {
+ if (!Utils.areStatesEqual(newState, this.state)) {
this.setState(newState);
}
}
resize() {
- var height = $(window).height() - $('#error_bar').outerHeight() - 100;
+ var height = this.state.windowHeight - $('#error_bar').outerHeight() - 100;
$('.post-right__scroll').css('height', height + 'px');
$('.post-right__scroll').scrollTop(100000);
- if ($(window).width() > 768) {
+ if (this.state.windowWidth > 768) {
$('.post-right__scroll').perfectScrollbar();
$('.post-right__scroll').perfectScrollbar('update');
}
diff --git a/web/react/components/search_results.jsx b/web/react/components/search_results.jsx
index e55fd3752..30e15d0ad 100644
--- a/web/react/components/search_results.jsx
+++ b/web/react/components/search_results.jsx
@@ -4,7 +4,7 @@
var PostStore = require('../stores/post_store.jsx');
var UserStore = require('../stores/user_store.jsx');
var SearchBox = require('./search_bar.jsx');
-var utils = require('../utils/utils.jsx');
+var Utils = require('../utils/utils.jsx');
var SearchResultsHeader = require('./search_results_header.jsx');
var SearchResultsItem = require('./search_results_item.jsx');
@@ -20,18 +20,19 @@ export default class SearchResults extends React.Component {
this.onChange = this.onChange.bind(this);
this.resize = this.resize.bind(this);
+ this.handleResize = this.handleResize.bind(this);
- this.state = getStateFromStores();
+ const state = getStateFromStores();
+ state.windowWidth = Utils.windowWidth();
+ state.windowHeight = Utils.windowHeight();
+ this.state = state;
}
componentDidMount() {
this.mounted = true;
PostStore.addSearchChangeListener(this.onChange);
this.resize();
- var self = this;
- $(window).resize(function resize() {
- self.resize();
- });
+ window.addEventListener('resize', this.handleResize);
}
componentDidUpdate() {
@@ -41,22 +42,30 @@ export default class SearchResults extends React.Component {
componentWillUnmount() {
PostStore.removeSearchChangeListener(this.onChange);
this.mounted = false;
+ window.removeEventListener('resize', this.handleResize);
+ }
+
+ handleResize() {
+ this.setState({
+ windowWidth: Utils.windowWidth(),
+ windowHeight: Utils.windowHeight()
+ });
}
onChange() {
if (this.mounted) {
var newState = getStateFromStores();
- if (!utils.areStatesEqual(newState, this.state)) {
+ if (!Utils.areStatesEqual(newState, this.state)) {
this.setState(newState);
}
}
}
resize() {
- var height = $(window).height() - $('#error_bar').outerHeight() - 100;
+ var height = this.state.windowHeight - $('#error_bar').outerHeight() - 100;
$('#search-items-container').css('height', height + 'px');
$('#search-items-container').scrollTop(0);
- if ($(window).width() > 768) {
+ if (this.state.windowWidth > 768) {
$('#search-items-container').perfectScrollbar();
}
}
diff --git a/web/react/components/sidebar.jsx b/web/react/components/sidebar.jsx
index 89506c028..d1fe37300 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');
@@ -31,9 +29,10 @@ export default class Sidebar extends React.Component {
this.onChange = this.onChange.bind(this);
this.onScroll = this.onScroll.bind(this);
- this.onResize = this.onResize.bind(this);
this.updateUnreadIndicators = this.updateUnreadIndicators.bind(this);
this.handleLeaveDirectChannel = this.handleLeaveDirectChannel.bind(this);
+ this.updateScrollbar = this.updateScrollbar.bind(this);
+ this.handleResize = this.handleResize.bind(this);
this.showNewChannelModal = this.showNewChannelModal.bind(this);
this.hideNewChannelModal = this.hideNewChannelModal.bind(this);
@@ -46,8 +45,9 @@ export default class Sidebar extends React.Component {
const state = this.getStateFromStores();
state.newChannelModalType = '';
- state.showMoreDirectChannelsModal = false;
+ state.showDirectChannelsModal = false;
state.loadingDMChannel = -1;
+ state.windowWidth = Utils.windowWidth();
this.state = state;
}
@@ -129,15 +129,13 @@ 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();
-
this.updateTitle();
this.updateUnreadIndicators();
+ this.updateScrollbar();
- $(window).on('resize', this.onResize);
+ window.addEventListener('resize', this.handleResize);
}
shouldComponentUpdate(nextProps, nextState) {
if (!Utils.areStatesEqual(nextProps, this.props)) {
@@ -152,111 +150,35 @@ export default class Sidebar extends React.Component {
componentDidUpdate() {
this.updateTitle();
this.updateUnreadIndicators();
+ this.updateScrollbar();
}
componentWillUnmount() {
- $(window).off('resize', this.onResize);
+ window.removeEventListener('resize', this.handleResize);
ChannelStore.removeChangeListener(this.onChange);
UserStore.removeChangeListener(this.onChange);
UserStore.removeStatusesChangeListener(this.onChange);
TeamStore.removeChangeListener(this.onChange);
- SocketStore.removeChangeListener(this.onSocketChange);
PreferenceStore.removeChangeListener(this.onChange);
}
+ handleResize() {
+ this.setState({
+ windowWidth: Utils.windowWidth(),
+ windowHeight: Utils.windowHeight()
+ });
+ }
+ updateScrollbar() {
+ if (this.state.windowWidth > 768) {
+ $('.nav-pills__container').perfectScrollbar();
+ $('.nav-pills__container').perfectScrollbar('update');
+ }
+ }
onChange() {
var newState = this.getStateFromStores();
if (!Utils.areStatesEqual(newState, this.state)) {
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) {
@@ -276,9 +198,6 @@ export default class Sidebar extends React.Component {
onScroll() {
this.updateUnreadIndicators();
}
- onResize() {
- this.updateUnreadIndicators();
- }
updateUnreadIndicators() {
const container = $(ReactDOM.findDOMNode(this.refs.container));
@@ -471,11 +390,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 +485,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 +532,7 @@ export default class Sidebar extends React.Component {
<h4>
{'Channels'}
<OverlayTrigger
- delayShow='500'
+ delayShow={500}
placement='top'
overlay={createChannelTootlip}
>
@@ -640,7 +565,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_url_page.jsx b/web/react/components/team_signup_url_page.jsx
index 67e4c9dd7..75ec2dfd9 100644
--- a/web/react/components/team_signup_url_page.jsx
+++ b/web/react/components/team_signup_url_page.jsx
@@ -40,10 +40,12 @@ export default class TeamSignupUrlPage extends React.Component {
return;
}
- for (let index = 0; index < Constants.RESERVED_TEAM_NAMES.length; index++) {
- if (cleanedName.indexOf(Constants.RESERVED_TEAM_NAMES[index]) === 0) {
- this.setState({nameError: 'URL is taken or contains a reserved word'});
- return;
+ if (global.window.config.RestrictTeamNames === 'true') {
+ for (let index = 0; index < Constants.RESERVED_TEAM_NAMES.length; index++) {
+ if (cleanedName.indexOf(Constants.RESERVED_TEAM_NAMES[index]) === 0) {
+ this.setState({nameError: 'URL is taken or contains a reserved word'});
+ return;
+ }
}
}
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..bea6ce7a5 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) {
@@ -37,7 +38,10 @@ export default class ViewImageModal extends React.Component {
progress: progress,
images: {},
fileSizes: {},
- showFooter: false
+ fileMimes: {},
+ showFooter: false,
+ isPlaying: {},
+ isLoading: {}
};
}
handleNext(e) {
@@ -63,11 +67,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();
}
}
@@ -121,6 +125,36 @@ export default class ViewImageModal extends React.Component {
this.setState({loaded});
}
}
+ playGif(e, filename, fileUrl) {
+ var isLoading = this.state.isLoading;
+ var isPlaying = this.state.isPlaying;
+
+ isLoading[filename] = fileUrl;
+ this.setState({isLoading});
+
+ var img = new Image();
+ img.load(fileUrl);
+ img.onload = () => {
+ delete isLoading[filename];
+ isPlaying[filename] = fileUrl;
+ this.setState({isPlaying, isLoading});
+ };
+ img.onError = () => {
+ delete isLoading[filename];
+ this.setState({isLoading});
+ };
+
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ stopGif(e, filename) {
+ var isPlaying = this.state.isPlaying;
+ delete isPlaying[filename];
+ this.setState({isPlaying});
+
+ e.stopPropagation();
+ e.preventDefault();
+ }
componentDidMount() {
$(window).on('keyup', this.handleKeyPress);
@@ -153,6 +187,10 @@ export default class ViewImageModal extends React.Component {
var fileType = Utils.getFileType(fileInfo.ext);
if (fileType === 'image') {
+ if (filename in this.state.isPlaying) {
+ return this.state.isPlaying[filename];
+ }
+
// This is a temporary patch to fix issue with old files using absolute paths
if (fileInfo.path.indexOf('/api/v1/files/get') !== -1) {
fileInfo.path = fileInfo.path.split('/api/v1/files/get')[1];
@@ -188,12 +226,62 @@ export default class ViewImageModal extends React.Component {
var fileType = Utils.getFileType(fileInfo.ext);
if (fileType === 'image') {
+ if (!(filename in this.state.fileMimes)) {
+ Client.getFileInfo(
+ filename,
+ (data) => {
+ if (this.canSetState) {
+ var fileMimes = this.state.fileMimes;
+ fileMimes[filename] = data.mime;
+ this.setState(fileMimes);
+ }
+ },
+ () => {}
+ );
+ }
+
+ var playbackControls = '';
+ if (this.state.fileMimes[filename] === 'image/gif' && !(filename in this.state.isLoading)) {
+ if (filename in this.state.isPlaying) {
+ playbackControls = (
+ <div
+ className='file-playback-controls stop'
+ onClick={(e) => this.stopGif(e, filename)}
+ >
+ {"■"}
+ </div>
+ );
+ } else {
+ playbackControls = (
+ <div
+ className='file-playback-controls play'
+ onClick={(e) => this.playGif(e, filename, fileUrl)}
+ >
+ {"►"}
+ </div>
+ );
+ }
+ }
+
+ var loadingIndicator = '';
+ if (this.state.isLoading[filename] === fileUrl) {
+ loadingIndicator = (
+ <img
+ className='spinner file__loading'
+ src='/static/images/load.gif'
+ />
+ );
+ playbackControls = '';
+ }
+
// image files just show a preview of the file
content = (
<a
href={fileUrl}
target='_blank'
>
+ {loadingIndicator}
+ {playbackControls}
<img
style={{maxHeight: this.state.imgHeight}}
ref='image'
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/post_store.jsx b/web/react/stores/post_store.jsx
index 8609d8bbf..0ace956d2 100644
--- a/web/react/stores/post_store.jsx
+++ b/web/react/stores/post_store.jsx
@@ -230,7 +230,7 @@ class PostStoreClass extends EventEmitter {
getPosts(channelId) {
return BrowserStore.getItem('posts_' + channelId);
}
- getCurrentUsersLatestPost(channelId) {
+ getCurrentUsersLatestPost(channelId, rootId) {
const userId = UserStore.getCurrentId();
var postList = makePostListNonNull(this.getPosts(channelId));
var i = 0;
@@ -239,8 +239,15 @@ class PostStoreClass extends EventEmitter {
for (i; i < len; i++) {
if (postList.posts[postList.order[i]].user_id === userId) {
- lastPost = postList.posts[postList.order[i]];
- break;
+ if (rootId) {
+ if (postList.posts[postList.order[i]].root_id === rootId || postList.posts[postList.order[i]].id === rootId) {
+ lastPost = postList.posts[postList.order[i]];
+ break;
+ }
+ } else {
+ lastPost = postList.posts[postList.order[i]];
+ break;
+ }
}
}
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/emoticons.jsx b/web/react/utils/emoticons.jsx
index 94bb91503..7b43e48b4 100644
--- a/web/react/utils/emoticons.jsx
+++ b/web/react/utils/emoticons.jsx
@@ -3,6 +3,7 @@
const emoticonPatterns = {
smile: /(^|\s)(:-?\))($|\s)/g, // :)
+ wink: /(^|\s)(;-?\))($|\s)/g, // ;)
open_mouth: /(^|\s)(:o)($|\s)/gi, // :o
scream: /(^|\s)(:-o)($|\s)/gi, // :-o
smirk: /(^|\s)(:-?])($|\s)/g, // :]
diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx
index 561c2c4c4..b9084b26e 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);
@@ -543,6 +544,7 @@ export function applyTheme(theme) {
if (theme.buttonBg) {
changeCss('.btn.btn-primary', 'background:' + theme.buttonBg, 1);
changeCss('.btn.btn-primary:hover, .btn.btn-primary:active, .btn.btn-primary:focus', 'background:' + changeColor(theme.buttonBg, -0.25), 1);
+ changeCss('.file-playback-controls', 'color:' + changeColor(theme.buttonBg, -0.25), 1);
}
if (theme.buttonColor) {
@@ -967,3 +969,11 @@ export function getShortenedTeamURL() {
}
return teamURL + '/';
}
+
+export function windowWidth() {
+ return $(window).width();
+}
+
+export function windowHeight() {
+ return $(window).height();
+}
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/_files.scss b/web/sass-files/sass/partials/_files.scss
index 01057423d..d3ab3b9f8 100644
--- a/web/sass-files/sass/partials/_files.scss
+++ b/web/sass-files/sass/partials/_files.scss
@@ -133,12 +133,34 @@
height: 100%;
background-color: #FFF;
background-repeat: no-repeat;
+ overflow: hidden;
+ position: relative;
+ text-align: center;
&.small {
background-position: center;
}
&.normal {
background-position: top left;
}
+ .spinner.file__loading {
+ position: absolute;
+ left: 50%;
+ margin-left: -16px;
+ top: 50%;
+ margin-top: -16px;
+ }
+ .file__loaded {
+ max-width: initial;
+ &.landscape, &.quadrat {
+ height: 100px;
+ }
+ &.portrait {
+ width: 120px;
+ }
+ }
+ &:hover .file-playback-controls.stop {
+ @include opacity(1);
+ }
}
.post-image__thumbnail {
float: left;
@@ -215,3 +237,20 @@
}
}
}
+
+.file-playback-controls {
+ position: absolute;
+ right: 5px;
+ bottom: 0;
+ font-size: 22px;
+ cursor: pointer;
+ z-index: 2;
+ -webkit-transition: opacity 0.6s;
+ -moz-transition: opacity 0.6s;
+ -o-transition: opacity 0.6s;
+ transition: opacity 0.6s;
+
+ &.stop {
+ @include opacity(0);
+ }
+}
diff --git a/web/sass-files/sass/partials/_modal.scss b/web/sass-files/sass/partials/_modal.scss
index b942a5a40..1dcdbf348 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;
@@ -223,11 +228,24 @@
background: #FFF;
display: table-cell;
vertical-align: middle;
+ position: relative;
+
+ &:hover .file-playback-controls.stop {
+ @include opacity(1);
+ }
}
img {
max-width: 100%;
max-height: 100%;
}
+ .spinner.file__loading {
+ z-index: 2;
+ position: absolute;
+ left: 50%;
+ margin-left: -16px;
+ top: 50%;
+ margin-top: -16px;
+ }
}
.modal-content{
box-shadow: none;
@@ -331,47 +349,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/_post.scss b/web/sass-files/sass/partials/_post.scss
index 0f3cc0ef6..6ecc0d965 100644
--- a/web/sass-files/sass/partials/_post.scss
+++ b/web/sass-files/sass/partials/_post.scss
@@ -119,20 +119,52 @@ body.ios {
background-color: rgba(0, 0, 0, 0.6);
text-align: center;
color: #FFF;
- display: table;
- font-size: 1.7em;
+ font-size: em(20px);
font-weight: 600;
z-index: 6;
- > div {
- display: table-cell;
- vertical-align: middle;
+ &.right-file-overlay {
+ font-size: em(18px);
+ .overlay__circle {
+ width: 300px;
+ height: 300px;
+ margin: -150px 0 0 -150px;
+ }
+ .overlay__files {
+ margin: 60px auto 15px;
+ width: 150px;
+ }
}
- .fa {
+ .overlay__circle {
+ background: #111;
+ background: rgba(black, 0.7);
+ width: 370px;
+ height: 370px;
+ margin: -185px 0 0 -185px;
+ @include border-radius(500px);
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ }
+
+ .overlay__files {
display: block;
- font-size: 2em;
- margin: 0 0 0.3em;
+ margin: 75px auto 20px;
+ }
+
+ .overlay__logo {
+ position: absolute;
+ left: 50%;
+ bottom: 30px;
+ margin-left: -50px;
+ @include opacity(0.3);
+ }
+
+ .fa {
+ display: inline-block;
+ font-size: 1.1em;
+ margin-right: 8px;
}
}
diff --git a/web/sass-files/sass/partials/_responsive.scss b/web/sass-files/sass/partials/_responsive.scss
index 09f2c179e..c8bb24f3a 100644
--- a/web/sass-files/sass/partials/_responsive.scss
+++ b/web/sass-files/sass/partials/_responsive.scss
@@ -199,9 +199,6 @@
}
@media screen and (max-width: 960px) {
- .center-file-overlay {
- font-size: 1.5em;
- }
.post {
.post-header .post-header-col.post-header__reply {
.comment-icon__container__hide {
@@ -267,11 +264,28 @@
}
}
.file-details {
+ width: 100%;
height: auto;
}
}
- .center-file-overlay {
- font-size: 1.3em;
+ .modal-direct-channels {
+ .member-count {
+ float: none;
+ margin-top: 10px;
+ display: block;
+ }
+ }
+ .file-overlay {
+ font-size: em(18px);
+ .overlay__circle {
+ width: 300px;
+ height: 300px;
+ margin: -150px 0 0 -150px;
+ }
+ .overlay__files {
+ margin: 60px auto 15px;
+ width: 150px;
+ }
}
.date-separator, .new-separator {
&.hovered--after {
@@ -631,6 +645,9 @@
}
&.has-close {
.btn-close {
+ width: 40px;
+ text-align: center;
+ right: 0;
display: block;
@include opacity(0.5);
}
@@ -690,7 +707,7 @@
.modal-image {
.image-wrapper {
font-size: 12px;
- min-width: 280px;
+ min-width: 250px;
.modal-close {
@include opacity(1);
}
@@ -741,6 +758,10 @@
.post-comments {
padding: 9px 21px 10px 10px !important;
}
+
+ .post-image__column .post__image .file-playback-controls.stop, .image-wrapper > a .file-playback-controls.stop {
+ @include opacity(1);
+ }
}
@media screen and (max-width: 640px) {
.access-history__table {
diff --git a/web/sass-files/sass/partials/_settings.scss b/web/sass-files/sass/partials/_settings.scss
index 0c2f25eab..bc53dc0e4 100644
--- a/web/sass-files/sass/partials/_settings.scss
+++ b/web/sass-files/sass/partials/_settings.scss
@@ -103,6 +103,9 @@
text-overflow: ellipsis;
margin-bottom: 0;
}
+ .input-group-addon {
+ background: transparent;
+ }
.radio {
label {
font-weight: 600;
@@ -230,13 +233,6 @@
font-weight:500;
}
-.profile-img {
- width:128px;
- height:128px;
- margin-bottom: 10px;
- @include border-radius(128px);
-}
-
.sel-btn {
margin-right:5px;
}
@@ -298,3 +294,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/filesOverlay.png b/web/static/images/filesOverlay.png
new file mode 100644
index 000000000..d24da7626
--- /dev/null
+++ b/web/static/images/filesOverlay.png
Binary files differ
diff --git a/web/static/images/logoWhite.png b/web/static/images/logoWhite.png
new file mode 100644
index 000000000..11bbd4632
--- /dev/null
+++ b/web/static/images/logoWhite.png
Binary files differ
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
}