summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md74
-rw-r--r--CONTRIBUTING.md51
-rw-r--r--Makefile4
-rw-r--r--api/auto_environment.go (renamed from api/auto_enviroment.go)30
-rw-r--r--api/auto_teams.go2
-rw-r--r--api/channel.go65
-rw-r--r--api/channel_benchmark_test.go6
-rw-r--r--api/channel_test.go115
-rw-r--r--api/command.go18
-rw-r--r--api/context.go2
-rw-r--r--api/file.go2
-rw-r--r--api/post.go8
-rw-r--r--api/slackimport.go2
-rw-r--r--api/team.go45
-rw-r--r--api/team_test.go32
-rw-r--r--api/user.go6
-rw-r--r--api/user_test.go12
-rw-r--r--config/config.json3
-rw-r--r--doc/README.md2
-rw-r--r--doc/developer/API-Web-Service.md (renamed from doc/api/Overview.md)6
-rw-r--r--doc/developer/API.md12
-rw-r--r--doc/developer/Code-Contribution-Guidelines.md45
-rw-r--r--doc/install/Amazon-Elastic-Beanstalk.md45
-rw-r--r--doc/install/Requirements.md21
-rw-r--r--doc/install/Troubleshooting.md10
-rw-r--r--doc/integrations/webhooks/Incoming-Webhooks.md6
-rw-r--r--doc/integrations/webhooks/Outgoing-Webhooks.md2
-rw-r--r--doc/process/documentation-guidelines.md185
-rw-r--r--doc/process/release-process.md99
-rw-r--r--docker/dev/config_docker.json4
-rw-r--r--docker/local/config_docker.json4
-rw-r--r--manualtesting/manual_testing.go6
-rw-r--r--manualtesting/test_autolink.go2
-rw-r--r--model/channel.go11
-rw-r--r--model/channel_test.go20
-rw-r--r--model/client.go17
-rw-r--r--model/config.go6
-rw-r--r--model/team.go29
-rw-r--r--store/sql_audit_store.go4
-rw-r--r--store/sql_channel_store.go14
-rw-r--r--store/sql_oauth_store.go10
-rw-r--r--store/sql_post_store.go2
-rw-r--r--store/sql_preference_store.go6
-rw-r--r--store/sql_session_store.go8
-rw-r--r--store/sql_store.go40
-rw-r--r--store/sql_system_store.go6
-rw-r--r--store/sql_team_store.go98
-rw-r--r--store/sql_team_store_test.go77
-rw-r--r--store/sql_user_store.go12
-rw-r--r--store/store.go2
-rw-r--r--utils/config.go1
-rw-r--r--web/react/components/access_history_modal.jsx5
-rw-r--r--web/react/components/admin_console/gitlab_settings.jsx1
-rw-r--r--web/react/components/admin_console/team_analytics.jsx188
-rw-r--r--web/react/components/admin_console/team_settings.jsx34
-rw-r--r--web/react/components/admin_console/team_users.jsx10
-rw-r--r--web/react/components/channel_header.jsx174
-rw-r--r--web/react/components/create_comment.jsx33
-rw-r--r--web/react/components/create_post.jsx33
-rw-r--r--web/react/components/edit_channel_modal.jsx38
-rw-r--r--web/react/components/edit_channel_purpose_modal.jsx124
-rw-r--r--web/react/components/edit_post_modal.jsx23
-rw-r--r--web/react/components/file_attachment.jsx2
-rw-r--r--web/react/components/find_team.jsx2
-rw-r--r--web/react/components/get_link_modal.jsx2
-rw-r--r--web/react/components/invite_member_modal.jsx3
-rw-r--r--web/react/components/login.jsx59
-rw-r--r--web/react/components/more_channels.jsx2
-rw-r--r--web/react/components/navbar.jsx66
-rw-r--r--web/react/components/navbar_dropdown.jsx3
-rw-r--r--web/react/components/new_channel_flow.jsx10
-rw-r--r--web/react/components/new_channel_modal.jsx14
-rw-r--r--web/react/components/popover_list_members.jsx32
-rw-r--r--web/react/components/post_body.jsx2
-rw-r--r--web/react/components/post_info.jsx4
-rw-r--r--web/react/components/post_list.jsx64
-rw-r--r--web/react/components/register_app_modal.jsx135
-rw-r--r--web/react/components/rhs_comment.jsx1
-rw-r--r--web/react/components/rhs_root_post.jsx1
-rw-r--r--web/react/components/search_autocomplete.jsx23
-rw-r--r--web/react/components/search_bar.jsx1
-rw-r--r--web/react/components/setting_item_max.jsx4
-rw-r--r--web/react/components/setting_picture.jsx4
-rw-r--r--web/react/components/sidebar_header.jsx11
-rw-r--r--web/react/components/sidebar_right_menu.jsx3
-rw-r--r--web/react/components/signup_team.jsx42
-rw-r--r--web/react/components/team_general_tab.jsx372
-rw-r--r--web/react/components/team_settings.jsx8
-rw-r--r--web/react/components/team_settings_modal.jsx2
-rw-r--r--web/react/components/team_signup_display_name_page.jsx3
-rw-r--r--web/react/components/team_signup_url_page.jsx4
-rw-r--r--web/react/components/team_signup_with_email.jsx2
-rw-r--r--web/react/components/user_settings/custom_theme_chooser.jsx5
-rw-r--r--web/react/components/user_settings/manage_outgoing_hooks.jsx2
-rw-r--r--web/react/components/user_settings/user_settings.jsx12
-rw-r--r--web/react/components/user_settings/user_settings_advanced.jsx169
-rw-r--r--web/react/components/user_settings/user_settings_appearance.jsx4
-rw-r--r--web/react/components/user_settings/user_settings_general.jsx1
-rw-r--r--web/react/components/user_settings/user_settings_integrations.jsx4
-rw-r--r--web/react/components/user_settings/user_settings_modal.jsx1
-rw-r--r--web/react/components/user_settings/user_settings_notifications.jsx12
-rw-r--r--web/react/pages/channel.jsx2
-rw-r--r--web/react/pages/login.jsx1
-rw-r--r--web/react/pages/signup_team.jsx14
-rw-r--r--web/react/stores/post_store.jsx9
-rw-r--r--web/react/stores/socket_store.jsx6
-rw-r--r--web/react/utils/client.jsx35
-rw-r--r--web/react/utils/constants.jsx5
-rw-r--r--web/react/utils/markdown.jsx100
-rw-r--r--web/react/utils/text_formatting.jsx32
-rw-r--r--web/react/utils/utils.jsx80
-rw-r--r--web/sass-files/sass/partials/_admin-console.scss60
-rw-r--r--web/sass-files/sass/partials/_base.scss6
-rw-r--r--web/sass-files/sass/partials/_headers.scss5
-rw-r--r--web/sass-files/sass/partials/_modal.scss7
-rw-r--r--web/sass-files/sass/partials/_popover.scss70
-rw-r--r--web/sass-files/sass/partials/_post.scss9
-rw-r--r--web/sass-files/sass/partials/_responsive.scss14
-rw-r--r--web/sass-files/sass/partials/_search.scss47
-rw-r--r--web/sass-files/sass/partials/_settings.scss57
-rw-r--r--web/sass-files/sass/partials/_signup.scss28
-rw-r--r--web/sass-files/sass/partials/_statistics.scss84
-rw-r--r--web/sass-files/sass/styles.scss1
-rw-r--r--web/static/help/about.html2
-rw-r--r--web/web.go41
125 files changed, 2795 insertions, 963 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8082b2536..46b74ccb3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,9 +7,79 @@ The "UNDER DEVELOPMENT" section of the Mattermost changelog appears in the produ
- **Release candidate anticipated:** 2015-11-10
- **Final release anticipated:** 2015-11-16
-### Changes
+### Improvements
-- IE 10 no longer supported since global share of IE 10 fell below 5%
+Onboarding
+
+- New tutorial explaining how to use Mattermost for new users
+
+Messaging and Notifications
+
+- Users can now search for teammates to add to **Direct Message** list via **More** menu
+- Users can now personalize Direct Messages list by removing users listed
+- Link previews - Adding URL with .gif file adds image below message
+
+Search
+
+- Adding search term `in:[channel_url_name]` now limits searches within a specific channel
+- Adding search term `from:[username]` now limits searches to messages from a specific user
+- Tip explaining search options when clicking into search box
+
+Integrations
+
+- [Outgoing webhooks](https://github.com/mattermost/platform/blob/master/doc/integrations/webhooks/Outgoing-Webhooks.md) now available
+- Made available [application template showing outgoing webhooks working with Mattermost and external application](https://github.com/mattermost/mattermost-integration-giphy)
+
+User Interface
+
+- Member list in Channel display now scrollable, and includes Message button to message channel members directly
+- Added ability to edit previous message by hitting UP arrow
+- Syntax highlighting added for code blocks
+ - Languages include `Diff, Apache, Makefile, HTTP, JSON, Markdown, Java, CSS, nginx, ObjectiveC, Python, XML, Perl, Bash, PHP, Coffee, C, SQL, Go, Ruby, Java, and ini`.
+ - Use by adding the name of the language on the first link of the code block, for example: ```python
+ - Syntax color theme can be defined under **Account Settings** > **Appearance Settings** > **Custom Theme**
+
+Team Settings
+
+- Added Team Settings option to include account creation URL on team login page
+- Added Team Settings option to include link to given team on root page
+- Ability to rotate invite code for invite URL
+
+System Console
+
+- New statistics page
+- Configurable option to create an account directly from team page
+
+#### Bug Fixes
+
+- Various fixes to theme colors
+
+### Compatibility
+
+- IE 11 new minimum version for IE, since IE 10 share fell below 5% on desktop
+- Safari 8 new minimum version for Safari, since Safari 7 fell below 1% on desktop
+
+#### Known Issues
+
+- Microsoft Edge does not yet support drag and drop
+- Incoming webhooks no longer disrupted when channel is deleted
+
+#### Contributors
+
+Many thanks to our external contributors. In no particular order:
+
+- [florianorben](https://github.com/florianorben)
+- [trashcan](https://github.com/trashcan)
+- [girishso](https://github.com/girishso)
+- [apaatsio](https://github.com/apaatsio)
+- [jlebleu](https://github.com/jlebleu)
+- [stasvovk](https://github.com/stasvovk)
+- [mcmillhj](https://github.com/mcmillhj)
+- [sharms](https://github.com/sharms)
+- [jvasallo](https://github.com/jvasallo)
+- [layzerar](https://github.com/layzerar)
+- [optimistiks](https://github.com/optimistiks)
+- [layzerar](https://github.com/layzerar)
## Release v1.1.1 (Bug Fix Release)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8ffce2a9e..db8c90023 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,5 +1,50 @@
-# Contributing
+# Code Contribution Guidelines
-## Contributing Code
+Thank you for your interest in contributing to Mattermost. This guide provides an overview of important information for contributors to know.
-Please see [Mattermost Code Contribution Guidelines](https://github.com/mattermost/platform/blob/master/doc/developer/Code-Contribution-Guidelines.md)
+## Choose a Ticket
+
+1. Review the list of [Good First Contribution](https://mattermost.atlassian.net/issues/?filter=10206) tickets listed in Jira
+ - You are welcome to work on any ticket, even if it is assigned, so long as it is not yet marked "in progress"
+ - (optional) Comment on the ticket that you're starting so no one else inadvertently duplicates your work
+
+2. These projects are intended to be a straight forward first pull requests from new contributors
+ - If you don't find something appropriate for your interests, please see the full list of tickets [Accepting Pull Requests](https://mattermost.atlassian.net/issues/?filter=10101)
+ - Also, feel free to fix bugs you find, or items in GitHub issues that the core team has approved, but not yet added to Jira
+
+3. If you have any questions at all about a ticket, there are several options to ask:
+ 1. Start a topic in the [Mattermost forum](http://forum.mattermost.org/)
+ 2. Join the [Mattermost core team discussion](https://pre-release.mattermost.com/signup_user_complete/?id=rcgiyftm7jyrxnma1osd8zswby) and post in the "Tickets" channel
+
+## Install Mattermost and set up a Fork
+
+1. Follow [developer setup instructions](https://github.com/mattermost/platform/blob/master/doc/developer/Setup.md) to install Mattermost
+
+2. Create a branch with <branch name> set to the ID of the ticket you're working on, for example ```PLT-394```, using command:
+
+```
+git checkout -b <branch name>
+```
+
+## Programming and Testing
+
+1. Please review the [Mattermost Style Guide](doc/developer/Style-Guide.md) prior to making changes
+
+ To keep code clean and well structured, Mattermost uses ESLint to check that pull requests adhere to style guidelines for React. Code will need to follow Mattermost's React style guidelines in order to pass the automated build tests when a pull request is submitted
+
+2. Please make sure to thoroughly test your change before submitting a pull request
+
+ Please review the ["Fast, Obvious, Forgiving" experience design principles](http://www.mattermost.org/design-principles/) for Mattermost and check that your feature meets the criteria. Also, for any changes to user interface or help text, please read the changes out loud, as a quick and easy way to catch any inconsitencies
+
+
+## Submitting a Pull Request
+
+1. Please add yourself to the Mattermost [approved contributor list](https://docs.google.com/spreadsheets/d/1NTCeG-iL_VS9bFqtmHSfwETo5f-8MQ7oMDE5IUYJi_Y/pubhtml?gid=0&single=true) prior to submitting by completing the [contributor license agreement](http://www.mattermost.org/mattermost-contributor-agreement/).
+
+2. When you submit your pull request please make it against `master` and include the Ticket ID at the beginning of your pull request comment, followed by a colon
+
+ - For example, for a ticket ID `PLT-394` start your comment with: `PLT-394:`. See [previously closed pull requests](https://github.com/mattermost/platform/pulls?q=is%3Apr+is%3Aclosed) for examples
+
+3. Once submitted, your pull request will be checked via an automated build process and will be reviewed by at least two members of the Mattermost core team, who may either accept the PR or follow-up with feedback. It would then get merged into `master` for the next release
+
+4. If you've included your mailing address in Step 1, you'll be receiving a [Limited Edition Mattermost Mug](http://forum.mattermost.org/t/limited-edition-mattermost-mugs/143) as a thank you gift after your first pull request has been accepted
diff --git a/Makefile b/Makefile
index 5e798ee76..573036f06 100644
--- a/Makefile
+++ b/Makefile
@@ -46,7 +46,7 @@ travis:
cd web/react/ && npm run build-libs
@echo Checking for style guide compliance
- cd web/react && $(ESLINT) --quiet components/* dispatcher/* pages/* stores/* utils/*
+ cd web/react && $(ESLINT) --ext ".jsx" --ignore-pattern node_modules --quiet .
@echo Running gofmt
$(eval GOFMT_OUTPUT := $(shell gofmt -d -s api/ model/ store/ utils/ manualtesting/ mattermost.go 2>&1))
@echo "$(GOFMT_OUTPUT)"
@@ -143,7 +143,7 @@ install:
check: install
@echo Running ESLint...
- -cd web/react && $(ESLINT) components/* dispatcher/* pages/* stores/* utils/*
+ -cd web/react && $(ESLINT) --ext ".jsx" --ignore-pattern node_modules .
@echo Running gofmt
$(eval GOFMT_OUTPUT := $(shell gofmt -d -s api/ model/ store/ utils/ manualtesting/ mattermost.go 2>&1))
@echo "$(GOFMT_OUTPUT)"
diff --git a/api/auto_enviroment.go b/api/auto_environment.go
index c6453f5da..68186ec6c 100644
--- a/api/auto_enviroment.go
+++ b/api/auto_environment.go
@@ -10,42 +10,42 @@ import (
"time"
)
-type TestEnviroment struct {
- Teams []*model.Team
- Enviroments []TeamEnviroment
+type TestEnvironment struct {
+ Teams []*model.Team
+ Environments []TeamEnvironment
}
-func CreateTestEnviromentWithTeams(client *model.Client, rangeTeams utils.Range, rangeChannels utils.Range, rangeUsers utils.Range, rangePosts utils.Range, fuzzy bool) (TestEnviroment, bool) {
+func CreateTestEnvironmentWithTeams(client *model.Client, rangeTeams utils.Range, rangeChannels utils.Range, rangeUsers utils.Range, rangePosts utils.Range, fuzzy bool) (TestEnvironment, bool) {
rand.Seed(time.Now().UTC().UnixNano())
teamCreator := NewAutoTeamCreator(client)
teamCreator.Fuzzy = fuzzy
teams, err := teamCreator.CreateTestTeams(rangeTeams)
if err != true {
- return TestEnviroment{}, false
+ return TestEnvironment{}, false
}
- enviroment := TestEnviroment{teams, make([]TeamEnviroment, len(teams))}
+ environment := TestEnvironment{teams, make([]TeamEnvironment, len(teams))}
for i, team := range teams {
userCreator := NewAutoUserCreator(client, team.Id)
userCreator.Fuzzy = fuzzy
randomUser, err := userCreator.createRandomUser()
if err != true {
- return TestEnviroment{}, false
+ return TestEnvironment{}, false
}
client.LoginById(randomUser.Id, USER_PASSWORD)
- teamEnviroment, err := CreateTestEnviromentInTeam(client, team.Id, rangeChannels, rangeUsers, rangePosts, fuzzy)
+ teamEnvironment, err := CreateTestEnvironmentInTeam(client, team.Id, rangeChannels, rangeUsers, rangePosts, fuzzy)
if err != true {
- return TestEnviroment{}, false
+ return TestEnvironment{}, false
}
- enviroment.Enviroments[i] = teamEnviroment
+ environment.Environments[i] = teamEnvironment
}
- return enviroment, true
+ return environment, true
}
-func CreateTestEnviromentInTeam(client *model.Client, teamID string, rangeChannels utils.Range, rangeUsers utils.Range, rangePosts utils.Range, fuzzy bool) (TeamEnviroment, bool) {
+func CreateTestEnvironmentInTeam(client *model.Client, teamID string, rangeChannels utils.Range, rangeUsers utils.Range, rangePosts utils.Range, fuzzy bool) (TeamEnvironment, bool) {
rand.Seed(time.Now().UTC().UnixNano())
// We need to create at least one user
@@ -57,7 +57,7 @@ func CreateTestEnviromentInTeam(client *model.Client, teamID string, rangeChanne
userCreator.Fuzzy = fuzzy
users, err := userCreator.CreateTestUsers(rangeUsers)
if err != true {
- return TeamEnviroment{}, false
+ return TeamEnvironment{}, false
}
usernames := make([]string, len(users))
for i, user := range users {
@@ -77,7 +77,7 @@ func CreateTestEnviromentInTeam(client *model.Client, teamID string, rangeChanne
}
if err != true {
- return TeamEnviroment{}, false
+ return TeamEnvironment{}, false
}
numPosts := utils.RandIntFromRange(rangePosts)
numImages := utils.RandIntFromRange(rangePosts) / 4
@@ -93,5 +93,5 @@ func CreateTestEnviromentInTeam(client *model.Client, teamID string, rangeChanne
}
}
- return TeamEnviroment{users, channels}, true
+ return TeamEnvironment{users, channels}, true
}
diff --git a/api/auto_teams.go b/api/auto_teams.go
index 6677ac9bf..082415d32 100644
--- a/api/auto_teams.go
+++ b/api/auto_teams.go
@@ -8,7 +8,7 @@ import (
"github.com/mattermost/platform/utils"
)
-type TeamEnviroment struct {
+type TeamEnvironment struct {
Users []*model.User
Channels []*model.Channel
}
diff --git a/api/channel.go b/api/channel.go
index a8c8505e9..44be1cf97 100644
--- a/api/channel.go
+++ b/api/channel.go
@@ -22,7 +22,8 @@ func InitChannel(r *mux.Router) {
sr.Handle("/create", ApiUserRequired(createChannel)).Methods("POST")
sr.Handle("/create_direct", ApiUserRequired(createDirectChannel)).Methods("POST")
sr.Handle("/update", ApiUserRequired(updateChannel)).Methods("POST")
- sr.Handle("/update_desc", ApiUserRequired(updateChannelDesc)).Methods("POST")
+ sr.Handle("/update_header", ApiUserRequired(updateChannelHeader)).Methods("POST")
+ sr.Handle("/update_purpose", ApiUserRequired(updateChannelPurpose)).Methods("POST")
sr.Handle("/update_notify_props", ApiUserRequired(updateNotifyProps)).Methods("POST")
sr.Handle("/{id:[A-Za-z0-9]+}/", ApiUserRequiredActivity(getChannel, false)).Methods("GET")
sr.Handle("/{id:[A-Za-z0-9]+}/extra_info", ApiUserRequired(getChannelExtraInfo)).Methods("GET")
@@ -124,7 +125,7 @@ func CreateDirectChannel(c *Context, otherUserId string) (*model.Channel, *model
channel.Name = model.GetDMNameFromIds(otherUserId, c.Session.UserId)
channel.TeamId = c.Session.TeamId
- channel.Description = ""
+ channel.Header = ""
channel.Type = model.CHANNEL_DIRECT
if uresult := <-uc; uresult.Err != nil {
@@ -209,7 +210,8 @@ func updateChannel(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
- oldChannel.Description = channel.Description
+ oldChannel.Header = channel.Header
+ oldChannel.Purpose = channel.Purpose
if len(channel.DisplayName) > 0 {
oldChannel.DisplayName = channel.DisplayName
@@ -233,18 +235,18 @@ func updateChannel(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
-func updateChannelDesc(c *Context, w http.ResponseWriter, r *http.Request) {
+func updateChannelHeader(c *Context, w http.ResponseWriter, r *http.Request) {
props := model.MapFromJson(r.Body)
channelId := props["channel_id"]
if len(channelId) != 26 {
- c.SetInvalidParam("updateChannelDesc", "channel_id")
+ c.SetInvalidParam("updateChannelHeader", "channel_id")
return
}
- channelDesc := props["channel_description"]
- if len(channelDesc) > 1024 {
- c.SetInvalidParam("updateChannelDesc", "channel_description")
+ channelHeader := props["channel_header"]
+ if len(channelHeader) > 1024 {
+ c.SetInvalidParam("updateChannelHeader", "channel_header")
return
}
@@ -261,11 +263,54 @@ func updateChannelDesc(c *Context, w http.ResponseWriter, r *http.Request) {
channel := cresult.Data.(*model.Channel)
// Don't need to do anything channel member, just wanted to confirm it exists
- if !c.HasPermissionsToTeam(channel.TeamId, "updateChannelDesc") {
+ if !c.HasPermissionsToTeam(channel.TeamId, "updateChannelHeader") {
return
}
- channel.Description = channelDesc
+ channel.Header = channelHeader
+
+ if ucresult := <-Srv.Store.Channel().Update(channel); ucresult.Err != nil {
+ c.Err = ucresult.Err
+ return
+ } else {
+ c.LogAudit("name=" + channel.Name)
+ w.Write([]byte(channel.ToJson()))
+ }
+ }
+}
+
+func updateChannelPurpose(c *Context, w http.ResponseWriter, r *http.Request) {
+ props := model.MapFromJson(r.Body)
+ channelId := props["channel_id"]
+ if len(channelId) != 26 {
+ c.SetInvalidParam("updateChannelPurpose", "channel_id")
+ return
+ }
+
+ channelPurpose := props["channel_purpose"]
+ if len(channelPurpose) > 1024 {
+ c.SetInvalidParam("updateChannelPurpose", "channel_purpose")
+ return
+ }
+
+ sc := Srv.Store.Channel().Get(channelId)
+ cmc := Srv.Store.Channel().GetMember(channelId, c.Session.UserId)
+
+ if cresult := <-sc; cresult.Err != nil {
+ c.Err = cresult.Err
+ return
+ } else if cmcresult := <-cmc; cmcresult.Err != nil {
+ c.Err = cmcresult.Err
+ return
+ } else {
+ channel := cresult.Data.(*model.Channel)
+ // Don't need to do anything channel member, just wanted to confirm it exists
+
+ if !c.HasPermissionsToTeam(channel.TeamId, "updateChannelPurpose") {
+ return
+ }
+
+ channel.Purpose = channelPurpose
if ucresult := <-Srv.Store.Channel().Update(channel); ucresult.Err != nil {
c.Err = ucresult.Err
diff --git a/api/channel_benchmark_test.go b/api/channel_benchmark_test.go
index 58e3fa18d..fb8dd61bc 100644
--- a/api/channel_benchmark_test.go
+++ b/api/channel_benchmark_test.go
@@ -61,8 +61,8 @@ func BenchmarkCreateDirectChannel(b *testing.B) {
func BenchmarkUpdateChannel(b *testing.B) {
var (
- NUM_CHANNELS_RANGE = utils.Range{NUM_CHANNELS, NUM_CHANNELS}
- CHANNEL_DESCRIPTION_LEN = 50
+ NUM_CHANNELS_RANGE = utils.Range{NUM_CHANNELS, NUM_CHANNELS}
+ CHANNEL_HEADER_LEN = 50
)
team, _, _ := SetupBenchmark()
@@ -73,7 +73,7 @@ func BenchmarkUpdateChannel(b *testing.B) {
}
for i := range channels {
- channels[i].Description = utils.RandString(CHANNEL_DESCRIPTION_LEN, utils.ALPHANUMERIC)
+ channels[i].Header = utils.RandString(CHANNEL_HEADER_LEN, utils.ALPHANUMERIC)
}
// Benchmark Start
diff --git a/api/channel_test.go b/api/channel_test.go
index 899016065..a41f63b1b 100644
--- a/api/channel_test.go
+++ b/api/channel_test.go
@@ -8,6 +8,7 @@ import (
"github.com/mattermost/platform/store"
"github.com/mattermost/platform/utils"
"net/http"
+ "strings"
"testing"
"time"
)
@@ -173,12 +174,17 @@ func TestUpdateChannel(t *testing.T) {
Client.AddChannelMember(channel1.Id, userTeamAdmin.Id)
- desc := "a" + model.NewId() + "a"
- upChannel1 := &model.Channel{Id: channel1.Id, Description: desc}
+ header := "a" + model.NewId() + "a"
+ purpose := "a" + model.NewId() + "a"
+ upChannel1 := &model.Channel{Id: channel1.Id, Header: header, Purpose: purpose}
upChannel1 = Client.Must(Client.UpdateChannel(upChannel1)).Data.(*model.Channel)
- if upChannel1.Description != desc {
- t.Fatal("Channel admin failed to update desc")
+ if upChannel1.Header != header {
+ t.Fatal("Channel admin failed to update header")
+ }
+
+ if upChannel1.Purpose != purpose {
+ t.Fatal("Channel admin failed to update purpose")
}
if upChannel1.DisplayName != channel1.DisplayName {
@@ -187,12 +193,17 @@ func TestUpdateChannel(t *testing.T) {
Client.LoginByEmail(team.Name, userTeamAdmin.Email, "pwd")
- desc = "b" + model.NewId() + "b"
- upChannel1 = &model.Channel{Id: channel1.Id, Description: desc}
+ header = "b" + model.NewId() + "b"
+ purpose = "b" + model.NewId() + "b"
+ upChannel1 = &model.Channel{Id: channel1.Id, Header: header, Purpose: purpose}
upChannel1 = Client.Must(Client.UpdateChannel(upChannel1)).Data.(*model.Channel)
- if upChannel1.Description != desc {
- t.Fatal("Team admin failed to update desc")
+ if upChannel1.Header != header {
+ t.Fatal("Team admin failed to update header")
+ }
+
+ if upChannel1.Purpose != purpose {
+ t.Fatal("Team admin failed to update purpose")
}
if upChannel1.DisplayName != channel1.DisplayName {
@@ -203,7 +214,7 @@ func TestUpdateChannel(t *testing.T) {
data := rget.Data.(*model.ChannelList)
for _, c := range data.Channels {
if c.Name == model.DEFAULT_CHANNEL {
- c.Description = "new desc"
+ c.Header = "new header"
if _, err := Client.UpdateChannel(c); err == nil {
t.Fatal("should have errored on updating default channel")
}
@@ -218,7 +229,7 @@ func TestUpdateChannel(t *testing.T) {
}
}
-func TestUpdateChannelDesc(t *testing.T) {
+func TestUpdateChannelHeader(t *testing.T) {
Setup()
team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN}
@@ -235,36 +246,92 @@ func TestUpdateChannelDesc(t *testing.T) {
data := make(map[string]string)
data["channel_id"] = channel1.Id
- data["channel_description"] = "new desc"
+ data["channel_header"] = "new header"
var upChannel1 *model.Channel
- if result, err := Client.UpdateChannelDesc(data); err != nil {
+ if result, err := Client.UpdateChannelHeader(data); err != nil {
t.Fatal(err)
} else {
upChannel1 = result.Data.(*model.Channel)
}
- if upChannel1.Description != data["channel_description"] {
- t.Fatal("Failed to update desc")
+ if upChannel1.Header != data["channel_header"] {
+ t.Fatal("Failed to update header")
}
data["channel_id"] = "junk"
- if _, err := Client.UpdateChannelDesc(data); err == nil {
+ if _, err := Client.UpdateChannelHeader(data); err == nil {
t.Fatal("should have errored on junk channel id")
}
data["channel_id"] = "12345678901234567890123456"
- if _, err := Client.UpdateChannelDesc(data); err == nil {
+ if _, err := Client.UpdateChannelHeader(data); err == nil {
t.Fatal("should have errored on non-existent channel id")
}
data["channel_id"] = channel1.Id
- data["channel_description"] = ""
- for i := 0; i < 1050; i++ {
- data["channel_description"] += "a"
+ data["channel_header"] = strings.Repeat("a", 1050)
+ if _, err := Client.UpdateChannelHeader(data); err == nil {
+ t.Fatal("should have errored on bad channel header")
+ }
+
+ user2 := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"}
+ user2 = Client.Must(Client.CreateUser(user2, "")).Data.(*model.User)
+ store.Must(Srv.Store.User().VerifyEmail(user2.Id))
+
+ Client.LoginByEmail(team.Name, user2.Email, "pwd")
+
+ data["channel_id"] = channel1.Id
+ data["channel_header"] = "new header"
+ if _, err := Client.UpdateChannelHeader(data); err == nil {
+ t.Fatal("should have errored non-channel member trying to update header")
}
- if _, err := Client.UpdateChannelDesc(data); err == nil {
- t.Fatal("should have errored on bad channel desc")
+}
+
+func TestUpdateChannelPurpose(t *testing.T) {
+ Setup()
+
+ team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN}
+ team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team)
+
+ user := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"}
+ user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User)
+ store.Must(Srv.Store.User().VerifyEmail(user.Id))
+
+ Client.LoginByEmail(team.Name, user.Email, "pwd")
+
+ channel1 := &model.Channel{DisplayName: "A Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
+ channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel)
+
+ data := make(map[string]string)
+ data["channel_id"] = channel1.Id
+ data["channel_purpose"] = "new purpose"
+
+ var upChannel1 *model.Channel
+ if result, err := Client.UpdateChannelPurpose(data); err != nil {
+ t.Fatal(err)
+ } else {
+ upChannel1 = result.Data.(*model.Channel)
+ }
+
+ if upChannel1.Purpose != data["channel_purpose"] {
+ t.Fatal("Failed to update purpose")
+ }
+
+ data["channel_id"] = "junk"
+ if _, err := Client.UpdateChannelPurpose(data); err == nil {
+ t.Fatal("should have errored on junk channel id")
+ }
+
+ data["channel_id"] = "12345678901234567890123456"
+ if _, err := Client.UpdateChannelPurpose(data); err == nil {
+ t.Fatal("should have errored on non-existent channel id")
+ }
+
+ data["channel_id"] = channel1.Id
+ data["channel_purpose"] = strings.Repeat("a", 150)
+ if _, err := Client.UpdateChannelPurpose(data); err == nil {
+ t.Fatal("should have errored on bad channel purpose")
}
user2 := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"}
@@ -274,9 +341,9 @@ func TestUpdateChannelDesc(t *testing.T) {
Client.LoginByEmail(team.Name, user2.Email, "pwd")
data["channel_id"] = channel1.Id
- data["channel_description"] = "new desc"
- if _, err := Client.UpdateChannelDesc(data); err == nil {
- t.Fatal("should have errored non-channel member trying to update desc")
+ data["channel_purpose"] = "new purpose"
+ if _, err := Client.UpdateChannelPurpose(data); err == nil {
+ t.Fatal("should have errored non-channel member trying to update purpose")
}
}
diff --git a/api/command.go b/api/command.go
index b2a4f4a0b..e22a05a5f 100644
--- a/api/command.go
+++ b/api/command.go
@@ -381,11 +381,11 @@ func loadTestSetupCommand(c *Context, command *model.Command) bool {
if doTeams {
if err := CreateBasicUser(client); err != nil {
- l4g.Error("Failed to create testing enviroment")
+ l4g.Error("Failed to create testing environment")
return true
}
client.LoginByEmail(BTEST_TEAM_NAME, BTEST_USER_EMAIL, BTEST_USER_PASSWORD)
- enviroment, err := CreateTestEnviromentWithTeams(
+ environment, err := CreateTestEnvironmentWithTeams(
client,
utils.Range{numTeams, numTeams},
utils.Range{numChannels, numChannels},
@@ -393,18 +393,18 @@ func loadTestSetupCommand(c *Context, command *model.Command) bool {
utils.Range{numPosts, numPosts},
doFuzz)
if err != true {
- l4g.Error("Failed to create testing enviroment")
+ l4g.Error("Failed to create testing environment")
return true
} else {
- l4g.Info("Testing enviroment created")
- for i := 0; i < len(enviroment.Teams); i++ {
- l4g.Info("Team Created: " + enviroment.Teams[i].Name)
- l4g.Info("\t User to login: " + enviroment.Enviroments[i].Users[0].Email + ", " + USER_PASSWORD)
+ l4g.Info("Testing environment created")
+ for i := 0; i < len(environment.Teams); i++ {
+ l4g.Info("Team Created: " + environment.Teams[i].Name)
+ l4g.Info("\t User to login: " + environment.Environments[i].Users[0].Email + ", " + USER_PASSWORD)
}
}
} else {
client.MockSession(c.Session.Token)
- CreateTestEnviromentInTeam(
+ CreateTestEnvironmentInTeam(
client,
c.Session.TeamId,
utils.Range{numChannels, numChannels},
@@ -416,7 +416,7 @@ func loadTestSetupCommand(c *Context, command *model.Command) bool {
} else if strings.Index(cmd, command.Command) == 0 {
command.AddSuggestion(&model.SuggestCommand{
Suggestion: cmd,
- Description: "Creates a testing enviroment in current team. [teams] [fuzz] <Num Channels> <Num Users> <NumPosts>"})
+ Description: "Creates a testing environment in current team. [teams] [fuzz] <Num Channels> <Num Users> <NumPosts>"})
}
return false
diff --git a/api/context.go b/api/context.go
index 9be3e85cc..a5d4169cb 100644
--- a/api/context.go
+++ b/api/context.go
@@ -320,7 +320,7 @@ func (c *Context) HasSystemAdminPermissions(where string) bool {
return true
}
- c.Err = model.NewAppError(where, "You do not have the appropriate permissions", "userId="+c.Session.UserId)
+ c.Err = model.NewAppError(where, "You do not have the appropriate permissions (system)", "userId="+c.Session.UserId)
c.Err.StatusCode = http.StatusForbidden
return false
}
diff --git a/api/file.go b/api/file.go
index f65be145d..8afc70692 100644
--- a/api/file.go
+++ b/api/file.go
@@ -137,7 +137,7 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
c.Err = model.NewAppError("uploadFile", "Unable to upload image file.", err.Error())
return
} else if config.Width*config.Height > MaxImageSize {
- c.Err = model.NewAppError("uploadFile", "Unable to upload image file. File is too large.", err.Error())
+ c.Err = model.NewAppError("uploadFile", "Unable to upload image file. File is too large.", "File exceeds max image size.")
return
}
}
diff --git a/api/post.go b/api/post.go
index b8588fe6a..c98bf2d71 100644
--- a/api/post.go
+++ b/api/post.go
@@ -249,7 +249,7 @@ func handleWebhookEventsAndForget(c *Context, post *model.Post, team *model.Team
}
for _, hook := range relevantHooks {
- go func() {
+ go func(hook *model.OutgoingWebhook) {
p := url.Values{}
p.Set("token", hook.Token)
@@ -270,7 +270,7 @@ func handleWebhookEventsAndForget(c *Context, post *model.Post, team *model.Team
client := &http.Client{}
for _, url := range hook.CallbackURLs {
- go func() {
+ go func(url string) {
req, _ := http.NewRequest("POST", url, strings.NewReader(p.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
@@ -289,10 +289,10 @@ func handleWebhookEventsAndForget(c *Context, post *model.Post, team *model.Team
}
}
}
- }()
+ }(url)
}
- }()
+ }(hook)
}
}()
diff --git a/api/slackimport.go b/api/slackimport.go
index 06032c068..cab4c6184 100644
--- a/api/slackimport.go
+++ b/api/slackimport.go
@@ -182,7 +182,7 @@ func SlackAddChannels(teamId string, slackchannels []SlackChannel, posts map[str
Type: model.CHANNEL_OPEN,
DisplayName: sChannel.Name,
Name: SlackConvertChannelName(sChannel.Name),
- Description: sChannel.Topic["value"],
+ Purpose: sChannel.Topic["value"],
}
mChannel := ImportChannel(&newChannel)
if mChannel == nil {
diff --git a/api/team.go b/api/team.go
index d39d8ed60..7d746d922 100644
--- a/api/team.go
+++ b/api/team.go
@@ -30,7 +30,7 @@ func InitTeam(r *mux.Router) {
sr.Handle("/find_teams", ApiAppHandler(findTeams)).Methods("POST")
sr.Handle("/email_teams", ApiAppHandler(emailTeams)).Methods("POST")
sr.Handle("/invite_members", ApiUserRequired(inviteMembers)).Methods("POST")
- sr.Handle("/update_name", ApiUserRequired(updateTeamDisplayName)).Methods("POST")
+ sr.Handle("/update", ApiUserRequired(updateTeam)).Methods("POST")
sr.Handle("/me", ApiUserRequired(getMyTeam)).Methods("GET")
// These should be moved to the global admain console
sr.Handle("/import_team", ApiUserRequired(importTeam)).Methods("POST")
@@ -541,40 +541,47 @@ func InviteMembers(c *Context, team *model.Team, user *model.User, invites []str
}
}
-func updateTeamDisplayName(c *Context, w http.ResponseWriter, r *http.Request) {
+func updateTeam(c *Context, w http.ResponseWriter, r *http.Request) {
- props := model.MapFromJson(r.Body)
+ team := model.TeamFromJson(r.Body)
- new_name := props["new_name"]
- if len(new_name) == 0 {
- c.SetInvalidParam("updateTeamDisplayName", "new_name")
+ if team == nil {
+ c.SetInvalidParam("updateTeam", "team")
return
}
- teamId := props["team_id"]
- if len(teamId) > 0 && len(teamId) != 26 {
- c.SetInvalidParam("updateTeamDisplayName", "team_id")
- return
- } else if len(teamId) == 0 {
- teamId = c.Session.TeamId
- }
+ team.Id = c.Session.TeamId
- if !c.HasPermissionsToTeam(teamId, "updateTeamDisplayName") {
+ if !c.IsTeamAdmin() {
+ c.Err = model.NewAppError("updateTeam", "You do not have the appropriate permissions", "userId="+c.Session.UserId)
+ c.Err.StatusCode = http.StatusForbidden
return
}
- if !c.IsTeamAdmin() {
- c.Err = model.NewAppError("updateTeamDisplayName", "You do not have the appropriate permissions", "userId="+c.Session.UserId)
- c.Err.StatusCode = http.StatusForbidden
+ var oldTeam *model.Team
+ if result := <-Srv.Store.Team().Get(team.Id); result.Err != nil {
+ c.Err = result.Err
return
+ } else {
+ oldTeam = result.Data.(*model.Team)
}
- if result := <-Srv.Store.Team().UpdateDisplayName(new_name, c.Session.TeamId); result.Err != nil {
+ oldTeam.DisplayName = team.DisplayName
+ oldTeam.InviteId = team.InviteId
+ oldTeam.AllowOpenInvite = team.AllowOpenInvite
+ oldTeam.AllowTeamListing = team.AllowTeamListing
+ oldTeam.CompanyName = team.CompanyName
+ oldTeam.AllowedDomains = team.AllowedDomains
+ //oldTeam.Type = team.Type
+
+ if result := <-Srv.Store.Team().Update(oldTeam); result.Err != nil {
c.Err = result.Err
return
}
- w.Write([]byte(model.MapToJson(props)))
+ oldTeam.Sanitize()
+
+ w.Write([]byte(oldTeam.ToJson()))
}
func getMyTeam(c *Context, w http.ResponseWriter, r *http.Request) {
diff --git a/api/team_test.go b/api/team_test.go
index 507f4252a..7a3b092ce 100644
--- a/api/team_test.go
+++ b/api/team_test.go
@@ -281,41 +281,23 @@ func TestUpdateTeamDisplayName(t *testing.T) {
Client.LoginByEmail(team.Name, user2.Email, "pwd")
- data := make(map[string]string)
- data["new_name"] = "NewName"
- if _, err := Client.UpdateTeamDisplayName(data); err == nil {
+ vteam := &model.Team{DisplayName: team.DisplayName, Name: team.Name, Email: team.Email, Type: team.Type}
+ vteam.DisplayName = "NewName"
+ if _, err := Client.UpdateTeam(vteam); err == nil {
t.Fatal("Should have errored, not admin")
}
Client.LoginByEmail(team.Name, user.Email, "pwd")
- data["new_name"] = ""
- if _, err := Client.UpdateTeamDisplayName(data); err == nil {
+ vteam.DisplayName = ""
+ if _, err := Client.UpdateTeam(vteam); err == nil {
t.Fatal("Should have errored, empty name")
}
- data["new_name"] = "NewName"
- if _, err := Client.UpdateTeamDisplayName(data); err != nil {
+ vteam.DisplayName = "NewName"
+ if _, err := Client.UpdateTeam(vteam); err != nil {
t.Fatal(err)
}
- // No GET team web service, so hard to confirm here that team name updated
-
- data["team_id"] = "junk"
- if _, err := Client.UpdateTeamDisplayName(data); err == nil {
- t.Fatal("Should have errored, junk team id")
- }
-
- data["team_id"] = "12345678901234567890123456"
- if _, err := Client.UpdateTeamDisplayName(data); err == nil {
- t.Fatal("Should have errored, bad team id")
- }
-
- data["team_id"] = team.Id
- data["new_name"] = "NewNameAgain"
- if _, err := Client.UpdateTeamDisplayName(data); err != nil {
- t.Fatal(err)
- }
- // No GET team web service, so hard to confirm here that team name updated
}
func TestFuzzyTeamCreate(t *testing.T) {
diff --git a/api/user.go b/api/user.go
index 3796a50ee..732c6b9a8 100644
--- a/api/user.go
+++ b/api/user.go
@@ -652,6 +652,12 @@ func getProfiles(c *Context, w http.ResponseWriter, r *http.Request) {
for k, p := range profiles {
options := utils.SanitizeOptions
options["passwordupdate"] = false
+
+ if c.IsSystemAdmin() {
+ options["fullname"] = true
+ options["email"] = true
+ }
+
p.Sanitize(options)
profiles[k] = p
}
diff --git a/api/user_test.go b/api/user_test.go
index b54e030c5..0ad3541bc 100644
--- a/api/user_test.go
+++ b/api/user_test.go
@@ -661,12 +661,6 @@ func TestUserUpdateRoles(t *testing.T) {
t.Fatal("Should have errored, not admin")
}
- name := make(map[string]string)
- name["new_name"] = "NewName"
- if _, err := Client.UpdateTeamDisplayName(name); err == nil {
- t.Fatal("should have errored - user not admin yet")
- }
-
team2 := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN}
team2 = Client.Must(Client.CreateTeam(team2)).Data.(*model.Team)
@@ -707,12 +701,6 @@ func TestUserUpdateRoles(t *testing.T) {
t.Fatal("Roles did not update properly")
}
}
-
- Client.LoginByEmail(team.Name, user2.Email, "pwd")
-
- if _, err := Client.UpdateTeamDisplayName(name); err != nil {
- t.Fatal(err)
- }
}
func TestUserUpdateActive(t *testing.T) {
diff --git a/config/config.json b/config/config.json
index 7bac58df7..a927620b5 100644
--- a/config/config.json
+++ b/config/config.json
@@ -18,7 +18,8 @@
"EnableTeamCreation": true,
"EnableUserCreation": true,
"RestrictCreationToDomains": "",
- "RestrictTeamNames": true
+ "RestrictTeamNames": true,
+ "EnableTeamListing": false
},
"SqlSettings": {
"DriverName": "mysql",
diff --git a/doc/README.md b/doc/README.md
index ccb702a5d..1b9cc759d 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -27,7 +27,7 @@ Set up Mattermost in your data center
- [Code Contribution Guidelines](developer/Code-Contribution-Guidelines.md)
- [Developer Machine Setup](developer/Setup.md)
- [Mattermost Style Guide](developer/Style-Guide.md)
-- [API Overview](api/Overview.md)
+- [API Overview](developer/API.md)
- [Incoming Webhooks](integrations/webhooks/Incoming-Webhooks.md)
## Help
diff --git a/doc/api/Overview.md b/doc/developer/API-Web-Service.md
index 0a407b1e9..53ebc869c 100644
--- a/doc/api/Overview.md
+++ b/doc/developer/API-Web-Service.md
@@ -1,6 +1,8 @@
-# API Overview
+# Mattermost Web Service API
-This provides a basic overview of the Mattermost API. All examples assume there is a Mattermost instance running at http://localhost:8065.
+This provides a basic overview of the Mattermost Web Service API. Drivers interfacing with this API are available in different languages. Current documentation focuses on the transport layer for the API and functional documentation will be developed next.
+
+All examples assume there is a Mattermost instance running at http://localhost:8065.
## Schema
diff --git a/doc/developer/API.md b/doc/developer/API.md
index 6327f1173..4c4b2f04e 100644
--- a/doc/developer/API.md
+++ b/doc/developer/API.md
@@ -2,7 +2,7 @@
Mattermost APIs let you integrate your favorite tools and services withing your Mattermost experience.
-## Slack-compatible integration support
+## Slack-compatible Webhooks
To offer an alternative to propreitary SaaS services, Mattermost focuses on being "Slack-compatible, but not Slack limited". That means providing support for developers of Slack applications to easily extend their apps to Mattermost, as well as support and capabilities beyond what Slack offers.
@@ -12,19 +12,19 @@ Incoming webhooks allow external applications to post messages into Mattermost c
In addition to supporting Slack's incoming webhook formatting, Mattermost webhooks offer full support of industry-standard markdown formatting, including headings, tables and in-line images.
-### Outgoing Webhooks (coming in Mattermost v1.2)
+### [Outgoing Webhooks (in Mattermost v1.2)](https://github.com/mattermost/platform/blob/master/doc/integrations/webhooks/Outgoing-Webhooks.md)
Outgoing webhooks allow external applications to receive webhook events from events happening within Mattermost channels and private groups via JSON payloads via HTTP POST requests sent to incoming webhook URLs defined by your applications.
Over time, Mattermost outgoing webhooks will support not only Slack applications using a compatible format, but also offer optional events and triggers beyond Slack's feature set.
-## Mattermost Drivers
+## Mattermost Web Service API
-Mattermost is written in Golang and React and designed as a self-hosted system, which differs from Slack's technical platform and focus on SaaS. Therefore the Mattermost drivers will differ from Slack's interfaces.
+Mattermost offers a Web Service API accessible by Mattermost Drivers, listed below. Initial documentation on the [transport layer for the web service is available](API-Web-Service.md) and functional documentation is under development.
-Another key difference is that as an open source project, you are welcome to access and use Mattermost's APIs on your installations the same way the core team would use them for buildling new features.
+## Mattermost Drivers
-While detailed documentation of the interfaces is pending, if you want to build deep integrations with Mattermost there are two drivers at the heart of the system:
+Mattermost drivers offer access to the Mattermost web service API in different languages and frameworks.
### [ReactJS Javascript Driver](https://github.com/mattermost/platform/blob/master/web/react/utils/client.jsx)
diff --git a/doc/developer/Code-Contribution-Guidelines.md b/doc/developer/Code-Contribution-Guidelines.md
index 48bbf2491..18be4aa0b 100644
--- a/doc/developer/Code-Contribution-Guidelines.md
+++ b/doc/developer/Code-Contribution-Guidelines.md
@@ -1,48 +1,5 @@
# Code Contribution Guidelines
-Thank you for your interest in contributing to Mattermost. This guide provides an overview of important information for contributors to know.
-
-## Choose a Ticket
-
-1. Review the list of [Good First Contribution](https://mattermost.atlassian.net/issues/?filter=10206) tickets listed in Jira.
-2. These projects are intended to be a straight forward first pull requests from new contributors.
-If you don't find something appropriate for your interests, please see the full list of tickets [Accepting Pull Requests](https://mattermost.atlassian.net/issues/?filter=10101).
-
-3. If you have any questions at all about a ticket, please post to the [Contributor Discussion section](http://forum.mattermost.org/) of the Mattermost forum, or email the [Mattermost Developer Mailing list](https://groups.google.com/a/mattermost.com/forum/#!forum/developer/join).
-
-## Install Mattermost and set up a Fork
-
-1. Follow [developer setup instructions](https://github.com/mattermost/platform/blob/master/doc/developer/Setup.md) to install Mattermost.
-
-2. Create a branch with <branch name> set to the ID of the ticket you're working on, for example ```PLT-394```, using command:
-
-```
-git checkout -b <branch name>
-```
-
-## Programming and Testing
-
-1. Please review the [Mattermost Style Guide](Style-Guide.md) prior to making changes.
-
- To keep code clean and well structured, Mattermost uses ESLint to check that pull requests adhere to style guidelines for React. Code will need to follow Mattermost's React style guidelines in order to pass the automated build tests when a pull request is submitted.
-
-2. Please make sure to thoroughly test your change before submitting a pull request.
-
- Please review the ["Fast, Obvious, Forgiving" experience design principles](http://www.mattermost.org/design-principles/) for Mattermost and check that your feature meets the criteria. Also, for any changes to user interface or help text, please read the changes out loud, as a quick and easy way to catch any inconsitencies.
-
-
-## Submitting a Pull Request
-
-1. Please add yourself to the Mattermost [approved contributor list](https://docs.google.com/spreadsheets/d/1NTCeG-iL_VS9bFqtmHSfwETo5f-8MQ7oMDE5IUYJi_Y/pubhtml?gid=0&single=true) prior to submitting by completing the [contributor license agreement](http://www.mattermost.org/mattermost-contributor-agreement/).
-
-2. When you submit your pull request please make it against `master` and include the Ticket ID at the beginning of your pull request comment, followed by a colon.
-
- For example, for a ticket ID `PLT-394` start your comment with: `PLT-394:`. See [previously closed pull requests](https://github.com/mattermost/platform/pulls?q=is%3Apr+is%3Aclosed) for examples.
-
-3. Once submitted, your pull request will be checked via an automated build process and will be reviewed by at least two members of the Mattermost core team, who may either accept the PR or follow-up with feedback. It would then get merged into `master` for the next release.
-
-4. If you've included your mailing address in Step 1, you'll be receiving a [Limited Edition Mattermost Mug](http://forum.mattermost.org/t/limited-edition-mattermost-mugs/143) as a thank you gift after your first pull request has been accepted.
-
-
+Please see [CONTRIBUTING.md](https://github.com/mattermost/platform/blob/master/CONTRIBUTING.md)
diff --git a/doc/install/Amazon-Elastic-Beanstalk.md b/doc/install/Amazon-Elastic-Beanstalk.md
index 0416b67ea..8738ab3ac 100644
--- a/doc/install/Amazon-Elastic-Beanstalk.md
+++ b/doc/install/Amazon-Elastic-Beanstalk.md
@@ -1,27 +1,26 @@
## AWS Elastic Beanstalk Setup (Docker)
+These instructions will guide you through the process of setting up Mattermost for product evaluation using an EBS Docker single-container application using [Dockerrun.aws.zip](https://github.com/mattermost/platform/raw/master/docker/1.1/Dockerrun.aws.zip).
-1. Create a new Elastic Beanstalk Docker application using the [Dockerrun.aws.zip](https://github.com/mattermost/platform/raw/master/docker/1.0/Dockerrun.aws.zip) file provided.
- 1. From the AWS console select Elastic Beanstalk.
- 2. Select "Create New Application" from the top right.
- 3. Name the application and press next.
- 4. Select "Create a web server" environment.
- 5. If asked, select create an IAM role and instance profile and press next.
- 6. For predefined configuration select under Generic: Docker. For environment type select single instance.
- 7. For application source, select upload your own and upload Dockerrun.aws.zip from [Dockerrun.aws.zip](https://github.com/mattermost/platform/raw/master/docker/1.0/Dockerrun.aws.zip). Everything else may be left at default.
- 8. Select an environment name, this is how you will refer to your environment. Make sure the URL is available then press next.
- 9. The options on the additional resources page may be left at default unless you wish to change them. Press Next.
- 10. On the configuration details place. Select an instance type of t2.small or larger.
- 11. You can set the configuration details as you please but they may be left at their defaults. When you are done press next.
- 12. Environment tags my be left blank. Press next.
- 13. You will be asked to review your information. Press Launch.
-
-4. Try it out!
- 14. Wait for beanstalk to update the environment.
- 15. Try it out by entering the domain of the form \*.elasticbeanstalk.com found at the top of the dashboard into your browser. You can also map your own domain if you wish.
-
-
- ### (Recommended) Enable Email
- The default single-container Docker instance for Mattermost is designed for product evaluation, and sets `ByPassEmail=true` so the product can run without enabling email, when doing so maybe difficult.
+1. From your [AWS console]( https://console.aws.amazon.com/console/home) select **Elastic Beanstalk** under the Compute section.
+2. Select **Create New Application** from the top right.
+3. Name your Elastic Beanstalk application and click **Next**,
+4. Select **Create web server** on the New Enviroment page.
+5. If asked, select **Create an IAM role and instance profile**, then click **Next**.
+6. On the Enviroment Type page,
+ 1. Set Predefined Configuration to **Docker** under the generic heading in the drop-down list.
+ 2. Set Environment Type to **Single instance** in the drop-down list.
+ 3. Click **Next**.
+7. For Application Source, select **Upload your own** and upload the [Dockerrun.aws.zip](https://github.com/mattermost/platform/raw/master/docker/1.1/Dockerrun.aws.zip) file, then click **Next**.
+8. Type an Environment Name and URL. Make sure the URL is available by clicking **Check availability**, then click **Next**.
+9. The options on the Additional Resources page may be left at default unless you wish to change them. Click **Next**.
+10. On the Configuration Details page,
+ 1. Select an Instance Type of **t2.small** or larger.
+ 2. The remaining options may be left at their default values unless you wish to change them. Click **Next**.
+11. Environment tags may be left blank. Click **Next**.
+12. You will be asked to review your information, then click **Launch**.
+14. It may take a few minutes for beanstalk to launch your environment. If the launch is successful, you will see a see a large green checkmark and the Health status should change to “Green”.
+15. Test your environment by clicking the domain link next to your application name at the top of the dashboard. Alternatively, enter the domain into your browser in the form `http://<your-ebs-application-url>.elasticbeanstalk.com`. You can also map your own domain if you wish. If everything is working correctly, the domain should navigate you to the Mattermost signup page. Enjoy exploring Mattermost!
- To see the product's full functionality, [enabling SMTP email is recommended](SMTP-Email-Setup.md).
+### (Recommended) Enable Email
+The default single-container Docker instance for Mattermost is designed for product evaluation, and sets `SendEmailNotifications=false` so the product can function without enabling email. To see the product's full functionality, [enabling SMTP email is recommended](SMTP-Email-Setup.md).
diff --git a/doc/install/Requirements.md b/doc/install/Requirements.md
index 1e0a12fb9..61fa8e7be 100644
--- a/doc/install/Requirements.md
+++ b/doc/install/Requirements.md
@@ -10,6 +10,27 @@ Supported Operating Systems and Browsers for the Mattermost Web Client include:
- iPhone 4s and higher (Safari on iOS 8.3+, Chrome 43+)
- Android 5 and higher (Chrome 43+)
+### Email Client
+
+Supported Email Clients for rendering Mattermost email notifications include:
+
+Web based clients:
+- Gmail
+- Office 365
+- Outlook
+- Yahoo
+- AOL
+
+Desktop Clients:
+- Apple Mail version 7+
+- Outlook 2016+
+- Thunderbird 38.2+
+
+Mobile Clients:
+- Gmail Mobile App (Android, iOS)
+- iOS Mail App (iOS 7+)
+- Blackberry Mail App (OS version 4+)
+
### Server
Supported Operating Systems for the Mattermost Server include:
diff --git a/doc/install/Troubleshooting.md b/doc/install/Troubleshooting.md
index 284303949..7166f9978 100644
--- a/doc/install/Troubleshooting.md
+++ b/doc/install/Troubleshooting.md
@@ -17,8 +17,14 @@
The following is a list of common error messages and solutions:
###### `Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.`
-- Message appears in blue bar on team site. Check that [your websocket port is properly configured](https://github.com/mattermost/platform/blob/master/doc/install/Production-Ubuntu.md#set-up-nginx-server).
+- Message appears in blue bar on team site.
+- **Solution:** Check that [your websocket port is properly configured](https://github.com/mattermost/platform/blob/master/doc/install/Production-Ubuntu.md#set-up-nginx-server).
###### `x509: certificate signed by unknown authority` in server logs when attempting to sign-up
- - This error may appear when attempt to use a self-signed certificate to setup SSL, which is not yet supported by Mattermost. You can resolve this issue by setting up a load balancer like Ngnix. A ticket exists to [add support for self-signed certificates in future](x509: certificate signed by unknown authority).
+ - This error may appear when attempt to use a self-signed certificate to setup SSL, which is not yet supported by Mattermost. You
+ - **Solution:** Set up a load balancer like Ngnix [per production install guide](https://github.com/mattermost/platform/blob/master/doc/install/Production-Ubuntu.md#set-up-nginx-with-ssl-recommended). A ticket exists to [add support for self-signed certificates in future](x509: certificate signed by unknown authority).
+
+###### `panic: runtime error: invalid memory address or nil pointer dereference`
+ - This error can occur if you have manually manipulated the Mattermost database, typically with deletions. Mattermost is designed to serve as a searchable archive, and manual manipulation of the database elements compromises integrity and may prevent upgrade.
+ - **Solution:** Restore from database backup created prior to manual database updates, or reinstall the system.
diff --git a/doc/integrations/webhooks/Incoming-Webhooks.md b/doc/integrations/webhooks/Incoming-Webhooks.md
index b10b6e342..b5ae0fde2 100644
--- a/doc/integrations/webhooks/Incoming-Webhooks.md
+++ b/doc/integrations/webhooks/Incoming-Webhooks.md
@@ -90,9 +90,9 @@ As mentioned above, Mattermost makes it easy to take integrations written for Sl
To see samples and community contributions, please visit <http://mattermost.org/webhooks>.
-#### Known Issues
+#### Known Issues in v1.1
- The `attachments` payload used in Slack is not yet supported
-- Overriding of usernames does not yet apply to notifications
+- Overriding of usernames does not yet apply to notifications (fixed on master)
- Cannot supply `icon_emoji` to override the message icon
-- Webhook UI fails when connected to deleted channel
+- Webhook UI fails when connected to deleted channel (fixed on master)
diff --git a/doc/integrations/webhooks/Outgoing-Webhooks.md b/doc/integrations/webhooks/Outgoing-Webhooks.md
index abe26ceae..008245715 100644
--- a/doc/integrations/webhooks/Outgoing-Webhooks.md
+++ b/doc/integrations/webhooks/Outgoing-Webhooks.md
@@ -114,7 +114,7 @@ As mentioned above, Mattermost makes it easy to take integrations written for Sl
To see samples and community contributions, please visit <http://mattermost.org/webhooks>.
-#### Known Issues
+#### Known Issues in v1.1
- Overriding of usernames does not yet apply to notifications
- Cannot supply `icon_emoji` to override the message icon
diff --git a/doc/process/documentation-guidelines.md b/doc/process/documentation-guidelines.md
new file mode 100644
index 000000000..f37f0c5fc
--- /dev/null
+++ b/doc/process/documentation-guidelines.md
@@ -0,0 +1,185 @@
+# Documentation Conventions
+
+The most important thing about documentation is getting it done and out to the community.
+
+After that, we can work on upgrading the quality of documentation. The below chart summarizes the different levels of documentation and how the quality gates are applied.
+
+_Note: Documentation Guidelines are new, and iterating. Documentation has started to balloon and this is our attempt at reducing ambiguity and increasing consistency, but the conventions here are very open to discussion._
+
+| Stars | Benchmark | Timeline |
+|:-------------|:--------------------------------|:--------------------------------|
+| 1 | Documentation is correct. | First draft checked in by developer. Okay to ship in first release of new feature. |
+| 2 | Documentation a) follows all objective formatting criteria, b) is tested by someone other than the author, c) satisfies above. | First edit under objective rules. Required before second release cycle with this feature included. |
+| 3 | Documentation a) follows all subjective style criteria, b) is reviewed and edited by someone who has previously authored 3-star documentation, and c) satisfies above. | Second edit under subjective rules. Required before third release cycle with this feature included |
+| 4 | Documentation a) has received at least 1 edit due to user feedback, b) has received at least one unprompted compliment from user community on quality, c) satisfies above. | Additional edits to refine documentation based on user feedback |
+
+## 1-Star Requirements: Correctness
+
+### List precise dependencies
+
+1. Be explicit about what specific dependencies have been tested as part of an installation procedure.
+2. Be explicit about assumptions of compatibility on systems that have not been tested.
+3. Do not claim the system works on later versions of a platform if backwards compatibility is not a priority for the dependency (It's okay to say Chrome version 43 and higher, but not Python 2.6 and higher, because Python 3.0 is explicitly incompatible with previous versions).
+
+#### Correct
+
+----
+This procedure works on an Ubuntu 14.04 server with Python 2.6 installed and should work on compatible Linux-based operating systems and compatible versions of Python.
+
+----
+#### Incorrect
+
+----
+This procedure works on Linux servers running Python.
+
+also:
+
+This procedure works on Linux servers running Python 2.6 and higher.
+
+----
+## 2-Star Requirements: Objective Formatting Checklist
+
+### Use headings
+
+Headings in markdown provide anchors that can be used to easily reference sub-sections of long pieces of documentation. This is preferrable to just numbering sections without headings.
+
+##### Correct:
+
+----
+##### Step 1: Add a heading
+This makes things easier to reference via hyperlinks
+##### Step 2: Link to headings
+So things are eariser to find
+
+----
+##### Incorrect:
+
+----
+**Step 1: Add a heading**
+This makes things easier to reference via hyperlinks
+**Step 2: Link to headings**
+So things are eariser to find
+----
+
+### Use appropriate heading case
+
+Cases in headings may vary depending on usage.
+
+#### When to use Title Case
+
+H1, H2, H3 headings should be "Title Case" and less than four words, except if a colon is used, then four words per segment separated by the colon.
+
+These large headings are typically shorter and help with navigating large documents
+
+#### When to use sentence case
+
+H3, H4, H5 headings should be "Sentence case" and can be any length.
+
+These headers are smaller and used to summarize sections. H3 can be considered either a large or small heading.
+
+These conventions are new, so there's flexibility around them, when you're not sure, consider the convention here as default.
+
+### One instruction per line
+
+It's easy to miss instructions when they're compounded. Have only one instruction per line, so documentation looks more like a checklist.
+
+A support person should be able to say "Did you complete step 7?" instead of "Did you complete the second part of step 7 after doing XXX?"
+
+##### Correct:
+
+----
+
+6. For **Predefined configuration** look under **Generic** and select **Docker**.
+ 7. For **Environment type** select **Single instance**
+
+----
+
+##### Incorrect:
+
+----
+
+6. For **Predefined configuration** look under **Generic** and select **Docker**. For **Environment type** select **Single instance**
+
+----
+
+### Lists end without periods
+
+Sentences within bullet points or numbered lists should end in normal punctuation. The sentence or fragment at the end of a bullet point should not have a period.
+
+##### Correct
+
+----
+- This is a sentence within a bullet point. This is the end of a bullet point without a period
+
+----
+##### Incorrect
+
+----
+- This is an incorrect ending of a bullet point with a period.
+
+----
+### Avoid Passive Phrases
+
+Examples of passive phrases include "have", "had", "was", "can be", "has been" and documentation is shorter and clearer without them.
+
+##### Correct
+
+----
+This software **runs** on any server that supports Python.
+
+----
+##### Incorrect
+
+----
+This software **can be run** on any server that supports Python.
+
+----
+## 3-Star Requirements: Subjective Style Guidelines
+
+### Be Concise
+
+Try to use fewer words when possible.
+
+##### Correct:
+
+----
+This integration posts [issue](http://doc.gitlab.com/ee/web_hooks/web_hooks.html#issues-events), [comment](http://doc.gitlab.com/ee/web_hooks/web_hooks.html#comment-events) and [merge request](http://doc.gitlab.com/ee/web_hooks/web_hooks.html#merge-request-events) events from a GitLab repository into specific Mattermost channels by formatting output from [GitLab's outgoing webhooks](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/web_hooks/web_hooks.md) to [Mattermost's incoming webhooks](https://github.com/mattermost/platform/blob/master/doc/integrations/webhooks/Incoming-Webhooks.md).
+
+----
+##### Incorrect:
+
+----
+This integration makes use of GitLab's outgoing webhooks and Mattermost's incoming webhooks to post GitLab events into Mattermost. You can find GitLab's outgoing webhooks described [here](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/web_hooks/web_hooks.md) and Mattermost's incoming webhooks described [here](https://github.com/mattermost/platform/blob/master/doc/integrations/webhooks/Incoming-Webhooks.md).
+
+----
+
+### Use appropriate emphasis
+
+Mention Clickable Controls in **Bold**, Sections and Setting Names in *Italics*, and Key Strokes in `pre-formatted text`.
+
+To make it clear and consistent across documentation on how we describe controls that a user is asked to manipulate, we have a number of guidelines:
+
+**Bold**
+- Please **bold** the names of controls you're asking users to click. The text that is bolded should match the label of the control in the user interface. Do not format these references with _italics_, ALL-CAPS or `pre-formatted text`.
+- Use `>` to express a series of clicks, for example clicking on **Button One** > **Button Two** > **Button Three**.
+- If a button might be difficult to find, give a hint about its location _before_ mentioning the name of the control (this helps people find the hint before they start searching, if the see the name of the button first, they might not continue reading to find the hint before starting to look).
+
+***Italics***
+- Please *italicize* setting names or section headings that identify that the user is looking in the correct area. The text that is italicized should match the name of the setting or section in the user interface.
+- It is helpful to use italics to guide the user to the correct area before mentioning a clickable action in bold.
+
+**`pre-formatted text`**
+- Please use `pre-formatted text` to identify when a user must enter key strokes or paste text into an input box.
+
+#### Correct
+
+----
+Type `mattermost-integration-giphy` in the *repo-name* field, then click **Search** and then the **Connect** button once Heroku finds your repository
+
+----
+#### Incorrect
+
+----
+Type "mattermost-integration-giphy" in the **repo-name** field, then click Search and then the *Connect* button once Heroku finds your repository
+
+----
diff --git a/doc/process/release-process.md b/doc/process/release-process.md
new file mode 100644
index 000000000..04ec9a366
--- /dev/null
+++ b/doc/process/release-process.md
@@ -0,0 +1,99 @@
+We're working on making internal processes in the Mattermost core team more transparent for the community. Below is a working draft of our software development process, which will be updated live as we refine our process.
+
+Questions, feedback, comments always welcome,
+
+----------
+
+Mattermost core team works on a monthly release process, with a new version shipping on the 16th of each month.
+
+This document outlines the development process for the Mattermost core team, which draws from what we find works best for us from Agile, Scrum and Software Development Lifecycle approaches.
+
+This is a working document that will update as our process evolves.
+
+
+### - Beginning of release
+- (Ops) Queue an agenda item for first team meeting of the release to review Roadmap
+
+### - (10 weekdays before release date) Cut-off for major features
+- No major features can be committed to the current release after this date
+- (Dev) Prioritize reviewing, updating, and merging of all pull requests that are going to make it into the release
+ - There should be 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
+ - Review roadmap for next release
+- (Marketing) Writes the "Highlights" section of the Changelog
+- (PM) Write compatibility updates for config.json and database changes [See example](https://github.com/mattermost/platform/blob/master/CHANGELOG.md#compatibility)
+- (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 RCs builds, filing issue in GitLab omnibus to take RC candidate, testing GitLab RC with Mattermost
+- (Stand-up) Each team member discusses worst bug
+
+### - (8 weekdays before release date) Feature Complete and Stabilization
+- After the cut-off time for Feature Complete, Dev prioritizes reviewing PRs and committing to master so Stabilization period can begin, with testing and high priority bug fixes
+- During Stabilization period only BUGS can be committed to master, non-bug tickets are tagged for next version and wait until after a release candidate is cut to be added to master
+ - (PM) Review all [S1 bugs](https://mattermost.atlassian.net/secure/IssueNavigator.jspa?mode=hide&requestId=10600) and mark important ones as high priority
+ - (Dev + PM) Exceptions can be made by triage team consensus across PM and Dev. List of approved changes for release candidate 1 here: https://mattermost.atlassian.net/issues/?filter=10204
+- (PM) 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 and devs) 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)
+- (Ops) Mail out mugs to any new contributors
+- (Team) Select "Top Contributor" for the release from external contributions to be mentioned in release announcement
+- (Marketing) Decides announce date (discuss in meeting)
+- (Ops) Post Announce Date in Release channel + update the channel header to reflect date
+- (Marketing) Communicates checklist of items needed by specific dates to write the blog post announce (eg screenshots, GIFs, documentation) and begins to write the blog post, tweet, and email for the release announcement
+- (PM) Works with Ops to check the Quality Gate for feature complete
+- (PM) Communicate to team the plan for next release
+- (Stand-up) Each team member discusses worst bug
+
+### - (5 weekdays before release date) Code Compete and Release Candidate Cut
+- (Team) Meets to discuss release at 10am PST
+ - (PM) Each area changed in latest release is assigned a PM owner to lead testing
+ - (Ops) Walks through each item of the **Code Complete and Release Candidate Cut** checklist
+ - (Dev) Last check of tickets that need to be merged before RC1
+ - (Team) Each team member discusses worst bug
+- After 10am PST meeting the release is considered “Code Complete”.
+ - (Dev) Completes final reviews and updates of PRs marked for the release version
+ - There should be no more tickets in the [pull request queue](https://github.com/mattermost/platform/pulls) marked for the current release
+ - Master is tagged and branched and “Release Candidate 1″ is cut (e.g. 1.1.0-RC1) according to the Release Candidate Checklist
+ - (PM) Create meta issue for regressions in GitHub (see [example](https://github.com/mattermost/platform/issues/574))
+
+### - (4 weekdays before release date) Release Candidate Testing
+- Final testing is conducted by the team on the acceptance server and any issues found are filed
+ - (Dev) Tests upgrade from previous version to current version, following the [Upgrade Guide](https://github.com/mattermost/platform/blob/master/doc/install/Upgrade-Guide.md)
+ - (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 & 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
+
+### - (2 weekdays before release date) Release
+ - (Dev) 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 hotfix release (e.g. 1.1.1) may be released, however this should be very rare, given a monthly cadence
+ - (PM) Copy and paste the Release Notes from the Changelog to the Release Description
+ - (PM) Update the mattermost.org/download page
+ - (Dev) Delete RCs after final version is shipped
+ - (PM) Close final GitHub RC meta ticket
+
+### - (0 weekdays before release date) End of Release
+- (PM) Makes sure marketing has been posted (animated GIFs, screenshots, mail announcement, Tweets, blog posts)
+- (PM) Close the release in Jira
+- (Dev) Check if any libraries need to be updated for the next release, and if so bring up in weekly team meeting
+- (Ops) Post important dates for the next release in the header of the Release channel
+- (Ops) Queue an agenda item for next team meeting for "Stepping Back" Q&A
+- (Ops) Queue an agenda item for next team meeting for Roadmap review
diff --git a/docker/dev/config_docker.json b/docker/dev/config_docker.json
index 00729395e..80e6ab14e 100644
--- a/docker/dev/config_docker.json
+++ b/docker/dev/config_docker.json
@@ -17,7 +17,9 @@
"MaxUsersPerTeam": 50,
"EnableTeamCreation": true,
"EnableUserCreation": true,
- "RestrictCreationToDomains": ""
+ "RestrictCreationToDomains": "",
+ "RestrictTeamNames": true,
+ "EnableTeamListing": false
},
"SqlSettings": {
"DriverName": "mysql",
diff --git a/docker/local/config_docker.json b/docker/local/config_docker.json
index 00729395e..80e6ab14e 100644
--- a/docker/local/config_docker.json
+++ b/docker/local/config_docker.json
@@ -17,7 +17,9 @@
"MaxUsersPerTeam": 50,
"EnableTeamCreation": true,
"EnableUserCreation": true,
- "RestrictCreationToDomains": ""
+ "RestrictCreationToDomains": "",
+ "RestrictTeamNames": true,
+ "EnableTeamListing": false
},
"SqlSettings": {
"DriverName": "mysql",
diff --git a/manualtesting/manual_testing.go b/manualtesting/manual_testing.go
index 3c2289626..ffdb578a4 100644
--- a/manualtesting/manual_testing.go
+++ b/manualtesting/manual_testing.go
@@ -16,7 +16,7 @@ import (
"time"
)
-type TestEnviroment struct {
+type TestEnvironment struct {
Params map[string][]string
Client *model.Client
CreatedTeamId string
@@ -121,8 +121,8 @@ func manualTest(c *api.Context, w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/channels/town-square", http.StatusTemporaryRedirect)
}
- // Setup test enviroment
- env := TestEnviroment{
+ // Setup test environment
+ env := TestEnvironment{
Params: params,
Client: client,
CreatedTeamId: teamID,
diff --git a/manualtesting/test_autolink.go b/manualtesting/test_autolink.go
index e4d49659a..a13ec7a75 100644
--- a/manualtesting/test_autolink.go
+++ b/manualtesting/test_autolink.go
@@ -19,7 +19,7 @@ http://www.google.com.pk/url?sa=t&rct=j&q=&esrc=s&source=web&cd=4&cad=rja&uact=8
https://medium.com/@slackhq/11-useful-tips-for-getting-the-most-of-slack-5dfb3d1af77
`
-func testAutoLink(env TestEnviroment) *model.AppError {
+func testAutoLink(env TestEnvironment) *model.AppError {
l4g.Info("Manual Auto Link Test")
channelID, err := getChannelID(model.DEFAULT_CHANNEL, env.CreatedTeamId, env.CreatedUserId)
if err != true {
diff --git a/model/channel.go b/model/channel.go
index 076ddf0b5..ac54a7e44 100644
--- a/model/channel.go
+++ b/model/channel.go
@@ -24,7 +24,8 @@ type Channel struct {
Type string `json:"type"`
DisplayName string `json:"display_name"`
Name string `json:"name"`
- Description string `json:"description"`
+ Header string `json:"header"`
+ Purpose string `json:"purpose"`
LastPostAt int64 `json:"last_post_at"`
TotalMsgCount int64 `json:"total_msg_count"`
ExtraUpdateAt int64 `json:"extra_update_at"`
@@ -89,8 +90,12 @@ func (o *Channel) IsValid() *AppError {
return NewAppError("Channel.IsValid", "Invalid type", "id="+o.Id)
}
- if len(o.Description) > 1024 {
- return NewAppError("Channel.IsValid", "Invalid description", "id="+o.Id)
+ if len(o.Header) > 1024 {
+ return NewAppError("Channel.IsValid", "Invalid header", "id="+o.Id)
+ }
+
+ if len(o.Purpose) > 128 {
+ return NewAppError("Channel.IsValid", "Invalid purpose", "id="+o.Id)
}
if len(o.CreatorId) > 26 {
diff --git a/model/channel_test.go b/model/channel_test.go
index e5dfa3734..590417cfe 100644
--- a/model/channel_test.go
+++ b/model/channel_test.go
@@ -67,6 +67,26 @@ func TestChannelIsValid(t *testing.T) {
if err := o.IsValid(); err != nil {
t.Fatal(err)
}
+
+ o.Header = strings.Repeat("01234567890", 100)
+ if err := o.IsValid(); err == nil {
+ t.Fatal("should be invalid")
+ }
+
+ o.Header = "1234"
+ if err := o.IsValid(); err != nil {
+ t.Fatal(err)
+ }
+
+ o.Purpose = strings.Repeat("01234567890", 20)
+ if err := o.IsValid(); err == nil {
+ t.Fatal("should be invalid")
+ }
+
+ o.Purpose = "1234"
+ if err := o.IsValid(); err != nil {
+ t.Fatal(err)
+ }
}
func TestChannelPreSave(t *testing.T) {
diff --git a/model/client.go b/model/client.go
index 4d2c49e70..19183098e 100644
--- a/model/client.go
+++ b/model/client.go
@@ -211,8 +211,8 @@ func (c *Client) InviteMembers(invites *Invites) (*Result, *AppError) {
}
}
-func (c *Client) UpdateTeamDisplayName(data map[string]string) (*Result, *AppError) {
- if r, err := c.DoApiPost("/teams/update_name", MapToJson(data)); err != nil {
+func (c *Client) UpdateTeam(team *Team) (*Result, *AppError) {
+ if r, err := c.DoApiPost("/teams/update", team.ToJson()); err != nil {
return nil, err
} else {
return &Result{r.Header.Get(HEADER_REQUEST_ID),
@@ -452,8 +452,17 @@ func (c *Client) UpdateChannel(channel *Channel) (*Result, *AppError) {
}
}
-func (c *Client) UpdateChannelDesc(data map[string]string) (*Result, *AppError) {
- if r, err := c.DoApiPost("/channels/update_desc", MapToJson(data)); err != nil {
+func (c *Client) UpdateChannelHeader(data map[string]string) (*Result, *AppError) {
+ if r, err := c.DoApiPost("/channels/update_header", MapToJson(data)); err != nil {
+ return nil, err
+ } else {
+ return &Result{r.Header.Get(HEADER_REQUEST_ID),
+ r.Header.Get(HEADER_ETAG_SERVER), ChannelFromJson(r.Body)}, nil
+ }
+}
+
+func (c *Client) UpdateChannelPurpose(data map[string]string) (*Result, *AppError) {
+ if r, err := c.DoApiPost("/channels/update_purpose", MapToJson(data)); err != nil {
return nil, err
} else {
return &Result{r.Header.Get(HEADER_REQUEST_ID),
diff --git a/model/config.go b/model/config.go
index 216b1de86..50a8dc133 100644
--- a/model/config.go
+++ b/model/config.go
@@ -123,6 +123,7 @@ type TeamSettings struct {
EnableUserCreation bool
RestrictCreationToDomains string
RestrictTeamNames *bool
+ EnableTeamListing *bool
}
type Config struct {
@@ -175,6 +176,11 @@ func (o *Config) SetDefaults() {
o.TeamSettings.RestrictTeamNames = new(bool)
*o.TeamSettings.RestrictTeamNames = true
}
+
+ if o.TeamSettings.EnableTeamListing == nil {
+ o.TeamSettings.EnableTeamListing = new(bool)
+ *o.TeamSettings.EnableTeamListing = false
+ }
}
func (o *Config) IsValid() *AppError {
diff --git a/model/team.go b/model/team.go
index 9da2cd5b2..4d14ec2ee 100644
--- a/model/team.go
+++ b/model/team.go
@@ -17,16 +17,19 @@ const (
)
type Team struct {
- Id string `json:"id"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
- DeleteAt int64 `json:"delete_at"`
- DisplayName string `json:"display_name"`
- Name string `json:"name"`
- Email string `json:"email"`
- Type string `json:"type"`
- CompanyName string `json:"company_name"`
- AllowedDomains string `json:"allowed_domains"`
+ Id string `json:"id"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+ DeleteAt int64 `json:"delete_at"`
+ DisplayName string `json:"display_name"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Type string `json:"type"`
+ CompanyName string `json:"company_name"`
+ AllowedDomains string `json:"allowed_domains"`
+ InviteId string `json:"invite_id"`
+ AllowOpenInvite bool `json:"allow_open_invite"`
+ AllowTeamListing bool `json:"allow_team_listing"`
}
type Invites struct {
@@ -119,7 +122,7 @@ func (o *Team) IsValid(restrictTeamNames bool) *AppError {
return NewAppError("Team.IsValid", "Invalid email", "id="+o.Id)
}
- if len(o.DisplayName) > 64 {
+ if len(o.DisplayName) == 0 || len(o.DisplayName) > 64 {
return NewAppError("Team.IsValid", "Invalid name", "id="+o.Id)
}
@@ -157,6 +160,10 @@ func (o *Team) PreSave() {
o.CreateAt = GetMillis()
o.UpdateAt = o.CreateAt
+
+ if len(o.InviteId) == 0 {
+ o.InviteId = NewId()
+ }
}
func (o *Team) PreUpdate() {
diff --git a/store/sql_audit_store.go b/store/sql_audit_store.go
index 898cf8f78..b3e2daea0 100644
--- a/store/sql_audit_store.go
+++ b/store/sql_audit_store.go
@@ -46,7 +46,7 @@ func (s SqlAuditStore) Save(audit *model.Audit) StoreChannel {
if err := s.GetMaster().Insert(audit); err != nil {
result.Err = model.NewAppError("SqlAuditStore.Save",
- "We encounted an error saving the audit", "user_id="+
+ "We encountered an error saving the audit", "user_id="+
audit.UserId+" action="+audit.Action)
}
@@ -75,7 +75,7 @@ func (s SqlAuditStore) Get(user_id string, limit int) StoreChannel {
var audits model.Audits
if _, err := s.GetReplica().Select(&audits, "SELECT * FROM Audits WHERE UserId = :user_id ORDER BY CreateAt DESC LIMIT :limit",
map[string]interface{}{"user_id": user_id, "limit": limit}); err != nil {
- result.Err = model.NewAppError("SqlAuditStore.Get", "We encounted an error finding the audits", "user_id="+user_id)
+ result.Err = model.NewAppError("SqlAuditStore.Get", "We encountered an error finding the audits", "user_id="+user_id)
} else {
result.Data = audits
}
diff --git a/store/sql_channel_store.go b/store/sql_channel_store.go
index 80fe75130..2d62b8614 100644
--- a/store/sql_channel_store.go
+++ b/store/sql_channel_store.go
@@ -25,7 +25,8 @@ func NewSqlChannelStore(sqlStore *SqlStore) ChannelStore {
table.ColMap("DisplayName").SetMaxSize(64)
table.ColMap("Name").SetMaxSize(64)
table.SetUniqueTogether("Name", "TeamId")
- table.ColMap("Description").SetMaxSize(1024)
+ table.ColMap("Header").SetMaxSize(1024)
+ table.ColMap("Purpose").SetMaxSize(128)
table.ColMap("CreatorId").SetMaxSize(26)
tablem := db.AddTableWithName(model.ChannelMember{}, "ChannelMembers").SetKeys(false, "ChannelId", "UserId")
@@ -83,6 +84,11 @@ func (s SqlChannelStore) UpgradeSchemaIfNeeded() {
s.RemoveColumnIfExists("ChannelMembers", "NotifyLevel")
}
+
+ // BEGIN REMOVE AFTER 1.2.0
+ s.RenameColumnIfExists("Channels", "Description", "Header", "varchar(1024)")
+ s.CreateColumnIfNotExists("Channels", "Purpose", "varchar(1024)", "varchar(1024)", "")
+ // END REMOVE AFTER 1.2.0
}
func (s SqlChannelStore) CreateIndexesIfNotExists() {
@@ -241,7 +247,7 @@ func (s SqlChannelStore) Update(channel *model.Channel) StoreChannel {
result.Err = model.NewAppError("SqlChannelStore.Update", "A channel with that handle already exists", "id="+channel.Id+", "+err.Error())
}
} else {
- result.Err = model.NewAppError("SqlChannelStore.Update", "We encounted an error updating the channel", "id="+channel.Id+", "+err.Error())
+ result.Err = model.NewAppError("SqlChannelStore.Update", "We encountered an error updating the channel", "id="+channel.Id+", "+err.Error())
}
} else if count != 1 {
result.Err = model.NewAppError("SqlChannelStore.Update", "We couldn't update the channel", "id="+channel.Id)
@@ -291,7 +297,7 @@ func (s SqlChannelStore) Get(id string) StoreChannel {
result := StoreResult{}
if obj, err := s.GetReplica().Get(model.Channel{}, id); err != nil {
- result.Err = model.NewAppError("SqlChannelStore.Get", "We encounted an error finding the channel", "id="+id+", "+err.Error())
+ result.Err = model.NewAppError("SqlChannelStore.Get", "We encountered an error finding the channel", "id="+id+", "+err.Error())
} else if obj == nil {
result.Err = model.NewAppError("SqlChannelStore.Get", "We couldn't find the existing channel", "id="+id)
} else {
@@ -531,7 +537,7 @@ func (s SqlChannelStore) UpdateMember(member *model.ChannelMember) StoreChannel
}
if _, err := s.GetMaster().Update(member); err != nil {
- result.Err = model.NewAppError("SqlChannelStore.UpdateMember", "We encounted an error updating the channel member",
+ result.Err = model.NewAppError("SqlChannelStore.UpdateMember", "We encountered an error updating the channel member",
"channel_id="+member.ChannelId+", "+"user_id="+member.UserId+", "+err.Error())
} else {
result.Data = member
diff --git a/store/sql_oauth_store.go b/store/sql_oauth_store.go
index db52e379b..751207b85 100644
--- a/store/sql_oauth_store.go
+++ b/store/sql_oauth_store.go
@@ -102,7 +102,7 @@ func (as SqlOAuthStore) UpdateApp(app *model.OAuthApp) StoreChannel {
}
if oldAppResult, err := as.GetMaster().Get(model.OAuthApp{}, app.Id); err != nil {
- result.Err = model.NewAppError("SqlOAuthStore.UpdateApp", "We encounted an error finding the app", "app_id="+app.Id+", "+err.Error())
+ result.Err = model.NewAppError("SqlOAuthStore.UpdateApp", "We encountered an error finding the app", "app_id="+app.Id+", "+err.Error())
} else if oldAppResult == nil {
result.Err = model.NewAppError("SqlOAuthStore.UpdateApp", "We couldn't find the existing app to update", "app_id="+app.Id)
} else {
@@ -112,7 +112,7 @@ func (as SqlOAuthStore) UpdateApp(app *model.OAuthApp) StoreChannel {
app.CreatorId = oldApp.CreatorId
if count, err := as.GetMaster().Update(app); err != nil {
- result.Err = model.NewAppError("SqlOAuthStore.UpdateApp", "We encounted an error updating the app", "app_id="+app.Id+", "+err.Error())
+ result.Err = model.NewAppError("SqlOAuthStore.UpdateApp", "We encountered an error updating the app", "app_id="+app.Id+", "+err.Error())
} else if count != 1 {
result.Err = model.NewAppError("SqlOAuthStore.UpdateApp", "We couldn't update the app", "app_id="+app.Id)
} else {
@@ -135,7 +135,7 @@ func (as SqlOAuthStore) GetApp(id string) StoreChannel {
result := StoreResult{}
if obj, err := as.GetReplica().Get(model.OAuthApp{}, id); err != nil {
- result.Err = model.NewAppError("SqlOAuthStore.GetApp", "We encounted an error finding the app", "app_id="+id+", "+err.Error())
+ result.Err = model.NewAppError("SqlOAuthStore.GetApp", "We encountered an error finding the app", "app_id="+id+", "+err.Error())
} else if obj == nil {
result.Err = model.NewAppError("SqlOAuthStore.GetApp", "We couldn't find the existing app", "app_id="+id)
} else {
@@ -208,7 +208,7 @@ func (as SqlOAuthStore) GetAccessData(token string) StoreChannel {
accessData := model.AccessData{}
if err := as.GetReplica().SelectOne(&accessData, "SELECT * FROM OAuthAccessData WHERE Token = :Token", map[string]interface{}{"Token": token}); err != nil {
- result.Err = model.NewAppError("SqlOAuthStore.GetAccessData", "We encounted an error finding the access token", err.Error())
+ result.Err = model.NewAppError("SqlOAuthStore.GetAccessData", "We encountered an error finding the access token", err.Error())
} else {
result.Data = &accessData
}
@@ -300,7 +300,7 @@ func (as SqlOAuthStore) GetAuthData(code string) StoreChannel {
result := StoreResult{}
if obj, err := as.GetReplica().Get(model.AuthData{}, code); err != nil {
- result.Err = model.NewAppError("SqlOAuthStore.GetAuthData", "We encounted an error finding the authorization code", err.Error())
+ result.Err = model.NewAppError("SqlOAuthStore.GetAuthData", "We encountered an error finding the authorization code", err.Error())
} else if obj == nil {
result.Err = model.NewAppError("SqlOAuthStore.GetAuthData", "We couldn't find the existing authorization code", "")
} else {
diff --git a/store/sql_post_store.go b/store/sql_post_store.go
index 7894ff488..f514d23ed 100644
--- a/store/sql_post_store.go
+++ b/store/sql_post_store.go
@@ -548,7 +548,7 @@ func (s SqlPostStore) Search(teamId string, userId string, params *model.SearchP
_, err := s.GetReplica().Select(&posts, searchQuery, queryParams)
if err != nil {
- result.Err = model.NewAppError("SqlPostStore.Search", "We encounted an error while searching for posts", "teamId="+teamId+", err="+err.Error())
+ result.Err = model.NewAppError("SqlPostStore.Search", "We encountered an error while searching for posts", "teamId="+teamId+", err="+err.Error())
}
list := &model.PostList{Order: make([]string, 0, len(posts))}
diff --git a/store/sql_preference_store.go b/store/sql_preference_store.go
index bf6e030bf..f9f38b747 100644
--- a/store/sql_preference_store.go
+++ b/store/sql_preference_store.go
@@ -173,7 +173,7 @@ func (s SqlPreferenceStore) Get(userId string, category string, name string) Sto
UserId = :UserId
AND Category = :Category
AND Name = :Name`, map[string]interface{}{"UserId": userId, "Category": category, "Name": name}); err != nil {
- result.Err = model.NewAppError("SqlPreferenceStore.Get", "We encounted an error while finding preferences", err.Error())
+ result.Err = model.NewAppError("SqlPreferenceStore.Get", "We encountered an error while finding preferences", err.Error())
} else {
result.Data = preference
}
@@ -201,7 +201,7 @@ func (s SqlPreferenceStore) GetCategory(userId string, category string) StoreCha
WHERE
UserId = :UserId
AND Category = :Category`, map[string]interface{}{"UserId": userId, "Category": category}); err != nil {
- result.Err = model.NewAppError("SqlPreferenceStore.GetCategory", "We encounted an error while finding preferences", err.Error())
+ result.Err = model.NewAppError("SqlPreferenceStore.GetCategory", "We encountered an error while finding preferences", err.Error())
} else {
result.Data = preferences
}
@@ -228,7 +228,7 @@ func (s SqlPreferenceStore) GetAll(userId string) StoreChannel {
Preferences
WHERE
UserId = :UserId`, map[string]interface{}{"UserId": userId}); err != nil {
- result.Err = model.NewAppError("SqlPreferenceStore.GetAll", "We encounted an error while finding preferences", err.Error())
+ result.Err = model.NewAppError("SqlPreferenceStore.GetAll", "We encountered an error while finding preferences", err.Error())
} else {
result.Data = preferences
}
diff --git a/store/sql_session_store.go b/store/sql_session_store.go
index 15ec6924b..27b34ee39 100644
--- a/store/sql_session_store.go
+++ b/store/sql_session_store.go
@@ -80,9 +80,9 @@ func (me SqlSessionStore) Get(sessionIdOrToken string) StoreChannel {
var sessions []*model.Session
if _, err := me.GetReplica().Select(&sessions, "SELECT * FROM Sessions WHERE Token = :Token OR Id = :Id LIMIT 1", map[string]interface{}{"Token": sessionIdOrToken, "Id": sessionIdOrToken}); err != nil {
- result.Err = model.NewAppError("SqlSessionStore.Get", "We encounted an error finding the session", "sessionIdOrToken="+sessionIdOrToken+", "+err.Error())
+ result.Err = model.NewAppError("SqlSessionStore.Get", "We encountered an error finding the session", "sessionIdOrToken="+sessionIdOrToken+", "+err.Error())
} else if sessions == nil || len(sessions) == 0 {
- result.Err = model.NewAppError("SqlSessionStore.Get", "We encounted an error finding the session", "sessionIdOrToken="+sessionIdOrToken)
+ result.Err = model.NewAppError("SqlSessionStore.Get", "We encountered an error finding the session", "sessionIdOrToken="+sessionIdOrToken)
} else {
result.Data = sessions[0]
}
@@ -109,7 +109,7 @@ func (me SqlSessionStore) GetSessions(userId string) StoreChannel {
var sessions []*model.Session
if _, err := me.GetReplica().Select(&sessions, "SELECT * FROM Sessions WHERE UserId = :UserId ORDER BY LastActivityAt DESC", map[string]interface{}{"UserId": userId}); err != nil {
- result.Err = model.NewAppError("SqlSessionStore.GetSessions", "We encounted an error while finding user sessions", err.Error())
+ result.Err = model.NewAppError("SqlSessionStore.GetSessions", "We encountered an error while finding user sessions", err.Error())
} else {
result.Data = sessions
@@ -165,7 +165,7 @@ func (me SqlSessionStore) CleanUpExpiredSessions(userId string) StoreChannel {
result := StoreResult{}
if _, err := me.GetMaster().Exec("DELETE FROM Sessions WHERE UserId = :UserId AND ExpiresAt != 0 AND :ExpiresAt > ExpiresAt", map[string]interface{}{"UserId": userId, "ExpiresAt": model.GetMillis()}); err != nil {
- result.Err = model.NewAppError("SqlSessionStore.CleanUpExpiredSessions", "We encounted an error while deleting expired user sessions", err.Error())
+ result.Err = model.NewAppError("SqlSessionStore.CleanUpExpiredSessions", "We encountered an error while deleting expired user sessions", err.Error())
} else {
result.Data = userId
}
diff --git a/store/sql_store.go b/store/sql_store.go
index d5c84d522..e5c540e06 100644
--- a/store/sql_store.go
+++ b/store/sql_store.go
@@ -121,7 +121,10 @@ func NewSqlStore() Store {
sqlStore.webhook = NewSqlWebhookStore(sqlStore)
sqlStore.preference = NewSqlPreferenceStore(sqlStore)
- sqlStore.master.CreateTablesIfNotExists()
+ err := sqlStore.master.CreateTablesIfNotExists()
+ if err != nil {
+ l4g.Critical("Error creating database tables: %v", err)
+ }
sqlStore.team.(*SqlTeamStore).UpgradeSchemaIfNeeded()
sqlStore.channel.(*SqlChannelStore).UpgradeSchemaIfNeeded()
@@ -364,27 +367,26 @@ func (ss SqlStore) RemoveColumnIfExists(tableName string, columnName string) boo
return true
}
-// func (ss SqlStore) RenameColumnIfExists(tableName string, oldColumnName string, newColumnName string, colType string) bool {
-
-// // XXX TODO FIXME this should be removed after 0.6.0
-// if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES {
-// return false
-// }
-
-// if !ss.DoesColumnExist(tableName, oldColumnName) {
-// return false
-// }
+func (ss SqlStore) RenameColumnIfExists(tableName string, oldColumnName string, newColumnName string, colType string) bool {
+ if !ss.DoesColumnExist(tableName, oldColumnName) {
+ return false
+ }
-// _, err := ss.GetMaster().Exec("ALTER TABLE " + tableName + " CHANGE " + oldColumnName + " " + newColumnName + " " + colType)
+ var err error
+ if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_MYSQL {
+ _, err = ss.GetMaster().Exec("ALTER TABLE " + tableName + " CHANGE " + oldColumnName + " " + newColumnName + " " + colType)
+ } else if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES {
+ _, err = ss.GetMaster().Exec("ALTER TABLE " + tableName + " RENAME COLUMN " + oldColumnName + " TO " + newColumnName)
+ }
-// if err != nil {
-// l4g.Critical("Failed to rename column %v", err)
-// time.Sleep(time.Second)
-// panic("Failed to drop column " + err.Error())
-// }
+ if err != nil {
+ l4g.Critical("Failed to rename column %v", err)
+ time.Sleep(time.Second)
+ panic("Failed to drop column " + err.Error())
+ }
-// return true
-// }
+ return true
+}
func (ss SqlStore) CreateIndexIfNotExists(indexName string, tableName string, columnName string) {
ss.createIndexIfNotExists(indexName, tableName, columnName, INDEX_TYPE_DEFAULT)
diff --git a/store/sql_system_store.go b/store/sql_system_store.go
index a4cb52d4d..1fbdfb333 100644
--- a/store/sql_system_store.go
+++ b/store/sql_system_store.go
@@ -37,7 +37,7 @@ func (s SqlSystemStore) Save(system *model.System) StoreChannel {
result := StoreResult{}
if err := s.GetMaster().Insert(system); err != nil {
- result.Err = model.NewAppError("SqlSystemStore.Save", "We encounted an error saving the system property", "")
+ result.Err = model.NewAppError("SqlSystemStore.Save", "We encountered an error saving the system property", "")
}
storeChannel <- result
@@ -55,7 +55,7 @@ func (s SqlSystemStore) Update(system *model.System) StoreChannel {
result := StoreResult{}
if _, err := s.GetMaster().Update(system); err != nil {
- result.Err = model.NewAppError("SqlSystemStore.Save", "We encounted an error updating the system property", "")
+ result.Err = model.NewAppError("SqlSystemStore.Save", "We encountered an error updating the system property", "")
}
storeChannel <- result
@@ -75,7 +75,7 @@ func (s SqlSystemStore) Get() StoreChannel {
var systems []model.System
props := make(model.StringMap)
if _, err := s.GetReplica().Select(&systems, "SELECT * FROM Systems"); err != nil {
- result.Err = model.NewAppError("SqlSystemStore.Get", "We encounted an error finding the system properties", "")
+ result.Err = model.NewAppError("SqlSystemStore.Get", "We encountered an error finding the system properties", "")
} else {
for _, prop := range systems {
props[prop.Name] = prop.Value
diff --git a/store/sql_team_store.go b/store/sql_team_store.go
index 8700a9d04..dfc07d3d8 100644
--- a/store/sql_team_store.go
+++ b/store/sql_team_store.go
@@ -23,6 +23,7 @@ func NewSqlTeamStore(sqlStore *SqlStore) TeamStore {
table.ColMap("Email").SetMaxSize(128)
table.ColMap("CompanyName").SetMaxSize(64)
table.ColMap("AllowedDomains").SetMaxSize(500)
+ table.ColMap("InviteId").SetMaxSize(32)
}
return s
@@ -31,10 +32,14 @@ func NewSqlTeamStore(sqlStore *SqlStore) TeamStore {
func (s SqlTeamStore) UpgradeSchemaIfNeeded() {
// REMOVE AFTER 1.2 SHIP see PLT-828
s.RemoveColumnIfExists("Teams", "AllowValet")
+ s.CreateColumnIfNotExists("Teams", "InviteId", "varchar(26)", "varchar(26)", "")
+ s.CreateColumnIfNotExists("Teams", "AllowOpenInvite", "tinyint(1)", "boolean", "0")
+ s.CreateColumnIfNotExists("Teams", "AllowTeamListing", "tinyint(1)", "boolean", "0")
}
func (s SqlTeamStore) CreateIndexesIfNotExists() {
s.CreateIndexIfNotExists("idx_teams_name", "Teams", "Name")
+ s.CreateIndexIfNotExists("idx_teams_invite_id", "Teams", "InviteId")
}
func (s SqlTeamStore) Save(team *model.Team) StoreChannel {
@@ -92,16 +97,17 @@ func (s SqlTeamStore) Update(team *model.Team) StoreChannel {
}
if oldResult, err := s.GetMaster().Get(model.Team{}, team.Id); err != nil {
- result.Err = model.NewAppError("SqlTeamStore.Update", "We encounted an error finding the team", "id="+team.Id+", "+err.Error())
+ result.Err = model.NewAppError("SqlTeamStore.Update", "We encountered an error finding the team", "id="+team.Id+", "+err.Error())
} else if oldResult == nil {
result.Err = model.NewAppError("SqlTeamStore.Update", "We couldn't find the existing team to update", "id="+team.Id)
} else {
oldTeam := oldResult.(*model.Team)
team.CreateAt = oldTeam.CreateAt
+ team.UpdateAt = model.GetMillis()
team.Name = oldTeam.Name
if count, err := s.GetMaster().Update(team); err != nil {
- result.Err = model.NewAppError("SqlTeamStore.Update", "We encounted an error updating the team", "id="+team.Id+", "+err.Error())
+ result.Err = model.NewAppError("SqlTeamStore.Update", "We encountered an error updating the team", "id="+team.Id+", "+err.Error())
} else if count != 1 {
result.Err = model.NewAppError("SqlTeamStore.Update", "We couldn't update the team", "id="+team.Id)
} else {
@@ -143,11 +149,16 @@ func (s SqlTeamStore) Get(id string) StoreChannel {
result := StoreResult{}
if obj, err := s.GetReplica().Get(model.Team{}, id); err != nil {
- result.Err = model.NewAppError("SqlTeamStore.Get", "We encounted an error finding the team", "id="+id+", "+err.Error())
+ result.Err = model.NewAppError("SqlTeamStore.Get", "We encountered an error finding the team", "id="+id+", "+err.Error())
} else if obj == nil {
result.Err = model.NewAppError("SqlTeamStore.Get", "We couldn't find the existing team", "id="+id)
} else {
- result.Data = obj.(*model.Team)
+ team := obj.(*model.Team)
+ if len(team.InviteId) == 0 {
+ team.InviteId = team.Id
+ }
+
+ result.Data = team
}
storeChannel <- result
@@ -157,6 +168,35 @@ func (s SqlTeamStore) Get(id string) StoreChannel {
return storeChannel
}
+func (s SqlTeamStore) GetByInviteId(inviteId string) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ team := model.Team{}
+
+ if err := s.GetReplica().SelectOne(&team, "SELECT * FROM Teams WHERE Id = :InviteId OR InviteId = :InviteId", map[string]interface{}{"InviteId": inviteId}); err != nil {
+ result.Err = model.NewAppError("SqlTeamStore.GetByInviteId", "We couldn't find the existing team", "inviteId="+inviteId+", "+err.Error())
+ }
+
+ if len(team.InviteId) == 0 {
+ team.InviteId = team.Id
+ }
+
+ if len(inviteId) == 0 || team.InviteId != inviteId {
+ result.Err = model.NewAppError("SqlTeamStore.GetByInviteId", "We couldn't find the existing team", "inviteId="+inviteId)
+ }
+
+ result.Data = &team
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
func (s SqlTeamStore) GetByName(name string) StoreChannel {
storeChannel := make(StoreChannel)
@@ -169,6 +209,10 @@ func (s SqlTeamStore) GetByName(name string) StoreChannel {
result.Err = model.NewAppError("SqlTeamStore.GetByName", "We couldn't find the existing team", "name="+name+", "+err.Error())
}
+ if len(team.InviteId) == 0 {
+ team.InviteId = team.Id
+ }
+
result.Data = &team
storeChannel <- result
@@ -186,7 +230,13 @@ func (s SqlTeamStore) GetTeamsForEmail(email string) StoreChannel {
var data []*model.Team
if _, err := s.GetReplica().Select(&data, "SELECT Teams.* FROM Teams, Users WHERE Teams.Id = Users.TeamId AND Users.Email = :Email", map[string]interface{}{"Email": email}); err != nil {
- result.Err = model.NewAppError("SqlTeamStore.GetTeamsForEmail", "We encounted a problem when looking up teams", "email="+email+", "+err.Error())
+ result.Err = model.NewAppError("SqlTeamStore.GetTeamsForEmail", "We encountered a problem when looking up teams", "email="+email+", "+err.Error())
+ }
+
+ for _, team := range data {
+ if len(team.InviteId) == 0 {
+ team.InviteId = team.Id
+ }
}
result.Data = data
@@ -209,6 +259,44 @@ func (s SqlTeamStore) GetAll() StoreChannel {
result.Err = model.NewAppError("SqlTeamStore.GetAllTeams", "We could not get all teams", err.Error())
}
+ for _, team := range data {
+ if len(team.InviteId) == 0 {
+ team.InviteId = team.Id
+ }
+ }
+
+ result.Data = data
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
+func (s SqlTeamStore) GetAllTeamListing() StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ query := "SELECT * FROM Teams WHERE AllowTeamListing = 1"
+
+ if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES {
+ query = "SELECT * FROM Teams WHERE AllowTeamListing = true"
+ }
+
+ var data []*model.Team
+ if _, err := s.GetReplica().Select(&data, query); err != nil {
+ result.Err = model.NewAppError("SqlTeamStore.GetAllTeams", "We could not get all teams", err.Error())
+ }
+
+ for _, team := range data {
+ if len(team.InviteId) == 0 {
+ team.InviteId = team.Id
+ }
+ }
+
result.Data = data
storeChannel <- result
diff --git a/store/sql_team_store_test.go b/store/sql_team_store_test.go
index 3d9b4d435..71740f7e7 100644
--- a/store/sql_team_store_test.go
+++ b/store/sql_team_store_test.go
@@ -132,6 +132,54 @@ func TestTeamStoreGetByName(t *testing.T) {
}
}
+func TestTeamStoreGetByIniviteId(t *testing.T) {
+ Setup()
+
+ o1 := model.Team{}
+ o1.DisplayName = "DisplayName"
+ o1.Name = "a" + model.NewId() + "b"
+ o1.Email = model.NewId() + "@nowhere.com"
+ o1.Type = model.TEAM_OPEN
+ o1.InviteId = model.NewId()
+
+ if err := (<-store.Team().Save(&o1)).Err; err != nil {
+ t.Fatal(err)
+ }
+
+ o2 := model.Team{}
+ o2.DisplayName = "DisplayName"
+ o2.Name = "a" + model.NewId() + "b"
+ o2.Email = model.NewId() + "@nowhere.com"
+ o2.Type = model.TEAM_OPEN
+
+ if err := (<-store.Team().Save(&o2)).Err; err != nil {
+ t.Fatal(err)
+ }
+
+ if r1 := <-store.Team().GetByInviteId(o1.InviteId); r1.Err != nil {
+ t.Fatal(r1.Err)
+ } else {
+ if r1.Data.(*model.Team).ToJson() != o1.ToJson() {
+ t.Fatal("invalid returned team")
+ }
+ }
+
+ o2.InviteId = ""
+ <-store.Team().Update(&o2)
+
+ if r1 := <-store.Team().GetByInviteId(o2.Id); r1.Err != nil {
+ t.Fatal(r1.Err)
+ } else {
+ if r1.Data.(*model.Team).Id != o2.Id {
+ t.Fatal("invalid returned team")
+ }
+ }
+
+ if err := (<-store.Team().GetByInviteId("")).Err; err == nil {
+ t.Fatal("Missing id should have failed")
+ }
+}
+
func TestTeamStoreGetForEmail(t *testing.T) {
Setup()
@@ -161,3 +209,32 @@ func TestTeamStoreGetForEmail(t *testing.T) {
t.Fatal(r1.Err)
}
}
+
+func TestAllTeamListing(t *testing.T) {
+ Setup()
+
+ o1 := model.Team{}
+ o1.DisplayName = "DisplayName"
+ o1.Name = "a" + model.NewId() + "b"
+ o1.Email = model.NewId() + "@nowhere.com"
+ o1.Type = model.TEAM_OPEN
+ o1.AllowTeamListing = true
+ Must(store.Team().Save(&o1))
+
+ o2 := model.Team{}
+ o2.DisplayName = "DisplayName"
+ o2.Name = "a" + model.NewId() + "b"
+ o2.Email = model.NewId() + "@nowhere.com"
+ o2.Type = model.TEAM_OPEN
+ Must(store.Team().Save(&o2))
+
+ if r1 := <-store.Team().GetAllTeamListing(); r1.Err != nil {
+ t.Fatal(r1.Err)
+ } else {
+ teams := r1.Data.([]*model.Team)
+
+ if len(teams) == 0 {
+ t.Fatal("failed team listing")
+ }
+ }
+}
diff --git a/store/sql_user_store.go b/store/sql_user_store.go
index d825cda57..3347df08b 100644
--- a/store/sql_user_store.go
+++ b/store/sql_user_store.go
@@ -118,7 +118,7 @@ func (us SqlUserStore) Update(user *model.User, allowActiveUpdate bool) StoreCha
}
if oldUserResult, err := us.GetMaster().Get(model.User{}, user.Id); err != nil {
- result.Err = model.NewAppError("SqlUserStore.Update", "We encounted an error finding the account", "user_id="+user.Id+", "+err.Error())
+ result.Err = model.NewAppError("SqlUserStore.Update", "We encountered an error finding the account", "user_id="+user.Id+", "+err.Error())
} else if oldUserResult == nil {
result.Err = model.NewAppError("SqlUserStore.Update", "We couldn't find the existing account to update", "user_id="+user.Id)
} else {
@@ -161,7 +161,7 @@ func (us SqlUserStore) Update(user *model.User, allowActiveUpdate bool) StoreCha
} else if IsUniqueConstraintError(err.Error(), "Username", "users_username_teamid_key") {
result.Err = model.NewAppError("SqlUserStore.Update", "This username is already taken. Please choose another.", "user_id="+user.Id+", "+err.Error())
} else {
- result.Err = model.NewAppError("SqlUserStore.Update", "We encounted an error updating the account", "user_id="+user.Id+", "+err.Error())
+ result.Err = model.NewAppError("SqlUserStore.Update", "We encountered an error updating the account", "user_id="+user.Id+", "+err.Error())
}
} else if count != 1 {
result.Err = model.NewAppError("SqlUserStore.Update", "We couldn't update the account", fmt.Sprintf("user_id=%v, count=%v", user.Id, count))
@@ -306,7 +306,7 @@ func (us SqlUserStore) Get(id string) StoreChannel {
result := StoreResult{}
if obj, err := us.GetReplica().Get(model.User{}, id); err != nil {
- result.Err = model.NewAppError("SqlUserStore.Get", "We encounted an error finding the account", "user_id="+id+", "+err.Error())
+ result.Err = model.NewAppError("SqlUserStore.Get", "We encountered an error finding the account", "user_id="+id+", "+err.Error())
} else if obj == nil {
result.Err = model.NewAppError("SqlUserStore.Get", "We couldn't find the existing account", "user_id="+id)
} else {
@@ -351,7 +351,7 @@ func (us SqlUserStore) GetProfiles(teamId string) StoreChannel {
var users []*model.User
if _, err := us.GetReplica().Select(&users, "SELECT * FROM Users WHERE TeamId = :TeamId", map[string]interface{}{"TeamId": teamId}); err != nil {
- result.Err = model.NewAppError("SqlUserStore.GetProfiles", "We encounted an error while finding user profiles", err.Error())
+ result.Err = model.NewAppError("SqlUserStore.GetProfiles", "We encountered an error while finding user profiles", err.Error())
} else {
userMap := make(map[string]*model.User)
@@ -382,7 +382,7 @@ func (us SqlUserStore) GetSystemAdminProfiles() StoreChannel {
var users []*model.User
if _, err := us.GetReplica().Select(&users, "SELECT * FROM Users WHERE Roles = :Roles", map[string]interface{}{"Roles": "system_admin"}); err != nil {
- result.Err = model.NewAppError("SqlUserStore.GetSystemAdminProfiles", "We encounted an error while finding user profiles", err.Error())
+ result.Err = model.NewAppError("SqlUserStore.GetSystemAdminProfiles", "We encountered an error while finding user profiles", err.Error())
} else {
userMap := make(map[string]*model.User)
@@ -498,7 +498,7 @@ func (us SqlUserStore) GetForExport(teamId string) StoreChannel {
var users []*model.User
if _, err := us.GetReplica().Select(&users, "SELECT * FROM Users WHERE TeamId = :TeamId", map[string]interface{}{"TeamId": teamId}); err != nil {
- result.Err = model.NewAppError("SqlUserStore.GetProfiles", "We encounted an error while finding user profiles", err.Error())
+ result.Err = model.NewAppError("SqlUserStore.GetProfiles", "We encountered an error while finding user profiles", err.Error())
} else {
for _, u := range users {
u.Password = ""
diff --git a/store/store.go b/store/store.go
index 42329b036..53a6e053b 100644
--- a/store/store.go
+++ b/store/store.go
@@ -50,6 +50,8 @@ type TeamStore interface {
GetByName(name string) StoreChannel
GetTeamsForEmail(domain string) StoreChannel
GetAll() StoreChannel
+ GetAllTeamListing() StoreChannel
+ GetByInviteId(inviteId string) StoreChannel
}
type ChannelStore interface {
diff --git a/utils/config.go b/utils/config.go
index 6b34c76ed..13b7b6b64 100644
--- a/utils/config.go
+++ b/utils/config.go
@@ -190,6 +190,7 @@ func getClientConfig(c *model.Config) map[string]string {
props["SiteName"] = c.TeamSettings.SiteName
props["EnableTeamCreation"] = strconv.FormatBool(c.TeamSettings.EnableTeamCreation)
props["RestrictTeamNames"] = strconv.FormatBool(*c.TeamSettings.RestrictTeamNames)
+ props["EnableTeamListing"] = strconv.FormatBool(*c.TeamSettings.EnableTeamListing)
props["EnableOAuthServiceProvider"] = strconv.FormatBool(c.ServiceSettings.EnableOAuthServiceProvider)
diff --git a/web/react/components/access_history_modal.jsx b/web/react/components/access_history_modal.jsx
index c8af2553d..f0a31ce90 100644
--- a/web/react/components/access_history_modal.jsx
+++ b/web/react/components/access_history_modal.jsx
@@ -90,8 +90,9 @@ export default class AccessHistoryModal extends React.Component {
case '/channels/update':
currentAuditDesc = 'Updated the ' + channelName + ' channel/group name';
break;
- case '/channels/update_desc':
- currentAuditDesc = 'Updated the ' + channelName + ' channel/group description';
+ case '/channels/update_desc': // support the old path
+ case '/channels/update_header':
+ currentAuditDesc = 'Updated the ' + channelName + ' channel/group header';
break;
default:
let userIdField = [];
diff --git a/web/react/components/admin_console/gitlab_settings.jsx b/web/react/components/admin_console/gitlab_settings.jsx
index 8b0f00083..f8fb6d115 100644
--- a/web/react/components/admin_console/gitlab_settings.jsx
+++ b/web/react/components/admin_console/gitlab_settings.jsx
@@ -259,7 +259,6 @@ export default class GitLabSettings extends React.Component {
}
}
-
//config.GitLabSettings.Scope = ReactDOM.findDOMNode(this.refs.Scope).value.trim();
// <div className='form-group'>
// <label
diff --git a/web/react/components/admin_console/team_analytics.jsx b/web/react/components/admin_console/team_analytics.jsx
index dd8812ad0..0c9d1f61b 100644
--- a/web/react/components/admin_console/team_analytics.jsx
+++ b/web/react/components/admin_console/team_analytics.jsx
@@ -56,6 +56,8 @@ export default class TeamAnalytics extends React.Component {
teamId,
'post_counts_day',
(data) => {
+ data.reverse();
+
var chartData = {
labels: [],
datasets: [{
@@ -89,6 +91,8 @@ export default class TeamAnalytics extends React.Component {
teamId,
'user_counts_with_posts_day',
(data) => {
+ data.reverse();
+
var chartData = {
labels: [],
datasets: [{
@@ -206,69 +210,89 @@ export default class TeamAnalytics extends React.Component {
}
var totalCount = (
- <div className='total-count text-center'>
- <div>{'Total Users'}</div>
- <div>{this.state.users == null ? 'Loading...' : Object.keys(this.state.users).length}</div>
+ <div className='col-sm-3'>
+ <div className='total-count'>
+ <div className='title'>{'Total Users'}<i className='fa fa-users'/></div>
+ <div className='content'>{this.state.users == null ? 'Loading...' : Object.keys(this.state.users).length}</div>
+ </div>
</div>
);
var openChannelCount = (
- <div className='total-count text-center'>
- <div>{'Public Groups'}</div>
- <div>{this.state.channel_open_count == null ? 'Loading...' : this.state.channel_open_count}</div>
+ <div className='col-sm-3'>
+ <div className='total-count'>
+ <div className='title'>{'Public Groups'}<i className='fa fa-unlock-alt'/></div>
+ <div className='content'>{this.state.channel_open_count == null ? 'Loading...' : this.state.channel_open_count}</div>
+ </div>
</div>
);
var openPrivateCount = (
- <div className='total-count text-center'>
- <div>{'Private Groups'}</div>
- <div>{this.state.channel_private_count == null ? 'Loading...' : this.state.channel_private_count}</div>
+ <div className='col-sm-3'>
+ <div className='total-count'>
+ <div className='title'>{'Private Groups'}<i className='fa fa-lock'/></div>
+ <div className='content'>{this.state.channel_private_count == null ? 'Loading...' : this.state.channel_private_count}</div>
+ </div>
</div>
);
var postCount = (
- <div className='total-count text-center'>
- <div>{'Total Posts'}</div>
- <div>{this.state.post_count == null ? 'Loading...' : this.state.post_count}</div>
+ <div className='col-sm-3'>
+ <div className='total-count'>
+ <div className='title'>{'Total Posts'}<i className='fa fa-comment'/></div>
+ <div className='content'>{this.state.post_count == null ? 'Loading...' : this.state.post_count}</div>
+ </div>
</div>
);
var postCountsByDay = (
- <div className='total-count-by-day'>
- <div>{'Total Posts'}</div>
- <div>{'Loading...'}</div>
+ <div className='col-sm-12'>
+ <div className='total-count by-day'>
+ <div className='title'>{'Total Posts'}</div>
+ <div className='content'>{'Loading...'}</div>
+ </div>
</div>
);
if (this.state.post_counts_day != null) {
postCountsByDay = (
- <div className='total-count-by-day'>
- <div>{'Total Posts'}</div>
- <LineChart
- data={this.state.post_counts_day}
- width='740'
- height='225'
- />
+ <div className='col-sm-12'>
+ <div className='total-count by-day'>
+ <div className='title'>{'Total Posts'}</div>
+ <div className='content'>
+ <LineChart
+ data={this.state.post_counts_day}
+ width='740'
+ height='225'
+ />
+ </div>
+ </div>
</div>
);
}
var usersWithPostsByDay = (
- <div className='total-count-by-day'>
- <div>{'Total Posts'}</div>
- <div>{'Loading...'}</div>
+ <div className='col-sm-12'>
+ <div className='total-count by-day'>
+ <div className='title'>{'Total Posts'}</div>
+ <div>{'Loading...'}</div>
+ </div>
</div>
);
if (this.state.user_counts_with_posts_day != null) {
usersWithPostsByDay = (
- <div className='total-count-by-day'>
- <div>{'Active Users With Posts'}</div>
- <LineChart
- data={this.state.user_counts_with_posts_day}
- width='740'
- height='225'
- />
+ <div className='col-sm-12'>
+ <div className='total-count by-day'>
+ <div className='title'>{'Active Users With Posts'}</div>
+ <div className='content'>
+ <LineChart
+ data={this.state.user_counts_with_posts_day}
+ width='740'
+ height='225'
+ />
+ </div>
+ </div>
</div>
);
}
@@ -282,22 +306,26 @@ export default class TeamAnalytics extends React.Component {
if (this.state.recent_active_users != null) {
recentActiveUser = (
- <div className='recent-active-users'>
- <div>{'Recent Active Users'}</div>
- <table width='90%'>
- <tbody>
- {
- this.state.recent_active_users.map((user) => {
- return (
- <tr key={user.id}>
- <td className='recent-active-users-td'>{user.email}</td>
- <td className='recent-active-users-td'>{Utils.displayDateTime(user.last_activity_at)}</td>
- </tr>
- );
- })
- }
- </tbody>
- </table>
+ <div className='col-sm-6'>
+ <div className='total-count recent-active-users'>
+ <div className='title'>{'Recent Active Users'}</div>
+ <div className='content'>
+ <table>
+ <tbody>
+ {
+ this.state.recent_active_users.map((user) => {
+ return (
+ <tr key={user.id}>
+ <td>{user.email}</td>
+ <td>{Utils.displayDateTime(user.last_activity_at)}</td>
+ </tr>
+ );
+ })
+ }
+ </tbody>
+ </table>
+ </div>
+ </div>
</div>
);
}
@@ -311,38 +339,50 @@ export default class TeamAnalytics extends React.Component {
if (this.state.newly_created_users != null) {
newUsers = (
- <div className='recent-active-users'>
- <div>{'Newly Created Users'}</div>
- <table width='90%'>
- <tbody>
- {
- this.state.newly_created_users.map((user) => {
- return (
- <tr key={user.id}>
- <td className='recent-active-users-td'>{user.email}</td>
- <td className='recent-active-users-td'>{Utils.displayDateTime(user.create_at)}</td>
- </tr>
- );
- })
- }
- </tbody>
- </table>
+ <div className='col-sm-6'>
+ <div className='total-count recent-active-users'>
+ <div className='title'>{'Newly Created Users'}</div>
+ <div className='content'>
+ <table>
+ <tbody>
+ {
+ this.state.newly_created_users.map((user) => {
+ return (
+ <tr key={user.id}>
+ <td>{user.email}</td>
+ <td>{Utils.displayDateTime(user.create_at)}</td>
+ </tr>
+ );
+ })
+ }
+ </tbody>
+ </table>
+ </div>
+ </div>
</div>
);
}
return (
- <div className='wrapper--fixed'>
- <h2>{'Statistics for ' + this.props.team.name}</h2>
+ <div className='wrapper--fixed team_statistics'>
+ <h3>{'Statistics for ' + this.props.team.name}</h3>
{serverError}
- {totalCount}
- {postCount}
- {openChannelCount}
- {openPrivateCount}
- {postCountsByDay}
- {usersWithPostsByDay}
- {recentActiveUser}
- {newUsers}
+ <div className='row'>
+ {totalCount}
+ {postCount}
+ {openChannelCount}
+ {openPrivateCount}
+ </div>
+ <div className='row'>
+ {postCountsByDay}
+ </div>
+ <div className='row'>
+ {usersWithPostsByDay}
+ </div>
+ <div className='row'>
+ {recentActiveUser}
+ {newUsers}
+ </div>
</div>
);
}
diff --git a/web/react/components/admin_console/team_settings.jsx b/web/react/components/admin_console/team_settings.jsx
index 9ecd14a1e..6587184ea 100644
--- a/web/react/components/admin_console/team_settings.jsx
+++ b/web/react/components/admin_console/team_settings.jsx
@@ -32,6 +32,7 @@ export default class TeamSettings extends React.Component {
config.TeamSettings.EnableTeamCreation = ReactDOM.findDOMNode(this.refs.EnableTeamCreation).checked;
config.TeamSettings.EnableUserCreation = ReactDOM.findDOMNode(this.refs.EnableUserCreation).checked;
config.TeamSettings.RestrictTeamNames = ReactDOM.findDOMNode(this.refs.RestrictTeamNames).checked;
+ config.TeamSettings.EnableTeamListing = ReactDOM.findDOMNode(this.refs.EnableTeamListing).checked;
var MaxUsersPerTeam = 50;
if (!isNaN(parseInt(ReactDOM.findDOMNode(this.refs.MaxUsersPerTeam).value, 10))) {
@@ -243,6 +244,39 @@ export default class TeamSettings extends React.Component {
</div>
<div className='form-group'>
+ <label
+ className='control-label col-sm-4'
+ htmlFor='EnableTeamListing'
+ >
+ {'Enable Team Directory: '}
+ </label>
+ <div className='col-sm-8'>
+ <label className='radio-inline'>
+ <input
+ type='radio'
+ name='EnableTeamListing'
+ value='true'
+ ref='EnableTeamListing'
+ defaultChecked={this.props.config.TeamSettings.EnableTeamListing}
+ onChange={this.handleChange}
+ />
+ {'true'}
+ </label>
+ <label className='radio-inline'>
+ <input
+ type='radio'
+ name='EnableTeamListing'
+ value='false'
+ defaultChecked={!this.props.config.TeamSettings.EnableTeamListing}
+ onChange={this.handleChange}
+ />
+ {'false'}
+ </label>
+ <p className='help-text'>{'When true, teams that are configured to show in team directory will show on main page inplace of creating a new team.'}</p>
+ </div>
+ </div>
+
+ <div className='form-group'>
<div className='col-sm-12'>
{serverError}
<button
diff --git a/web/react/components/admin_console/team_users.jsx b/web/react/components/admin_console/team_users.jsx
index ffb412159..b44aba56e 100644
--- a/web/react/components/admin_console/team_users.jsx
+++ b/web/react/components/admin_console/team_users.jsx
@@ -33,14 +33,6 @@ export default class UserList extends React.Component {
this.getTeamProfiles(this.props.team.id);
}
- // this.setState({
- // teamId: this.state.teamId,
- // users: this.state.users,
- // serverError: this.state.serverError,
- // showPasswordModal: this.state.showPasswordModal,
- // user: this.state.user
- // });
-
getTeamProfiles(teamId) {
Client.getProfilesForTeam(
teamId,
@@ -95,8 +87,6 @@ export default class UserList extends React.Component {
}
doPasswordResetDismiss() {
- this.state.showPasswordModal = false;
- this.state.user = null;
this.setState({
teamId: this.state.teamId,
users: this.state.users,
diff --git a/web/react/components/channel_header.jsx b/web/react/components/channel_header.jsx
index d66777cc6..101fd85e5 100644
--- a/web/react/components/channel_header.jsx
+++ b/web/react/components/channel_header.jsx
@@ -11,6 +11,7 @@ const TextFormatting = require('../utils/text_formatting.jsx');
const Utils = require('../utils/utils.jsx');
const MessageWrapper = require('./message_wrapper.jsx');
const PopoverListMembers = require('./popover_list_members.jsx');
+const EditChannelPurposeModal = require('./edit_channel_purpose_modal.jsx');
const AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
const Constants = require('../utils/constants.jsx');
@@ -27,7 +28,9 @@ export default class ChannelHeader extends React.Component {
this.handleLeave = this.handleLeave.bind(this);
this.searchMentions = this.searchMentions.bind(this);
- this.state = this.getStateFromStores();
+ const state = this.getStateFromStores();
+ state.showEditChannelPurposeModal = false;
+ this.state = state;
}
getStateFromStores() {
return {
@@ -110,11 +113,11 @@ export default class ChannelHeader extends React.Component {
bSize='large'
placement='bottom'
className='description'
- onMouseOver={() => this.refs.descriptionOverlay.show()}
- onMouseOut={() => this.refs.descriptionOverlay.hide()}
+ onMouseOver={() => this.refs.headerOverlay.show()}
+ onMouseOut={() => this.refs.headerOverlay.hide()}
>
<MessageWrapper
- message={channel.description}
+ message={channel.header}
/>
</Popover>
);
@@ -144,7 +147,7 @@ export default class ChannelHeader extends React.Component {
if (isDirect) {
dropdownContents.push(
<li
- key='edit_description_direct'
+ key='edit_header_direct'
role='presentation'
>
<a
@@ -152,11 +155,11 @@ export default class ChannelHeader extends React.Component {
href='#'
data-toggle='modal'
data-target='#edit_channel'
- data-desc={channel.description}
+ data-header={channel.header}
data-title={channel.display_name}
data-channelid={channel.id}
>
- Set Channel Description...
+ Set Channel Header...
</a>
</li>
);
@@ -216,7 +219,7 @@ export default class ChannelHeader extends React.Component {
dropdownContents.push(
<li
- key='set_channel_description'
+ key='set_channel_header'
role='presentation'
>
<a
@@ -224,11 +227,25 @@ export default class ChannelHeader extends React.Component {
href='#'
data-toggle='modal'
data-target='#edit_channel'
- data-desc={channel.description}
+ data-header={channel.header}
data-title={channel.display_name}
data-channelid={channel.id}
>
- Set {channelTerm} Description...
+ Set {channelTerm} Header...
+ </a>
+ </li>
+ );
+ dropdownContents.push(
+ <li
+ key='set_channel_purpose'
+ role='presentation'
+ >
+ <a
+ role='menuitem'
+ href='#'
+ onClick={() => this.setState({showEditChannelPurposeModal: true})}
+ >
+ Set {channelTerm} Purpose...
</a>
</li>
);
@@ -307,84 +324,91 @@ export default class ChannelHeader extends React.Component {
}
return (
- <table className='channel-header alt'>
- <tbody>
- <tr>
- <th>
- <div className='channel-header__info'>
- <div className='dropdown'>
+ <div>
+ <table className='channel-header alt'>
+ <tbody>
+ <tr>
+ <th>
+ <div className='channel-header__info'>
+ <div className='dropdown'>
+ <a
+ href='#'
+ className='dropdown-toggle theme'
+ type='button'
+ id='channel_header_dropdown'
+ data-toggle='dropdown'
+ aria-expanded='true'
+ >
+ <strong className='heading'>{channelTitle} </strong>
+ <span className='glyphicon glyphicon-chevron-down header-dropdown__icon' />
+ </a>
+ <ul
+ className='dropdown-menu'
+ role='menu'
+ aria-labelledby='channel_header_dropdown'
+ >
+ {dropdownContents}
+ </ul>
+ </div>
+ <OverlayTrigger
+ trigger={['hover', 'focus']}
+ placement='bottom'
+ overlay={popoverContent}
+ ref='headerOverlay'
+ >
+ <div
+ onClick={TextFormatting.handleClick}
+ className='description'
+ dangerouslySetInnerHTML={{__html: TextFormatting.formatText(channel.header, {singleline: true, mentionHighlight: false})}}
+ />
+ </OverlayTrigger>
+ </div>
+ </th>
+ <th>
+ <PopoverListMembers
+ members={this.state.users}
+ channelId={channel.id}
+ />
+ </th>
+ <th className='search-bar__container'><NavbarSearchBox /></th>
+ <th>
+ <div className='dropdown channel-header__links'>
<a
href='#'
className='dropdown-toggle theme'
type='button'
- id='channel_header_dropdown'
+ id='channel_header_right_dropdown'
data-toggle='dropdown'
aria-expanded='true'
>
- <strong className='heading'>{channelTitle} </strong>
- <span className='glyphicon glyphicon-chevron-down header-dropdown__icon' />
+ <span dangerouslySetInnerHTML={{__html: Constants.MENU_ICON}} />
</a>
<ul
- className='dropdown-menu'
+ className='dropdown-menu dropdown-menu-right'
role='menu'
- aria-labelledby='channel_header_dropdown'
+ aria-labelledby='channel_header_right_dropdown'
>
- {dropdownContents}
+ <li role='presentation'>
+ <a
+ role='menuitem'
+ href='#'
+ onClick={this.searchMentions}
+ >
+ Recent Mentions
+ </a>
+ </li>
</ul>
</div>
- <OverlayTrigger
- trigger={['hover', 'focus']}
- placement='bottom'
- overlay={popoverContent}
- ref='descriptionOverlay'
- >
- <div
- onClick={TextFormatting.handleClick}
- className='description'
- dangerouslySetInnerHTML={{__html: TextFormatting.formatText(channel.description, {singleline: true, mentionHighlight: false})}}
- />
- </OverlayTrigger>
- </div>
- </th>
- <th>
- <PopoverListMembers
- members={this.state.users}
- channelId={channel.id}
- />
- </th>
- <th className='search-bar__container'><NavbarSearchBox /></th>
- <th>
- <div className='dropdown channel-header__links'>
- <a
- href='#'
- className='dropdown-toggle theme'
- type='button'
- id='channel_header_right_dropdown'
- data-toggle='dropdown'
- aria-expanded='true'
- >
- <span dangerouslySetInnerHTML={{__html: Constants.MENU_ICON}} />
- </a>
- <ul
- className='dropdown-menu dropdown-menu-right'
- role='menu'
- aria-labelledby='channel_header_right_dropdown'
- >
- <li role='presentation'>
- <a
- role='menuitem'
- href='#'
- onClick={this.searchMentions}
- >
- Recent Mentions
- </a>
- </li>
- </ul>
- </div>
- </th>
- </tr>
- </tbody>
- </table>
+ </th>
+ </tr>
+ </tbody>
+ </table>
+ <EditChannelPurposeModal
+ show={this.state.showEditChannelPurposeModal}
+ onModalDismissed={() => this.setState({showEditChannelPurposeModal: false})}
+ channel={channel}
+ />
+ </div>
);
}
}
diff --git a/web/react/components/create_comment.jsx b/web/react/components/create_comment.jsx
index 18936e808..058594165 100644
--- a/web/react/components/create_comment.jsx
+++ b/web/react/components/create_comment.jsx
@@ -8,6 +8,7 @@ const SocketStore = require('../stores/socket_store.jsx');
const ChannelStore = require('../stores/channel_store.jsx');
const UserStore = require('../stores/user_store.jsx');
const PostStore = require('../stores/post_store.jsx');
+const PreferenceStore = require('../stores/preference_store.jsx');
const Textbox = require('./textbox.jsx');
const MsgTyping = require('./msg_typing.jsx');
const FileUpload = require('./file_upload.jsx');
@@ -27,7 +28,7 @@ export default class CreateComment extends React.Component {
this.handleSubmit = this.handleSubmit.bind(this);
this.commentMsgKeyPress = this.commentMsgKeyPress.bind(this);
this.handleUserInput = this.handleUserInput.bind(this);
- this.handleArrowUp = this.handleArrowUp.bind(this);
+ this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleUploadStart = this.handleUploadStart.bind(this);
this.handleFileUploadComplete = this.handleFileUploadComplete.bind(this);
this.handleUploadError = this.handleUploadError.bind(this);
@@ -36,6 +37,7 @@ export default class CreateComment extends React.Component {
this.handleSubmit = this.handleSubmit.bind(this);
this.getFileCount = this.getFileCount.bind(this);
this.handleResize = this.handleResize.bind(this);
+ this.onPreferenceChange = this.onPreferenceChange.bind(this);
PostStore.clearCommentDraftUploads();
@@ -45,15 +47,23 @@ export default class CreateComment extends React.Component {
uploadsInProgress: draft.uploadsInProgress,
previews: draft.previews,
submitting: false,
- windowWidth: Utils.windowWidth()
+ windowWidth: Utils.windowWidth(),
+ ctrlSend: PreferenceStore.getPreference(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter', {value: 'false'}).value
};
}
componentDidMount() {
+ PreferenceStore.addChangeListener(this.onPreferenceChange);
window.addEventListener('resize', this.handleResize);
}
componentWillUnmount() {
+ PreferenceStore.removeChangeListener(this.onPreferenceChange);
window.removeEventListener('resize', this.handleResize);
}
+ onPreferenceChange() {
+ this.setState({
+ ctrlSend: PreferenceStore.getPreference(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter', {value: 'false'}).value
+ });
+ }
handleResize() {
this.setState({windowWidth: Utils.windowWidth()});
}
@@ -140,10 +150,12 @@ export default class CreateComment extends React.Component {
this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null});
}
commentMsgKeyPress(e) {
- if (e.which === 13 && !e.shiftKey && !e.altKey) {
- e.preventDefault();
- ReactDOM.findDOMNode(this.refs.textbox).blur();
- this.handleSubmit(e);
+ if (this.state.ctrlSend === 'true' && e.ctrlKey || this.state.ctrlSend === 'false') {
+ if (e.which === KeyCodes.ENTER && !e.shiftKey && !e.altKey) {
+ e.preventDefault();
+ ReactDOM.findDOMNode(this.refs.textbox).blur();
+ this.handleSubmit(e);
+ }
}
const t = Date.now();
@@ -161,7 +173,12 @@ export default class CreateComment extends React.Component {
$('.post-right__scroll').perfectScrollbar('update');
this.setState({messageText: messageText});
}
- handleArrowUp(e) {
+ handleKeyDown(e) {
+ if (this.state.ctrlSend === 'true' && e.keyCode === KeyCodes.ENTER && e.ctrlKey === true) {
+ this.commentMsgKeyPress(e);
+ return;
+ }
+
if (e.keyCode === KeyCodes.UP && this.state.messageText === '') {
e.preventDefault();
@@ -313,7 +330,7 @@ export default class CreateComment extends React.Component {
<Textbox
onUserInput={this.handleUserInput}
onKeyPress={this.commentMsgKeyPress}
- onKeyDown={this.handleArrowUp}
+ onKeyDown={this.handleKeyDown}
messageText={this.state.messageText}
createMessage='Add a comment...'
initialText=''
diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx
index 32ee31efe..cdbc3bc6d 100644
--- a/web/react/components/create_post.jsx
+++ b/web/react/components/create_post.jsx
@@ -8,6 +8,7 @@ const ChannelStore = require('../stores/channel_store.jsx');
const PostStore = require('../stores/post_store.jsx');
const UserStore = require('../stores/user_store.jsx');
const SocketStore = require('../stores/socket_store.jsx');
+const PreferenceStore = require('../stores/preference_store.jsx');
const MsgTyping = require('./msg_typing.jsx');
const Textbox = require('./textbox.jsx');
const FileUpload = require('./file_upload.jsx');
@@ -36,9 +37,10 @@ export default class CreatePost extends React.Component {
this.removePreview = this.removePreview.bind(this);
this.onChange = this.onChange.bind(this);
this.getFileCount = this.getFileCount.bind(this);
- this.handleArrowUp = this.handleArrowUp.bind(this);
+ this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleResize = this.handleResize.bind(this);
this.sendMessage = this.sendMessage.bind(this);
+ this.onPreferenceChange = this.onPreferenceChange.bind(this);
PostStore.clearDraftUploads();
@@ -52,8 +54,16 @@ export default class CreatePost extends React.Component {
submitting: false,
initialText: draft.messageText,
windowWidth: Utils.windowWidth(),
- windowHeight: Utils.windowHeight()
+ windowHeight: Utils.windowHeight(),
+ ctrlSend: PreferenceStore.getPreference(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter', {value: 'false'}).value
};
+
+ PreferenceStore.addChangeListener(this.onPreferenceChange);
+ }
+ onPreferenceChange() {
+ this.setState({
+ ctrlSend: PreferenceStore.getPreference(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter', {value: 'false'}).value
+ });
}
handleResize() {
this.setState({
@@ -201,10 +211,12 @@ export default class CreatePost extends React.Component {
);
}
postMsgKeyPress(e) {
- if (e.which === KeyCodes.ENTER && !e.shiftKey && !e.altKey) {
- e.preventDefault();
- ReactDOM.findDOMNode(this.refs.textbox).blur();
- this.handleSubmit(e);
+ if (this.state.ctrlSend === 'true' && e.ctrlKey || this.state.ctrlSend === 'false') {
+ if (e.which === KeyCodes.ENTER && !e.shiftKey && !e.altKey) {
+ e.preventDefault();
+ ReactDOM.findDOMNode(this.refs.textbox).blur();
+ this.handleSubmit(e);
+ }
}
const t = Date.now();
@@ -328,7 +340,12 @@ export default class CreatePost extends React.Component {
const draft = PostStore.getDraft(channelId);
return draft.previews.length + draft.uploadsInProgress.length;
}
- handleArrowUp(e) {
+ handleKeyDown(e) {
+ if (this.state.ctrlSend === 'true' && e.keyCode === KeyCodes.ENTER && e.ctrlKey === true) {
+ this.postMsgKeyPress(e);
+ return;
+ }
+
if (e.keyCode === KeyCodes.UP && this.state.messageText === '') {
e.preventDefault();
@@ -393,7 +410,7 @@ export default class CreatePost extends React.Component {
<Textbox
onUserInput={this.handleUserInput}
onKeyPress={this.postMsgKeyPress}
- onKeyDown={this.handleArrowUp}
+ onKeyDown={this.handleKeyDown}
onHeightChange={this.resizePostHolder}
messageText={this.state.messageText}
createMessage='Write a message...'
diff --git a/web/react/components/edit_channel_modal.jsx b/web/react/components/edit_channel_modal.jsx
index d63a1db30..5b3c74e82 100644
--- a/web/react/components/edit_channel_modal.jsx
+++ b/web/react/components/edit_channel_modal.jsx
@@ -14,7 +14,7 @@ export default class EditChannelModal extends React.Component {
this.onShow = this.onShow.bind(this);
this.state = {
- description: '',
+ header: '',
title: '',
channelId: '',
serverError: ''
@@ -28,32 +28,32 @@ export default class EditChannelModal extends React.Component {
return;
}
- data.channel_description = this.state.description.trim();
+ data.channel_header = this.state.header.trim();
- Client.updateChannelDesc(data,
- function handleUpdateSuccess() {
+ Client.updateChannelHeader(data,
+ () => {
this.setState({serverError: ''});
AsyncClient.getChannel(this.state.channelId);
$(ReactDOM.findDOMNode(this.refs.modal)).modal('hide');
- }.bind(this),
- function handleUpdateError(err) {
- if (err.message === 'Invalid channel_description parameter') {
- this.setState({serverError: 'This description is too long, please enter a shorter one'});
+ },
+ (err) => {
+ if (err.message === 'Invalid channel_header parameter') {
+ this.setState({serverError: 'This channel header is too long, please enter a shorter one'});
} else {
this.setState({serverError: err.message});
}
- }.bind(this)
+ }
);
}
handleUserInput(e) {
- this.setState({description: e.target.value});
+ this.setState({header: e.target.value});
}
handleClose() {
- this.setState({description: '', serverError: ''});
+ this.setState({header: '', serverError: ''});
}
onShow(e) {
const button = e.relatedTarget;
- this.setState({description: $(button).attr('data-desc'), title: $(button).attr('data-title'), channelId: $(button).attr('data-channelid'), serverError: ''});
+ this.setState({header: $(button).attr('data-header'), title: $(button).attr('data-title'), channelId: $(button).attr('data-channelid'), serverError: ''});
}
componentDidMount() {
$(ReactDOM.findDOMNode(this.refs.modal)).on('show.bs.modal', this.onShow);
@@ -73,7 +73,7 @@ export default class EditChannelModal extends React.Component {
className='modal-title'
ref='title'
>
- Edit Description
+ {'Edit Header'}
</h4>
);
if (this.state.title) {
@@ -82,7 +82,7 @@ export default class EditChannelModal extends React.Component {
className='modal-title'
ref='title'
>
- Edit Description for <span className='name'>{this.state.title}</span>
+ {'Edit Header for '}<span className='name'>{this.state.title}</span>
</h4>
);
}
@@ -105,17 +105,17 @@ export default class EditChannelModal extends React.Component {
data-dismiss='modal'
aria-label='Close'
>
- <span aria-hidden='true'>&times;</span>
+ <span aria-hidden='true'>{'×'}</span>
</button>
{editTitle}
</div>
<div className='modal-body'>
+ <p>{'Edit the text appearing next to the channel name in the channel header.'}</p>
<textarea
className='form-control no-resize'
rows='6'
- ref='channelDesc'
maxLength='1024'
- value={this.state.description}
+ value={this.state.header}
onChange={this.handleUserInput}
/>
{serverError}
@@ -126,14 +126,14 @@ export default class EditChannelModal extends React.Component {
className='btn btn-default'
data-dismiss='modal'
>
- Cancel
+ {'Cancel'}
</button>
<button
type='button'
className='btn btn-primary'
onClick={this.handleEdit}
>
- Save
+ {'Save'}
</button>
</div>
</div>
diff --git a/web/react/components/edit_channel_purpose_modal.jsx b/web/react/components/edit_channel_purpose_modal.jsx
new file mode 100644
index 000000000..4d162cfe7
--- /dev/null
+++ b/web/react/components/edit_channel_purpose_modal.jsx
@@ -0,0 +1,124 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+const AsyncClient = require('../utils/async_client.jsx');
+const Client = require('../utils/client.jsx');
+const Modal = ReactBootstrap.Modal;
+
+export default class EditChannelPurposeModal extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.handleHide = this.handleHide.bind(this);
+ this.handleSave = this.handleSave.bind(this);
+
+ this.state = {serverError: ''};
+ }
+
+ handleHide() {
+ this.setState({serverError: ''});
+
+ if (this.props.onModalDismissed) {
+ this.props.onModalDismissed();
+ }
+ }
+
+ handleSave() {
+ if (!this.props.channel) {
+ return;
+ }
+
+ const data = {
+ channel_id: this.props.channel.id,
+ channel_purpose: ReactDOM.findDOMNode(this.refs.purpose).value.trim()
+ };
+
+ Client.updateChannelPurpose(data,
+ () => {
+ AsyncClient.getChannel(this.props.channel.id);
+
+ this.handleHide();
+ },
+ (err) => {
+ if (err.message === 'Invalid channel_purpose parameter') {
+ this.setState({serverError: 'This channel purpose is too long, please enter a shorter one'});
+ } else {
+ this.setState({serverError: err.message});
+ }
+ }
+ );
+ }
+
+ render() {
+ if (!this.props.show) {
+ return null;
+ }
+
+ let serverError = null;
+ if (this.state.serverError) {
+ serverError = (
+ <div className='form-group has-error'>
+ <br/>
+ <label className='control-label'>{this.state.serverError}</label>
+ </div>
+ );
+ }
+
+ let title = <span>{'Edit Purpose'}</span>;
+ if (this.props.channel.display_name) {
+ title = <span>{'Edit Purpose for '}<span className='name'>{this.props.channel.display_name}</span></span>;
+ }
+
+ let channelTerm = 'Channel';
+ if (this.props.channel.channelType === 'P') {
+ channelTerm = 'Group';
+ }
+
+ return (
+ <Modal
+ className='modal-edit-channel-purpose'
+ show={this.props.show}
+ onHide={this.handleHide}
+ >
+ <Modal.Header closeButton={true}>
+ <Modal.Title>
+ {title}
+ </Modal.Title>
+ </Modal.Header>
+ <Modal.Body>
+ <p>{`Describe how this ${channelTerm} should be used.`}</p>
+ <textarea
+ ref='purpose'
+ className='form-control no-resize'
+ rows='6'
+ maxLength='128'
+ defaultValue={this.props.channel.purpose}
+ />
+ {serverError}
+ </Modal.Body>
+ <Modal.Footer>
+ <button
+ type='button'
+ className='btn btn-default'
+ onClick={this.handleHide}
+ >
+ {'Cancel'}
+ </button>
+ <button
+ type='button'
+ className='btn btn-primary'
+ onClick={this.handleSave}
+ >
+ {'Save'}
+ </button>
+ </Modal.Footer>
+ </Modal>
+ );
+ }
+}
+
+EditChannelPurposeModal.propTypes = {
+ show: React.PropTypes.bool.isRequired,
+ channel: React.PropTypes.object,
+ onModalDismissed: React.PropTypes.func.isRequired
+};
diff --git a/web/react/components/edit_post_modal.jsx b/web/react/components/edit_post_modal.jsx
index b259b3c18..2abb3f151 100644
--- a/web/react/components/edit_post_modal.jsx
+++ b/web/react/components/edit_post_modal.jsx
@@ -6,6 +6,10 @@ var AsyncClient = require('../utils/async_client.jsx');
var Textbox = require('./textbox.jsx');
var BrowserStore = require('../stores/browser_store.jsx');
var PostStore = require('../stores/post_store.jsx');
+var PreferenceStore = require('../stores/preference_store.jsx');
+
+var Constants = require('../utils/constants.jsx');
+var KeyCodes = Constants.KeyCodes;
export default class EditPostModal extends React.Component {
constructor() {
@@ -16,6 +20,8 @@ export default class EditPostModal extends React.Component {
this.handleEditKeyPress = this.handleEditKeyPress.bind(this);
this.handleUserInput = this.handleUserInput.bind(this);
this.handleEditPostEvent = this.handleEditPostEvent.bind(this);
+ this.handleKeyDown = this.handleKeyDown.bind(this);
+ this.onPreferenceChange = this.onPreferenceChange.bind(this);
this.state = {editText: '', title: '', post_id: '', channel_id: '', comments: 0, refocusId: ''};
}
@@ -51,7 +57,7 @@ export default class EditPostModal extends React.Component {
this.setState({editText: editMessage});
}
handleEditKeyPress(e) {
- if (e.which === 13 && !e.shiftKey && !e.altKey) {
+ if (this.state.ctrlSend === 'false' && e.which === KeyCodes.ENTER && !e.shiftKey && !e.altKey) {
e.preventDefault();
ReactDOM.findDOMNode(this.refs.editbox).blur();
this.handleEdit(e);
@@ -72,6 +78,16 @@ export default class EditPostModal extends React.Component {
$(ReactDOM.findDOMNode(this.refs.modal)).modal('show');
}
+ handleKeyDown(e) {
+ if (this.state.ctrlSend === 'true' && e.keyCode === KeyCodes.ENTER && e.ctrlKey === true) {
+ this.handleEdit(e);
+ }
+ }
+ onPreferenceChange() {
+ this.setState({
+ ctrlSend: PreferenceStore.getPreference(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter', {value: 'false'}).value
+ });
+ }
componentDidMount() {
var self = this;
@@ -84,7 +100,7 @@ export default class EditPostModal extends React.Component {
if (!button) {
return;
}
- self.setState({editText: $(button).attr('data-message'), title: $(button).attr('data-title'), channel_id: $(button).attr('data-channelid'), post_id: $(button).attr('data-postid'), comments: $(button).attr('data-comments'), refocusId: $(button).attr('data-refoucsid')});
+ self.setState({editText: $(button).attr('data-message'), title: $(button).attr('data-title'), channel_id: $(button).attr('data-channelid'), post_id: $(button).attr('data-postid'), comments: $(button).attr('data-comments'), refocusId: $(button).attr('data-refocusid')});
});
$(ReactDOM.findDOMNode(this.refs.modal)).on('shown.bs.modal', function onShown() {
@@ -101,9 +117,11 @@ export default class EditPostModal extends React.Component {
});
PostStore.addEditPostListener(this.handleEditPostEvent);
+ PreferenceStore.addChangeListener(this.onPreferenceChange);
}
componentWillUnmount() {
PostStore.removeEditPostListener(this.handleEditPostEvent);
+ PreferenceStore.removeChangeListener(this.onPreferenceChange);
}
render() {
var error = (<div className='form-group'><br /></div>);
@@ -138,6 +156,7 @@ export default class EditPostModal extends React.Component {
<Textbox
onUserInput={this.handleEditInput}
onKeyPress={this.handleEditKeyPress}
+ onKeyDown={this.handleKeyDown}
messageText={this.state.editText}
createMessage='Edit the post...'
id='edit_textbox'
diff --git a/web/react/components/file_attachment.jsx b/web/react/components/file_attachment.jsx
index 4d4e8390c..e707e32f5 100644
--- a/web/react/components/file_attachment.jsx
+++ b/web/react/components/file_attachment.jsx
@@ -270,7 +270,7 @@ export default class FileAttachment extends React.Component {
href={fileUrl}
download={filenameString}
data-toggle='tooltip'
- title={filenameString}
+ title={'Download ' + filenameString}
className='post-image__name'
>
{trimmedFilename}
diff --git a/web/react/components/find_team.jsx b/web/react/components/find_team.jsx
index e324f3666..bd3c11973 100644
--- a/web/react/components/find_team.jsx
+++ b/web/react/components/find_team.jsx
@@ -50,7 +50,7 @@ export default class FindTeam extends React.Component {
if (this.state.sent) {
return (
<div>
- <h4>{'Find Your team'}</h4>
+ <h4>{'Find Your teams'}</h4>
<p>{'An email was sent with links to any teams to which you are a member.'}</p>
</div>
);
diff --git a/web/react/components/get_link_modal.jsx b/web/react/components/get_link_modal.jsx
index 325e86f3d..8839bc3c7 100644
--- a/web/react/components/get_link_modal.jsx
+++ b/web/react/components/get_link_modal.jsx
@@ -98,7 +98,7 @@ export default class GetLinkModal extends React.Component {
<br /><br />
</p>
<textarea
- className='form-control no-resize'
+ className='form-control no-resize min-height'
readOnly='true'
ref='textarea'
value={this.state.value}
diff --git a/web/react/components/invite_member_modal.jsx b/web/react/components/invite_member_modal.jsx
index 86a4b04cf..bea700725 100644
--- a/web/react/components/invite_member_modal.jsx
+++ b/web/react/components/invite_member_modal.jsx
@@ -4,6 +4,7 @@
var utils = require('../utils/utils.jsx');
var Client = require('../utils/client.jsx');
var UserStore = require('../stores/user_store.jsx');
+var TeamStore = require('../stores/team_store.jsx');
var ConfirmModal = require('./confirm_modal.jsx');
export default class InviteMemberModal extends React.Component {
@@ -292,7 +293,7 @@ export default class InviteMemberModal extends React.Component {
} else {
var teamInviteLink = null;
if (currentUser && this.props.teamType === 'O') {
- var linkUrl = utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + currentUser.team_id;
+ var linkUrl = utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + TeamStore.getCurrent().invite_id;
var link =
(
<a
diff --git a/web/react/components/login.jsx b/web/react/components/login.jsx
index 108735caf..c519959af 100644
--- a/web/react/components/login.jsx
+++ b/web/react/components/login.jsx
@@ -101,7 +101,7 @@ export default class Login extends React.Component {
href={'/' + teamName + '/login/gitlab'}
>
<span className='icon' />
- <span>with GitLab</span>
+ <span>{'with GitLab'}</span>
</a>
);
}
@@ -154,7 +154,7 @@ export default class Login extends React.Component {
type='submit'
className='btn btn-primary'
>
- Sign in
+ {'Sign in'}
</button>
</div>
</div>
@@ -166,7 +166,7 @@ export default class Login extends React.Component {
<div>
{loginMessage}
<div className='or__container'>
- <span>or</span>
+ <span>{'or'}</span>
</div>
</div>
);
@@ -176,16 +176,48 @@ export default class Login extends React.Component {
if (emailSignup) {
forgotPassword = (
<div className='form-group'>
- <a href={'/' + teamName + '/reset_password'}>I forgot my password</a>
+ <a href={'/' + teamName + '/reset_password'}>{'I forgot my password'}</a>
+ </div>
+ );
+ }
+
+ let userSignUp = null;
+ if (this.props.inviteId) {
+ userSignUp = (
+ <div>
+ <span>{'Do not have an account? '}
+ <a
+ href={'/signup_user_complete/?id=' + this.props.inviteId}
+ className='signup-team-login'
+ >
+ {'Create one now'}
+ </a>
+ </span>
+ </div>
+ );
+ }
+
+ let teamSignUp = null;
+ if (global.window.mm_config.EnableTeamCreation === 'true') {
+ teamSignUp = (
+ <div className='margin--extra'>
+ <span>{'Want to create your own team? '}
+ <a
+ href='/'
+ className='signup-team-login'
+ >
+ {'Sign up now'}
+ </a>
+ </span>
</div>
);
}
return (
<div className='signup-team__container'>
- <h5 className='margin--less'>Sign in to:</h5>
+ <h5 className='margin--less'>{'Sign in to:'}</h5>
<h2 className='signup-team__name'>{teamDisplayName}</h2>
- <h2 className='signup-team__subdomain'>on {global.window.mm_config.SiteName}</h2>
+ <h2 className='signup-team__subdomain'>{'on '}{global.window.mm_config.SiteName}</h2>
<form onSubmit={this.handleSubmit}>
{verifiedBox}
<div className={'form-group' + errorClass}>
@@ -193,20 +225,12 @@ export default class Login extends React.Component {
</div>
{loginMessage}
{emailSignup}
+ {userSignUp}
<div className='form-group margin--extra form-group--small'>
<span><a href='/find_team'>{'Find other teams'}</a></span>
</div>
{forgotPassword}
- <div className='margin--extra'>
- <span>{'Want to create your own team? '}
- <a
- href='/'
- className='signup-team-login'
- >
- Sign up now
- </a>
- </span>
- </div>
+ {teamSignUp}
</form>
</div>
);
@@ -219,5 +243,6 @@ Login.defaultProps = {
};
Login.propTypes = {
teamName: React.PropTypes.string,
- teamDisplayName: React.PropTypes.string
+ teamDisplayName: React.PropTypes.string,
+ inviteId: React.PropTypes.string
};
diff --git a/web/react/components/more_channels.jsx b/web/react/components/more_channels.jsx
index a0084ad30..c4f831c2e 100644
--- a/web/react/components/more_channels.jsx
+++ b/web/react/components/more_channels.jsx
@@ -109,7 +109,7 @@ export default class MoreChannels extends React.Component {
<tr key={channel.id}>
<td>
<p className='more-name'>{channel.display_name}</p>
- <p className='more-description'>{channel.description}</p>
+ <p className='more-purpose'>{channel.purpose}</p>
</td>
<td className='td--action'>
{joinButton}
diff --git a/web/react/components/navbar.jsx b/web/react/components/navbar.jsx
index f9cd525fd..f7778f25f 100644
--- a/web/react/components/navbar.jsx
+++ b/web/react/components/navbar.jsx
@@ -8,6 +8,7 @@ var ChannelStore = require('../stores/channel_store.jsx');
var TeamStore = require('../stores/team_store.jsx');
var MessageWrapper = require('./message_wrapper.jsx');
var NotifyCounts = require('./notify_counts.jsx');
+const EditChannelPurposeModal = require('./edit_channel_purpose_modal.jsx');
const Utils = require('../utils/utils.jsx');
var Constants = require('../utils/constants.jsx');
@@ -26,7 +27,9 @@ export default class Navbar extends React.Component {
this.createCollapseButtons = this.createCollapseButtons.bind(this);
this.createDropdown = this.createDropdown.bind(this);
- this.state = this.getStateFromStores();
+ const state = this.getStateFromStores();
+ state.showEditChannelPurposeModal = false;
+ this.state = state;
}
getStateFromStores() {
return {
@@ -106,22 +109,35 @@ export default class Navbar extends React.Component {
</li>
);
- var setChannelDescriptionOption = (
+ var setChannelHeaderOption = (
<li role='presentation'>
<a
role='menuitem'
href='#'
data-toggle='modal'
data-target='#edit_channel'
- data-desc={channel.description}
+ data-header={channel.header}
data-title={channel.display_name}
data-channelid={channel.id}
>
- Set Channel Description...
+ Set Channel Header...
</a>
</li>
);
+ var setChannelPurposeOption = null;
+ if (!isDirect) {
+ setChannelPurposeOption = (
+ <li role='presentation'>
+ <a
+ role='menuitem'
+ href='#'
+ onClick={() => this.setState({showEditChannelPurposeModal: true})}
+ />
+ </li>
+ );
+ }
+
var addMembersOption;
var leaveChannelOption;
if (!isDirect && !ChannelStore.isDefault(channel)) {
@@ -249,7 +265,8 @@ export default class Navbar extends React.Component {
{viewInfoOption}
{addMembersOption}
{manageMembersOption}
- {setChannelDescriptionOption}
+ {setChannelHeaderOption}
+ {setChannelPurposeOption}
{notificationPreferenceOption}
{renameChannelOption}
{deleteChannelOption}
@@ -335,10 +352,10 @@ export default class Navbar extends React.Component {
<Popover
bsStyle='info'
placement='bottom'
- id='description-popover'
+ id='header-popover'
>
<MessageWrapper
- message={channel.description}
+ message={channel.header}
options={{singleline: true, mentionHighlight: false}}
/>
</Popover>
@@ -360,20 +377,20 @@ export default class Navbar extends React.Component {
}
}
- if (channel.description.length === 0) {
+ if (channel.header.length === 0) {
popoverContent = (
<Popover
bsStyle='info'
placement='bottom'
- id='description-popover'
+ id='header-popover'
>
<div>
- {'No channel description yet.'}
+ {'No channel header yet.'}
<br/>
<a
href='#'
data-toggle='modal'
- data-desc={channel.description}
+ data-header={channel.header}
data-title={channel.display_name}
data-channelid={channel.id}
data-target='#edit_channel'
@@ -392,17 +409,24 @@ export default class Navbar extends React.Component {
var channelMenuDropdown = this.createDropdown(channel, channelTitle, isAdmin, isDirect, popoverContent);
return (
- <nav
- className='navbar navbar-default navbar-fixed-top'
- role='navigation'
- >
- <div className='container-fluid theme'>
- <div className='navbar-header'>
- {collapseButtons}
- {channelMenuDropdown}
+ <div>
+ <nav
+ className='navbar navbar-default navbar-fixed-top'
+ role='navigation'
+ >
+ <div className='container-fluid theme'>
+ <div className='navbar-header'>
+ {collapseButtons}
+ {channelMenuDropdown}
+ </div>
</div>
- </div>
- </nav>
+ </nav>
+ <EditChannelPurposeModal
+ show={this.state.showEditChannelPurposeModal}
+ onModalDismissed={() => this.setState({showEditChannelPurposeModal: false})}
+ channel={channel}
+ />
+ </div>
);
}
}
diff --git a/web/react/components/navbar_dropdown.jsx b/web/react/components/navbar_dropdown.jsx
index 2b68645e5..dc21fad21 100644
--- a/web/react/components/navbar_dropdown.jsx
+++ b/web/react/components/navbar_dropdown.jsx
@@ -58,6 +58,7 @@ export default class NavbarDropdown extends React.Component {
TeamStore.addChangeListener(this.onListenerChange);
$(ReactDOM.findDOMNode(this.refs.dropdown)).on('hide.bs.dropdown', () => {
+ $('.sidebar--left .dropdown-menu').scrollTop(0);
this.blockToggle = true;
setTimeout(() => {
this.blockToggle = false;
@@ -111,7 +112,7 @@ export default class NavbarDropdown extends React.Component {
data-toggle='modal'
data-target='#get_link'
data-title='Team Invite'
- data-value={Utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + currentUser.team_id}
+ data-value={Utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + TeamStore.getCurrent().invite_id}
>
{'Get Team Invite Link'}
</a>
diff --git a/web/react/components/new_channel_flow.jsx b/web/react/components/new_channel_flow.jsx
index 186cfc2b0..d6280d118 100644
--- a/web/react/components/new_channel_flow.jsx
+++ b/web/react/components/new_channel_flow.jsx
@@ -30,7 +30,7 @@ export default class NewChannelFlow extends React.Component {
flowState: SHOW_NEW_CHANNEL,
channelDisplayName: '',
channelName: '',
- channelDescription: '',
+ channelPurpose: '',
nameModified: false
};
}
@@ -43,7 +43,7 @@ export default class NewChannelFlow extends React.Component {
flowState: SHOW_NEW_CHANNEL,
channelDisplayName: '',
channelName: '',
- channelDescription: '',
+ channelPurpose: '',
nameModified: false
});
}
@@ -65,7 +65,7 @@ export default class NewChannelFlow extends React.Component {
const cu = UserStore.getCurrentUser();
channel.team_id = cu.team_id;
- channel.description = this.state.channelDescription;
+ channel.purpose = this.state.channelPurpose;
channel.type = this.state.channelType;
Client.createChannel(channel,
@@ -109,7 +109,7 @@ export default class NewChannelFlow extends React.Component {
channelDataChanged(data) {
this.setState({
channelDisplayName: data.displayName,
- channelDescription: data.description
+ channelPurpose: data.purpose
});
if (!this.state.nameModified) {
this.setState({channelName: Utils.cleanUpUrlable(data.displayName.trim())});
@@ -119,7 +119,7 @@ export default class NewChannelFlow extends React.Component {
const channelData = {
name: this.state.channelName,
displayName: this.state.channelDisplayName,
- description: this.state.channelDescription
+ purpose: this.state.channelPurpose
};
let showChannelModal = false;
diff --git a/web/react/components/new_channel_modal.jsx b/web/react/components/new_channel_modal.jsx
index 4e6280c99..c0cea496f 100644
--- a/web/react/components/new_channel_modal.jsx
+++ b/web/react/components/new_channel_modal.jsx
@@ -36,7 +36,7 @@ export default class NewChannelModal extends React.Component {
handleChange() {
const newData = {
displayName: ReactDOM.findDOMNode(this.refs.display_name).value,
- description: ReactDOM.findDOMNode(this.refs.channel_desc).value
+ purpose: ReactDOM.findDOMNode(this.refs.channel_purpose).value
};
this.props.onDataChanged(newData);
}
@@ -136,22 +136,22 @@ export default class NewChannelModal extends React.Component {
</div>
<div className='form-group less'>
<div className='col-sm-3'>
- <label className='form__label control-label'>{'Description'}</label>
+ <label className='form__label control-label'>{'Purpose'}</label>
<label className='form__label light'>{'(optional)'}</label>
</div>
<div className='col-sm-9'>
<textarea
className='form-control no-resize'
- ref='channel_desc'
+ ref='channel_purpose'
rows='4'
- placeholder='Description'
- maxLength='1024'
- value={this.props.channelData.description}
+ placeholder='Purpose'
+ maxLength='128'
+ value={this.props.channelData.purpose}
onChange={this.handleChange}
tabIndex='2'
/>
<p className='input__help'>
- {'Description helps others decide whether to join this channel.'}
+ {`Describe how this ${channelTerm} should be used.`}
</p>
{serverError}
</div>
diff --git a/web/react/components/popover_list_members.jsx b/web/react/components/popover_list_members.jsx
index 9cffa2400..f3c0fa0b4 100644
--- a/web/react/components/popover_list_members.jsx
+++ b/web/react/components/popover_list_members.jsx
@@ -42,6 +42,10 @@ export default class PopoverListMembers extends React.Component {
};
}
+ componentDidUpdate() {
+ $(ReactDOM.findDOMNode(this.refs.memebersPopover)).find('.popover-content').perfectScrollbar();
+ }
+
handleShowDirectChannel(teammate, e) {
e.preventDefault();
@@ -106,27 +110,27 @@ export default class PopoverListMembers extends React.Component {
let button = '';
if (currentUserId !== m.id && ch.type !== 'D') {
button = (
- <button
- type='button'
- className='btn btn-primary btn-message'
+ <a
+ href='#'
+ className='btn-message'
onClick={(e) => this.handleShowDirectChannel(m, e)}
>
{'Message'}
- </button>
+ </a>
);
}
if (teamMembers[m.username] && teamMembers[m.username].delete_at <= 0) {
popoverHtml.push(
<div
- className='text--nowrap'
+ className='text-nowrap'
key={'popover-member-' + i}
>
<img
- className='profile-img pull-left'
- width='38'
- height='38'
+ className='profile-img rounded pull-left'
+ width='26px'
+ height='26px'
src={`/api/v1/users/${m.id}/image?time=${m.update_at}&${Utils.getSessionIndex()}`}
/>
<div className='pull-left'>
@@ -135,14 +139,9 @@ export default class PopoverListMembers extends React.Component {
>
{m.username}
</div>
- <div
- className='more-description'
- >
- {details}
- </div>
</div>
<div
- className='pull-right profile-action'
+ className='pull-right'
>
{button}
</div>
@@ -182,12 +181,11 @@ export default class PopoverListMembers extends React.Component {
placement='bottom'
>
<Popover
+ ref='memebersPopover'
title='Members'
id='member-list-popover'
>
- <div>
- {popoverHtml}
- </div>
+ {popoverHtml}
</Popover>
</Overlay>
</div>
diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx
index 45eae8c6a..7138e2cb4 100644
--- a/web/react/components/post_body.jsx
+++ b/web/react/components/post_body.jsx
@@ -297,7 +297,7 @@ export default class PostBody extends React.Component {
}
let embed;
- if (filenames.length === 0 && this.state.links) {
+ if (filenames.length === 0 && this.state.links && this.state.links.length > 0) {
embed = this.createEmbed(this.state.links[0]);
}
diff --git a/web/react/components/post_info.jsx b/web/react/components/post_info.jsx
index 36260d77c..ddda48e06 100644
--- a/web/react/components/post_info.jsx
+++ b/web/react/components/post_info.jsx
@@ -44,7 +44,7 @@ export default class PostInfo extends React.Component {
role='menuitem'
data-toggle='modal'
data-target='#edit_post'
- data-refoucsid='#post_textbox'
+ data-refocusid='#post_textbox'
data-title={type}
data-message={post.message}
data-postid={post.id}
@@ -126,7 +126,7 @@ export default class PostInfo extends React.Component {
lastCommentClass = ' comment-icon__container__show';
}
- if (this.props.commentCount >= 1 && post.state !== Constants.POST_FAILED && post.state !== Constants.POST_LOADING) {
+ if (this.props.commentCount >= 1 && post.state !== Constants.POST_FAILED && post.state !== Constants.POST_LOADING && post.state !== Constants.POST_DELETED) {
comments = (
<a
href='#'
diff --git a/web/react/components/post_list.jsx b/web/react/components/post_list.jsx
index 3ceef478c..444736db5 100644
--- a/web/react/components/post_list.jsx
+++ b/web/react/components/post_list.jsx
@@ -9,6 +9,7 @@ const LoadingScreen = require('./loading_screen.jsx');
const PostStore = require('../stores/post_store.jsx');
const ChannelStore = require('../stores/channel_store.jsx');
const UserStore = require('../stores/user_store.jsx');
+const TeamStore = require('../stores/team_store.jsx');
const SocketStore = require('../stores/socket_store.jsx');
const PreferenceStore = require('../stores/preference_store.jsx');
@@ -358,11 +359,11 @@ export default class PostList extends React.Component {
href='#'
data-toggle='modal'
data-target='#edit_channel'
- data-desc={channel.description}
+ data-header={channel.header}
data-title={channel.display_name}
data-channelid={channel.id}
>
- <i className='fa fa-pencil'></i>{'Set a description'}
+ <i className='fa fa-pencil'></i>{'Set a header'}
</a>
</div>
);
@@ -386,17 +387,55 @@ export default class PostList extends React.Component {
}
}
createDefaultIntroMessage(channel) {
+ const team = TeamStore.getCurrent();
+ let inviteModalLink;
+ if (team.type === Constants.INVITE_TEAM) {
+ inviteModalLink = (
+ <a
+ className='intro-links'
+ href='#'
+ data-toggle='modal'
+ data-target='#invite_member'
+ >
+ <i className='fa fa-user-plus'></i>{'Invite others to this team'}
+ </a>
+ );
+ } else {
+ inviteModalLink = (
+ <a
+ className='intro-links'
+ href='#'
+ data-toggle='modal'
+ data-target='#get_link'
+ data-title='Team Invite'
+ data-value={Utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + team.id}
+ >
+ <i className='fa fa-user-plus'></i>{'Invite others to this team'}
+ </a>
+ );
+ }
+
return (
<div className='channel-intro'>
<h4 className='channel-intro__title'>{'Beginning of ' + channel.display_name}</h4>
<p className='channel-intro__content'>
- {'Welcome to ' + channel.display_name + '!'}
+ <strong>{'Welcome to ' + channel.display_name + '!'}</strong>
<br/><br/>
{'This is the first channel teammates see when they sign up - use it for posting updates everyone needs to know.'}
- <br/><br/>
- {'To create a new channel or join an existing one, go to the Left Sidebar under “Channels” and click “More…”.'}
- <br/>
</p>
+ {inviteModalLink}
+ <a
+ className='intro-links'
+ href='#'
+ data-toggle='modal'
+ data-target='#edit_channel'
+ data-header={channel.header}
+ data-title={channel.display_name}
+ data-channelid={channel.id}
+ >
+ <i className='fa fa-pencil'></i>{'Set a header'}
+ </a>
+ <br/>
</div>
);
}
@@ -413,11 +452,11 @@ export default class PostList extends React.Component {
href='#'
data-toggle='modal'
data-target='#edit_channel'
- data-desc={channel.description}
+ data-header={channel.header}
data-title={channel.display_name}
data-channelid={channel.id}
>
- <i className='fa fa-pencil'></i>{'Set a description'}
+ <i className='fa fa-pencil'></i>{'Set a header'}
</a>
<a
className='intro-links'
@@ -479,11 +518,11 @@ export default class PostList extends React.Component {
href='#'
data-toggle='modal'
data-target='#edit_channel'
- data-desc={channel.description}
+ data-header={channel.header}
data-title={channel.display_name}
data-channelid={channel.id}
>
- <i className='fa fa-pencil'></i>{'Set a description'}
+ <i className='fa fa-pencil'></i>{'Set a header'}
</a>
<a
className='intro-links'
@@ -517,6 +556,11 @@ export default class PostList extends React.Component {
var post = posts[order[i]];
var parentPost = posts[post.parent_id];
+ // If the post is a comment whose parent has been deleted, don't add it to the list.
+ if (parentPost && parentPost.state === Constants.POST_DELETED) {
+ continue;
+ }
+
var sameUser = false;
var sameRoot = false;
var hideProfilePic = false;
diff --git a/web/react/components/register_app_modal.jsx b/web/react/components/register_app_modal.jsx
index 3d4d9bf45..c40409dcc 100644
--- a/web/react/components/register_app_modal.jsx
+++ b/web/react/components/register_app_modal.jsx
@@ -96,75 +96,74 @@ export default class RegisterAppModal extends React.Component {
var body = '';
if (this.state.clientId === '') {
body = (
- <div className='form-group user-settings'>
- <h3>{'Register a New Application'}</h3>
- <br/>
- <label className='col-sm-4 control-label'>{'Application Name'}</label>
- <div className='col-sm-7'>
- <input
- ref='name'
- className='form-control'
- type='text'
- placeholder='Required'
- />
- {nameError}
- </div>
- <br/>
- <br/>
- <label className='col-sm-4 control-label'>{'Homepage URL'}</label>
- <div className='col-sm-7'>
- <input
- ref='homepage'
- className='form-control'
- type='text'
- placeholder='Required'
- />
- {homepageError}
- </div>
- <br/>
- <br/>
- <label className='col-sm-4 control-label'>{'Description'}</label>
- <div className='col-sm-7'>
- <input
- ref='desc'
- className='form-control'
- type='text'
- placeholder='Optional'
- />
- </div>
- <br/>
- <br/>
- <label className='col-sm-4 control-label'>{'Callback URL'}</label>
- <div className='col-sm-7'>
- <textarea
- ref='callback'
- className='form-control'
- type='text'
- placeholder='Required'
- rows='5'
- />
- {callbackError}
+ <div className='settings-modal'>
+ <div className='form-horizontal user-settings'>
+ <h4 className='padding-bottom x3'>{'Register a New Application'}</h4>
+ <div className='row'>
+ <label className='col-sm-4 control-label'>{'Application Name'}</label>
+ <div className='col-sm-7'>
+ <input
+ ref='name'
+ className='form-control'
+ type='text'
+ placeholder='Required'
+ />
+ {nameError}
+ </div>
+ </div>
+ <div className='row padding-top x2'>
+ <label className='col-sm-4 control-label'>{'Homepage URL'}</label>
+ <div className='col-sm-7'>
+ <input
+ ref='homepage'
+ className='form-control'
+ type='text'
+ placeholder='Required'
+ />
+ {homepageError}
+ </div>
+ </div>
+ <div className='row padding-top x2'>
+ <label className='col-sm-4 control-label'>{'Description'}</label>
+ <div className='col-sm-7'>
+ <input
+ ref='desc'
+ className='form-control'
+ type='text'
+ placeholder='Optional'
+ />
+ </div>
+ </div>
+ <div className='row padding-top padding-bottom x2'>
+ <label className='col-sm-4 control-label'>{'Callback URL'}</label>
+ <div className='col-sm-7'>
+ <textarea
+ ref='callback'
+ className='form-control'
+ type='text'
+ placeholder='Required'
+ rows='5'
+ />
+ {callbackError}
+ </div>
+ </div>
+ {serverError}
+ <hr />
+ <a
+ className='btn btn-sm theme pull-right'
+ href='#'
+ data-dismiss='modal'
+ aria-label='Close'
+ >
+ {'Cancel'}
+ </a>
+ <a
+ className='btn btn-sm btn-primary pull-right'
+ onClick={this.register}
+ >
+ {'Register'}
+ </a>
</div>
- <br/>
- <br/>
- <br/>
- <br/>
- <br/>
- {serverError}
- <a
- className='btn btn-sm theme pull-right'
- href='#'
- data-dismiss='modal'
- aria-label='Close'
- >
- {'Cancel'}
- </a>
- <a
- className='btn btn-sm btn-primary pull-right'
- onClick={this.register}
- >
- {'Register'}
- </a>
</div>
);
} else {
diff --git a/web/react/components/rhs_comment.jsx b/web/react/components/rhs_comment.jsx
index cfff04fa2..8c6324c72 100644
--- a/web/react/components/rhs_comment.jsx
+++ b/web/react/components/rhs_comment.jsx
@@ -93,6 +93,7 @@ export default class RhsComment extends React.Component {
role='menuitem'
data-toggle='modal'
data-target='#edit_post'
+ data-refocusid='#reply_textbox'
data-title='Comment'
data-message={post.message}
data-postid={post.id}
diff --git a/web/react/components/rhs_root_post.jsx b/web/react/components/rhs_root_post.jsx
index deef389e2..21e52b438 100644
--- a/web/react/components/rhs_root_post.jsx
+++ b/web/react/components/rhs_root_post.jsx
@@ -79,6 +79,7 @@ export default class RhsRootPost extends React.Component {
role='menuitem'
data-toggle='modal'
data-target='#edit_post'
+ data-refocusid='#reply_textbox'
data-title={type}
data-message={post.message}
data-postid={post.id}
diff --git a/web/react/components/search_autocomplete.jsx b/web/react/components/search_autocomplete.jsx
index 03c7b894c..f7d772677 100644
--- a/web/react/components/search_autocomplete.jsx
+++ b/web/react/components/search_autocomplete.jsx
@@ -10,6 +10,7 @@ const patterns = new Map([
['channels', /\b(?:in|channel):\s*(\S*)$/i],
['users', /\bfrom:\s*(\S*)$/i]
]);
+const Popover = ReactBootstrap.Popover;
export default class SearchAutocomplete extends React.Component {
constructor(props) {
@@ -36,6 +37,11 @@ export default class SearchAutocomplete extends React.Component {
$(document).on('click', this.handleDocumentClick);
}
+ componentDidUpdate() {
+ $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content').perfectScrollbar();
+ $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content').css('max-height', $(window).height() - 200);
+ }
+
componentWillUnmount() {
$(document).off('click', this.handleDocumentClick);
}
@@ -193,7 +199,7 @@ export default class SearchAutocomplete extends React.Component {
if (this.state.mode === 'channels') {
suggestions = this.state.suggestions.map((channel, index) => {
- let className = 'search-autocomplete__channel';
+ let className = 'search-autocomplete__item';
if (this.state.selection === index) {
className += ' selected';
}
@@ -211,7 +217,7 @@ export default class SearchAutocomplete extends React.Component {
});
} else if (this.state.mode === 'users') {
suggestions = this.state.suggestions.map((user, index) => {
- let className = 'search-autocomplete__user';
+ let className = 'search-autocomplete__item';
if (this.state.selection === index) {
className += ' selected';
}
@@ -224,7 +230,7 @@ export default class SearchAutocomplete extends React.Component {
className={className}
>
<img
- className='profile-img'
+ className='profile-img rounded'
src={'/api/v1/users/' + user.id + '/image?time=' + user.update_at}
/>
{user.username}
@@ -234,12 +240,15 @@ export default class SearchAutocomplete extends React.Component {
}
return (
- <div
- ref='container'
- className='search-autocomplete'
+ <Popover
+ ref='searchPopover'
+ onShow={this.componentDidMount}
+ id='search-autocomplete__popover'
+ className='search-help-popover autocomplete visible'
+ placement='bottom'
>
{suggestions}
- </div>
+ </Popover>
);
}
}
diff --git a/web/react/components/search_bar.jsx b/web/react/components/search_bar.jsx
index 83c10494a..1b81a5ee0 100644
--- a/web/react/components/search_bar.jsx
+++ b/web/react/components/search_bar.jsx
@@ -185,6 +185,7 @@ export default class SearchBar extends React.Component {
className='search__form relative-div'
onSubmit={this.handleSubmit}
style={{overflow: 'visible'}}
+ autoComplete='off'
>
<span className='glyphicon glyphicon-search sidebar__search-icon' />
<input
diff --git a/web/react/components/setting_item_max.jsx b/web/react/components/setting_item_max.jsx
index 774f98a43..d6c4b0d4b 100644
--- a/web/react/components/setting_item_max.jsx
+++ b/web/react/components/setting_item_max.jsx
@@ -35,8 +35,10 @@ export default class SettingItemMax extends React.Component {
var widthClass;
if (this.props.width === 'full') {
widthClass = 'col-sm-12';
- } else {
+ } else if (this.props.width === 'medium') {
widthClass = 'col-sm-10 col-sm-offset-2';
+ } else {
+ widthClass = 'col-sm-9 col-sm-offset-3';
}
return (
diff --git a/web/react/components/setting_picture.jsx b/web/react/components/setting_picture.jsx
index b6bcb13a6..e69412cca 100644
--- a/web/react/components/setting_picture.jsx
+++ b/web/react/components/setting_picture.jsx
@@ -42,7 +42,7 @@ export default class SettingPicture extends React.Component {
img = (
<img
ref='image'
- className='profile-img'
+ className='profile-img rounded'
src=''
/>
);
@@ -50,7 +50,7 @@ export default class SettingPicture extends React.Component {
img = (
<img
ref='image'
- className='profile-img'
+ className='profile-img rounded'
src={this.props.src}
/>
);
diff --git a/web/react/components/sidebar_header.jsx b/web/react/components/sidebar_header.jsx
index de28a8374..65e4c6d7e 100644
--- a/web/react/components/sidebar_header.jsx
+++ b/web/react/components/sidebar_header.jsx
@@ -5,6 +5,9 @@ var NavbarDropdown = require('./navbar_dropdown.jsx');
var UserStore = require('../stores/user_store.jsx');
const Utils = require('../utils/utils.jsx');
+const Tooltip = ReactBootstrap.Tooltip;
+const OverlayTrigger = ReactBootstrap.OverlayTrigger;
+
export default class SidebarHeader extends React.Component {
constructor(props) {
super(props);
@@ -47,7 +50,15 @@ export default class SidebarHeader extends React.Component {
{profilePicture}
<div className='header__info'>
<div className='user__name'>{'@' + me.username}</div>
+ <OverlayTrigger
+ trigger={['hover', 'focus']}
+ delayShow={1000}
+ placement='bottom'
+ overlay={<Tooltip id='team-name__tooltip'>{this.props.teamDisplayName}</Tooltip>}
+ ref='descriptionOverlay'
+ >
<div className='team__name'>{this.props.teamDisplayName}</div>
+ </OverlayTrigger>
</div>
</a>
<NavbarDropdown
diff --git a/web/react/components/sidebar_right_menu.jsx b/web/react/components/sidebar_right_menu.jsx
index fddc98c9d..9350bbd42 100644
--- a/web/react/components/sidebar_right_menu.jsx
+++ b/web/react/components/sidebar_right_menu.jsx
@@ -2,6 +2,7 @@
// See License.txt for license information.
var UserStore = require('../stores/user_store.jsx');
+var TeamStore = require('../stores/team_store.jsx');
var client = require('../utils/client.jsx');
var utils = require('../utils/utils.jsx');
@@ -51,7 +52,7 @@ export default class SidebarRightMenu extends React.Component {
data-toggle='modal'
data-target='#get_link'
data-title='Team Invite'
- data-value={utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + currentUser.team_id}
+ data-value={utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + TeamStore.getCurrent().invite_id}
><i className='glyphicon glyphicon-link'></i>Get Team Invite Link</a>
</li>
);
diff --git a/web/react/components/signup_team.jsx b/web/react/components/signup_team.jsx
index 1858703ef..f926f5cbb 100644
--- a/web/react/components/signup_team.jsx
+++ b/web/react/components/signup_team.jsx
@@ -12,6 +12,11 @@ export default class TeamSignUp extends React.Component {
this.updatePage = this.updatePage.bind(this);
+ if (global.window.mm_config.EnableTeamListing === 'true') {
+ this.state = {page: 'team_listing'};
+ return;
+ }
+
var count = 0;
if (global.window.mm_config.EnableSignUpWithEmail === 'true') {
@@ -36,6 +41,38 @@ export default class TeamSignUp extends React.Component {
}
render() {
+ if (this.state.page === 'team_listing') {
+ return (
+ <div>
+ <h3>{'Choose a Team'}</h3>
+ <div className='signup-team-all'>
+ {
+ this.props.teams.map((team) => {
+ return (
+ <div
+ key={'team_' + team.name}
+ className='signup-team-dir'
+ >
+ <a
+ href={'/' + team.name}
+ >
+ <div className='signup-team-dir__group'>
+ <span className='signup-team-dir__name'>{team.display_name}</span>
+ <span
+ className='glyphicon glyphicon-menu-right right signup-team-dir__arrow'
+ aria-hidden='true'
+ />
+ </div>
+ </a>
+ </div>
+ );
+ })
+ }
+ </div>
+ </div>
+ );
+ }
+
if (this.state.page === 'choose') {
return (
<ChoosePage
@@ -51,3 +88,8 @@ export default class TeamSignUp extends React.Component {
}
}
}
+
+TeamSignUp.propTypes = {
+ teams: React.PropTypes.array
+};
+
diff --git a/web/react/components/team_general_tab.jsx b/web/react/components/team_general_tab.jsx
index 923180e27..69ba44664 100644
--- a/web/react/components/team_general_tab.jsx
+++ b/web/react/components/team_general_tab.jsx
@@ -6,29 +6,112 @@ const SettingItemMax = require('./setting_item_max.jsx');
const Client = require('../utils/client.jsx');
const Utils = require('../utils/utils.jsx');
+const TeamStore = require('../stores/team_store.jsx');
export default class GeneralTab extends React.Component {
constructor(props) {
super(props);
this.handleNameSubmit = this.handleNameSubmit.bind(this);
+ this.handleInviteIdSubmit = this.handleInviteIdSubmit.bind(this);
+ this.handleOpenInviteSubmit = this.handleOpenInviteSubmit.bind(this);
+ this.handleTeamListingSubmit = this.handleTeamListingSubmit.bind(this);
this.handleClose = this.handleClose.bind(this);
- this.onUpdateSection = this.onUpdateSection.bind(this);
+ this.onUpdateNameSection = this.onUpdateNameSection.bind(this);
this.updateName = this.updateName.bind(this);
+ this.onUpdateInviteIdSection = this.onUpdateInviteIdSection.bind(this);
+ this.updateInviteId = this.updateInviteId.bind(this);
+ this.onUpdateOpenInviteSection = this.onUpdateOpenInviteSection.bind(this);
+ this.handleOpenInviteRadio = this.handleOpenInviteRadio.bind(this);
+ this.onUpdateTeamListingSection = this.onUpdateTeamListingSection.bind(this);
+ this.handleTeamListingRadio = this.handleTeamListingRadio.bind(this);
+ this.handleGenerateInviteId = this.handleGenerateInviteId.bind(this);
- this.state = {name: this.props.teamDisplayName, serverError: '', clientError: ''};
+ this.state = {
+ name: props.team.display_name,
+ invite_id: props.team.invite_id,
+ allow_open_invite: props.team.allow_open_invite,
+ allow_team_listing: props.team.allow_team_listing,
+ serverError: '',
+ clientError: ''
+ };
}
+
+ handleGenerateInviteId(e) {
+ e.preventDefault();
+
+ var newId = '';
+ for (var i = 0; i < 32; i++) {
+ newId += Math.floor(Math.random() * 16).toString(16);
+ }
+
+ this.setState({invite_id: newId});
+ }
+
+ handleOpenInviteRadio(openInvite) {
+ this.setState({allow_open_invite: openInvite});
+ }
+
+ handleTeamListingRadio(listing) {
+ if (global.window.mm_config.EnableTeamListing !== 'true' && listing) {
+ ReactDOM.findDOMNode(this.refs.teamListingRadioNo).checked = true;
+ this.setState({clientError: 'Team directory has been disabled. Please ask a system admin to enable it.'});
+ } else {
+ this.setState({allow_team_listing: listing});
+ }
+ }
+
+ handleOpenInviteSubmit(e) {
+ e.preventDefault();
+
+ var state = {serverError: '', clientError: ''};
+
+ var data = this.props.team;
+ data.allow_open_invite = this.state.allow_open_invite;
+ Client.updateTeam(data,
+ (team) => {
+ TeamStore.saveTeam(team);
+ TeamStore.emitChange();
+ this.props.updateSection('');
+ },
+ (err) => {
+ state.serverError = err.message;
+ this.setState(state);
+ }
+ );
+ }
+
+ handleTeamListingSubmit(e) {
+ e.preventDefault();
+
+ var state = {serverError: '', clientError: ''};
+
+ var data = this.props.team;
+ data.allow_team_listing = this.state.allow_team_listing;
+ Client.updateTeam(data,
+ (team) => {
+ TeamStore.saveTeam(team);
+ TeamStore.emitChange();
+ this.props.updateSection('');
+ },
+ (err) => {
+ state.serverError = err.message;
+ this.setState(state);
+ }
+ );
+ }
+
handleNameSubmit(e) {
e.preventDefault();
- let state = {serverError: '', clientError: ''};
+ var state = {serverError: '', clientError: ''};
let valid = true;
const name = this.state.name.trim();
if (!name) {
state.clientError = 'This field is required';
valid = false;
- } else if (name === this.props.teamDisplayName) {
+ } else if (name === this.props.team.display_name) {
state.clientError = 'Please choose a new name for your team';
valid = false;
} else {
@@ -41,37 +124,76 @@ export default class GeneralTab extends React.Component {
return;
}
- let data = {};
- data.new_name = name;
+ var data = this.props.team;
+ data.display_name = this.state.name;
+ Client.updateTeam(data,
+ (team) => {
+ TeamStore.saveTeam(team);
+ TeamStore.emitChange();
+ this.props.updateSection('');
+ },
+ (err) => {
+ state.serverError = err.message;
+ this.setState(state);
+ }
+ );
+ }
+
+ handleInviteIdSubmit(e) {
+ e.preventDefault();
+
+ var state = {serverError: '', clientError: ''};
+ let valid = true;
- Client.updateTeamDisplayName(data,
- function nameChangeSuccess() {
+ const inviteId = this.state.invite_id.trim();
+ if (inviteId) {
+ state.clientError = '';
+ } else {
+ state.clientError = 'This field is required';
+ valid = false;
+ }
+
+ this.setState(state);
+
+ if (!valid) {
+ return;
+ }
+
+ var data = this.props.team;
+ data.invite_id = this.state.invite_id;
+ Client.updateTeam(data,
+ (team) => {
+ TeamStore.saveTeam(team);
+ TeamStore.emitChange();
this.props.updateSection('');
- $('#team_settings').modal('hide');
- window.location.reload();
- }.bind(this),
- function nameChangeFail(err) {
+ },
+ (err) => {
state.serverError = err.message;
this.setState(state);
- }.bind(this)
+ }
);
}
+
componentWillReceiveProps(newProps) {
if (newProps.team && newProps.teamDisplayName) {
this.setState({name: newProps.teamDisplayName});
}
}
+
handleClose() {
this.setState({clientError: '', serverError: ''});
this.props.updateSection('');
}
+
componentDidMount() {
$('#team_settings').on('hidden.bs.modal', this.handleClose);
}
+
componentWillUnmount() {
$('#team_settings').off('hidden.bs.modal', this.handleClose);
}
- onUpdateSection(e) {
+
+ onUpdateNameSection(e) {
e.preventDefault();
if (this.props.activeSection === 'name') {
this.props.updateSection('');
@@ -79,10 +201,44 @@ export default class GeneralTab extends React.Component {
this.props.updateSection('name');
}
}
+
+ onUpdateInviteIdSection(e) {
+ e.preventDefault();
+ if (this.props.activeSection === 'invite_id') {
+ this.props.updateSection('');
+ } else {
+ this.props.updateSection('invite_id');
+ }
+ }
+
+ onUpdateOpenInviteSection(e) {
+ e.preventDefault();
+ if (this.props.activeSection === 'open_invite') {
+ this.props.updateSection('');
+ } else {
+ this.props.updateSection('open_invite');
+ }
+ }
+
+ onUpdateTeamListingSection(e) {
+ e.preventDefault();
+ if (this.props.activeSection === 'team_listing') {
+ this.props.updateSection('');
+ } else {
+ this.props.updateSection('team_listing');
+ }
+ }
+
updateName(e) {
e.preventDefault();
this.setState({name: e.target.value});
}
+
+ updateInviteId(e) {
+ e.preventDefault();
+ this.setState({invite_id: e.target.value});
+ }
+
render() {
let clientError = null;
let serverError = null;
@@ -93,10 +249,180 @@ export default class GeneralTab extends React.Component {
serverError = this.state.serverError;
}
+ let teamListingSection;
+ if (this.props.activeSection === 'team_listing') {
+ const inputs = [
+ <div key='userTeamListingOptions'>
+ <div className='radio'>
+ <label>
+ <input
+ name='userTeamListingOptions'
+ type='radio'
+ defaultChecked={this.state.allow_team_listing}
+ onChange={this.handleTeamListingRadio.bind(this, true)}
+ />
+ {'Yes'}
+ </label>
+ <br/>
+ </div>
+ <div className='radio'>
+ <label>
+ <input
+ ref='teamListingRadioNo'
+ name='userTeamListingOptions'
+ type='radio'
+ defaultChecked={!this.state.allow_team_listing}
+ onChange={this.handleTeamListingRadio.bind(this, false)}
+ />
+ {'No'}
+ </label>
+ <br/>
+ </div>
+ <div><br/>{'When allowed the team will appear on the main page as part of team directory.'}</div>
+ </div>
+ ];
+
+ teamListingSection = (
+ <SettingItemMax
+ title='Allow in Team Directory'
+ inputs={inputs}
+ submit={this.handleTeamListingSubmit}
+ server_error={serverError}
+ client_error={clientError}
+ updateSection={this.onUpdateTeamListingSection}
+ />
+ );
+ } else {
+ let describe = '';
+ if (this.state.allow_team_listing === true) {
+ describe = 'Yes';
+ } else {
+ describe = 'No';
+ }
+
+ teamListingSection = (
+ <SettingItemMin
+ title='Allow in Team Directory'
+ describe={describe}
+ updateSection={this.onUpdateTeamListingSection}
+ />
+ );
+ }
+
+ let openInviteSection;
+ if (this.props.activeSection === 'open_invite') {
+ const inputs = [
+ <div key='userOpenInviteOptions'>
+ <div className='radio'>
+ <label>
+ <input
+ name='userOpenInviteOptions'
+ type='radio'
+ defaultChecked={this.state.allow_open_invite}
+ onChange={this.handleOpenInviteRadio.bind(this, true)}
+ />
+ {'Yes'}
+ </label>
+ <br/>
+ </div>
+ <div className='radio'>
+ <label>
+ <input
+ name='userOpenInviteOptions'
+ type='radio'
+ defaultChecked={!this.state.allow_open_invite}
+ onChange={this.handleOpenInviteRadio.bind(this, false)}
+ />
+ {'No'}
+ </label>
+ <br/>
+ </div>
+ <div><br/>{'When allowed the team signup link will be included on the login page and anyone can signup to this team.'}</div>
+ </div>
+ ];
+
+ openInviteSection = (
+ <SettingItemMax
+ title='Allow Open Invitations'
+ inputs={inputs}
+ submit={this.handleOpenInviteSubmit}
+ server_error={serverError}
+ updateSection={this.onUpdateOpenInviteSection}
+ />
+ );
+ } else {
+ let describe = '';
+ if (this.state.allow_open_invite === true) {
+ describe = 'Yes';
+ } else {
+ describe = 'No';
+ }
+
+ openInviteSection = (
+ <SettingItemMin
+ title='Allow Open Invitations'
+ describe={describe}
+ updateSection={this.onUpdateOpenInviteSection}
+ />
+ );
+ }
+
+ let inviteSection;
+
+ if (this.props.activeSection === 'invite_id') {
+ const inputs = [];
+
+ inputs.push(
+ <div
+ key='teamInviteSetting'
+ className='form-group'
+ >
+ <label className='col-sm-5 control-label'>{'Invite Code'}</label>
+ <div className='col-sm-7'>
+ <input
+ className='form-control'
+ type='text'
+ onChange={this.updateInviteId}
+ value={this.state.invite_id}
+ maxLength='32'
+ />
+ </div>
+ <div><br/>{'When allowing open invites this code is used as part of the signup process. Changing this code will invalidate the previous open signup link.'}</div>
+ <div className='help-text'>
+ <button
+ className='btn btn-default'
+ onClick={this.handleGenerateInviteId}
+ >
+ {'Re-Generate'}
+ </button>
+ </div>
+ </div>
+ );
+
+ inviteSection = (
+ <SettingItemMax
+ title={`Invite Code`}
+ inputs={inputs}
+ submit={this.handleInviteIdSubmit}
+ server_error={serverError}
+ client_error={clientError}
+ updateSection={this.onUpdateInviteIdSection}
+ />
+ );
+ } else {
+ inviteSection = (
+ <SettingItemMin
+ title={`Invite Code`}
+ describe={`Click 'Edit' to re-generate invite Code.`}
+ updateSection={this.onUpdateInviteIdSection}
+ />
+ );
+ }
+
let nameSection;
if (this.props.activeSection === 'name') {
- let inputs = [];
+ const inputs = [];
let teamNameLabel = 'Team Name';
if (Utils.isMobile()) {
@@ -127,17 +453,17 @@ export default class GeneralTab extends React.Component {
submit={this.handleNameSubmit}
server_error={serverError}
client_error={clientError}
- updateSection={this.onUpdateSection}
+ updateSection={this.onUpdateNameSection}
/>
);
} else {
- let describe = this.state.name;
+ var describe = this.state.name;
nameSection = (
<SettingItemMin
title={`Team Name`}
describe={describe}
- updateSection={this.onUpdateSection}
+ updateSection={this.onUpdateNameSection}
/>
);
}
@@ -158,16 +484,19 @@ export default class GeneralTab extends React.Component {
ref='title'
>
<i className='modal-back'></i>
- General Settings
+ {'General Settings'}
</h4>
</div>
<div
ref='wrapper'
className='user-settings'
>
- <h3 className='tab-header'>General Settings</h3>
+ <h3 className='tab-header'>{'General Settings'}</h3>
<div className='divider-dark first'/>
{nameSection}
+ {openInviteSection}
+ {teamListingSection}
+ {inviteSection}
<div className='divider-dark'/>
</div>
</div>
@@ -178,6 +507,5 @@ export default class GeneralTab extends React.Component {
GeneralTab.propTypes = {
updateSection: React.PropTypes.func.isRequired,
team: React.PropTypes.object.isRequired,
- activeSection: React.PropTypes.string.isRequired,
- teamDisplayName: React.PropTypes.string.isRequired
+ activeSection: React.PropTypes.string.isRequired
};
diff --git a/web/react/components/team_settings.jsx b/web/react/components/team_settings.jsx
index e14da4f04..09674f1ef 100644
--- a/web/react/components/team_settings.jsx
+++ b/web/react/components/team_settings.jsx
@@ -37,7 +37,6 @@ export default class TeamSettings extends React.Component {
team={this.state.team}
activeSection={this.props.activeSection}
updateSection={this.props.updateSection}
- teamDisplayName={this.props.teamDisplayName}
/>
</div>
);
@@ -72,12 +71,11 @@ export default class TeamSettings extends React.Component {
TeamSettings.defaultProps = {
activeTab: '',
- activeSection: '',
- teamDisplayName: ''
+ activeSection: ''
};
+
TeamSettings.propTypes = {
activeTab: React.PropTypes.string.isRequired,
activeSection: React.PropTypes.string.isRequired,
- updateSection: React.PropTypes.func.isRequired,
- teamDisplayName: React.PropTypes.string.isRequired
+ updateSection: React.PropTypes.func.isRequired
};
diff --git a/web/react/components/team_settings_modal.jsx b/web/react/components/team_settings_modal.jsx
index 5c5995020..17fe31c65 100644
--- a/web/react/components/team_settings_modal.jsx
+++ b/web/react/components/team_settings_modal.jsx
@@ -82,7 +82,6 @@ export default class TeamSettingsModal extends React.Component {
activeTab={this.state.activeTab}
activeSection={this.state.activeSection}
updateSection={this.updateSection}
- teamDisplayName={this.props.teamDisplayName}
/>
</div>
</div>
@@ -95,5 +94,4 @@ export default class TeamSettingsModal extends React.Component {
}
TeamSettingsModal.propTypes = {
- teamDisplayName: React.PropTypes.string.isRequired
};
diff --git a/web/react/components/team_signup_display_name_page.jsx b/web/react/components/team_signup_display_name_page.jsx
index 4d08274e4..2005ecc31 100644
--- a/web/react/components/team_signup_display_name_page.jsx
+++ b/web/react/components/team_signup_display_name_page.jsx
@@ -25,6 +25,9 @@ export default class TeamSignupDisplayNamePage extends React.Component {
if (!displayName) {
this.setState({nameError: 'This field is required'});
return;
+ } else if (displayName.length < 4 || displayName.length > 15) {
+ this.setState({nameError: 'Name must be 4 or more characters up to a maximum of 15'});
+ return;
}
this.props.state.wizard = 'team_url';
diff --git a/web/react/components/team_signup_url_page.jsx b/web/react/components/team_signup_url_page.jsx
index 02d5cab8e..8972fda1a 100644
--- a/web/react/components/team_signup_url_page.jsx
+++ b/web/react/components/team_signup_url_page.jsx
@@ -35,8 +35,8 @@ export default class TeamSignupUrlPage extends React.Component {
if (cleanedName !== name || !urlRegex.test(name)) {
this.setState({nameError: "Use only lower case letters, numbers and dashes. Must start with a letter and can't end in a dash."});
return;
- } else if (cleanedName.length <= 2 || cleanedName.length > 15) {
- this.setState({nameError: 'Name must be 3 or more characters up to a maximum of 15'});
+ } else if (cleanedName.length < 4 || cleanedName.length > 15) {
+ this.setState({nameError: 'Name must be 4 or more characters up to a maximum of 15'});
return;
}
diff --git a/web/react/components/team_signup_with_email.jsx b/web/react/components/team_signup_with_email.jsx
index ff4ccd4d8..021713f04 100644
--- a/web/react/components/team_signup_with_email.jsx
+++ b/web/react/components/team_signup_with_email.jsx
@@ -71,7 +71,7 @@ export default class EmailSignUpPage extends React.Component {
className='btn btn-md btn-primary'
type='submit'
>
- {'Sign up'}
+ {'Create Team'}
</button>
{serverError}
</div>
diff --git a/web/react/components/user_settings/custom_theme_chooser.jsx b/web/react/components/user_settings/custom_theme_chooser.jsx
index 44b3f4544..095e5b622 100644
--- a/web/react/components/user_settings/custom_theme_chooser.jsx
+++ b/web/react/components/user_settings/custom_theme_chooser.jsx
@@ -40,11 +40,12 @@ export default class CustomThemeChooser extends React.Component {
const theme = {type: 'custom'};
let index = 0;
Constants.THEME_ELEMENTS.forEach((element) => {
- if (index < colors.length) {
+ if (index < colors.length - 1) {
theme[element.id] = colors[index];
}
index++;
});
+ theme.codeTheme = colors[colors.length - 1];
this.props.updateTheme(theme);
}
@@ -78,6 +79,8 @@ export default class CustomThemeChooser extends React.Component {
colors += theme[element.id] + ',';
});
+ colors += theme.codeTheme;
+
const pasteBox = (
<div className='col-sm-12'>
<label className='custom-label'>
diff --git a/web/react/components/user_settings/manage_outgoing_hooks.jsx b/web/react/components/user_settings/manage_outgoing_hooks.jsx
index 6e9b2205d..9b0701583 100644
--- a/web/react/components/user_settings/manage_outgoing_hooks.jsx
+++ b/web/react/components/user_settings/manage_outgoing_hooks.jsx
@@ -6,7 +6,6 @@ var Constants = require('../../utils/constants.jsx');
var ChannelStore = require('../../stores/channel_store.jsx');
var LoadingScreen = require('../loading_screen.jsx');
-
export default class ManageOutgoingHooks extends React.Component {
constructor() {
super();
@@ -236,6 +235,7 @@ export default class ManageOutgoingHooks extends React.Component {
return (
<div key='addOutgoingHook'>
<label className='control-label'>{'Add a new outgoing webhook'}</label>
+ <div className='padding-top divider-light'></div>
<div className='padding-top'>
<div>
<label className='control-label'>{'Channel'}</label>
diff --git a/web/react/components/user_settings/user_settings.jsx b/web/react/components/user_settings/user_settings.jsx
index 15bf961d6..546e26ca3 100644
--- a/web/react/components/user_settings/user_settings.jsx
+++ b/web/react/components/user_settings/user_settings.jsx
@@ -10,6 +10,7 @@ var AppearanceTab = require('./user_settings_appearance.jsx');
var DeveloperTab = require('./user_settings_developer.jsx');
var IntegrationsTab = require('./user_settings_integrations.jsx');
var DisplayTab = require('./user_settings_display.jsx');
+var AdvancedTab = require('./user_settings_advanced.jsx');
export default class UserSettings extends React.Component {
constructor(props) {
@@ -110,6 +111,17 @@ export default class UserSettings extends React.Component {
/>
</div>
);
+ } else if (this.props.activeTab === 'advanced') {
+ return (
+ <div>
+ <AdvancedTab
+ user={this.state.user}
+ activeSection={this.props.activeSection}
+ updateSection={this.props.updateSection}
+ updateTab={this.props.updateTab}
+ />
+ </div>
+ );
}
return <div/>;
diff --git a/web/react/components/user_settings/user_settings_advanced.jsx b/web/react/components/user_settings/user_settings_advanced.jsx
new file mode 100644
index 000000000..910444735
--- /dev/null
+++ b/web/react/components/user_settings/user_settings_advanced.jsx
@@ -0,0 +1,169 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+const Client = require('../../utils/client.jsx');
+const SettingItemMin = require('../setting_item_min.jsx');
+const SettingItemMax = require('../setting_item_max.jsx');
+const Constants = require('../../utils/constants.jsx');
+const PreferenceStore = require('../../stores/preference_store.jsx');
+
+export default class AdvancedSettingsDisplay extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.updateSection = this.updateSection.bind(this);
+ this.updateSetting = this.updateSetting.bind(this);
+ this.handleClose = this.handleClose.bind(this);
+ this.setupInitialState = this.setupInitialState.bind(this);
+
+ this.state = this.setupInitialState();
+ }
+
+ setupInitialState() {
+ const sendOnCtrlEnter = PreferenceStore.getPreference(
+ Constants.Preferences.CATEGORY_ADVANCED_SETTINGS,
+ 'send_on_ctrl_enter',
+ {value: 'false'}
+ ).value;
+
+ return {
+ settings: {send_on_ctrl_enter: sendOnCtrlEnter}
+ };
+ }
+
+ updateSetting(setting, value) {
+ const settings = this.state.settings;
+ settings[setting] = value;
+ this.setState(settings);
+ }
+
+ handleSubmit(setting) {
+ const preference = PreferenceStore.setPreference(
+ Constants.Preferences.CATEGORY_ADVANCED_SETTINGS,
+ setting,
+ this.state.settings[setting]
+ );
+
+ Client.savePreferences([preference],
+ () => {
+ PreferenceStore.emitChange();
+ this.updateSection('');
+ },
+ (err) => {
+ this.setState({serverError: err.message});
+ }
+ );
+ }
+
+ updateSection(section) {
+ this.props.updateSection(section);
+ }
+
+ handleClose() {
+ this.updateSection('');
+ }
+
+ componentDidMount() {
+ $('#user_settings').on('hidden.bs.modal', this.handleClose);
+ }
+
+ componentWillUnmount() {
+ $('#user_settings').off('hidden.bs.modal', this.handleClose);
+ }
+
+ render() {
+ const serverError = this.state.serverError || null;
+ let ctrlSendSection;
+
+ if (this.props.activeSection === 'advancedCtrlSend') {
+ const ctrlSendActive = [
+ this.state.settings.send_on_ctrl_enter === 'true',
+ this.state.settings.send_on_ctrl_enter === 'false'
+ ];
+
+ const inputs = [
+ <div key='ctrlSendSetting'>
+ <div className='radio'>
+ <label>
+ <input
+ type='radio'
+ checked={ctrlSendActive[0]}
+ onChange={this.updateSetting.bind(this, 'send_on_ctrl_enter', 'true')}
+ />
+ {'On'}
+ </label>
+ <br/>
+ </div>
+ <div className='radio'>
+ <label>
+ <input
+ type='radio'
+ checked={ctrlSendActive[1]}
+ onChange={this.updateSetting.bind(this, 'send_on_ctrl_enter', 'false')}
+ />
+ {'Off'}
+ </label>
+ <br/>
+ </div>
+ <div><br/>{'If enabled \'Enter\' inserts a new line and \'Ctrl + Enter\' submits the message.'}</div>
+ </div>
+ ];
+
+ ctrlSendSection = (
+ <SettingItemMax
+ title='Send messages on Ctrl + Enter'
+ inputs={inputs}
+ submit={() => this.handleSubmit('send_on_ctrl_enter')}
+ server_error={serverError}
+ updateSection={(e) => {
+ this.updateSection('');
+ e.preventDefault();
+ }}
+ />
+ );
+ } else {
+ ctrlSendSection = (
+ <SettingItemMin
+ title='Send messages on Ctrl + Enter'
+ describe={this.state.settings.send_on_ctrl_enter === 'true' ? 'On' : 'Off'}
+ updateSection={() => this.props.updateSection('advancedCtrlSend')}
+ />
+ );
+ }
+
+ return (
+ <div>
+ <div className='modal-header'>
+ <button
+ type='button'
+ className='close'
+ data-dismiss='modal'
+ aria-label='Close'
+ >
+ <span aria-hidden='true'>{'×'}</span>
+ </button>
+ <h4
+ className='modal-title'
+ ref='title'
+ >
+ <i className='modal-back'></i>
+ {'Advanced Settings'}
+ </h4>
+ </div>
+ <div className='user-settings'>
+ <h3 className='tab-header'>{'Advanced Settings'}</h3>
+ <div className='divider-dark first'/>
+ {ctrlSendSection}
+ <div className='divider-dark'/>
+ </div>
+ </div>
+ );
+ }
+}
+
+AdvancedSettingsDisplay.propTypes = {
+ user: React.PropTypes.object,
+ updateSection: React.PropTypes.func,
+ updateTab: React.PropTypes.func,
+ activeSection: React.PropTypes.string
+};
diff --git a/web/react/components/user_settings/user_settings_appearance.jsx b/web/react/components/user_settings/user_settings_appearance.jsx
index e94894a1d..7b4b54e27 100644
--- a/web/react/components/user_settings/user_settings_appearance.jsx
+++ b/web/react/components/user_settings/user_settings_appearance.jsx
@@ -100,7 +100,9 @@ export default class UserSettingsAppearance extends React.Component {
);
}
updateTheme(theme) {
- theme.codeTheme = this.state.theme.codeTheme;
+ if (!theme.codeTheme) {
+ theme.codeTheme = this.state.theme.codeTheme;
+ }
this.setState({theme});
Utils.applyTheme(theme);
}
diff --git a/web/react/components/user_settings/user_settings_general.jsx b/web/react/components/user_settings/user_settings_general.jsx
index 1c8ce3c79..3adac197a 100644
--- a/web/react/components/user_settings/user_settings_general.jsx
+++ b/web/react/components/user_settings/user_settings_general.jsx
@@ -570,6 +570,7 @@ export default class UserSettingsGeneralTab extends React.Component {
/>
);
}
+
return (
<div>
<div className='modal-header'>
diff --git a/web/react/components/user_settings/user_settings_integrations.jsx b/web/react/components/user_settings/user_settings_integrations.jsx
index 4b1e5e532..9bee74343 100644
--- a/web/react/components/user_settings/user_settings_integrations.jsx
+++ b/web/react/components/user_settings/user_settings_integrations.jsx
@@ -43,6 +43,7 @@ export default class UserSettingsIntegrationsTab extends React.Component {
incomingHooksSection = (
<SettingItemMax
title='Incoming Webhooks'
+ width='medium'
inputs={inputs}
updateSection={(e) => {
this.updateSection('');
@@ -54,6 +55,7 @@ export default class UserSettingsIntegrationsTab extends React.Component {
incomingHooksSection = (
<SettingItemMin
title='Incoming Webhooks'
+ width='medium'
describe='Manage your incoming webhooks (Developer feature)'
updateSection={() => {
this.updateSection('incoming-hooks');
@@ -72,6 +74,7 @@ export default class UserSettingsIntegrationsTab extends React.Component {
outgoingHooksSection = (
<SettingItemMax
title='Outgoing Webhooks'
+ width='medium'
inputs={inputs}
updateSection={(e) => {
this.updateSection('');
@@ -83,6 +86,7 @@ export default class UserSettingsIntegrationsTab extends React.Component {
outgoingHooksSection = (
<SettingItemMin
title='Outgoing Webhooks'
+ width='medium'
describe='Manage your outgoing webhooks'
updateSection={() => {
this.updateSection('outgoing-hooks');
diff --git a/web/react/components/user_settings/user_settings_modal.jsx b/web/react/components/user_settings/user_settings_modal.jsx
index 5449ae91e..18dd490e7 100644
--- a/web/react/components/user_settings/user_settings_modal.jsx
+++ b/web/react/components/user_settings/user_settings_modal.jsx
@@ -43,6 +43,7 @@ export default class UserSettingsModal extends React.Component {
tabs.push({name: 'integrations', uiName: 'Integrations', icon: 'glyphicon glyphicon-transfer'});
}
tabs.push({name: 'display', uiName: 'Display', icon: 'glyphicon glyphicon-eye-open'});
+ tabs.push({name: 'advanced', uiName: 'Advanced', icon: 'glyphicon glyphicon-list-alt'});
return (
<div
diff --git a/web/react/components/user_settings/user_settings_notifications.jsx b/web/react/components/user_settings/user_settings_notifications.jsx
index 61d49acb2..2b904763c 100644
--- a/web/react/components/user_settings/user_settings_notifications.jsx
+++ b/web/react/components/user_settings/user_settings_notifications.jsx
@@ -37,18 +37,18 @@ function getNotificationsStateFromStores() {
if (user.notify_props.mention_keys) {
var keys = user.notify_props.mention_keys.split(',');
- if (keys.indexOf(user.username) !== -1) {
+ if (keys.indexOf(user.username) === -1) {
+ usernameKey = false;
+ } else {
usernameKey = true;
keys.splice(keys.indexOf(user.username), 1);
- } else {
- usernameKey = false;
}
- if (keys.indexOf('@' + user.username) !== -1) {
+ if (keys.indexOf('@' + user.username) === -1) {
+ mentionKey = false;
+ } else {
mentionKey = true;
keys.splice(keys.indexOf('@' + user.username), 1);
- } else {
- mentionKey = false;
}
customKeys = keys.join(',');
diff --git a/web/react/pages/channel.jsx b/web/react/pages/channel.jsx
index 03e049db0..7a04c5979 100644
--- a/web/react/pages/channel.jsx
+++ b/web/react/pages/channel.jsx
@@ -90,7 +90,7 @@ function setupChannelPage(props) {
);
ReactDOM.render(
- <TeamSettingsModal teamDisplayName={props.TeamDisplayName} />,
+ <TeamSettingsModal />,
document.getElementById('team_settings_modal')
);
diff --git a/web/react/pages/login.jsx b/web/react/pages/login.jsx
index 430de980c..9865e6fd2 100644
--- a/web/react/pages/login.jsx
+++ b/web/react/pages/login.jsx
@@ -8,6 +8,7 @@ function setupLoginPage(props) {
<Login
teamDisplayName={props.TeamDisplayName}
teamName={props.TeamName}
+ inviteId={props.InviteId}
/>,
document.getElementById('login')
);
diff --git a/web/react/pages/signup_team.jsx b/web/react/pages/signup_team.jsx
index dc8394a77..caa93b5bf 100644
--- a/web/react/pages/signup_team.jsx
+++ b/web/react/pages/signup_team.jsx
@@ -3,9 +3,19 @@
var SignupTeam = require('../components/signup_team.jsx');
-function setupSignupTeamPage() {
+function setupSignupTeamPage(props) {
+ var teams = [];
+
+ for (var prop in props) {
+ if (props.hasOwnProperty(prop)) {
+ if (prop !== 'Title') {
+ teams.push({name: prop, display_name: props[prop]});
+ }
+ }
+ }
+
ReactDOM.render(
- <SignupTeam />,
+ <SignupTeam teams={teams} />,
document.getElementById('signup-team')
);
}
diff --git a/web/react/stores/post_store.jsx b/web/react/stores/post_store.jsx
index a58fdde3a..8f4e30e7c 100644
--- a/web/react/stores/post_store.jsx
+++ b/web/react/stores/post_store.jsx
@@ -172,14 +172,15 @@ class PostStoreClass extends EventEmitter {
var lastPost = null;
for (i; i < len; i++) {
- if (postList.posts[postList.order[i]].user_id === userId) {
+ let post = postList.posts[postList.order[i]];
+ if (post.user_id === userId && (post.props && !post.props.from_webhook || !post.props)) {
if (rootId) {
- if (postList.posts[postList.order[i]].root_id === rootId || postList.posts[postList.order[i]].id === rootId) {
- lastPost = postList.posts[postList.order[i]];
+ if (post.root_id === rootId || post.id === rootId) {
+ lastPost = post;
break;
}
} else {
- lastPost = postList.posts[postList.order[i]];
+ lastPost = post;
break;
}
}
diff --git a/web/react/stores/socket_store.jsx b/web/react/stores/socket_store.jsx
index 4d69a6716..4efeb7c8f 100644
--- a/web/react/stores/socket_store.jsx
+++ b/web/react/stores/socket_store.jsx
@@ -165,7 +165,7 @@ function handleNewPostEvent(msg) {
}
// Send desktop notification
- if (UserStore.getCurrentId() !== msg.user_id) {
+ if (UserStore.getCurrentId() !== msg.user_id || post.props.from_webhook === 'true') {
const msgProps = msg.props;
let mentions = [];
@@ -189,7 +189,9 @@ function handleNewPostEvent(msg) {
}
let username = 'Someone';
- if (UserStore.hasProfile(msg.user_id)) {
+ if (post.props.override_username && global.window.mm_config.EnablePostUsernameOverride === 'true') {
+ username = post.props.override_username;
+ } else if (UserStore.hasProfile(msg.user_id)) {
username = UserStore.getProfile(msg.user_id).username;
}
diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx
index bf117b3b3..7ce1346f9 100644
--- a/web/react/utils/client.jsx
+++ b/web/react/utils/client.jsx
@@ -442,16 +442,16 @@ export function inviteMembers(data, success, error) {
track('api', 'api_teams_invite_members');
}
-export function updateTeamDisplayName(data, success, error) {
+export function updateTeam(team, success, error) {
$.ajax({
- url: '/api/v1/teams/update_name',
+ url: '/api/v1/teams/update',
dataType: 'json',
contentType: 'application/json',
type: 'POST',
- data: JSON.stringify(data),
+ data: JSON.stringify(team),
success,
- error: function onError(xhr, status, err) {
- var e = handleError('updateTeamDisplayName', xhr, status, err);
+ error: (xhr, status, err) => {
+ var e = handleError('updateTeam', xhr, status, err);
error(e);
}
});
@@ -589,21 +589,38 @@ export function updateChannel(channel, success, error) {
track('api', 'api_channels_update');
}
-export function updateChannelDesc(data, success, error) {
+export function updateChannelHeader(data, success, error) {
+ $.ajax({
+ url: '/api/v1/channels/update_header',
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(data),
+ success,
+ error: function onError(xhr, status, err) {
+ var e = handleError('updateChannelHeader', xhr, status, err);
+ error(e);
+ }
+ });
+
+ track('api', 'api_channels_header');
+}
+
+export function updateChannelPurpose(data, success, error) {
$.ajax({
- url: '/api/v1/channels/update_desc',
+ url: '/api/v1/channels/update_purpose',
dataType: 'json',
contentType: 'application/json',
type: 'POST',
data: JSON.stringify(data),
success,
error: function onError(xhr, status, err) {
- var e = handleError('updateChannelDesc', xhr, status, err);
+ var e = handleError('updateChannelPurpose', xhr, status, err);
error(e);
}
});
- track('api', 'api_channels_desc');
+ track('api', 'api_channels_purpose');
}
export function updateNotifyProps(data, success, error) {
diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx
index 0e89b9470..1593f6706 100644
--- a/web/react/utils/constants.jsx
+++ b/web/react/utils/constants.jsx
@@ -127,6 +127,8 @@ module.exports = {
MAX_DMS: 20,
DM_CHANNEL: 'D',
OPEN_CHANNEL: 'O',
+ INVITE_TEAM: 'I',
+ OPEN_TEAM: 'O',
MAX_POST_LEN: 4000,
EMOJI_SIZE: 16,
ONLINE_ICON_SVG: "<svg version='1.1' id='Layer_1' xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:cc='http://creativecommons.org/ns#' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:svg='http://www.w3.org/2000/svg' xmlns:sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd' xmlns:inkscape='http://www.inkscape.org/namespaces/inkscape' sodipodi:docname='TRASH_1_4.svg' inkscape:version='0.48.4 r9939' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='12px' height='12px' viewBox='0 0 12 12' enable-background='new 0 0 12 12' xml:space='preserve'><sodipodi:namedview inkscape:cy='139.7898' inkscape:cx='26.358185' inkscape:zoom='1.18' showguides='true' showgrid='false' id='namedview6' guidetolerance='10' gridtolerance='10' objecttolerance='10' borderopacity='1' bordercolor='#666666' pagecolor='#ffffff' inkscape:current-layer='Layer_1' inkscape:window-maximized='1' inkscape:window-y='-8' inkscape:window-x='-8' inkscape:window-height='705' inkscape:window-width='1366' inkscape:guide-bbox='true' inkscape:pageshadow='2' inkscape:pageopacity='0'><sodipodi:guide position='50.036793,85.991376' orientation='1,0' id='guide2986'></sodipodi:guide><sodipodi:guide position='58.426196,66.216355' orientation='0,1' id='guide3047'></sodipodi:guide></sodipodi:namedview><g><g><path class='online--icon' d='M6,5.487c1.371,0,2.482-1.116,2.482-2.493c0-1.378-1.111-2.495-2.482-2.495S3.518,1.616,3.518,2.994C3.518,4.371,4.629,5.487,6,5.487z M10.452,8.545c-0.101-0.829-0.36-1.968-0.726-2.541C9.475,5.606,8.5,5.5,8.5,5.5S8.43,7.521,6,7.521C3.507,7.521,3.5,5.5,3.5,5.5S2.527,5.606,2.273,6.004C1.908,6.577,1.648,7.716,1.547,8.545C1.521,8.688,1.49,9.082,1.498,9.142c0.161,1.295,2.238,2.322,4.375,2.358C5.916,11.501,5.958,11.501,6,11.501c0.043,0,0.084,0,0.127-0.001c2.076-0.026,4.214-1.063,4.375-2.358C10.509,9.082,10.471,8.696,10.452,8.545z'/></g></g></svg>",
@@ -311,7 +313,8 @@ module.exports = {
DEFAULT_CODE_THEME: 'github',
Preferences: {
CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show',
- CATEGORY_DISPLAY_SETTINGS: 'display_settings'
+ CATEGORY_DISPLAY_SETTINGS: 'display_settings',
+ CATEGORY_ADVANCED_SETTINGS: 'advanced_settings'
},
KeyCodes: {
UP: 38,
diff --git a/web/react/utils/markdown.jsx b/web/react/utils/markdown.jsx
index ad11a95ac..179416ea0 100644
--- a/web/react/utils/markdown.jsx
+++ b/web/react/utils/markdown.jsx
@@ -34,7 +34,43 @@ const highlightJsIni = require('highlight.js/lib/languages/ini.js');
const Constants = require('../utils/constants.jsx');
const HighlightedLanguages = Constants.HighlightedLanguages;
-export class MattermostMarkdownRenderer extends marked.Renderer {
+class MattermostInlineLexer extends marked.InlineLexer {
+ constructor(links, options) {
+ super(links, options);
+
+ this.rules = Object.assign({}, this.rules);
+
+ // modified version of the regex that doesn't break up words in snake_case,
+ // allows for links starting with www, and allows links succounded by parentheses
+ // the original is /^[\s\S]+?(?=[\\<!\[_*`~]|https?:\/\/| {2,}\n|$)/
+ this.rules.text = /^[\s\S]+?(?=[^\w\/]_|[\\<!\[*`~]|https?:\/\/|www\.|\(| {2,}\n|$)/;
+
+ // modified version of the regex that allows links starting with www and those surrounded
+ // by parentheses
+ // the original is /^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/
+ this.rules.url = /^(\(?(?:https?:\/\/|www\.)[^\s<.][^\s<]*[^<.,:;"'\]\s])/;
+
+ // modified version of the regex that allows <links> starting with www.
+ // the original is /^<([^ >]+(@|:\/)[^ >]+)>/
+ this.rules.autolink = /^<((?:[^ >]+(@|:\/)|www\.)[^ >]+)>/;
+ }
+}
+
+class MattermostParser extends marked.Parser {
+ parse(src) {
+ this.inline = new MattermostInlineLexer(src.links, this.options, this.renderer);
+ this.tokens = src.reverse();
+
+ var out = '';
+ while (this.next()) {
+ out += this.tok();
+ }
+
+ return out;
+ }
+}
+
+class MattermostMarkdownRenderer extends marked.Renderer {
constructor(options, formattingOptions = {}) {
super(options);
@@ -70,15 +106,21 @@ export class MattermostMarkdownRenderer extends marked.Renderer {
}
code(code, language) {
- if (!language || highlightJs.listLanguages().indexOf(language) < 0) {
- let parsed = super.code(code, language);
- return '<code class="hljs">' + $(parsed).text() + '</code>';
+ let usedLanguage = language;
+
+ if (String(usedLanguage).toLocaleLowerCase() === 'html') {
+ usedLanguage = 'xml';
}
- let parsed = highlightJs.highlight(language, code);
+ if (!usedLanguage || highlightJs.listLanguages().indexOf(usedLanguage) < 0) {
+ let parsed = super.code(code, usedLanguage);
+ return '<div class="post-body--code"><code class="hljs">' + TextFormatting.sanitizeHtml($(parsed).text()) + '</code></div>';
+ }
+
+ let parsed = highlightJs.highlight(usedLanguage, code);
return '<div class="post-body--code">' +
- '<span class="post-body--code__language">' + HighlightedLanguages[language] + '</span>' +
- '<code style="white-space: pre;" class="hljs">' + parsed.value + '</code>' +
+ '<span class="post-body--code__language">' + HighlightedLanguages[usedLanguage] + '</span>' +
+ '<code class="hljs">' + parsed.value + '</code>' +
'</div>';
}
@@ -97,8 +139,20 @@ export class MattermostMarkdownRenderer extends marked.Renderer {
link(href, title, text) {
let outHref = href;
+ let outText = text;
+ let prefix = '';
+ let suffix = '';
+
+ // some links like https://en.wikipedia.org/wiki/Rendering_(computer_graphics) contain brackets
+ // and we try our best to differentiate those from ones just wrapped in brackets when autolinking
+ if (outHref.startsWith('(') && outHref.endsWith(')') && text === outHref) {
+ prefix = '(';
+ suffix = ')';
+ outText = text.substring(1, text.length - 1);
+ outHref = outHref.substring(1, outHref.length - 1);
+ }
- if (!(/^(mailto|https?|ftp)/.test(outHref))) {
+ if (!(/[a-z+.-]+:/i).test(outHref)) {
outHref = `http://${outHref}`;
}
@@ -113,26 +167,17 @@ export class MattermostMarkdownRenderer extends marked.Renderer {
output += ' target="_blank">';
}
- output += text + '</a>';
+ output += outText + '</a>';
- return output;
+ return prefix + output + suffix;
}
paragraph(text) {
- let outText = text;
-
- // required so markdown does not strip '_' from @user_names
- outText = TextFormatting.doFormatMentions(text);
-
- if (!('emoticons' in this.options) || this.options.emoticon) {
- outText = TextFormatting.doFormatEmoticons(outText);
- }
-
if (this.formattingOptions.singleline) {
- return `<p class="markdown__paragraph-inline">${outText}</p>`;
+ return `<p class="markdown__paragraph-inline">${text}</p>`;
}
- return super.paragraph(outText);
+ return super.paragraph(text);
}
table(header, body) {
@@ -143,3 +188,16 @@ export class MattermostMarkdownRenderer extends marked.Renderer {
return TextFormatting.doFormatText(txt, this.formattingOptions);
}
}
+
+export function format(text, options) {
+ const markdownOptions = {
+ renderer: new MattermostMarkdownRenderer(null, options),
+ sanitize: true,
+ gfm: true
+ };
+
+ const tokens = marked.lexer(text, markdownOptions);
+
+ return new MattermostParser(markdownOptions).parse(tokens);
+}
+
diff --git a/web/react/utils/text_formatting.jsx b/web/react/utils/text_formatting.jsx
index 9f1a5a53f..2de858a17 100644
--- a/web/react/utils/text_formatting.jsx
+++ b/web/react/utils/text_formatting.jsx
@@ -8,8 +8,6 @@ const Markdown = require('./markdown.jsx');
const UserStore = require('../stores/user_store.jsx');
const Utils = require('./utils.jsx');
-const marked = require('marked');
-
// Performs formatting of user posts including highlighting mentions and search terms and converting urls, hashtags, and
// @mentions to links by taking a user's message and returning a string of formatted html. Also takes a number of options
// as part of the second parameter:
@@ -22,11 +20,8 @@ export function formatText(text, options = {}) {
let output;
if (!('markdown' in options) || options.markdown) {
- // the markdown renderer will call doFormatText as necessary so just call marked
- output = marked(text, {
- renderer: new Markdown.MattermostMarkdownRenderer(null, options),
- sanitize: true
- });
+ // the markdown renderer will call doFormatText as necessary
+ output = Markdown.format(text, options);
} else {
output = sanitizeHtml(text);
output = doFormatText(output, options);
@@ -48,7 +43,7 @@ export function doFormatText(text, options) {
// replace important words and phrases with tokens
output = autolinkAtMentions(output, tokens);
- output = autolinkUrls(output, tokens);
+ output = autolinkEmails(output, tokens);
output = autolinkHashtags(output, tokens);
if (!('emoticons' in options) || options.emoticon) {
@@ -98,28 +93,21 @@ export function sanitizeHtml(text) {
return output;
}
-// Convert URLs into tokens
-function autolinkUrls(text, tokens) {
- function replaceUrlWithToken(autolinker, match) {
+// Convert emails into tokens
+function autolinkEmails(text, tokens) {
+ function replaceEmailWithToken(autolinker, match) {
const linkText = match.getMatchedText();
let url = linkText;
if (match.getType() === 'email') {
url = `mailto:${url}`;
- } else if (!(/^(mailto|https?|ftp)/.test(url))) {
- url = `http://${url}`;
}
const index = tokens.size;
- const alias = `MM_LINK${index}`;
-
- var target = 'target="_blank"';
- if (url.lastIndexOf(Utils.getTeamURLFromAddressBar(), 0) === 0) {
- target = '';
- }
+ const alias = `MM_EMAIL${index}`;
tokens.set(alias, {
- value: `<a class="theme" ${target} href="${url}">${linkText}</a>`,
+ value: `<a class="theme" href="${url}">${linkText}</a>`,
originalText: linkText
});
@@ -128,12 +116,12 @@ function autolinkUrls(text, tokens) {
// we can't just use a static autolinker because we need to set replaceFn
const autolinker = new Autolinker({
- urls: true,
+ urls: false,
email: true,
phone: false,
twitter: false,
hashtag: false,
- replaceFn: replaceUrlWithToken
+ replaceFn: replaceEmailWithToken
});
return autolinker.link(text);
diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx
index fadab27a7..4bd651649 100644
--- a/web/react/utils/utils.jsx
+++ b/web/react/utils/utils.jsx
@@ -211,11 +211,15 @@ export function displayDateTime(ticks) {
}
interval = Math.floor(seconds / 60);
- if (interval > 1) {
+ if (interval >= 2) {
return interval + ' minutes ago';
}
- return '1 minute ago';
+ if (interval >= 1) {
+ return '1 minute ago';
+ }
+
+ return 'just now';
}
export function displayCommentDateTime(ticks) {
@@ -227,46 +231,62 @@ export function getTimestamp() {
return Date.now();
}
-function testUrlMatch(text) {
- var urlMatcher = new Autolinker.matchParser.MatchParser({
+// extracts links not styled by Markdown
+export function extractLinks(text) {
+ const urlMatcher = new Autolinker.matchParser.MatchParser({
urls: true,
emails: false,
twitter: false,
phone: false,
hashtag: false
});
- var result = [];
+ const links = [];
+ let replaceText = text;
+
+ // pull out the Markdown code blocks
+ const codeBlocks = [];
+ const splitText = replaceText.split('`'); // also handles ```
+ for (let i = 1; i < splitText.length; i += 2) {
+ if (splitText[i].trim() !== '') {
+ codeBlocks.push(splitText[i]);
+ }
+ }
+
function replaceFn(match) {
- var linkData = {};
- var matchText = match.getMatchedText();
+ let link = '';
+ const matchText = match.getMatchedText();
+ const tempText = replaceText;
+
+ const start = replaceText.indexOf(matchText);
+ const end = start + matchText.length;
+
+ replaceText = replaceText.substring(0, start) + replaceText.substring(end);
+
+ // if it's a Markdown link, just skip it
+ if (start > 1) {
+ if (tempText.charAt(start - 2) === ']' && tempText.charAt(start - 1) === '(' && tempText.charAt(end) === ')') {
+ return;
+ }
+ }
+
+ // if it's in a Markdown code block, skip it
+ for (const i in codeBlocks) {
+ if (codeBlocks[i].indexOf(matchText) === 0) {
+ codeBlocks[i] = codeBlocks[i].replace(matchText, '');
+ return;
+ }
+ }
- linkData.text = matchText;
if (matchText.trim().indexOf('http') === 0) {
- linkData.link = matchText;
+ link = matchText;
} else {
- linkData.link = 'http://' + matchText;
+ link = 'http://' + matchText;
}
- result.push(linkData);
+ links.push(link);
}
urlMatcher.replace(text, replaceFn, this);
- return result;
-}
-
-export function extractLinks(text) {
- var repRegex = new RegExp('<br>', 'g');
- var matches = testUrlMatch(text.replace(repRegex, '\n'));
-
- if (!matches.length) {
- return {links: null, text: text};
- }
-
- var links = [];
- for (var i = 0; i < matches.length; i++) {
- links.push(matches[i].link);
- }
-
- return {links: links, text: text};
+ return {links, text};
}
export function escapeRegExp(string) {
@@ -439,6 +459,8 @@ export function applyTheme(theme) {
if (theme.sidebarTextActiveColor) {
changeCss('.sidebar--left .nav-pills__container li.active a, .sidebar--left .nav-pills__container li.active a:hover, .sidebar--left .nav-pills__container li.active a:focus, .settings-modal .nav-pills>li.active a, .settings-modal .nav-pills>li.active a:hover, .settings-modal .nav-pills>li.active a:active', 'color:' + theme.sidebarTextActiveColor, 2);
changeCss('.sidebar--left .nav li.active a, .sidebar--left .nav li.active a:hover, .sidebar--left .nav li.active a:focus', 'background:' + changeOpacity(theme.sidebarTextActiveColor, 0.1), 1);
+ changeCss('.search-help-popover .search-autocomplete__item:hover', 'background:' + changeOpacity(theme.sidebarTextActiveColor, 0.05), 1);
+ changeCss('.search-help-popover .search-autocomplete__item.selected', 'background:' + changeOpacity(theme.sidebarTextActiveColor, 0.15), 1);
}
if (theme.sidebarHeaderBg) {
@@ -954,7 +976,7 @@ export function isBrowserIE() {
}
export function isBrowserEdge() {
- return window.naviagtor && navigator.userAgent && navigator.userAgent.toLowerCase().indexOf('edge') > -1;
+ return window.navigator && navigator.userAgent && navigator.userAgent.toLowerCase().indexOf('edge') > -1;
}
export function getDirectChannelName(id, otherId) {
diff --git a/web/sass-files/sass/partials/_admin-console.scss b/web/sass-files/sass/partials/_admin-console.scss
index d0241f795..206d5bfca 100644
--- a/web/sass-files/sass/partials/_admin-console.scss
+++ b/web/sass-files/sass/partials/_admin-console.scss
@@ -2,64 +2,16 @@
> div {
height: 100%;
}
- .table {
- background: #fff;
- }
-
- .total-count {
- width: 175px;
- height: 100px;
- border: 1px solid #ddd;
- padding: 22px 10px 10px 10px;
- margin: 10px 10px 10px 10px;
- background: #fff;
- float: left;
-
- > div {
- font-size: 18px;
- font-weight: 300;
- }
- }
-
- .total-count-by-day {
- width: 760px;
- height: 275px;
- border: 1px solid #ddd;
- padding: 5px 10px 10px 10px;
- margin: 10px 10px 10px 10px;
- background: #fff;
- clear: both;
- > div {
- font-size: 18px;
- font-weight: 300;
- }
+ h3 {
+ font-weight: 600;
+ border-bottom: 1px solid #ddd;
+ padding-bottom: 0.5em;
+ margin: 1em 0;
}
- .recent-active-users {
- width: 365px;
- height: 375px;
- border: 1px solid #ddd;
- padding: 5px 10px 10px 10px;
- margin: 10px 10px 10px 10px;
+ .table {
background: #fff;
- float: left;
-
- > div {
- font-size: 18px;
- font-weight: 300;
- }
-
- > table {
- margin: 10px 10px 10px 10px;
- }
-
- .recent-active-users-td {
- font-size: 14px;
- font-weight: 300;
- border: 1px solid #ddd;
- padding: 3px 3px 3px 5px;
- }
}
.sidebar--left {
diff --git a/web/sass-files/sass/partials/_base.scss b/web/sass-files/sass/partials/_base.scss
index 6399b8fd8..635928fe3 100644
--- a/web/sass-files/sass/partials/_base.scss
+++ b/web/sass-files/sass/partials/_base.scss
@@ -37,6 +37,9 @@ body {
img {
max-width: 100%;
height: auto;
+ &.rounded {
+ @include border-radius(100%);
+ }
}
.popover {
@@ -122,6 +125,9 @@ a:focus, a:hover {
&.no-resize {
resize: none;
}
+ &.min-height {
+ min-height: 100px;
+ }
}
.form-control[disabled], .form-control[readonly], fieldset[disabled] .form-control {
diff --git a/web/sass-files/sass/partials/_headers.scss b/web/sass-files/sass/partials/_headers.scss
index 8bf163f90..feb392234 100644
--- a/web/sass-files/sass/partials/_headers.scss
+++ b/web/sass-files/sass/partials/_headers.scss
@@ -1,3 +1,6 @@
+#channel-header {
+ padding: 3px 0;
+}
.row {
&.header {
position: relative;
@@ -215,7 +218,7 @@
color: #999;
cursor: pointer;
.fa {
- margin-left: 3px;
+ margin-left: 4px;
font-size: 16px;
}
}
diff --git a/web/sass-files/sass/partials/_modal.scss b/web/sass-files/sass/partials/_modal.scss
index 1dcdbf348..9314b4980 100644
--- a/web/sass-files/sass/partials/_modal.scss
+++ b/web/sass-files/sass/partials/_modal.scss
@@ -113,11 +113,10 @@
text-align: center;
padding: 2em 1em;
.primary-message {
- font-size: 1.2em;
+ font-size: 1.25em;
}
.secondary-message {
- font-size: 1.25em;
- color: #888;
+ @include opacity(0.8);
margin: 1em 0 0;
}
}
@@ -377,7 +376,7 @@
@include opacity(0.8);
}
- .more-description {
+ .more-purpose {
@include opacity(0.7);
}
diff --git a/web/sass-files/sass/partials/_popover.scss b/web/sass-files/sass/partials/_popover.scss
index 4a2ad2748..430813e63 100644
--- a/web/sass-files/sass/partials/_popover.scss
+++ b/web/sass-files/sass/partials/_popover.scss
@@ -28,6 +28,39 @@
@include single-transition(opacity, 0.3s, ease-in);
font-size: em(13px);
+ &.autocomplete {
+ display: block;
+ .popover-content {
+ padding: 10px;
+ position: relative;
+ }
+ }
+
+ .search-autocomplete__item {
+ cursor: pointer;
+ padding: 6px 8px;
+ margin: 3px 0;
+ @include border-radius(2px);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &:hover {
+ background: rgba(black, 0.1);
+ }
+
+ &.selected {
+ background: rgba(black, 0.2);
+ }
+
+ .profile-img {
+ margin-top: -1px;
+ height: 16px;
+ margin-right: 6px;
+ width: 16px;
+ }
+ }
+
&.bottom > .arrow {
top: -18px;
border-width: 9px;
@@ -64,33 +97,20 @@
#member-list-popover {
max-width: initial;
- .popover-content > div {
+ .popover-content {
+ position: relative;
+ padding: 0;
+ width: 260px;
max-height: 350px;
- overflow-y: auto;
- overflow-x: hidden;
- > div {
- border-bottom: 1px solid rgba(51,51,51,0.1);
- padding: 8px 8px 8px 15px;
+ .text-nowrap {
+ padding: 6px 10px;
width: 100%;
- box-sizing: content-box;
- @include clearfix;
- .profile-img {
- border-radius: 50px;
- margin-right: 8px;
- }
- .more-name {
- font-weight: 600;
- font-size: 0.95em;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .more-description {
- @include opacity(0.7);
- }
- .profile-action {
- margin-left: 8px;
- margin-right: 18px;
- }
+ overflow: hidden;
+ line-height: 26px;
+ font-size: 13px;
+ }
+ .more-name {
+ margin-left: 6px;
}
}
}
diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss
index 3fac1fed9..7709e17f3 100644
--- a/web/sass-files/sass/partials/_post.scss
+++ b/web/sass-files/sass/partials/_post.scss
@@ -397,11 +397,18 @@ body.ios {
padding: 0;
}
p {
- margin: 0 0 5px;
+ margin: 0 0 1em;
+ line-height: 1.6em;
font-size: 0.97em;
white-space: pre-wrap;
}
+ span {
+ p:last-child {
+ margin-bottom: 0.5em;
+ }
+ }
+
.post-loading-gif {
height:10px;
width:10px;
diff --git a/web/sass-files/sass/partials/_responsive.scss b/web/sass-files/sass/partials/_responsive.scss
index 2cd5560ef..8f353c401 100644
--- a/web/sass-files/sass/partials/_responsive.scss
+++ b/web/sass-files/sass/partials/_responsive.scss
@@ -379,7 +379,9 @@
}
.btn {
&.btn-primary {
- margin: 8px 0 0 -10px;
+ display: block;
+ margin: 10px 0 6px;
+ width: 100%;
float: none;
}
}
@@ -401,7 +403,13 @@
&.minimize-settings {
display: block;
.section-edit {
- text-align: left;
+ position: absolute;
+ top: 7px;
+ right: 0;
+ width: 50px;
+ .fa {
+ display: inline-block;
+ }
}
}
.no-padding--left {
@@ -755,7 +763,7 @@
.channel__wrap & {
padding-top: 45px;
}
- .channel-header {
+ #channel-header {
display: none;
}
}
diff --git a/web/sass-files/sass/partials/_search.scss b/web/sass-files/sass/partials/_search.scss
index ce3563885..73b69c866 100644
--- a/web/sass-files/sass/partials/_search.scss
+++ b/web/sass-files/sass/partials/_search.scss
@@ -1,5 +1,8 @@
+#channel-header .search-bar__container {
+ padding: 8px 8px 8px 0;
+}
.search-bar__container {
- padding: 8px 8px 8px 0;
+ padding: 12px 8px 12px 0;
}
.search__clear {
display: none;
@@ -108,44 +111,4 @@
.search-highlight {
background-color: #FFF2BB;
-}
-
-.search-autocomplete {
- background-color: #fff;
- border: $border-gray;
- line-height: 36px;
- overflow-x: hidden;
- overflow-y: scroll;
- position: absolute;
- text-align: left;
- width: 100%;
- z-index: 100;
- @extend %popover-box-shadow;
-}
-
-.search-autocomplete__channel {
- cursor: pointer;
- height: 36px;
- padding: 0px 6px;
-
- &.selected {
- background-color:rgba(51, 51, 51, 0.15);
- }
-}
-
-.search-autocomplete__user {
- cursor: pointer;
- height: 36px;
- padding: 0px;
-
- .profile-img {
- height: 32px;
- margin-right: 6px;
- width: 32px;
- @include border-radius(16px);
- }
-
- &.selected {
- background-color:rgba(51, 51, 51, 0.15);
- }
-}
+} \ No newline at end of file
diff --git a/web/sass-files/sass/partials/_settings.scss b/web/sass-files/sass/partials/_settings.scss
index c881f9073..fbbd07485 100644
--- a/web/sass-files/sass/partials/_settings.scss
+++ b/web/sass-files/sass/partials/_settings.scss
@@ -33,6 +33,33 @@
label {
font-weight: 600;
}
+ .no-padding--left {
+ padding-left: 0;
+ }
+ .padding-top {
+ padding-top: 7px;
+ &.x2 {
+ padding-top: 14px;
+ }
+ &.x3 {
+ padding-top: 21px;
+ }
+ }
+ .padding-bottom {
+ padding-bottom: 7px;
+ &.x2 {
+ padding-bottom: 14px;
+ }
+ &.x3 {
+ padding-bottom: 21px;
+ }
+ .control-label {
+ font-weight: 600;
+ &.text-left {
+ text-align: left;
+ }
+ }
+ }
.settings-table {
display: table;
table-layout: fixed;
@@ -44,7 +71,7 @@
.nav {
position: fixed;
top: 57px;
- width: 180px;
+ width: 179px;
}
.security-links {
margin-right: 20px;
@@ -66,6 +93,7 @@
padding: 1em 0;
margin-bottom: 0;
cursor: pointer;
+ position: relative;
@include clearfix;
&:hover {
background: #f9f9f9;
@@ -170,33 +198,6 @@
.has-error {
color: #a94442;
}
- .no-padding--left {
- padding-left: 0;
- }
- .padding-top {
- padding-top: 7px;
- &.x2 {
- padding-top: 14px;
- }
- &.x3 {
- padding-top: 21px;
- }
- }
- .padding-bottom {
- padding-bottom: 7px;
- &.x2 {
- padding-bottom: 14px;
- }
- &.x3 {
- padding-bottom: 21px;
- }
- .control-label {
- font-weight: 600;
- &.text-left {
- text-align: left;
- }
- }
- }
.file-status {
font-size: 13px;
diff --git a/web/sass-files/sass/partials/_signup.scss b/web/sass-files/sass/partials/_signup.scss
index 6d0256142..14c676f82 100644
--- a/web/sass-files/sass/partials/_signup.scss
+++ b/web/sass-files/sass/partials/_signup.scss
@@ -313,6 +313,34 @@
}
+.signup-team-all {
+ width: 280px;
+ box-shadow: 3px 3px 1px #d5d5d5;
+ margin: 0px 0px 0px 5px;
+}
+
+.signup-team-dir {
+ background: #fafafa;
+ border-bottom: 1px solid #d5d5d5;
+}
+
+.signup-team-dir__group {
+ padding: 15px 10px 15px 10px;
+}
+
+.signup-team-dir__name {
+ line-height: 1.3 !important;
+ font-size: 1.5em !important;
+ font-weight: 300 !important;
+}
+
+.signup-team-dir__arrow {
+ float: right;
+ line-height: 1.3 !important;
+ font-size: 1.5em !important;
+ font-weight: 300 !important;
+}
+
.authorize-box {
margin: 100px auto;
width:500px;
diff --git a/web/sass-files/sass/partials/_statistics.scss b/web/sass-files/sass/partials/_statistics.scss
new file mode 100644
index 000000000..a2401a70f
--- /dev/null
+++ b/web/sass-files/sass/partials/_statistics.scss
@@ -0,0 +1,84 @@
+.team_statistics {
+ .total-count {
+ margin: 1em 0;
+ text-align: center;
+ background: #fff;
+ border: 1px solid #ddd;
+ width: 100%;
+ @include border-radius(3px);
+
+ .title {
+ font-weight: 400;
+ padding: 7px 10px;
+ border-bottom: 1px solid #ddd;
+ text-align: left;
+
+ .fa {
+ float: right;
+ margin: 3px 0 0;
+ color: #555;
+ font-size: 16px;
+ }
+
+ }
+
+ .content {
+ font-size: 30px;
+ font-weight: 600;
+ color: #555;
+ padding: 0.3em 0 0.35em;
+ overflow: auto;
+ }
+
+ }
+
+ .total-count--day {
+ width: 760px;
+ height: 275px;
+ border: 1px solid #ddd;
+ padding: 5px 10px 10px 10px;
+ margin: 10px 10px 10px 10px;
+ background: #fff;
+ clear: both;
+
+ > div {
+ font-size: 18px;
+ font-weight: 300;
+ }
+ }
+
+ .recent-active-users {
+
+ table {
+ width: 100%;
+ table-layout: fixed;
+ }
+
+ .content {
+ max-height: 400px;
+ overflow: auto;
+ padding: 0;
+ }
+
+ tr {
+ &:first-child {
+ td {
+ border-top: none;
+ }
+ }
+ td {
+ font-weight: 400;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ font-size: 13px;
+ border-left: 1px solid #ddd;
+ border-top: 1px solid #ddd;
+ padding: 5px 5px 6px;
+ @include clearfix;
+ &:first-child {
+ border-left: none;
+ }
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/web/sass-files/sass/styles.scss b/web/sass-files/sass/styles.scss
index c614052da..ad2cae194 100644
--- a/web/sass-files/sass/styles.scss
+++ b/web/sass-files/sass/styles.scss
@@ -38,6 +38,7 @@
@import "partials/loading";
@import "partials/get-link";
@import "partials/markdown";
+@import "partials/statistics";
// Responsive Css
@import "partials/responsive";
diff --git a/web/static/help/about.html b/web/static/help/about.html
index 4659aa9cc..6bdbee27e 100644
--- a/web/static/help/about.html
+++ b/web/static/help/about.html
@@ -5,7 +5,7 @@
</p>
<p>We built Mattermost to help teams focus on what matters most to them. It works for us, we hope it works for you too.
-Learn more, or download the source code from <a href=http://mattermost.com>http://mattermost.com</a>.</p>
+Learn more, or download the source code from <a href=http://mattermost.org>http://mattermost.org</a>.</p>
<h1>Join the community</h1>
<p>To take part in the community building Mattermost, please consider sharing comments, feature requests, votes, and contributions. If you like the project, please Tweet about us at <a href=https://twitter.com/mattermosthq>@mattermosthq</a>.</p>
diff --git a/web/web.go b/web/web.go
index bffe4858e..34f4df08f 100644
--- a/web/web.go
+++ b/web/web.go
@@ -132,7 +132,7 @@ func watchAndParseTemplates() {
}
}
-var browsersNotSupported string = "MSIE/8;MSIE/9;Internet Explorer/8;Internet Explorer/9"
+var browsersNotSupported string = "MSIE/8;MSIE/9;MSIE/10;Internet Explorer/8;Internet Explorer/9;Internet Explorer/10;Safari/7"
func CheckBrowserCompatability(c *api.Context, r *http.Request) bool {
ua := user_agent.New(r.UserAgent())
@@ -143,7 +143,7 @@ func CheckBrowserCompatability(c *api.Context, r *http.Request) bool {
version := strings.Split(browser, "/")
if strings.HasPrefix(bname, version[0]) && strings.HasPrefix(bversion, version[1]) {
- c.Err = model.NewAppError("CheckBrowserCompatability", "Your current browser is not supported, please upgrade to one of the following browsers: Google Chrome 21 or higher, Internet Explorer 10 or higher, FireFox 14 or higher", "")
+ c.Err = model.NewAppError("CheckBrowserCompatability", "Your current browser is not supported, please upgrade to one of the following browsers: Google Chrome 21 or higher, Internet Explorer 11 or higher, FireFox 14 or higher, Safari 8 or higher", "")
return false
}
}
@@ -152,20 +152,6 @@ func CheckBrowserCompatability(c *api.Context, r *http.Request) bool {
}
-// func getTeamAndUser(c *api.Context) (*model.Team, *model.User) {
-// if tr := <-api.Srv.Store.Team().Get(c.Session.TeamId); tr.Err != nil {
-// c.Err = tr.Err
-// return nil, nil
-// } else {
-// if ur := <-api.Srv.Store.User().Get(c.Session.UserId); ur.Err != nil {
-// c.Err = ur.Err
-// return nil, nil
-// } else {
-// return tr.Data.(*model.Team), ur.Data.(*model.User)
-// }
-// }
-// }
-
func root(c *api.Context, w http.ResponseWriter, r *http.Request) {
if !CheckBrowserCompatability(c, r) {
@@ -174,6 +160,22 @@ func root(c *api.Context, w http.ResponseWriter, r *http.Request) {
if len(c.Session.UserId) == 0 {
page := NewHtmlTemplatePage("signup_team", "Signup")
+
+ if result := <-api.Srv.Store.Team().GetAllTeamListing(); result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ teams := result.Data.([]*model.Team)
+ for _, team := range teams {
+ page.Props[team.Name] = team.DisplayName
+ }
+
+ if len(teams) == 1 && *utils.Cfg.TeamSettings.EnableTeamListing {
+ http.Redirect(w, r, c.GetSiteURL()+"/"+teams[0].Name, http.StatusTemporaryRedirect)
+ return
+ }
+ }
+
page.Render(c, w)
} else {
teamChan := api.Srv.Store.Team().Get(c.Session.TeamId)
@@ -240,6 +242,11 @@ func login(c *api.Context, w http.ResponseWriter, r *http.Request) {
page := NewHtmlTemplatePage("login", "Login")
page.Props["TeamDisplayName"] = team.DisplayName
page.Props["TeamName"] = team.Name
+
+ if team.AllowOpenInvite {
+ page.Props["InviteId"] = team.InviteId
+ }
+
page.Render(c, w)
}
@@ -285,7 +292,7 @@ func signupUserComplete(c *api.Context, w http.ResponseWriter, r *http.Request)
if len(id) > 0 {
props = make(map[string]string)
- if result := <-api.Srv.Store.Team().Get(id); result.Err != nil {
+ if result := <-api.Srv.Store.Team().GetByInviteId(id); result.Err != nil {
c.Err = result.Err
return
} else {