Этот коммит содержится в:
=Corey Hulen
2015-09-10 15:15:04 -07:00
родитель 1108ac5306 927b474005
Коммит 83b04181da
52 изменённых файлов: 1465 добавлений и 239 удалений

246
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

Просмотреть файл

@@ -7,7 +7,7 @@ Mattermost server is made available under two separate licensing options:
- Free Software Foundations 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

Просмотреть файл

@@ -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).

292
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
}

Просмотреть файл

@@ -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()
}

Просмотреть файл

@@ -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)))
}
}

Просмотреть файл

@@ -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

Просмотреть файл

@@ -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)

Просмотреть файл

@@ -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`

Просмотреть файл

@@ -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.

17
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)

Просмотреть файл

@@ -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.

Просмотреть файл

@@ -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

Просмотреть файл

@@ -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.

Просмотреть файл

@@ -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 /

Просмотреть файл

@@ -117,3 +117,6 @@ func (o *Channel) PreUpdate() {
func (o *Channel) ExtraUpdated() {
o.ExtraUpdateAt = GetMillis()
}
func (o *Channel) PreExport() {
}

Просмотреть файл

@@ -147,3 +147,6 @@ func (o *Post) AddProp(key string, value string) {
o.Props[key] = value
}
func (o *Post) PreExport() {
}

Просмотреть файл

@@ -197,3 +197,6 @@ func CleanTeamName(s string) string {
return s
}
func (o *Team) PreExport() {
}

Просмотреть файл

@@ -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{

Просмотреть файл

@@ -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
}

Просмотреть файл

@@ -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
}

Просмотреть файл

@@ -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
}

Просмотреть файл

@@ -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
}

Просмотреть файл

@@ -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 {

Просмотреть файл

@@ -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');
}

Просмотреть файл

@@ -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()) {

Просмотреть файл

@@ -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}

Просмотреть файл

@@ -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>

Просмотреть файл

@@ -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');
}
});
this.scrollToBottom();
if (this.state.channel.id != null) {
this.loadFirstPosts(this.state.channel.id);
if (!this.state.isFirstLoadComplete) {
this.loadFirstPosts(this.props.channelId);
}
this.resize();
this.onChange();
this.scrollToBottom();
}
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
};

Просмотреть файл

@@ -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>
);
}
}

Просмотреть файл

@@ -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>

Просмотреть файл

@@ -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'>

Просмотреть файл

@@ -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;

94
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>
);
}
}

Просмотреть файл

@@ -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'/>

Просмотреть файл

@@ -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/>

Просмотреть файл

@@ -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 (

Просмотреть файл

@@ -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
};

Просмотреть файл

@@ -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
};
};

Просмотреть файл

@@ -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
};

Просмотреть файл

@@ -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')
);

Просмотреть файл

@@ -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;
}

Просмотреть файл

@@ -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',

Просмотреть файл

@@ -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,

Просмотреть файл

@@ -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;
}

Просмотреть файл

@@ -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;

Просмотреть файл

@@ -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;
}

Просмотреть файл

@@ -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;

Просмотреть файл

@@ -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;

Просмотреть файл

@@ -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;
}

Просмотреть файл

@@ -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;

Просмотреть файл

@@ -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 {