summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md1
-rw-r--r--api/file.go54
-rw-r--r--api/file_test.go7
-rw-r--r--api/templates/verify_body.html2
-rw-r--r--api/templates/welcome_body.html4
-rw-r--r--doc/developer/tests/test-markdown-lists.md19
-rw-r--r--doc/help/Creating-Teams.md38
-rw-r--r--doc/help/README.md2
-rw-r--r--doc/help/Team-Settings.md2
-rw-r--r--doc/install/Configuration-Settings.md48
-rw-r--r--doc/install/LDAP-Setup.md34
-rw-r--r--doc/install/Production-Debian.md105
-rw-r--r--doc/install/Production-RHEL6.md231
-rw-r--r--doc/install/Production-RHEL7.md238
-rw-r--r--doc/install/Production-Ubuntu.md85
-rw-r--r--doc/process/release-process.md224
-rw-r--r--model/client.go2
-rw-r--r--model/config.go44
-rw-r--r--model/file_info.go72
-rw-r--r--model/file_info_test.go76
-rw-r--r--model/oauth.go2
-rw-r--r--web/react/components/admin_console/admin_sidebar.jsx9
-rw-r--r--web/react/components/admin_console/email_settings.jsx2
-rw-r--r--web/react/components/admin_console/ldap_settings.jsx26
-rw-r--r--web/react/components/admin_console/rate_settings.jsx2
-rw-r--r--web/react/components/admin_console/service_settings.jsx11
-rw-r--r--web/react/components/admin_console/team_settings.jsx2
-rw-r--r--web/react/components/posts_view.jsx160
-rw-r--r--web/react/components/rhs_root_post.jsx1
-rw-r--r--web/react/components/setting_item_min.jsx2
-rw-r--r--web/react/components/settings_sidebar.jsx7
-rw-r--r--web/react/components/sidebar.jsx10
-rw-r--r--web/react/components/sidebar_right.jsx19
-rw-r--r--web/react/components/signup_team.jsx4
-rw-r--r--web/react/components/suggestion/search_channel_provider.jsx2
-rw-r--r--web/react/components/suggestion/search_user_provider.jsx2
-rw-r--r--web/react/components/team_general_tab.jsx2
-rw-r--r--web/react/components/tutorial/tutorial_tip.jsx32
-rw-r--r--web/react/components/user_settings/custom_theme_chooser.jsx2
-rw-r--r--web/react/components/user_settings/manage_incoming_hooks.jsx9
-rw-r--r--web/react/components/user_settings/manage_outgoing_hooks.jsx9
-rw-r--r--web/react/components/user_settings/user_settings_appearance.jsx9
-rw-r--r--web/react/components/user_settings/user_settings_display.jsx25
-rw-r--r--web/react/components/view_image.jsx356
-rw-r--r--web/react/stores/file_store.jsx60
-rw-r--r--web/react/utils/async_client.jsx28
-rw-r--r--web/react/utils/client.jsx7
-rw-r--r--web/react/utils/constants.jsx1
-rw-r--r--web/react/utils/delayed_action.jsx27
-rw-r--r--web/react/utils/emoticons.jsx42
-rw-r--r--web/react/utils/utils.jsx14
-rw-r--r--web/sass-files/sass/partials/_base.scss4
-rw-r--r--web/sass-files/sass/partials/_content.scss2
-rw-r--r--web/sass-files/sass/partials/_files.scss5
-rw-r--r--web/sass-files/sass/partials/_modal.scss6
-rw-r--r--web/sass-files/sass/partials/_popover.scss5
-rw-r--r--web/sass-files/sass/partials/_post.scss64
-rw-r--r--web/sass-files/sass/partials/_post_right.scss4
-rw-r--r--web/sass-files/sass/partials/_responsive.scss36
-rw-r--r--web/sass-files/sass/partials/_search.scss9
-rw-r--r--web/sass-files/sass/partials/_settings.scss244
-rw-r--r--web/sass-files/sass/partials/_sidebar--right.scss6
-rw-r--r--web/sass-files/sass/partials/_tutorial.scss8
-rw-r--r--web/static/images/postArrows.pngbin0 -> 5684 bytes
-rw-r--r--web/static/js/velocity.min.js4
-rw-r--r--web/templates/head.html3
66 files changed, 1897 insertions, 675 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0bbb2be93..d5094f06e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -90,6 +90,7 @@ Multiple settings were added to [`config.json`](./config/config.json). These opt
#### Known Issues
+- System Console does not save Email Settings when "Save" is clicked
- When navigating to a page with new messages as well as message containing inline images added via markdown, the channel may move up and down while loading the inline images
- Microsoft Edge does not yet support drag and drop
- Media files of type .avi .mkv .wmv .mov .flv .mp4a do not play properly
diff --git a/api/file.go b/api/file.go
index 4339e610b..67ebc14b7 100644
--- a/api/file.go
+++ b/api/file.go
@@ -23,7 +23,6 @@ import (
"image/jpeg"
"io"
"io/ioutil"
- "mime"
"net/http"
"net/url"
"os"
@@ -323,25 +322,22 @@ func getFileInfo(c *Context, w http.ResponseWriter, r *http.Request) {
cchan := Srv.Store.Channel().CheckPermissionsTo(c.Session.TeamId, channelId, c.Session.UserId)
path := "teams/" + c.Session.TeamId + "/channels/" + channelId + "/users/" + userId + "/" + filename
- size := ""
+ var info *model.FileInfo
- if s, ok := fileInfoCache.Get(path); ok {
- size = s.(string)
+ if cached, ok := fileInfoCache.Get(path); ok {
+ info = cached.(*model.FileInfo)
} else {
-
fileData := make(chan []byte)
getFileAndForget(path, fileData)
- f := <-fileData
-
- if f == nil {
- c.Err = model.NewAppError("getFileInfo", "Could not find file.", "path="+path)
- c.Err.StatusCode = http.StatusNotFound
+ newInfo, err := model.GetInfoForBytes(filename, <-fileData)
+ if err != nil {
+ c.Err = err
return
+ } else {
+ fileInfoCache.Add(path, newInfo)
+ info = newInfo
}
-
- size = strconv.Itoa(len(f))
- fileInfoCache.Add(path, size)
}
if !c.HasPermissionsToChannel(cchan, "getFileInfo") {
@@ -350,19 +346,7 @@ 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)))
+ w.Write([]byte(info.ToJson()))
}
func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
@@ -538,7 +522,7 @@ func writeFile(f []byte, path string) *model.AppError {
auth.AccessKey = utils.Cfg.FileSettings.AmazonS3AccessKeyId
auth.SecretKey = utils.Cfg.FileSettings.AmazonS3SecretAccessKey
- s := s3.New(auth, aws.Regions[utils.Cfg.FileSettings.AmazonS3Region])
+ s := s3.New(auth, awsRegion())
bucket := s.Bucket(utils.Cfg.FileSettings.AmazonS3Bucket)
ext := filepath.Ext(path)
@@ -578,7 +562,7 @@ func readFile(path string) ([]byte, *model.AppError) {
auth.AccessKey = utils.Cfg.FileSettings.AmazonS3AccessKeyId
auth.SecretKey = utils.Cfg.FileSettings.AmazonS3SecretAccessKey
- s := s3.New(auth, aws.Regions[utils.Cfg.FileSettings.AmazonS3Region])
+ s := s3.New(auth, awsRegion())
bucket := s.Bucket(utils.Cfg.FileSettings.AmazonS3Bucket)
// try to get the file from S3 with some basic retry logic
@@ -629,3 +613,17 @@ func openFileWriteStream(path string) (io.Writer, *model.AppError) {
func closeFileWriteStream(file io.Writer) {
file.(*os.File).Close()
}
+
+func awsRegion() aws.Region {
+ if region, ok := aws.Regions[utils.Cfg.FileSettings.AmazonS3Region]; ok {
+ return region
+ }
+
+ return aws.Region{
+ Name: utils.Cfg.FileSettings.AmazonS3Region,
+ S3Endpoint: utils.Cfg.FileSettings.AmazonS3Endpoint,
+ S3BucketEndpoint: utils.Cfg.FileSettings.AmazonS3BucketEndpoint,
+ S3LocationConstraint: *utils.Cfg.FileSettings.AmazonS3LocationConstraint,
+ S3LowercaseBucket: *utils.Cfg.FileSettings.AmazonS3LowercaseBucket,
+ }
+}
diff --git a/api/file_test.go b/api/file_test.go
index b5501e4bd..b3fbd2a27 100644
--- a/api/file_test.go
+++ b/api/file_test.go
@@ -152,7 +152,6 @@ func TestGetFile(t *testing.T) {
channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel)
if utils.Cfg.FileSettings.DriverName != "" {
-
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("files", "test.png")
@@ -204,9 +203,9 @@ func TestGetFile(t *testing.T) {
if resp, downErr := Client.GetFileInfo(filenames[0]); downErr != nil {
t.Fatal(downErr)
} else {
- m := resp.Data.(map[string]string)
- if len(m["size"]) == 0 {
- t.Fail()
+ info := resp.Data.(*model.FileInfo)
+ if info.Size == 0 {
+ t.Fatal("No file size returned")
}
}
diff --git a/api/templates/verify_body.html b/api/templates/verify_body.html
index 97571d9e3..c42b2a372 100644
--- a/api/templates/verify_body.html
+++ b/api/templates/verify_body.html
@@ -17,7 +17,7 @@
<table border="0" cellpadding="0" cellspacing="0" style="padding: 20px 50px 0; text-align: center; margin: 0 auto">
<tr>
<td style="border-bottom: 1px solid #ddd; padding: 0 0 20px;">
- <h2 style="font-weight: normal; margin-top: 10px;">You've been invited</h2>
+ <h2 style="font-weight: normal; margin-top: 10px;">You've joined the {{ .Props.TeamDisplayName }} team</h2>
<p>Please verify your email address by clicking below.</p>
<p style="margin: 20px 0 15px">
<a href="{{.Props.VerifyUrl}}" style="background: #2389D7; border-radius: 3px; color: #fff; border: none; outline: none; min-width: 200px; padding: 15px 25px; font-size: 14px; font-family: inherit; cursor: pointer; -webkit-appearance: none;text-decoration: none;">Verify Email</a>
diff --git a/api/templates/welcome_body.html b/api/templates/welcome_body.html
index dbb94cf06..71d838b08 100644
--- a/api/templates/welcome_body.html
+++ b/api/templates/welcome_body.html
@@ -18,7 +18,7 @@
{{if .Props.VerifyUrl }}
<tr>
<td style="border-bottom: 1px solid #ddd; padding: 0 0 20px;">
- <h2 style="font-weight: normal; margin-top: 10px;">You've been invited</h2>
+ <h2 style="font-weight: normal; margin-top: 10px;">You've joined the {{ .Props.TeamDisplayName }} team</h2>
<p>Please verify your email address by clicking below.</p>
<p style="margin: 20px 0 15px">
<a href="{{.Props.VerifyUrl}}" style="background: #2389D7; border-radius: 3px; color: #fff; border: none; outline: none; min-width: 200px; padding: 15px 25px; font-size: 14px; font-family: inherit; cursor: pointer; -webkit-appearance: none;text-decoration: none;">Verify Email</a>
@@ -28,7 +28,7 @@
{{end}}
<tr>
<td style="border-bottom: 1px solid #ddd; padding: 0 0 20px;">
- <h2 style="font-weight: normal; margin-top: 10px;">You can sign-in to your new team from the web address:</h2>
+ <h2 style="font-weight: normal; margin-top: 25px; line-height: 1.5;">You can sign-in to your new team from the web address:</h2>
<a href="{{.Props.TeamURL}}">{{.Props.TeamURL}}</a>
<p>Mattermost lets you share messages and files from your PC or phone, with instant search and archiving.</p>
</td>
diff --git a/doc/developer/tests/test-markdown-lists.md b/doc/developer/tests/test-markdown-lists.md
index d5bbd82ac..7d080526a 100644
--- a/doc/developer/tests/test-markdown-lists.md
+++ b/doc/developer/tests/test-markdown-lists.md
@@ -195,6 +195,25 @@ This text should be on a new line.
- Two
This text should be on a new line.
+**Expected:**
+```
+List:
+
+- One
+- Two
+
+This line should have a line break above it.
+```
+
+**Actual:**
+
+List:
+
+- One
+- Two
+
+This line should have a line break above it.
+
### Task Lists
**Expected:**
diff --git a/doc/help/Creating-Teams.md b/doc/help/Creating-Teams.md
new file mode 100644
index 000000000..598f38e75
--- /dev/null
+++ b/doc/help/Creating-Teams.md
@@ -0,0 +1,38 @@
+# Creating Teams
+___
+New teams can be created if the System Administrator has *Enable Team Creation* set to true from the system console.
+
+## Methods to Create a Team
+Teams can be created from the main menu, system home page or team sign in page.
+
+#### Main Menu
+Click the **Three-Dot** menu in Mattermost, then select **Create a New Team**. If this option is not visible in the menu, then the System Administrator has *Enable Team Creation* set to false.
+
+
+#### System Home Page
+Navigate to the web address of your system, `https://domain.com/`. Enter a valid email address and click **Create Team** to be guided through the rest of the set up steps. If this option is not visible on the web page, then the System Administrator has *Enable Team Creation* set to false. It is not necessary to have an existing account on the system in order to create a team from the system home page.
+
+#### Team Sign In Page
+Navigate to the web address of your team, `https://domain.com/teamurl/`. If you are logged in, the web address will open Mattermost and you can create a new team from the main menu. If you are logged out, the web address will direct you to the sign in page where you can click **Create a New Team**. If this option is not visible on the web page, then the System Administrator has *Enable Team Creation* set to false. It is not necessary to have an existing account on the system in order to create a team from the sign in page.
+
+## Team Name and URL Selection
+There are a few details and restrictions to consider when selecting a team name and team URL.
+
+#### Team Name
+This is the display name of your team that appears in menus and headings.
+
+- It can contain any letters, numbers or symbols.
+- It is case sensitive.
+- It must be 4 - 15 characters in length.
+
+#### Team URL
+The team URL is part of the web address that navigates to your team on the system domain, `https://domain.com/teamurl/`.
+
+- It may contain only lowercase letters, numbers and dashes.
+- It must start with a letter and cannot end in a dash.
+- It must be 4 - 15 characters in length.
+
+If the system administrator has *Restrict Team Names* set to true, the team URL cannot start with the following restricted words: www, web, admin, support, notify, test, demo, mail, team, channel, internal, localhost, dockerhost, stag, post, cluster, api, oauth.
+
+## User Roles on Multiple Teams
+Each user is distinct and owned by a team. A team creator is automatically granted Team Administrator privileges for that team, even if they are a System Administrator on another team. A System Administrator with accounts on multiple teams must grant all their accounts *System Admin* privileges from the system console. To do this, go to the **Main Menu > System Console**, then click **Users** under the *Teams* heading for the team you want to manage.
diff --git a/doc/help/README.md b/doc/help/README.md
index 23c8b192d..fc11aebaa 100644
--- a/doc/help/README.md
+++ b/doc/help/README.md
@@ -2,6 +2,7 @@
- Getting Started
- [Sign-in](Sign-in.md)
+ - [Creating Teams](Creating-Teams.md)
- User Interface
- Main Menu
@@ -17,6 +18,7 @@
- [Channel Types](Channels.md#channel-types)
- [Managing Channels](Channels.md#managing-channels)
- [Channel Settings](Channels.md#channel-settings)
+ - [Notifications](Notifications.md)
- System Console
- Team
diff --git a/doc/help/Team-Settings.md b/doc/help/Team-Settings.md
index fead9f4ca..99960a575 100644
--- a/doc/help/Team-Settings.md
+++ b/doc/help/Team-Settings.md
@@ -38,7 +38,7 @@ Team Administrators would set this to **No** when they:
#### Invite Code
-When allowing anyone to sign-up from the login page, the **Invite Code** is used as part of the sign-up process. Clicking **Re-Generate** will invalidate the previous invitations and invitation URLs.
+The **Invite Code** is used as part of the URL in the team invitation link retrieved from the **Main Menu** > **Get Team Invite Link**. Click **Re-Generate** and then **Save** to generate a new team invitation link and invalidate the previous link.
### Import
diff --git a/doc/install/Configuration-Settings.md b/doc/install/Configuration-Settings.md
index 5f9b3b62d..74a7c777c 100644
--- a/doc/install/Configuration-Settings.md
+++ b/doc/install/Configuration-Settings.md
@@ -61,7 +61,7 @@ Maximum number of users per team, including both active and inactive users.
"true": Ability to create new accounts is enabled via inviting new members or sharing the team invite link; “false”: the ability to create accounts is disabled. The create account button displays an error when trying to signup via an email invite or team invite link.
```"RestrictCreationToDomains": ""```
-Teams can only be created by a verified email from this list of comma-separated domains (e.g. "corp.mattermost.com, mattermost.org").
+Teams and user accounts can only be created by a verified email from this list of comma-separated domains (e.g. "corp.mattermost.com, mattermost.org").
```"RestrictTeamNames": true```
"true": Newly created team names cannot contain the following restricted words: www, web, admin, support, notify, test, demo, mail, team, channel, internal, localhost, dockerhost, stag, post, cluster, api, oauth; “false”: Newly created team names are not restricted.
@@ -289,6 +289,52 @@ Enter `https://<your-gitlab-url>/oauth/authorize` (example: `https://example.com
```"UserApiEndpoint": ""```
Enter `https://<your-gitlab-url>/oauth/authorize` (example: `https://example.com:3000/api/v3/user`). Use HTTP or HTTPS depending on how your server is configured.
+### LDAP Settings (Enterprise)
+
+Settings used to enable and configure LDAP authentication with Mattermost. Available in the Enterprise version of Mattermost.
+
+```"Enable Login With LDAP": "false"```
+When true, Mattermost allows login using LDAP.
+
+```“LDAP Server”: “”```
+The domain or IP address of the LDAP server.
+
+```“LDAP Port”: “389”```
+The port Mattermost will use to connect to the LDAP server. Default is 389.
+
+```”BaseDN”: ””```
+The Base DN is the Distinguished Name of the location where Mattermost should start its search for users in the LDAP tree.
+
+```”Bind Username”: ””```
+The username used to perform the LDAP search. This should typically be an account created specifically for use with Mattermost. It should be a read only account with access limited to the portion of the LDAP tree specified in the BaseDN field.
+
+```”Bind Password”: ””```
+Password of the user given in “Bind Username”.
+
+```”First Name Attribute”: ””```
+The attribute in the LDAP server that will be used to populate the first name of users in Mattermost.
+
+```”Last Name Attribute”: ””```
+The attribute in the LDAP server that will be used to populate the last name of users in Mattermost.
+
+```”Email Attribute”: ””```
+The attribute in the LDAP server that will be used to populate the email addresses of users in Mattermost.
+
+```”Username Attribute”: ””```
+The attribute in the LDAP server that will be used to populate the username field in Mattermost. This may be the same as the ID Attribute.
+
+```”ID Attribute”: ””```
+
+The attribute in the LDAP server that will be used as a unique identifier in Mattermost.
+
+This is the attribute that will be used to create Mattermost accounts. It should be an LDAP attribute with a value that does not change, such as username or uid. If a user’s Id Attribute changes, it will create a new Mattermost account unassociated with their old one.
+
+This is also the value used to log in to Mattermost in the “LDAP Username” field on the sign in page. Normally this attribute is the same as the “Username Attribute” field above. If your team typically uses domain\username to sign in to other services with LDAP, you may choose to put domain\username in this field to maintain consistency between sites.
+
+```”Query Timeout (seconds)”: ”60”```
+
+The timeout value for queries to the LDAP server. Increase this value if you are getting timeout errors caused by a slow LDAP server.
+
## Config.json Settings Not in System Console
System Console allows an IT Admin to update settings defined in `config.json`. However there are a number of settings in `config.json` unavailable in the System Console and require update from the file itself. We describe them here:
diff --git a/doc/install/LDAP-Setup.md b/doc/install/LDAP-Setup.md
new file mode 100644
index 000000000..a619e645e
--- /dev/null
+++ b/doc/install/LDAP-Setup.md
@@ -0,0 +1,34 @@
+## LDAP Setup
+
+LDAP authentication is available in the Enterprise version of Mattermost.
+### How to enable LDAP
+
+After installing Mattermost:
+
+1. Create a team using email authentication
+ - Note: The first account used to create a team will be the “System Administrator” account, used to configure settings for your Mattermost site
+ 3. Go to Main Menu (the three dots near your team name in the top left of your screen) > **System Console**
+ 4. Go to LDAP Settings
+ 5. Fill in the fields to set up Mattermost authentication with your LDAP server
+
+ After LDAP has been enabled, users should be able to go to your Mattermost site and sign in using their LDAP credentials. The “LDAP username” will be the attribute set in the “Id Attribute” field.
+
+ **Note: In the initial implementation of LDAP, if a user attribute changes on the LDAP server it will be updated the next time the user enters their credentials to log in to Mattermost. This includes if a user is made inactive or removed from an LDAP server. Synchronization with LDAP servers is planned in a future release.**
+
+### Switching System Administrator account to LDAP authentication
+
+If you would like to switch your System Administrator account to LDAP authentication, it is recommended you do the following:
+
+1. Create a new account using LDAP
+ - Note: If your LDAP credentials use the same email address as your System Administrator account, it is recommended you change the email on your System Administrator account by going to Main Menu -> Account Settings -> General -> Email. This will free up the email address so it can be used by the LDAP account.
+ 2. Sign in to your email based System Administrator account
+ 3. Navigate to the System Console
+ 4. Go to Teams -> Team Name -> Users, and find your new LDAP user account
+ 5. Promote your LDAP account to “System Administrator” using the dropdown menu beside the username
+ 6. Log in with your LDAP account
+ 7. Navigate to the System Console
+ 8. Go to Teams -> Team Name -> Users, and find your old email based System Administrator account
+ 9. Make the email account “Inactive” using the dropdown beside the username
+
+ **Note: If you make the email account inactive without promoting another account to System Administrator, you will lose your System Administrator privileges. This can be fixed by promoting another account to System Administrator using the command line.**
+
diff --git a/doc/install/Production-Debian.md b/doc/install/Production-Debian.md
index 13ff051a3..e33dd2960 100644
--- a/doc/install/Production-Debian.md
+++ b/doc/install/Production-Debian.md
@@ -13,6 +13,7 @@ Note: This install guide has been generously contributed by the Mattermost commu
* ``` sudo apt-get update```
* ``` sudo apt-get upgrade```
+
## Set up Database Server
1. For the purposes of this guide we will assume this server has an IP address of 10.10.10.1
1. Install PostgreSQL 9.3+ (or MySQL 5.6+)
@@ -31,22 +32,36 @@ Note: This install guide has been generously contributed by the Mattermost commu
* ```postgre=# \q```
1. You can exit the postgres account by typing:
* ``` exit```
+1. Allow Postgres to listen on all assigned IP Addresses
+ * ```sudo vi /etc/postgresql/9.3/main/postgresql.conf```
+ * Uncomment 'listen_addresses' and change 'localhost' to '*'
+1. Alter pg_hba.conf to allow the mattermost server to talk to the postgres database
+ * ```sudo vi /etc/postgresql/9.3/main/pg_hba.conf```
+ * Add the following line to the 'IPv4 local connections'
+ * host all all 10.10.10.2/32 md5
+1. Reload Postgres database
+ * ```sudo /etc/init.d/postgresql reload```
+1. Attempt to connect with the new created user to verify everything looks good
+ * ```psql --host=10.10.10.1 --dbname=mattermost --username=mmuser --password```
+ * ```mattermost=> \q```
+
## Set up Mattermost Server
1. For the purposes of this guide we will assume this server has an IP address of 10.10.10.2
1. Download the latest Mattermost Server by typing:
- * ``` wget https://github.com/mattermost/platform/releases/download/v1.1.0/mattermost.tar.gz```
+ * ``` wget https://github.com/mattermost/platform/releases/download/v1.3.0/mattermost.tar.gz```
1. Install Mattermost under /opt
- * ``` cd /opt```
* Unzip the Mattermost Server by typing:
* ``` tar -xvzf mattermost.tar.gz```
-1. Create the storage directory for files. We assume you will have attached a large drive for storage of images and files. For this setup we will assume the directory is located at `/mattermost/data`.
+ * ``` sudo mv mattermost /opt```
+1. Create the storage directory for files. We assume you will have attached a large drive for storage of images and files. For this setup we will assume the directory is located at `/opt/mattermost/data`.
* Create the directory by typing:
* ``` sudo mkdir -p /opt/mattermost/data```
1. Create a system user and group called mattermost that will run this service
- * ``` useradd -r mattermost -U```
+ * ``` sudo useradd -r mattermost -U```
* Set the mattermost account as the directory owner by typing:
* ``` sudo chown -R mattermost:mattermost /opt/mattermost```
+ * ``` sudo chmod -R g+w /opt/mattermost```
* Add yourself to the mattermost group to ensure you can edit these files:
* ``` sudo usermod -aG mattermost USERNAME```
1. Configure Mattermost Server by editing the config.json file at /opt/mattermost/config
@@ -183,7 +198,7 @@ esac
exit 0
```
* Make sure that /etc/init.d/mattermost is executable
- * ``` chmod +x /etc/init.d/mattermost```
+ * ``` sudo chmod +x /etc/init.d/mattermost```
1. On reboot, systemd will generate a unit file from the headers in this init script and install it in `/run/systemd/generator.late/`
## Set up Nginx Server
@@ -207,21 +222,22 @@ exit 0
* Create a configuration for Mattermost
* ``` sudo touch /etc/nginx/sites-available/mattermost```
* Below is a sample configuration with the minimum settings required to configure Mattermost
- ```
+```
server {
- server_name mattermost.example.com;
+ server_name mattermost.example.com;
+
location / {
- client_max_body_size 50M;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection "upgrade";
- proxy_set_header Host $http_host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_set_header X-Frame-Options SAMEORIGIN;
- proxy_pass http://localhost:8065;
+ client_max_body_size 50M;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Frame-Options SAMEORIGIN;
+ proxy_pass http://10.10.10.2:8065;
}
- }
+ }
```
* Remove the existing file with
* ``` sudo rm /etc/nginx/sites-enabled/default```
@@ -249,33 +265,46 @@ exit 0
Common Name (e.g. server FQDN or YOUR name) []:mattermost.example.com
Email Address []:admin@mattermost.example.com
```
-1. Modify the file at `/etc/nginx/sites-available/mattermost` and add the following lines
- *
+1. Run `openssl dhparam -out dhparam.pem 4096` (it will take some time).
+1. Modify the file at `/etc/nginx/sites-available/mattermost` and add the following lines:
```
server {
- listen 80;
- server_name mattermost.example.com;
- return 301 https://$server_name$request_uri;
+ listen 80;
+ server_name mattermost.example.com;
+ return 301 https://$server_name$request_uri;
}
-
+
server {
- listen 443 ssl;
- server_name mattermost.example.com;
-
- ssl on;
- ssl_certificate /home/mattermost/cert/mattermost.crt;
- ssl_certificate_key /home/mattermost/cert/mattermost.key;
- ssl_session_timeout 5m;
- ssl_protocols SSLv3 TLSv1 TLSv1.1 TLSv1.2;
- ssl_ciphers "HIGH:!aNULL:!MD5 or HIGH:!aNULL:!MD5:!3DES";
- ssl_prefer_server_ciphers on;
- ssl_session_cache shared:SSL:10m;
+ listen 443 ssl;
+ server_name mattermost.example.com;
+
+ ssl on;
+ ssl_certificate /home/ubuntu/cert/mattermost.crt;
+ ssl_certificate_key /home/ubuntu/cert/mattermost.key;
+ ssl_dhparam /home/ubuntu/cert/dhparam.pem;
+ ssl_session_timeout 5m;
+ ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
+ ssl_ciphers 'EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH';
+ ssl_prefer_server_ciphers on;
+ ssl_session_cache shared:SSL:10m;
- # add to location / above
- location / {
- gzip off;
- proxy_set_header X-Forwarded-Ssl on;
+ location / {
+ gzip off;
+ proxy_set_header X-Forwarded-Ssl on;
+ client_max_body_size 50M;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Frame-Options SAMEORIGIN;
+ proxy_pass http://10.10.10.2:8065;
+ }
+ }
```
+
+
## 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.
diff --git a/doc/install/Production-RHEL6.md b/doc/install/Production-RHEL6.md
new file mode 100644
index 000000000..d73295ebc
--- /dev/null
+++ b/doc/install/Production-RHEL6.md
@@ -0,0 +1,231 @@
+# Production Installation on Red Hat Enterprise Linux 6.6
+
+## Install Red Hat Enterprise Linux (x64) 6.6
+1. Set up 3 machines with RHEL with 2GB of RAM or more. The servers will be used for the Load Balancer, Mattermost (this must be x64 to use pre-built binaries), and Database.
+ - **Optional:** You can also use a single machine for all 3 components in this install guide, depending on the standards of your data center.
+2. Make sure the system is up to date with the most recent security patches.
+ * ``` sudo yum update```
+ * ``` sudo yum upgrade```
+
+## Set up Database Server
+1. For the purposes of this guide we will assume this server has an IP address of `10.10.10.1`
+ - **Optional:** if installing on the same machine substitute `10.10.10.1` with `127.0.0.1`
+1. Install PostgreSQL 9.4+ (or MySQL 5.6+)
+ * ``` sudo yum install http://yum.postgresql.org/9.4/redhat/rhel-6-x86_64/pgdg-redhat94-9.4-1.noarch.rpm```
+ * ``` sudo yum install postgresql94-server postgresql94-contrib```
+ * ``` sudo service postgresql-9.4 initdb```
+ * ``` sudo chkconfig postgresql-9.4 on```
+ * ``` sudo service postgresql-9.4 start```
+1. PostgreSQL created a user account called `postgres`. You will need to log into that account with:
+ * ``` sudo -i -u postgres```
+1. You can get a PostgreSQL prompt by typing:
+ * ``` psql```
+1. Create the Mattermost database by typing:
+ * ```postgres=# CREATE DATABASE mattermost;```
+1. Create the Mattermost user by typing:
+ * ```postgres=# CREATE USER mmuser WITH PASSWORD 'mmuser_password';```
+1. Grant the user access to the Mattermost database by typing:
+ * ```postgres=# GRANT ALL PRIVILEGES ON DATABASE mattermost to mmuser;```
+1. You can exit out of PostgreSQL by typing:
+ * ```postgres=# \q```
+1. You can exit the Postgres account by typing:
+ * ``` exit```
+1. Allow Postgres to listen on all assigned IP Addresses:
+ * ```sudo vi /var/lib/pgsql/9.4/data/postgresql.conf```
+ * Uncomment 'listen_addresses' and change 'localhost' to '*'
+1. Alter `pg_hba.conf` to allow the Mattermost Server to talk to the Postgres database:
+ * ```sudo vi /var/lib/pgsql/9.4/data/pg_hba.conf```
+ * Add the following line to the 'IPv4 local connections':
+ * host all all 10.10.10.2/32 md5
+1. Reload Postgres database:
+ * ```sudo service postgresql-9.4 restart```
+1. Attempt to connect with the new created user to verify everything looks good:
+ * ```psql --host=10.10.10.1 --dbname=mattermost --username=mmuser --password```
+ * ```mattermost=> \q```
+
+
+## Set up Mattermost Server
+1. For the purposes of this guide we will assume this server has an IP address of `10.10.10.2`
+1. Download the latest Mattermost Server by typing:
+ * ``` wget https://github.com/mattermost/platform/releases/download/v1.3.0/mattermost.tar.gz```
+1. Install Mattermost under `/opt`
+ * Unzip the Mattermost Server by typing:
+ * ``` tar -xvzf mattermost.tar.gz```
+ * ``` sudo mv mattermost /opt```
+1. Create the storage directory for files. We assume you will have attached a large drive for storage of images and files. For this setup we will assume the directory is located at `/opt/mattermost/data`.
+ * Create the directory by typing:
+ * ``` sudo mkdir -p /opt/mattermost/data```
+1. Create a system user and group called mattermost that will run this service:
+ * ``` sudo useradd -r mattermost -U```
+ * Set the Mattermost account as the directory owner by typing:
+ * ``` sudo chown -R mattermost:mattermost /opt/mattermost```
+ * ``` sudo chmod -R g+w /opt/mattermost```
+ * Add yourself to the mattermost group to ensure you can edit these files:
+ * ``` sudo usermod -a -G mattermost USERNAME```
+1. Configure Mattermost Server by editing the `config.json` file at `/opt/mattermost/config`
+ * ``` cd /opt/mattermost/config```
+ * Edit the file by typing:
+ * ``` sudo vi config.json```
+ * replace `DriverName": "mysql"` with `DriverName": "postgres"`
+ * replace `"DataSource": "mmuser:mostest@tcp(dockerhost:3306)/mattermost_test?charset=utf8mb4,utf8"` with `"DataSource": "postgres://mmuser:mmuser_password@10.10.10.1:5432/mattermost?sslmode=disable&connect_timeout=10"`
+ * Optionally you may continue to edit configuration settings in `config.json` or use the System Console described in a later section to finish the configuration.
+1. Test the Mattermost Server
+ * ``` cd /opt/mattermost/bin```
+ * Run the Mattermost Server by typing:
+ * ``` sudo su mattermost```
+ * ``` ./platform```
+ * You should see a console log like `Server is listening on :8065` letting you know the service is running.
+ * Stop the server for now by typing `Ctrl-C`
+1. Setup Mattermost to use the Upstart daemon which handles supervision of the Mattermost process.
+ * ``` sudo touch /etc/init/mattermost.conf```
+ * ``` sudo vi /etc/init/mattermost.conf```
+ * Copy the following lines into `/etc/init/mattermost.conf`
+```
+start on runlevel [2345]
+stop on runlevel [016]
+respawn
+chdir /opt/mattermost
+exec bin/platform
+```
+ * You can manage the process by typing:
+ * ``` sudo start mattermost```
+ * Verify the service is running by typing:
+ * ``` curl http://10.10.10.2:8065```
+ * You should see a page titles *Mattermost - Signup*
+ * You can also stop the process by running the command ` sudo stop mattermost`, but we will skip this step for now.
+
+## Set up Nginx Server
+1. For the purposes of this guide we will assume this server has an IP address of `10.10.10.3`
+1. We use Nginx for proxying request to the Mattermost Server. The main benefits are:
+ * SSL termination
+ * HTTP to HTTPS redirect
+ * Port mapping :80 to :8065
+ * Standard request logs
+1. Install Nginx on RHEL with
+ * ``` sudo vi /etc/yum.repos.d/nginx.repo```
+ * Copy the below into the file
+```
+[nginx]
+name=nginx repo
+baseurl=http://nginx.org/packages/rhel/6/$basearch/
+gpgcheck=0
+enabled=1
+```
+ * ``` sudo yum install nginx.x86_64```
+ * ``` sudo service nginx start```
+ * ``` sudo chkconfig nginx on```
+1. Verify Nginx is running
+ * ``` curl http://10.10.10.3```
+ * You should see a *Welcome to nginx!* page
+1. Map a FQDN (fully qualified domain name) like **mattermost.example.com** to point to the Nginx server.
+1. Configure Nginx to proxy connections from the internet to the Mattermost Server
+ * Create a configuration for Mattermost
+ * ``` sudo touch /etc/nginx/conf.d/mattermost.conf```
+ * Below is a sample configuration with the minimum settings required to configure Mattermost
+```
+ server {
+ server_name mattermost.example.com;
+
+ location / {
+ client_max_body_size 50M;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Frame-Options SAMEORIGIN;
+ proxy_pass http://10.10.10.2:8065;
+ }
+ }
+```
+ * Remove the existing file with:
+ * ``` sudo mv /etc/nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf.bak```
+ * Restart Nginx by typing:
+ * ``` sudo service nginx restart```
+ * Verify you can see Mattermost thru the proxy by typing:
+ * ``` curl http://localhost```
+ * You should see a page titles *Mattermost - Signup*
+ * Not seeing the page? Look for errors with ``` sudo cat /var/log/audit/audit.log | grep nginx | grep denied```
+ * **Optional** if you're running on the same server as the Mattermost server and see 502 errors you may need to run `sudo setsebool -P httpd_can_network_connect true` because SELinux is preventing the connection
+
+## Set up Nginx with SSL (Recommended)
+1. You will need a SSL cert from a certificate authority.
+1. For simplicity we will generate a test certificate.
+ * ``` mkdir /top/mattermost/cert```
+ * ``` cd /top/mattermost/cert```
+ * ``` sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout mattermost.key -out mattermost.crt```
+ * Input the following info
+```
+ Country Name (2 letter code) [AU]:US
+ State or Province Name (full name) [Some-State]:California
+ Locality Name (eg, city) []:Palo Alto
+ Organization Name (eg, company) [Internet Widgits Pty Ltd]:Example LLC
+ Organizational Unit Name (eg, section) []:
+ Common Name (e.g. server FQDN or YOUR name) []:mattermost.example.com
+ Email Address []:admin@mattermost.example.com
+```
+1. Run `openssl dhparam -out dhparam.pem 4096` (it will take some time).
+1. Modify the file at `/etc/nginx/conf.d/mattermost.conf` and add the following lines
+```
+ server {
+ listen 80;
+ server_name mattermost.example.com;
+ return 301 https://$server_name$request_uri;
+ }
+
+ server {
+ listen 443 ssl;
+ server_name mattermost.example.com;
+
+ ssl on;
+ ssl_certificate /home/ubuntu/cert/mattermost.crt;
+ ssl_certificate_key /home/ubuntu/cert/mattermost.key;
+ ssl_dhparam /home/ubuntu/cert/dhparam.pem;
+ ssl_session_timeout 5m;
+ ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
+ ssl_ciphers 'EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH';
+ ssl_prefer_server_ciphers on;
+ ssl_session_cache shared:SSL:10m;
+
+ location / {
+ gzip off;
+ proxy_set_header X-Forwarded-Ssl on;
+ client_max_body_size 50M;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Frame-Options SAMEORIGIN;
+ proxy_pass http://10.10.10.2:8065;
+ }
+ }
+```
+
+## 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.
+ * Set *Send Email Notifications* to true
+ * Set *Require Email Verification* to true
+ * Set *Feedback Name* to `No-Reply`
+ * Set *Feedback Email* to `mattermost@example.com`
+ * Set *SMTP Username* to `AFIADTOVDKDLGERR`
+ * Set *SMTP Password* to `DFKJoiweklsjdflkjOIGHLSDFJewiskdjf`
+ * Set *SMTP Server* to `email-smtp.us-east-1.amazonaws.com`
+ * Set *SMTP Port* to `465`
+ * Set *Connection Security* to `TLS`
+ * Save the Settings
+1. Update File Settings:
+ * Change *Local Directory Location* from `./data/` to `/opt/mattermost/data`
+1. Update Log Settings:
+ * Set *Log to The Console* to `false`
+1. Update Rate Limit Settings:
+ * Set *Vary By Remote Address* to false
+ * Set *Vary By HTTP Header* to X-Real-IP
+1. Feel free to modify other settings
+1. Restart the Mattermost Service by typing:
+ * ``` sudo restart mattermost```
diff --git a/doc/install/Production-RHEL7.md b/doc/install/Production-RHEL7.md
new file mode 100644
index 000000000..4e003dd46
--- /dev/null
+++ b/doc/install/Production-RHEL7.md
@@ -0,0 +1,238 @@
+# Production Installation on Red Hat Enterprise Linux 7.1+
+
+## Install Red Hat Enterprise Linux (x64) 7.1+
+1. Set up 3 machines with RHEL with 2GB of RAM or more. The servers will be used for the Load Balancer, Mattermost (this must be x64 to use pre-built binaries), and Database.
+ - **Optional:** You can also use a single machine for all 3 components in this install guide, depending on the standards of your data center.
+2. Make sure the system is up to date with the most recent security patches.
+ * ``` sudo yum update```
+ * ``` sudo yum upgrade```
+
+## Set up Database Server
+1. For the purposes of this guide we will assume this server has an IP address of `10.10.10.1`
+ - **Optional:** if installing on the same machine substitute `10.10.10.1` with `127.0.0.1`
+1. Install PostgreSQL 9.4+ (or MySQL 5.6+)
+ * ``` sudo yum install http://yum.postgresql.org/9.4/redhat/rhel-6-x86_64/pgdg-redhat94-9.4-1.noarch.rpm```
+ * ``` sudo yum install postgresql94-server postgresql94-contrib```
+ * ``` sudo /usr/pgsql-9.4/bin/postgresql94-setup initdb```
+ * ``` sudo systemctl enable postgresql-9.4.service```
+ * ``` sudo systemctl start postgresql-9.4.service```
+1. PostgreSQL created a user account called `postgres`. You will need to log into that account with:
+ * ``` sudo -i -u postgres```
+1. You can get a PostgreSQL prompt by typing:
+ * ``` psql```
+1. Create the Mattermost database by typing:
+ * ```postgres=# CREATE DATABASE mattermost;```
+1. Create the Mattermost user by typing:
+ * ```postgres=# CREATE USER mmuser WITH PASSWORD 'mmuser_password';```
+1. Grant the user access to the Mattermost database by typing:
+ * ```postgres=# GRANT ALL PRIVILEGES ON DATABASE mattermost to mmuser;```
+1. You can exit out of PostgreSQL by typing:
+ * ```postgres=# \q```
+1. You can exit the Postgres account by typing:
+ * ``` exit```
+1. Allow Postgres to listen on all assigned IP Addresses:
+ * ```sudo vi /var/lib/pgsql/9.4/data/postgresql.conf```
+ * Uncomment 'listen_addresses' and change 'localhost' to '*'
+1. Alter `pg_hba.conf` to allow the Mattermost Server to talk to the Postgres database:
+ * ```sudo vi /var/lib/pgsql/9.4/data/pg_hba.conf```
+ * Add the following line to the 'IPv4 local connections':
+ * host all all 10.10.10.2/32 md5
+1. Reload Postgres database:
+ * ```sudo systemctl reload postgresql-9.4.service```
+1. Attempt to connect with the new created user to verify everything looks good:
+ * ```psql --host=10.10.10.1 --dbname=mattermost --username=mmuser --password```
+ * ```mattermost=> \q```
+
+
+## Set up Mattermost Server
+1. For the purposes of this guide we will assume this server has an IP address of `10.10.10.2`
+1. Download the latest Mattermost Server by typing:
+ * ``` wget https://github.com/mattermost/platform/releases/download/v1.3.0/mattermost.tar.gz```
+1. Install Mattermost under `/opt`
+ * Unzip the Mattermost Server by typing:
+ * ``` tar -xvzf mattermost.tar.gz```
+ * ``` sudo mv mattermost /opt```
+1. Create the storage directory for files. We assume you will have attached a large drive for storage of images and files. For this setup we will assume the directory is located at `/opt/mattermost/data`.
+ * Create the directory by typing:
+ * ``` sudo mkdir -p /opt/mattermost/data```
+1. Create a system user and group called mattermost that will run this service:
+ * ``` sudo useradd -r mattermost -U```
+ * Set the Mattermost account as the directory owner by typing:
+ * ``` sudo chown -R mattermost:mattermost /opt/mattermost```
+ * ``` sudo chmod -R g+w /opt/mattermost```
+ * Add yourself to the mattermost group to ensure you can edit these files:
+ * ``` sudo usermod -aG mattermost USERNAME```
+1. Configure Mattermost Server by editing the `config.json` file at `/opt/mattermost/config`
+ * ``` cd /opt/mattermost/config```
+ * Edit the file by typing:
+ * ``` sudo vi config.json```
+ * replace `DriverName": "mysql"` with `DriverName": "postgres"`
+ * replace `"DataSource": "mmuser:mostest@tcp(dockerhost:3306)/mattermost_test?charset=utf8mb4,utf8"` with `"DataSource": "postgres://mmuser:mmuser_password@10.10.10.1:5432/mattermost?sslmode=disable&connect_timeout=10"`
+ * Optionally you may continue to edit configuration settings in `config.json` or use the System Console described in a later section to finish the configuration.
+1. Test the Mattermost Server
+ * ``` cd /opt/mattermost/bin```
+ * Run the Mattermost Server by typing:
+ * ``` ./platform```
+ * You should see a console log like `Server is listening on :8065` letting you know the service is running.
+ * Stop the server for now by typing `Ctrl-C`
+1. Set up Mattermost to use the systemd init daemon which handles supervision of the Mattermost process:
+ * ``` sudo touch /etc/systemd/system/mattermost.service```
+ * ``` sudo vi /etc/systemd/system/mattermost.service```
+ * Copy the following lines into `/etc/systemd/system/mattermost.service`
+```
+[Unit]
+Description=Mattermost
+After=syslog.target network.target
+
+[Service]
+Type=simple
+WorkingDirectory=/opt/mattermost/bin
+User=mattermost
+ExecStart=/opt/mattermost/bin/platform
+PIDFile=/var/spool/mattermost/pid/master.pid
+
+[Install]
+WantedBy=multi-user.target
+```
+ * Make sure the service is executable with ``` sudo chmod 664 /etc/systemd/system/mattermost.service```
+ * Reload the services with `sudo systemctl daemon-reload`
+ * Start Mattermost service with `sudo systemctl start mattermost.service`
+ * `sudo chkconfig mattermost on`
+ * Start server on reboot `sudo systemctl enable mattermost.service`
+
+
+## Set up Nginx Server
+1. For the purposes of this guide we will assume this server has an IP address of `10.10.10.3`
+1. We use Nginx for proxying request to the Mattermost Server. The main benefits are:
+ * SSL termination
+ * HTTP to HTTPS redirect
+ * Port mapping :80 to :8065
+ * Standard request logs
+1. Install Nginx on RHEL with
+ * ``` sudo vi /etc/yum.repos.d/nginx.repo```
+ * Copy the below into the file
+```
+[nginx]
+name=nginx repo
+baseurl=http://nginx.org/packages/rhel/7/$basearch/
+gpgcheck=0
+enabled=1
+```
+ * ``` sudo yum install nginx.x86_64```
+ * ``` sudo service nginx start```
+ * ``` sudo chkconfig nginx on```
+1. Verify Nginx is running
+ * ``` curl http://10.10.10.3```
+ * You should see a *Welcome to nginx!* page
+1. Map a FQDN (fully qualified domain name) like **mattermost.example.com** to point to the Nginx server.
+1. Configure Nginx to proxy connections from the internet to the Mattermost Server
+ * Create a configuration for Mattermost
+ * ``` sudo touch /etc/nginx/conf.d/mattermost.conf```
+ * Below is a sample configuration with the minimum settings required to configure Mattermost
+```
+ server {
+ server_name mattermost.example.com;
+
+ location / {
+ client_max_body_size 50M;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Frame-Options SAMEORIGIN;
+ proxy_pass http://10.10.10.2:8065;
+ }
+ }
+```
+ * Remove the existing file with:
+ * ``` sudo mv /etc/nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf.bak```
+ * Restart Nginx by typing:
+ * ``` sudo service nginx restart```
+ * Verify you can see Mattermost thru the proxy by typing:
+ * ``` curl http://localhost```
+ * You should see a page titles *Mattermost - Signup*
+ * Not seeing the page? Look for errors with ``` sudo cat /var/log/audit/audit.log | grep nginx | grep denied```
+ * **Optional** if you're running on the same server as the Mattermost server and see 502 errors you may need to run `sudo setsebool -P httpd_can_network_connect true` because SELinux is preventing the connection
+
+## Set up Nginx with SSL (Recommended)
+1. You will need a SSL cert from a certificate authority.
+1. For simplicity we will generate a test certificate.
+ * ``` mkdir /top/mattermost/cert```
+ * ``` cd /top/mattermost/cert```
+ * ``` sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout mattermost.key -out mattermost.crt```
+ * Input the following info
+```
+ Country Name (2 letter code) [AU]:US
+ State or Province Name (full name) [Some-State]:California
+ Locality Name (eg, city) []:Palo Alto
+ Organization Name (eg, company) [Internet Widgits Pty Ltd]:Example LLC
+ Organizational Unit Name (eg, section) []:
+ Common Name (e.g. server FQDN or YOUR name) []:mattermost.example.com
+ Email Address []:admin@mattermost.example.com
+```
+1. Run `openssl dhparam -out dhparam.pem 4096` (it will take some time).
+1. Modify the file at `/etc/nginx/conf.d/mattermost.conf` and add the following lines
+```
+ server {
+ listen 80;
+ server_name mattermost.example.com;
+ return 301 https://$server_name$request_uri;
+ }
+
+ server {
+ listen 443 ssl;
+ server_name mattermost.example.com;
+
+ ssl on;
+ ssl_certificate /home/ubuntu/cert/mattermost.crt;
+ ssl_certificate_key /home/ubuntu/cert/mattermost.key;
+ ssl_dhparam /home/ubuntu/cert/dhparam.pem;
+ ssl_session_timeout 5m;
+ ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
+ ssl_ciphers 'EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH';
+ ssl_prefer_server_ciphers on;
+ ssl_session_cache shared:SSL:10m;
+
+ location / {
+ gzip off;
+ proxy_set_header X-Forwarded-Ssl on;
+ client_max_body_size 50M;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Frame-Options SAMEORIGIN;
+ proxy_pass http://10.10.10.2:8065;
+ }
+ }
+```
+
+## 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.
+ * Set *Send Email Notifications* to true
+ * Set *Require Email Verification* to true
+ * Set *Feedback Name* to `No-Reply`
+ * Set *Feedback Email* to `mattermost@example.com`
+ * Set *SMTP Username* to `AFIADTOVDKDLGERR`
+ * Set *SMTP Password* to `DFKJoiweklsjdflkjOIGHLSDFJewiskdjf`
+ * Set *SMTP Server* to `email-smtp.us-east-1.amazonaws.com`
+ * Set *SMTP Port* to `465`
+ * Set *Connection Security* to `TLS`
+ * Save the Settings
+1. Update File Settings:
+ * Change *Local Directory Location* from `./data/` to `/opt/mattermost/data`
+1. Update Log Settings:
+ * Set *Log to The Console* to `false`
+1. Update Rate Limit Settings:
+ * Set *Vary By Remote Address* to false
+ * Set *Vary By HTTP Header* to X-Real-IP
+1. Feel free to modify other settings
+1. Restart the Mattermost Service by typing:
+ * ``` sudo restart mattermost```
diff --git a/doc/install/Production-Ubuntu.md b/doc/install/Production-Ubuntu.md
index f8fcff978..e3f91f2a1 100644
--- a/doc/install/Production-Ubuntu.md
+++ b/doc/install/Production-Ubuntu.md
@@ -34,6 +34,9 @@
* host all all 10.10.10.2/32 md5
1. Reload Postgres database
* ```sudo /etc/init.d/postgresql reload```
+1. Attempt to connect with the new created user to verify everything looks good
+ * ```psql --host=10.10.10.1 --dbname=mattermost --username=mmuser --password```
+ * ```mattermost=> \q```
## Set up Mattermost Server
@@ -41,7 +44,7 @@
1. For the sake of making this guide simple we located the files at `/home/ubuntu/mattermost`. In the future we will give guidance for storing under `/opt`.
1. We have also elected to run the Mattermost Server as the `ubuntu` account for simplicity. We recommend setting up and running the service under a `mattermost` user account with limited permissions.
1. Download the latest Mattermost Server by typing:
- * ``` wget https://github.com/mattermost/platform/releases/download/v1.2.1/mattermost.tar.gz```
+ * ``` wget https://github.com/mattermost/platform/releases/download/v1.3.0/mattermost.tar.gz```
1. Unzip the Mattermost Server by typing:
* ``` tar -xvzf mattermost.tar.gz```
1. Create the storage directory for files. We assume you will have attached a large drive for storage of images and files. For this setup we will assume the directory is located at `/mattermost/data`.
@@ -62,7 +65,7 @@
* ``` ./platform```
* You should see a console log like `Server is listening on :8065` letting you know the service is running.
* Stop the server for now by typing `ctrl-c`
-1. Setup Mattermost to use the Ubuntu Upstart daemon which handles supervision of the Mattermost process.
+1. Setup Mattermost to use the Upstart daemon which handles supervision of the Mattermost process.
* ``` sudo touch /etc/init/mattermost.conf```
* ``` sudo vi /etc/init/mattermost.conf```
* Copy the following lines into `/etc/init/mattermost.conf`
@@ -73,7 +76,7 @@ respawn
chdir /home/ubuntu/mattermost
setuid ubuntu
exec bin/platform
-```
+```
* You can manage the process by typing:
* ``` sudo start mattermost```
* Verify the service is running by typing:
@@ -102,21 +105,22 @@ exec bin/platform
* Create a configuration for Mattermost
* ``` sudo touch /etc/nginx/sites-available/mattermost```
* Below is a sample configuration with the minimum settings required to configure Mattermost
- ```
+```
server {
- server_name mattermost.example.com;
+ server_name mattermost.example.com;
+
location / {
- client_max_body_size 50M;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection "upgrade";
- proxy_set_header Host $http_host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_set_header X-Frame-Options SAMEORIGIN;
- proxy_pass http://10.10.10.2:8065;
+ client_max_body_size 50M;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Frame-Options SAMEORIGIN;
+ proxy_pass http://10.10.10.2:8065;
}
- }
+ }
```
* Remove the existing file with
* ``` sudo rm /etc/nginx/sites-enabled/default```
@@ -148,32 +152,43 @@ exec bin/platform
4. Modify the file at `/etc/nginx/sites-available/mattermost` and add the following lines:
```
server {
- listen 80;
- server_name mattermost.example.com;
- return 301 https://$server_name$request_uri;
+ listen 80;
+ server_name mattermost.example.com;
+ return 301 https://$server_name$request_uri;
}
server {
- listen 443 ssl;
- server_name mattermost.example.com;
-
- ssl on;
- ssl_certificate /home/ubuntu/cert/mattermost.crt;
- ssl_certificate_key /home/ubuntu/cert/mattermost.key;
- ssl_dhparam /home/ubuntu/cert/dhparam.pem;
- ssl_session_timeout 5m;
- ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
- ssl_ciphers 'EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH';
- ssl_prefer_server_ciphers on;
- ssl_session_cache shared:SSL:10m;
+ listen 443 ssl;
+ server_name mattermost.example.com;
+
+ ssl on;
+ ssl_certificate /home/ubuntu/cert/mattermost.crt;
+ ssl_certificate_key /home/ubuntu/cert/mattermost.key;
+ ssl_dhparam /home/ubuntu/cert/dhparam.pem;
+ ssl_session_timeout 5m;
+ ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
+ ssl_ciphers 'EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH';
+ ssl_prefer_server_ciphers on;
+ ssl_session_cache shared:SSL:10m;
- # add to location / above
- location / {
- gzip off;
- proxy_set_header X-Forwarded-Ssl on;
+ location / {
+ gzip off;
+ proxy_set_header X-Forwarded-Ssl on;
+ client_max_body_size 50M;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Frame-Options SAMEORIGIN;
+ proxy_pass http://10.10.10.2:8065;
+ }
+ }
```
-## Finish Mattermost Server setup
+
+## 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
diff --git a/doc/process/release-process.md b/doc/process/release-process.md
index 294f5aa79..6e1cf6a77 100644
--- a/doc/process/release-process.md
+++ b/doc/process/release-process.md
@@ -9,114 +9,148 @@ Notes:
- T-minus counts are measured in "working days" (weekdays other than major holidays concurrent in US and Canada) prior to release day.
### (Code complete date of previous release) Beginning of release
-- Pre-work for the current release begins at the code complete date of the previous release. See "Code Complete" section below for details.
+
+Pre-work for the current release begins at the code complete date of the previous release. See "Code Complete" section below for details.
### (T-minus 10 working days) Cut-off for major features
-- No pull requests for major features should be submitted to the current release after this date (except if release manager decides to add "release-exception" label)
-- (Ops) Post this checklist in Release channel
-- (PM) Write compatibility updates for config.json and database changes [See example](https://github.com/mattermost/platform/blob/master/CHANGELOG.md#compatibility)
-- (PM) Confirm changes to config.json in compatibility section of Changelog are written back to [settings documentation](https://github.com/mattermost/platform/blob/master/doc/install/Configuration-Settings.md)
-- (Dev) Prioritize reviewing, updating, and merging of pull requests for current release until there are no more tickets in the [pull request queue](https://github.com/mattermost/platform/pulls) marked for the current release
-- (Leads) Meets to prioritize the final tickets of the release
- - Backlog is reviewed and major features that won’t make it are moved to next release
- - Triage tickets
- - Finalize roadmap for next release
- - Draft roadmap for release after next (used to prioritize design tasks)
-- (Marketing) Drafts marketing bullet points for next release based off of roadmap
-- (Marketing) Submits pull request for "Highlights" section of the Changelog
-- (Marketing) Notes date of announcements in release channel
-- (PM) Update [Upgrade Guide](https://github.com/mattermost/platform/blob/master/doc/install/Upgrade-Guide.md) for any steps needed to upgrade to new version
-- (PM) Prepare tickets for [cutting RC builds](https://mattermost.atlassian.net/browse/PLT-985), [creating the final release candidate](https://mattermost.atlassian.net/browse/PLT-986), [creating AMIs](https://mattermost.atlassian.net/browse/PLT-1213), and [testing GitLab RC with Mattermost](https://mattermost.atlassian.net/browse/PLT-1013)
-- (Stand-up) Each team member discusses worst bug (10-15s)
+No pull requests for major features should be submitted to the current release after this date (except if release manager decides to add "release-exception" label to Jira ticket).
+
+1. Logistics:
+ 1. Post this checklist in Release channel
+2. PM:
+ 1. Write compatibility updates for config.json and database changes [See example](https://github.com/mattermost/platform/blob/master/CHANGELOG.md#compatibility)
+  - Confirm changes to config.json in compatibility section of Changelog are written back to [settings documentation](https://github.com/mattermost/platform/blob/master/doc/install/Configuration-Settings.md)
+ - Update [Upgrade Guide](https://github.com/mattermost/platform/blob/master/doc/install/Upgrade-Guide.md) for any steps needed to upgrade to new version
+  - Prepare tickets for [cutting RC builds](https://mattermost.atlassian.net/browse/PLT-985), [creating the final release candidate](https://mattermost.atlassian.net/browse/PLT-986), [creating AMIs](https://mattermost.atlassian.net/browse/PLT-1213), and [testing GitLab RC with Mattermost](https://mattermost.atlassian.net/browse/PLT-1013)
+3. Dev:
+ 1. Prioritize reviewing, updating, and merging of pull requests for current release until there are no more tickets in the [pull request queue](https://github.com/mattermost/platform/pulls) marked for the current release
+4. Leads: Meet to prioritize the final tickets of the release
  
+ 1. Backlog is reviewed and major features that won’t make it are moved to next release
+  - Triage tickets
+  - Finalize roadmap for next release
+  - Draft roadmap for release after next (used to prioritize design tasks)
+5. Marketing:
+ 1. Drafts marketing bullet points for next release based off of roadmap
+ - Submits pull request for "Highlights" section of the Changelog
+ - Notes date of announcements in release channel
+6. Team:
+ 1. In Stand-up, each team member discusses worst bug (10-15s)
+
### (T-minus 8 working days) Feature Complete and Stabilization
-- No pull requests for features can be submitted to the current release after this date (except if release manager add "release-exception" label to Jira ticket)
-- (Ops) Post this checklist in Release channel
-- (Dev) Prioritize reviewing, updating, and merging of pull requests for current release until there are no more tickets in the [pull request queue](https://github.com/mattermost/platform/pulls) marked for the current release
-- **Stablization** period begins when all features for release have been committed.
- - During this period, only **bugs** can be committed to master. Non-bug pull requests are tagged for next version and wait until after a release candidate is cut to be committed to master
- - (RM) Exceptions can be made by release manager by setting priority to "Highest" and labelling "release-exception", which will add ticket to [Hotfix list for release candidate](https://mattermost.atlassian.net/issues/?filter=10204).
- - (PM) Review all [Severity 1 bugs (data loss or security)](https://mattermost.atlassian.net/secure/IssueNavigator.jspa?mode=hide&requestId=10600) to consider for adding to Hotfix list.
-- (PM) Complete documentation
- - (PM) Make Changelog PR with updates for latest feature additions and changes
- - (PM) Make Changelog PR with updates to contributors
- - (PM) Make NOTICE.txt PR for any new libraries added from dev, if not added already
- - (PM) Prioritize any developer documentation tickets
- - (PM) Draft [GitLab ticket](https://gitlab.com/gitlab-org/omnibus-gitlab/issues/942) to take next Mattermost version in the Omnibus, but do not post until RC1 is cut
-- (PM) Check testing is complete
- - (PM) Works with Ops to check the [Quality Gate](https://github.com/mattermost/process/blob/master/release/quality-gates.md) for feature complete
- - (PM + Dev) Sign-off testing of their feature areas (i.e. PM/dev either signs-off that their area is well tested, or they flag that potential quality issues may exist)
-- **(Team) Feature Complete Meeting (10:15am PST)**
- - (PM) Leads review of Changelog
+
+**Stablization** period begins when all features for release have been committed. During this period, only **bugs** can be committed to master. Non-bug pull requests are tagged for next version, and are not committed to master until after a release candidate is cut.
+
+Exceptions can be made by the release manager setting priority to "Highest" and adding a "release-exception" label to the Jira ticket. This will add the ticket to the [hotfix list for release candidate](https://mattermost.atlassian.net/issues/?filter=10204).
+
+1. Logistics:
+ 1. Post this checklist in Release channel
+ - Update the channel header to reflect date
+ - Mail out mugs to any new contributors
+- Dev:
+ 1. Prioritize reviewing, updating, and merging of pull requests for current release until there are no more tickets in the [pull request queue](https://github.com/mattermost/platform/pulls) marked for the current release
+- PM:
+ 1. Review all [Severity 1 bugs (data loss or security)](https://mattermost.atlassian.net/secure/IssueNavigator.jspa?mode=hide&requestId=10600) to consider for adding to Hotfix list
+ - Update documentation:
+ 1. Make Changelog PR with updates for latest feature additions, known issues, and contributors
+ - Make NOTICE.txt PR for any new libraries added from dev, if not added already
+ - Prioritize any developer documentation tickets
+ - Draft [GitLab ticket](https://gitlab.com/gitlab-org/omnibus-gitlab/issues/942) to take next Mattermost version in the Omnibus, but do not post until RC1 is cut
+ - Coordinate testing:
+ 1. Work with Ops to check the [Quality Gate](https://github.com/mattermost/process/blob/master/release/quality-gates.md) for feature complete
+ - Receive testing sign-off from feature area owners (i.e. PM/Dev either signs-off that their area is well tested, or flags potential quality issues that may exist)
+ - Check that Release Candidate Testing Spreadsheet is up to date
+- **(Team) Feature Complete Meeting (10:15am PST)**:
+ 1. (PM) Leads review of Changelog
- (Team) Each team member discusses worst bug (10-15s)
- (PM) Review feature list for next release
- - (Marketing) Share draft of marketing announce for next release
-- (Marketing) Communicates checklist of items needed by specific dates to write the blog post announce (e.g. screenshots, GIFs,
-- (Ops) Post Announce Date in Release channel + update the channel header to reflect date
-- (Ops) Mail out mugs to any new contributors
+ - (Marketing) Share draft of marketing bullet points for next release
+- Marketing:
+ 1. Communicate checklist of items needed by specific dates to write the blog post announce (e.g. screenshots, GIFs, documentation) and begins to write the blog post, tweet, and email for the release announcement
### (T-minus 5 working days) Code Complete and Release Candidate Cut
-- (Ops) Post this checklist in Release channel
-- (Ops) For the next release, create team meetings on Feature Complete and Code Complete dates
-- (PM) Remove "Under Development" notice for current release from Changelog on master
+
+1. Logistics:
+ 1. Post this checklist in Release channel
+ - For the next release, create team meetings on Feature Complete and Code Complete dates
+- PM:
+ 1. Remove "Under Development" notice for current release from Changelog on master
+ - Assign each area of the release testing spreadsheet to a team member
- **(Team) Code Complete Meeting (10:15am PST meeting)**
- - (Ops) Walks through each item of this checklist
- - (PM) Assigns each area of the release testing spreadsheet to a team member
+ 1. (Logistics) Walk through each item of this checklist
- (Dev) Last check of tickets that need to be merged before RC1
- (Team) Each team member discusses worst bug (10-15s)
- **Code Complete** is declared after meeting
- - (Dev) Prioritize reviewing, updating, and merging of pull requests for current release until there are no more tickets in the [pull request queue](https://github.com/mattermost/platform/pulls) marked for the current release
- - (Build) Master is tagged and branched and “Release Candidate 1″ is cut (e.g. 1.1.0-RC1) according to the [Release Candidate Checklist](https://github.com/mattermost/process/blob/master/release/create-release-candidate.md)
- - (PM) Create meta issue for regressions in GitHub (see [example](https://github.com/mattermost/platform/issues/574))
- - (PM) Include link to meta-issue in release notes of RC1
- - (PM) Tweet announcement that RC1 is ready (see [example](https://twitter.com/mattermosthq/status/664172166368264192))
- - (PM) Submit GitLab ticket to take next Mattermost version in the Omnibus
-
+- Dev:
+ 1. Prioritize reviewing, updating, and merging of pull requests for current release until there are no more tickets in the [pull request queue](https://github.com/mattermost/platform/pulls) marked for the current release
+- Build:
+ 1. Master is tagged and branched and “Release Candidate 1″ is cut (e.g. 1.1.0-RC1) according to the [Release Candidate Checklist](https://github.com/mattermost/process/blob/master/release/create-release-candidate.md)
+- PM:
+ 1.  Create meta issue for regressions in GitHub (see [example](https://github.com/mattermost/platform/issues/574))
+ - Include link to meta-issue in release notes of RC1
+ - Submit GitLab ticket to take next Mattermost version in the Omnibus
+- Marketing:
+ 1.  Tweet announcement that RC1 is ready (see [example](https://twitter.com/mattermosthq/status/664172166368264192))
+
### (T-minus 4 working days) Release Candidate Testing
-- (Team) Final testing is conducted by the team on the acceptance server and any issues found are filed
-- (Build) Tests upgrade from previous version to current version, following the [Upgrade Guide](https://github.com/mattermost/platform/blob/master/doc/install/Upgrade-Guide.md)
- - Database upgrade should be tested on both MySQL and Postgres
- - (Ops) Posts copy of the **Release Candidate Testing** checklist into Town Square in PRODUCTION
- - (Ops) Moves meeting, test and community channels over to the production version of RC, and posts in Town Square asking everyone to move communication over to the new team for testing purposes
- - (PM) Test feature areas and post bugs to Bugs/Issues in PRODUCTION
- - (Ops) Runs through general testing checklist on RC1 and post bugs to Bugs/Issues in PRODUCTION
- - (PM & DEV) Add **#p1** tag to any “Blocking” issue that looks like a hotfix to the RC is needed, and create a public ticket in Jira. Blocking issues are considered to be security issues, data loss issues, issues that break core functionality, or significantly impact aesthetics.
-- (PM) Updates the GitHub meta issue
- - (PM) Posts links to all issues found in RC as comments on the meta issue
- - (PM) Updates description to include approved fixes
- - (PM) Posts screenshot and link to final tickets for next RC to the Release room
- - (PM) Updates Release Notes with any new issues that will not be fixed for the current version
-- (PM & DEV leads) Triage hotfix candidates and decide on whether and when to cut next RC or final
-- (Dev) PRs for hotfixes made to release branch, and changes from release branch are merged into master
- - (Ops) Tests approved fixes on master
- - (Dev) Pushes next RC to acceptance after testing is complete and approved fixes merged
-- (Dev) pushes next RC to acceptance and announces in Town Square on PRODUCTION
- - (PM) closes the meta issue after the next RC is cut, and opens another ticket for new RC
-- (Ops) verifies each of the issues in meta ticket is fixed
- - (PM) If no blocking issues are found, PM, Dev and Ops signs off on the release
+1. Logistics:
+ 1. Post this checklist in Release channel
+ - Queue an agenda item for next team meeting for Release Process Kaizen/Q&A
+- Build:
+ 1. Test upgrade from previous version to current version, following the [Upgrade Guide](https://github.com/mattermost/platform/blob/master/doc/install/Upgrade-Guide.md)
+ - Database upgrade should be tested on both MySQL and Postgres
+- Team:
+ 1. Test assigned areas of the Release Candidate Testing Spreadsheet and file any bugs found in Jira
+ - Post a link to any "Blocking" issue that may need a hotfix to the RC in the Release room, with the **#p1** tag. If the issue is security related or contains confidential information, post the link in the Confidential Bugs private group. Blocking issues are considered to be security issues, data loss issues, issues that break core functionality, or significantly impact aesthetics.
+ - Triage hotfix candidates and decide on whether and when to cut next RC or final
+ - If no blocking issues are found, PM, Dev and Ops signs off on the release
+- PM:
+ 1. Post links to all issues found in RC as comments on the meta issue
+ - Update the meta issue description to include approved fixes
+ - Post screenshot and link to final tickets for next RC to the Release room
+ - Update Changelog “Known Issues” section with any significant issues that were found and not fixed for the final release
+- Dev:
+ 1. PRs for hotfixes made to release branch, and changes from release branch are merged into master
+- Logistics:
+ 1. For potentially destabilizing changes, test approved fixes on a spinmint private build
+- Build:
+ 1. Push next RC to acceptance after testing is complete and approved fixes merged, announces in Town Square on pre-release.mattermost.com/core
+- PM:
+ 1. Closes the meta issue after the next RC is cut, and opens another ticket for new RC
+- Ops:
+ 1. Verifies each of the issues in meta ticket is fixed
+
### (T-minus 2 working days) Release Build Cut
-- (Ops) Post this checklist in Release channel
-- (Build) Tags a new release (e.g. 1.1.0) and runs an official build which should be essentially identical to the last RC
- - (PM) Any significant issues that were found and not fixed for the final release are noted in the release notes
- - If an urgent and important issue needs to be addressed between major releases, a bug fix release (e.g. 1.1.1) may be created
-- (PM) Copy and paste the Release Notes from the Changelog to the Release Description
-- (PM) Update the mattermost.org/download page
-- (PM) Update the AMI links on mattermost.org/installation
-- (PM) Close final GitHub RC meta ticket
-- (Dev) Delete RCs after final version is shipped
-- (Marketing) Finalize marketing
- - (Marketing) Finalize mailchimp email blast
- - (Marketing) Finalize blog post and put on timer for release
- - (Marketing) Finalize tweet announcement
- - (Marketing) Finalize announcement on general mailing list
- - (Marketing) Finalize announcement for gitlab.mattermost.com
-
+
+The final release is cut. If an urgent and important issue needs to be addressed between major releases, a bug fix release (e.g. 1.1.1) may be created
+
+1. Logistics:
+ 1. Post this checklist in Release channel
+- Build:
+ 1. Tags a new release (e.g. 1.1.0) and runs an official build which should be essentially identical to the last RC
+ - Delete RCs after final version is shipped
+- PM:
+ 1. Copy and paste the Release Notes from the Changelog to the Release Description
+ - Update the mattermost.org/download page
+ - Update the AMI links on mattermost.org/download and mattermost.org/installation
+ - Close final GitHub RC meta ticket
+- Marketing:
+ 1. Finalize mailchimp email blast
+ - Finalize blog post and put on timer for release
+ - Finalize tweet announcement
+ - Finalize announcement on general mailing list
+ - Finalize announcement for gitlab.mattermost.com
+
### (T-minus 0 working days) Release Day
-- (Ops) Post this checklist in Release channel
-- (PM) Confirm marketing has been posted (animated GIFs, screenshots, mail announcement, Tweets, blog posts)
-- (PM) Close the release in Jira
-- (PM) Set header of next release as UNDER DEVELOPMENT in CHANGELOG on master
-- (Dev) Check if any libraries need to be updated for the next release, and if so bring up in weekly team meeting
-- (Ops) Post key dates for the next release in the header of the Release channel
-- (Ops) Queue an agenda item for next team meeting for Release Process Kaizen/Q&A
+
+1. Logistics:
+ 1. Post this checklist in Release channel
+ - Post key dates for the next release in the header of the Release channel
+- PM:
+ 1. Close the release in Jira
+ - Set header of next release as UNDER DEVELOPMENT in CHANGELOG on master
+- Dev:
+ 1. Check if any libraries need to be updated for the next release, and if so bring up in weekly team meeting
+- Marketing:
+ 1. Confirm marketing has been posted (animated GIFs, screenshots, mail announcement, tweets, blog posts)
diff --git a/model/client.go b/model/client.go
index 00cc1bdce..f1773f3c7 100644
--- a/model/client.go
+++ b/model/client.go
@@ -735,7 +735,7 @@ func (c *Client) GetFileInfo(url string) (*Result, *AppError) {
return nil, AppErrorFromJson(rp.Body)
} else {
return &Result{rp.Header.Get(HEADER_REQUEST_ID),
- rp.Header.Get(HEADER_ETAG_SERVER), MapFromJson(rp.Body)}, nil
+ rp.Header.Get(HEADER_ETAG_SERVER), FileInfoFromJson(rp.Body)}, nil
}
}
diff --git a/model/config.go b/model/config.go
index 38ef81a85..a4792ff9e 100644
--- a/model/config.go
+++ b/model/config.go
@@ -68,21 +68,25 @@ type LogSettings struct {
}
type FileSettings struct {
- DriverName string
- Directory string
- EnablePublicLink bool
- PublicLinkSalt string
- ThumbnailWidth int
- ThumbnailHeight int
- PreviewWidth int
- PreviewHeight int
- ProfileWidth int
- ProfileHeight int
- InitialFont string
- AmazonS3AccessKeyId string
- AmazonS3SecretAccessKey string
- AmazonS3Bucket string
- AmazonS3Region string
+ DriverName string
+ Directory string
+ EnablePublicLink bool
+ PublicLinkSalt string
+ ThumbnailWidth int
+ ThumbnailHeight int
+ PreviewWidth int
+ PreviewHeight int
+ ProfileWidth int
+ ProfileHeight int
+ InitialFont string
+ AmazonS3AccessKeyId string
+ AmazonS3SecretAccessKey string
+ AmazonS3Bucket string
+ AmazonS3Region string
+ AmazonS3Endpoint string
+ AmazonS3BucketEndpoint string
+ AmazonS3LocationConstraint *bool
+ AmazonS3LowercaseBucket *bool
}
type EmailSettings struct {
@@ -210,6 +214,16 @@ func (o *Config) SetDefaults() {
o.FileSettings.PublicLinkSalt = NewRandomString(32)
}
+ if o.FileSettings.AmazonS3LocationConstraint == nil {
+ o.FileSettings.AmazonS3LocationConstraint = new(bool)
+ *o.FileSettings.AmazonS3LocationConstraint = false
+ }
+
+ if o.FileSettings.AmazonS3LowercaseBucket == nil {
+ o.FileSettings.AmazonS3LowercaseBucket = new(bool)
+ *o.FileSettings.AmazonS3LowercaseBucket = false
+ }
+
if len(o.EmailSettings.InviteSalt) == 0 {
o.EmailSettings.InviteSalt = NewRandomString(32)
}
diff --git a/model/file_info.go b/model/file_info.go
new file mode 100644
index 000000000..741b4e55d
--- /dev/null
+++ b/model/file_info.go
@@ -0,0 +1,72 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package model
+
+import (
+ "bytes"
+ "encoding/json"
+ "image/gif"
+ "io"
+ "mime"
+ "path/filepath"
+)
+
+type FileInfo struct {
+ Filename string `json:"filename"`
+ Size int `json:"size"`
+ Extension string `json:"extension"`
+ MimeType string `json:"mime_type"`
+ HasPreviewImage bool `json:"has_preview_image"`
+}
+
+func GetInfoForBytes(filename string, data []byte) (*FileInfo, *AppError) {
+ size := len(data)
+
+ var mimeType string
+ extension := filepath.Ext(filename)
+ isImage := IsFileExtImage(extension)
+ if isImage {
+ mimeType = GetImageMimeType(extension)
+ } else {
+ mimeType = mime.TypeByExtension(extension)
+ }
+
+ hasPreviewImage := isImage
+ if mimeType == "image/gif" {
+ // just show the gif itself instead of a preview image for animated gifs
+ if gifImage, err := gif.DecodeAll(bytes.NewReader(data)); err != nil {
+ return nil, NewAppError("GetInfoForBytes", "Could not decode gif.", "filename="+filename)
+ } else {
+ hasPreviewImage = len(gifImage.Image) == 1
+ }
+ }
+
+ return &FileInfo{
+ Filename: filename,
+ Size: size,
+ Extension: extension[1:],
+ MimeType: mimeType,
+ HasPreviewImage: hasPreviewImage,
+ }, nil
+}
+
+func (info *FileInfo) ToJson() string {
+ b, err := json.Marshal(info)
+ if err != nil {
+ return ""
+ } else {
+ return string(b)
+ }
+}
+
+func FileInfoFromJson(data io.Reader) *FileInfo {
+ decoder := json.NewDecoder(data)
+
+ var info FileInfo
+ if err := decoder.Decode(&info); err != nil {
+ return nil
+ } else {
+ return &info
+ }
+}
diff --git a/model/file_info_test.go b/model/file_info_test.go
new file mode 100644
index 000000000..ecf0d509c
--- /dev/null
+++ b/model/file_info_test.go
@@ -0,0 +1,76 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package model
+
+import (
+ "encoding/base64"
+ "io/ioutil"
+ "testing"
+)
+
+func TestGetInfoForBytes(t *testing.T) {
+ fakeFile := make([]byte, 1000)
+
+ if info, err := GetInfoForBytes("file.txt", fakeFile); err != nil {
+ t.Fatal(err)
+ } else if info.Filename != "file.txt" {
+ t.Fatalf("Got incorrect filename: %v", info.Filename)
+ } else if info.Size != 1000 {
+ t.Fatalf("Got incorrect size: %v", info.Size)
+ } else if info.Extension != "txt" {
+ t.Fatalf("Git incorrect file extension: %v", info.Extension)
+ } else if info.MimeType != "text/plain; charset=utf-8" {
+ t.Fatalf("Got incorrect mime type: %v", info.MimeType)
+ } else if info.HasPreviewImage {
+ t.Fatalf("Got HasPreviewImage = true for non-image file")
+ }
+
+ if info, err := GetInfoForBytes("file.png", fakeFile); err != nil {
+ t.Fatal(err)
+ } else if info.Filename != "file.png" {
+ t.Fatalf("Got incorrect filename: %v", info.Filename)
+ } else if info.Size != 1000 {
+ t.Fatalf("Got incorrect size: %v", info.Size)
+ } else if info.Extension != "png" {
+ t.Fatalf("Git incorrect file extension: %v", info.Extension)
+ } else if info.MimeType != "image/png" {
+ t.Fatalf("Got incorrect mime type: %v", info.MimeType)
+ } else if !info.HasPreviewImage {
+ t.Fatalf("Got HasPreviewImage = false for image")
+ }
+
+ // base 64 encoded version of handtinywhite.gif from http://probablyprogramming.com/2009/03/15/the-tiniest-gif-ever
+ gifFile, _ := base64.StdEncoding.DecodeString("R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=")
+ if info, err := GetInfoForBytes("handtinywhite.gif", gifFile); err != nil {
+ t.Fatal(err)
+ } else if info.Filename != "handtinywhite.gif" {
+ t.Fatalf("Got incorrect filename: %v", info.Filename)
+ } else if info.Size != 35 {
+ t.Fatalf("Got incorrect size: %v", info.Size)
+ } else if info.Extension != "gif" {
+ t.Fatalf("Git incorrect file extension: %v", info.Extension)
+ } else if info.MimeType != "image/gif" {
+ t.Fatalf("Got incorrect mime type: %v", info.MimeType)
+ } else if !info.HasPreviewImage {
+ t.Fatalf("Got HasPreviewImage = false for static gif")
+ }
+
+ animatedGifFile, err := ioutil.ReadFile("../web/static/images/testgif.gif")
+ if err != nil {
+ t.Fatalf("Failed to load testgif.gif: %v", err.Error())
+ }
+ if info, err := GetInfoForBytes("testgif.gif", animatedGifFile); err != nil {
+ t.Fatal(err)
+ } else if info.Filename != "testgif.gif" {
+ t.Fatalf("Got incorrect filename: %v", info.Filename)
+ } else if info.Size != 38689 {
+ t.Fatalf("Got incorrect size: %v", info.Size)
+ } else if info.Extension != "gif" {
+ t.Fatalf("Git incorrect file extension: %v", info.Extension)
+ } else if info.MimeType != "image/gif" {
+ t.Fatalf("Got incorrect mime type: %v", info.MimeType)
+ } else if info.HasPreviewImage {
+ t.Fatalf("Got HasPreviewImage = true for animated gif")
+ }
+}
diff --git a/model/oauth.go b/model/oauth.go
index 19f3160fc..8336e26ba 100644
--- a/model/oauth.go
+++ b/model/oauth.go
@@ -20,7 +20,7 @@ const (
type OAuthApp struct {
Id string `json:"id"`
CreatorId string `json:"creator_id"`
- CreateAt int64 `json:"update_at"`
+ CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
ClientSecret string `json:"client_secret"`
Name string `json:"name"`
diff --git a/web/react/components/admin_console/admin_sidebar.jsx b/web/react/components/admin_console/admin_sidebar.jsx
index 587fb35ed..1279f4d22 100644
--- a/web/react/components/admin_console/admin_sidebar.jsx
+++ b/web/react/components/admin_console/admin_sidebar.jsx
@@ -255,15 +255,6 @@ export default class AdminSidebar extends React.Component {
<li>
<a
href='#'
- className={this.isSelected('ldap_settings')}
- onClick={this.handleClick.bind(this, 'ldap_settings', null)}
- >
- {'LDAP Settings'}
- </a>
- </li>
- <li>
- <a
- href='#'
className={this.isSelected('legal_and_support_settings')}
onClick={this.handleClick.bind(this, 'legal_and_support_settings', null)}
>
diff --git a/web/react/components/admin_console/email_settings.jsx b/web/react/components/admin_console/email_settings.jsx
index 42e3507d6..91d73dccd 100644
--- a/web/react/components/admin_console/email_settings.jsx
+++ b/web/react/components/admin_console/email_settings.jsx
@@ -52,7 +52,7 @@ export default class EmailSettings extends React.Component {
var config = this.props.config;
config.EmailSettings.EnableSignUpWithEmail = ReactDOM.findDOMNode(this.refs.allowSignUpWithEmail).checked;
config.EmailSettings.SendEmailNotifications = ReactDOM.findDOMNode(this.refs.sendEmailNotifications).checked;
- config.EmailSettings.SendPushlNotifications = ReactDOM.findDOMNode(this.refs.sendPushNotifications).checked;
+ config.EmailSettings.SendPushNotifications = ReactDOM.findDOMNode(this.refs.sendPushNotifications).checked;
config.EmailSettings.RequireEmailVerification = ReactDOM.findDOMNode(this.refs.requireEmailVerification).checked;
config.EmailSettings.FeedbackName = ReactDOM.findDOMNode(this.refs.feedbackName).value.trim();
config.EmailSettings.FeedbackEmail = ReactDOM.findDOMNode(this.refs.feedbackEmail).value.trim();
diff --git a/web/react/components/admin_console/ldap_settings.jsx b/web/react/components/admin_console/ldap_settings.jsx
index f8ea62192..6e3da2f72 100644
--- a/web/react/components/admin_console/ldap_settings.jsx
+++ b/web/react/components/admin_console/ldap_settings.jsx
@@ -92,6 +92,12 @@ export default class LdapSettings extends React.Component {
return (
<div className='wrapper--fixed'>
+ <div className='banner'>
+ <div className='banner__content'>
+ <h4 className='banner__heading'>{'Note:'}</h4>
+ <p>{'If a user attribute changes on the LDAP server it will be updated the next time the user enters their credentials to log in to Mattermost. This includes if a user is made inactive or removed from an LDAP server. Synchronization with LDAP servers is planned in a future release.'}</p>
+ </div>
+ </div>
<h3>{'LDAP Settings'}</h3>
<form
className='form-horizontal'
@@ -147,7 +153,7 @@ export default class LdapSettings extends React.Component {
onChange={this.handleChange}
disabled={!this.state.enable}
/>
- <p className='help-text'>{'The domain or ip address of LDAP server.'}</p>
+ <p className='help-text'>{'The domain or IP address of LDAP server.'}</p>
</div>
</div>
<div className='form-group'>
@@ -168,7 +174,7 @@ export default class LdapSettings extends React.Component {
onChange={this.handleChange}
disabled={!this.state.enable}
/>
- <p className='help-text'>{'The port to connect to the LDAP server on. Default is 389.'}</p>
+ <p className='help-text'>{'The port Mattermost will use to connect to the LDAP server. Default is 389.'}</p>
</div>
</div>
<div className='form-group'>
@@ -189,7 +195,7 @@ export default class LdapSettings extends React.Component {
onChange={this.handleChange}
disabled={!this.state.enable}
/>
- <p className='help-text'>{'The base dn where mattermost should search for users.'}</p>
+ <p className='help-text'>{'The Base DN is the Distinguished Name of the location where Mattermost should start its search for users in the LDAP tree.'}</p>
</div>
</div>
<div className='form-group'>
@@ -210,7 +216,7 @@ export default class LdapSettings extends React.Component {
onChange={this.handleChange}
disabled={!this.state.enable}
/>
- <p className='help-text'>{'Username of a user with read access to the LDAP server specified.'}</p>
+ <p className='help-text'>{'The username used to perform the LDAP search. This should typically be an account created specifically for use with Mattermost. It should have access limited to read the portion of the LDAP tree specified in the BaseDN field.'}</p>
</div>
</div>
<div className='form-group'>
@@ -231,7 +237,7 @@ export default class LdapSettings extends React.Component {
onChange={this.handleChange}
disabled={!this.state.enable}
/>
- <p className='help-text'>{'Password of the user given above.'}</p>
+ <p className='help-text'>{'Password of the user given in "Bind Username".'}</p>
</div>
</div>
<div className='form-group'>
@@ -252,7 +258,7 @@ export default class LdapSettings extends React.Component {
onChange={this.handleChange}
disabled={!this.state.enable}
/>
- <p className='help-text'>{'The first name attribute of entires in the LDAP server.'}</p>
+ <p className='help-text'>{'The attribute in the LDAP server that will be used to populate the first name of users in Mattermost.'}</p>
</div>
</div>
<div className='form-group'>
@@ -273,7 +279,7 @@ export default class LdapSettings extends React.Component {
onChange={this.handleChange}
disabled={!this.state.enable}
/>
- <p className='help-text'>{'The last name attribute of entries in the LDAP server.'}</p>
+ <p className='help-text'>{'The attribute in the LDAP server that will be used to populate the last name of users in Mattermost.'}</p>
</div>
</div>
<div className='form-group'>
@@ -294,7 +300,7 @@ export default class LdapSettings extends React.Component {
onChange={this.handleChange}
disabled={!this.state.enable}
/>
- <p className='help-text'>{'The email attribute of entries in the LDAP server.'}</p>
+ <p className='help-text'>{'The attribute in the LDAP server that will be used to populate the email addresses of users in Mattermost.'}</p>
</div>
</div>
<div className='form-group'>
@@ -315,7 +321,7 @@ export default class LdapSettings extends React.Component {
onChange={this.handleChange}
disabled={!this.state.enable}
/>
- <p className='help-text'>{'The attribute of entries in the LDAP server to use for username in Mattermost. May be the same as the ID Attribute.'}</p>
+ <p className='help-text'>{'The attribute in the LDAP server that will be used to populate the username field in Mattermost. This may be the same as the ID Attribute.'}</p>
</div>
</div>
<div className='form-group'>
@@ -336,7 +342,7 @@ export default class LdapSettings extends React.Component {
onChange={this.handleChange}
disabled={!this.state.enable}
/>
- <p className='help-text'>{'The attribute of entries in the LDAP server to use as a unique identifier. Users will use this to login. Ideally this would be the username they are used to loging in with. May be the same as the username attribute above.'}</p>
+ <p className='help-text'>{'The attribute in the LDAP server that will be used as a unique identifier in Mattermost. It should be an LDAP attribute with a value that does not change, such as username or uid. If a user’s Id Attribute changes, it will create a new Mattermost account unassociated with their old one. This is the value used to log in to Mattermost in the "LDAP Username" field on the sign in page. Normally this attribute is the same as the “Username Attribute” field above. If your team typically uses domain\\username to sign in to other services with LDAP, you may choose to put domain\\username in this field to maintain consistency between sites.'}</p>
</div>
</div>
<div className='form-group'>
diff --git a/web/react/components/admin_console/rate_settings.jsx b/web/react/components/admin_console/rate_settings.jsx
index ca9fcb074..aabb24326 100644
--- a/web/react/components/admin_console/rate_settings.jsx
+++ b/web/react/components/admin_console/rate_settings.jsx
@@ -241,7 +241,7 @@ export default class RateSettings extends React.Component {
onChange={this.handleChange}
disabled={!this.state.EnableRateLimiter || this.state.VaryByRemoteAddr}
/>
- <p className='help-text'>{'When filled in, vary rate limiting by HTTP header field specified (e.g. when configuring Ngnix set to "X-Real-IP", when configuring AmazonELB set to "X-Forwarded-For").'}</p>
+ <p className='help-text'>{'When filled in, vary rate limiting by HTTP header field specified (e.g. when configuring NGINX set to "X-Real-IP", when configuring AmazonELB set to "X-Forwarded-For").'}</p>
</div>
</div>
diff --git a/web/react/components/admin_console/service_settings.jsx b/web/react/components/admin_console/service_settings.jsx
index d7582d682..e235819fe 100644
--- a/web/react/components/admin_console/service_settings.jsx
+++ b/web/react/components/admin_console/service_settings.jsx
@@ -172,7 +172,16 @@ export default class ServiceSettings extends React.Component {
defaultValue={this.props.config.ServiceSettings.GoogleDeveloperKey}
onChange={this.handleChange}
/>
- <p className='help-text'>{'Set this key to enable embedding of YouTube video previews based on hyperlinks appearing in messages or comments. Instructions to obtain a key available at '}<a href='https://www.youtube.com/watch?v=Im69kzhpR3I'>{'https://www.youtube.com/watch?v=Im69kzhpR3I'}</a>{'. Leaving field blank disables the automatic generation of YouTube video previews from links.'}</p>
+ <p className='help-text'>
+ {'Set this key to enable embedding of YouTube video previews based on hyperlinks appearing in messages or comments. Instructions to obtain a key available at '}
+ <a
+ href='https://www.youtube.com/watch?v=Im69kzhpR3I'
+ target='_blank'
+ >
+ {'https://www.youtube.com/watch?v=Im69kzhpR3I'}
+ </a>
+ {'. Leaving the field blank disables the automatic generation of YouTube video previews from links.'}
+ </p>
</div>
</div>
diff --git a/web/react/components/admin_console/team_settings.jsx b/web/react/components/admin_console/team_settings.jsx
index 7991b9a01..9d958ce91 100644
--- a/web/react/components/admin_console/team_settings.jsx
+++ b/web/react/components/admin_console/team_settings.jsx
@@ -206,7 +206,7 @@ export default class TeamSettings extends React.Component {
defaultValue={this.props.config.TeamSettings.RestrictCreationToDomains}
onChange={this.handleChange}
/>
- <p className='help-text'>{'Teams can only be created from a specific domain (e.g. "mattermost.org") or list of comma-separated domains (e.g. "corp.mattermost.com, mattermost.org").'}</p>
+ <p className='help-text'>{'Teams and user accounts can only be created from a specific domain (e.g. "mattermost.org") or list of comma-separated domains (e.g. "corp.mattermost.com, mattermost.org").'}</p>
</div>
</div>
diff --git a/web/react/components/posts_view.jsx b/web/react/components/posts_view.jsx
index e116fdeea..a28efbd04 100644
--- a/web/react/components/posts_view.jsx
+++ b/web/react/components/posts_view.jsx
@@ -7,6 +7,7 @@ import * as EventHelpers from '../dispatcher/event_helpers.jsx';
import * as Utils from '../utils/utils.jsx';
import Post from './post.jsx';
import Constants from '../utils/constants.jsx';
+import DelayedAction from '../utils/delayed_action.jsx';
const Preferences = Constants.Preferences;
export default class PostsView extends React.Component {
@@ -15,18 +16,26 @@ export default class PostsView extends React.Component {
this.updateState = this.updateState.bind(this);
this.handleScroll = this.handleScroll.bind(this);
+ this.handleScrollStop = this.handleScrollStop.bind(this);
this.isAtBottom = this.isAtBottom.bind(this);
this.loadMorePostsTop = this.loadMorePostsTop.bind(this);
this.loadMorePostsBottom = this.loadMorePostsBottom.bind(this);
this.createPosts = this.createPosts.bind(this);
this.updateScrolling = this.updateScrolling.bind(this);
this.handleResize = this.handleResize.bind(this);
+ this.scrollToBottom = this.scrollToBottom.bind(this);
this.jumpToPostNode = null;
this.wasAtBottom = true;
this.scrollHeight = 0;
- this.state = {displayNameType: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', 'false')};
+ this.scrollStopAction = new DelayedAction(this.handleScrollStop);
+
+ this.state = {
+ displayNameType: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', 'false'),
+ isScrolling: false,
+ topPostId: null
+ };
}
static get SCROLL_TYPE_FREE() {
return 1;
@@ -69,6 +78,55 @@ export default class PostsView extends React.Component {
this.props.postViewScrolled(this.isAtBottom());
this.prevScrollHeight = this.refs.postlist.scrollHeight;
this.prevOffsetTop = this.jumpToPostNode.offsetTop;
+
+ this.updateFloatingTimestamp();
+
+ if (!this.state.isScrolling) {
+ this.setState({
+ isScrolling: true
+ });
+ }
+
+ this.scrollStopAction.fireAfter(1000);
+ }
+ handleScrollStop() {
+ this.setState({
+ isScrolling: false
+ });
+ }
+ updateFloatingTimestamp() {
+ // skip this in non-mobile view since that's when the timestamp is visible
+ if ($(window).width() > 768) {
+ return;
+ }
+
+ if (this.props.postList) {
+ // iterate through posts starting at the bottom since users are more likely to be viewing newer posts
+ for (let i = 0; i < this.props.postList.order.length; i++) {
+ const id = this.props.postList.order[i];
+ const element = ReactDOM.findDOMNode(this.refs[id]);
+
+ if (!element || element.offsetTop + element.clientHeight <= this.refs.postlist.scrollTop) {
+ // this post is off the top of the screen so the last one is at the top of the screen
+ let topPostId;
+
+ if (i > 0) {
+ topPostId = this.props.postList.order[i - 1];
+ } else {
+ // the first post we look at should always be on the screen, but handle that case anyway
+ topPostId = id;
+ }
+
+ if (topPostId !== this.state.topPostId) {
+ this.setState({
+ topPostId
+ });
+ }
+
+ break;
+ }
+ }
+ }
}
loadMorePostsTop() {
this.props.loadMorePostsTopClicked();
@@ -226,9 +284,7 @@ export default class PostsView extends React.Component {
}
updateScrolling() {
if (this.props.scrollType === PostsView.SCROLL_TYPE_BOTTOM) {
- window.requestAnimationFrame(() => {
- this.refs.postlist.scrollTop = this.refs.postlist.scrollHeight;
- });
+ this.scrollToBottom();
} else if (this.props.scrollType === PostsView.SCROLL_TYPE_NEW_MESSAGE) {
window.requestAnimationFrame(() => {
// If separator exists scroll to it. Otherwise scroll to bottom.
@@ -278,6 +334,11 @@ export default class PostsView extends React.Component {
handleResize() {
this.updateScrolling();
}
+ scrollToBottom() {
+ window.requestAnimationFrame(() => {
+ this.refs.postlist.scrollTop = this.refs.postlist.scrollHeight;
+ });
+ }
componentDidMount() {
if (this.props.postList != null) {
this.updateScrolling();
@@ -322,6 +383,12 @@ export default class PostsView extends React.Component {
if (nextState.displayNameType !== this.state.displayNameType) {
return true;
}
+ if (this.state.topPostId !== nextState.topPostId) {
+ return true;
+ }
+ if (this.state.isScrolling !== nextState.isScrolling) {
+ return true;
+ }
return false;
}
@@ -377,20 +444,36 @@ export default class PostsView extends React.Component {
}
}
+ let topPost = null;
+ if (this.state.topPostId) {
+ topPost = this.props.postList.posts[this.state.topPostId];
+ }
+
return (
- <div
- ref='postlist'
- className={'post-list-holder-by-time ' + activeClass}
- onScroll={this.handleScroll}
- >
- <div className='post-list__table'>
- <div
- ref='postlistcontent'
- className='post-list__content'
- >
- {moreMessagesTop}
- {postElements}
- {moreMessagesBottom}
+ <div className={activeClass}>
+ <FloatingTimestamp
+ isScrolling={this.state.isScrolling}
+ post={topPost}
+ />
+ <ScrollToBottomArrows
+ isScrolling={this.state.isScrolling}
+ atBottom={this.wasAtBottom}
+ onClick={this.scrollToBottom}
+ />
+ <div
+ ref='postlist'
+ className='post-list-holder-by-time'
+ onScroll={this.handleScroll}
+ >
+ <div className='post-list__table'>
+ <div
+ ref='postlistcontent'
+ className='post-list__content'
+ >
+ {moreMessagesTop}
+ {postElements}
+ {moreMessagesBottom}
+ </div>
</div>
</div>
</div>
@@ -414,3 +497,46 @@ PostsView.propTypes = {
messageSeparatorTime: React.PropTypes.number,
postsToHighlight: React.PropTypes.object
};
+
+function FloatingTimestamp({isScrolling, post}) {
+ // only show on mobile
+ if ($(window).width() > 768) {
+ return <noscript />;
+ }
+
+ if (!post) {
+ return <noscript />;
+ }
+
+ const dateString = Utils.getDateForUnixTicks(post.create_at).toDateString();
+
+ let className = 'post-list__timestamp';
+ if (isScrolling) {
+ className += ' scrolling';
+ }
+
+ return (
+ <div className={className}>
+ <span>{dateString}</span>
+ </div>
+ );
+}
+
+function ScrollToBottomArrows({isScrolling, atBottom, onClick}) {
+ // only show on mobile
+ if ($(window).width() > 768) {
+ return <noscript />;
+ }
+
+ let className = 'post-list__arrows';
+ if (isScrolling && !atBottom) {
+ className += ' scrolling';
+ }
+
+ return (
+ <div
+ className={className}
+ onClick={onClick}
+ />
+ );
+}
diff --git a/web/react/components/rhs_root_post.jsx b/web/react/components/rhs_root_post.jsx
index dd9a793be..cd7f6766c 100644
--- a/web/react/components/rhs_root_post.jsx
+++ b/web/react/components/rhs_root_post.jsx
@@ -227,7 +227,6 @@ export default class RhsRootPost extends React.Component {
</div>
</div>
</div>
- <hr />
</div>
);
}
diff --git a/web/react/components/setting_item_min.jsx b/web/react/components/setting_item_min.jsx
index 2a72be207..ffd2061fe 100644
--- a/web/react/components/setting_item_min.jsx
+++ b/web/react/components/setting_item_min.jsx
@@ -8,7 +8,7 @@ export default class SettingItemMin extends React.Component {
editButton = (
<li className='col-sm-2 section-edit'>
<a
- className='section-edit theme'
+ className='theme'
href='#'
onClick={this.props.updateSection}
>
diff --git a/web/react/components/settings_sidebar.jsx b/web/react/components/settings_sidebar.jsx
index 4af46c35a..271ea9a41 100644
--- a/web/react/components/settings_sidebar.jsx
+++ b/web/react/components/settings_sidebar.jsx
@@ -1,6 +1,8 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
+import * as Utils from '../utils/utils.jsx';
+
export default class SettingsSidebar extends React.Component {
componentDidUpdate() {
$('.settings-modal').find('.modal-body').scrollTop(0);
@@ -16,6 +18,11 @@ export default class SettingsSidebar extends React.Component {
this.props.updateTab(tab.name);
$(e.target).closest('.settings-modal').addClass('display--content');
}
+ componentDidMount() {
+ if (Utils.isBrowserFirefox()) {
+ $('.settings-modal .settings-table .nav').addClass('position--top');
+ }
+ }
render() {
let tabList = this.props.tabs.map(function makeTab(tab) {
let key = `${tab.name}_li`;
diff --git a/web/react/components/sidebar.jsx b/web/react/components/sidebar.jsx
index cc2279b57..18c360cb8 100644
--- a/web/react/components/sidebar.jsx
+++ b/web/react/components/sidebar.jsx
@@ -39,6 +39,7 @@ export default class Sidebar extends React.Component {
this.handleLeaveDirectChannel = this.handleLeaveDirectChannel.bind(this);
this.handleResize = this.handleResize.bind(this);
+ this.showMoreChannelsModal = this.showMoreChannelsModal.bind(this);
this.showNewChannelModal = this.showNewChannelModal.bind(this);
this.hideNewChannelModal = this.hideNewChannelModal.bind(this);
this.showMoreDirectChannelsModal = this.showMoreDirectChannelsModal.bind(this);
@@ -250,6 +251,11 @@ export default class Sidebar extends React.Component {
return a.display_name.localeCompare(b.display_name);
}
+ showMoreChannelsModal() {
+ // manually show the modal because using data-toggle messes with keyboard focus when the modal is dismissed
+ $('#more_channels').modal({'data-channeltype': 'O'}).modal('show');
+ }
+
showNewChannelModal(type) {
this.setState({newChannelModalType: type});
}
@@ -594,10 +600,8 @@ export default class Sidebar extends React.Component {
<li>
<a
href='#'
- data-toggle='modal'
className='nav-more'
- data-target='#more_channels'
- data-channeltype='O'
+ onClick={this.showMoreChannelsModal}
>
{'More...'}
</a>
diff --git a/web/react/components/sidebar_right.jsx b/web/react/components/sidebar_right.jsx
index ac1049da0..ee247265d 100644
--- a/web/react/components/sidebar_right.jsx
+++ b/web/react/components/sidebar_right.jsx
@@ -52,14 +52,29 @@ export default class SidebarRight extends React.Component {
doStrangeThings() {
// We should have a better way to do this stuff
// Hence the function name.
+ var windowWidth = $(window).outerWidth();
+ var sidebarRightWidth = $('.sidebar--right').outerWidth();
+
$('.inner__wrap').removeClass('.move--right');
$('.inner__wrap').addClass('move--left');
$('.sidebar--left').removeClass('move--right');
$('.sidebar--right').addClass('move--left');
//$('.sidebar--right').prepend('<div class="sidebar__overlay"></div>');
-
- if (!(this.state.search_visible || this.state.post_right_visible)) {
+ if (this.state.search_visible || this.state.post_right_visible) {
+ if (windowWidth > 960) {
+ $('.inner__wrap').velocity({marginRight: sidebarRightWidth}, {duration: 500, easing: 'easeOutSine'});
+ $('.sidebar--right').velocity({translateX: 0}, {duration: 500, easing: 'easeOutSine'});
+ } else {
+ $('.inner__wrap, .sidebar--right').attr('style', '');
+ }
+ } else {
+ if (windowWidth > 960) {
+ $('.inner__wrap').velocity({marginRight: 0}, {duration: 500, easing: 'easeOutSine'});
+ $('.sidebar--right').velocity({translateX: sidebarRightWidth}, {duration: 500, easing: 'easeOutSine'});
+ } else {
+ $('.inner__wrap, .sidebar--right').attr('style', '');
+ }
$('.inner__wrap').removeClass('move--left').removeClass('move--right');
$('.sidebar--right').removeClass('move--left');
return (
diff --git a/web/react/components/signup_team.jsx b/web/react/components/signup_team.jsx
index 0e05bc533..a554427d5 100644
--- a/web/react/components/signup_team.jsx
+++ b/web/react/components/signup_team.jsx
@@ -28,6 +28,8 @@ export default class TeamSignUp extends React.Component {
this.state = {page: 'email'};
} else if (global.window.mm_config.EnableSignUpWithGitLab === 'true') {
this.state = {page: 'gitlab'};
+ } else {
+ this.state = {page: 'none'};
}
}
@@ -119,6 +121,8 @@ export default class TeamSignUp extends React.Component {
<SSOSignupPage service={Constants.GOOGLE_SERVICE} />
</div>
);
+ } else if (this.state.page === 'none') {
+ return (<div>{'No team creation method has been enabled. Please contact an administrator for access.'}</div>);
}
}
}
diff --git a/web/react/components/suggestion/search_channel_provider.jsx b/web/react/components/suggestion/search_channel_provider.jsx
index 7547a9341..66a534907 100644
--- a/web/react/components/suggestion/search_channel_provider.jsx
+++ b/web/react/components/suggestion/search_channel_provider.jsx
@@ -19,7 +19,7 @@ class SearchChannelSuggestion extends React.Component {
onClick={onClick}
className={className}
>
- {item.name}
+ <i className='fa fa fa-plus-square'></i>{item.name}
</div>
);
}
diff --git a/web/react/components/suggestion/search_user_provider.jsx b/web/react/components/suggestion/search_user_provider.jsx
index cf2953937..0d553bfc4 100644
--- a/web/react/components/suggestion/search_user_provider.jsx
+++ b/web/react/components/suggestion/search_user_provider.jsx
@@ -22,7 +22,7 @@ class SearchUserSuggestion extends React.Component {
className='profile-img rounded'
src={'/api/v1/users/' + item.id + '/image?time=' + item.update_at}
/>
- {item.username}
+ <i className='fa fa fa-plus-square'></i>{item.username}
</div>
);
}
diff --git a/web/react/components/team_general_tab.jsx b/web/react/components/team_general_tab.jsx
index dc615f2e8..cc06a940e 100644
--- a/web/react/components/team_general_tab.jsx
+++ b/web/react/components/team_general_tab.jsx
@@ -424,7 +424,7 @@ export default class GeneralTab extends React.Component {
</div>
</div>
</div>
- <div className='setting-list__hint'>{'Your Invite Code is used in the URL sent to people to join your team. Regenerating your Invite Code will invalidate the URLs in previous invitations, unless "Allow anyone to sign-up from login page" is enabled.'}</div>
+ <div className='setting-list__hint'>{'The Invite Code is used as part of the URL in the team invitation link created by **Get Team Invite Link** in the main menu. Regenerating creates a new team invitation link and invalidates the previous link.'}</div>
</div>
);
diff --git a/web/react/components/tutorial/tutorial_tip.jsx b/web/react/components/tutorial/tutorial_tip.jsx
index d7c67cc9c..03ecdabab 100644
--- a/web/react/components/tutorial/tutorial_tip.jsx
+++ b/web/react/components/tutorial/tutorial_tip.jsx
@@ -101,22 +101,24 @@ export default class TutorialTip extends React.Component {
<div className={'tip-overlay ' + this.props.overlayClass}>
<div className='arrow'></div>
{this.props.screens[this.state.currentScreen]}
- <div className='tutorial__circles'>{dots}</div>
- <div className='text-right'>
- <button
- className='btn btn-primary'
- onClick={this.handleNext}
- >
- {buttonText}
- </button>
- <div className='tip-opt'>
- {'Seen this before? '}
- <a
- href='#'
- onClick={this.skipTutorial}
+ <div className='tutorial__footer'>
+ <div className='tutorial__circles'>{dots}</div>
+ <div className='text-right'>
+ <button
+ className='btn btn-primary'
+ onClick={this.handleNext}
>
- {'Opt out of these tips.'}
- </a>
+ {buttonText}
+ </button>
+ <div className='tip-opt'>
+ {'Seen this before? '}
+ <a
+ href='#'
+ onClick={this.skipTutorial}
+ >
+ {'Opt out of these tips.'}
+ </a>
+ </div>
</div>
</div>
</div>
diff --git a/web/react/components/user_settings/custom_theme_chooser.jsx b/web/react/components/user_settings/custom_theme_chooser.jsx
index b7d90922a..8ec3863f3 100644
--- a/web/react/components/user_settings/custom_theme_chooser.jsx
+++ b/web/react/components/user_settings/custom_theme_chooser.jsx
@@ -104,7 +104,7 @@ export default class CustomThemeChooser extends React.Component {
>
<label className='custom-label'>{element.uiName}</label>
<div
- className='input-group theme-group dropdown'
+ className='input-group theme-group group--code dropdown'
id={element.id}
>
<select
diff --git a/web/react/components/user_settings/manage_incoming_hooks.jsx b/web/react/components/user_settings/manage_incoming_hooks.jsx
index 9ebb55646..1506e3c98 100644
--- a/web/react/components/user_settings/manage_incoming_hooks.jsx
+++ b/web/react/components/user_settings/manage_incoming_hooks.jsx
@@ -162,7 +162,14 @@ export default class ManageIncomingHooks extends React.Component {
return (
<div key='addIncomingHook'>
- {'Create webhook URLs for use in external integrations. Please see '}<a href='http://mattermost.org/webhooks'>{'http://mattermost.org/webhooks'}</a> {' to learn more.'}
+ {'Create webhook URLs for use in external integrations. Please see '}
+ <a
+ href='http://mattermost.org/webhooks'
+ target='_blank'
+ >
+ {'http://mattermost.org/webhooks'}
+ </a>
+ {' to learn more.'}
<div><label className='control-label padding-top x2'>{'Add a new incoming webhook'}</label></div>
<div className='row padding-top'>
<div className='col-sm-10 padding-bottom'>
diff --git a/web/react/components/user_settings/manage_outgoing_hooks.jsx b/web/react/components/user_settings/manage_outgoing_hooks.jsx
index ede639691..17acf0f10 100644
--- a/web/react/components/user_settings/manage_outgoing_hooks.jsx
+++ b/web/react/components/user_settings/manage_outgoing_hooks.jsx
@@ -240,7 +240,14 @@ export default class ManageOutgoingHooks extends React.Component {
return (
<div key='addOutgoingHook'>
- {'Create webhooks to send new message events to an external integration. Please see '}<a href='http://mattermost.org/webhooks'>{'http://mattermost.org/webhooks'}</a> {' to learn more.'}
+ {'Create webhooks to send new message events to an external integration. Please see '}
+ <a
+ href='http://mattermost.org/webhooks'
+ target='_blank'
+ >
+ {'http://mattermost.org/webhooks'}
+ </a>
+ {' to learn more.'}
<div><label className='control-label padding-top x2'>{'Add a new outgoing webhook'}</label></div>
<div className='padding-top divider-light'></div>
<div className='padding-top'>
diff --git a/web/react/components/user_settings/user_settings_appearance.jsx b/web/react/components/user_settings/user_settings_appearance.jsx
index ad41ab771..7bfc9fdbd 100644
--- a/web/react/components/user_settings/user_settings_appearance.jsx
+++ b/web/react/components/user_settings/user_settings_appearance.jsx
@@ -74,6 +74,10 @@ export default class UserSettingsAppearance extends React.Component {
this.props.setEnforceFocus(true);
}
+ scrollToTop() {
+ $('.ps-container.modal-body').scrollTop(0);
+ $('.ps-container.modal-body').perfectScrollbar('update');
+ }
submitTheme(e) {
e.preventDefault();
var user = UserStore.getCurrentUser();
@@ -88,9 +92,7 @@ export default class UserSettingsAppearance extends React.Component {
this.props.setRequireConfirm(false);
this.originalTheme = Object.assign({}, this.state.theme);
-
- $('.ps-container.modal-body').scrollTop(0);
- $('.ps-container.modal-body').perfectScrollbar('update');
+ this.scrollToTop();
},
(err) => {
var state = this.getStateFromStores();
@@ -129,6 +131,7 @@ export default class UserSettingsAppearance extends React.Component {
const state = this.getStateFromStores();
state.serverError = null;
this.setState(state);
+ this.scrollToTop();
Utils.applyTheme(state.theme);
diff --git a/web/react/components/user_settings/user_settings_display.jsx b/web/react/components/user_settings/user_settings_display.jsx
index c464258de..96c3985d0 100644
--- a/web/react/components/user_settings/user_settings_display.jsx
+++ b/web/react/components/user_settings/user_settings_display.jsx
@@ -141,6 +141,9 @@ export default class UserSettingsDisplay extends React.Component {
);
}
+ const showUsername = 'Show username (team default)';
+ const showNickname = 'Show nickname if one exists, otherwise show first and last name';
+ const showFullName = 'Show first and last name';
if (this.props.activeSection === 'name_format') {
const nameFormat = [false, false, false];
if (this.state.nameFormat === 'nickname_full_name') {
@@ -157,10 +160,10 @@ export default class UserSettingsDisplay extends React.Component {
<label>
<input
type='radio'
- checked={nameFormat[0]}
- onChange={this.handleNameRadio.bind(this, 'nickname_full_name')}
+ checked={nameFormat[1]}
+ onChange={this.handleNameRadio.bind(this, 'username')}
/>
- {'Show nickname if one exists, otherwise show first and last name (team default)'}
+ {showUsername}
</label>
<br/>
</div>
@@ -168,10 +171,10 @@ export default class UserSettingsDisplay extends React.Component {
<label>
<input
type='radio'
- checked={nameFormat[1]}
- onChange={this.handleNameRadio.bind(this, 'username')}
+ checked={nameFormat[0]}
+ onChange={this.handleNameRadio.bind(this, 'nickname_full_name')}
/>
- {'Show username'}
+ {showNickname}
</label>
<br/>
</div>
@@ -182,11 +185,11 @@ export default class UserSettingsDisplay extends React.Component {
checked={nameFormat[2]}
onChange={this.handleNameRadio.bind(this, 'full_name')}
/>
- {'Show first and last name'}
+ {showFullName}
</label>
<br/>
</div>
- <div><br/>{'Set what name to display in the Direct Messages list.'}</div>
+ <div><br/>{'Set how to display other user\'s names in posts and the Direct Messages list.'}</div>
</div>
];
@@ -205,11 +208,11 @@ export default class UserSettingsDisplay extends React.Component {
} else {
let describe = '';
if (this.state.nameFormat === 'username') {
- describe = 'Show username';
+ describe = showUsername;
} else if (this.state.nameFormat === 'full_name') {
- describe = 'Show first and last name';
+ describe = showFullName;
} else {
- describe = 'Show nickname if one exists, otherwise show first and last name (team default)';
+ describe = showNickname;
}
nameFormatSection = (
diff --git a/web/react/components/view_image.jsx b/web/react/components/view_image.jsx
index 820f8fd8e..7edf6283b 100644
--- a/web/react/components/view_image.jsx
+++ b/web/react/components/view_image.jsx
@@ -1,9 +1,11 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
+import * as AsyncClient from '../utils/async_client.jsx';
import * as Client from '../utils/client.jsx';
import * as Utils from '../utils/utils.jsx';
import Constants from '../utils/constants.jsx';
+import FileStore from '../stores/file_store.jsx';
import ViewImagePopoverBar from './view_image_popover_bar.jsx';
const Modal = ReactBootstrap.Modal;
const KeyCodes = Constants.KeyCodes;
@@ -12,80 +14,90 @@ export default class ViewImageModal extends React.Component {
constructor(props) {
super(props);
- this.canSetState = false;
-
+ this.showImage = this.showImage.bind(this);
this.loadImage = this.loadImage.bind(this);
+
this.handleNext = this.handleNext.bind(this);
this.handlePrev = this.handlePrev.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
- this.getPublicLink = this.getPublicLink.bind(this);
- this.getPreviewImagePath = this.getPreviewImagePath.bind(this);
+
this.onModalShown = this.onModalShown.bind(this);
this.onModalHidden = this.onModalHidden.bind(this);
+
+ this.onFileStoreChange = this.onFileStoreChange.bind(this);
+
+ this.getPublicLink = this.getPublicLink.bind(this);
+ this.getPreviewImagePath = this.getPreviewImagePath.bind(this);
this.onMouseEnterImage = this.onMouseEnterImage.bind(this);
this.onMouseLeaveImage = this.onMouseLeaveImage.bind(this);
- var loaded = [];
- var progress = [];
+ const loaded = [];
+ const progress = [];
for (var i = 0; i < this.props.filenames.length; i++) {
loaded.push(false);
progress.push(0);
}
+
this.state = {
imgId: this.props.startId,
+ fileInfo: new Map(),
imgHeight: '100%',
- loaded: loaded,
- progress: progress,
- images: {},
- fileSizes: {},
- fileMimes: {},
- showFooter: false,
- isPlaying: {},
- isLoading: {}
+ loaded,
+ progress,
+ showFooter: false
};
}
+
handleNext(e) {
if (e) {
e.stopPropagation();
}
- var id = this.state.imgId + 1;
+ let id = this.state.imgId + 1;
if (id > this.props.filenames.length - 1) {
id = 0;
}
- this.setState({imgId: id});
- this.loadImage(id);
+ this.showImage(id);
}
+
handlePrev(e) {
if (e) {
e.stopPropagation();
}
- var id = this.state.imgId - 1;
+ let id = this.state.imgId - 1;
if (id < 0) {
id = this.props.filenames.length - 1;
}
- this.setState({imgId: id});
- this.loadImage(id);
+ this.showImage(id);
}
+
handleKeyPress(e) {
- if (!e || !this.props.show) {
- return;
- } else if (e.keyCode === KeyCodes.RIGHT) {
+ if (e.keyCode === KeyCodes.RIGHT) {
this.handleNext();
} else if (e.keyCode === KeyCodes.LEFT) {
this.handlePrev();
}
}
+
onModalShown(nextProps) {
- this.setState({imgId: nextProps.startId});
- this.loadImage(nextProps.startId);
+ $(window).on('keyup', this.handleKeyPress);
+
+ this.showImage(nextProps.startId);
+
+ FileStore.addChangeListener(this.onFileStoreChange);
}
+
onModalHidden() {
+ $(window).off('keyup', this.handleKeyPress);
+
if (this.refs.video) {
var video = ReactDOM.findDOMNode(this.refs.video);
video.pause();
video.currentTime = 0;
}
+
+ FileStore.removeChangeListener(this.onFileStoreChange);
}
+
componentWillReceiveProps(nextProps) {
if (nextProps.show === true && this.props.show === false) {
this.onModalShown(nextProps);
@@ -93,31 +105,65 @@ export default class ViewImageModal extends React.Component {
this.onModalHidden();
}
}
- loadImage(id) {
- var imgHeight = $(window).height() - 100;
+
+ onFileStoreChange(filename) {
+ const id = this.props.filenames.indexOf(filename);
+
+ if (id !== -1 && !this.state.loaded[id]) {
+ const fileInfo = this.state.fileInfo;
+ fileInfo.set(filename, FileStore.getInfo(filename));
+ this.setState({fileInfo});
+
+ this.loadImage(id, filename);
+ }
+ }
+
+ showImage(id) {
+ this.setState({imgId: id});
+
+ const imgHeight = $(window).height() - 100;
this.setState({imgHeight});
- var filename = this.props.filenames[id];
+ const filename = this.props.filenames[id];
- var fileInfo = Utils.splitFileLocation(filename);
- var fileType = Utils.getFileType(fileInfo.ext);
+ if (!FileStore.hasInfo(filename)) {
+ // the image will actually be loaded once we know what we need to load
+ AsyncClient.getFileInfo(filename);
+ return;
+ }
+
+ if (!this.state.loaded[id]) {
+ this.loadImage(id, filename);
+ }
+ }
+
+ loadImage(id, filename) {
+ const fileInfo = FileStore.getInfo(filename);
+ const fileType = Utils.getFileType(fileInfo.extension);
if (fileType === 'image') {
- var img = new Image();
- img.load(this.getPreviewImagePath(filename),
- () => {
- const progress = this.state.progress;
- progress[id] = img.completedPercentage;
- this.setState({progress});
- });
+ let previewUrl;
+ if (fileInfo.has_image_preview) {
+ previewUrl = fileInfo.getPreviewImagePath(filename);
+ } else {
+ // some images (eg animated gifs) just show the file itself and not a preview
+ previewUrl = Utils.getFileUrl(filename);
+ }
+
+ const img = new Image();
+ img.load(
+ previewUrl,
+ () => {
+ const progress = this.state.progress;
+ progress[id] = img.completedPercentage;
+ this.setState({progress});
+ }
+ );
img.onload = () => {
const loaded = this.state.loaded;
loaded[id] = true;
this.setState({loaded});
};
- var images = this.state.images;
- images[id] = img;
- this.setState({images});
} else {
// there's nothing to load for non-image files
var loaded = this.state.loaded;
@@ -125,169 +171,82 @@ 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);
- // keep track of whether or not this component is mounted so we can safely set the state asynchronously
- this.canSetState = true;
- }
- componentWillUnmount() {
- this.canSetState = false;
- $(window).off('keyup', this.handleKeyPress);
- }
getPublicLink() {
var data = {};
data.channel_id = this.props.channelId;
data.user_id = this.props.userId;
data.filename = this.props.filenames[this.state.imgId];
- Client.getPublicLink(data,
- function sucess(serverData) {
+ Client.getPublicLink(
+ data,
+ (serverData) => {
if (Utils.isMobile()) {
window.location.href = serverData.public_link;
} else {
window.open(serverData.public_link);
}
},
- function error() {}
+ () => {}
);
}
+
getPreviewImagePath(filename) {
// Returns the path to a preview image that can be used to represent a file.
var fileInfo = Utils.splitFileLocation(filename);
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];
}
fileInfo.path = Utils.getWindowLocationOrigin() + '/api/v1/files/get' + fileInfo.path;
- return fileInfo.path + '_preview.jpg' + '?' + Utils.getSessionIndex();
+ return fileInfo.path + '_preview.jpg?' + Utils.getSessionIndex();
}
// only images have proper previews, so just use a placeholder icon for non-images
return Utils.getPreviewImagePathForFileType(fileType);
}
+
onMouseEnterImage() {
this.setState({showFooter: true});
}
+
onMouseLeaveImage() {
this.setState({showFooter: false});
}
+
render() {
if (this.props.filenames.length < 1 || this.props.filenames.length - 1 < this.state.imgId) {
return <div/>;
}
- var filename = this.props.filenames[this.state.imgId];
- var fileUrl = Utils.getFileUrl(filename);
-
- var name = decodeURIComponent(Utils.getFileName(filename));
+ const filename = this.props.filenames[this.state.imgId];
+ const fileUrl = Utils.getFileUrl(filename);
var content;
- var bgClass = '';
if (this.state.loaded[this.state.imgId]) {
- var fileInfo = Utils.splitFileLocation(filename);
- var fileType = Utils.getFileType(fileInfo.ext);
+ // if a file has been loaded, we also have its info
+ const fileInfo = this.state.fileInfo.get(filename);
- 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);
- }
- },
- () => {}
- );
- }
+ const extension = Utils.splitFileLocation(filename).ext;
+ const fileType = Utils.getFileType(extension);
- 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 = '';
+ if (fileType === 'image') {
+ let previewUrl;
+ if (fileInfo.has_preview_image) {
+ previewUrl = this.getPreviewImagePath(filename);
+ } else {
+ previewUrl = fileUrl;
}
- // 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'
- src={this.getPreviewImagePath(filename)}
- />
- </a>
+ <ImagePreview
+ fileUrl={fileUrl}
+ previewUrl={previewUrl}
+ maxHeight={this.state.imgHeight}
+ />
);
} else if (fileType === 'video' || fileType === 'audio') {
let width = Constants.WEB_VIDEO_WIDTH;
@@ -311,11 +270,13 @@ export default class ViewImageModal extends React.Component {
);
} else {
// non-image files include a section providing details about the file
- var infoString = 'File type ' + fileInfo.ext.toUpperCase();
- if (this.state.fileSizes[filename] && this.state.fileSizes[filename] >= 0) {
- infoString += ', Size ' + Utils.fileSizeToString(this.state.fileSizes[filename]);
+ let infoString = 'File type ' + fileInfo.extension.toUpperCase();
+ if (fileInfo.size > 0) {
+ infoString += ', Size ' + Utils.fileSizeToString(fileInfo.size);
}
+ const name = decodeURIComponent(Utils.getFileName(filename));
+
content = (
<div className='file-details__container'>
<a
@@ -335,53 +296,16 @@ export default class ViewImageModal extends React.Component {
</div>
</div>
);
- bgClass = 'white-bg';
-
- // asynchronously request the actual size of this file
- if (!(filename in this.state.fileSizes)) {
- Client.getFileInfo(
- filename,
- function success(data) {
- if (this.canSetState) {
- var fileSizes = this.state.fileSizes;
- fileSizes[filename] = parseInt(data.size, 10);
- this.setState(fileSizes);
- }
- }.bind(this),
- function fail() {}
- );
- }
}
} else {
// display a progress indicator when the preview for an image is still loading
- var percentage = Math.floor(this.state.progress[this.state.imgId]);
- if (percentage) {
- content = (
- <div>
- <img
- className='loader-image'
- src='/static/images/load.gif'
- />
- <span className='loader-percent'>
- {'Previewing ' + percentage + '%'}
- </span>
- </div>
- );
- } else {
- content = (
- <div>
- <img
- className='loader-image'
- src='/static/images/load.gif'
- />
- </div>
- );
- }
- bgClass = 'black-bg';
+ const progress = Math.floor(this.state.progress[this.state.imgId]);
+
+ content = <LoadingImagePreview progress={progress} />;
}
- var leftArrow = '';
- var rightArrow = '';
+ let leftArrow = null;
+ let rightArrow = null;
if (this.props.filenames.length > 1) {
leftArrow = (
<a
@@ -427,7 +351,6 @@ export default class ViewImageModal extends React.Component {
onClick={this.props.onModalDismissed}
>
<div
- className={bgClass}
onMouseEnter={this.onMouseEnterImage}
onMouseLeave={this.onMouseLeaveImage}
onClick={(e) => e.stopPropagation()}
@@ -471,3 +394,38 @@ ViewImageModal.propTypes = {
userId: React.PropTypes.string,
startId: React.PropTypes.number
};
+
+function LoadingImagePreview({progress}) {
+ let progressView = null;
+ if (progress) {
+ progressView = (
+ <span className='loader-percent'>
+ {'Loading ' + progress + '%'}
+ </span>
+ );
+ }
+
+ return (
+ <div className='view-image__loading'>
+ <img
+ className='loader-image'
+ src='/static/images/load.gif'
+ />
+ {progressView}
+ </div>
+ );
+}
+
+function ImagePreview({maxHeight, fileUrl, previewUrl}) {
+ return (
+ <a
+ href={fileUrl}
+ target='_blank'
+ >
+ <img
+ style={{maxHeight}}
+ src={previewUrl}
+ />
+ </a>
+ );
+}
diff --git a/web/react/stores/file_store.jsx b/web/react/stores/file_store.jsx
new file mode 100644
index 000000000..ca8c6a96b
--- /dev/null
+++ b/web/react/stores/file_store.jsx
@@ -0,0 +1,60 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import AppDispatcher from '../dispatcher/app_dispatcher.jsx';
+import Constants from '../utils/constants.jsx';
+import EventEmitter from 'events';
+
+const ActionTypes = Constants.ActionTypes;
+
+const CHANGE_EVENT = 'changed';
+
+class FileStore extends EventEmitter {
+ constructor() {
+ super();
+
+ this.addChangeListener = this.addChangeListener.bind(this);
+ this.removeChangeListener = this.removeChangeListener.bind(this);
+ this.emitChange = this.emitChange.bind(this);
+
+ this.handleEventPayload = this.handleEventPayload.bind(this);
+ this.dispatchToken = AppDispatcher.register(this.handleEventPayload);
+
+ this.fileInfo = new Map();
+ }
+
+ addChangeListener(callback) {
+ this.on(CHANGE_EVENT, callback);
+ }
+ removeChangeListener(callback) {
+ this.removeListener(CHANGE_EVENT, callback);
+ }
+ emitChange(filename) {
+ this.emit(CHANGE_EVENT, filename);
+ }
+
+ hasInfo(filename) {
+ return this.fileInfo.has(filename);
+ }
+
+ getInfo(filename) {
+ return this.fileInfo.get(filename);
+ }
+
+ setInfo(filename, info) {
+ this.fileInfo.set(filename, info);
+ }
+
+ handleEventPayload(payload) {
+ const action = payload.action;
+
+ switch (action.type) {
+ case ActionTypes.RECIEVED_FILE_INFO:
+ this.setInfo(action.filename, action.info);
+ this.emitChange(action.filename);
+ break;
+ }
+ }
+}
+
+export default new FileStore();
diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx
index 88b5aa739..f218270da 100644
--- a/web/react/utils/async_client.jsx
+++ b/web/react/utils/async_client.jsx
@@ -769,3 +769,31 @@ export function getSuggestedCommands(command, suggestionId, component) {
}
);
}
+
+export function getFileInfo(filename) {
+ const callName = 'getFileInfo' + filename;
+
+ if (isCallInProgress(callName)) {
+ return;
+ }
+
+ callTracker[callName] = utils.getTimestamp();
+
+ client.getFileInfo(
+ filename,
+ (data) => {
+ callTracker[callName] = 0;
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_FILE_INFO,
+ filename,
+ info: data
+ });
+ },
+ (err) => {
+ callTracker[callName] = 0;
+
+ dispatchError(err, 'getFileInfo');
+ }
+ );
+}
diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx
index 2a90da168..e1c331aff 100644
--- a/web/react/utils/client.jsx
+++ b/web/react/utils/client.jsx
@@ -280,6 +280,7 @@ export function loginByEmail(name, email, password, success, error) {
data: JSON.stringify({name, email, password}),
success: function onSuccess(data, textStatus, xhr) {
track('api', 'api_users_login_success', data.team_id, 'email', data.email);
+ sessionStorage.removeItem(data.id + '_last_error');
BrowserStore.signalLogin();
success(data, textStatus, xhr);
},
@@ -301,6 +302,8 @@ export function loginByLdap(teamName, id, password, success, error) {
data: JSON.stringify({teamName, id, password}),
success: function onSuccess(data, textStatus, xhr) {
track('api', 'api_users_loginLdap_success', data.team_id, 'id', id);
+ sessionStorage.removeItem(data.id + '_last_error');
+ BrowserStore.signalLogin();
success(data, textStatus, xhr);
},
error: function onError(xhr, status, err) {
@@ -1110,7 +1113,9 @@ export function getFileInfo(filename, success, error) {
dataType: 'json',
contentType: 'application/json',
type: 'GET',
- success,
+ success: (data) => {
+ success(data);
+ },
error: function onError(xhr, status, err) {
var e = handleError('getFileInfo', xhr, status, err);
error(e);
diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx
index fa5fa8e8d..0298ce533 100644
--- a/web/react/utils/constants.jsx
+++ b/web/react/utils/constants.jsx
@@ -37,6 +37,7 @@ export default {
RECIEVED_STATUSES: null,
RECIEVED_PREFERENCE: null,
RECIEVED_PREFERENCES: null,
+ RECIEVED_FILE_INFO: null,
RECIEVED_MSG: null,
diff --git a/web/react/utils/delayed_action.jsx b/web/react/utils/delayed_action.jsx
new file mode 100644
index 000000000..4f6239ad0
--- /dev/null
+++ b/web/react/utils/delayed_action.jsx
@@ -0,0 +1,27 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+export default class DelayedAction {
+ constructor(action) {
+ this.action = action;
+
+ this.timer = -1;
+
+ // bind fire since it doesn't get passed the correct this value with setTimeout
+ this.fire = this.fire.bind(this);
+ }
+
+ fire() {
+ this.action();
+
+ this.timer = -1;
+ }
+
+ fireAfter(timeout) {
+ if (this.timer >= 0) {
+ window.clearTimeout(this.timer);
+ }
+
+ this.timer = window.setTimeout(this.fire, timeout);
+ }
+}
diff --git a/web/react/utils/emoticons.jsx b/web/react/utils/emoticons.jsx
index fa5177232..23a847969 100644
--- a/web/react/utils/emoticons.jsx
+++ b/web/react/utils/emoticons.jsx
@@ -2,21 +2,21 @@
// See License.txt for license information.
const emoticonPatterns = {
- smile: /(^|\s)(:-?\))(?=$|\s)/g, // :)
+ slightly_smiling_face: /(^|\s)(:-?\))(?=$|\s)/g, // :)
wink: /(^|\s)(;-?\))(?=$|\s)/g, // ;)
open_mouth: /(^|\s)(:o)(?=$|\s)/gi, // :o
scream: /(^|\s)(:-o)(?=$|\s)/gi, // :-o
smirk: /(^|\s)(:-?])(?=$|\s)/g, // :]
- grinning: /(^|\s)(:-?d)(?=$|\s)/gi, // :D
+ smile: /(^|\s)(:-?d)(?=$|\s)/gi, // :D
stuck_out_tongue_closed_eyes: /(^|\s)(x-d)(?=$|\s)/gi, // x-d
stuck_out_tongue: /(^|\s)(:-?p)(?=$|\s)/gi, // :p
rage: /(^|\s)(:-?[\[@])(?=$|\s)/g, // :@
- frowning: /(^|\s)(:-?\()(?=$|\s)/g, // :(
- sob: /(^|\s)(:['’]-?\(|:&#x27;\(|:&#39;\()(?=$|\s)/g, // :`(
- pensive: /(^|\s)(:-?\/)(?=$|\s)/g, // :/
+ slightly_frowning_face: /(^|\s)(:-?\()(?=$|\s)/g, // :(
+ cry: /(^|\s)(:['’]-?\(|:&#x27;\(|:&#39;\()(?=$|\s)/g, // :`(
+ confused: /(^|\s)(:-?\/)(?=$|\s)/g, // :/
confounded: /(^|\s)(:-?s)(?=$|\s)/gi, // :s
- flushed: /(^|\s)(:-?\|)(?=$|\s)/g, // :|
- relaxed: /(^|\s)(:-?\$)(?=$|\s)/g, // :$
+ neutral_face: /(^|\s)(:-?\|)(?=$|\s)/g, // :|
+ flushed: /(^|\s)(:-?\$)(?=$|\s)/g, // :$
mask: /(^|\s)(:-x)(?=$|\s)/gi, // :-x
heart: /(^|\s)(<3|&lt;3)(?=$|\s)/g, // <3
broken_heart: /(^|\s)(<\/3|&lt;&#x2F;3)(?=$|\s)/g, // </3
@@ -34,10 +34,10 @@ function initializeEmoticonMap() {
'baby_bottle,baby_chick,baby_symbol,back,baggage_claim,balloon,ballot_box_with_check,bamboo,banana,bangbang,' +
'bank,bar_chart,barber,baseball,basketball,bath,bathtub,battery,bear,bee,beer,beers,beetle,beginner,bell,bento,' +
'bicyclist,bike,bikini,bird,birthday,black_circle,black_joker,black_medium_small_square,black_medium_square,' +
- 'black_nib,black_small_square,black_square,black_square_button,blossom,blowfish,blue_book,blue_car,blue_heart,' +
- 'blush,boar,boat,bomb,book,bookmark,bookmark_tabs,books,boom,boot,bouquet,bow,bowling,bowtie,boy,bread,' +
- 'bride_with_veil,bridge_at_night,briefcase,broken_heart,bug,bulb,bullettrain_front,bullettrain_side,bus,busstop,' +
- 'bust_in_silhouette,busts_in_silhouette,cactus,cake,calendar,calling,camel,camera,cancer,candy,capital_abcd,' +
+ 'black_large_square,black_nib,black_small_square,black_square,black_square_button,blossom,blowfish,blue_book,' +
+ 'blue_car,blue_heart,blush,boar,boat,bomb,book,bookmark,bookmark_tabs,books,boom,boot,bouquet,bow,bowling,bowtie,' +
+ 'boy,bread,bride_with_veil,bridge_at_night,briefcase,broken_heart,bug,bulb,bullettrain_front,bullettrain_side,bus,' +
+ 'busstop,bust_in_silhouette,busts_in_silhouette,cactus,cake,calendar,calling,camel,camera,cancer,candy,capital_abcd,' +
'capricorn,car,card_index,carousel_horse,cat,cat2,cd,chart,chart_with_downwards_trend,chart_with_upwards_trend,' +
'checkered_flag,cherries,cherry_blossom,chestnut,chicken,children_crossing,chocolate_bar,christmas_tree,church,' +
'cinema,circus_tent,city_sunrise,city_sunset,cl,clap,clapper,clipboard,clock1,clock10,clock1030,clock11,' +
@@ -92,16 +92,16 @@ function initializeEmoticonMap() {
'rugby_football,runner,running,running_shirt_with_sash,sa,sagittarius,sailboat,sake,sandal,santa,satellite,' +
'satisfied,saxophone,school,school_satchel,scissors,scorpius,scream,scream_cat,scroll,seat,secret,see_no_evil,' +
'seedling,seven,shaved_ice,sheep,shell,ship,shipit,shirt,shit,shoe,shower,signal_strength,six,six_pointed_star,' +
- 'ski,skull,sleeping,sleepy,slot_machine,small_blue_diamond,small_orange_diamond,small_red_triangle,' +
- 'small_red_triangle_down,smile,smile_cat,smiley,smiley_cat,smiling_imp,smirk,smirk_cat,smoking,snail,snake,' +
- 'snowboarder,snowflake,snowman,sob,soccer,soon,sos,sound,space_invader,spades,spaghetti,sparkle,sparkler,' +
- 'sparkles,sparkling_heart,speak_no_evil,speaker,speech_balloon,speedboat,squirrel,star,star2,stars,station,' +
- 'statue_of_liberty,steam_locomotive,stew,straight_ruler,strawberry,stuck_out_tongue,stuck_out_tongue_closed_eyes,' +
- 'stuck_out_tongue_winking_eye,sun_with_face,sunflower,sunglasses,sunny,sunrise,sunrise_over_mountains,surfer,' +
- 'sushi,suspect,suspension_railway,sweat,sweat_drops,sweat_smile,sweet_potato,swimmer,symbols,syringe,tada,' +
- 'tanabata_tree,tangerine,taurus,taxi,tea,telephone,telephone_receiver,telescope,tennis,tent,thought_balloon,' +
- 'three,thumbsdown,thumbsup,ticket,tiger,tiger2,tired_face,tm,toilet,tokyo_tower,tomato,tongue,top,tophat,' +
- 'tractor,traffic_light,train,train2,tram,triangular_flag_on_post,triangular_ruler,trident,triumph,trolleybus,' +
+ 'ski,skull,sleeping,sleepy,slightly_smiling_face,slightly_frowning_face,slot_machine,small_blue_diamond,' +
+ 'small_orange_diamond,small_red_triangle,small_red_triangle_down,smile,smile_cat,smiley,smiley_cat,smiling_imp,' +
+ 'smirk,smirk_cat,smoking,snail,snake,snowboarder,snowflake,snowman,sob,soccer,soon,sos,sound,space_invader,spades,' +
+ 'spaghetti,sparkle,sparkler,sparkles,sparkling_heart,speak_no_evil,speaker,speech_balloon,speedboat,squirrel,star,' +
+ 'star2,stars,station,statue_of_liberty,steam_locomotive,stew,straight_ruler,strawberry,stuck_out_tongue,' +
+ 'stuck_out_tongue_closed_eyes,stuck_out_tongue_winking_eye,sun_with_face,sunflower,sunglasses,sunny,sunrise,' +
+ 'sunrise_over_mountains,surfer,sushi,suspect,suspension_railway,sweat,sweat_drops,sweat_smile,sweet_potato,swimmer,' +
+ 'symbols,syringe,tada,tanabata_tree,tangerine,taurus,taxi,tea,telephone,telephone_receiver,telescope,tennis,tent,' +
+ 'thought_balloon,three,thumbsdown,thumbsup,ticket,tiger,tiger2,tired_face,tm,toilet,tokyo_tower,tomato,tongue,top,' +
+ 'tophat,tractor,traffic_light,train,train2,tram,triangular_flag_on_post,triangular_ruler,trident,triumph,trolleybus,' +
'trollface,trophy,tropical_drink,tropical_fish,truck,trumpet,tshirt,tulip,turtle,tv,twisted_rightwards_arrows,' +
'two,two_hearts,two_men_holding_hands,two_women_holding_hands,u5272,u5408,u55b6,u6307,u6708,u6709,u6e80,u7121,' +
'u7533,u7981,u7a7a,uk,umbrella,unamused,underage,unlock,up,us,v,vertical_traffic_light,vhs,vibration_mode,' +
diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx
index 2ade00d29..24d27b10a 100644
--- a/web/react/utils/utils.jsx
+++ b/web/react/utils/utils.jsx
@@ -567,7 +567,7 @@ export function applyTheme(theme) {
}
if (theme.sidebarHeaderBg) {
- changeCss('.sidebar--left .team__header, .sidebar--menu .team__header', 'background:' + theme.sidebarHeaderBg, 1);
+ changeCss('.sidebar--left .team__header, .sidebar--menu .team__header, .post-list__timestamp', 'background:' + theme.sidebarHeaderBg, 1);
changeCss('.modal .modal-header', 'background:' + theme.sidebarHeaderBg, 1);
changeCss('#navbar .navbar-default', 'background:' + theme.sidebarHeaderBg, 1);
changeCss('@media(max-width: 768px){.search-bar__container', 'background:' + theme.sidebarHeaderBg, 1);
@@ -575,7 +575,7 @@ export function applyTheme(theme) {
}
if (theme.sidebarHeaderTextColor) {
- changeCss('.sidebar--left .team__header .header__info, .sidebar--menu .team__header .header__info', 'color:' + theme.sidebarHeaderTextColor, 1);
+ changeCss('.sidebar--left .team__header .header__info, .sidebar--menu .team__header .header__info, .post-list__timestamp', 'color:' + theme.sidebarHeaderTextColor, 1);
changeCss('.sidebar--left .team__header .navbar-right .dropdown__icon, .sidebar--menu .team__header .navbar-right .dropdown__icon', 'fill:' + theme.sidebarHeaderTextColor, 1);
changeCss('.sidebar--left .team__header .user__name, .sidebar--menu .team__header .user__name', 'color:' + changeOpacity(theme.sidebarHeaderTextColor, 0.8), 1);
changeCss('.sidebar--left .team__header:hover .user__name, .sidebar--menu .team__header:hover .user__name', 'color:' + theme.sidebarHeaderTextColor, 1);
@@ -617,6 +617,7 @@ export function applyTheme(theme) {
if (theme.centerChannelColor) {
changeCss('.sidebar--left, .sidebar--right .sidebar--right__header', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1);
changeCss('.app__content, .post-create__container .post-create-body .btn-file, .post-create__container .post-create-footer .msg-typing, .command-name, .modal .modal-content, .dropdown-menu, .popover, .mentions-name, .tip-overlay', 'color:' + theme.centerChannelColor, 1);
+ changeCss('#archive-link-home', 'background:' + changeOpacity(theme.centerChannelColor, 0.15), 1);
changeCss('#post-create', 'color:' + theme.centerChannelColor, 2);
changeCss('.mentions--top, .suggestion-list', 'box-shadow:' + changeOpacity(theme.centerChannelColor, 0.2) + ' 1px -3px 12px', 3);
changeCss('.mentions--top, .suggestion-list', '-webkit-box-shadow:' + changeOpacity(theme.centerChannelColor, 0.2) + ' 1px -3px 12px', 2);
@@ -647,10 +648,11 @@ export function applyTheme(theme) {
changeCss('.attachment .attachment__content', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.3), 1);
changeCss('.channel-intro .channel-intro__content, .webhooks__container', 'background:' + changeOpacity(theme.centerChannelColor, 0.05), 1);
changeCss('.date-separator .separator__text', 'color:' + theme.centerChannelColor, 2);
- changeCss('.date-separator .separator__hr, .modal-footer, .modal .custom-textarea, .post-right__container .post.post--root hr, .search-item-container', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1);
+ changeCss('.date-separator .separator__hr, .modal-footer, .modal .custom-textarea', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1);
+ changeCss('.search-item-container, .post-right__container .post.post--root', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.1), 1);
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, pre, .post-right__container .post.post--root', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1);
changeCss('.post.current--user .post__body, .post.post--comment.other--root.current--user .post-comment, .post.same--root.post--comment .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: 1800px){.inner__wrap.move--left .post.post--comment.same--root', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.07), 2);
changeCss('.post:hover, .modal .more-table tbody>tr:hover td, .settings-modal .settings-table .settings-content .section-min:hover', 'background:' + changeOpacity(theme.centerChannelColor, 0.07), 1);
@@ -684,11 +686,11 @@ export function applyTheme(theme) {
}
if (theme.mentionHighlightBg) {
- changeCss('.mention-highlight, .search-highlight, #archive-link-home', 'background:' + theme.mentionHighlightBg, 1);
+ changeCss('.mention-highlight, .search-highlight', 'background:' + theme.mentionHighlightBg, 1);
}
if (theme.mentionHighlightBg) {
- changeCss('.post.post--highlight, #archive-link-home', 'background:' + changeOpacity(theme.mentionHighlightBg, 0.5), 1);
+ changeCss('.post.post--highlight', 'background:' + changeOpacity(theme.mentionHighlightBg, 0.5), 1);
}
if (theme.mentionHighlightLink) {
diff --git a/web/sass-files/sass/partials/_base.scss b/web/sass-files/sass/partials/_base.scss
index 4f9e1d5c7..04ba9c51e 100644
--- a/web/sass-files/sass/partials/_base.scss
+++ b/web/sass-files/sass/partials/_base.scss
@@ -125,10 +125,6 @@ a:focus, a:hover {
}
}
-select {
- -moz-appearance:none;
-}
-
.form-control {
@include border-radius(2px);
&:focus {
diff --git a/web/sass-files/sass/partials/_content.scss b/web/sass-files/sass/partials/_content.scss
index da75bc61b..b54c97b41 100644
--- a/web/sass-files/sass/partials/_content.scss
+++ b/web/sass-files/sass/partials/_content.scss
@@ -1,9 +1,7 @@
@charset "UTF-8";
.inner__wrap {
- @include single-transition(all, 0.5s, ease);
&.move--left {
- margin-right: 400px;
.search-bar__container {
display: none;
}
diff --git a/web/sass-files/sass/partials/_files.scss b/web/sass-files/sass/partials/_files.scss
index 2c341f61e..62e067437 100644
--- a/web/sass-files/sass/partials/_files.scss
+++ b/web/sass-files/sass/partials/_files.scss
@@ -257,3 +257,8 @@
@include opacity(0);
}
}
+
+.view-image__loading {
+ background: black;
+ min-height: 100px;
+}
diff --git a/web/sass-files/sass/partials/_modal.scss b/web/sass-files/sass/partials/_modal.scss
index 6f70e502f..7627f6a4c 100644
--- a/web/sass-files/sass/partials/_modal.scss
+++ b/web/sass-files/sass/partials/_modal.scss
@@ -229,6 +229,12 @@
height: 80%;
}
+ audio, canvas, progress, video {
+ max-width: 100%;
+ height: auto;
+ display: block;
+ }
+
.modal-close {
background: url("../images/close.png") no-repeat;
@include background-size(100% 100%);
diff --git a/web/sass-files/sass/partials/_popover.scss b/web/sass-files/sass/partials/_popover.scss
index 1ae07fe5b..8a61758f1 100644
--- a/web/sass-files/sass/partials/_popover.scss
+++ b/web/sass-files/sass/partials/_popover.scss
@@ -85,6 +85,11 @@
background: rgba(black, 0.2);
}
+ .fa {
+ margin-right: 5px;
+ @include opacity(0.5);
+ }
+
.profile-img {
margin-top: -1px;
height: 16px;
diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss
index ff430ba8a..937b08084 100644
--- a/web/sass-files/sass/partials/_post.scss
+++ b/web/sass-files/sass/partials/_post.scss
@@ -211,6 +211,10 @@ body.ios {
overflow-y: hidden;
height: 100%;
+ .inactive {
+ display: none;
+ }
+
.post-list-holder-by-time {
background: #fff;
overflow-y: scroll;
@@ -222,21 +226,18 @@ body.ios {
&::-webkit-scrollbar {
width: 0px !important;
}
- &.inactive {
- display: none;
- }
&.active {
display: inline;
}
}
.more-messages-text {
- margin-top: 2px;
- margin-bottom: 5px;
+ margin: 5px 0 10px;
display: block;
text-align: center;
outline: none;
border: none;
+ font-size: 13px;
}
.beginning-messages-text {
margin-top: 2px;
@@ -247,6 +248,50 @@ body.ios {
}
}
+.post-list__timestamp {
+ position: absolute;
+ top: 8px;
+ left: 50%;
+ z-index: 50;
+ width: 120px;
+ text-align: center;
+ background: $primary-color;
+ color: #fff;
+ @include border-radius(3px);
+ font-size: 12px;
+ line-height: 25px;
+ margin-left: -60px;
+ -webkit-font-smoothing: initial;
+ @include single-transition(all, 0.3s, ease);
+ @include translateY(-45px);
+ @include opacity(0);
+ display: none;
+
+ &.scrolling {
+ @include single-transition(all, 0.3s, ease);
+ @include translateY(0);
+ @include opacity(0.8);
+ }
+}
+
+.post-list__arrows {
+ background: url('../images/postArrows.png') center;
+ @include background-size(28px 28px);
+ background-repeat: no-repeat;
+ width: 40px;
+ height: 40px;
+ position: absolute;
+ bottom: 50px;
+ right: 5px;
+ z-index: 50;
+ @include opacity(0);
+ @include single-transition(all, 0.3s);
+
+ &.scrolling {
+ @include opacity(1);
+ }
+}
+
.post-create__container {
form {
width: 100%;
@@ -379,7 +424,7 @@ body.ios {
p {
- margin: 0 0 1em;
+ margin: 0;
line-height: 1.6em;
font-size: 0.97em;
white-space: pre-wrap;
@@ -631,9 +676,14 @@ body.ios {
}
ul {
+ margin-bottom: 0.6em;
padding: 5px 0 0 20px;
}
+ ul + p {
+ margin-top: 1em;
+ }
+
ul, ol {
p {
margin-bottom: 0;
@@ -683,7 +733,7 @@ body.ios {
}
.post__time {
- font-size: 13px;
+ font-size: 0.9em;
}
.post__time, &.post--system .post__body {
diff --git a/web/sass-files/sass/partials/_post_right.scss b/web/sass-files/sass/partials/_post_right.scss
index fa52e2972..d820447f5 100644
--- a/web/sass-files/sass/partials/_post_right.scss
+++ b/web/sass-files/sass/partials/_post_right.scss
@@ -17,7 +17,8 @@
.post {
&.post--root {
- padding-bottom: 0;
+ padding-bottom: 1.2em;
+ border-bottom: 1px solid #ddd;
}
.post__header {
@@ -36,6 +37,7 @@
hr {
margin-bottom: 0;
+ border: none;
}
.post-create__container {
diff --git a/web/sass-files/sass/partials/_responsive.scss b/web/sass-files/sass/partials/_responsive.scss
index 2aa130fa9..8491869a6 100644
--- a/web/sass-files/sass/partials/_responsive.scss
+++ b/web/sass-files/sass/partials/_responsive.scss
@@ -56,6 +56,7 @@
.sidebar--right {
z-index: 5;
@include translateX(100%);
+ @include single-transition(all, 0.5s, ease);
&.move--left {
@include translateX(0);
@@ -241,6 +242,9 @@
}
}
}
+ .post-list__timestamp {
+ display: block;
+ }
.signup-team__container {
padding: 30px 0;
margin-bottom: 30px;
@@ -295,15 +299,6 @@
.settings-content {
&.minimize-settings {
display: block;
- .section-edit {
- position: absolute;
- top: 7px;
- right: 0;
- width: 50px;
- .fa {
- display: inline-block;
- }
- }
}
.section-min:hover {
background: none;
@@ -336,12 +331,24 @@
}
}
.settings-table {
- .nav, .nav.absolute {
+ .nav {
+ &.position--top {
+ top: auto;
+ }
position: relative;
top: auto;
width: 100%;
}
.settings-content {
+ .section-edit {
+ position: absolute;
+ top: 14px;
+ right: 0;
+ padding-right: 0;
+ .fa {
+ display: inline-block;
+ }
+ }
&.minimize-settings {
padding: 0;
display: none;
@@ -349,6 +356,9 @@
padding: 70px 20px 30px;
}
}
+ .section-min:hover {
+ background: none !important;
+ }
}
.settings-links {
background: #fff;
@@ -625,6 +635,7 @@
height: calc(100% - 44px);
}
.inner__wrap {
+ @include single-transition(all, 0.5s, ease);
&.move--right {
@include translate3d(290px, 0, 0);
&:before {
@@ -728,6 +739,9 @@
}
}
@media screen and (max-width: 480px) {
+ .tip-overlay.tip-overlay--sidebar {
+ min-height: 350px;
+ }
.modal {
.modal-body {
max-height: 70%;
@@ -795,4 +809,4 @@
font-size: 2em;
}
}
-} \ No newline at end of file
+}
diff --git a/web/sass-files/sass/partials/_search.scss b/web/sass-files/sass/partials/_search.scss
index 0d9d7b50d..cb125bff0 100644
--- a/web/sass-files/sass/partials/_search.scss
+++ b/web/sass-files/sass/partials/_search.scss
@@ -18,6 +18,12 @@
z-index: 5;
cursor: pointer;
}
+
+.search-item-snippet {
+ @include clearfix;
+ text-overflow: ellipsis;
+}
+
.sidebar__collapse {
cursor: pointer;
z-index: 5;
@@ -134,7 +140,8 @@
}
.search-item-time {
- color: #a8adb7;
+ @include opacity(0.7);
+ font-size: 0.9em;
}
.search-results-none {
diff --git a/web/sass-files/sass/partials/_settings.scss b/web/sass-files/sass/partials/_settings.scss
index 473ffb28d..bd47ca960 100644
--- a/web/sass-files/sass/partials/_settings.scss
+++ b/web/sass-files/sass/partials/_settings.scss
@@ -82,9 +82,8 @@
.nav {
position: fixed;
width: 179px;
- &.absolute {
- position: absolute;
- top: 0;
+ &.position--top {
+ top: 57px;
}
}
.security-links {
@@ -141,144 +140,167 @@
width: 29px;
}
}
- .premade-themes {
- margin-bottom: 10px;
- .theme-label {
- font-weight: 400;
- margin-top: 5px;
- }
- img {
- border: 3px solid transparent;
+ .group--code {
+ select {
+ padding-right: 25px;
}
- .active {
- img {
- border-color: $primary-color;
- }
+ &:before {
+ position: absolute;
+ top: 11px;
+ right: 50px;
+ z-index: 5;
+ content: "\f0d7";
+ display: inline-block;
+ font: normal normal normal 14px/1 FontAwesome;
+ font-size: inherit;
+ text-rendering: auto;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
}
+ select {
+ -moz-appearance:none;
+ -webkit-appearance:none;
+ appearance:none;
+ }
+ }
+ .premade-themes {
+ margin-bottom: 10px;
+ .theme-label {
+ font-weight: 400;
+ margin-top: 5px;
}
- .custom-label {
- font-weight: normal;
- font-size: 13px;
- width: 100%;
- overflow: hidden;
- text-overflow: ellipsis;
- margin-bottom: 0;
- }
- .input-group-addon {
- background: transparent;
+ img {
+ border: 3px solid transparent;
}
- .radio {
- label {
- font-weight: 600;
+ .active {
+ img {
+ border-color: $primary-color;
}
}
}
-
- .section-title {
- margin-bottom: 5px;
- font-weight: 600;
+ .custom-label {
+ font-weight: normal;
+ font-size: 13px;
+ width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-bottom: 0;
}
-
- .section-edit {
- text-align: right;
- margin-bottom: 5px;
- .fa {
- margin-right: 5px;
- font-size: 12px;
- @include opacity(0.5);
- display: none;
+ .input-group-addon {
+ background: transparent;
+ }
+ .radio {
+ label {
+ font-weight: 600;
}
}
+ }
- .section-describe {
- @include opacity(0.7);
- white-space:pre;
- }
+ .section-title {
+ margin-bottom: 5px;
+ font-weight: 600;
+ }
- .divider-dark {
- border-bottom:1px solid #aaaaaa;
+ .section-edit {
+ text-align: right;
+ margin-bottom: 5px;
+ .fa {
+ margin-right: 5px;
+ font-size: 12px;
+ @include opacity(0.5);
+ display: none;
}
+ }
- .divider-light {
- border-bottom:1px solid lightgrey;
- }
+ .section-describe {
+ @include opacity(0.7);
+ white-space:pre;
+ }
- .setting-list {
- padding: 0;
- list-style-type:none;
- }
+ .divider-dark {
+ border-bottom:1px solid #aaaaaa;
+ }
- .setting-list__hint {
- margin-top: 20px;
- }
+ .divider-light {
+ border-bottom:1px solid lightgrey;
+ }
- .mentions-input {
- margin-top: 10px;
- }
+ .setting-list {
+ padding: 0;
+ list-style-type:none;
+ }
- .setting-list-item {
- margin-top:7px;
- }
- .has-error {
- color: #a94442;
- }
+ .setting-list__hint {
+ margin-top: 20px;
+ }
- .file-status {
- font-size: 13px;
- margin-top: 8px;
- color: #555;
- }
+ .mentions-input {
+ margin-top: 10px;
+ }
- .confirm-import {
- padding: 4px 10px;
- margin: 10px 0;
- }
+ .setting-list-item {
+ margin-top:7px;
+ }
+ .has-error {
+ color: #a94442;
+ }
+ .file-status {
+ font-size: 13px;
+ margin-top: 8px;
+ color: #555;
}
+
+ .confirm-import {
+ padding: 4px 10px;
+ margin: 10px 0;
+ }
+
}
- .nav-pills {
- > li {
- margin: 0;
+}
+.nav-pills {
+ > li {
+ margin: 0;
+ a {
+ border-radius: 0;
+ color: #777;
+ }
+ .glyphicon {
+ width: 25px;
+ top: 2px;
+ }
+ &:hover {
a {
- border-radius: 0;
- color: #777;
+ background: #E6F2FA;
}
- .glyphicon {
- width: 25px;
- top: 2px;
- }
- &:hover {
- a {
- background: #E6F2FA;
+ }
+ &.active {
+ a {
+ color: #111;
+ background-color: #E1E1E1;
+ &:before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 5px;
+ height: 100%;
+ background: #000;
}
}
- &.active {
- a {
- color: #111;
- background-color: #E1E1E1;
- &:before {
- content: '';
- position: absolute;
- top: 0;
- left: 0;
- width: 5px;
- height: 100%;
- background: #000;
- }
- }
- a, a:hover, a:focus {
- padding-right: 10px;
- background-color: rgba(black, 0.1);
- border-radius: 0;
- font-weight: 400;
- position: relative;
- }
+ a, a:hover, a:focus {
+ padding-right: 10px;
+ background-color: rgba(black, 0.1);
+ border-radius: 0;
+ font-weight: 400;
+ position: relative;
}
}
}
- h3 {
- font-size: em(20px);
- }
+}
+h3 {
+ font-size: em(20px);
+}
}
.channel-settings {
diff --git a/web/sass-files/sass/partials/_sidebar--right.scss b/web/sass-files/sass/partials/_sidebar--right.scss
index e39f7730b..f40a50b03 100644
--- a/web/sass-files/sass/partials/_sidebar--right.scss
+++ b/web/sass-files/sass/partials/_sidebar--right.scss
@@ -7,13 +7,7 @@
right: 0px;
padding: 0;
background: #fff;
- @include single-transition(transform, 0.5s, ease);
@include translateX(400px);
-
- &.move--left {
- @include translateX(0);
- }
-
.post-body {
img {
diff --git a/web/sass-files/sass/partials/_tutorial.scss b/web/sass-files/sass/partials/_tutorial.scss
index 9e5e1ad7a..20a15441e 100644
--- a/web/sass-files/sass/partials/_tutorial.scss
+++ b/web/sass-files/sass/partials/_tutorial.scss
@@ -32,6 +32,14 @@
&.tip-overlay--sidebar {
max-width: 75%;
margin: 50px 0 0 10px;
+ min-height: 310px;
+ .tutorial__footer {
+ position: absolute;
+ width: 100%;
+ bottom: 20px;
+ left: 0;
+ padding: 0 20px;
+ }
.arrow {
top: 80px;
left: -10px;
diff --git a/web/static/images/postArrows.png b/web/static/images/postArrows.png
new file mode 100644
index 000000000..7b5919fc3
--- /dev/null
+++ b/web/static/images/postArrows.png
Binary files differ
diff --git a/web/static/js/velocity.min.js b/web/static/js/velocity.min.js
new file mode 100644
index 000000000..100360353
--- /dev/null
+++ b/web/static/js/velocity.min.js
@@ -0,0 +1,4 @@
+/*! VelocityJS.org (1.2.3). (C) 2014 Julian Shapiro. MIT @license: en.wikipedia.org/wiki/MIT_License */
+/*! VelocityJS.org jQuery Shim (1.0.1). (C) 2014 The jQuery Foundation. MIT @license: en.wikipedia.org/wiki/MIT_License. */
+!function(a){function b(a){var b=a.length,d=c.type(a);return"function"===d||c.isWindow(a)?!1:1===a.nodeType&&b?!0:"array"===d||0===b||"number"==typeof b&&b>0&&b-1 in a}if(!a.jQuery){var c=function(a,b){return new c.fn.init(a,b)};c.isWindow=function(a){return null!=a&&a==a.window},c.type=function(a){return null==a?a+"":"object"==typeof a||"function"==typeof a?e[g.call(a)]||"object":typeof a},c.isArray=Array.isArray||function(a){return"array"===c.type(a)},c.isPlainObject=function(a){var b;if(!a||"object"!==c.type(a)||a.nodeType||c.isWindow(a))return!1;try{if(a.constructor&&!f.call(a,"constructor")&&!f.call(a.constructor.prototype,"isPrototypeOf"))return!1}catch(d){return!1}for(b in a);return void 0===b||f.call(a,b)},c.each=function(a,c,d){var e,f=0,g=a.length,h=b(a);if(d){if(h)for(;g>f&&(e=c.apply(a[f],d),e!==!1);f++);else for(f in a)if(e=c.apply(a[f],d),e===!1)break}else if(h)for(;g>f&&(e=c.call(a[f],f,a[f]),e!==!1);f++);else for(f in a)if(e=c.call(a[f],f,a[f]),e===!1)break;return a},c.data=function(a,b,e){if(void 0===e){var f=a[c.expando],g=f&&d[f];if(void 0===b)return g;if(g&&b in g)return g[b]}else if(void 0!==b){var f=a[c.expando]||(a[c.expando]=++c.uuid);return d[f]=d[f]||{},d[f][b]=e,e}},c.removeData=function(a,b){var e=a[c.expando],f=e&&d[e];f&&c.each(b,function(a,b){delete f[b]})},c.extend=function(){var a,b,d,e,f,g,h=arguments[0]||{},i=1,j=arguments.length,k=!1;for("boolean"==typeof h&&(k=h,h=arguments[i]||{},i++),"object"!=typeof h&&"function"!==c.type(h)&&(h={}),i===j&&(h=this,i--);j>i;i++)if(null!=(f=arguments[i]))for(e in f)a=h[e],d=f[e],h!==d&&(k&&d&&(c.isPlainObject(d)||(b=c.isArray(d)))?(b?(b=!1,g=a&&c.isArray(a)?a:[]):g=a&&c.isPlainObject(a)?a:{},h[e]=c.extend(k,g,d)):void 0!==d&&(h[e]=d));return h},c.queue=function(a,d,e){function f(a,c){var d=c||[];return null!=a&&(b(Object(a))?!function(a,b){for(var c=+b.length,d=0,e=a.length;c>d;)a[e++]=b[d++];if(c!==c)for(;void 0!==b[d];)a[e++]=b[d++];return a.length=e,a}(d,"string"==typeof a?[a]:a):[].push.call(d,a)),d}if(a){d=(d||"fx")+"queue";var g=c.data(a,d);return e?(!g||c.isArray(e)?g=c.data(a,d,f(e)):g.push(e),g):g||[]}},c.dequeue=function(a,b){c.each(a.nodeType?[a]:a,function(a,d){b=b||"fx";var e=c.queue(d,b),f=e.shift();"inprogress"===f&&(f=e.shift()),f&&("fx"===b&&e.unshift("inprogress"),f.call(d,function(){c.dequeue(d,b)}))})},c.fn=c.prototype={init:function(a){if(a.nodeType)return this[0]=a,this;throw new Error("Not a DOM node.")},offset:function(){var b=this[0].getBoundingClientRect?this[0].getBoundingClientRect():{top:0,left:0};return{top:b.top+(a.pageYOffset||document.scrollTop||0)-(document.clientTop||0),left:b.left+(a.pageXOffset||document.scrollLeft||0)-(document.clientLeft||0)}},position:function(){function a(){for(var a=this.offsetParent||document;a&&"html"===!a.nodeType.toLowerCase&&"static"===a.style.position;)a=a.offsetParent;return a||document}var b=this[0],a=a.apply(b),d=this.offset(),e=/^(?:body|html)$/i.test(a.nodeName)?{top:0,left:0}:c(a).offset();return d.top-=parseFloat(b.style.marginTop)||0,d.left-=parseFloat(b.style.marginLeft)||0,a.style&&(e.top+=parseFloat(a.style.borderTopWidth)||0,e.left+=parseFloat(a.style.borderLeftWidth)||0),{top:d.top-e.top,left:d.left-e.left}}};var d={};c.expando="velocity"+(new Date).getTime(),c.uuid=0;for(var e={},f=e.hasOwnProperty,g=e.toString,h="Boolean Number String Function Array Date RegExp Object Error".split(" "),i=0;i<h.length;i++)e["[object "+h[i]+"]"]=h[i].toLowerCase();c.fn.init.prototype=c.fn,a.Velocity={Utilities:c}}}(window),function(a){"object"==typeof module&&"object"==typeof module.exports?module.exports=a():"function"==typeof define&&define.amd?define(a):a()}(function(){return function(a,b,c,d){function e(a){for(var b=-1,c=a?a.length:0,d=[];++b<c;){var e=a[b];e&&d.push(e)}return d}function f(a){return p.isWrapped(a)?a=[].slice.call(a):p.isNode(a)&&(a=[a]),a}function g(a){var b=m.data(a,"velocity");return null===b?d:b}function h(a){return function(b){return Math.round(b*a)*(1/a)}}function i(a,c,d,e){function f(a,b){return 1-3*b+3*a}function g(a,b){return 3*b-6*a}function h(a){return 3*a}function i(a,b,c){return((f(b,c)*a+g(b,c))*a+h(b))*a}function j(a,b,c){return 3*f(b,c)*a*a+2*g(b,c)*a+h(b)}function k(b,c){for(var e=0;p>e;++e){var f=j(c,a,d);if(0===f)return c;var g=i(c,a,d)-b;c-=g/f}return c}function l(){for(var b=0;t>b;++b)x[b]=i(b*u,a,d)}function m(b,c,e){var f,g,h=0;do g=c+(e-c)/2,f=i(g,a,d)-b,f>0?e=g:c=g;while(Math.abs(f)>r&&++h<s);return g}function n(b){for(var c=0,e=1,f=t-1;e!=f&&x[e]<=b;++e)c+=u;--e;var g=(b-x[e])/(x[e+1]-x[e]),h=c+g*u,i=j(h,a,d);return i>=q?k(b,h):0==i?h:m(b,c,c+u)}function o(){y=!0,(a!=c||d!=e)&&l()}var p=4,q=.001,r=1e-7,s=10,t=11,u=1/(t-1),v="Float32Array"in b;if(4!==arguments.length)return!1;for(var w=0;4>w;++w)if("number"!=typeof arguments[w]||isNaN(arguments[w])||!isFinite(arguments[w]))return!1;a=Math.min(a,1),d=Math.min(d,1),a=Math.max(a,0),d=Math.max(d,0);var x=v?new Float32Array(t):new Array(t),y=!1,z=function(b){return y||o(),a===c&&d===e?b:0===b?0:1===b?1:i(n(b),c,e)};z.getControlPoints=function(){return[{x:a,y:c},{x:d,y:e}]};var A="generateBezier("+[a,c,d,e]+")";return z.toString=function(){return A},z}function j(a,b){var c=a;return p.isString(a)?t.Easings[a]||(c=!1):c=p.isArray(a)&&1===a.length?h.apply(null,a):p.isArray(a)&&2===a.length?u.apply(null,a.concat([b])):p.isArray(a)&&4===a.length?i.apply(null,a):!1,c===!1&&(c=t.Easings[t.defaults.easing]?t.defaults.easing:s),c}function k(a){if(a){var b=(new Date).getTime(),c=t.State.calls.length;c>1e4&&(t.State.calls=e(t.State.calls));for(var f=0;c>f;f++)if(t.State.calls[f]){var h=t.State.calls[f],i=h[0],j=h[2],n=h[3],o=!!n,q=null;n||(n=t.State.calls[f][3]=b-16);for(var r=Math.min((b-n)/j.duration,1),s=0,u=i.length;u>s;s++){var w=i[s],y=w.element;if(g(y)){var z=!1;if(j.display!==d&&null!==j.display&&"none"!==j.display){if("flex"===j.display){var A=["-webkit-box","-moz-box","-ms-flexbox","-webkit-flex"];m.each(A,function(a,b){v.setPropertyValue(y,"display",b)})}v.setPropertyValue(y,"display",j.display)}j.visibility!==d&&"hidden"!==j.visibility&&v.setPropertyValue(y,"visibility",j.visibility);for(var B in w)if("element"!==B){var C,D=w[B],E=p.isString(D.easing)?t.Easings[D.easing]:D.easing;if(1===r)C=D.endValue;else{var F=D.endValue-D.startValue;if(C=D.startValue+F*E(r,j,F),!o&&C===D.currentValue)continue}if(D.currentValue=C,"tween"===B)q=C;else{if(v.Hooks.registered[B]){var G=v.Hooks.getRoot(B),H=g(y).rootPropertyValueCache[G];H&&(D.rootPropertyValue=H)}var I=v.setPropertyValue(y,B,D.currentValue+(0===parseFloat(C)?"":D.unitType),D.rootPropertyValue,D.scrollData);v.Hooks.registered[B]&&(g(y).rootPropertyValueCache[G]=v.Normalizations.registered[G]?v.Normalizations.registered[G]("extract",null,I[1]):I[1]),"transform"===I[0]&&(z=!0)}}j.mobileHA&&g(y).transformCache.translate3d===d&&(g(y).transformCache.translate3d="(0px, 0px, 0px)",z=!0),z&&v.flushTransformCache(y)}}j.display!==d&&"none"!==j.display&&(t.State.calls[f][2].display=!1),j.visibility!==d&&"hidden"!==j.visibility&&(t.State.calls[f][2].visibility=!1),j.progress&&j.progress.call(h[1],h[1],r,Math.max(0,n+j.duration-b),n,q),1===r&&l(f)}}t.State.isTicking&&x(k)}function l(a,b){if(!t.State.calls[a])return!1;for(var c=t.State.calls[a][0],e=t.State.calls[a][1],f=t.State.calls[a][2],h=t.State.calls[a][4],i=!1,j=0,k=c.length;k>j;j++){var l=c[j].element;if(b||f.loop||("none"===f.display&&v.setPropertyValue(l,"display",f.display),"hidden"===f.visibility&&v.setPropertyValue(l,"visibility",f.visibility)),f.loop!==!0&&(m.queue(l)[1]===d||!/\.velocityQueueEntryFlag/i.test(m.queue(l)[1]))&&g(l)){g(l).isAnimating=!1,g(l).rootPropertyValueCache={};var n=!1;m.each(v.Lists.transforms3D,function(a,b){var c=/^scale/.test(b)?1:0,e=g(l).transformCache[b];g(l).transformCache[b]!==d&&new RegExp("^\\("+c+"[^.]").test(e)&&(n=!0,delete g(l).transformCache[b])}),f.mobileHA&&(n=!0,delete g(l).transformCache.translate3d),n&&v.flushTransformCache(l),v.Values.removeClass(l,"velocity-animating")}if(!b&&f.complete&&!f.loop&&j===k-1)try{f.complete.call(e,e)}catch(o){setTimeout(function(){throw o},1)}h&&f.loop!==!0&&h(e),g(l)&&f.loop===!0&&!b&&(m.each(g(l).tweensContainer,function(a,b){/^rotate/.test(a)&&360===parseFloat(b.endValue)&&(b.endValue=0,b.startValue=360),/^backgroundPosition/.test(a)&&100===parseFloat(b.endValue)&&"%"===b.unitType&&(b.endValue=0,b.startValue=100)}),t(l,"reverse",{loop:!0,delay:f.delay})),f.queue!==!1&&m.dequeue(l,f.queue)}t.State.calls[a]=!1;for(var p=0,q=t.State.calls.length;q>p;p++)if(t.State.calls[p]!==!1){i=!0;break}i===!1&&(t.State.isTicking=!1,delete t.State.calls,t.State.calls=[])}var m,n=function(){if(c.documentMode)return c.documentMode;for(var a=7;a>4;a--){var b=c.createElement("div");if(b.innerHTML="<!--[if IE "+a+"]><span></span><![endif]-->",b.getElementsByTagName("span").length)return b=null,a}return d}(),o=function(){var a=0;return b.webkitRequestAnimationFrame||b.mozRequestAnimationFrame||function(b){var c,d=(new Date).getTime();return c=Math.max(0,16-(d-a)),a=d+c,setTimeout(function(){b(d+c)},c)}}(),p={isString:function(a){return"string"==typeof a},isArray:Array.isArray||function(a){return"[object Array]"===Object.prototype.toString.call(a)},isFunction:function(a){return"[object Function]"===Object.prototype.toString.call(a)},isNode:function(a){return a&&a.nodeType},isNodeList:function(a){return"object"==typeof a&&/^\[object (HTMLCollection|NodeList|Object)\]$/.test(Object.prototype.toString.call(a))&&a.length!==d&&(0===a.length||"object"==typeof a[0]&&a[0].nodeType>0)},isWrapped:function(a){return a&&(a.jquery||b.Zepto&&b.Zepto.zepto.isZ(a))},isSVG:function(a){return b.SVGElement&&a instanceof b.SVGElement},isEmptyObject:function(a){for(var b in a)return!1;return!0}},q=!1;if(a.fn&&a.fn.jquery?(m=a,q=!0):m=b.Velocity.Utilities,8>=n&&!q)throw new Error("Velocity: IE8 and below require jQuery to be loaded before Velocity.");if(7>=n)return void(jQuery.fn.velocity=jQuery.fn.animate);var r=400,s="swing",t={State:{isMobile:/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent),isAndroid:/Android/i.test(navigator.userAgent),isGingerbread:/Android 2\.3\.[3-7]/i.test(navigator.userAgent),isChrome:b.chrome,isFirefox:/Firefox/i.test(navigator.userAgent),prefixElement:c.createElement("div"),prefixMatches:{},scrollAnchor:null,scrollPropertyLeft:null,scrollPropertyTop:null,isTicking:!1,calls:[]},CSS:{},Utilities:m,Redirects:{},Easings:{},Promise:b.Promise,defaults:{queue:"",duration:r,easing:s,begin:d,complete:d,progress:d,display:d,visibility:d,loop:!1,delay:!1,mobileHA:!0,_cacheValues:!0},init:function(a){m.data(a,"velocity",{isSVG:p.isSVG(a),isAnimating:!1,computedStyle:null,tweensContainer:null,rootPropertyValueCache:{},transformCache:{}})},hook:null,mock:!1,version:{major:1,minor:2,patch:2},debug:!1};b.pageYOffset!==d?(t.State.scrollAnchor=b,t.State.scrollPropertyLeft="pageXOffset",t.State.scrollPropertyTop="pageYOffset"):(t.State.scrollAnchor=c.documentElement||c.body.parentNode||c.body,t.State.scrollPropertyLeft="scrollLeft",t.State.scrollPropertyTop="scrollTop");var u=function(){function a(a){return-a.tension*a.x-a.friction*a.v}function b(b,c,d){var e={x:b.x+d.dx*c,v:b.v+d.dv*c,tension:b.tension,friction:b.friction};return{dx:e.v,dv:a(e)}}function c(c,d){var e={dx:c.v,dv:a(c)},f=b(c,.5*d,e),g=b(c,.5*d,f),h=b(c,d,g),i=1/6*(e.dx+2*(f.dx+g.dx)+h.dx),j=1/6*(e.dv+2*(f.dv+g.dv)+h.dv);return c.x=c.x+i*d,c.v=c.v+j*d,c}return function d(a,b,e){var f,g,h,i={x:-1,v:0,tension:null,friction:null},j=[0],k=0,l=1e-4,m=.016;for(a=parseFloat(a)||500,b=parseFloat(b)||20,e=e||null,i.tension=a,i.friction=b,f=null!==e,f?(k=d(a,b),g=k/e*m):g=m;;)if(h=c(h||i,g),j.push(1+h.x),k+=16,!(Math.abs(h.x)>l&&Math.abs(h.v)>l))break;return f?function(a){return j[a*(j.length-1)|0]}:k}}();t.Easings={linear:function(a){return a},swing:function(a){return.5-Math.cos(a*Math.PI)/2},spring:function(a){return 1-Math.cos(4.5*a*Math.PI)*Math.exp(6*-a)}},m.each([["ease",[.25,.1,.25,1]],["ease-in",[.42,0,1,1]],["ease-out",[0,0,.58,1]],["ease-in-out",[.42,0,.58,1]],["easeInSine",[.47,0,.745,.715]],["easeOutSine",[.39,.575,.565,1]],["easeInOutSine",[.445,.05,.55,.95]],["easeInQuad",[.55,.085,.68,.53]],["easeOutQuad",[.25,.46,.45,.94]],["easeInOutQuad",[.455,.03,.515,.955]],["easeInCubic",[.55,.055,.675,.19]],["easeOutCubic",[.215,.61,.355,1]],["easeInOutCubic",[.645,.045,.355,1]],["easeInQuart",[.895,.03,.685,.22]],["easeOutQuart",[.165,.84,.44,1]],["easeInOutQuart",[.77,0,.175,1]],["easeInQuint",[.755,.05,.855,.06]],["easeOutQuint",[.23,1,.32,1]],["easeInOutQuint",[.86,0,.07,1]],["easeInExpo",[.95,.05,.795,.035]],["easeOutExpo",[.19,1,.22,1]],["easeInOutExpo",[1,0,0,1]],["easeInCirc",[.6,.04,.98,.335]],["easeOutCirc",[.075,.82,.165,1]],["easeInOutCirc",[.785,.135,.15,.86]]],function(a,b){t.Easings[b[0]]=i.apply(null,b[1])});var v=t.CSS={RegEx:{isHex:/^#([A-f\d]{3}){1,2}$/i,valueUnwrap:/^[A-z]+\((.*)\)$/i,wrappedValueAlreadyExtracted:/[0-9.]+ [0-9.]+ [0-9.]+( [0-9.]+)?/,valueSplit:/([A-z]+\(.+\))|(([A-z0-9#-.]+?)(?=\s|$))/gi},Lists:{colors:["fill","stroke","stopColor","color","backgroundColor","borderColor","borderTopColor","borderRightColor","borderBottomColor","borderLeftColor","outlineColor"],transformsBase:["translateX","translateY","scale","scaleX","scaleY","skewX","skewY","rotateZ"],transforms3D:["transformPerspective","translateZ","scaleZ","rotateX","rotateY"]},Hooks:{templates:{textShadow:["Color X Y Blur","black 0px 0px 0px"],boxShadow:["Color X Y Blur Spread","black 0px 0px 0px 0px"],clip:["Top Right Bottom Left","0px 0px 0px 0px"],backgroundPosition:["X Y","0% 0%"],transformOrigin:["X Y Z","50% 50% 0px"],perspectiveOrigin:["X Y","50% 50%"]},registered:{},register:function(){for(var a=0;a<v.Lists.colors.length;a++){var b="color"===v.Lists.colors[a]?"0 0 0 1":"255 255 255 1";v.Hooks.templates[v.Lists.colors[a]]=["Red Green Blue Alpha",b]}var c,d,e;if(n)for(c in v.Hooks.templates){d=v.Hooks.templates[c],e=d[0].split(" ");var f=d[1].match(v.RegEx.valueSplit);"Color"===e[0]&&(e.push(e.shift()),f.push(f.shift()),v.Hooks.templates[c]=[e.join(" "),f.join(" ")])}for(c in v.Hooks.templates){d=v.Hooks.templates[c],e=d[0].split(" ");for(var a in e){var g=c+e[a],h=a;v.Hooks.registered[g]=[c,h]}}},getRoot:function(a){var b=v.Hooks.registered[a];return b?b[0]:a},cleanRootPropertyValue:function(a,b){return v.RegEx.valueUnwrap.test(b)&&(b=b.match(v.RegEx.valueUnwrap)[1]),v.Values.isCSSNullValue(b)&&(b=v.Hooks.templates[a][1]),b},extractValue:function(a,b){var c=v.Hooks.registered[a];if(c){var d=c[0],e=c[1];return b=v.Hooks.cleanRootPropertyValue(d,b),b.toString().match(v.RegEx.valueSplit)[e]}return b},injectValue:function(a,b,c){var d=v.Hooks.registered[a];if(d){var e,f,g=d[0],h=d[1];return c=v.Hooks.cleanRootPropertyValue(g,c),e=c.toString().match(v.RegEx.valueSplit),e[h]=b,f=e.join(" ")}return c}},Normalizations:{registered:{clip:function(a,b,c){switch(a){case"name":return"clip";case"extract":var d;return v.RegEx.wrappedValueAlreadyExtracted.test(c)?d=c:(d=c.toString().match(v.RegEx.valueUnwrap),d=d?d[1].replace(/,(\s+)?/g," "):c),d;case"inject":return"rect("+c+")"}},blur:function(a,b,c){switch(a){case"name":return t.State.isFirefox?"filter":"-webkit-filter";case"extract":var d=parseFloat(c);if(!d&&0!==d){var e=c.toString().match(/blur\(([0-9]+[A-z]+)\)/i);d=e?e[1]:0}return d;case"inject":return parseFloat(c)?"blur("+c+")":"none"}},opacity:function(a,b,c){if(8>=n)switch(a){case"name":return"filter";case"extract":var d=c.toString().match(/alpha\(opacity=(.*)\)/i);return c=d?d[1]/100:1;case"inject":return b.style.zoom=1,parseFloat(c)>=1?"":"alpha(opacity="+parseInt(100*parseFloat(c),10)+")"}else switch(a){case"name":return"opacity";case"extract":return c;case"inject":return c}}},register:function(){9>=n||t.State.isGingerbread||(v.Lists.transformsBase=v.Lists.transformsBase.concat(v.Lists.transforms3D));for(var a=0;a<v.Lists.transformsBase.length;a++)!function(){var b=v.Lists.transformsBase[a];v.Normalizations.registered[b]=function(a,c,e){switch(a){case"name":return"transform";case"extract":return g(c)===d||g(c).transformCache[b]===d?/^scale/i.test(b)?1:0:g(c).transformCache[b].replace(/[()]/g,"");case"inject":var f=!1;switch(b.substr(0,b.length-1)){case"translate":f=!/(%|px|em|rem|vw|vh|\d)$/i.test(e);break;case"scal":case"scale":t.State.isAndroid&&g(c).transformCache[b]===d&&1>e&&(e=1),f=!/(\d)$/i.test(e);break;case"skew":f=!/(deg|\d)$/i.test(e);break;case"rotate":f=!/(deg|\d)$/i.test(e)}return f||(g(c).transformCache[b]="("+e+")"),g(c).transformCache[b]}}}();for(var a=0;a<v.Lists.colors.length;a++)!function(){var b=v.Lists.colors[a];v.Normalizations.registered[b]=function(a,c,e){switch(a){case"name":return b;case"extract":var f;if(v.RegEx.wrappedValueAlreadyExtracted.test(e))f=e;else{var g,h={black:"rgb(0, 0, 0)",blue:"rgb(0, 0, 255)",gray:"rgb(128, 128, 128)",green:"rgb(0, 128, 0)",red:"rgb(255, 0, 0)",white:"rgb(255, 255, 255)"};/^[A-z]+$/i.test(e)?g=h[e]!==d?h[e]:h.black:v.RegEx.isHex.test(e)?g="rgb("+v.Values.hexToRgb(e).join(" ")+")":/^rgba?\(/i.test(e)||(g=h.black),f=(g||e).toString().match(v.RegEx.valueUnwrap)[1].replace(/,(\s+)?/g," ")}return 8>=n||3!==f.split(" ").length||(f+=" 1"),f;case"inject":return 8>=n?4===e.split(" ").length&&(e=e.split(/\s+/).slice(0,3).join(" ")):3===e.split(" ").length&&(e+=" 1"),(8>=n?"rgb":"rgba")+"("+e.replace(/\s+/g,",").replace(/\.(\d)+(?=,)/g,"")+")"}}}()}},Names:{camelCase:function(a){return a.replace(/-(\w)/g,function(a,b){return b.toUpperCase()})},SVGAttribute:function(a){var b="width|height|x|y|cx|cy|r|rx|ry|x1|x2|y1|y2";return(n||t.State.isAndroid&&!t.State.isChrome)&&(b+="|transform"),new RegExp("^("+b+")$","i").test(a)},prefixCheck:function(a){if(t.State.prefixMatches[a])return[t.State.prefixMatches[a],!0];for(var b=["","Webkit","Moz","ms","O"],c=0,d=b.length;d>c;c++){var e;if(e=0===c?a:b[c]+a.replace(/^\w/,function(a){return a.toUpperCase()}),p.isString(t.State.prefixElement.style[e]))return t.State.prefixMatches[a]=e,[e,!0]}return[a,!1]}},Values:{hexToRgb:function(a){var b,c=/^#?([a-f\d])([a-f\d])([a-f\d])$/i,d=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i;return a=a.replace(c,function(a,b,c,d){return b+b+c+c+d+d}),b=d.exec(a),b?[parseInt(b[1],16),parseInt(b[2],16),parseInt(b[3],16)]:[0,0,0]},isCSSNullValue:function(a){return 0==a||/^(none|auto|transparent|(rgba\(0, ?0, ?0, ?0\)))$/i.test(a)},getUnitType:function(a){return/^(rotate|skew)/i.test(a)?"deg":/(^(scale|scaleX|scaleY|scaleZ|alpha|flexGrow|flexHeight|zIndex|fontWeight)$)|((opacity|red|green|blue|alpha)$)/i.test(a)?"":"px"},getDisplayType:function(a){var b=a&&a.tagName.toString().toLowerCase();return/^(b|big|i|small|tt|abbr|acronym|cite|code|dfn|em|kbd|strong|samp|var|a|bdo|br|img|map|object|q|script|span|sub|sup|button|input|label|select|textarea)$/i.test(b)?"inline":/^(li)$/i.test(b)?"list-item":/^(tr)$/i.test(b)?"table-row":/^(table)$/i.test(b)?"table":/^(tbody)$/i.test(b)?"table-row-group":"block"},addClass:function(a,b){a.classList?a.classList.add(b):a.className+=(a.className.length?" ":"")+b},removeClass:function(a,b){a.classList?a.classList.remove(b):a.className=a.className.toString().replace(new RegExp("(^|\\s)"+b.split(" ").join("|")+"(\\s|$)","gi")," ")}},getPropertyValue:function(a,c,e,f){function h(a,c){function e(){j&&v.setPropertyValue(a,"display","none")}var i=0;if(8>=n)i=m.css(a,c);else{var j=!1;if(/^(width|height)$/.test(c)&&0===v.getPropertyValue(a,"display")&&(j=!0,v.setPropertyValue(a,"display",v.Values.getDisplayType(a))),!f){if("height"===c&&"border-box"!==v.getPropertyValue(a,"boxSizing").toString().toLowerCase()){var k=a.offsetHeight-(parseFloat(v.getPropertyValue(a,"borderTopWidth"))||0)-(parseFloat(v.getPropertyValue(a,"borderBottomWidth"))||0)-(parseFloat(v.getPropertyValue(a,"paddingTop"))||0)-(parseFloat(v.getPropertyValue(a,"paddingBottom"))||0);return e(),k}if("width"===c&&"border-box"!==v.getPropertyValue(a,"boxSizing").toString().toLowerCase()){var l=a.offsetWidth-(parseFloat(v.getPropertyValue(a,"borderLeftWidth"))||0)-(parseFloat(v.getPropertyValue(a,"borderRightWidth"))||0)-(parseFloat(v.getPropertyValue(a,"paddingLeft"))||0)-(parseFloat(v.getPropertyValue(a,"paddingRight"))||0);return e(),l}}var o;o=g(a)===d?b.getComputedStyle(a,null):g(a).computedStyle?g(a).computedStyle:g(a).computedStyle=b.getComputedStyle(a,null),"borderColor"===c&&(c="borderTopColor"),i=9===n&&"filter"===c?o.getPropertyValue(c):o[c],(""===i||null===i)&&(i=a.style[c]),e()}if("auto"===i&&/^(top|right|bottom|left)$/i.test(c)){var p=h(a,"position");("fixed"===p||"absolute"===p&&/top|left/i.test(c))&&(i=m(a).position()[c]+"px")}return i}var i;if(v.Hooks.registered[c]){var j=c,k=v.Hooks.getRoot(j);e===d&&(e=v.getPropertyValue(a,v.Names.prefixCheck(k)[0])),v.Normalizations.registered[k]&&(e=v.Normalizations.registered[k]("extract",a,e)),i=v.Hooks.extractValue(j,e)}else if(v.Normalizations.registered[c]){var l,o;l=v.Normalizations.registered[c]("name",a),"transform"!==l&&(o=h(a,v.Names.prefixCheck(l)[0]),v.Values.isCSSNullValue(o)&&v.Hooks.templates[c]&&(o=v.Hooks.templates[c][1])),i=v.Normalizations.registered[c]("extract",a,o)}if(!/^[\d-]/.test(i))if(g(a)&&g(a).isSVG&&v.Names.SVGAttribute(c))if(/^(height|width)$/i.test(c))try{i=a.getBBox()[c]}catch(p){i=0}else i=a.getAttribute(c);else i=h(a,v.Names.prefixCheck(c)[0]);return v.Values.isCSSNullValue(i)&&(i=0),t.debug>=2&&console.log("Get "+c+": "+i),i},setPropertyValue:function(a,c,d,e,f){var h=c;if("scroll"===c)f.container?f.container["scroll"+f.direction]=d:"Left"===f.direction?b.scrollTo(d,f.alternateValue):b.scrollTo(f.alternateValue,d);else if(v.Normalizations.registered[c]&&"transform"===v.Normalizations.registered[c]("name",a))v.Normalizations.registered[c]("inject",a,d),h="transform",d=g(a).transformCache[c];else{if(v.Hooks.registered[c]){var i=c,j=v.Hooks.getRoot(c);e=e||v.getPropertyValue(a,j),d=v.Hooks.injectValue(i,d,e),c=j}if(v.Normalizations.registered[c]&&(d=v.Normalizations.registered[c]("inject",a,d),c=v.Normalizations.registered[c]("name",a)),h=v.Names.prefixCheck(c)[0],8>=n)try{a.style[h]=d}catch(k){t.debug&&console.log("Browser does not support ["+d+"] for ["+h+"]")}else g(a)&&g(a).isSVG&&v.Names.SVGAttribute(c)?a.setAttribute(c,d):a.style[h]=d;t.debug>=2&&console.log("Set "+c+" ("+h+"): "+d)}return[h,d]},flushTransformCache:function(a){function b(b){return parseFloat(v.getPropertyValue(a,b))}var c="";if((n||t.State.isAndroid&&!t.State.isChrome)&&g(a).isSVG){var d={translate:[b("translateX"),b("translateY")],skewX:[b("skewX")],skewY:[b("skewY")],scale:1!==b("scale")?[b("scale"),b("scale")]:[b("scaleX"),b("scaleY")],rotate:[b("rotateZ"),0,0]};m.each(g(a).transformCache,function(a){/^translate/i.test(a)?a="translate":/^scale/i.test(a)?a="scale":/^rotate/i.test(a)&&(a="rotate"),d[a]&&(c+=a+"("+d[a].join(" ")+") ",delete d[a])})}else{var e,f;m.each(g(a).transformCache,function(b){return e=g(a).transformCache[b],"transformPerspective"===b?(f=e,!0):(9===n&&"rotateZ"===b&&(b="rotate"),void(c+=b+e+" "))}),f&&(c="perspective"+f+" "+c)}v.setPropertyValue(a,"transform",c)}};v.Hooks.register(),v.Normalizations.register(),t.hook=function(a,b,c){var e=d;return a=f(a),m.each(a,function(a,f){if(g(f)===d&&t.init(f),c===d)e===d&&(e=t.CSS.getPropertyValue(f,b));else{var h=t.CSS.setPropertyValue(f,b,c);"transform"===h[0]&&t.CSS.flushTransformCache(f),e=h}}),e};var w=function(){function a(){return h?B.promise||null:i}function e(){function a(){function a(a,b){var c=d,e=d,g=d;return p.isArray(a)?(c=a[0],!p.isArray(a[1])&&/^[\d-]/.test(a[1])||p.isFunction(a[1])||v.RegEx.isHex.test(a[1])?g=a[1]:(p.isString(a[1])&&!v.RegEx.isHex.test(a[1])||p.isArray(a[1]))&&(e=b?a[1]:j(a[1],h.duration),a[2]!==d&&(g=a[2]))):c=a,b||(e=e||h.easing),p.isFunction(c)&&(c=c.call(f,y,x)),p.isFunction(g)&&(g=g.call(f,y,x)),[c||0,e,g]}function l(a,b){var c,d;return d=(b||"0").toString().toLowerCase().replace(/[%A-z]+$/,function(a){return c=a,""}),c||(c=v.Values.getUnitType(a)),[d,c]}function n(){var a={myParent:f.parentNode||c.body,position:v.getPropertyValue(f,"position"),fontSize:v.getPropertyValue(f,"fontSize")},d=a.position===I.lastPosition&&a.myParent===I.lastParent,e=a.fontSize===I.lastFontSize;I.lastParent=a.myParent,I.lastPosition=a.position,I.lastFontSize=a.fontSize;var h=100,i={};if(e&&d)i.emToPx=I.lastEmToPx,i.percentToPxWidth=I.lastPercentToPxWidth,i.percentToPxHeight=I.lastPercentToPxHeight;else{var j=g(f).isSVG?c.createElementNS("http://www.w3.org/2000/svg","rect"):c.createElement("div");t.init(j),a.myParent.appendChild(j),m.each(["overflow","overflowX","overflowY"],function(a,b){t.CSS.setPropertyValue(j,b,"hidden")}),t.CSS.setPropertyValue(j,"position",a.position),t.CSS.setPropertyValue(j,"fontSize",a.fontSize),t.CSS.setPropertyValue(j,"boxSizing","content-box"),m.each(["minWidth","maxWidth","width","minHeight","maxHeight","height"],function(a,b){t.CSS.setPropertyValue(j,b,h+"%")}),t.CSS.setPropertyValue(j,"paddingLeft",h+"em"),i.percentToPxWidth=I.lastPercentToPxWidth=(parseFloat(v.getPropertyValue(j,"width",null,!0))||1)/h,i.percentToPxHeight=I.lastPercentToPxHeight=(parseFloat(v.getPropertyValue(j,"height",null,!0))||1)/h,i.emToPx=I.lastEmToPx=(parseFloat(v.getPropertyValue(j,"paddingLeft"))||1)/h,a.myParent.removeChild(j)}return null===I.remToPx&&(I.remToPx=parseFloat(v.getPropertyValue(c.body,"fontSize"))||16),null===I.vwToPx&&(I.vwToPx=parseFloat(b.innerWidth)/100,I.vhToPx=parseFloat(b.innerHeight)/100),i.remToPx=I.remToPx,i.vwToPx=I.vwToPx,i.vhToPx=I.vhToPx,t.debug>=1&&console.log("Unit ratios: "+JSON.stringify(i),f),i}if(h.begin&&0===y)try{h.begin.call(o,o)}catch(r){setTimeout(function(){throw r},1)}if("scroll"===C){var u,w,z,A=/^x$/i.test(h.axis)?"Left":"Top",D=parseFloat(h.offset)||0;h.container?p.isWrapped(h.container)||p.isNode(h.container)?(h.container=h.container[0]||h.container,u=h.container["scroll"+A],z=u+m(f).position()[A.toLowerCase()]+D):h.container=null:(u=t.State.scrollAnchor[t.State["scrollProperty"+A]],w=t.State.scrollAnchor[t.State["scrollProperty"+("Left"===A?"Top":"Left")]],z=m(f).offset()[A.toLowerCase()]+D),i={scroll:{rootPropertyValue:!1,startValue:u,currentValue:u,endValue:z,unitType:"",easing:h.easing,scrollData:{container:h.container,direction:A,alternateValue:w}},element:f},t.debug&&console.log("tweensContainer (scroll): ",i.scroll,f)}else if("reverse"===C){if(!g(f).tweensContainer)return void m.dequeue(f,h.queue);"none"===g(f).opts.display&&(g(f).opts.display="auto"),"hidden"===g(f).opts.visibility&&(g(f).opts.visibility="visible"),g(f).opts.loop=!1,g(f).opts.begin=null,g(f).opts.complete=null,s.easing||delete h.easing,s.duration||delete h.duration,h=m.extend({},g(f).opts,h);var E=m.extend(!0,{},g(f).tweensContainer);for(var F in E)if("element"!==F){var G=E[F].startValue;E[F].startValue=E[F].currentValue=E[F].endValue,E[F].endValue=G,p.isEmptyObject(s)||(E[F].easing=h.easing),t.debug&&console.log("reverse tweensContainer ("+F+"): "+JSON.stringify(E[F]),f)}i=E}else if("start"===C){var E;g(f).tweensContainer&&g(f).isAnimating===!0&&(E=g(f).tweensContainer),m.each(q,function(b,c){if(RegExp("^"+v.Lists.colors.join("$|^")+"$").test(b)){var e=a(c,!0),f=e[0],g=e[1],h=e[2];if(v.RegEx.isHex.test(f)){for(var i=["Red","Green","Blue"],j=v.Values.hexToRgb(f),k=h?v.Values.hexToRgb(h):d,l=0;l<i.length;l++){var m=[j[l]];g&&m.push(g),k!==d&&m.push(k[l]),q[b+i[l]]=m}delete q[b]}}});for(var H in q){var K=a(q[H]),L=K[0],M=K[1],N=K[2];H=v.Names.camelCase(H);var O=v.Hooks.getRoot(H),P=!1;if(g(f).isSVG||"tween"===O||v.Names.prefixCheck(O)[1]!==!1||v.Normalizations.registered[O]!==d){(h.display!==d&&null!==h.display&&"none"!==h.display||h.visibility!==d&&"hidden"!==h.visibility)&&/opacity|filter/.test(H)&&!N&&0!==L&&(N=0),h._cacheValues&&E&&E[H]?(N===d&&(N=E[H].endValue+E[H].unitType),P=g(f).rootPropertyValueCache[O]):v.Hooks.registered[H]?N===d?(P=v.getPropertyValue(f,O),N=v.getPropertyValue(f,H,P)):P=v.Hooks.templates[O][1]:N===d&&(N=v.getPropertyValue(f,H));var Q,R,S,T=!1;if(Q=l(H,N),N=Q[0],S=Q[1],Q=l(H,L),L=Q[0].replace(/^([+-\/*])=/,function(a,b){return T=b,""}),R=Q[1],N=parseFloat(N)||0,L=parseFloat(L)||0,"%"===R&&(/^(fontSize|lineHeight)$/.test(H)?(L/=100,R="em"):/^scale/.test(H)?(L/=100,R=""):/(Red|Green|Blue)$/i.test(H)&&(L=L/100*255,R="")),/[\/*]/.test(T))R=S;else if(S!==R&&0!==N)if(0===L)R=S;else{e=e||n();var U=/margin|padding|left|right|width|text|word|letter/i.test(H)||/X$/.test(H)||"x"===H?"x":"y";switch(S){case"%":N*="x"===U?e.percentToPxWidth:e.percentToPxHeight;break;case"px":break;default:N*=e[S+"ToPx"]}switch(R){case"%":N*=1/("x"===U?e.percentToPxWidth:e.percentToPxHeight);break;case"px":break;default:N*=1/e[R+"ToPx"]}}switch(T){case"+":L=N+L;break;case"-":L=N-L;break;case"*":L=N*L;break;case"/":L=N/L}i[H]={rootPropertyValue:P,startValue:N,currentValue:N,endValue:L,unitType:R,easing:M},t.debug&&console.log("tweensContainer ("+H+"): "+JSON.stringify(i[H]),f)}else t.debug&&console.log("Skipping ["+O+"] due to a lack of browser support.")}i.element=f}i.element&&(v.Values.addClass(f,"velocity-animating"),J.push(i),""===h.queue&&(g(f).tweensContainer=i,g(f).opts=h),g(f).isAnimating=!0,y===x-1?(t.State.calls.push([J,o,h,null,B.resolver]),t.State.isTicking===!1&&(t.State.isTicking=!0,k())):y++)}var e,f=this,h=m.extend({},t.defaults,s),i={};switch(g(f)===d&&t.init(f),parseFloat(h.delay)&&h.queue!==!1&&m.queue(f,h.queue,function(a){t.velocityQueueEntryFlag=!0,g(f).delayTimer={setTimeout:setTimeout(a,parseFloat(h.delay)),next:a}}),h.duration.toString().toLowerCase()){case"fast":h.duration=200;break;case"normal":h.duration=r;break;case"slow":h.duration=600;break;default:h.duration=parseFloat(h.duration)||1}t.mock!==!1&&(t.mock===!0?h.duration=h.delay=1:(h.duration*=parseFloat(t.mock)||1,h.delay*=parseFloat(t.mock)||1)),h.easing=j(h.easing,h.duration),h.begin&&!p.isFunction(h.begin)&&(h.begin=null),h.progress&&!p.isFunction(h.progress)&&(h.progress=null),h.complete&&!p.isFunction(h.complete)&&(h.complete=null),h.display!==d&&null!==h.display&&(h.display=h.display.toString().toLowerCase(),"auto"===h.display&&(h.display=t.CSS.Values.getDisplayType(f))),h.visibility!==d&&null!==h.visibility&&(h.visibility=h.visibility.toString().toLowerCase()),h.mobileHA=h.mobileHA&&t.State.isMobile&&!t.State.isGingerbread,h.queue===!1?h.delay?setTimeout(a,h.delay):a():m.queue(f,h.queue,function(b,c){return c===!0?(B.promise&&B.resolver(o),!0):(t.velocityQueueEntryFlag=!0,void a(b))}),""!==h.queue&&"fx"!==h.queue||"inprogress"===m.queue(f)[0]||m.dequeue(f)}var h,i,n,o,q,s,u=arguments[0]&&(arguments[0].p||m.isPlainObject(arguments[0].properties)&&!arguments[0].properties.names||p.isString(arguments[0].properties));if(p.isWrapped(this)?(h=!1,n=0,o=this,i=this):(h=!0,n=1,o=u?arguments[0].elements||arguments[0].e:arguments[0]),o=f(o)){u?(q=arguments[0].properties||arguments[0].p,s=arguments[0].options||arguments[0].o):(q=arguments[n],s=arguments[n+1]);var x=o.length,y=0;if(!/^(stop|finish|finishAll)$/i.test(q)&&!m.isPlainObject(s)){var z=n+1;s={};for(var A=z;A<arguments.length;A++)p.isArray(arguments[A])||!/^(fast|normal|slow)$/i.test(arguments[A])&&!/^\d/.test(arguments[A])?p.isString(arguments[A])||p.isArray(arguments[A])?s.easing=arguments[A]:p.isFunction(arguments[A])&&(s.complete=arguments[A]):s.duration=arguments[A]}var B={promise:null,resolver:null,rejecter:null};h&&t.Promise&&(B.promise=new t.Promise(function(a,b){B.resolver=a,B.rejecter=b}));var C;switch(q){case"scroll":C="scroll";break;case"reverse":C="reverse";break;case"finish":case"finishAll":case"stop":m.each(o,function(a,b){g(b)&&g(b).delayTimer&&(clearTimeout(g(b).delayTimer.setTimeout),g(b).delayTimer.next&&g(b).delayTimer.next(),delete g(b).delayTimer),"finishAll"!==q||s!==!0&&!p.isString(s)||(m.each(m.queue(b,p.isString(s)?s:""),function(a,b){p.isFunction(b)&&b()}),m.queue(b,p.isString(s)?s:"",[]))});var D=[];return m.each(t.State.calls,function(a,b){b&&m.each(b[1],function(c,e){var f=s===d?"":s;return f===!0||b[2].queue===f||s===d&&b[2].queue===!1?void m.each(o,function(c,d){d===e&&((s===!0||p.isString(s))&&(m.each(m.queue(d,p.isString(s)?s:""),function(a,b){p.isFunction(b)&&b(null,!0)
+}),m.queue(d,p.isString(s)?s:"",[])),"stop"===q?(g(d)&&g(d).tweensContainer&&f!==!1&&m.each(g(d).tweensContainer,function(a,b){b.endValue=b.currentValue}),D.push(a)):("finish"===q||"finishAll"===q)&&(b[2].duration=1))}):!0})}),"stop"===q&&(m.each(D,function(a,b){l(b,!0)}),B.promise&&B.resolver(o)),a();default:if(!m.isPlainObject(q)||p.isEmptyObject(q)){if(p.isString(q)&&t.Redirects[q]){var E=m.extend({},s),F=E.duration,G=E.delay||0;return E.backwards===!0&&(o=m.extend(!0,[],o).reverse()),m.each(o,function(a,b){parseFloat(E.stagger)?E.delay=G+parseFloat(E.stagger)*a:p.isFunction(E.stagger)&&(E.delay=G+E.stagger.call(b,a,x)),E.drag&&(E.duration=parseFloat(F)||(/^(callout|transition)/.test(q)?1e3:r),E.duration=Math.max(E.duration*(E.backwards?1-a/x:(a+1)/x),.75*E.duration,200)),t.Redirects[q].call(b,b,E||{},a,x,o,B.promise?B:d)}),a()}var H="Velocity: First argument ("+q+") was not a property map, a known action, or a registered redirect. Aborting.";return B.promise?B.rejecter(new Error(H)):console.log(H),a()}C="start"}var I={lastParent:null,lastPosition:null,lastFontSize:null,lastPercentToPxWidth:null,lastPercentToPxHeight:null,lastEmToPx:null,remToPx:null,vwToPx:null,vhToPx:null},J=[];m.each(o,function(a,b){p.isNode(b)&&e.call(b)});var K,E=m.extend({},t.defaults,s);if(E.loop=parseInt(E.loop),K=2*E.loop-1,E.loop)for(var L=0;K>L;L++){var M={delay:E.delay,progress:E.progress};L===K-1&&(M.display=E.display,M.visibility=E.visibility,M.complete=E.complete),w(o,"reverse",M)}return a()}};t=m.extend(w,t),t.animate=w;var x=b.requestAnimationFrame||o;return t.State.isMobile||c.hidden===d||c.addEventListener("visibilitychange",function(){c.hidden?(x=function(a){return setTimeout(function(){a(!0)},16)},k()):x=b.requestAnimationFrame||o}),a.Velocity=t,a!==b&&(a.fn.velocity=w,a.fn.velocity.defaults=t.defaults),m.each(["Down","Up"],function(a,b){t.Redirects["slide"+b]=function(a,c,e,f,g,h){var i=m.extend({},c),j=i.begin,k=i.complete,l={height:"",marginTop:"",marginBottom:"",paddingTop:"",paddingBottom:""},n={};i.display===d&&(i.display="Down"===b?"inline"===t.CSS.Values.getDisplayType(a)?"inline-block":"block":"none"),i.begin=function(){j&&j.call(g,g);for(var c in l){n[c]=a.style[c];var d=t.CSS.getPropertyValue(a,c);l[c]="Down"===b?[d,0]:[0,d]}n.overflow=a.style.overflow,a.style.overflow="hidden"},i.complete=function(){for(var b in n)a.style[b]=n[b];k&&k.call(g,g),h&&h.resolver(g)},t(a,l,i)}}),m.each(["In","Out"],function(a,b){t.Redirects["fade"+b]=function(a,c,e,f,g,h){var i=m.extend({},c),j={opacity:"In"===b?1:0},k=i.complete;i.complete=e!==f-1?i.begin=null:function(){k&&k.call(g,g),h&&h.resolver(g)},i.display===d&&(i.display="In"===b?"auto":"none"),t(this,j,i)}}),t}(window.jQuery||window.Zepto||window,window,document)});
diff --git a/web/templates/head.html b/web/templates/head.html
index e80f0a24c..70c94e8ff 100644
--- a/web/templates/head.html
+++ b/web/templates/head.html
@@ -36,6 +36,7 @@
<script src="/static/js/bootstrap-3.3.5.js"></script>
<script src="/static/js/bootstrap-colorpicker.min.js"></script>
<script src="/static/js/react-bootstrap-0.28.1.js"></script>
+ <script src="/static/js/velocity.min.js"></script>
<script src="/static/js/perfect-scrollbar-0.6.7.jquery.min.js"></script>
<script src="/static/js/jquery-dragster/jquery.dragster.js"></script>
<script src="/static/js/babel-polyfill-6.1.18.min.js"></script>
@@ -76,7 +77,7 @@
}
console.log('detected login from a different tab');
- window.location.href = '/';
+ window.location.href = '/' + window.mm_team.name;
}
});
});