diff options
43 files changed, 768 insertions, 539 deletions
@@ -1,5 +1,5 @@ -**Mattermost Alpha** -**Team Communication Service** +**Mattermost Alpha** +**Team Communication Service** **Development Build** @@ -22,9 +22,9 @@ Learn More Installing Mattermost ===================== -You're installing "Mattermost Alpha", a pre-released version intended for an early look at what we're building. While SpinPunch runs this version internally, it's not recommended for production deployments since we can't guarantee API stability or backwards compatibility until our production release. +You're installing "Mattermost Alpha", a pre-released version intended for an early look at what we're building. While SpinPunch runs this version internally, it's not recommended for production deployments since we can't guarantee API stability or backwards compatibility until our production release. -That said, any issues at all, please let us know on the Mattermost forum at: http://forum.mattermost.org +That said, any issues at all, please let us know on the Mattermost forum at: http://forum.mattermost.org Local Machine Setup (Docker) ----------------------------- @@ -77,7 +77,7 @@ Local Machine Setup (Docker) ### Notes ### If your ISP blocks port 25 then you may install locally but email will not be sent. -If you want to work with the latest bits in the repo you can run the cmd +If you want to work with the latest bits in the repo you can run the cmd `docker run --name mattermost-dev -d --publish 8065:80 mattermost/platform:latest` You can update to the latest bits by running @@ -113,13 +113,23 @@ AWS Elastic Beanstalk Setup (Docker) 14. Wait for beanstalk to update the environment. 15. Try it out by entering the domain of the form \*.elasticbeanstalk.com found at the top of the dashboard into your browser. You can also map your own domain if you wish. -Contributing ------------- - -To contribute to this open source project please review the Mattermost Contribution Guidelines at http://www.mattermost.org/contribute-to-mattermost/. +Configuration Settings +---------------------- + +There are a few configuration settings you might want to adjust when setting up your instance of Mattermost. You can edit them in ./config/config.json or ./config/config/config_docker.json if you're running a docker instance. + +* *EmailSettings*:*ByPassEmail* - If this is set to true, then users on the system will not need to verify their email addresses when signing up. In addition, no emails will ever be sent. +* *ServiceSettings*:*UseLocalStorage* - If this is set to true, then your Mattermost server will store uploaded files in the storage directory specified by *StorageDirectory*. *StorageDirectory* must be set if *UseLocalStorage* is set to true. +* *ServiceSettings*:*StorageDirectory* - The file path where files will be stored locally if *UseLocalStorage* is set to true. The operating system user that is running the Mattermost application must have read and write privileges to this directory. +* *AWSSettings*:*S3*\* - If *UseLocalStorage* is set to false, and the S3 settings are configured here, then Mattermost will store files in the provided S3 bucket. + +Contributing +------------ + +To contribute to this open source project please review the Mattermost Contribution Guidelines at http://www.mattermost.org/contribute-to-mattermost/. License ------- -Mattermost is licensed under an "Apache-wrapped AGPL" model, which means you can run and link to the system using Configuration Files and Admin Tools licensed under Apache, version 2.0, as described in the LICENSE file, as an explicit exception to the terms of the GNU Affero General Public License (AGPL) that applies to most of the remaining source files. See individual files for details. +Mattermost is licensed under an "Apache-wrapped AGPL" model inspired by MongoDB. Similar to MongoDB, you can run and link to the system using Configuration Files and Admin Tools licensed under Apache, version 2.0, as described in the LICENSE file, as an explicit exception to the terms of the GNU Affero General Public License (AGPL) that applies to most of the remaining source files. See individual files for details. diff --git a/api/file.go b/api/file.go index 3ef50fbbd..4ec421eb9 100644 --- a/api/file.go +++ b/api/file.go @@ -33,7 +33,7 @@ func InitFile(r *mux.Router) { sr := r.PathPrefix("/files").Subrouter() sr.Handle("/upload", ApiUserRequired(uploadFile)).Methods("POST") - 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/{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", "HEAD") sr.Handle("/get_public_link", ApiUserRequired(getPublicLink)).Methods("POST") } @@ -140,11 +140,18 @@ func fireAndForgetHandleImages(filenames []string, fileData [][]byte, teamId, ch // Create thumbnail go func() { + thumbWidth := float64(utils.Cfg.ImageSettings.ThumbnailWidth) + thumbHeight := float64(utils.Cfg.ImageSettings.ThumbnailHeight) + imgWidth := float64(imgConfig.Width) + imgHeight := float64(imgConfig.Height) + var thumbnail image.Image - if imgConfig.Width > int(utils.Cfg.ImageSettings.ThumbnailWidth) { - thumbnail = resize.Resize(utils.Cfg.ImageSettings.ThumbnailWidth, utils.Cfg.ImageSettings.ThumbnailHeight, img, resize.Lanczos3) - } else { + if imgHeight < thumbHeight && imgWidth < thumbWidth { thumbnail = img + } else if imgHeight/imgWidth < thumbHeight/thumbWidth { + thumbnail = resize.Resize(0, utils.Cfg.ImageSettings.ThumbnailHeight, img, resize.Lanczos3) + } else { + thumbnail = resize.Resize(utils.Cfg.ImageSettings.ThumbnailWidth, 0, img, resize.Lanczos3) } buf := new(bytes.Buffer) @@ -261,7 +268,10 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "max-age=2592000, public") w.Header().Set("Content-Length", strconv.Itoa(len(f))) - w.Write(f) + + if r.Method != "HEAD" { + w.Write(f) + } } func asyncGetFile(path string, fileData chan []byte) { diff --git a/api/user.go b/api/user.go index 5d6e649cb..66527ca1a 100644 --- a/api/user.go +++ b/api/user.go @@ -195,7 +195,7 @@ func CreateUser(c *Context, team *model.Team, user *model.User) *model.User { l4g.Error("Failed to set email verified err=%v", cresult.Err) } } else { - FireAndForgetVerifyEmail(result.Data.(*model.User).Id, ruser.FirstName, ruser.Email, team.DisplayName, c.GetTeamURLFromTeam(team)) + FireAndForgetVerifyEmail(result.Data.(*model.User).Id, ruser.Email, team.Name, team.DisplayName, c.GetSiteURL(), c.GetTeamURLFromTeam(team)) } ruser.Sanitize(map[string]bool{}) @@ -225,19 +225,18 @@ func fireAndForgetWelcomeEmail(name, email, teamDisplayName, link string) { }() } -func FireAndForgetVerifyEmail(userId, name, email, teamDisplayName, teamURL string) { +func FireAndForgetVerifyEmail(userId, userEmail, teamName, teamDisplayName, siteURL, teamURL string) { go func() { - link := fmt.Sprintf("%s/verify_email?uid=%s&hid=%s", teamURL, userId, model.HashPassword(userId)) + link := fmt.Sprintf("%s/verify_email?uid=%s&hid=%s&teamname=%s&email=%s", siteURL, userId, model.HashPassword(userId), teamName, userEmail) subjectPage := NewServerTemplatePage("verify_subject", teamURL) subjectPage.Props["TeamDisplayName"] = teamDisplayName bodyPage := NewServerTemplatePage("verify_body", teamURL) - bodyPage.Props["Nickname"] = name bodyPage.Props["TeamDisplayName"] = teamDisplayName bodyPage.Props["VerifyUrl"] = link - if err := utils.SendMail(email, subjectPage.Render(), bodyPage.Render()); err != nil { + if err := utils.SendMail(userEmail, subjectPage.Render(), bodyPage.Render()); err != nil { l4g.Error("Failed to send verification email successfully err=%v", err) } }() @@ -864,7 +863,7 @@ func updatePassword(c *Context, w http.ResponseWriter, r *http.Request) { } if !model.ComparePassword(user.Password, currentPassword) { - c.Err = model.NewAppError("updatePassword", "Update password failed because of invalid password", "") + c.Err = model.NewAppError("updatePassword", "The \"Current Password\" you entered is incorrect. Please check that Caps Lock is off and try again.", "") c.Err.StatusCode = http.StatusForbidden return } diff --git a/config/config.json b/config/config.json index a1e73eb03..10a8b6553 100644 --- a/config/config.json +++ b/config/config.json @@ -49,8 +49,8 @@ "S3Region": "" }, "ImageSettings": { - "ThumbnailWidth": 200, - "ThumbnailHeight": 0, + "ThumbnailWidth": 120, + "ThumbnailHeight": 100, "PreviewWidth": 1024, "PreviewHeight": 0, "ProfileWidth": 128, diff --git a/config/config_docker.json b/config/config_docker.json index 6d220f919..c9fa5931a 100644 --- a/config/config_docker.json +++ b/config/config_docker.json @@ -49,8 +49,8 @@ "S3Region": "" }, "ImageSettings": { - "ThumbnailWidth": 200, - "ThumbnailHeight": 0, + "ThumbnailWidth": 120, + "ThumbnailHeight": 100, "PreviewWidth": 1024, "PreviewHeight": 0, "ProfileWidth": 128, diff --git a/store/sql_post_store.go b/store/sql_post_store.go index ede69d125..479caf838 100644 --- a/store/sql_post_store.go +++ b/store/sql_post_store.go @@ -401,7 +401,7 @@ func (s SqlPostStore) Search(teamId string, userId string, terms string, isHasht Id = ChannelId AND TeamId = $1 AND UserId = $2 AND DeleteAt = 0) - AND %s @@ plainto_tsquery($3) + AND %s @@ to_tsquery($3) ORDER BY CreateAt DESC LIMIT 100`, searchType) diff --git a/store/sql_post_store_test.go b/store/sql_post_store_test.go index d1639aa03..336a20d98 100644 --- a/store/sql_post_store_test.go +++ b/store/sql_post_store_test.go @@ -483,4 +483,9 @@ func TestPostStoreSearch(t *testing.T) { if len(r8.Order) != 0 { t.Fatal("returned wrong serach result") } + + r9 := (<-store.Post().Search(teamId, userId, "mattermost jersey", false)).Data.(*model.PostList) + if len(r9.Order) != 2 { + t.Fatal("returned wrong search result") + } } diff --git a/web/react/components/access_history_modal.jsx b/web/react/components/access_history_modal.jsx index 6cc8ec8a9..16768a119 100644 --- a/web/react/components/access_history_modal.jsx +++ b/web/react/components/access_history_modal.jsx @@ -15,13 +15,13 @@ function getStateFromStoresForAudits() { module.exports = React.createClass({ componentDidMount: function() { UserStore.addAuditsChangeListener(this._onChange); - $(this.refs.modal.getDOMNode()).on('shown.bs.modal', function (e) { + $(this.refs.modal.getDOMNode()).on('shown.bs.modal', function(e) { AsyncClient.getAudits(); }); var self = this; $(this.refs.modal.getDOMNode()).on('hidden.bs.modal', function(e) { - self.setState({ moreInfo: [] }); + self.setState({moreInfo: []}); }); }, componentWillUnmount: function() { @@ -36,7 +36,7 @@ module.exports = React.createClass({ handleMoreInfo: function(index) { var newMoreInfo = this.state.moreInfo; newMoreInfo[index] = true; - this.setState({ moreInfo: newMoreInfo }); + this.setState({moreInfo: newMoreInfo}); }, getInitialState: function() { var initialState = getStateFromStoresForAudits(); @@ -57,24 +57,28 @@ module.exports = React.createClass({ newDate = (<div> {currentHistoryDate.toDateString()} </div>); } + if (!currentAudit.session_id && currentAudit.action.search('/users/login') !== -1) { + currentAudit.session_id = 'N/A (Login attempt)'; + } + accessList[i] = ( - <div className="access-history__table"> - <div className="access__date">{newDate}</div> - <div className="access__report"> - <div className="report__time">{newHistoryDate.toLocaleTimeString(navigator.language, {hour: '2-digit', minute:'2-digit'})}</div> - <div className="report__info"> - <div>{"IP: " + currentAudit.ip_address}</div> - { this.state.moreInfo[i] ? + <div className='access-history__table'> + <div className='access__date'>{newDate}</div> + <div className='access__report'> + <div className='report__time'>{newHistoryDate.toLocaleTimeString(navigator.language, {hour: '2-digit', minute: '2-digit'})}</div> + <div className='report__info'> + <div>{'IP: ' + currentAudit.ip_address}</div> + {this.state.moreInfo[i] ? <div> - <div>{"Session ID: " + currentAudit.session_id}</div> - <div>{"URL: " + currentAudit.action.replace("/api/v1", "")}</div> + <div>{'Session ID: ' + currentAudit.session_id}</div> + <div>{'URL: ' + currentAudit.action.replace(/\/api\/v[1-9]/, '')}</div> </div> : - <a href="#" className="theme" onClick={this.handleMoreInfo.bind(this, i)}>More info</a> + <a href='#' className='theme' onClick={this.handleMoreInfo.bind(this, i)}>More info</a> } </div> {i < this.state.audits.length - 1 ? - <div className="divider-light"/> + <div className='divider-light'/> : null } @@ -85,17 +89,17 @@ module.exports = React.createClass({ return ( <div> - <div className="modal fade" ref="modal" id="access-history" tabIndex="-1" role="dialog" aria-hidden="true"> - <div className="modal-dialog modal-lg"> - <div className="modal-content"> - <div className="modal-header"> - <button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button> - <h4 className="modal-title" id="myModalLabel">Access History</h4> + <div className='modal fade' ref='modal' id='access-history' tabIndex='-1' role='dialog' aria-hidden='true'> + <div className='modal-dialog modal-lg'> + <div className='modal-content'> + <div className='modal-header'> + <button type='button' className='close' data-dismiss='modal' aria-label='Close'><span aria-hidden='true'>×</span></button> + <h4 className='modal-title' id='myModalLabel'>Access History</h4> </div> - <div ref="modalBody" className="modal-body"> - { !this.state.audits.loading ? - <form role="form"> - { accessList } + <div ref='modalBody' className='modal-body'> + {!this.state.audits.loading ? + <form role='form'> + {accessList} </form> : <LoadingScreen /> diff --git a/web/react/components/channel_loader.jsx b/web/react/components/channel_loader.jsx index b7cb248db..6b80f6012 100644 --- a/web/react/components/channel_loader.jsx +++ b/web/react/components/channel_loader.jsx @@ -8,6 +8,7 @@ var BrowserStore = require('../stores/browser_store.jsx'); var AsyncClient = require('../utils/async_client.jsx'); var SocketStore = require('../stores/socket_store.jsx'); +var ChannelStore = require('../stores/channel_store.jsx'); var Constants = require('../utils/constants.jsx'); module.exports = React.createClass({ @@ -15,7 +16,7 @@ module.exports = React.createClass({ /* Start initial aysnc loads */ AsyncClient.getMe(); - AsyncClient.getPosts(true); + AsyncClient.getPosts(true, ChannelStore.getCurrentId(), Constants.POST_CHUNK_SIZE); AsyncClient.getChannels(true, true); AsyncClient.getChannelExtraInfo(true); AsyncClient.findTeams(); diff --git a/web/react/components/delete_channel_modal.jsx b/web/react/components/delete_channel_modal.jsx index e23a37740..64ceec450 100644 --- a/web/react/components/delete_channel_modal.jsx +++ b/web/react/components/delete_channel_modal.jsx @@ -47,7 +47,7 @@ module.exports = React.createClass({ </p> </div> <div className="modal-footer"> - <button type="button" className="btn btn-default" data-dismiss="modal">Close</button> + <button type="button" className="btn btn-default" data-dismiss="modal">Cancel</button> <button type="button" className="btn btn-danger" data-dismiss="modal" onClick={this.handleDelete}>Delete</button> </div> </div> diff --git a/web/react/components/edit_channel_modal.jsx b/web/react/components/edit_channel_modal.jsx index a35a531b5..1b0cc185f 100644 --- a/web/react/components/edit_channel_modal.jsx +++ b/web/react/components/edit_channel_modal.jsx @@ -63,7 +63,7 @@ module.exports = React.createClass({ { server_error } </div> <div className="modal-footer"> - <button type="button" className="btn btn-default" data-dismiss="modal">Close</button> + <button type="button" className="btn btn-default" data-dismiss="modal">Cancel</button> <button type="button" className="btn btn-primary" onClick={this.handleEdit}>Save</button> </div> </div> diff --git a/web/react/components/email_verify.jsx b/web/react/components/email_verify.jsx index 168608274..678eb9928 100644 --- a/web/react/components/email_verify.jsx +++ b/web/react/components/email_verify.jsx @@ -11,7 +11,7 @@ module.exports = React.createClass({ var resend = ""; if (this.props.isVerified === "true") { title = config.SiteName + " Email Verified"; - body = <p>Your email has been verified! Click <a href="/">here</a> to log in.</p>; + body = <p>Your email has been verified! Click <a href={this.props.teamURL + "?email=" + this.props.userEmail}>here</a> to log in.</p>; } else { title = config.SiteName + " Email Not Verified"; body = <p>Please verify your email address. Check your inbox for an email.</p>; diff --git a/web/react/components/file_attachment.jsx b/web/react/components/file_attachment.jsx new file mode 100644 index 000000000..b7ea5734f --- /dev/null +++ b/web/react/components/file_attachment.jsx @@ -0,0 +1,132 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var utils = require('../utils/utils.jsx'); +var Constants = require('../utils/constants.jsx'); + +module.exports = React.createClass({ + displayName: "FileAttachment", + canSetState: false, + propTypes: { + // a list of file pathes displayed by the parent FileAttachmentList + filenames: React.PropTypes.arrayOf(React.PropTypes.string).isRequired, + // the index of this attachment preview in the parent FileAttachmentList + index: React.PropTypes.number.isRequired, + // the identifier of the modal dialog used to preview files + modalId: React.PropTypes.string.isRequired, + // handler for when the thumbnail is clicked + handleImageClick: React.PropTypes.func + }, + getInitialState: function() { + return {fileSize: -1}; + }, + componentDidMount: function() { + this.canSetState = true; + + var filename = this.props.filenames[this.props.index]; + + if (filename) { + var fileInfo = utils.splitFileLocation(filename); + var type = utils.getFileType(fileInfo.ext); + + // This is a temporary patch to fix issue with old files using absolute paths + if (fileInfo.path.indexOf("/api/v1/files/get") != -1) { + fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; + } + fileInfo.path = utils.getWindowLocationOrigin() + "/api/v1/files/get" + fileInfo.path; + + if (type === "image") { + var self = this; + $('<img/>').attr('src', fileInfo.path+'_thumb.jpg').load(function(path, name){ return function() { + $(this).remove(); + if (name in self.refs) { + var imgDiv = self.refs[name].getDOMNode(); + + $(imgDiv).removeClass('post__load'); + $(imgDiv).addClass('post__image'); + + var width = this.width || $(this).width(); + var height = this.height || $(this).height(); + + if (width < Constants.THUMBNAIL_WIDTH + && height < Constants.THUMBNAIL_HEIGHT) { + $(imgDiv).addClass('small'); + } else { + $(imgDiv).addClass('normal'); + } + + var re1 = new RegExp(' ', 'g'); + var re2 = new RegExp('\\(', 'g'); + var re3 = new RegExp('\\)', 'g'); + var url = path.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29'); + $(imgDiv).css('background-image', 'url('+url+'_thumb.jpg)'); + } + }}(fileInfo.path, filename)); + } + } + }, + componentWillUnmount: function() { + // keep track of when this component is mounted so that we can asynchronously change state without worrying about whether or not we're mounted + this.canSetState = false; + }, + shouldComponentUpdate: function(nextProps, nextState) { + // the only time this object should update is when it receives an updated file size which we can usually handle without re-rendering + if (nextState.fileSize != this.state.fileSize) { + if (this.refs.fileSize) { + // update the UI element to display the file size without re-rendering the whole component + this.refs.fileSize.getDOMNode().innerHTML = utils.fileSizeToString(nextState.fileSize); + + return false; + } else { + // we can't find the element that should hold the file size so we must not have rendered yet + return true; + } + } else { + return true; + } + }, + render: function() { + var filenames = this.props.filenames; + var filename = filenames[this.props.index]; + + var fileInfo = utils.splitFileLocation(filename); + var type = utils.getFileType(fileInfo.ext); + + var thumbnail; + if (type === "image") { + thumbnail = <div ref={filename} className="post__load" style={{backgroundImage: 'url(/static/images/load.gif)'}}/>; + } else { + thumbnail = <div className={"file-icon "+utils.getIconClassName(type)}/>; + } + + var fileSizeString = ""; + if (this.state.fileSize < 0) { + var self = this; + + // asynchronously request the size of the file so that we can display it next to the thumbnail + utils.getFileSize(utils.getFileUrl(filename), function(fileSize) { + if (self.canSetState) { + self.setState({fileSize: fileSize}); + } + }); + } else { + fileSizeString = utils.fileSizeToString(this.state.fileSize); + } + + return ( + <div className="post-image__column" key={filename}> + <a className="post-image__thumbnail" href="#" onClick={this.props.handleImageClick} + data-img-id={this.props.index} data-toggle="modal" data-target={"#" + this.props.modalId }> + {thumbnail} + </a> + <div className="post-image__details"> + <div className="post-image__name">{decodeURIComponent(utils.getFileName(filename))}</div> + <div> + <span className="post-image__type">{fileInfo.ext.toUpperCase()}</span> + <span className="post-image__size">{fileSizeString}</span> + </div> + </div> + </div> + ); + } +}); diff --git a/web/react/components/file_attachment_list.jsx b/web/react/components/file_attachment_list.jsx new file mode 100644 index 000000000..b92442957 --- /dev/null +++ b/web/react/components/file_attachment_list.jsx @@ -0,0 +1,49 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var ViewImageModal = require('./view_image.jsx'); +var FileAttachment = require('./file_attachment.jsx'); +var Constants = require('../utils/constants.jsx'); + +module.exports = React.createClass({ + displayName: "FileAttachmentList", + propTypes: { + // a list of file pathes displayed by this + filenames: React.PropTypes.arrayOf(React.PropTypes.string).isRequired, + // the identifier of the modal dialog used to preview files + modalId: React.PropTypes.string.isRequired, + // the channel that this is part of + channelId: React.PropTypes.string, + // the user that owns the post that this is attached to + userId: React.PropTypes.string + }, + getInitialState: function() { + return {startImgId: 0}; + }, + render: function() { + var filenames = this.props.filenames; + var modalId = this.props.modalId; + + var postFiles = []; + for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) { + postFiles.push(<FileAttachment key={i} filenames={filenames} index={i} modalId={modalId} handleImageClick={this.handleImageClick} />); + } + + return ( + <div> + <div className="post-image__columns"> + {postFiles} + </div> + <ViewImageModal + channelId={this.props.channelId} + userId={this.props.userId} + modalId={modalId} + startId={this.state.startImgId} + filenames={filenames} /> + </div> + ); + }, + handleImageClick: function(e) { + this.setState({startImgId: parseInt($(e.target.parentNode).attr('data-img-id'))}); + } +}); diff --git a/web/react/components/invite_member_modal.jsx b/web/react/components/invite_member_modal.jsx index 94be2acd6..fed96b50a 100644 --- a/web/react/components/invite_member_modal.jsx +++ b/web/react/components/invite_member_modal.jsx @@ -212,7 +212,7 @@ module.exports = React.createClass({ <span>People invited automatically join Town Square channel.</span> </div> <div className="modal-footer"> - <button type="button" className="btn btn-default" data-dismiss="modal">Close</button> + <button type="button" className="btn btn-default" data-dismiss="modal">Cancel</button> <button onClick={this.handleSubmit} type="button" className="btn btn-primary">Send Invitations</button> </div> </div> diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx index 641ffeef2..860c96d84 100644 --- a/web/react/components/post_body.jsx +++ b/web/react/components/post_body.jsx @@ -1,63 +1,23 @@ // Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. // See License.txt for license information. -var CreateComment = require( './create_comment.jsx' ); +var FileAttachmentList = require('./file_attachment_list.jsx'); var UserStore = require('../stores/user_store.jsx'); var utils = require('../utils/utils.jsx'); -var ViewImageModal = require('./view_image.jsx'); -var Constants = require('../utils/constants.jsx'); module.exports = React.createClass({ - handleImageClick: function(e) { - this.setState({startImgId: parseInt($(e.target.parentNode).attr('data-img-id'))}); - }, componentWillReceiveProps: function(nextProps) { var linkData = utils.extractLinks(nextProps.post.message); this.setState({ links: linkData["links"], message: linkData["text"] }); }, - componentDidMount: function() { - var filenames = this.props.post.filenames; - var self = this; - if (filenames) { - var re1 = new RegExp(' ', 'g'); - var re2 = new RegExp('\\(', 'g'); - var re3 = new RegExp('\\)', 'g'); - for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) { - var fileInfo = utils.splitFileLocation(filenames[i]); - if (Object.keys(fileInfo).length === 0) continue; - - var type = utils.getFileType(fileInfo.ext); - - // This is a temporary patch to fix issue with old files using absolute paths - if (fileInfo.path.indexOf("/api/v1/files/get") != -1) { - fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; - } - fileInfo.path = utils.getWindowLocationOrigin() + "/api/v1/files/get" + fileInfo.path; - - if (type === "image") { - $('<img/>').attr('src', fileInfo.path+'_thumb.jpg').load(function(path, name){ return function() { - $(this).remove(); - if (name in self.refs) { - var imgDiv = self.refs[name].getDOMNode(); - $(imgDiv).removeClass('post__load'); - $(imgDiv).addClass('post__image'); - var url = path.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29'); - $(imgDiv).css('background-image', 'url('+url+'_thumb.jpg)'); - } - }}(fileInfo.path, filenames[i])); - } - } - } - }, getInitialState: function() { var linkData = utils.extractLinks(this.props.post.message); - return { startImgId: 0, links: linkData["links"], message: linkData["text"] }; + return { links: linkData["links"], message: linkData["text"] }; }, render: function() { var post = this.props.post; var filenames = this.props.post.filenames; var parentPost = this.props.parentPost; - var postImageModalId = "view_image_modal_" + post.id; var inner = utils.textToJsx(this.state.message); var comment = ""; @@ -99,44 +59,8 @@ module.exports = React.createClass({ postClass += " post-comment"; } - var postFiles = []; - var images = []; - if (filenames) { - for (var i = 0; i < filenames.length; i++) { - var fileInfo = utils.splitFileLocation(filenames[i]); - if (Object.keys(fileInfo).length === 0) continue; - - var type = utils.getFileType(fileInfo.ext); - - // This is a temporary patch to fix issue with old files using absolute paths - if (fileInfo.path.indexOf("/api/v1/files/get") != -1) { - fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; - } - fileInfo.path = utils.getWindowLocationOrigin() + "/api/v1/files/get" + fileInfo.path; - - if (type === "image") { - if (i < Constants.MAX_DISPLAY_FILES) { - postFiles.push( - <div className="post-image__column" key={filenames[i]}> - <a href="#" onClick={this.handleImageClick} data-img-id={images.length.toString()} data-toggle="modal" data-target={"#" + postImageModalId }><div ref={filenames[i]} className="post__load" style={{backgroundImage: 'url(/static/images/load.gif)'}}></div></a> - </div> - ); - } - images.push(filenames[i]); - } else if (i < Constants.MAX_DISPLAY_FILES) { - postFiles.push( - <div className="post-image__column custom-file" key={fileInfo.name+i}> - <a href={fileInfo.path + (fileInfo.ext ? "." + fileInfo.ext : "")} download={fileInfo.name + (fileInfo.ext ? "." + fileInfo.ext : "")}> - <div className={"file-icon "+utils.getIconClassName(type)}/> - </a> - </div> - ); - } - } - } - var embed; - if (postFiles.length === 0 && this.state.links) { + if (filenames.length === 0 && this.state.links) { embed = utils.getEmbed(this.state.links[0]); } @@ -145,21 +69,13 @@ module.exports = React.createClass({ { comment } <p key={post.id+"_message"} className={postClass}><span>{inner}</span></p> { filenames && filenames.length > 0 ? - <div className="post-image__columns"> - { postFiles } - </div> - : "" } - { embed } - - { images.length > 0 ? - <ViewImageModal + <FileAttachmentList + filenames={filenames} + modalId={"view_image_modal_" + post.id} channelId={post.channel_id} - userId={post.user_id} - modalId={postImageModalId} - startId={this.state.startImgId} - imgCount={post.img_count} - filenames={images} /> + userId={post.user_id} /> : "" } + { embed } </div> ); } diff --git a/web/react/components/post_right.jsx b/web/react/components/post_right.jsx index 8097a181e..ad8b54012 100644 --- a/web/react/components/post_right.jsx +++ b/web/react/components/post_right.jsx @@ -10,7 +10,7 @@ var utils = require('../utils/utils.jsx'); var SearchBox =require('./search_bar.jsx'); var CreateComment = require( './create_comment.jsx' ); var Constants = require('../utils/constants.jsx'); -var ViewImageModal = require('./view_image.jsx'); +var FileAttachmentList = require('./file_attachment_list.jsx'); var ActionTypes = Constants.ActionTypes; RhsHeaderPost = React.createClass({ @@ -55,28 +55,20 @@ RhsHeaderPost = React.createClass({ }); RootPost = React.createClass({ - handleImageClick: function(e) { - this.setState({startImgId: parseInt($(e.target.parentNode).attr('data-img-id'))}); - }, - getInitialState: function() { - return { startImgId: 0 }; - }, render: function() { - - var postImageModalId = "rhs_view_image_modal_" + this.props.post.id; - var message = utils.textToJsx(this.props.post.message); - var filenames = this.props.post.filenames; - var isOwner = UserStore.getCurrentId() == this.props.post.user_id; - var timestamp = UserStore.getProfile(this.props.post.user_id).update_at; - var channel = ChannelStore.get(this.props.post.channel_id); + var post = this.props.post; + var message = utils.textToJsx(post.message); + var isOwner = UserStore.getCurrentId() == post.user_id; + var timestamp = UserStore.getProfile(post.user_id).update_at; + var channel = ChannelStore.get(post.channel_id); var type = "Post"; - if (this.props.post.root_id.length > 0) { + if (post.root_id.length > 0) { type = "Comment"; } var currentUserCss = ""; - if (UserStore.getCurrentId() === this.props.post.user_id) { + if (UserStore.getCurrentId() === post.user_id) { currentUserCss = "current--user"; } @@ -84,60 +76,24 @@ RootPost = React.createClass({ channelName = (channel.type === 'D') ? "Private Message" : channel.display_name; } - if (filenames) { - var postFiles = []; - var images = []; - var re1 = new RegExp(' ', 'g'); - var re2 = new RegExp('\\(', 'g'); - var re3 = new RegExp('\\)', 'g'); - for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) { - var fileInfo = utils.splitFileLocation(filenames[i]); - var ftype = utils.getFileType(fileInfo.ext); - - // This is a temporary patch to fix issue with old files using absolute paths - if (fileInfo.path.indexOf("/api/v1/files/get") != -1) { - fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; - } - fileInfo.path = utils.getWindowLocationOrigin() + "/api/v1/files/get" + fileInfo.path; - - if (ftype === "image") { - var url = fileInfo.path.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29'); - postFiles.push( - <div className="post-image__column" key={fileInfo.path}> - <a href="#" onClick={this.handleImageClick} data-img-id={images.length.toString()} data-toggle="modal" data-target={"#" + postImageModalId }><div ref={fileInfo.path} className="post__image" style={{backgroundImage: 'url(' + url + '_thumb.jpg)'}}></div></a> - </div> - ); - images.push(filenames[i]); - } else { - postFiles.push( - <div className="post-image__column custom-file" key={fileInfo.path}> - <a href={fileInfo.path+"."+fileInfo.ext} download={fileInfo.name+"."+fileInfo.ext}> - <div className={"file-icon "+utils.getIconClassName(ftype)}/> - </a> - </div> - ); - } - } - } - return ( <div className={"post post--root " + currentUserCss}> <div className="post-right-channel__name">{ channelName }</div> <div className="post-profile-img__container"> - <img className="post-profile-img" src={"/api/v1/users/" + this.props.post.user_id + "/image?time=" + timestamp} height="36" width="36" /> + <img className="post-profile-img" src={"/api/v1/users/" + post.user_id + "/image?time=" + timestamp} height="36" width="36" /> </div> <div className="post__content"> <ul className="post-header"> - <li className="post-header-col"><strong><UserProfile userId={this.props.post.user_id} /></strong></li> - <li className="post-header-col"><time className="post-right-root-time">{ utils.displayDate(this.props.post.create_at)+' '+utils.displayTime(this.props.post.create_at) }</time></li> + <li className="post-header-col"><strong><UserProfile userId={post.user_id} /></strong></li> + <li className="post-header-col"><time className="post-right-root-time">{ utils.displayDate(post.create_at)+' '+utils.displayTime(post.create_at) }</time></li> <li className="post-header-col post-header__reply"> <div className="dropdown"> { isOwner ? <div> <a href="#" className="dropdown-toggle theme" type="button" data-toggle="dropdown" aria-expanded="false" /> <ul className="dropdown-menu" role="menu"> - <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#edit_post" data-title={type} data-message={this.props.post.message} data-postid={this.props.post.id} data-channelid={this.props.post.channel_id}>Edit</a></li> - <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#delete_post" data-title={type} data-postid={this.props.post.id} data-channelid={this.props.post.channel_id} data-comments={this.props.commentCount}>Delete</a></li> + <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#edit_post" data-title={type} data-message={post.message} data-postid={post.id} data-channelid={post.channel_id}>Edit</a></li> + <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#delete_post" data-title={type} data-postid={post.id} data-channelid={post.channel_id} data-comments={this.props.commentCount}>Delete</a></li> </ul> </div> : "" } @@ -146,19 +102,12 @@ RootPost = React.createClass({ </ul> <div className="post-body"> <p>{message}</p> - { filenames.length > 0 ? - <div className="post-image__columns"> - { postFiles } - </div> - : "" } - { images.length > 0 ? - <ViewImageModal - channelId={this.props.post.channel_id} - userId={this.props.post.user_id} - modalId={postImageModalId} - startId={this.state.startImgId} - imgCount={this.props.post.img_count} - filenames={images} /> + { post.filenames && post.filenames.length > 0 ? + <FileAttachmentList + filenames={post.filenames} + modalId={"rhs_view_image_modal_" + post.id} + channelId={post.channel_id} + userId={post.user_id} /> : "" } </div> </div> @@ -169,86 +118,42 @@ RootPost = React.createClass({ }); CommentPost = React.createClass({ - handleImageClick: function(e) { - this.setState({startImgId: parseInt($(e.target.parentNode).attr('data-img-id'))}); - }, - getInitialState: function() { - return { startImgId: 0 }; - }, render: function() { + var post = this.props.post; var commentClass = "post"; var currentUserCss = ""; - if (UserStore.getCurrentId() === this.props.post.user_id) { + if (UserStore.getCurrentId() === post.user_id) { currentUserCss = "current--user"; } - var postImageModalId = "rhs_comment_view_image_modal_" + this.props.post.id; - var filenames = this.props.post.filenames; - var isOwner = UserStore.getCurrentId() == this.props.post.user_id; + var isOwner = UserStore.getCurrentId() == post.user_id; var type = "Post" - if (this.props.post.root_id.length > 0) { + if (post.root_id.length > 0) { type = "Comment" } - if (filenames) { - var postFiles = []; - var images = []; - var re1 = new RegExp(' ', 'g'); - var re2 = new RegExp('\\(', 'g'); - var re3 = new RegExp('\\)', 'g'); - for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) { - - var fileInfo = utils.splitFileLocation(filenames[i]); - var type = utils.getFileType(fileInfo.ext); - - // This is a temporary patch to fix issue with old files using absolute paths - if (fileInfo.path.indexOf("/api/v1/files/get") != -1) { - fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; - } - fileInfo.path = utils.getWindowLocationOrigin() + "/api/v1/files/get" + fileInfo.path; - - if (type === "image") { - var url = fileInfo.path.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29'); - postFiles.push( - <div className="post-image__column" key={fileInfo.path}> - <a href="#" onClick={this.handleImageClick} data-img-id={images.length.toString()} data-toggle="modal" data-target={"#" + postImageModalId }><div ref={fileInfo.path} className="post__image" style={{backgroundImage: 'url(' + url + '_thumb.jpg)'}}></div></a> - </div> - ); - images.push(filenames[i]); - } else { - postFiles.push( - <div className="post-image__column custom-file" key={fileInfo.path}> - <a href={fileInfo.path+"."+fileInfo.ext} download={fileInfo.name+"."+fileInfo.ext}> - <div className={"file-icon "+utils.getIconClassName(type)}/> - </a> - </div> - ); - } - } - } - - var message = utils.textToJsx(this.props.post.message); + var message = utils.textToJsx(post.message); var timestamp = UserStore.getCurrentUser().update_at; return ( <div className={commentClass + " " + currentUserCss}> <div className="post-profile-img__container"> - <img className="post-profile-img" src={"/api/v1/users/" + this.props.post.user_id + "/image?time=" + timestamp} height="36" width="36" /> + <img className="post-profile-img" src={"/api/v1/users/" + post.user_id + "/image?time=" + timestamp} height="36" width="36" /> </div> <div className="post__content"> <ul className="post-header"> - <li className="post-header-col"><strong><UserProfile userId={this.props.post.user_id} /></strong></li> - <li className="post-header-col"><time className="post-right-comment-time">{ utils.displayDateTime(this.props.post.create_at) }</time></li> + <li className="post-header-col"><strong><UserProfile userId={post.user_id} /></strong></li> + <li className="post-header-col"><time className="post-right-comment-time">{ utils.displayDateTime(post.create_at) }</time></li> <li className="post-header-col post-header__reply"> { isOwner ? <div className="dropdown" onClick={function(e){$('.post-list-holder-by-time').scrollTop($(".post-list-holder-by-time").scrollTop() + 50);}}> <a href="#" className="dropdown-toggle theme" type="button" data-toggle="dropdown" aria-expanded="false" /> <ul className="dropdown-menu" role="menu"> - <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#edit_post" data-title={type} data-message={this.props.post.message} data-postid={this.props.post.id} data-channelid={this.props.post.channel_id}>Edit</a></li> - <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#delete_post" data-title={type} data-postid={this.props.post.id} data-channelid={this.props.post.channel_id} data-comments={0}>Delete</a></li> + <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#edit_post" data-title={type} data-message={post.message} data-postid={post.id} data-channelid={post.channel_id}>Edit</a></li> + <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#delete_post" data-title={type} data-postid={post.id} data-channelid={post.channel_id} data-comments={0}>Delete</a></li> </ul> </div> : "" } @@ -256,19 +161,12 @@ CommentPost = React.createClass({ </ul> <div className="post-body"> <p>{message}</p> - { filenames.length > 0 ? - <div className="post-image__columns"> - { postFiles } - </div> - : "" } - { images.length > 0 ? - <ViewImageModal - channelId={this.props.post.channel_id} - userId={this.props.post.user_id} - modalId={postImageModalId} - startId={this.state.startImgId} - imgCount={this.props.post.img_count} - filenames={images} /> + { post.filenames && post.filenames.length > 0 ? + <FileAttachmentList + filenames={post.filenames} + modalId={"rhs_comment_view_image_modal_" + post.id} + channelId={post.channel_id} + userId={post.user_id} /> : "" } </div> </div> diff --git a/web/react/components/rename_channel_modal.jsx b/web/react/components/rename_channel_modal.jsx index d67ab3afe..26593b7fa 100644 --- a/web/react/components/rename_channel_modal.jsx +++ b/web/react/components/rename_channel_modal.jsx @@ -139,7 +139,7 @@ module.exports = React.createClass({ </form> </div> <div className="modal-footer"> - <button type="button" className="btn btn-default" data-dismiss="modal">Close</button> + <button type="button" className="btn btn-default" data-dismiss="modal">Cancel</button> <button onClick={this.handleSubmit} type="button" className="btn btn-primary">Save</button> </div> </div> diff --git a/web/react/components/rename_team_modal.jsx b/web/react/components/rename_team_modal.jsx index dfd775a3b..bebdd6662 100644 --- a/web/react/components/rename_team_modal.jsx +++ b/web/react/components/rename_team_modal.jsx @@ -83,7 +83,7 @@ module.exports = React.createClass({ </form> </div> <div className="modal-footer"> - <button type="button" className="btn btn-default" data-dismiss="modal">Close</button> + <button type="button" className="btn btn-default" data-dismiss="modal">Cancel</button> <button onClick={this.handleSubmit} type="button" className="btn btn-primary">Save</button> </div> </div> diff --git a/web/react/components/sidebar_header.jsx b/web/react/components/sidebar_header.jsx index 0156dc01a..e01ddcd05 100644 --- a/web/react/components/sidebar_header.jsx +++ b/web/react/components/sidebar_header.jsx @@ -17,11 +17,20 @@ var NavbarDropdown = React.createClass({ e.preventDefault(); client.logout(); }, + blockToggle: false, componentDidMount: function() { UserStore.addTeamsChangeListener(this._onChange); + + var self = this; + $(this.refs.dropdown.getDOMNode()).on('hide.bs.dropdown', function(e) { + self.blockToggle = true; + setTimeout(function(){self.blockToggle = false;}, 100); + }); }, componentWillUnmount: function() { UserStore.removeTeamsChangeListener(this._onChange); + + $(this.refs.dropdown.getDOMNode()).off('hide.bs.dropdown'); }, _onChange: function() { if (this.isMounted()) { @@ -75,7 +84,7 @@ var NavbarDropdown = React.createClass({ return ( <ul className="nav navbar-nav navbar-right"> - <li className="dropdown"> + <li ref="dropdown" className="dropdown"> <a href="#" className="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false"> <span className="dropdown__icon" dangerouslySetInnerHTML={{__html: Constants.MENU_ICON }} /> </a> @@ -108,7 +117,11 @@ module.exports = React.createClass({ }, toggleDropdown: function(e) { - $('.team__header').find('.dropdown-toggle').trigger('click'); + if (this.refs.dropdown.blockToggle) { + this.refs.dropdown.blockToggle = false; + return; + } + $('.team__header').find('.dropdown-toggle').dropdown('toggle'); }, render: function() { @@ -131,7 +144,7 @@ module.exports = React.createClass({ <div className="team__name">{ this.props.teamDisplayName }</div> </div> </a> - <NavbarDropdown teamType={this.props.teamType} /> + <NavbarDropdown ref="dropdown" teamType={this.props.teamType} /> </div> ); } diff --git a/web/react/components/signup_team_complete.jsx b/web/react/components/signup_team_complete.jsx index 83daa3b1f..447a405bd 100644 --- a/web/react/components/signup_team_complete.jsx +++ b/web/react/components/signup_team_complete.jsx @@ -496,60 +496,58 @@ SendInivtesPage = React.createClass({ }); UsernamePage = React.createClass({ - submitBack: function (e) { + submitBack: function(e) { e.preventDefault(); - this.props.state.wizard = "send_invites"; + this.props.state.wizard = 'send_invites'; this.props.updateParent(this.props.state); }, - submitNext: function (e) { + submitNext: function(e) { e.preventDefault(); var name = this.refs.name.getDOMNode().value.trim(); var username_error = utils.isValidUsername(name); - if (username_error === "Cannot use a reserved word as a username.") { - this.setState({name_error: "This username is reserved, please choose a new one." }); + if (username_error === 'Cannot use a reserved word as a username.') { + this.setState({name_error: 'This username is reserved, please choose a new one.'}); return; } else if (username_error) { - this.setState({name_error: "Username must begin with a letter, and contain between 3 to 15 lowercase characters made up of numbers, letters, and the symbols '.', '-' and '_'." }); + this.setState({name_error: "Username must begin with a letter, and contain 3 to 15 characters in total, which may be numbers, lowercase letters, or any of the symbols '.', '-', or '_'"}); return; } - - this.props.state.wizard = "password"; + this.props.state.wizard = 'password'; this.props.state.user.username = name; this.props.updateParent(this.props.state); }, getInitialState: function() { - return { }; + return {}; }, render: function() { - client.track('signup', 'signup_team_06_username'); - var name_error = this.state.name_error ? <label className="control-label">{ this.state.name_error }</label> : null; + var name_error = this.state.name_error ? <label className='control-label'>{this.state.name_error}</label> : null; return ( <div> <form> - <img className="signup-team-logo" src="/static/images/logo.png" /> - <h2 className="margin--less">Your username</h2> - <h5 className="color--light">{"Select a memorable username that makes it easy for " + strings.Team + "mates to identify you:"}</h5> - <div className="inner__content margin--extra"> - <div className={ name_error ? "form-group has-error" : "form-group" }> - <div className="row"> - <div className="col-sm-11"> + <img className='signup-team-logo' src='/static/images/logo.png' /> + <h2 className='margin--less'>Your username</h2> + <h5 className='color--light'>{'Select a memorable username that makes it easy for ' + strings.Team + 'mates to identify you:'}</h5> + <div className='inner__content margin--extra'> + <div className={name_error ? 'form-group has-error' : 'form-group'}> + <div className='row'> + <div className='col-sm-11'> <h5><strong>Choose your username</strong></h5> - <input autoFocus={true} type="text" ref="name" className="form-control" placeholder="" defaultValue={this.props.state.user.username} maxLength="128" /> - <div className="color--light form__hint">Usernames must begin with a letter and contain 3 to 15 characters made up of lowercase letters, numbers, and the symbols '.', '-' and '_'</div> + <input autoFocus={true} type='text' ref='name' className='form-control' placeholder='' defaultValue={this.props.state.user.username} maxLength='128' /> + <div className='color--light form__hint'>Usernames must begin with a letter and contain 3 to 15 characters made up of lowercase letters, numbers, and the symbols '.', '-' and '_'</div> </div> </div> - { name_error } + {name_error} </div> </div> - <button type="submit" className="btn btn-primary margin--extra" onClick={this.submitNext}>Next<i className="glyphicon glyphicon-chevron-right"></i></button> - <div className="margin--extra"> - <a href="#" onClick={this.submitBack}>Back to previous step</a> + <button type='submit' className='btn btn-primary margin--extra' onClick={this.submitNext}>Next<i className='glyphicon glyphicon-chevron-right'></i></button> + <div className='margin--extra'> + <a href='#' onClick={this.submitBack}>Back to previous step</a> </div> </form> </div> diff --git a/web/react/components/signup_user_complete.jsx b/web/react/components/signup_user_complete.jsx index 670aab943..03808e821 100644 --- a/web/react/components/signup_user_complete.jsx +++ b/web/react/components/signup_user_complete.jsx @@ -58,7 +58,7 @@ module.exports = React.createClass({ }.bind(this), function(err) { if (err.message == "Login failed because email address has not been verified") { - window.location.href = "/verify_email?email="+ encodeURIComponent(this.state.user.email) + "&domain=" + encodeURIComponent(this.props.teamName); + window.location.href = "/verify_email?email="+ encodeURIComponent(this.state.user.email) + "&teamname=" + encodeURIComponent(this.props.teamName); } else { this.state.server_error = err.message; this.setState(this.state); @@ -107,7 +107,7 @@ module.exports = React.createClass({ <div className={ this.state.original_email == "" ? "margin--extra" : "hidden"} > <h5><strong>What's your email address?</strong></h5> <div className={ email_error ? "form-group has-error" : "form-group" }> - <input type="email" ref="email" className="form-control" defaultValue={ this.state.user.email } placeholder="" maxLength="128" /> + <input type="email" ref="email" className="form-control" defaultValue={ this.state.user.email } placeholder="" maxLength="128" autoFocus={true} /> { email_error } </div> </div> @@ -123,6 +123,7 @@ module.exports = React.createClass({ return ( <div> + <form> <img className="signup-team-logo" src="/static/images/logo.png" /> <h5 className="margin--less">Welcome to:</h5> <h2 className="signup-team__name">{ this.props.teamDisplayName }</h2> @@ -148,9 +149,10 @@ module.exports = React.createClass({ </div> </div> </div> - <p className="margin--extra"><button onClick={this.handleSubmit} className="btn-primary btn">Create Account</button></p> + <p className="margin--extra"><button type='submit' onClick={this.handleSubmit} className="btn-primary btn">Create Account</button></p> { server_error } <p>By creating an account and using Mattermost you are agreeing to our <a href={ config.TermsLink }>Terms of Service</a>. If you do not agree, you cannot use this service.</p> + </form> </div> ); } diff --git a/web/react/components/user_settings.jsx b/web/react/components/user_settings.jsx index 902989b7b..95d1178d1 100644 --- a/web/react/components/user_settings.jsx +++ b/web/react/components/user_settings.jsx @@ -638,8 +638,8 @@ var GeneralTab = React.createClass({ var username = this.state.username.trim(); var username_error = utils.isValidUsername(username); - if (username_error === "Cannot use a reserved word as a username.") { - this.setState({client_error: "This username is reserved, please choose a new one." }); + if (username_error === 'Cannot use a reserved word as a username.') { + this.setState({client_error: 'This username is reserved, please choose a new one.' }); return; } else if (username_error) { this.setState({client_error: "Username must begin with a letter, and contain between 3 to 15 lowercase characters made up of numbers, letters, and the symbols '.', '-' and '_'." }); @@ -647,7 +647,7 @@ var GeneralTab = React.createClass({ } if (user.username === username) { - this.setState({client_error: "You must submit a new username"}); + this.setState({client_error: 'You must submit a new username'}); return; } @@ -662,7 +662,7 @@ var GeneralTab = React.createClass({ var nickname = this.state.nickname.trim(); if (user.nickname === nickname) { - this.setState({client_error: "You must submit a new nickname"}) + this.setState({client_error: 'You must submit a new nickname'}) return; } @@ -678,7 +678,7 @@ var GeneralTab = React.createClass({ var lastName = this.state.last_name.trim(); if (user.first_name === firstName && user.last_name === lastName) { - this.setState({client_error: "You must submit a new first or last name"}) + this.setState({client_error: 'You must submit a new first or last name'}) return; } @@ -698,7 +698,7 @@ var GeneralTab = React.createClass({ } if (email === '' || !utils.isEmail(email)) { - this.setState({ email_error: "Please enter a valid email address" }); + this.setState({ email_error: 'Please enter a valid email address' }); return; } @@ -708,16 +708,16 @@ var GeneralTab = React.createClass({ }, submitUser: function(user) { client.updateUser(user, - function(data) { - this.updateSection(""); + function() { + this.updateSection(''); AsyncClient.getMe(); }.bind(this), function(err) { state = this.getInitialState(); - if(err.message) { + if (err.message) { state.server_error = err.message; } else { - state.server_error = err + state.server_error = err; } this.setState(state); }.bind(this) @@ -726,22 +726,26 @@ var GeneralTab = React.createClass({ submitPicture: function(e) { e.preventDefault(); - if (!this.state.picture) return; + if (!this.state.picture) { + return; + } - if(!this.submitActive) return; + if (!this.submitActive) { + return; + } var picture = this.state.picture; - if(picture.type !== "image/jpeg" && picture.type !== "image/png") { - this.setState({client_error: "Only JPG or PNG images may be used for profile pictures"}); + if (picture.type !== 'image/jpeg' && picture.type !== 'image/png') { + this.setState({client_error: 'Only JPG or PNG images may be used for profile pictures'}); return; } - formData = new FormData(); + var formData = new FormData(); formData.append('image', picture, picture.name); client.uploadProfileImage(formData, - function(data) { + function() { this.submitActive = false; AsyncClient.getMe(); window.location.reload(); @@ -754,39 +758,39 @@ var GeneralTab = React.createClass({ ); }, updateUsername: function(e) { - this.setState({ username: e.target.value }); + this.setState({username: e.target.value}); }, updateFirstName: function(e) { - this.setState({ first_name: e.target.value }); + this.setState({first_name: e.target.value}); }, updateLastName: function(e) { - this.setState({ last_name: e.target.value}); + this.setState({last_name: e.target.value}); }, updateNickname: function(e) { this.setState({nickname: e.target.value}); }, updateEmail: function(e) { - this.setState({ email: e.target.value}); + this.setState({email: e.target.value}); }, updatePicture: function(e) { if (e.target.files && e.target.files[0]) { this.setState({ picture: e.target.files[0] }); this.submitActive = true; - this.setState({client_error:null}) + this.setState({client_error: null}); } else { - this.setState({ picture: null }); + this.setState({picture: null}); } }, updateSection: function(section) { - this.setState({client_error:""}) - this.submitActive = false + this.setState({client_error:''}); + this.submitActive = false; this.props.updateSection(section); }, handleClose: function() { - $(this.getDOMNode()).find(".form-control").each(function() { - this.value = ""; + $(this.getDOMNode()).find('.form-control').each(function() { + this.value = ''; }); this.setState(assign({}, this.getInitialState(), {client_error: null, server_error: null, email_error: null})); @@ -812,43 +816,45 @@ var GeneralTab = React.createClass({ var nameSection; var self = this; + var inputs = []; if (this.props.activeSection === 'name') { - var inputs = []; - inputs.push( - <div className="form-group"> - <label className="col-sm-5 control-label">First Name</label> - <div className="col-sm-7"> - <input className="form-control" type="text" onChange={this.updateFirstName} value={this.state.first_name}/> + <div className='form-group'> + <label className='col-sm-5 control-label'>First Name</label> + <div className='col-sm-7'> + <input className='form-control' type='text' onChange={this.updateFirstName} value={this.state.first_name}/> </div> </div> ); inputs.push( - <div className="form-group"> - <label className="col-sm-5 control-label">Last Name</label> - <div className="col-sm-7"> - <input className="form-control" type="text" onChange={this.updateLastName} value={this.state.last_name}/> + <div className='form-group'> + <label className='col-sm-5 control-label'>Last Name</label> + <div className='col-sm-7'> + <input className='form-control' type='text' onChange={this.updateLastName} value={this.state.last_name}/> </div> </div> ); nameSection = ( <SettingItemMax - title="Full Name" + title='Full Name' inputs={inputs} submit={this.submitName} server_error={server_error} client_error={client_error} - updateSection={function(e){self.updateSection("");e.preventDefault();}} + updateSection={function(e) { + self.updateSection(''); + e.preventDefault(); + }} /> ); } else { - var full_name = ""; + var full_name = ''; if (user.first_name && user.last_name) { - full_name = user.first_name + " " + user.last_name; + full_name = user.first_name + ' ' + user.last_name; } else if (user.first_name) { full_name = user.first_name; } else if (user.last_name) { @@ -857,107 +863,119 @@ var GeneralTab = React.createClass({ nameSection = ( <SettingItemMin - title="Full Name" + title='Full Name' describe={full_name} - updateSection={function(){self.updateSection("name");}} + updateSection={function() { + self.updateSection('name'); + }} /> ); } var nicknameSection; if (this.props.activeSection === 'nickname') { - var inputs = []; inputs.push( - <div className="form-group"> - <label className="col-sm-5 control-label">{utils.isMobile() ? "": "Nickname"}</label> - <div className="col-sm-7"> - <input className="form-control" type="text" onChange={this.updateNickname} value={this.state.nickname}/> + <div className='form-group'> + <label className='col-sm-5 control-label'>{utils.isMobile() ? '' : 'Nickname'}</label> + <div className='col-sm-7'> + <input className='form-control' type='text' onChange={this.updateNickname} value={this.state.nickname}/> </div> </div> ); nicknameSection = ( <SettingItemMax - title="Nickname" + title='Nickname' inputs={inputs} submit={this.submitNickname} server_error={server_error} client_error={client_error} - updateSection={function(e){self.updateSection("");e.preventDefault();}} + updateSection={function(e) { + self.updateSection(''); + e.preventDefault(); + }} /> ); } else { nicknameSection = ( <SettingItemMin - title="Nickname" + title='Nickname' describe={UserStore.getCurrentUser().nickname} - updateSection={function(){self.updateSection("nickname");}} + updateSection={function() { + self.updateSection('nickname'); + }} /> ); } var usernameSection; if (this.props.activeSection === 'username') { - var inputs = []; - inputs.push( - <div className="form-group"> - <label className="col-sm-5 control-label">{utils.isMobile() ? "": "Username"}</label> - <div className="col-sm-7"> - <input className="form-control" type="text" onChange={this.updateUsername} value={this.state.username}/> + <div className='form-group'> + <label className='col-sm-5 control-label'>{utils.isMobile() ? '': 'Username'}</label> + <div className='col-sm-7'> + <input className='form-control' type='text' onChange={this.updateUsername} value={this.state.username}/> </div> </div> ); usernameSection = ( <SettingItemMax - title="Username" + title='Username' inputs={inputs} submit={this.submitUsername} server_error={server_error} client_error={client_error} - updateSection={function(e){self.updateSection("");e.preventDefault();}} + updateSection={function(e) { + self.updateSection(''); + e.preventDefault(); + }} /> ); } else { usernameSection = ( <SettingItemMin - title="Username" + title='Username' describe={UserStore.getCurrentUser().username} - updateSection={function(){self.updateSection("username");}} + updateSection={function() { + self.updateSection('username'); + }} /> ); } var emailSection; if (this.props.activeSection === 'email') { - var inputs = []; - inputs.push( - <div className="form-group"> - <label className="col-sm-5 control-label">Primary Email</label> - <div className="col-sm-7"> - <input className="form-control" type="text" onChange={this.updateEmail} value={this.state.email}/> + <div className='form-group'> + <label className='col-sm-5 control-label'>Primary Email</label> + <div className='col-sm-7'> + <input className='form-control' type='text' onChange={this.updateEmail} value={this.state.email}/> </div> </div> ); emailSection = ( <SettingItemMax - title="Email" + title='Email' inputs={inputs} submit={this.submitEmail} server_error={server_error} client_error={email_error} - updateSection={function(e){self.updateSection("");e.preventDefault();}} + updateSection={function(e) { + self.updateSection(''); + e.preventDefault(); + }} /> ); } else { emailSection = ( <SettingItemMin - title="Email" + title='Email' describe={UserStore.getCurrentUser().email} - updateSection={function(){self.updateSection("email");}} + updateSection={function() { + self.updateSection('email'); + }} /> ); } @@ -966,57 +984,60 @@ var GeneralTab = React.createClass({ if (this.props.activeSection === 'picture') { pictureSection = ( <SettingPicture - title="Profile Picture" + title='Profile Picture' submit={this.submitPicture} - src={"/api/v1/users/" + user.id + "/image?time=" + user.last_picture_update} + src={'/api/v1/users/' + user.id + '/image?time=' + user.last_picture_update} server_error={server_error} client_error={client_error} - updateSection={function(e){self.updateSection("");e.preventDefault();}} + updateSection={function(e) { + self.updateSection(''); + e.preventDefault(); + }} picture={this.state.picture} pictureChange={this.updatePicture} submitActive={this.submitActive} /> ); - } else { - var minMessage = "Click Edit to upload an image."; + var minMessage = 'Click \'Edit\' to upload an image.'; if (user.last_picture_update) { - minMessage = "Image last updated " + utils.displayDate(user.last_picture_update) + minMessage = 'Image last updated ' + utils.displayDate(user.last_picture_update); } pictureSection = ( <SettingItemMin - title="Profile Picture" + title='Profile Picture' describe={minMessage} - updateSection={function(){self.updateSection("picture");}} + updateSection={function() { + self.updateSection('picture'); + }} /> ); } return ( <div> - <div className="modal-header"> - <button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button> - <h4 className="modal-title" ref="title"><i className="modal-back"></i>General Settings</h4> + <div className='modal-header'> + <button type='button' className='close' data-dismiss='modal' aria-label='Close'><span aria-hidden='true'>×</span></button> + <h4 className='modal-title' ref='title'><i className='modal-back'></i>General Settings</h4> </div> - <div className="user-settings"> - <h3 className="tab-header">General Settings</h3> - <div className="divider-dark first"/> + <div className='user-settings'> + <h3 className='tab-header'>General Settings</h3> + <div className='divider-dark first'/> {nameSection} - <div className="divider-light"/> + <div className='divider-light'/> {usernameSection} - <div className="divider-light"/> + <div className='divider-light'/> {nicknameSection} - <div className="divider-light"/> + <div className='divider-light'/> {emailSection} - <div className="divider-light"/> + <div className='divider-light'/> {pictureSection} - <div className="divider-dark"/> + <div className='divider-dark'/> </div> </div> ); } }); - var AppearanceTab = React.createClass({ submitTheme: function(e) { e.preventDefault(); diff --git a/web/react/components/view_image.jsx b/web/react/components/view_image.jsx index 7b096c629..4b2f8f650 100644 --- a/web/react/components/view_image.jsx +++ b/web/react/components/view_image.jsx @@ -5,6 +5,8 @@ var Client = require('../utils/client.jsx'); var utils = require('../utils/utils.jsx'); module.exports = React.createClass({ + displayName: "ViewImageModal", + canSetState: false, handleNext: function() { var id = this.state.imgId + 1; if (id > this.props.filenames.length-1) { @@ -31,42 +33,41 @@ module.exports = React.createClass({ return; }; - var src = ""; - if (this.props.imgCount > 0) { - src = this.props.filenames[id]; + var filename = this.props.filenames[id]; + + var fileInfo = utils.splitFileLocation(filename); + var fileType = utils.getFileType(fileInfo.ext); + + if (fileType === "image") { + var self = this; + var img = new Image(); + img.load(this.getPreviewImagePath(filename), + function(){ + var progress = self.state.progress; + progress[id] = img.completedPercentage; + self.setState({ progress: progress }); + }); + img.onload = function(imgid) { + return function() { + var loaded = self.state.loaded; + loaded[imgid] = true; + self.setState({ loaded: loaded }); + $(self.refs.image.getDOMNode()).css("max-height",imgHeight); + }; + }(id); + var images = this.state.images; + images[id] = img; + this.setState({ images: images }); } else { - var fileInfo = utils.splitFileLocation(this.props.filenames[id]); - // This is a temporary patch to fix issue with old files using absolute paths - if (fileInfo.path.indexOf("/api/v1/files/get") !== -1) { - fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; - } - fileInfo.path = utils.getWindowLocationOrigin() + "/api/v1/files/get" + fileInfo.path; - src = fileInfo['path'] + '_preview.jpg'; + // there's nothing to load for non-image files + var loaded = this.state.loaded; + loaded[id] = true; + this.setState({ loaded: loaded }); } - - var self = this; - var img = new Image(); - img.load(src, - function(){ - var progress = self.state.progress; - progress[id] = img.completedPercentage; - self.setState({ progress: progress }); - }); - img.onload = function(imgid) { - return function() { - var loaded = self.state.loaded; - loaded[imgid] = true; - self.setState({ loaded: loaded }); - $(self.refs.image.getDOMNode()).css("max-height",imgHeight); - }; - }(id); - var images = this.state.images; - images[id] = img; - this.setState({ images: images }); }, componentDidUpdate: function() { - if (this.refs.image) { - if (this.state.loaded[this.state.imgId]) { + if (this.state.loaded[this.state.imgId]) { + if (this.refs.imageWrap) { $(this.refs.imageWrap.getDOMNode()).removeClass("default"); } } @@ -91,6 +92,12 @@ module.exports = React.createClass({ $(self.refs.imageFooter.getDOMNode()).removeClass("footer--show"); } ); + + // keep track of whether or not this component is mounted so we can safely set the state asynchronously + this.canSetState = true; + }, + componentWillUnmount: function() { + this.canSetState = false; }, getPublicLink: function(e) { data = {}; @@ -112,62 +119,78 @@ module.exports = React.createClass({ loaded.push(false); progress.push(0); } - return { imgId: this.props.startId, viewed: false, loaded: loaded, progress: progress, images: {} }; + return { imgId: this.props.startId, viewed: false, loaded: loaded, progress: progress, images: {}, fileSizes: {} }; }, render: function() { if (this.props.filenames.length < 1 || this.props.filenames.length-1 < this.state.imgId) { return <div/>; } - var fileInfo = utils.splitFileLocation(this.props.filenames[this.state.imgId]); + var filename = this.props.filenames[this.state.imgId]; + var fileUrl = utils.getFileUrl(filename); - var name = fileInfo['name'] + '.' + fileInfo['ext']; + var name = decodeURIComponent(utils.getFileName(filename)); - var loading = ""; + var content; var bgClass = ""; - var img = {}; - if (!this.state.loaded[this.state.imgId]) { + if (this.state.loaded[this.state.imgId]) { + var fileInfo = utils.splitFileLocation(filename); + var fileType = utils.getFileType(fileInfo.ext); + + if (fileType === "image") { + // image files just show a preview of the file + content = ( + <a href={fileUrl} target="_blank"> + <img ref="image" src={this.getPreviewImagePath(filename)}/> + </a> + ); + } else { + // non-image files include a section providing details about the file + var infoString = "File type " + fileInfo.ext.toUpperCase(); + if (this.state.fileSizes[filename] && this.state.fileSizes[filename] >= 0) { + infoString += ", Size " + utils.fileSizeToString(this.state.fileSizes[filename]); + } + + content = ( + <div className="file-details__container"> + <a className={"file-details__preview"} href={fileUrl} target="_blank"> + <span className="file-details__preview-helper" /> + <img ref="image" src={this.getPreviewImagePath(filename)} /> + </a> + <div className="file-details"> + <div className="file-details__name">{name}</div> + <div className="file-details__info">{infoString}</div> + </div> + </div> + ); + + // asynchronously request the actual size of this file + if (!(filename in this.state.fileSizes)) { + var self = this; + + utils.getFileSize(utils.getFileUrl(filename), function(fileSize) { + if (self.canSetState) { + var fileSizes = self.state.fileSizes; + fileSizes[filename] = fileSize; + self.setState(fileSizes); + } + }); + } + } + } else { + // display a progress indicator when the preview for an image is still loading var percentage = Math.floor(this.state.progress[this.state.imgId]); - loading = ( - <div key={name+"_loading"}> - <img ref="placeholder" className="loader-image" src="/static/images/load.gif" /> + content = ( + <div> + <img className="loader-image" src="/static/images/load.gif" /> { percentage > 0 ? <span className="loader-percent" >{"Previewing " + percentage + "%"}</span> : ""} </div> ); bgClass = "black-bg"; - } else if (this.state.viewed) { - for (var id in this.state.images) { - var info = utils.splitFileLocation(this.props.filenames[id]); - var preview_filename = ""; - if (this.props.imgCount > 0) { - preview_filename = this.props.filenames[this.state.imgId]; - } else { - // This is a temporary patch to fix issue with old files using absolute paths - if (info.path.indexOf("/api/v1/files/get") !== -1) { - info.path = info.path.split("/api/v1/files/get")[1]; - } - info.path = utils.getWindowLocationOrigin() + "/api/v1/files/get" + info.path; - preview_filename = info['path'] + '_preview.jpg'; - } - - var imgClass = "hidden"; - if (this.state.loaded[id] && this.state.imgId == id) imgClass = ""; - - img[info['path']] = <a key={info['path']} className={imgClass} href={info.path+"."+info.ext} target="_blank"><img ref="image" src={preview_filename}/></a>; - } } - var imgFragment = React.addons.createFragment(img); - - // This is a temporary patch to fix issue with old files using absolute paths - var download_link = this.props.filenames[this.state.imgId]; - if (download_link.indexOf("/api/v1/files/get") !== -1) { - download_link = download_link.split("/api/v1/files/get")[1]; - } - download_link = utils.getWindowLocationOrigin() + "/api/v1/files/get" + download_link; - return ( <div className="modal fade image_modal" ref="modal" id={this.props.modalId} tabIndex="-1" role="dialog" aria-hidden="true"> <div className="modal-dialog modal-image"> @@ -175,7 +198,7 @@ module.exports = React.createClass({ <div ref="imageBody" className="modal-body image-body"> <div ref="imageWrap" className={"image-wrapper default " + bgClass}> <div className="modal-close" data-dismiss="modal"></div> - {imgFragment} + {content} <div ref="imageFooter" className="modal-button-bar"> <span className="pull-left text">{"Image "+(this.state.imgId+1)+" of "+this.props.filenames.length}</span> <div className="image-links"> @@ -185,10 +208,9 @@ module.exports = React.createClass({ <span className="text"> | </span> </div> : "" } - <a href={download_link} download={decodeURIComponent(name)} className="text">Download</a> + <a href={fileUrl} download={name} className="text">Download</a> </div> </div> - {loading} </div> { this.props.filenames.length > 1 ? <a className="modal-prev-bar" href="#" onClick={this.handlePrev}> @@ -205,5 +227,23 @@ module.exports = React.createClass({ </div> </div> ); + }, + // Returns the path to a preview image that can be used to represent a file. + getPreviewImagePath: function(filename) { + var fileInfo = utils.splitFileLocation(filename); + var fileType = utils.getFileType(fileInfo.ext); + + if (fileType === "image") { + // This is a temporary patch to fix issue with old files using absolute paths + if (fileInfo.path.indexOf("/api/v1/files/get") !== -1) { + fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; + } + fileInfo.path = utils.getWindowLocationOrigin() + "/api/v1/files/get" + fileInfo.path; + + return fileInfo.path + '_preview.jpg'; + } else { + // only images have proper previews, so just use a placeholder icon for non-images + return utils.getPreviewImagePathForFileType(fileType); + } } }); diff --git a/web/react/pages/verify.jsx b/web/react/pages/verify.jsx index 69850849f..96b556983 100644 --- a/web/react/pages/verify.jsx +++ b/web/react/pages/verify.jsx @@ -3,11 +3,9 @@ var EmailVerify = require('../components/email_verify.jsx'); -global.window.setup_verify_page = function(is_verified) { - +global.window.setupVerifyPage = function setupVerifyPage(isVerified, teamURL, userEmail) { React.render( - <EmailVerify isVerified={is_verified} />, + <EmailVerify isVerified={isVerified} teamURL={teamURL} userEmail={userEmail} />, document.getElementById('verify') ); - }; diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx index 00bd83ed1..dc4fc1096 100644 --- a/web/react/utils/async_client.jsx +++ b/web/react/utils/async_client.jsx @@ -272,17 +272,23 @@ module.exports.search = function(terms) { ); } -module.exports.getPosts = function(force, id) { +module.exports.getPosts = function(force, id, maxPosts) { if (PostStore.getCurrentPosts() == null || force) { var channelId = id ? id : ChannelStore.getCurrentId(); if (isCallInProgress("getPosts_"+channelId)) return; var post_list = PostStore.getCurrentPosts(); + + if (!maxPosts) { maxPosts = Constants.POST_CHUNK_SIZE * Constants.MAX_POST_CHUNKS }; + // if we already have more than POST_CHUNK_SIZE posts, // let's get the amount we have but rounded up to next multiple of POST_CHUNK_SIZE, - // with a max at 180 - var numPosts = post_list && post_list.order.length > 0 ? Math.min(180, Constants.POST_CHUNK_SIZE * Math.ceil(post_list.order.length / Constants.POST_CHUNK_SIZE)) : Constants.POST_CHUNK_SIZE; + // with a max at maxPosts + var numPosts = Math.min(maxPosts, Constants.POST_CHUNK_SIZE); + if (post_list && post_list.order.length > 0) { + numPosts = Math.min(maxPosts, Constants.POST_CHUNK_SIZE * Math.ceil(post_list.order.length / Constants.POST_CHUNK_SIZE)); + } if (channelId != null) { callTracker["getPosts_"+channelId] = utils.getTimestamp(); diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index 77ce19530..c51007a66 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -52,9 +52,12 @@ module.exports = { MAX_DISPLAY_FILES: 5, MAX_UPLOAD_FILES: 5, MAX_FILE_SIZE: 50000000, // 50 MB + THUMBNAIL_WIDTH: 128, + THUMBNAIL_HEIGHT: 100, DEFAULT_CHANNEL: 'town-square', OFFTOPIC_CHANNEL: 'off-topic', POST_CHUNK_SIZE: 60, + MAX_POST_CHUNKS: 3, RESERVED_TEAM_NAMES: [ "www", "web", diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 8a4d92b85..a759cc579 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -533,6 +533,19 @@ module.exports.getFileType = function(ext) { return "other"; }; +module.exports.getPreviewImagePathForFileType = function(fileType) { + fileType = fileType.toLowerCase(); + + var icon; + if (fileType in Constants.ICON_FROM_TYPE) { + icon = Constants.ICON_FROM_TYPE[fileType]; + } else { + icon = Constants.ICON_FROM_TYPE["other"]; + } + + return "/static/images/icons/" + icon + ".png"; +}; + module.exports.getIconClassName = function(fileType) { fileType = fileType.toLowerCase(); @@ -557,6 +570,23 @@ module.exports.splitFileLocation = function(fileLocation) { return {'ext': ext, 'name': filename, 'path': filePath}; } +// Asynchronously gets the size of a file by requesting its headers. If successful, it calls the +// provided callback with the file size in bytes as the argument. +module.exports.getFileSize = function(url, callback) { + var request = new XMLHttpRequest(); + + request.open('HEAD', url, true); + request.onreadystatechange = function() { + if (request.readyState == 4 && request.status == 200) { + if (callback) { + callback(parseInt(request.getResponseHeader("content-length"))); + } + } + }; + + request.send(); +}; + module.exports.toTitleCase = function(str) { return str.replace(/\w\S*/g, function(txt){return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();}); } @@ -720,7 +750,7 @@ module.exports.switchChannel = function(channel, teammate_name) { AsyncClient.getChannels(true, true, true); AsyncClient.getChannelExtraInfo(true); - AsyncClient.getPosts(true, channel.id); + AsyncClient.getPosts(true, channel.id, Constants.POST_CHUNK_SIZE); $('.inner__wrap').removeClass('move--right'); $('.sidebar--left').removeClass('move--right'); @@ -847,4 +877,39 @@ module.exports.getWindowLocationOrigin = function() { windowLocationOrigin = window.location.protocol + "//" + window.location.hostname + (window.location.port ? ':' + window.location.port: ''); } return windowLocationOrigin; +} + +// Converts a file size in bytes into a human-readable string of the form "123MB". +module.exports.fileSizeToString = function(bytes) { + // it's unlikely that we'll have files bigger than this + if (bytes > 1024 * 1024 * 1024 * 1024) { + return Math.floor(bytes / (1024 * 1024 * 1024 * 1024)) + "TB"; + } else if (bytes > 1024 * 1024 * 1024) { + return Math.floor(bytes / (1024 * 1024 * 1024)) + "GB"; + } else if (bytes > 1024 * 1024) { + return Math.floor(bytes / (1024 * 1024)) + "MB"; + } else if (bytes > 1024) { + return Math.floor(bytes / 1024) + "KB"; + } else { + return bytes + "B"; + } +}; + +// Converts a filename (like those attached to Post objects) to a url that can be used to retrieve attachments from the server. +module.exports.getFileUrl = function(filename) { + var url = filename; + + // This is a temporary patch to fix issue with old files using absolute paths + if (url.indexOf("/api/v1/files/get") != -1) { + url = filename.split("/api/v1/files/get")[1]; + } + url = module.exports.getWindowLocationOrigin() + "/api/v1/files/get" + url; + + return url; +}; + +// Gets the name of a file (including extension) from a given url or file path. +module.exports.getFileName = function(path) { + var split = path.split('/'); + return split[split.length - 1]; }; diff --git a/web/sass-files/sass/partials/_files.scss b/web/sass-files/sass/partials/_files.scss index 56d03e171..ddc5e98bb 100644 --- a/web/sass-files/sass/partials/_files.scss +++ b/web/sass-files/sass/partials/_files.scss @@ -14,10 +14,6 @@ position: relative; border: 1px solid #DDD; @include clearfix; - &.custom-file { - width: 85px; - height: 100px; - } &:hover .remove-preview:after { @include opacity(1); } @@ -71,60 +67,56 @@ width:300px; height:300px; } + +@mixin file-icon($path) { + background: #fff url($path); + background-position: center; + background-repeat: no-repeat; + @include background-size(60px auto); +} .file-icon { width: 100%; height: 100%; &.audio { - background: url("../images/icons/audio.png"); - @include background-size(100% 100%); + @include file-icon("../images/icons/audio.png"); } &.video { - background: url("../images/icons/video.png"); - @include background-size(100% 100%); + @include file-icon("../images/icons/video.png"); } &.ppt { - background: url("../images/icons/ppt.png"); - @include background-size(100% 100%); + @include file-icon("../images/icons/ppt.png"); } &.generic { - background: url("../images/icons/generic.png"); - @include background-size(100% 100%); + @include file-icon("../images/icons/generic.png"); } &.code { - background: url("../images/icons/code.png"); - @include background-size(100% 100%); + @include file-icon("../images/icons/code.png"); } &.excel { - background: url("../images/icons/excel.png"); - @include background-size(100% 100%); + @include file-icon("../images/icons/excel.png"); } &.word { - background: url("../images/icons/word.png"); - @include background-size(100% 100%); + @include file-icon("../images/icons/word.png"); } &.pdf { - background: url("../images/icons/pdf.png"); - @include background-size(100% 100%); + @include file-icon("../images/icons/pdf.png"); } &.patch { - background: url("../images/icons/patch.png"); - @include background-size(100% 100%); + @include file-icon("../images/icons/patch.png"); } &.image { - background: url("../images/icons/image.png"); - @include background-size(100% 100%); + @include file-icon("../images/icons/image.png"); } } .post-image__column { position: relative; - width: 120px; + width: 240px; height: 100px; float: left; margin: 5px 10px 5px 0; - &.custom-file { - width: 85px; - height: 100px; - } + @include display-flex; + display: -ms-flexbox; + border: 1px solid lightgrey; .post__load { height: 100%; width: 100%; @@ -133,15 +125,74 @@ background-position: center; } .post__image { - height: 100%; width: 100%; - border: 1px solid #E2E2E2; + height: 100%; background-color: #FFF; background-repeat: no-repeat; - background-position: top left; + &.small { + background-position: center; + } + &.normal { + background-position: top left; + } + } + .post-image__thumbnail { + width: 50%; + height: 100%; + } + .post-image__details { + width: 50%; + height: 100%; + background: white; + border-left: 1px solid #ddd; + font-size: 13px; + padding: 7px; + .post-image__name { + margin-bottom: 3px; + } + .post-image__type { + color: grey; + } + .post-image__size { + margin-left: 4px; + color: grey; + } } a { text-decoration: none; color: grey; } } + +.file-details__container { + @include display-flex; + display: -ms-flexbox; + + .file-details { + width: 320px; + height: 270px; + padding: 14px; + text-align: left; + vertical-align: top; + + .file-details__name { + font-size: 16px; + } + .file-details__info { + color: grey; + } + } + .file-details__preview { + width: 320px; + height: 270px; + border-right: 1px solid #ddd; + vertical-align: center; + + // helper to center the image icon in the preview window + .file-details__preview-helper { + height: 100%; + display: inline-block; + vertical-align: middle; + } + } +} diff --git a/web/sass-files/sass/partials/_responsive.scss b/web/sass-files/sass/partials/_responsive.scss index 81b94ab5a..e3f140413 100644 --- a/web/sass-files/sass/partials/_responsive.scss +++ b/web/sass-files/sass/partials/_responsive.scss @@ -427,9 +427,9 @@ body { &.white { .inner__wrap { - >.row.content { - margin-bottom: -185px; - } + >.row.content { + margin-bottom: -185px; + } } } } @@ -447,6 +447,9 @@ } } } + .search__clear { + display: block; + } .search-bar__container { padding: 0; height: 45px; @@ -457,15 +460,17 @@ @include translateX(-45px); } .search__form { - padding-left: 10px; - padding-right: 67px; + @include translateX(-45px); + padding-left: 55px; + padding-right: 24px; } .search__clear { - display: block; + @include translateX(0px); } } .search__form { border: none; + @include translateX(0px); padding: 7px 20px 0 49px; height: 45px; position: relative; diff --git a/web/sass-files/sass/partials/_search.scss b/web/sass-files/sass/partials/_search.scss index e2168ef75..9ae41ebb0 100644 --- a/web/sass-files/sass/partials/_search.scss +++ b/web/sass-files/sass/partials/_search.scss @@ -7,6 +7,8 @@ right: 0; line-height: 45px; margin-right: 13px; + @include single-transition(all, 0.2s, linear); + @include translateX(60px); z-index: 5; cursor: pointer; } diff --git a/web/static/images/icons/audio.png b/web/static/images/icons/audio.png Binary files differindex 2b6d37f8d..bd25b7f84 100644 --- a/web/static/images/icons/audio.png +++ b/web/static/images/icons/audio.png diff --git a/web/static/images/icons/code.png b/web/static/images/icons/code.png Binary files differindex 80db302ee..c59e4b8dc 100644 --- a/web/static/images/icons/code.png +++ b/web/static/images/icons/code.png diff --git a/web/static/images/icons/excel.png b/web/static/images/icons/excel.png Binary files differindex 70ddadcbf..275c65c4d 100644 --- a/web/static/images/icons/excel.png +++ b/web/static/images/icons/excel.png diff --git a/web/static/images/icons/generic.png b/web/static/images/icons/generic.png Binary files differindex d9e82c232..0eb82c2d2 100644 --- a/web/static/images/icons/generic.png +++ b/web/static/images/icons/generic.png diff --git a/web/static/images/icons/image.png b/web/static/images/icons/image.png Binary files differindex a3acdef4c..799317731 100644 --- a/web/static/images/icons/image.png +++ b/web/static/images/icons/image.png diff --git a/web/static/images/icons/patch.png b/web/static/images/icons/patch.png Binary files differindex 18af126d4..a0affc9ee 100644 --- a/web/static/images/icons/patch.png +++ b/web/static/images/icons/patch.png diff --git a/web/static/images/icons/pdf.png b/web/static/images/icons/pdf.png Binary files differindex e4582570e..8c7507a1c 100644 --- a/web/static/images/icons/pdf.png +++ b/web/static/images/icons/pdf.png diff --git a/web/static/images/icons/ppt.png b/web/static/images/icons/ppt.png Binary files differindex 3571b4649..51553a11c 100644 --- a/web/static/images/icons/ppt.png +++ b/web/static/images/icons/ppt.png diff --git a/web/static/images/icons/video.png b/web/static/images/icons/video.png Binary files differindex e61a9e5f4..f53da93e4 100644 --- a/web/static/images/icons/video.png +++ b/web/static/images/icons/video.png diff --git a/web/static/images/icons/word.png b/web/static/images/icons/word.png Binary files differindex 20f830665..658937817 100644 --- a/web/static/images/icons/word.png +++ b/web/static/images/icons/word.png diff --git a/web/templates/verify.html b/web/templates/verify.html index a61964bb3..de839db68 100644 --- a/web/templates/verify.html +++ b/web/templates/verify.html @@ -9,7 +9,7 @@ </div> </div> <script> - window.setup_verify_page('{{ .Props.IsVerified }}'); + window.setupVerifyPage('{{.Props.IsVerified}}', '{{.Props.TeamURL}}', '{{.Props.UserEmail}}'); </script> </body> </html> diff --git a/web/web.go b/web/web.go index 68e2a5226..8b329c149 100644 --- a/web/web.go +++ b/web/web.go @@ -352,27 +352,26 @@ func getChannel(c *api.Context, w http.ResponseWriter, r *http.Request) { func verifyEmail(c *api.Context, w http.ResponseWriter, r *http.Request) { resend := r.URL.Query().Get("resend") - name := r.URL.Query().Get("name") + name := r.URL.Query().Get("teamname") email := r.URL.Query().Get("email") hashedId := r.URL.Query().Get("hid") userId := r.URL.Query().Get("uid") - if resend == "true" { - - teamId := "" - if result := <-api.Srv.Store.Team().GetByName(name); result.Err != nil { - c.Err = result.Err - return - } else { - teamId = result.Data.(*model.Team).Id - } + var team *model.Team + if result := <-api.Srv.Store.Team().GetByName(name); result.Err != nil { + c.Err = result.Err + return + } else { + team = result.Data.(*model.Team) + } - if result := <-api.Srv.Store.User().GetByEmail(teamId, email); result.Err != nil { + if resend == "true" { + if result := <-api.Srv.Store.User().GetByEmail(team.Id, email); result.Err != nil { c.Err = result.Err return } else { user := result.Data.(*model.User) - api.FireAndForgetVerifyEmail(user.Id, strings.Split(user.Nickname, " ")[0], user.Email, name, c.GetTeamURL()) + api.FireAndForgetVerifyEmail(user.Id, user.Email, team.Name, team.DisplayName, c.GetSiteURL(), c.GetTeamURLFromTeam(team)) http.Redirect(w, r, "/", http.StatusFound) return } @@ -396,6 +395,8 @@ func verifyEmail(c *api.Context, w http.ResponseWriter, r *http.Request) { page := NewHtmlTemplatePage("verify", "Email Verified") page.Props["IsVerified"] = isVerified + page.Props["TeamURL"] = c.GetTeamURLFromTeam(team) + page.Props["UserEmail"] = email page.Render(c, w) } |