summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
author=Corey Hulen <corey@hulen.com>2015-09-10 15:15:04 -0700
committer=Corey Hulen <corey@hulen.com>2015-09-10 15:15:04 -0700
commit83b04181da84d0456dfa02b8d52953eb3fd3d7d1 (patch)
tree49510d8a6d4be6aeed9a86816cffcbc82d66b09b
parent1108ac53063bedcfe00647fa0577e91cf60555de (diff)
parent927b474005b9e6c03f7385f4d1a06626dd0450e3 (diff)
downloadchat-83b04181da84d0456dfa02b8d52953eb3fd3d7d1.tar.gz
chat-83b04181da84d0456dfa02b8d52953eb3fd3d7d1.tar.bz2
chat-83b04181da84d0456dfa02b8d52953eb3fd3d7d1.zip
merging
-rw-r--r--CHANGELOG.md246
-rw-r--r--LICENSE.txt2
-rw-r--r--README.md12
-rw-r--r--api/export.go292
-rw-r--r--api/file.go42
-rw-r--r--api/team.go21
-rw-r--r--api/user.go5
-rw-r--r--doc/README.md21
-rw-r--r--doc/config/smtp-email-setup.md41
-rw-r--r--doc/install/aws-ebs-setup.md4
-rw-r--r--doc/install/release-numbering.md17
-rw-r--r--doc/install/requirements.md57
-rw-r--r--doc/install/single-container-install.md7
-rw-r--r--doc/integrations/sso/gitlab-sso.md2
-rw-r--r--docker/0.7/Dockerfile2
-rw-r--r--model/channel.go3
-rw-r--r--model/post.go3
-rw-r--r--model/team.go3
-rw-r--r--model/user.go15
-rw-r--r--store/sql_channel_store.go22
-rw-r--r--store/sql_post_store.go24
-rw-r--r--store/sql_team_store.go20
-rw-r--r--store/sql_user_store.go27
-rw-r--r--store/store.go4
-rw-r--r--web/react/components/channel_header.jsx7
-rw-r--r--web/react/components/channel_loader.jsx60
-rw-r--r--web/react/components/create_post.jsx51
-rw-r--r--web/react/components/file_attachment.jsx16
-rw-r--r--web/react/components/post_list.jsx212
-rw-r--r--web/react/components/post_list_container.jsx62
-rw-r--r--web/react/components/setting_item_min.jsx8
-rw-r--r--web/react/components/setting_upload.jsx6
-rw-r--r--web/react/components/sidebar.jsx8
-rw-r--r--web/react/components/team_export_tab.jsx94
-rw-r--r--web/react/components/team_import_tab.jsx16
-rw-r--r--web/react/components/team_settings.jsx8
-rw-r--r--web/react/components/team_settings_modal.jsx1
-rw-r--r--web/react/components/textbox.jsx6
-rw-r--r--web/react/components/user_settings.jsx3
-rw-r--r--web/react/components/user_settings_general.jsx49
-rw-r--r--web/react/pages/channel.jsx4
-rw-r--r--web/react/stores/channel_store.jsx14
-rw-r--r--web/react/utils/client.jsx13
-rw-r--r--web/react/utils/constants.jsx1
-rw-r--r--web/react/utils/utils.jsx2
-rw-r--r--web/sass-files/sass/partials/_files.scss12
-rw-r--r--web/sass-files/sass/partials/_modal.scss16
-rw-r--r--web/sass-files/sass/partials/_post.scss28
-rw-r--r--web/sass-files/sass/partials/_responsive.scss70
-rw-r--r--web/sass-files/sass/partials/_settings.scss36
-rw-r--r--web/sass-files/sass/partials/_sidebar--left.scss6
-rw-r--r--web/web.go1
52 files changed, 1464 insertions, 238 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 000000000..5876dc894
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,246 @@
+# Mattermost Changelog
+
+## UNDER DEVELOPMENT - Release v0.8.0 (Beta2)
+
+The "UNDER DEVELOPMENT" section of the Mattermost changelog appears in the product's `master` branch to note key changes committed to master and are on their way to the next stable release. When a stable release is pushed the "UNDER DEVELOPMENT" heading is removed from the final changelog of the release.
+
+- **Release candidate anticipated:** September 28, 2015
+- **Final release anticipated:** October 2, 2015
+
+### Release Highlights
+
+- [See Product Roadmap for anticipated features](https://mattermost.atlassian.net/issues/?filter=10002)
+
+### New Features
+
+- [See Product Roadmap for anticipated features](https://mattermost.atlassian.net/issues/?filter=10002)
+
+### Improvements
+
+Documentation
+
+- Updated software and hardware requirements documentation
+- Re-organized install instructions out of README and into separate files
+- Consolidated licensing information into LICENSE.txt and NOTICE.txt
+
+Performance
+
+- Enabled Javascript optimizations
+
+Code Quality
+
+- Reformatted Javascript per Mattermost Style Guide
+
+### Bug Fixes
+
+- Fixed performance issue with slow typing on iOS
+
+### Contributors
+
+Many thanks to our external contributors. In no particular order:
+
+- TBA
+
+## Release v0.7.0 (Beta1)
+
+Released 2015-09-05
+
+### Release Highlights
+
+#### Improved GitLab Mattermost support
+
+Following the release of Mattermost v0.6.0 Alpha, GitLab 7.14 offered an automated install of Mattermost with GitLab Single-Sign-On (co-branded as "GitLab Mattermost") in its omnibus installer.
+
+New features, improvements, and bug fixes recommended by the GitLab community were incorporated into Mattermost v0.7.0 Beta1--in particular, extending support of GitLab SSO to team creation, and restricting team creation to users with verified emails from a configurable list of domains.
+
+#### Slack Import (Preview)
+
+Preview of Slack import functionality supports the processing of an "Export" file from Slack containing account information and public channel archives from a Slack team.
+
+- In the feature preview, emails and usernames from Slack are used to create new Mattermost accounts, which users can activate by going to the Password Reset screen in Mattermost to set new credentials.
+- Once logged in, users will have access to previous Slack messages shared in public channels, now imported to Mattermost.
+
+Limitations:
+
+- Slack does not export files or images your team has stored in Slack's database. Mattermost will provide links to the location of your assets in Slack's web UI.
+- Slack does not export any content from private groups or direct messages that your team has stored in Slack's database.
+- The Preview release of Slack Import does not offer pre-checks or roll-back and will not import Slack accounts with username or email address collisions with existing Mattermost accounts. Also, Slack channel names with underscores will not import. Also, mentions do not yet resolve as Mattermost usernames (still show Slack ID). These issues are being addressed in [Mattermost v0.8.0 Migration Support](https://mattermost.atlassian.net/browse/PLT-22?filter=10002).
+
+### New Features
+
+GitLab Mattermost
+
+- Ability to create teams using GitLab SSO (previously GitLab SSO only supported account creation and sign-in)
+- Ability to restrict team creation to GitLab SSO and/or users with email verified from a specific list of domains.
+
+File and Image Sharing
+
+- New drag-and-drop file sharing to messages and comments
+- Ability to paste images from clipboard to messages and comments
+
+Messaging, Comments and Notifications
+
+- Send messages faster with from optimistic posting and retry on failure
+
+Documentation
+
+- New style guidelines for Go, React and Javascript
+
+### Improvements
+
+Messaging, Comments and Notifications
+
+- Performance improvements to channel rendering
+- Added "Unread posts" in left hand sidebar when notification indicator is off-screen
+
+Documentation
+
+- Install documentation improved based on early adopter feedback
+
+### Bug Fixes
+
+- Fixed multiple issues with GitLab SSO, installation and on-boarding
+- Fixed multiple IE 10 issues
+- Fixed broken link in verify email function
+- Fixed public links not working on mobile
+
+### Contributors
+
+Many thanks to our external contributors. In no particular order:
+
+- [asubset](https://github.com/asubset)
+- [felixbuenemann](https://github.com/felixbuenemann)
+- [CtrlZvi](https://github.com/CtrlZvi)
+- [BastienDurel](https://github.com/BastienDurel)
+- [manusajith](https://github.com/manusajith)
+- [doosp](https://github.com/doosp)
+- [zackify](https://github.com/zackify)
+- [willstacey](https://github.com/willstacey)
+
+Special thanks to the GitLab Mattermost early adopter community who influenced this release, and who play a pivotal role in bringing Mattermost to over 100,000 organizations using GitLab today. In no particular order:
+
+- [cifvts](http://forum.mattermost.org/users/cifvts/activity)
+- [Chryb](https://gitlab.com/u/Chryb)
+- [cookacounty](https://gitlab.com/u/cookacounty)
+- [bweston92](https://gitlab.com/u/bweston92)
+- [mablae](https://gitlab.com/u/mablae)
+- [picharmer](https://gitlab.com/u/picharmer)
+- [cmtonkinson](https://gitlab.com/u/cmtonkinson)
+- [cmthomps](https://gitlab.com/u/cmthomps)
+- [m.gamperl](https://gitlab.com/u/m.gamperl)
+- [StanMarsh](https://gitlab.com/u/StanMarsh)
+- [alx1](https://gitlab.com/u/alx1)
+- [jeanmarc-leroux](https://gitlab.com/u/jeanmarc-leroux)
+- [dnoe](https://gitlab.com/u/dnoe)
+- [dblessing](https://gitlab.com/u/dblessing)
+- [mechanicjay](https://gitlab.com/u/mechanicjay)
+- [larsemil](https://gitlab.com/u/larsemil)
+- [vga](https://gitlab.com/u/vga)
+- [stanhu](https://gitlab.com/u/stanhu)
+- [kohenkatz](https://gitlab.com/u/kohenkatz)
+- [RavenB1](https://gitlab.com/u/RavenB1)
+- [booksprint](http://forum.mattermost.org/users/booksprint/activity)
+- [scottcorscadden](http://forum.mattermost.org/users/scottcorscadden/activity)
+- [sskmani](http://forum.mattermost.org/users/sskmani/activity)
+- [gosure](http://forum.mattermost.org/users/gosure/activity)
+- [jigarshah](http://forum.mattermost.org/users/jigarshah/activity)
+
+Extra special thanks to GitLab community leaders for successful release of GitLab Mattermost Alpha:
+
+- [marin](https://gitlab.com/u/marin)
+- [sytse](https://gitlab.com/u/sytse)
+
+
+## Release v0.6.0 (Alpha)
+
+Released 2015-08-07
+
+### Release Highlights
+
+- Simplified on-prem install
+- Support for GitLab Mattermost (GitLab SSO, Postgres support, IE 10+ support)
+
+### Compatibility
+
+*Note: While use of Mattermost Preview (v0.5.0) and Mattermost Alpha (v0.6.0) in production is not recommended, we document compatibility considerations for a small number of organizations running Mattermost in production, supported directly by Mattermost product team.*
+
+- Switched Team URLs from team.domain.com to domain.com/team
+
+### New Features
+
+GitLab Mattermost
+
+- OAuth2 support for GitLab Single-Sign-On
+- PostgreSQL support for GitLab Mattermost users
+- Support for Internet Explorer 10+ for GitLab Mattermost users
+
+File and Image Sharing
+
+- New thumbnails and formatting for files and images
+
+Messaging, Comments and Notifications
+
+- Users now see posts they sent highlighted in a different color
+- Mentions can now also trigger on user-defined words
+
+Security and Administration
+
+- Enable users to view and log out of active sessions
+- Team Admin can now delete posts from any user
+
+On-boarding
+
+- “Off-Topic” now available as default channel, in addition to “Town Square”
+
+### Improvements
+
+Installation
+
+- New "ByPassEmail" setting enables Mattermost to operate without having to set up email
+- New option to use local storage instead of S3
+- Removed use of Redis to simplify on-premise installation
+
+On-boarding
+
+- Team setup wizard updated with usability improvements
+
+Documentation
+
+- Install documentation improved based on early adopter feedback
+
+### Contributors
+
+Many thanks to our external contributors. In no particular order:
+
+- [ralder](https://github.com/ralder)
+- [jedisct1](https://github.com/jedisct1)
+- [faebser](https://github.com/faebser)
+- [firstrow](https://github.com/firstrow)
+- [haikoschol](https://github.com/haikoschol)
+- [adamenger](https://github.com/adamenger)
+
+## Release v0.5.0 (Preview)
+
+Released 2015-06-24
+
+### Release Highlights
+
+- First release of Mattermost as a team communication service for sharing messagse and files across PCs and phones, with archiving and instant search.
+
+### New Features
+
+Messaging and File Sharing
+
+- Send messages, comments, files and images across public, private and 1-1 channels
+- Personalize notifications for unreads and mentions by channel
+- Use #hashtags to tag and find messages, discussions and files
+
+Archiving and Search
+
+- Search public and private channels for historical messages and comments
+- View recent mentions of your name, username, nickname, and custom search terms
+
+Anywhere Access
+
+- Use Mattermost from web-enabled PCs and phones
+- Define team-specific branding and color themes across your devices
diff --git a/LICENSE.txt b/LICENSE.txt
index aba905e79..f7e182eac 100644
--- a/LICENSE.txt
+++ b/LICENSE.txt
@@ -7,7 +7,7 @@ Mattermost server is made available under two separate licensing options:
- Free Software Foundation’s GNU AGPL v.3.0, subject to the exceptions outlined in this policy; or
- Commercial licenses available from SpinPunch, Inc. by contacting commercial@mattermost.com
-Admin Tools and Configuration Files (model/, web/static/, web/templates/, web/react/utils/, api/templates/ and all
+Admin Tools and Configuration Files (api/templates/, config/, model/, web/react/utils/, web/static/, web/templates/ and all
subdirectories thereof) are made available under:
- Apache License v2.0
diff --git a/README.md b/README.md
index fc6698208..de0f4b079 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
-# Mattermost is an open source, on-prem Slack-alternative
+# Mattermost
-Mattermost modernizes team communication without locking in your data to a single provider.
+Mattermost is an open source, on-prem Slack-alternative.
-Offer your end users messaging and file sharing across PCs and phones with archiving and instant search--without losing control of your data.
+It modernizes team communication without locking in your data to a single provider. Offer your end users messaging and file sharing across PCs and phones with archiving and instant search--without losing control of your data.
## All team communication in one place, searchable and accessible anywhere
@@ -26,7 +26,7 @@ Please see the [features pages of the Mattermost website](http://www.mattermost.
- Attach sound, video and image files from mobile devices
- Define team-specific branding and color themes across your devices
-#### Enterprise Focused
+#### Enterprise Compatible
- Self-host Mattermost entirely within your organization's insfrastructure
- GitLab Mattermost omnibus supports install to over 100,000 organizations using GitLab
@@ -56,10 +56,10 @@ There are multiple ways to install Mattermost depending on your needs.
- [Developer Machine Setup](doc/install/dev-setup.md) - Setup your local machine development environment using Docker on Mac OSX or Ubuntu.
-#### Production Deployment
+#### Production Deployment (for Beta2 and later)
Prior to production installation, please review [Mattermost system requirements](doc/install/requirements.md).
-- [GitLab Mattermost Production Installation](https://about.gitlab.com/downloads/) - Install Mattermost for production environments bundled with GitLab, a leading open source Git repository, using an omnibus package for Ubuntu 12.04, Ubuntu 14.04, Debian 7, Debian 8, and CentOS 6 (and RedHat/Oracle/Scientific Linux 6), CentOS 7 (and RedHat/Oracle/Scientific Linux 7).
+- [GitLab Mattermost Production Installation](https://gitlab.com/gitlab-org/gitlab-mattermost) - Install Mattermost for production environments bundled with GitLab, a leading open source Git repository, using an omnibus package for Ubuntu 12.04, Ubuntu 14.04, Debian 7, Debian 8, and CentOS 6 (and RedHat/Oracle/Scientific Linux 6), CentOS 7 (and RedHat/Oracle/Scientific Linux 7).
For technical questions and answers, please visit the [Mattermost forum](http://forum.mattermost.org).
diff --git a/api/export.go b/api/export.go
new file mode 100644
index 000000000..9345f892f
--- /dev/null
+++ b/api/export.go
@@ -0,0 +1,292 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package api
+
+import (
+ "archive/zip"
+ "encoding/json"
+ "github.com/mattermost/platform/model"
+ "github.com/mattermost/platform/utils"
+ "io"
+ "os"
+)
+
+const (
+ EXPORT_PATH = "export/"
+ EXPORT_FILENAME = "MattermostExport.zip"
+ EXPORT_OPTIONS_FILE = "options.json"
+ EXPORT_TEAMS_FOLDER = "teams"
+ EXPORT_CHANNELS_FOLDER = "channels"
+ EXPORT_CHANNEL_MEMBERS_FOLDER = "members"
+ EXPORT_POSTS_FOLDER = "posts"
+ EXPORT_USERS_FOLDER = "users"
+ EXPORT_LOCAL_STORAGE_FOLDER = "files"
+)
+
+type ExportWriter interface {
+ Create(name string) (io.Writer, error)
+}
+
+type ExportOptions struct {
+ TeamsToExport []string `json:"teams"`
+ ChannelsToExport []string `json:"channels"`
+ UsersToExport []string `json:"users"`
+ ExportLocalStorage bool `json:"export_local_storage"`
+}
+
+func (options *ExportOptions) ToJson() string {
+ b, err := json.Marshal(options)
+ if err != nil {
+ return ""
+ } else {
+ return string(b)
+ }
+}
+
+func ExportOptionsFromJson(data io.Reader) *ExportOptions {
+ decoder := json.NewDecoder(data)
+ var o ExportOptions
+ decoder.Decode(&o)
+ return &o
+}
+
+func ExportToFile(options *ExportOptions) (link string, err *model.AppError) {
+ // Open file for export
+ if file, err := openFileWriteStream(EXPORT_PATH + EXPORT_FILENAME); err != nil {
+ return "", err
+ } else {
+ defer closeFileWriteStream(file)
+ ExportToWriter(file, options)
+ }
+
+ return "/api/v1/files/get_export", nil
+}
+
+func ExportToWriter(w io.Writer, options *ExportOptions) *model.AppError {
+ // Open a writer to write to zip file
+ zipWriter := zip.NewWriter(w)
+ defer zipWriter.Close()
+
+ // Write our options to file
+ if optionsFile, err := zipWriter.Create(EXPORT_OPTIONS_FILE); err != nil {
+ return model.NewAppError("ExportToWriter", "Unable to create options file", err.Error())
+ } else {
+ if _, err := optionsFile.Write([]byte(options.ToJson())); err != nil {
+ return model.NewAppError("ExportToWriter", "Unable to write to options file", err.Error())
+ }
+ }
+
+ // Export Teams
+ ExportTeams(zipWriter, options)
+
+ return nil
+}
+
+func ExportTeams(writer ExportWriter, options *ExportOptions) *model.AppError {
+ // Get the teams
+ var teams []*model.Team
+ if len(options.TeamsToExport) == 0 {
+ if result := <-Srv.Store.Team().GetForExport(); result.Err != nil {
+ return result.Err
+ } else {
+ teams = result.Data.([]*model.Team)
+ }
+ } else {
+ for _, teamId := range options.TeamsToExport {
+ if result := <-Srv.Store.Team().Get(teamId); result.Err != nil {
+ return result.Err
+ } else {
+ team := result.Data.(*model.Team)
+ teams = append(teams, team)
+ }
+ }
+ }
+
+ // Export the teams
+ for i := range teams {
+ // Sanitize
+ teams[i].PreExport()
+
+ if teamFile, err := writer.Create(EXPORT_TEAMS_FOLDER + "/" + teams[i].Name + ".json"); err != nil {
+ return model.NewAppError("ExportTeams", "Unable to open file for export", err.Error())
+ } else {
+ if _, err := teamFile.Write([]byte(teams[i].ToJson())); err != nil {
+ return model.NewAppError("ExportTeams", "Unable to write to team export file", err.Error())
+ }
+ }
+
+ }
+
+ // Export the channels, local storage and users
+ for _, team := range teams {
+ if err := ExportChannels(writer, options, team.Id); err != nil {
+ return err
+ }
+ if err := ExportUsers(writer, options, team.Id); err != nil {
+ return err
+ }
+ if err := ExportLocalStorage(writer, options, team.Id); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func ExportChannels(writer ExportWriter, options *ExportOptions, teamId string) *model.AppError {
+ // Get the channels
+ var channels []*model.Channel
+ if len(options.ChannelsToExport) == 0 {
+ if result := <-Srv.Store.Channel().GetForExport(teamId); result.Err != nil {
+ return result.Err
+ } else {
+ channels = result.Data.([]*model.Channel)
+ }
+ } else {
+ for _, channelId := range options.ChannelsToExport {
+ if result := <-Srv.Store.Channel().Get(channelId); result.Err != nil {
+ return result.Err
+ } else {
+ channel := result.Data.(*model.Channel)
+ channels = append(channels, channel)
+ }
+ }
+ }
+
+ for i := range channels {
+ // Get members
+ mchan := Srv.Store.Channel().GetMembers(channels[i].Id)
+
+ // Sanitize
+ channels[i].PreExport()
+
+ if channelFile, err := writer.Create(EXPORT_CHANNELS_FOLDER + "/" + channels[i].Id + ".json"); err != nil {
+ return model.NewAppError("ExportChannels", "Unable to open file for export", err.Error())
+ } else {
+ if _, err := channelFile.Write([]byte(channels[i].ToJson())); err != nil {
+ return model.NewAppError("ExportChannels", "Unable to write to export file", err.Error())
+ }
+ }
+
+ var members []model.ChannelMember
+ if result := <-mchan; result.Err != nil {
+ return result.Err
+ } else {
+ members = result.Data.([]model.ChannelMember)
+ }
+
+ if membersFile, err := writer.Create(EXPORT_CHANNELS_FOLDER + "/" + channels[i].Id + "_members.json"); err != nil {
+ return model.NewAppError("ExportChannels", "Unable to open file for export", err.Error())
+ } else {
+ result, err2 := json.Marshal(members)
+ if err2 != nil {
+ return model.NewAppError("ExportChannels", "Unable to convert to json", err.Error())
+ }
+ if _, err3 := membersFile.Write([]byte(result)); err3 != nil {
+ return model.NewAppError("ExportChannels", "Unable to write to export file", err.Error())
+ }
+ }
+ }
+
+ for _, channel := range channels {
+ if err := ExportPosts(writer, options, channel.Id); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func ExportPosts(writer ExportWriter, options *ExportOptions, channelId string) *model.AppError {
+ // Get the posts
+ var posts []*model.Post
+ if result := <-Srv.Store.Post().GetForExport(channelId); result.Err != nil {
+ return result.Err
+ } else {
+ posts = result.Data.([]*model.Post)
+ }
+
+ // Export the posts
+ if postsFile, err := writer.Create(EXPORT_POSTS_FOLDER + "/" + channelId + "_posts.json"); err != nil {
+ return model.NewAppError("ExportPosts", "Unable to open file for export", err.Error())
+ } else {
+ result, err2 := json.Marshal(posts)
+ if err2 != nil {
+ return model.NewAppError("ExportPosts", "Unable to convert to json", err.Error())
+ }
+ if _, err3 := postsFile.Write([]byte(result)); err3 != nil {
+ return model.NewAppError("ExportPosts", "Unable to write to export file", err.Error())
+ }
+ }
+
+ return nil
+}
+
+func ExportUsers(writer ExportWriter, options *ExportOptions, teamId string) *model.AppError {
+ // Get the users
+ var users []*model.User
+ if result := <-Srv.Store.User().GetForExport(teamId); result.Err != nil {
+ return result.Err
+ } else {
+ users = result.Data.([]*model.User)
+ }
+
+ // Write the users
+ if usersFile, err := writer.Create(EXPORT_USERS_FOLDER + "/" + teamId + "_users.json"); err != nil {
+ return model.NewAppError("ExportUsers", "Unable to open file for export", err.Error())
+ } else {
+ result, err2 := json.Marshal(users)
+ if err2 != nil {
+ return model.NewAppError("ExportUsers", "Unable to convert to json", err.Error())
+ }
+ if _, err3 := usersFile.Write([]byte(result)); err3 != nil {
+ return model.NewAppError("ExportUsers", "Unable to write to export file", err.Error())
+ }
+ }
+ return nil
+}
+
+func copyDirToExportWriter(writer ExportWriter, inPath string, outPath string) *model.AppError {
+ dir, err := os.Open(inPath)
+ if err != nil {
+ return model.NewAppError("copyDirToExportWriter", "Unable to open directory", err.Error())
+ }
+
+ fileInfoList, err := dir.Readdir(0)
+ if err != nil {
+ return model.NewAppError("copyDirToExportWriter", "Unable to read directory", err.Error())
+ }
+
+ for _, fileInfo := range fileInfoList {
+ if fileInfo.IsDir() {
+ copyDirToExportWriter(writer, inPath+"/"+fileInfo.Name(), outPath+"/"+fileInfo.Name())
+ } else {
+ if toFile, err := writer.Create(outPath + "/" + fileInfo.Name()); err != nil {
+ return model.NewAppError("copyDirToExportWriter", "Unable to open file for export", err.Error())
+ } else {
+ fromFile, err := os.Open(inPath + "/" + fileInfo.Name())
+ if err != nil {
+ return model.NewAppError("copyDirToExportWriter", "Unable to open file", err.Error())
+ }
+ io.Copy(toFile, fromFile)
+ }
+ }
+ }
+
+ return nil
+}
+
+func ExportLocalStorage(writer ExportWriter, options *ExportOptions, teamId string) *model.AppError {
+ teamDir := utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + teamId
+
+ if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
+ return model.NewAppError("ExportLocalStorage", "S3 is not supported for local storage export.", "")
+ } else if utils.Cfg.ServiceSettings.UseLocalStorage && len(utils.Cfg.ServiceSettings.StorageDirectory) > 0 {
+ if err := copyDirToExportWriter(writer, teamDir, EXPORT_LOCAL_STORAGE_FOLDER); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/api/file.go b/api/file.go
index 800c512c5..1d8244fac 100644
--- a/api/file.go
+++ b/api/file.go
@@ -40,6 +40,7 @@ func InitFile(r *mux.Router) {
sr.Handle("/get/{channel_id:[A-Za-z0-9]+}/{user_id:[A-Za-z0-9]+}/{filename:([A-Za-z0-9]+/)?.+(\\.[A-Za-z0-9]{3,})?}", ApiAppHandler(getFile)).Methods("GET")
sr.Handle("/get_info/{channel_id:[A-Za-z0-9]+}/{user_id:[A-Za-z0-9]+}/{filename:([A-Za-z0-9]+/)?.+(\\.[A-Za-z0-9]{3,})?}", ApiAppHandler(getFileInfo)).Methods("GET")
sr.Handle("/get_public_link", ApiUserRequired(getPublicLink)).Methods("POST")
+ sr.Handle("/get_export", ApiUserRequired(getExport)).Methods("GET")
}
func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
@@ -414,6 +415,23 @@ func getPublicLink(c *Context, w http.ResponseWriter, r *http.Request) {
w.Write([]byte(model.MapToJson(rData)))
}
+func getExport(c *Context, w http.ResponseWriter, r *http.Request) {
+ if !c.HasPermissionsToTeam(c.Session.TeamId, "export") || !c.IsTeamAdmin(c.Session.UserId) {
+ c.Err = model.NewAppError("getExport", "Only a team admin can retrieve exported data.", "userId="+c.Session.UserId)
+ c.Err.StatusCode = http.StatusForbidden
+ return
+ }
+ data, err := readFile(EXPORT_PATH + EXPORT_FILENAME)
+ if err != nil {
+ c.Err = model.NewAppError("getExport", "Unable to retrieve exported file. Please re-export", err.Error())
+ return
+ }
+
+ w.Header().Set("Content-Disposition", "attachment; filename="+EXPORT_FILENAME)
+ w.Header().Set("Content-Type", "application/octet-stream")
+ w.Write(data)
+}
+
func writeFile(f []byte, path string) *model.AppError {
if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
@@ -488,3 +506,27 @@ func readFile(path string) ([]byte, *model.AppError) {
return nil, model.NewAppError("readFile", "File storage not configured properly. Please configure for either S3 or local server file storage.", "")
}
}
+
+func openFileWriteStream(path string) (io.Writer, *model.AppError) {
+ if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
+ return nil, model.NewAppError("openFileWriteStream", "S3 is not supported.", "")
+ } else if utils.Cfg.ServiceSettings.UseLocalStorage && len(utils.Cfg.ServiceSettings.StorageDirectory) > 0 {
+ if err := os.MkdirAll(filepath.Dir(utils.Cfg.ServiceSettings.StorageDirectory+path), 0774); err != nil {
+ return nil, model.NewAppError("openFileWriteStream", "Encountered an error creating the directory for the new file", err.Error())
+ }
+
+ if fileHandle, err := os.Create(utils.Cfg.ServiceSettings.StorageDirectory + path); err != nil {
+ return nil, model.NewAppError("openFileWriteStream", "Encountered an error writing to local server storage", err.Error())
+ } else {
+ fileHandle.Chmod(0644)
+ return fileHandle, nil
+ }
+
+ }
+
+ return nil, model.NewAppError("openFileWriteStream", "File storage not configured properly. Please configure for either S3 or local server file storage.", "")
+}
+
+func closeFileWriteStream(file io.Writer) {
+ file.(*os.File).Close()
+}
diff --git a/api/team.go b/api/team.go
index 8c0be9486..9288b86cb 100644
--- a/api/team.go
+++ b/api/team.go
@@ -32,7 +32,9 @@ func InitTeam(r *mux.Router) {
sr.Handle("/update_name", ApiUserRequired(updateTeamDisplayName)).Methods("POST")
sr.Handle("/update_valet_feature", ApiUserRequired(updateValetFeature)).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")
+ sr.Handle("/export_team", ApiUserRequired(exportTeam)).Methods("GET")
}
func signupTeam(c *Context, w http.ResponseWriter, r *http.Request) {
@@ -683,3 +685,22 @@ func importTeam(c *Context, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
http.ServeContent(w, r, "MattermostImportLog.txt", time.Now(), bytes.NewReader(log.Bytes()))
}
+
+func exportTeam(c *Context, w http.ResponseWriter, r *http.Request) {
+ if !c.HasPermissionsToTeam(c.Session.TeamId, "export") || !c.IsTeamAdmin(c.Session.UserId) {
+ c.Err = model.NewAppError("exportTeam", "Only a team admin can export data.", "userId="+c.Session.UserId)
+ c.Err.StatusCode = http.StatusForbidden
+ return
+ }
+
+ options := ExportOptionsFromJson(r.Body)
+
+ if link, err := ExportToFile(options); err != nil {
+ c.Err = err
+ return
+ } else {
+ result := map[string]string{}
+ result["link"] = link
+ w.Write([]byte(model.MapToJson(result)))
+ }
+}
diff --git a/api/user.go b/api/user.go
index 0698ea2f0..f32bbbe13 100644
--- a/api/user.go
+++ b/api/user.go
@@ -71,10 +71,7 @@ func createUser(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
- if !model.IsUsernameValid(user.Username) {
- c.Err = model.NewAppError("createUser", "That username is invalid", "might be using a resrved username")
- return
- }
+ // the user's username is checked to be valid when they are saved to the database
user.EmailVerified = false
diff --git a/doc/README.md b/doc/README.md
index baf499777..36d16b744 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -1,13 +1,24 @@
-# Documentation
+# Mattermost Documentation
+
+## General Information
+
+- [Mattermost Release Numbering Scheme](install/release-numbering.md)
## Administrator Documentation
### Installation
-- [Install Requirements](doc/install/requirements.md)
-- [Local Machine Setup ](doc/install/single-container-install.md)
-- [AWS Elastic Beanstalk Setup](doc/install/aws-ebs-setup.md)
+- [Software and Hardware Requirements](install/requirements.md)
+- [Local Machine Setup ](install/single-container-install.md)
+- [AWS Elastic Beanstalk Setup](install/aws-ebs-setup.md)
+- [Developer Machine Setup](doc/install/dev-setup.md)
### Configuration
-- [GitLab SSO Configuration](integrations/sso/gitlab-sso.md) - Configure OAuth2 Single-Sign-On for GitLab.
+- [GitLab SSO Configuration](integrations/sso/gitlab-sso.md)
+- [SMTP Email Setup](smtp-email-setup.md)
+
+## Developer Documentation
+
+- [Developer Machine Setup](doc/install/dev-setup.md)
+- [Mattermost Style Guide](developer/style-guide.md)
diff --git a/doc/config/smtp-email-setup.md b/doc/config/smtp-email-setup.md
index b90d78919..86e2bb20e 100644
--- a/doc/config/smtp-email-setup.md
+++ b/doc/config/smtp-email-setup.md
@@ -1,37 +1,40 @@
## SMTP Email Setup
-The following instructions maybe used when SMTP email is not setup as part of the installation process.
+In some product evaluation setups email is intentionally bypassed using a `ByPassEmail=true` option. This option allows account creation and system operation without having to set up an email service (e.g. no email verification is required for account creation). This also means neither email notifications nor password reset by email are available.
-1. Setup an email sending service. If you already have credentials for a SMTP server you can skip this step.
+To enable email, turn this option off by setting `ByPassEmail=false` and configuring an SMTP email service as follows:
+
+1. **Set up an SMTP email sending service.** (If you already have credentials for a SMTP server you can skip this step.)
1. [Setup Amazon Simple Email Service](https://console.aws.amazon.com/ses)
2. From the `SMTP Settings` menu click `Create My SMTP Credentials`
3. Copy the `Server Name`, `Port`, `SMTP Username`, and `SMTP Password`
4. From the `Domains` menu setup and verify a new domain. It it also a good practice to enable `Generate DKIM Settings` for this domain.
5. Choose an email address like `feedback@example.com` for Mattermost to send emails from.
6. Test sending an email from `feedback@example.com` by clicking the `Send a Test Email` button and verify everything appears to be working correctly.
-2. Modify the Mattermost configuration file config.json or config_docker.json with the SMTP information.
+2. **Modify the Mattermost configuration file config.json or config_docker.json with the SMTP information.**
1. If you're running Mattermost on Amazon Beanstalk you can shell into the instance with the following commands
2. `ssh ec2-user@[domain for the docker instance]`
3. `sudo gpasswd -a ec2-user docker`
4. Retrieve the name of the container with `sudo docker ps`
5. `sudo docker exec -ti container_name /bin/bash`
-3. Edit the config file `vi /config_docker.json` with the settings you captured from the step above. See an example below and notice `ByPassEmail` has been set to `false`
-``` bash
-"EmailSettings": {
- "ByPassEmail" : false,
- "SMTPUsername": "AKIADTOVBGERKLCBV",
- "SMTPPassword": "jcuS8PuvcpGhpgHhlcpT1Mx42pnqMxQY",
- "SMTPServer": "email-smtp.us-east-1.amazonaws.com:465",
- "UseTLS": true,
- "FeedbackEmail": "feedback@example.com",
- "FeedbackName": "Feedback",
- "ApplePushServer": "",
- "ApplePushCertPublic": "",
- "ApplePushCertPrivate": ""
-}
-```
-4. Restart Mattermost
+3. **Edit the config file `vi /config_docker.json` with the settings you captured from the step above.**
+ 1. See an example below and notice `ByPassEmail` has been set to `false`
+ ``` bash
+ "EmailSettings": {
+ "ByPassEmail" : false,
+ "SMTPUsername": "AKIADTOVBGERKLCBV",
+ "SMTPPassword": "jcuS8PuvcpGhpgHhlcpT1Mx42pnqMxQY",
+ "SMTPServer": "email-smtp.us-east-1.amazonaws.com:465",
+ "UseTLS": true,
+ "FeedbackEmail": "feedback@example.com",
+ "FeedbackName": "Feedback",
+ "ApplePushServer": "",
+ "ApplePushCertPublic": "",
+ "ApplePushCertPrivate": ""
+ }
+ ```
+4. **Restart Mattermost**
1. Find the process id with `ps -A` and look for the process named `platform`
2. Kill the process `kill pid`
3. The service should restart automatically. Verify the Mattermost service is running with `ps -A`
diff --git a/doc/install/aws-ebs-setup.md b/doc/install/aws-ebs-setup.md
index 452cfcb4f..e186fa9c1 100644
--- a/doc/install/aws-ebs-setup.md
+++ b/doc/install/aws-ebs-setup.md
@@ -1,14 +1,14 @@
## AWS Elastic Beanstalk Setup (Docker)
-1. Create a new Elastic Beanstalk Docker application using the [Dockerrun.aws.zip](docker/0.6/Dockerrun.aws//Dockerrun.aws.zip) file provided.
+1. Create a new Elastic Beanstalk Docker application using the [Dockerrun.aws.zip](/docker/0.7/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](docker/0.6/Dockerrun.aws//Dockerrun.aws.zip). Everything else may be left at default.
+ 7. For application source, select upload your own and upload Dockerrun.aws.zip from [Dockerrun.aws.zip](/docker/0.7/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.
diff --git a/doc/install/release-numbering.md b/doc/install/release-numbering.md
new file mode 100644
index 000000000..71374f7ef
--- /dev/null
+++ b/doc/install/release-numbering.md
@@ -0,0 +1,17 @@
+### Mattermost Release Numbering
+
+Mattermost numbers its stable releases based on the following format:
+
+ `[Version Number].[Major Build Number].[Minor Build Number]`
+
+Version Number:
+
+- Indicates a major system release (e.g. 1.x.x, 2.x.x)
+
+Major Build Number:
+
+- Indicates significant new functionality, (e.g. 0.5.x, 0.6.x, 0.7.x)
+
+Minor Build Number:
+
+- Indicates bugfix/security releases (e.g. 1.2.5, 1.2.6)
diff --git a/doc/install/requirements.md b/doc/install/requirements.md
index cc0d1833d..1e0a12fb9 100644
--- a/doc/install/requirements.md
+++ b/doc/install/requirements.md
@@ -1,8 +1,8 @@
-# Requirements
+## Software Requirements
-## Web Client
+### Web Client
-Supported Operating Systems and Browsers:
+Supported Operating Systems and Browsers for the Mattermost Web Client include:
- PC: Windows 7, Windows 8 (Chrome 43+, Firefox 38+, Internet Explorer 10+)
- Mac: OS 10 (Safari 7, Chrome 43+)
@@ -10,9 +10,9 @@ Supported Operating Systems and Browsers:
- iPhone 4s and higher (Safari on iOS 8.3+, Chrome 43+)
- Android 5 and higher (Chrome 43+)
-## Server
+### Server
-While the pre-released version of Mattermost is not currently supported in production, the intention from the product team is to support:
+Supported Operating Systems for the Mattermost Server include:
- Ubuntu
- Debian
@@ -21,3 +21,50 @@ While the pre-released version of Mattermost is not currently supported in produ
- Oracle Linux
The Mattermost roadmap does not currently include production support for Fedora, FreeBSD or Arch Linux.
+
+## Hardware Requirements
+
+Mattermost offers both real-time communication and file sharing. CPU and Memory requirements are typically driven by the number of concurrent users using real-time messaging. Storage requirements are typically driven by number and size of files shared.
+
+The below guidelines offer estimates based on real world usage of Mattermost across industries.
+
+### CPU
+
+- 2 cores is the recommended number of cores and supports up to 250 users
+- 4 cores supports up to 1,000 users
+- 8 cores supports up to 2,500 users
+- 16 cores supports up to 5,000 users
+- 32 cores supports up to 10,000 users
+- 64 cores supports up to 20,000 users
+
+### Memory
+
+- 2GB RAM is the recommended memory size and supports up to 50 users
+- 4GB RAM supports up to 500 users
+- 8GB RAM supports up to 1,000 users
+- 16GB RAM supports up to 2,000 users
+- 32GB RAM supports up to 4,000 users
+- 64GB RAM supports up to 8,000 users
+- 128GB RAM supports up to 16,000 users
+
+### Storage
+
+To estimate initial storage requirements, begin with a Mattermost server approximately 600 MB to 800 MB in size including operating system and database, then add the multiplied product of:
+
+- Estimated storage per user per month (see below), multipled by 12 months in a year
+- Estimated mean average number of users in a year
+- A 1-2x safety factor
+
+**Estimated storage per user per month**
+
+File usage per user varies significantly across industries. The below benchmarks are recommended:
+
+- **Low usage teams** (1-5 MB/user/month) - Primarily use text-messages and links to communicate. Examples would include software development teams that heavily use web-based document creation and management tools, and therefore rarely upload files to the server.
+
+- **Medium usage teams** (5-25 MB/user/month) - Use a mix of text-messages as well as shared documents and images to communicate. Examples might include business teams that may commonly drag and drop screenshots, PDFs and Microsoft Office documents into Mattermost for sharing and review.
+
+- **High usage teams** - (25-100 MB/user/month) - Heaviest utlization comes from teams uploading a high number of large files into Mattermost on a regular basis. Examples include creative teams who share and store artwork and media with tags and commentary in a pipeline production process.
+
+*Example:* A 30-person team with medium usage (5-25 MB/user/month) with a safety factor of 2x would require between 300 MB (30 users * 5 MB * 2x safety factor) and 1500 MB (30 users * 25 MB * 2x safety factor) of free space in the next year.
+
+It's recommended to review storage utilization at least quarterly to ensure adequate free space is available.
diff --git a/doc/install/single-container-install.md b/doc/install/single-container-install.md
index 3e307ca74..772f3becf 100644
--- a/doc/install/single-container-install.md
+++ b/doc/install/single-container-install.md
@@ -1,10 +1,7 @@
-# Single Container Installation and Upgrade
+# Local Machine Setup and Upgrade
The following install instructions are for single-container installs of Mattermost using Docker for exploring product functionality and upgrading to newer versions.
-Local Machine Setup (Docker)
------------------------------
-
### Mac OSX ###
1. Install Boot2Docker using instructions at: http://docs.docker.com/installation/mac/
@@ -89,7 +86,7 @@ There are a few configuration settings you might want to adjust when setting up
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.
-To see the product's full functionality, [enabling SMTP email is recommended](doc/config/smtp-email-setup.md).
+To see the product's full functionality, [enabling SMTP email is recommended](/doc/config/smtp-email-setup.md).
## Upgrading Mattermost
diff --git a/doc/integrations/sso/gitlab-sso.md b/doc/integrations/sso/gitlab-sso.md
index c0cd6cb45..6110db504 100644
--- a/doc/integrations/sso/gitlab-sso.md
+++ b/doc/integrations/sso/gitlab-sso.md
@@ -14,6 +14,6 @@ The following steps can be used to configure Mattermost to use GitLab as a singl
* _TokenEndpoint_: `<your-gitlab-url>/oauth/token`
* _UserApiEndpoint_: `<your-gitlab-url>/api/v3/user`
-6. (Optional) If you would like to force all users to sign-up with GitLab only, in the _ServiceSettings_ section of config/config.json please set _AllowEmailSignUp_ to `false`.
+6. (Optional) If you would like to force all users to sign-up with GitLab only, in the _ServiceSettings_ section of config/config.json please set _DisableEmailSignUp_ to `true`.
7. Restart your Mattermost server to see the changes take effect.
diff --git a/docker/0.7/Dockerfile b/docker/0.7/Dockerfile
index 66aac0fa3..6e82fd809 100644
--- a/docker/0.7/Dockerfile
+++ b/docker/0.7/Dockerfile
@@ -34,7 +34,7 @@ VOLUME /var/lib/mysql
WORKDIR /mattermost
# Copy over files
-ADD https://github.com/mattermost/platform/releases/download/v0.7.0-rc1/mattermost.tar.gz /
+ADD https://github.com/mattermost/platform/releases/download/v0.7.1/mattermost.tar.gz /
RUN tar -zxvf /mattermost.tar.gz --strip-components=1 && rm /mattermost.tar.gz
ADD config_docker.json /
ADD docker-entry.sh /
diff --git a/model/channel.go b/model/channel.go
index b46f79f75..7d8edeee7 100644
--- a/model/channel.go
+++ b/model/channel.go
@@ -117,3 +117,6 @@ func (o *Channel) PreUpdate() {
func (o *Channel) ExtraUpdated() {
o.ExtraUpdateAt = GetMillis()
}
+
+func (o *Channel) PreExport() {
+}
diff --git a/model/post.go b/model/post.go
index 0c035d4e7..e78469940 100644
--- a/model/post.go
+++ b/model/post.go
@@ -147,3 +147,6 @@ func (o *Post) AddProp(key string, value string) {
o.Props[key] = value
}
+
+func (o *Post) PreExport() {
+}
diff --git a/model/team.go b/model/team.go
index 95e2757c8..6006f738c 100644
--- a/model/team.go
+++ b/model/team.go
@@ -197,3 +197,6 @@ func CleanTeamName(s string) string {
return s
}
+
+func (o *Team) PreExport() {
+}
diff --git a/model/user.go b/model/user.go
index 9cec37ac6..1a169f763 100644
--- a/model/user.go
+++ b/model/user.go
@@ -317,6 +317,16 @@ func IsInRole(userRoles string, inRole string) bool {
return false
}
+func (u *User) PreExport() {
+ u.Password = ""
+ u.AuthData = ""
+ u.LastActivityAt = 0
+ u.LastPingAt = 0
+ u.LastPasswordUpdate = 0
+ u.LastPictureUpdate = 0
+ u.FailedAttempts = 0
+}
+
// UserFromJson will decode the input and return a User
func UserFromJson(data io.Reader) *User {
decoder := json.NewDecoder(data)
@@ -370,11 +380,6 @@ func ComparePassword(hash string, password string) bool {
return err == nil
}
-func IsUsernameValid(username string) bool {
-
- return true
-}
-
var validUsernameChars = regexp.MustCompile(`^[a-z0-9\.\-_]+$`)
var restrictedUsernames = []string{
diff --git a/store/sql_channel_store.go b/store/sql_channel_store.go
index d2e3943df..b58166fd6 100644
--- a/store/sql_channel_store.go
+++ b/store/sql_channel_store.go
@@ -678,3 +678,25 @@ func (s SqlChannelStore) UpdateNotifyLevel(channelId, userId, notifyLevel string
return storeChannel
}
+
+func (s SqlChannelStore) GetForExport(teamId string) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ var data []*model.Channel
+ _, err := s.GetReplica().Select(&data, "SELECT * FROM Channels WHERE TeamId = :TeamId AND DeleteAt = 0 AND Type = 'O'", map[string]interface{}{"TeamId": teamId})
+
+ if err != nil {
+ result.Err = model.NewAppError("SqlChannelStore.GetAllChannels", "We couldn't get all the channels", "teamId="+teamId+", err="+err.Error())
+ } else {
+ result.Data = data
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
diff --git a/store/sql_post_store.go b/store/sql_post_store.go
index 297d60397..20de23eb7 100644
--- a/store/sql_post_store.go
+++ b/store/sql_post_store.go
@@ -506,3 +506,27 @@ func (s SqlPostStore) Search(teamId string, userId string, terms string, isHasht
return storeChannel
}
+
+func (s SqlPostStore) GetForExport(channelId string) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ var posts []*model.Post
+ _, err := s.GetReplica().Select(
+ &posts,
+ "SELECT * FROM Posts WHERE ChannelId = :ChannelId AND DeleteAt = 0",
+ map[string]interface{}{"ChannelId": channelId})
+ if err != nil {
+ result.Err = model.NewAppError("SqlPostStore.GetForExport", "We couldn't get the posts for the channel", "channelId="+channelId+err.Error())
+ } else {
+ result.Data = posts
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
diff --git a/store/sql_team_store.go b/store/sql_team_store.go
index 5351b98f3..d2148c2e3 100644
--- a/store/sql_team_store.go
+++ b/store/sql_team_store.go
@@ -194,3 +194,23 @@ func (s SqlTeamStore) GetTeamsForEmail(email string) StoreChannel {
return storeChannel
}
+
+func (s SqlTeamStore) GetForExport() StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ var data []*model.Team
+ if _, err := s.GetReplica().Select(&data, "SELECT * FROM Teams"); err != nil {
+ result.Err = model.NewAppError("SqlTeamStore.GetForExport", "We could not get all teams", err.Error())
+ }
+
+ result.Data = data
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
diff --git a/store/sql_user_store.go b/store/sql_user_store.go
index 64a18545a..be1d29df0 100644
--- a/store/sql_user_store.go
+++ b/store/sql_user_store.go
@@ -452,3 +452,30 @@ func (us SqlUserStore) VerifyEmail(userId string) StoreChannel {
return storeChannel
}
+
+func (us SqlUserStore) GetForExport(teamId string) StoreChannel {
+
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ 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())
+ } else {
+ for _, u := range users {
+ u.Password = ""
+ u.AuthData = ""
+ }
+
+ result.Data = users
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
diff --git a/store/store.go b/store/store.go
index 271caa366..959e93fa4 100644
--- a/store/store.go
+++ b/store/store.go
@@ -44,6 +44,7 @@ type TeamStore interface {
Get(id string) StoreChannel
GetByName(name string) StoreChannel
GetTeamsForEmail(domain string) StoreChannel
+ GetForExport() StoreChannel
}
type ChannelStore interface {
@@ -55,6 +56,7 @@ type ChannelStore interface {
GetChannels(teamId string, userId string) StoreChannel
GetMoreChannels(teamId string, userId string) StoreChannel
GetChannelCounts(teamId string, userId string) StoreChannel
+ GetForExport(teamId string) StoreChannel
SaveMember(member *model.ChannelMember) StoreChannel
GetMembers(channelId string) StoreChannel
@@ -78,6 +80,7 @@ type PostStore interface {
GetPostsSince(channelId string, time int64) StoreChannel
GetEtag(channelId string) StoreChannel
Search(teamId string, userId string, terms string, isHashtagSearch bool) StoreChannel
+ GetForExport(channelId string) StoreChannel
}
type UserStore interface {
@@ -96,6 +99,7 @@ type UserStore interface {
VerifyEmail(userId string) StoreChannel
GetEtagForProfiles(teamId string) StoreChannel
UpdateFailedPasswordAttempts(userId string, attempts int) StoreChannel
+ GetForExport(teamId string) StoreChannel
}
type SessionStore interface {
diff --git a/web/react/components/channel_header.jsx b/web/react/components/channel_header.jsx
index 87b9cab04..db23a5831 100644
--- a/web/react/components/channel_header.jsx
+++ b/web/react/components/channel_header.jsx
@@ -64,9 +64,14 @@ export default class ChannelHeader extends React.Component {
handleLeave() {
Client.leaveChannel(this.state.channel.id,
function handleLeaveSuccess() {
+ AppDispatcher.handleViewAction({
+ type: ActionTypes.LEAVE_CHANNEL,
+ id: this.state.channel.id
+ });
+
const townsquare = ChannelStore.getByName('town-square');
Utils.switchChannel(townsquare);
- },
+ }.bind(this),
function handleLeaveError(err) {
AsyncClient.dispatchError(err, 'handleLeave');
}
diff --git a/web/react/components/channel_loader.jsx b/web/react/components/channel_loader.jsx
index 8e8ed3f73..ce6f60f87 100644
--- a/web/react/components/channel_loader.jsx
+++ b/web/react/components/channel_loader.jsx
@@ -17,6 +17,8 @@ export default class ChannelLoader extends React.Component {
constructor(props) {
super(props);
+ this.intervalId = null;
+
this.onSocketChange = this.onSocketChange.bind(this);
this.state = {};
@@ -35,10 +37,12 @@ export default class ChannelLoader extends React.Component {
PostStore.clearPendingPosts();
/* Set up interval functions */
- setInterval(
+ this.intervalId = setInterval(
function pollStatuses() {
AsyncClient.getStatuses();
- }, 30000);
+ },
+ 30000
+ );
/* Device tracking setup */
var iOS = (/(iPad|iPhone|iPod)/g).test(navigator.userAgent);
@@ -49,12 +53,12 @@ export default class ChannelLoader extends React.Component {
/* Set up tracking for whether the window is active */
window.isActive = true;
- $(window).focus(function windowFocus() {
+ $(window).on('focus', function windowFocus() {
AsyncClient.updateLastViewedAt();
window.isActive = true;
});
- $(window).blur(function windowBlur() {
+ $(window).on('blur', function windowBlur() {
window.isActive = false;
});
@@ -84,6 +88,54 @@ export default class ChannelLoader extends React.Component {
Utils.changeCss('.btn.btn-primary:hover, .btn.btn-primary:active, .btn.btn-primary:focus', 'background: ' + Utils.changeColor(user.props.theme, +10) + ';');
$('.team__header').addClass('theme--gray');
}
+
+ /* Setup global mouse events */
+ $('body').on('click.userpopover', function popOver(e) {
+ if ($(e.target).attr('data-toggle') !== 'popover' &&
+ $(e.target).parents('.popover.in').length === 0) {
+ $('.user-popover').popover('hide');
+ }
+ });
+
+ $('body').on('mouseenter mouseleave', '.post', function mouseOver(ev) {
+ if (ev.type === 'mouseenter') {
+ $(this).parent('div').prev('.date-separator, .new-separator').addClass('hovered--after');
+ $(this).parent('div').next('.date-separator, .new-separator').addClass('hovered--before');
+ } else {
+ $(this).parent('div').prev('.date-separator, .new-separator').removeClass('hovered--after');
+ $(this).parent('div').next('.date-separator, .new-separator').removeClass('hovered--before');
+ }
+ });
+
+ $('body').on('mouseenter mouseleave', '.post.post--comment.same--root', function mouseOver(ev) {
+ if (ev.type === 'mouseenter') {
+ $(this).parent('div').prev('.date-separator, .new-separator').addClass('hovered--comment');
+ $(this).parent('div').next('.date-separator, .new-separator').addClass('hovered--comment');
+ } else {
+ $(this).parent('div').prev('.date-separator, .new-separator').removeClass('hovered--comment');
+ $(this).parent('div').next('.date-separator, .new-separator').removeClass('hovered--comment');
+ }
+ });
+
+ /* Setup modal events */
+ $('.modal').on('show.bs.modal', function onShow() {
+ $('.modal-body').css('overflow-y', 'auto');
+ $('.modal-body').css('max-height', $(window).height() * 0.7);
+ });
+ }
+ componentWillUnmount() {
+ clearInterval(this.intervalId);
+
+ $(window).off('focus');
+ $(window).off('blur');
+
+ SocketStore.removeChangeListener(this.onSocketChange);
+
+ $('body').off('click.userpopover');
+ $('body').off('mouseenter mouseleave', '.post');
+ $('body').off('mouseenter mouseleave', '.post.post--comment.same--root');
+
+ $('.modal').off('show.bs.modal');
}
onSocketChange(msg) {
if (msg && msg.user_id && msg.user_id !== UserStore.getCurrentId()) {
diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx
index 871b72a43..d9e67836d 100644
--- a/web/react/components/create_post.jsx
+++ b/web/react/components/create_post.jsx
@@ -55,6 +55,11 @@ export default class CreatePost extends React.Component {
initialText: messageText
};
}
+ componentDidUpdate(prevProps, prevState) {
+ if (prevState.previews.length !== this.state.previews.length) {
+ this.resizePostHolder();
+ }
+ }
handleSubmit(e) {
e.preventDefault();
@@ -310,25 +315,33 @@ export default class CreatePost extends React.Component {
>
<div className='post-create'>
<div className='post-create-body'>
- <Textbox
- onUserInput={this.handleUserInput}
- onKeyPress={this.postMsgKeyPress}
- onHeightChange={this.resizePostHolder}
- messageText={this.state.messageText}
- createMessage='Write a message...'
- channelId={this.state.channelId}
- id='post_textbox'
- ref='textbox'
- />
- <FileUpload
- ref='fileUpload'
- getFileCount={this.getFileCount}
- onUploadStart={this.handleUploadStart}
- onFileUpload={this.handleFileUploadComplete}
- onUploadError={this.handleUploadError}
- postType='post'
- channelId=''
- />
+ <div className='post-body__cell'>
+ <Textbox
+ onUserInput={this.handleUserInput}
+ onKeyPress={this.postMsgKeyPress}
+ onHeightChange={this.resizePostHolder}
+ messageText={this.state.messageText}
+ createMessage='Write a message...'
+ channelId={this.state.channelId}
+ id='post_textbox'
+ ref='textbox'
+ />
+ <FileUpload
+ ref='fileUpload'
+ getFileCount={this.getFileCount}
+ onUploadStart={this.handleUploadStart}
+ onFileUpload={this.handleFileUploadComplete}
+ onUploadError={this.handleUploadError}
+ postType='post'
+ channelId=''
+ />
+ </div>
+ <a
+ className='send-button theme'
+ onClick={this.handleSubmit}
+ >
+ <i className='fa fa-paper-plane' />
+ </a>
</div>
<div className={postFooterClassName}>
{postError}
diff --git a/web/react/components/file_attachment.jsx b/web/react/components/file_attachment.jsx
index 78693df98..c9aa06a97 100644
--- a/web/react/components/file_attachment.jsx
+++ b/web/react/components/file_attachment.jsx
@@ -97,6 +97,7 @@ export default class FileAttachment extends React.Component {
var filename = this.props.filename;
var fileInfo = utils.splitFileLocation(filename);
+ var fileUrl = utils.getFileUrl(filename);
var type = utils.getFileType(fileInfo.ext);
var thumbnail;
@@ -150,14 +151,25 @@ export default class FileAttachment extends React.Component {
{thumbnail}
</a>
<div className='post-image__details'>
- <div
+ <a
+ href={fileUrl}
+ download={filenameString}
data-toggle='tooltip'
title={filenameString}
className='post-image__name'
>
{trimmedFilename}
- </div>
+ </a>
<div>
+ <a
+ href={fileUrl}
+ download={filenameString}
+ className='post-image__download'
+ >
+ <span
+ className='fa fa-download'
+ />
+ </a>
<span className='post-image__type'>{fileInfo.ext.toUpperCase()}</span>
<span className='post-image__size'>{fileSizeString}</span>
</div>
diff --git a/web/react/components/post_list.jsx b/web/react/components/post_list.jsx
index 6fa87ca4a..9d95887d9 100644
--- a/web/react/components/post_list.jsx
+++ b/web/react/components/post_list.jsx
@@ -18,8 +18,8 @@ var ActionTypes = Constants.ActionTypes;
import {strings} from '../utils/config.js';
export default class PostList extends React.Component {
- constructor() {
- super();
+ constructor(props) {
+ super(props);
this.gotMorePosts = false;
this.scrolled = false;
@@ -27,6 +27,7 @@ export default class PostList extends React.Component {
this.seenNewMessages = false;
this.isUserScroll = true;
this.userHasSeenNew = false;
+ this.loadInProgress = false;
this.onChange = this.onChange.bind(this);
this.onTimeChange = this.onTimeChange.bind(this);
@@ -34,22 +35,19 @@ export default class PostList extends React.Component {
this.createChannelIntroMessage = this.createChannelIntroMessage.bind(this);
this.loadMorePosts = this.loadMorePosts.bind(this);
this.loadFirstPosts = this.loadFirstPosts.bind(this);
+ this.activate = this.activate.bind(this);
+ this.deactivate = this.deactivate.bind(this);
+ this.resize = this.resize.bind(this);
- this.state = this.getStateFromStores();
+ this.state = this.getStateFromStores(props.channelId);
this.state.numToDisplay = Constants.POST_CHUNK_SIZE;
this.state.isFirstLoadComplete = false;
}
- getStateFromStores() {
- var channel = ChannelStore.getCurrent();
-
- if (channel == null) {
- channel = {};
- }
-
- var postList = PostStore.getCurrentPosts();
+ getStateFromStores(id) {
+ var postList = PostStore.getPosts(id);
if (postList != null) {
- var deletedPosts = PostStore.getUnseenDeletedPosts(channel.id);
+ var deletedPosts = PostStore.getUnseenDeletedPosts(id);
if (deletedPosts && Object.keys(deletedPosts).length > 0) {
for (var pid in deletedPosts) {
@@ -70,7 +68,7 @@ export default class PostList extends React.Component {
});
}
- var pendingPostList = PostStore.getPendingPosts(channel.id);
+ var pendingPostList = PostStore.getPendingPosts(id);
if (pendingPostList) {
postList.order = pendingPostList.order.concat(postList.order);
@@ -82,43 +80,42 @@ export default class PostList extends React.Component {
}
}
- var lastViewed = Number.MAX_VALUE;
-
- if (ChannelStore.getCurrentMember() != null) {
- lastViewed = ChannelStore.getCurrentMember().last_viewed_at;
- }
-
return {
- postList: postList,
- channel: channel,
- lastViewed: lastViewed
+ postList: postList
};
}
componentDidMount() {
+ if (this.props.isActive) {
+ this.activate();
+ this.loadFirstPosts(this.props.channelId);
+ }
+ }
+ componentWillUnmount() {
+ this.deactivate();
+ }
+ activate() {
+ this.gotMorePosts = false;
+ this.scrolled = false;
+ this.prevScrollTop = 0;
+ this.seenNewMessages = false;
+ this.isUserScroll = true;
+ this.userHasSeenNew = false;
+
+ PostStore.clearUnseenDeletedPosts(this.props.channelId);
PostStore.addChangeListener(this.onChange);
- ChannelStore.addChangeListener(this.onChange);
UserStore.addStatusesChangeListener(this.onTimeChange);
SocketStore.addChangeListener(this.onSocketChange);
- var postHolder = $('.post-list-holder-by-time');
-
- $('.modal').on('show.bs.modal', function onShow() {
- $('.modal-body').css('overflow-y', 'auto');
- $('.modal-body').css('max-height', $(window).height() * 0.7);
- });
-
- $(window).resize(function resize() {
- if ($('#create_post').length > 0) {
- var height = $(window).height() - $('#create_post').height() - $('#error_bar').outerHeight() - 50;
- postHolder.css('height', height + 'px');
- }
+ var postHolder = $(React.findDOMNode(this.refs.postlist));
+ $(window).on('resize.' + this.props.channelId, function resize() {
+ this.resize();
if (!this.scrolled) {
this.scrollToBottom();
}
}.bind(this));
- postHolder.scroll(function scroll() {
+ postHolder.on('scroll', function scroll() {
var position = postHolder.scrollTop() + postHolder.height() + 14;
var bottom = postHolder[0].scrollHeight;
@@ -134,43 +131,31 @@ export default class PostList extends React.Component {
this.isUserScroll = true;
}.bind(this));
- $('body').on('click.userpopover', function popOver(e) {
- if ($(e.target).attr('data-toggle') !== 'popover' &&
- $(e.target).parents('.popover.in').length === 0) {
- $('.user-popover').popover('hide');
- }
- });
-
$('.post-list__content div .post').removeClass('post--last');
$('.post-list__content div:last-child .post').addClass('post--last');
- $('body').on('mouseenter mouseleave', '.post', function mouseOver(ev) {
- if (ev.type === 'mouseenter') {
- $(this).parent('div').prev('.date-separator, .new-separator').addClass('hovered--after');
- $(this).parent('div').next('.date-separator, .new-separator').addClass('hovered--before');
- } else {
- $(this).parent('div').prev('.date-separator, .new-separator').removeClass('hovered--after');
- $(this).parent('div').next('.date-separator, .new-separator').removeClass('hovered--before');
- }
- });
-
- $('body').on('mouseenter mouseleave', '.post.post--comment.same--root', function mouseOver(ev) {
- if (ev.type === 'mouseenter') {
- $(this).parent('div').prev('.date-separator, .new-separator').addClass('hovered--comment');
- $(this).parent('div').next('.date-separator, .new-separator').addClass('hovered--comment');
- } else {
- $(this).parent('div').prev('.date-separator, .new-separator').removeClass('hovered--comment');
- $(this).parent('div').next('.date-separator, .new-separator').removeClass('hovered--comment');
- }
- });
+ if (!this.state.isFirstLoadComplete) {
+ this.loadFirstPosts(this.props.channelId);
+ }
+ this.resize();
+ this.onChange();
this.scrollToBottom();
-
- if (this.state.channel.id != null) {
- this.loadFirstPosts(this.state.channel.id);
- }
+ }
+ deactivate() {
+ PostStore.removeChangeListener(this.onChange);
+ UserStore.removeStatusesChangeListener(this.onTimeChange);
+ SocketStore.removeChangeListener(this.onSocketChange);
+ $('body').off('click.userpopover');
+ $(window).off('resize.' + this.props.channelId);
+ var postHolder = $(React.findDOMNode(this.refs.postlist));
+ postHolder.off('scroll');
}
componentDidUpdate(prevProps, prevState) {
+ if (!this.props.isActive) {
+ return;
+ }
+
$('.post-list__content div .post').removeClass('post--last');
$('.post-list__content div:last-child .post').addClass('post--last');
@@ -187,7 +172,7 @@ export default class PostList extends React.Component {
var firstPost = posts[order[0]] || {};
var isNewPost = oldOrder.indexOf(order[0]) === -1;
- if (this.state.channel.id !== prevState.channel.id) {
+ if (this.props.isActive && !prevProps.isActive) {
this.scrollToBottom();
} else if (oldOrder.length === 0) {
this.scrollToBottom();
@@ -201,39 +186,45 @@ export default class PostList extends React.Component {
} else if (isNewPost &&
userId === firstPost.user_id &&
!utils.isComment(firstPost)) {
- this.state.lastViewed = utils.getTimestamp();
this.scrollToBottom(true);
// the user clicked 'load more messages'
} else if (this.gotMorePosts) {
var lastPost = oldPosts[oldOrder[prevState.numToDisplay]];
$('#' + lastPost.id)[0].scrollIntoView();
+ this.gotMorePosts = false;
} else {
this.scrollTo(this.prevScrollTop);
}
}
componentWillUpdate() {
- var postHolder = $('.post-list-holder-by-time');
+ var postHolder = $(React.findDOMNode(this.refs.postlist));
this.prevScrollTop = postHolder.scrollTop();
}
- componentWillUnmount() {
- PostStore.removeChangeListener(this.onChange);
- ChannelStore.removeChangeListener(this.onChange);
- UserStore.removeStatusesChangeListener(this.onTimeChange);
- SocketStore.removeChangeListener(this.onSocketChange);
- $('body').off('click.userpopover');
- $('.modal').off('show.bs.modal');
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.isActive === true && this.props.isActive === false) {
+ this.activate();
+ } else if (nextProps.isActive === false && this.props.isActive === true) {
+ this.deactivate();
+ }
+ }
+ resize() {
+ const postHolder = $(React.findDOMNode(this.refs.postlist));
+ if ($('#create_post').length > 0) {
+ const height = $(window).height() - $('#create_post').height() - $('#error_bar').outerHeight() - 50;
+ postHolder.css('height', height + 'px');
+ }
}
scrollTo(val) {
this.isUserScroll = false;
- var postHolder = $('.post-list-holder-by-time');
+ var postHolder = $(React.findDOMNode(this.refs.postlist));
postHolder[0].scrollTop = val;
}
scrollToBottom(force) {
this.isUserScroll = false;
- var postHolder = $('.post-list-holder-by-time');
- if ($('#new_message')[0] && !this.userHasSeenNew && !force) {
- $('#new_message')[0].scrollIntoView();
+ var postHolder = $(React.findDOMNode(this.refs.postlist));
+ if ($('#new_message_' + this.props.channelId)[0] && !this.userHasSeenNew && !force) {
+ $('#new_message_' + this.props.channelId)[0].scrollIntoView();
} else {
postHolder.addClass('hide-scroll');
postHolder[0].scrollTop = postHolder[0].scrollHeight;
@@ -241,34 +232,32 @@ export default class PostList extends React.Component {
}
}
loadFirstPosts(id) {
+ if (this.loadInProgress) {
+ return;
+ }
+
+ if (this.props.channelId == null) {
+ return;
+ }
+
+ this.loadInProgress = true;
Client.getPosts(
id,
PostStore.getLatestUpdate(id),
function success() {
+ this.loadInProgress = false;
this.setState({isFirstLoadComplete: true});
}.bind(this),
function fail() {
+ this.loadInProgress = false;
this.setState({isFirstLoadComplete: true});
}.bind(this)
);
}
onChange() {
- var newState = this.getStateFromStores();
-
- // Special case where the channel wasn't yet set in componentDidMount
- if (!this.state.isFirstLoadComplete && this.state.channel.id == null && newState.channel.id != null) {
- this.loadFirstPosts(newState.channel.id);
- }
-
- if (!utils.areStatesEqual(newState, this.state)) {
- if (this.state.channel.id !== newState.channel.id) {
- PostStore.clearUnseenDeletedPosts(this.state.channel.id);
- this.userHasSeenNew = false;
- newState.numToDisplay = Constants.POST_CHUNK_SIZE;
- } else {
- newState.lastViewed = this.state.lastViewed;
- }
+ var newState = this.getStateFromStores(this.props.channelId);
+ if (!utils.areStatesEqual(newState.postList, this.state.postList)) {
this.setState(newState);
}
}
@@ -424,7 +413,7 @@ export default class PostList extends React.Component {
}
}
- var members = ChannelStore.getCurrentExtraInfo().members;
+ var members = ChannelStore.getExtraInfo(channel.id).members;
for (var i = 0; i < members.length; i++) {
if (members[i].roles.indexOf('admin') > -1) {
return members[i].username;
@@ -488,6 +477,11 @@ export default class PostList extends React.Component {
var userId = UserStore.getCurrentId();
var renderedLastViewed = false;
+ var lastViewed = Number.MAX_VALUE;
+
+ if (ChannelStore.getMember(this.props.channelId) != null) {
+ lastViewed = ChannelStore.getMember(this.props.channelId).last_viewed_at;
+ }
var numToDisplay = this.state.numToDisplay;
if (order.length - 1 < numToDisplay) {
@@ -543,13 +537,13 @@ export default class PostList extends React.Component {
);
}
- if (post.user_id !== userId && post.create_at > this.state.lastViewed && !renderedLastViewed) {
+ if (post.user_id !== userId && post.create_at > lastViewed && !renderedLastViewed) {
renderedLastViewed = true;
// Temporary fix to solve ie10/11 rendering issue
let newSeparatorId = '';
if (!utils.isBrowserIE()) {
- newSeparatorId = 'new_message';
+ newSeparatorId = 'new_message_' + this.props.channelId;
}
postCtls.push(
<div
@@ -577,7 +571,7 @@ export default class PostList extends React.Component {
var posts = this.state.postList.posts;
var order = this.state.postList.order;
- var channelId = this.state.channel.id;
+ var channelId = this.props.channelId;
$(React.findDOMNode(this.refs.loadmore)).text('Retrieving more messages...');
@@ -619,7 +613,7 @@ export default class PostList extends React.Component {
render() {
var order = [];
var posts;
- var channel = this.state.channel;
+ var channel = ChannelStore.get(this.props.channelId);
if (this.state.postList != null) {
posts = this.state.postList.posts;
@@ -628,7 +622,7 @@ export default class PostList extends React.Component {
var moreMessages = <p className='beginning-messages-text'>Beginning of Channel</p>;
if (channel != null) {
- if (order.length > this.state.numToDisplay) {
+ if (order.length >= this.state.numToDisplay) {
moreMessages = (
<a
ref='loadmore'
@@ -655,10 +649,15 @@ export default class PostList extends React.Component {
/>);
}
+ var activeClass = '';
+ if (!this.props.isActive) {
+ activeClass = 'inactive';
+ }
+
return (
<div
ref='postlist'
- className='post-list-holder-by-time'
+ className={'post-list-holder-by-time ' + activeClass}
>
<div className='post-list__table'>
<div className='post-list__content'>
@@ -670,3 +669,12 @@ export default class PostList extends React.Component {
);
}
}
+
+PostList.defaultProps = {
+ isActive: false,
+ channelId: null
+};
+PostList.propTypes = {
+ isActive: React.PropTypes.bool,
+ channelId: React.PropTypes.string
+};
diff --git a/web/react/components/post_list_container.jsx b/web/react/components/post_list_container.jsx
new file mode 100644
index 000000000..0815ac883
--- /dev/null
+++ b/web/react/components/post_list_container.jsx
@@ -0,0 +1,62 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+const PostList = require('./post_list.jsx');
+const ChannelStore = require('../stores/channel_store.jsx');
+
+export default class PostListContainer extends React.Component {
+ constructor() {
+ super();
+
+ this.onChange = this.onChange.bind(this);
+ this.onLeave = this.onLeave.bind(this);
+
+ let currentChannelId = ChannelStore.getCurrentId();
+ if (currentChannelId) {
+ this.state = {currentChannelId: currentChannelId, postLists: [currentChannelId]};
+ } else {
+ this.state = {currentChannelId: null, postLists: []};
+ }
+ }
+ componentDidMount() {
+ ChannelStore.addChangeListener(this.onChange);
+ ChannelStore.addLeaveListener(this.onLeave);
+ }
+ onChange() {
+ let channelId = ChannelStore.getCurrentId();
+ if (channelId === this.state.currentChannelId) {
+ return;
+ }
+
+ let postLists = this.state.postLists;
+ if (postLists.indexOf(channelId) === -1) {
+ postLists.push(channelId);
+ }
+ this.setState({currentChannelId: channelId, postLists: postLists});
+ }
+ onLeave(id) {
+ let postLists = this.state.postLists;
+ var index = postLists.indexOf(id);
+ if (index !== -1) {
+ postLists.splice(index, 1);
+ }
+ }
+ render() {
+ let postLists = this.state.postLists;
+ let channelId = this.state.currentChannelId;
+
+ let postListCtls = [];
+ for (let i = 0; i <= this.state.postLists.length - 1; i++) {
+ postListCtls.push(
+ <PostList
+ channelId={postLists[i]}
+ isActive={postLists[i] === channelId}
+ />
+ );
+ }
+
+ return (
+ <div>{postListCtls}</div>
+ );
+ }
+}
diff --git a/web/react/components/setting_item_min.jsx b/web/react/components/setting_item_min.jsx
index 098729a4f..2c0fdf2f4 100644
--- a/web/react/components/setting_item_min.jsx
+++ b/web/react/components/setting_item_min.jsx
@@ -12,14 +12,18 @@ export default class SettingItemMin extends React.Component {
href='#'
onClick={this.props.updateSection}
>
- Edit
+ <i className='fa fa-pencil'/>
+ {'Edit'}
</a>
</li>
);
}
return (
- <ul className='section-min'>
+ <ul
+ className='section-min'
+ onClick={this.props.updateSection}
+ >
<li className='col-sm-10 section-title'>{this.props.title}</li>
{editButton}
<li className='col-sm-7 section-describe'>{this.props.describe}</li>
diff --git a/web/react/components/setting_upload.jsx b/web/react/components/setting_upload.jsx
index 5979091c4..fad27b355 100644
--- a/web/react/components/setting_upload.jsx
+++ b/web/react/components/setting_upload.jsx
@@ -64,9 +64,9 @@ export default class SettingsUpload extends React.Component {
}
return (
<ul className='section-max'>
- <li className='col-xs-12 section-title'>{this.props.title}</li>
- <li className='col-xs-offset-3'>{this.props.helpText}</li>
- <li className='col-xs-offset-3 col-xs-8'>
+ <li className='col-sm-12 section-title'>{this.props.title}</li>
+ <li className='col-sm-offset-3 col-sm-9'>{this.props.helpText}</li>
+ <li className='col-sm-offset-3 col-sm-9'>
<ul className='setting-list'>
<li className='setting-list-item'>
<span className='btn btn-sm btn-primary btn-file sel-btn'>
diff --git a/web/react/components/sidebar.jsx b/web/react/components/sidebar.jsx
index 983260187..ad934d271 100644
--- a/web/react/components/sidebar.jsx
+++ b/web/react/components/sidebar.jsx
@@ -315,10 +315,12 @@ export default class Sidebar extends React.Component {
if (unread) {
titleClass = 'unread-title';
- if (!this.firstUnreadChannel) {
- this.firstUnreadChannel = channel.name;
+ if (channel.id !== activeId) {
+ if (!this.firstUnreadChannel) {
+ this.firstUnreadChannel = channel.name;
+ }
+ this.lastUnreadChannel = channel.name;
}
- this.lastUnreadChannel = channel.name;
}
var badge = null;
diff --git a/web/react/components/team_export_tab.jsx b/web/react/components/team_export_tab.jsx
new file mode 100644
index 000000000..2914904ad
--- /dev/null
+++ b/web/react/components/team_export_tab.jsx
@@ -0,0 +1,94 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var Client = require('../utils/client.jsx');
+
+export default class TeamExportTab extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {status: 'request', link: '', err: ''};
+
+ this.onExportSuccess = this.onExportSuccess.bind(this);
+ this.onExportFailure = this.onExportFailure.bind(this);
+ this.doExport = this.doExport.bind(this);
+ }
+ onExportSuccess(data) {
+ this.setState({status: 'ready', link: data.link, err: ''});
+ }
+ onExportFailure(e) {
+ this.setState({status: 'failure', link: '', err: e.message});
+ }
+ doExport() {
+ if (this.state.status === 'in-progress') {
+ return;
+ }
+ this.setState({status: 'in-progress'});
+ Client.exportTeam(this.onExportSuccess, this.onExportFailure);
+ }
+ render() {
+ var messageSection = '';
+ switch (this.state.status) {
+ case 'request':
+ messageSection = '';
+ break;
+ case 'in-progress':
+ messageSection = (
+ <p className='confirm-import alert alert-warning'>
+ <i className='fa fa-spinner fa-pulse' />
+ {' Exporting...'}
+ </p>
+ );
+ break;
+ case 'ready':
+ messageSection = (
+ <p className='confirm-import alert alert-success'>
+ <i className='fa fa-check' />
+ {' Ready for '}
+ <a
+ href={this.state.link}
+ download={true}
+ >
+ {'download'}
+ </a>
+ </p>
+ );
+ break;
+ case 'failure':
+ messageSection = (
+ <p className='confirm-import alert alert-warning'>
+ <i className='fa fa-warning' />
+ {' Unable to export: ' + this.state.err}
+ </p>
+ );
+ break;
+ }
+
+ return (
+ <div
+ ref='wrapper'
+ className='user-settings'
+ >
+ <h3 className='tab-header'>{'Export'}</h3>
+ <div className='divider-dark first'/>
+ <ul className='section-max'>
+ <li className='col-xs-12 section-title'>{'Export your team'}</li>
+ <li className='col-xs-offset-3 col-xs-8'>
+ <ul className='setting-list'>
+ <li className='setting-list-item'>
+ <a
+ className='btn btn-sm btn-primary btn-file sel-btn'
+ href='#'
+ onClick={this.doExport}
+ >
+ {'Export'}
+ </a>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ <div className='divider-dark'/>
+ {messageSection}
+ </div>
+ );
+ }
+}
diff --git a/web/react/components/team_import_tab.jsx b/web/react/components/team_import_tab.jsx
index 1ab348465..031abc36a 100644
--- a/web/react/components/team_import_tab.jsx
+++ b/web/react/components/team_import_tab.jsx
@@ -34,13 +34,11 @@ export default class TeamImportTab extends React.Component {
render() {
var uploadHelpText = (
<div>
- <br/>
- Slack does not allow you to export files, images, private groups or direct messages stored in Slack. Therefore, Slack import to Mattermost only supports importing of text messages in your Slack team's public channels.
- <br/><br/>
- The Slack import to Mattermost is in "Preview". Slack bot posts and channels with underscores do not yet import.
- <br/><br/>
+ <p>{'Slack does not allow you to export files, images, private groups or direct messages stored in Slack. Therefore, Slack import to Mattermost only supports importing of text messages in your Slack team\'\s public channels.'}</p>
+ <p>{'The Slack import to Mattermost is in "Preview". Slack bot posts and channels with underscores do not yet import.'}</p>
</div>
);
+
var uploadSection = (
<SettingUpload
title='Import from Slack'
@@ -58,7 +56,7 @@ export default class TeamImportTab extends React.Component {
break;
case 'in-progress':
messageSection = (
- <p className='confirm-import alert alert-warning'><i className='fa fa-spinner fa-pulse'></i> Importing...</p>
+ <p className='confirm-import alert alert-warning'><i className='fa fa-spinner fa-pulse'></i>{' Importing...'}</p>
);
break;
case 'done':
@@ -99,18 +97,18 @@ export default class TeamImportTab extends React.Component {
data-dismiss='modal'
aria-label='Close'
>
- <span aria-hidden='true'>&times;</span>
+ <span aria-hidden='true'>{'×'}</span>
</button>
<h4
className='modal-title'
ref='title'
- ><i className='modal-back'></i>Import</h4>
+ ><i className='modal-back'></i>{'Import'}</h4>
</div>
<div
ref='wrapper'
className='user-settings'
>
- <h3 className='tab-header'>Import</h3>
+ <h3 className='tab-header'>{'Import'}</h3>
<div className='divider-dark first'/>
{uploadSection}
<div className='divider-dark'/>
diff --git a/web/react/components/team_settings.jsx b/web/react/components/team_settings.jsx
index 53855fe1c..396521af9 100644
--- a/web/react/components/team_settings.jsx
+++ b/web/react/components/team_settings.jsx
@@ -3,6 +3,7 @@
var TeamStore = require('../stores/team_store.jsx');
var ImportTab = require('./team_import_tab.jsx');
+var ExportTab = require('./team_export_tab.jsx');
var FeatureTab = require('./team_feature_tab.jsx');
var GeneralTab = require('./team_general_tab.jsx');
var Utils = require('../utils/utils.jsx');
@@ -64,6 +65,13 @@ export default class TeamSettings extends React.Component {
</div>
);
break;
+ case 'export':
+ result = (
+ <div>
+ <ExportTab />
+ </div>
+ );
+ break;
default:
result = (
<div/>
diff --git a/web/react/components/team_settings_modal.jsx b/web/react/components/team_settings_modal.jsx
index 668bf76cf..0513c811f 100644
--- a/web/react/components/team_settings_modal.jsx
+++ b/web/react/components/team_settings_modal.jsx
@@ -36,6 +36,7 @@ export default class TeamSettingsModal extends React.Component {
let tabs = [];
tabs.push({name: 'general', uiName: 'General', icon: 'glyphicon glyphicon-cog'});
tabs.push({name: 'import', uiName: 'Import', icon: 'glyphicon glyphicon-upload'});
+ tabs.push({name: 'export', uiName: 'Export', icon: 'glyphicon glyphicon-download'});
tabs.push({name: 'feature', uiName: 'Advanced', icon: 'glyphicon glyphicon-wrench'});
return (
diff --git a/web/react/components/textbox.jsx b/web/react/components/textbox.jsx
index b4518fe80..ea8126bec 100644
--- a/web/react/components/textbox.jsx
+++ b/web/react/components/textbox.jsx
@@ -242,7 +242,7 @@ export default class Textbox extends React.Component {
const e = React.findDOMNode(this.refs.message);
const w = React.findDOMNode(this.refs.wrapper);
- let prevHeight = $(e).height();
+ const prevHeight = $(e).height();
const lht = parseInt($(e).css('lineHeight'), 10);
const lines = e.scrollHeight / lht;
@@ -260,7 +260,7 @@ export default class Textbox extends React.Component {
$(w).css({height: 'auto'}).height(167);
}
- if (prevHeight !== $(e).height()) {
+ if (prevHeight !== $(e).height() && this.props.onHeightChange) {
this.props.onHeightChange();
}
}
@@ -320,6 +320,6 @@ Textbox.propTypes = {
messageText: React.PropTypes.string.isRequired,
onUserInput: React.PropTypes.func.isRequired,
onKeyPress: React.PropTypes.func.isRequired,
- onHeightChange: React.PropTypes.func.isRequired,
+ onHeightChange: React.PropTypes.func,
createMessage: React.PropTypes.string.isRequired
};
diff --git a/web/react/components/user_settings.jsx b/web/react/components/user_settings.jsx
index 282fb7681..2a607b3e0 100644
--- a/web/react/components/user_settings.jsx
+++ b/web/react/components/user_settings.jsx
@@ -40,6 +40,7 @@ export default class UserSettings extends React.Component {
user={this.state.user}
activeSection={this.props.activeSection}
updateSection={this.props.updateSection}
+ updateTab={this.props.updateTab}
/>
</div>
);
@@ -86,4 +87,4 @@ UserSettings.propTypes = {
activeSection: React.PropTypes.string,
updateSection: React.PropTypes.func,
updateTab: React.PropTypes.func
-}; \ No newline at end of file
+};
diff --git a/web/react/components/user_settings_general.jsx b/web/react/components/user_settings_general.jsx
index ead7ac1d5..184534a9a 100644
--- a/web/react/components/user_settings_general.jsx
+++ b/web/react/components/user_settings_general.jsx
@@ -238,7 +238,7 @@ export default class UserSettingsGeneralTab extends React.Component {
key='firstNameSetting'
className='form-group'
>
- <label className='col-sm-5 control-label'>First Name</label>
+ <label className='col-sm-5 control-label'>{'First Name'}</label>
<div className='col-sm-7'>
<input
className='form-control'
@@ -255,7 +255,7 @@ export default class UserSettingsGeneralTab extends React.Component {
key='lastNameSetting'
className='form-group'
>
- <label className='col-sm-5 control-label'>Last Name</label>
+ <label className='col-sm-5 control-label'>{'Last Name'}</label>
<div className='col-sm-7'>
<input
className='form-control'
@@ -267,6 +267,28 @@ export default class UserSettingsGeneralTab extends React.Component {
</div>
);
+ function notifClick(e) {
+ e.preventDefault();
+ this.updateSection('');
+ this.props.updateTab('notifications');
+ }
+
+ const notifLink = (
+ <a
+ href='#'
+ onClick={notifClick.bind(this)}
+ >
+ {'Notifications'}
+ </a>
+ );
+
+ const extraInfo = (
+ <span>
+ {'By default, you will receive mention notifications when someone types your first name. '}
+ {'Go to '} {notifLink} {'settings to change this default.'}
+ </span>
+ );
+
nameSection = (
<SettingItemMax
title='Full Name'
@@ -278,6 +300,7 @@ export default class UserSettingsGeneralTab extends React.Component {
this.updateSection('');
e.preventDefault();
}.bind(this)}
+ extraInfo={extraInfo}
/>
);
} else {
@@ -326,6 +349,13 @@ export default class UserSettingsGeneralTab extends React.Component {
</div>
);
+ const extraInfo = (
+ <span>
+ {'Use Nickname for a name you might be called that is different from your first name and user name.'}
+ {'This is most often used when two or more people have similar sounding names and usernames.'}
+ </span>
+ );
+
nicknameSection = (
<SettingItemMax
title='Nickname'
@@ -337,6 +367,7 @@ export default class UserSettingsGeneralTab extends React.Component {
this.updateSection('');
e.preventDefault();
}.bind(this)}
+ extraInfo={extraInfo}
/>
);
} else {
@@ -375,6 +406,8 @@ export default class UserSettingsGeneralTab extends React.Component {
</div>
);
+ const extraInfo = (<span>{'Pick something easy for teammates to recognize and recall.'}</span>);
+
usernameSection = (
<SettingItemMax
title='Username'
@@ -386,6 +419,7 @@ export default class UserSettingsGeneralTab extends React.Component {
this.updateSection('');
e.preventDefault();
}.bind(this)}
+ extraInfo={extraInfo}
/>
);
} else {
@@ -404,13 +438,13 @@ export default class UserSettingsGeneralTab extends React.Component {
let helpText = <div>Email is used for notifications, and requires verification if changed.</div>;
if (!this.state.emailEnabled) {
- helpText = <div className='text-danger'><br />Email has been disabled by your system administrator. No notification emails will be sent until it is enabled.</div>;
+ helpText = <div className='setting-list__hint text-danger'>{'Email has been disabled by your system administrator. No notification emails will be sent until it is enabled.'}</div>;
}
inputs.push(
<div key='emailSetting'>
<div className='form-group'>
- <label className='col-sm-5 control-label'>Primary Email</label>
+ <label className='col-sm-5 control-label'>{'Primary Email'}</label>
<div className='col-sm-7'>
<input
className='form-control'
@@ -492,18 +526,18 @@ export default class UserSettingsGeneralTab extends React.Component {
data-dismiss='modal'
aria-label='Close'
>
- <span aria-hidden='true'>&times;</span>
+ <span aria-hidden='true'>{'×'}</span>
</button>
<h4
className='modal-title'
ref='title'
>
<i className='modal-back'></i>
- General Settings
+ {'General Settings'}
</h4>
</div>
<div className='user-settings'>
- <h3 className='tab-header'>General Settings</h3>
+ <h3 className='tab-header'>{'General Settings'}</h3>
<div className='divider-dark first'/>
{nameSection}
<div className='divider-light'/>
@@ -524,5 +558,6 @@ export default class UserSettingsGeneralTab extends React.Component {
UserSettingsGeneralTab.propTypes = {
user: React.PropTypes.object,
updateSection: React.PropTypes.func,
+ updateTab: React.PropTypes.func,
activeSection: React.PropTypes.string
};
diff --git a/web/react/pages/channel.jsx b/web/react/pages/channel.jsx
index 71a03cde0..182721bef 100644
--- a/web/react/pages/channel.jsx
+++ b/web/react/pages/channel.jsx
@@ -5,7 +5,7 @@ var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
var Navbar = require('../components/navbar.jsx');
var Sidebar = require('../components/sidebar.jsx');
var ChannelHeader = require('../components/channel_header.jsx');
-var PostList = require('../components/post_list.jsx');
+var PostListContainer = require('../components/post_list_container.jsx');
var CreatePost = require('../components/create_post.jsx');
var SidebarRight = require('../components/sidebar_right.jsx');
var SidebarRightMenu = require('../components/sidebar_right_menu.jsx');
@@ -159,7 +159,7 @@ function setupChannelPage(teamName, teamType, teamId, channelName, channelId) {
);
React.render(
- <PostList />,
+ <PostListContainer />,
document.getElementById('post-list')
);
diff --git a/web/react/stores/channel_store.jsx b/web/react/stores/channel_store.jsx
index bd655b767..b9ba37c27 100644
--- a/web/react/stores/channel_store.jsx
+++ b/web/react/stores/channel_store.jsx
@@ -10,6 +10,7 @@ var ActionTypes = Constants.ActionTypes;
var BrowserStore = require('../stores/browser_store.jsx');
var CHANGE_EVENT = 'change';
+var LEAVE_EVENT = 'leave';
var MORE_CHANGE_EVENT = 'change';
var EXTRA_INFO_EVENT = 'extra_info';
@@ -48,6 +49,15 @@ class ChannelStoreClass extends EventEmitter {
removeExtraInfoChangeListener(callback) {
this.removeListener(EXTRA_INFO_EVENT, callback);
}
+ emitLeave(id) {
+ this.emit(LEAVE_EVENT, id);
+ }
+ addLeaveListener(callback) {
+ this.on(LEAVE_EVENT, callback);
+ }
+ removeLeaveListener(callback) {
+ this.removeListener(LEAVE_EVENT, callback);
+ }
findFirstBy(field, value) {
var channels = this.pGetChannels();
for (var i = 0; i < channels.length; i++) {
@@ -272,6 +282,10 @@ ChannelStore.dispatchToken = AppDispatcher.register(function handleAction(payloa
ChannelStore.emitExtraInfoChange();
break;
+ case ActionTypes.LEAVE_CHANNEL:
+ ChannelStore.emitLeave(action.id);
+ break;
+
default:
break;
}
diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx
index 10f9c0b37..51fd16474 100644
--- a/web/react/utils/client.jsx
+++ b/web/react/utils/client.jsx
@@ -919,6 +919,19 @@ export function importSlack(fileData, success, error) {
});
}
+export function exportTeam(success, error) {
+ $.ajax({
+ url: '/api/v1/teams/export_team',
+ type: 'GET',
+ dataType: 'json',
+ success: success,
+ error: function onError(xhr, status, err) {
+ var e = handleError('exportTeam', xhr, status, err);
+ error(e);
+ }
+ });
+}
+
export function getStatuses(success, error) {
$.ajax({
url: '/api/v1/users/status',
diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx
index 18b7ff59c..7ead079d7 100644
--- a/web/react/utils/constants.jsx
+++ b/web/react/utils/constants.jsx
@@ -9,6 +9,7 @@ module.exports = {
CLICK_CHANNEL: null,
CREATE_CHANNEL: null,
+ LEAVE_CHANNEL: null,
RECIEVED_CHANNELS: null,
RECIEVED_CHANNEL: null,
RECIEVED_MORE_CHANNELS: null,
diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx
index ea42256aa..71cd1d344 100644
--- a/web/react/utils/utils.jsx
+++ b/web/react/utils/utils.jsx
@@ -498,7 +498,7 @@ export function textToJsx(textin, options) {
// do both a non-case sensitive and case senstive check
let mClass = '';
- if (('@' + name.toLowerCase()) !== -1 || implicitKeywords.indexOf('@' + name) !== -1) {
+ if (implicitKeywords.indexOf('@' + name.toLowerCase()) !== -1 || implicitKeywords.indexOf('@' + name) !== -1) {
mClass = mentionClass;
}
diff --git a/web/sass-files/sass/partials/_files.scss b/web/sass-files/sass/partials/_files.scss
index 70f440989..405265f92 100644
--- a/web/sass-files/sass/partials/_files.scss
+++ b/web/sass-files/sass/partials/_files.scss
@@ -61,8 +61,8 @@
cursor: pointer;
z-index: 5;
opacity: inherit;
- text-shadow: 0 0px 3px #444;
- text-shadow: 0 0px 3px rgba(0, 0, 0, 0.7);
+ text-shadow: 0 0px 3px #444;
+ text-shadow: 0 0px 3px rgba(0, 0, 0, 0.7);
}
}
}
@@ -159,6 +159,14 @@
padding: 7px;
.post-image__name {
margin-bottom: 3px;
+ display: block;
+ color: #333;
+ }
+ .post-image__download {
+ display: inline-block;
+ padding-right: 7px;
+ cursor: pointer;
+ color: #555;
}
.post-image__type {
color: grey;
diff --git a/web/sass-files/sass/partials/_modal.scss b/web/sass-files/sass/partials/_modal.scss
index dec08b567..d32306cbc 100644
--- a/web/sass-files/sass/partials/_modal.scss
+++ b/web/sass-files/sass/partials/_modal.scss
@@ -63,7 +63,6 @@
margin: 0;
}
button.close {
- margin: -2px -2px 0 0;
color: #fff;
@include opacity(1);
z-index: 5;
@@ -71,7 +70,8 @@
height: 30px;
line-height: 30px;
@include single-transition(all, 0.25s, ease-in);
- position: relative;
+ position: absolute;
+ right: 10px;
&:hover {
background: rgba(0, 0, 0, 0.1);
}
@@ -82,7 +82,7 @@
.btn {
&.btn-primary {
float: right;
- margin-top: -4px;
+ margin: -4px 25px 0 0;
position: relative;
i {
margin-right: 5px;
@@ -118,12 +118,11 @@
}
&#more_channels {
.modal-body {
- padding: 5px 10px;
+ padding: 0;
}
}
.more-channel-table {
margin: 0;
- table-layout: fixed;
p {
font-size: 0.9em;
overflow: hidden;
@@ -138,16 +137,23 @@
}
tbody {
> tr {
+ &:hover td {
+ background: #f7f7f7;
+ }
&:first-child {
td {
border: none;
}
}
td {
+ width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+ padding: 8px 8px 8px 15px;
&.td--action {
+ text-align: right;
+ padding: 8px 15px 8px 8px;
width: 70px;
vertical-align: middle;
}
diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss
index 0605e9c3b..6940cf2fb 100644
--- a/web/sass-files/sass/partials/_post.scss
+++ b/web/sass-files/sass/partials/_post.scss
@@ -139,9 +139,16 @@ body.ios {
width: 100%;
padding: 1em 0 0;
position: relative;
- &.hide-scroll::-webkit-scrollbar {
+ -webkit-overflow-scrolling: touch;
+ &::-webkit-scrollbar {
width: 0px !important;
}
+ &.inactive {
+ display: none;
+ }
+ &.active {
+ display: inline;
+ }
}
.post-list__table {
display: table;
@@ -181,6 +188,25 @@ body.ios {
max-width: 850px;
padding: 0 0 2px;
position: relative;
+ .post-body__cell {
+ vertical-align: top;
+ position: relative;
+ }
+ .send-button {
+ display: none;
+ cursor: pointer;
+ padding-right: 4px;
+ width: 45px;
+ height: 37px;
+ font-size: 18px;
+ line-height: 37px;
+ vertical-align: bottom;
+ text-align: center;
+ @include single-transition(all, 0.15s);
+ &:active {
+ @include opacity(0.75);
+ }
+ }
.custom-textarea {
padding-top: 8px;
padding-right: 28px;
diff --git a/web/sass-files/sass/partials/_responsive.scss b/web/sass-files/sass/partials/_responsive.scss
index f691fba43..a30782dd0 100644
--- a/web/sass-files/sass/partials/_responsive.scss
+++ b/web/sass-files/sass/partials/_responsive.scss
@@ -243,6 +243,23 @@
}
}
@media screen and (max-width: 768px) {
+ .file-details__container {
+ display: block;
+ .file-details__preview {
+ display: block;
+ width: 100%;
+ height: 150px;
+ border-right: none;
+ border-bottom: 1px solid #ddd;
+ img {
+ width: 64px;
+ height: 64px;
+ }
+ }
+ .file-details {
+ height: auto;
+ }
+ }
.center-file-overlay {
font-size: 1.3em;
}
@@ -348,6 +365,9 @@
width: 100%;
z-index: 5;
}
+ .modal-title {
+ padding-left: 20px;
+ }
.user-settings {
.tab-header {
display: none;
@@ -392,9 +412,35 @@
}
}
}
- #post-create {
+ .post-create__container {
+ .post-right__container & {
+ padding: 0 1em;
+ }
+ form {
+ padding: 0;
+ }
.post-create-body {
padding-bottom: 10px;
+ display: table;
+ width: 100%;
+ table-layout: fixed;
+ .post-body__cell {
+ display: table-cell;
+ padding-left: 45px;
+ }
+ .app__content & {
+ .btn-file {
+ width: 45px;
+ padding: 0;
+ line-height: 36px;
+ bottom: -2px;
+ left: 0;
+ top: auto;
+ }
+ }
+ .send-button {
+ display: table-cell;
+ }
}
.post-create-footer .msg-typing {
display: none;
@@ -405,23 +451,23 @@
margin-top: 0;
}
.remove-preview {
- width: 50px;
- height: 50px;
- left: 50%;
- top: 50%;
+ width: 28px;
+ height: 28px;
+ left: auto;
+ right: 0;
+ top: 0;
background: #444;
- margin: -25px 0 0 -25px;
- @include border-radius(50px);
+ background: rgba(#000, 0.5);
text-align: center;
&:after {
display: none;
}
i {
- line-height: 50px;
+ line-height: 29px;
top: auto;
right: auto;
position: relative;
- font-size: 28px;
+ font-size: 16px;
}
}
}
@@ -496,6 +542,12 @@
height: 45px;
position: relative;
@include single-transition(all, 0.2s, linear);
+ .glyphicon-refresh-animate {
+ right: 33px;
+ top: 15px;
+ color: #fff;
+ color: rgba(255,255,255,0.5);
+ }
.form-control {
border: none;
padding: 0 10px 0 31px;
diff --git a/web/sass-files/sass/partials/_settings.scss b/web/sass-files/sass/partials/_settings.scss
index 99a7eb7bc..2b59a943b 100644
--- a/web/sass-files/sass/partials/_settings.scss
+++ b/web/sass-files/sass/partials/_settings.scss
@@ -14,13 +14,15 @@
width:800px;
max-width: 100%;
.modal-back {
- width: 8px;
- height: 13px;
- background: url("../images/arrow-left.png");
- @include background-size(100% 100%);
- margin-right: 10px;
- display: inline-block;
+ width: 40px;
+ height: 56px;
+ background: url("../images/arrow-left.png") no-repeat;
+ @include background-size(8px 13px);
+ background-position: center;
+ top: 0;
+ left: 0;
cursor: pointer;
+ position: absolute;
}
.modal-body {
padding: 0;
@@ -54,12 +56,22 @@
.section-min {
padding: 1em 0;
margin-bottom: 0;
+ cursor: pointer;
@include clearfix;
+ &:hover {
+ background: #f9f9f9;
+ }
+ &:hover .fa {
+ display: inline-block;
+ }
+ &:hover .section-edit {
+ text-decoration: underline;
+ }
}
.section-max {
background: #f2f2f2;
- padding: 1em 0;
+ padding: 1em 0 1.3em;
margin-bottom: 0;
@include clearfix;
.section-title {
@@ -75,6 +87,12 @@
.section-edit {
text-align: right;
margin-bottom: 5px;
+ .fa {
+ margin-right: 7px;
+ font-size: 12px;
+ color: #aaa;
+ display: none;
+ }
}
.section-describe {
@@ -94,6 +112,10 @@
list-style-type:none;
}
+ .setting-list__hint {
+ margin-top: 20px;
+ }
+
.mentions-input {
margin-top: 10px;
}
diff --git a/web/sass-files/sass/partials/_sidebar--left.scss b/web/sass-files/sass/partials/_sidebar--left.scss
index 6b827eaee..d4ed41130 100644
--- a/web/sass-files/sass/partials/_sidebar--left.scss
+++ b/web/sass-files/sass/partials/_sidebar--left.scss
@@ -10,6 +10,10 @@
&.sidebar--padded {
padding-top: 44px;
}
+ .dropdown-menu {
+ max-height: 300px;
+ overflow: auto;
+ }
.search__form {
margin: 0;
padding: 1em 1em 0;
@@ -46,7 +50,7 @@
right: 0;
width: 72%;
color: #777;
- background: #DCF0FF;
+ background: #2389D7;
@include border-radius(50px);
margin: 0 auto;
padding: 3px 0 4px;
diff --git a/web/web.go b/web/web.go
index 44c9610a6..9cb81226b 100644
--- a/web/web.go
+++ b/web/web.go
@@ -560,6 +560,7 @@ func signupCompleteOAuth(c *api.Context, w http.ResponseWriter, r *http.Request)
}
user.TeamId = team.Id
+ user.EmailVerified = true
ruser := api.CreateUser(c, team, user)
if c.Err != nil {