diff options
-rw-r--r-- | api/command.go | 112 | ||||
-rw-r--r-- | api/command_test.go | 37 | ||||
-rw-r--r-- | api/templates/signup_team_body.html | 2 | ||||
-rw-r--r-- | model/command.go | 2 | ||||
-rw-r--r-- | store/sql_post_store.go | 14 | ||||
-rw-r--r-- | store/sql_post_store_test.go | 17 | ||||
-rw-r--r-- | web/react/components/command_list.jsx | 6 | ||||
-rw-r--r-- | web/react/components/create_comment.jsx | 7 | ||||
-rw-r--r-- | web/react/components/create_post.jsx | 7 | ||||
-rw-r--r-- | web/react/components/edit_post_modal.jsx | 6 | ||||
-rw-r--r-- | web/react/components/post_info.jsx | 17 | ||||
-rw-r--r-- | web/react/components/post_list.jsx | 42 | ||||
-rw-r--r-- | web/react/components/setting_item_max.jsx | 2 | ||||
-rw-r--r-- | web/react/components/sidebar_header.jsx | 2 | ||||
-rw-r--r-- | web/react/components/user_settings_general.jsx | 237 | ||||
-rw-r--r-- | web/react/components/user_settings_notifications.jsx | 16 | ||||
-rw-r--r-- | web/react/stores/channel_store.jsx | 1 | ||||
-rw-r--r-- | web/react/utils/utils.jsx | 95 | ||||
-rw-r--r-- | web/sass-files/sass/partials/_command-box.scss | 16 | ||||
-rw-r--r-- | web/sass-files/sass/partials/_settings.scss | 5 | ||||
-rw-r--r-- | web/templates/head.html | 2 |
21 files changed, 425 insertions, 220 deletions
diff --git a/api/command.go b/api/command.go index f051bd42e..749cbf790 100644 --- a/api/command.go +++ b/api/command.go @@ -4,15 +4,15 @@ package api import ( + "net/http" + "strconv" + "strings" + "time" + l4g "code.google.com/p/log4go" "github.com/gorilla/mux" "github.com/mattermost/platform/model" "github.com/mattermost/platform/utils" - "net/http" - "reflect" - "runtime" - "strconv" - "strings" ) type commandHandler func(c *Context, command *model.Command) bool @@ -24,6 +24,8 @@ var commands = []commandHandler{ echoCommand, } +var echoSem chan bool + func InitCommand(r *mux.Router) { l4g.Debug("Initializing command api routes") r.Handle("/command", ApiUserRequired(command)).Methods("POST") @@ -41,7 +43,6 @@ func command(c *Context, w http.ResponseWriter, r *http.Request) { } checkCommand(c, command) - if c.Err != nil { return } else { @@ -56,8 +57,6 @@ func checkCommand(c *Context, command *model.Command) bool { return false } - tchan := Srv.Store.Team().Get(c.Session.TeamId) - if len(command.ChannelId) > 0 { cchan := Srv.Store.Channel().CheckPermissionsTo(c.Session.TeamId, command.ChannelId, c.Session.UserId) @@ -66,24 +65,9 @@ func checkCommand(c *Context, command *model.Command) bool { } } - allowValet := false - if tResult := <-tchan; tResult.Err != nil { - c.Err = model.NewAppError("checkCommand", "Could not find the team for this session, team_id="+c.Session.TeamId, "") - return false - } else { - allowValet = tResult.Data.(*model.Team).AllowValet - } - - ec := runtime.FuncForPC(reflect.ValueOf(echoCommand).Pointer()).Name() - for _, v := range commands { - if !allowValet && ec == runtime.FuncForPC(reflect.ValueOf(v).Pointer()).Name() { - continue - } - if v(c, command) { - return true - } else if c.Err != nil { + if v(c, command) || c.Err != nil { return true } } @@ -112,55 +96,65 @@ func logoutCommand(c *Context, command *model.Command) bool { } func echoCommand(c *Context, command *model.Command) bool { - cmd := "/echo" + maxThreads := 100 - if strings.Index(command.Command, cmd) == 0 { - parts := strings.SplitN(command.Command, " ", 3) - - channelName := "" - if len(parts) >= 2 { - channelName = parts[1] + if !command.Suggest && strings.Index(command.Command, cmd) == 0 { + parameters := strings.SplitN(command.Command, " ", 2) + if len(parameters) != 2 || len(parameters[1]) == 0 { + return false } - - message := "" - if len(parts) >= 3 { - message = parts[2] + message := strings.Trim(parameters[1], " ") + delay := 0 + if endMsg := strings.LastIndex(message, "\""); string(message[0]) == "\"" && endMsg > 1 { + if checkDelay, err := strconv.Atoi(strings.Trim(message[endMsg:], " \"")); err == nil { + delay = checkDelay + } + message = message[1:endMsg] + } else if strings.Index(message, " ") > -1 { + delayIdx := strings.LastIndex(message, " ") + delayStr := strings.Trim(message[delayIdx:], " ") + + if checkDelay, err := strconv.Atoi(delayStr); err == nil { + delay = checkDelay + message = message[:delayIdx] + } } - if result := <-Srv.Store.Channel().GetChannels(c.Session.TeamId, c.Session.UserId); result.Err != nil { - c.Err = result.Err + if delay > 10000 { + c.Err = model.NewAppError("echoCommand", "Delays must be under 10000 seconds", "") return false - } else { - channels := result.Data.(*model.ChannelList) + } - for _, v := range channels.Channels { - if v.Type == model.CHANNEL_DIRECT { - continue - } + if echoSem == nil { + // We want one additional thread allowed so we never reach channel lockup + echoSem = make(chan bool, maxThreads+1) + } - if v.Name == channelName && !command.Suggest { - post := &model.Post{} - post.ChannelId = v.Id - post.Message = message + if len(echoSem) >= maxThreads { + c.Err = model.NewAppError("echoCommand", "High volume of echo request, cannot process request", "") + return false + } - if _, err := CreateValetPost(c, post); err != nil { - c.Err = err - return false - } + echoSem <- true + go func() { + defer func() { <-echoSem }() + post := &model.Post{} + post.ChannelId = command.ChannelId + post.Message = message - command.Response = model.RESP_EXECUTED - return true - } + time.Sleep(time.Duration(delay) * time.Second) - if len(channelName) == 0 || (strings.Index(v.Name, channelName) == 0 && len(parts) < 3) { - command.AddSuggestion(&model.SuggestCommand{Suggestion: cmd + " " + v.Name, Description: "Echo a message using Valet in a channel"}) - } + if _, err := CreatePost(c, post, false); err != nil { + l4g.Error("Unable to create /echo post, err=%v", err) } - } + }() + + command.Response = model.RESP_EXECUTED + return true } else if strings.Index(cmd, command.Command) == 0 { - command.AddSuggestion(&model.SuggestCommand{Suggestion: cmd, Description: "Echo a message using Valet in a channel"}) + command.AddSuggestion(&model.SuggestCommand{Suggestion: cmd, Description: "Echo back text from your account, /echo \"message\" [delay in seoncds]"}) } return false diff --git a/api/command_test.go b/api/command_test.go index a58ef9be5..fe52dd41b 100644 --- a/api/command_test.go +++ b/api/command_test.go @@ -4,9 +4,10 @@ package api import ( + "testing" + "github.com/mattermost/platform/model" "github.com/mattermost/platform/store" - "testing" ) func TestSuggestRootCommands(t *testing.T) { @@ -50,6 +51,12 @@ func TestSuggestRootCommands(t *testing.T) { if rs3.Suggestions[0].Suggestion != "/join" { t.Fatal("should have join cmd") } + + rs4 := Client.Must(Client.Command("", "/ech", true)).Data.(*model.Command) + + if rs4.Suggestions[0].Suggestion != "/echo" { + t.Fatal("should have echo cmd") + } } func TestLogoutCommands(t *testing.T) { @@ -145,3 +152,31 @@ func TestJoinCommands(t *testing.T) { t.Fatal("didn't join channel") } } + +func TestEchoCommand(t *testing.T) { + Setup() + + team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} + team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team) + + user1 := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"} + user1 = Client.Must(Client.CreateUser(user1, "")).Data.(*model.User) + store.Must(Srv.Store.User().VerifyEmail(user1.Id)) + + Client.LoginByEmail(team.Name, user1.Email, "pwd") + + channel1 := &model.Channel{DisplayName: "AA", Name: "aa" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} + channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) + + echoTestString := "/echo test" + + r1 := Client.Must(Client.Command(channel1.Id, echoTestString, false)).Data.(*model.Command) + if r1.Response != model.RESP_EXECUTED { + t.Fatal("Echo command failed to execute") + } + + p1 := Client.Must(Client.GetPosts(channel1.Id, 0, 2, "")).Data.(*model.PostList) + if len(p1.Order) != 1 { + t.Fatal("Echo command failed to send") + } +} diff --git a/api/templates/signup_team_body.html b/api/templates/signup_team_body.html index 71df0b9c8..b49cf5f36 100644 --- a/api/templates/signup_team_body.html +++ b/api/templates/signup_team_body.html @@ -21,7 +21,7 @@ <p style="margin: 20px 0 25px"> <a href="{{.Props.Link}}" style="background: #2389D7; border-radius: 3px; color: #fff; border: none; outline: none; min-width: 200px; padding: 15px 25px; font-size: 14px; font-family: inherit; cursor: pointer; -webkit-appearance: none;text-decoration: none;">Set up your team</a> </p> - {{ .SiteName }} is free for an unlimited time, for unlimited users.<br>You'll get more out of {{ .SiteName }} when your team is in constant communication--let's get them on board.<br></p> + {{ .SiteName }} is one place for all your team communication, searchable and available anywhere.<br>You'll get more out of {{ .SiteName }} when your team is in constant communication--let's get them on board.<br></p> <p> Learn more by <a href="{{.Props.TourUrl}}" style="text-decoration: none; color:#2389D7;">taking a tour</a> </p> diff --git a/model/command.go b/model/command.go index 23573205e..83243cc98 100644 --- a/model/command.go +++ b/model/command.go @@ -14,7 +14,7 @@ const ( type Command struct { Command string `json:"command"` - Response string `json:"reponse"` + Response string `json:"response"` GotoLocation string `json:"goto_location"` ChannelId string `json:"channel_id"` Suggest bool `json:"-"` diff --git a/store/sql_post_store.go b/store/sql_post_store.go index cd668b13c..297d60397 100644 --- a/store/sql_post_store.go +++ b/store/sql_post_store.go @@ -5,9 +5,11 @@ package store import ( "fmt" + "regexp" + "strings" + "github.com/mattermost/platform/model" "github.com/mattermost/platform/utils" - "strings" ) type SqlPostStore struct { @@ -358,7 +360,7 @@ func (s SqlPostStore) getParentsPosts(channelId string, offset int, limit int) S var posts []*model.Post _, err := s.GetReplica().Select(&posts, - `SELECT + `SELECT q2.* FROM Posts q2 @@ -366,7 +368,7 @@ func (s SqlPostStore) getParentsPosts(channelId string, offset int, limit int) S (SELECT DISTINCT q3.RootId FROM - (SELECT + (SELECT RootId FROM Posts @@ -417,6 +419,12 @@ func (s SqlPostStore) Search(teamId string, userId string, terms string, isHasht var posts []*model.Post if utils.Cfg.SqlSettings.DriverName == "postgres" { + + // Parse text for wildcards + if wildcard, err := regexp.Compile("\\*($| )"); err == nil { + terms = wildcard.ReplaceAllLiteralString(terms, ":* ") + } + searchQuery := fmt.Sprintf(`SELECT * FROM diff --git a/store/sql_post_store_test.go b/store/sql_post_store_test.go index 4564e2deb..d48dea51c 100644 --- a/store/sql_post_store_test.go +++ b/store/sql_post_store_test.go @@ -4,11 +4,11 @@ package store import ( - "github.com/mattermost/platform/model" - "github.com/mattermost/platform/utils" "strings" "testing" "time" + + "github.com/mattermost/platform/model" ) func TestPostStoreSave(t *testing.T) { @@ -547,11 +547,9 @@ func TestPostStoreSearch(t *testing.T) { t.Fatal("returned wrong serach result") } - if utils.Cfg.SqlSettings.DriverName == "mysql" { - r5 := (<-store.Post().Search(teamId, userId, "matter*", false)).Data.(*model.PostList) - if len(r5.Order) != 1 && r5.Order[0] != o1.Id { - t.Fatal("returned wrong serach result") - } + r5 := (<-store.Post().Search(teamId, userId, "matter*", false)).Data.(*model.PostList) + if len(r5.Order) != 1 && r5.Order[0] != o1.Id { + t.Fatal("returned wrong serach result") } r6 := (<-store.Post().Search(teamId, userId, "#hashtag", true)).Data.(*model.PostList) @@ -573,4 +571,9 @@ func TestPostStoreSearch(t *testing.T) { if len(r9.Order) != 2 { t.Fatal("returned wrong search result") } + + r10 := (<-store.Post().Search(teamId, userId, "matter* jer*", false)).Data.(*model.PostList) + if len(r10.Order) != 2 { + t.Fatal("returned wrong search result") + } } diff --git a/web/react/components/command_list.jsx b/web/react/components/command_list.jsx index 5efe98dc6..27264ff6e 100644 --- a/web/react/components/command_list.jsx +++ b/web/react/components/command_list.jsx @@ -48,15 +48,15 @@ module.exports = React.createClass({ if (this.state.suggestions[i].suggestion != this.state.cmd) { suggestions.push( <div key={i} className="command-name" onClick={this.handleClick.bind(this, i)}> - <div className="pull-left"><strong>{ this.state.suggestions[i].suggestion }</strong></div> - <div className="command-desc pull-right">{ this.state.suggestions[i].description }</div> + <div className="command__title"><strong>{ this.state.suggestions[i].suggestion }</strong></div> + <div className="command__desc">{ this.state.suggestions[i].description }</div> </div> ); } } return ( - <div ref="mentionlist" className="command-box" style={{height:(this.state.suggestions.length*37)+2}}> + <div ref="mentionlist" className="command-box" style={{height:(this.state.suggestions.length*56)+2}}> { suggestions } </div> ); diff --git a/web/react/components/create_comment.jsx b/web/react/components/create_comment.jsx index f6e34fda9..c2b7e222f 100644 --- a/web/react/components/create_comment.jsx +++ b/web/react/components/create_comment.jsx @@ -104,17 +104,14 @@ module.exports = React.createClass({ this.lastTime = t; } }, - handleUserInput: function(message) { - var messageText = utils.truncateText(message); - var newPostError = utils.checkMessageLengthError(messageText, this.state.postError, 'Comment length cannot exceed ' + Constants.MAX_POST_LEN + ' characters'); - + handleUserInput: function(messageText) { var draft = PostStore.getCommentDraft(this.props.rootId); draft.message = messageText; PostStore.storeCommentDraft(this.props.rootId, draft); $('.post-right__scroll').scrollTop($('.post-right__scroll')[0].scrollHeight); $('.post-right__scroll').perfectScrollbar('update'); - this.setState({messageText: messageText, postError: newPostError}); + this.setState({messageText: messageText}); }, handleUploadStart: function(clientIds, channelId) { var draft = PostStore.getCommentDraft(this.props.rootId); diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index 73210c855..b9142223f 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -130,12 +130,9 @@ module.exports = React.createClass({ this.lastTime = t; } }, - handleUserInput: function(message) { - var messageText = utils.truncateText(message); - var newPostError = utils.checkMessageLengthError(messageText, this.state.postError, 'Message length cannot exceed ' + Constants.MAX_POST_LEN + ' characters'); - + handleUserInput: function(messageText) { this.resizePostHolder(); - this.setState({messageText: messageText, postError: newPostError}); + this.setState({messageText: messageText}); var draft = PostStore.getCurrentDraft(); draft['message'] = messageText; diff --git a/web/react/components/edit_post_modal.jsx b/web/react/components/edit_post_modal.jsx index df692e1bb..1c5a1ed5e 100644 --- a/web/react/components/edit_post_modal.jsx +++ b/web/react/components/edit_post_modal.jsx @@ -38,10 +38,8 @@ module.exports = React.createClass({ $("#edit_post").modal('hide'); $(this.state.refocusId).focus(); }, - handleEditInput: function(editText) { - var editMessage = utils.truncateText(editText); - var newError = utils.checkMessageLengthError(editMessage, this.state.error, 'New message length cannot exceed ' + Constants.MAX_POST_LEN + ' characters'); - this.setState({editText: editMessage, error: newError}); + handleEditInput: function(editMessage) { + this.setState({editText: editMessage}); }, handleEditKeyPress: function(e) { if (e.which == 13 && !e.shiftKey && !e.altKey) { diff --git a/web/react/components/post_info.jsx b/web/react/components/post_info.jsx index 73e897f62..c80b287a3 100644 --- a/web/react/components/post_info.jsx +++ b/web/react/components/post_info.jsx @@ -43,13 +43,16 @@ export default class PostInfo extends React.Component { if (isOwner) { dropdownContents.push( - <li role='presentation'> + <li + key='editPost' + role='presentation' + > <a href='#' role='menuitem' data-toggle='modal' data-target='#edit_post' - data-refoucsid="#post_textbox" + data-refoucsid='#post_textbox' data-title={type} data-message={post.message} data-postid={post.id} @@ -64,7 +67,10 @@ export default class PostInfo extends React.Component { if (isOwner || isAdmin) { dropdownContents.push( - <li role='presentation'> + <li + key='deletePost' + role='presentation' + > <a href='#' role='menuitem' @@ -83,7 +89,10 @@ export default class PostInfo extends React.Component { if (this.props.allowReply === 'true') { dropdownContents.push( - <li role='presentation'> + <li + key='replyLink' + role='presentation' + > <a className='reply-link theme' href='#' diff --git a/web/react/components/post_list.jsx b/web/react/components/post_list.jsx index 865a22dbd..c1e6e490d 100644 --- a/web/react/components/post_list.jsx +++ b/web/react/components/post_list.jsx @@ -31,9 +31,11 @@ export default class PostList extends React.Component { this.onSocketChange = this.onSocketChange.bind(this); this.createChannelIntroMessage = this.createChannelIntroMessage.bind(this); this.loadMorePosts = this.loadMorePosts.bind(this); + this.loadFirstPosts = this.loadFirstPosts.bind(this); this.state = this.getStateFromStores(); this.state.numToDisplay = Constants.POST_CHUNK_SIZE; + this.state.isFirstLoadComplete = false; } getStateFromStores() { var channel = ChannelStore.getCurrent(); @@ -157,7 +159,10 @@ export default class PostList extends React.Component { }); this.scrollToBottom(); - setTimeout(this.scrollToBottom, 100); + + if (this.state.channel.id != null) { + this.loadFirstPosts(this.state.channel.id); + } } componentDidUpdate(prevProps, prevState) { $('.post-list__content div .post').removeClass('post--last'); @@ -229,9 +234,26 @@ export default class PostList extends React.Component { postHolder.removeClass('hide-scroll'); } } + loadFirstPosts(id) { + Client.getPosts( + id, + PostStore.getLatestUpdate(id), + function success() { + this.setState({isFirstLoadComplete: true}); + }.bind(this), + function fail() { + 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); @@ -379,6 +401,14 @@ export default class PostList extends React.Component { > <i className='fa fa-pencil'></i>Set a description </a> + <a + className='intro-links' + href='#' + data-toggle='modal' + data-target='#channel_invite' + > + <i className='fa fa-user-plus'></i>Invite others to this channel + </a> </div> ); } @@ -511,9 +541,15 @@ export default class PostList extends React.Component { if (post.user_id !== userId && post.create_at > this.state.lastViewed && !renderedLastViewed) { renderedLastViewed = true; + + // Temporary fix to solve ie10/11 rendering issue + let newSeparatorId = ''; + if (!utils.isBrowserIE()) { + newSeparatorId = 'new_message'; + } postCtls.push( <div - id='new_message' + id={newSeparatorId} key='unviewed' className='new-separator' > @@ -605,7 +641,7 @@ export default class PostList extends React.Component { } var postCtls = []; - if (posts) { + if (posts && this.state.isFirstLoadComplete) { postCtls = this.createPosts(posts, order); } else { postCtls.push(<LoadingScreen position='absolute' />); diff --git a/web/react/components/setting_item_max.jsx b/web/react/components/setting_item_max.jsx index 1599041b0..b978cdb0c 100644 --- a/web/react/components/setting_item_max.jsx +++ b/web/react/components/setting_item_max.jsx @@ -5,6 +5,7 @@ module.exports = React.createClass({ render: function() { var clientError = this.props.client_error ? <div className='form-group'><label className='col-sm-12 has-error'>{ this.props.client_error }</label></div> : null; var server_error = this.props.server_error ? <div className='form-group'><label className='col-sm-12 has-error'>{ this.props.server_error }</label></div> : null; + var extraInfo = this.props.extraInfo ? this.props.extraInfo : null; var inputs = this.props.inputs; @@ -15,6 +16,7 @@ module.exports = React.createClass({ <ul className="setting-list"> <li className="setting-list-item"> {inputs} + {extraInfo} </li> <li className="setting-list-item"> <hr /> diff --git a/web/react/components/sidebar_header.jsx b/web/react/components/sidebar_header.jsx index d5d16816f..af65b7e1d 100644 --- a/web/react/components/sidebar_header.jsx +++ b/web/react/components/sidebar_header.jsx @@ -85,7 +85,7 @@ var NavbarDropdown = React.createClass({ } }); } - teams.push(<li key='newTeam_li'><a key='newTeam_a' href={utils.getWindowLocationOrigin() + '/signup_team' }>Create a New Team</a></li>); + teams.push(<li key='newTeam_li'><a key='newTeam_a' target="_blank" href={utils.getWindowLocationOrigin() + '/signup_team' }>Create a New Team</a></li>); return ( <ul className='nav navbar-nav navbar-right'> diff --git a/web/react/components/user_settings_general.jsx b/web/react/components/user_settings_general.jsx index fed11fbe9..ddd2fb607 100644 --- a/web/react/components/user_settings_general.jsx +++ b/web/react/components/user_settings_general.jsx @@ -11,10 +11,32 @@ var AsyncClient = require('../utils/async_client.jsx'); var utils = require('../utils/utils.jsx'); var assign = require('object-assign'); -module.exports = React.createClass({ - displayName: 'GeneralTab', - submitActive: false, - submitUsername: function(e) { +export default class UserSettingsGeneralTab extends React.Component { + constructor(props) { + super(props); + this.submitActive = false; + + this.submitUsername = this.submitUsername.bind(this); + this.submitNickname = this.submitNickname.bind(this); + this.submitName = this.submitName.bind(this); + this.submitEmail = this.submitEmail.bind(this); + this.submitUser = this.submitUser.bind(this); + this.submitPicture = this.submitPicture.bind(this); + + this.updateUsername = this.updateUsername.bind(this); + this.updateFirstName = this.updateFirstName.bind(this); + this.updateLastName = this.updateLastName.bind(this); + this.updateNickname = this.updateNickname.bind(this); + this.updateEmail = this.updateEmail.bind(this); + this.updatePicture = this.updatePicture.bind(this); + this.updateSection = this.updateSection.bind(this); + + this.handleClose = this.handleClose.bind(this); + this.setupInitialState = this.setupInitialState.bind(this); + + this.state = this.setupInitialState(props); + } + submitUsername(e) { e.preventDefault(); var user = this.props.user; @@ -37,8 +59,8 @@ module.exports = React.createClass({ user.username = username; this.submitUser(user); - }, - submitNickname: function(e) { + } + submitNickname(e) { e.preventDefault(); var user = UserStore.getCurrentUser(); @@ -52,8 +74,8 @@ module.exports = React.createClass({ user.nickname = nickname; this.submitUser(user); - }, - submitName: function(e) { + } + submitName(e) { e.preventDefault(); var user = UserStore.getCurrentUser(); @@ -69,8 +91,8 @@ module.exports = React.createClass({ user.last_name = lastName; this.submitUser(user); - }, - submitEmail: function(e) { + } + submitEmail(e) { e.preventDefault(); var user = UserStore.getCurrentUser(); @@ -88,15 +110,15 @@ module.exports = React.createClass({ user.email = email; this.submitUser(user); - }, - submitUser: function(user) { + } + submitUser(user) { client.updateUser(user, - function() { + function updateSuccess() { this.updateSection(''); AsyncClient.getMe(); }.bind(this), - function(err) { - var state = this.getInitialState(); + function updateFailure(err) { + var state = this.setupInitialState(this.props); if (err.message) { state.serverError = err.message; } else { @@ -105,8 +127,8 @@ module.exports = React.createClass({ this.setState(state); }.bind(this) ); - }, - submitPicture: function(e) { + } + submitPicture(e) { e.preventDefault(); if (!this.state.picture) { @@ -129,34 +151,34 @@ module.exports = React.createClass({ this.setState({loadingPicture: true}); client.uploadProfileImage(formData, - function() { + function imageUploadSuccess() { this.submitActive = false; AsyncClient.getMe(); window.location.reload(); }.bind(this), - function(err) { - var state = this.getInitialState(); + function imageUploadFailure(err) { + var state = this.setupInitialState(this.props); state.serverError = err; this.setState(state); }.bind(this) ); - }, - updateUsername: function(e) { + } + updateUsername(e) { this.setState({username: e.target.value}); - }, - updateFirstName: function(e) { + } + updateFirstName(e) { this.setState({firstName: e.target.value}); - }, - updateLastName: function(e) { + } + updateLastName(e) { this.setState({lastName: e.target.value}); - }, - updateNickname: function(e) { + } + updateNickname(e) { this.setState({nickname: e.target.value}); - }, - updateEmail: function(e) { + } + updateEmail(e) { this.setState({email: e.target.value}); - }, - updatePicture: function(e) { + } + updatePicture(e) { if (e.target.files && e.target.files[0]) { this.setState({picture: e.target.files[0]}); @@ -165,34 +187,33 @@ module.exports = React.createClass({ } else { this.setState({picture: null}); } - }, - updateSection: function(section) { - this.setState(assign({}, this.getInitialState(), {clientError: ''})); + } + updateSection(section) { + this.setState(assign({}, this.setupInitialState(this.props), {clientError: '', serverError: '', emailError: ''})); this.submitActive = false; this.props.updateSection(section); - }, - handleClose: function() { - $(this.getDOMNode()).find('.form-control').each(function() { + } + handleClose() { + $(this.getDOMNode()).find('.form-control').each(function clearForms() { this.value = ''; }); - this.setState(assign({}, this.getInitialState(), {clientError: null, serverError: null, emailError: null})); + this.setState(assign({}, this.setupInitialState(this.props), {clientError: null, serverError: null, emailError: null})); this.props.updateSection(''); - }, - componentDidMount: function() { + } + componentDidMount() { $('#user_settings').on('hidden.bs.modal', this.handleClose); - }, - componentWillUnmount: function() { + } + componentWillUnmount() { $('#user_settings').off('hidden.bs.modal', this.handleClose); - }, - getInitialState: function() { - var user = this.props.user; + } + setupInitialState(props) { + var user = props.user; var emailEnabled = !ConfigStore.getSettingAsBoolean('ByPassEmail', false); - return {username: user.username, firstName: user.first_name, lastName: user.last_name, nickname: user.nickname, - email: user.email, picture: null, loadingPicture: false, emailEnabled: emailEnabled}; - }, - render: function() { + email: user.email, picture: null, loadingPicture: false, emailEnabled: emailEnabled}; + } + render() { var user = this.props.user; var clientError = null; @@ -214,19 +235,35 @@ module.exports = React.createClass({ if (this.props.activeSection === 'name') { inputs.push( - <div className='form-group'> + <div + key='firstNameSetting' + 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.firstName}/> + <input + className='form-control' + type='text' + onChange={this.updateFirstName} + value={this.state.firstName} + /> </div> </div> ); inputs.push( - <div className='form-group'> + <div + key='lastNameSetting' + 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.lastName}/> + <input + className='form-control' + type='text' + onChange={this.updateLastName} + value={this.state.lastName} + /> </div> </div> ); @@ -238,7 +275,7 @@ module.exports = React.createClass({ submit={this.submitName} server_error={serverError} client_error={clientError} - updateSection={function(e) { + updateSection={function clearSection(e) { self.updateSection(''); e.preventDefault(); }} @@ -259,7 +296,7 @@ module.exports = React.createClass({ <SettingItemMin title='Full Name' describe={fullName} - updateSection={function() { + updateSection={function updateNameSection() { self.updateSection('name'); }} /> @@ -268,11 +305,24 @@ module.exports = React.createClass({ var nicknameSection; if (this.props.activeSection === 'nickname') { + let nicknameLabel = 'Nickname'; + if (utils.isMobile()) { + nicknameLabel = ''; + } + inputs.push( - <div className='form-group'> - <label className='col-sm-5 control-label'>{utils.isMobile() ? '' : 'Nickname'}</label> + <div + key='nicknameSetting' + className='form-group' + > + <label className='col-sm-5 control-label'>{nicknameLabel}</label> <div className='col-sm-7'> - <input className='form-control' type='text' onChange={this.updateNickname} value={this.state.nickname}/> + <input + className='form-control' + type='text' + onChange={this.updateNickname} + value={this.state.nickname} + /> </div> </div> ); @@ -284,7 +334,7 @@ module.exports = React.createClass({ submit={this.submitNickname} server_error={serverError} client_error={clientError} - updateSection={function(e) { + updateSection={function clearSection(e) { self.updateSection(''); e.preventDefault(); }} @@ -295,7 +345,7 @@ module.exports = React.createClass({ <SettingItemMin title='Nickname' describe={UserStore.getCurrentUser().nickname} - updateSection={function() { + updateSection={function updateNicknameSection() { self.updateSection('nickname'); }} /> @@ -304,11 +354,24 @@ module.exports = React.createClass({ var usernameSection; if (this.props.activeSection === 'username') { + let usernameLabel = 'Username'; + if (utils.isMobile()) { + usernameLabel = ''; + } + inputs.push( - <div className='form-group'> - <label className='col-sm-5 control-label'>{utils.isMobile() ? '' : 'Username'}</label> + <div + key='usernameSetting' + className='form-group' + > + <label className='col-sm-5 control-label'>{usernameLabel}</label> <div className='col-sm-7'> - <input className='form-control' type='text' onChange={this.updateUsername} value={this.state.username}/> + <input + className='form-control' + type='text' + onChange={this.updateUsername} + value={this.state.username} + /> </div> </div> ); @@ -320,7 +383,7 @@ module.exports = React.createClass({ submit={this.submitUsername} server_error={serverError} client_error={clientError} - updateSection={function(e) { + updateSection={function clearSection(e) { self.updateSection(''); e.preventDefault(); }} @@ -331,7 +394,7 @@ module.exports = React.createClass({ <SettingItemMin title='Username' describe={UserStore.getCurrentUser().username} - updateSection={function() { + updateSection={function updateUsernameSection() { self.updateSection('username'); }} /> @@ -346,11 +409,16 @@ module.exports = React.createClass({ } inputs.push( - <div> + <div key='emailSetting'> <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}/> + <input + className='form-control' + type='text' + onChange={this.updateEmail} + value={this.state.email} + /> </div> </div> {helpText} @@ -364,7 +432,7 @@ module.exports = React.createClass({ submit={this.submitEmail} server_error={serverError} client_error={emailError} - updateSection={function(e) { + updateSection={function clearSection(e) { self.updateSection(''); e.preventDefault(); }} @@ -375,7 +443,7 @@ module.exports = React.createClass({ <SettingItemMin title='Email' describe={UserStore.getCurrentUser().email} - updateSection={function() { + updateSection={function updateEmailSection() { self.updateSection('email'); }} /> @@ -391,7 +459,7 @@ module.exports = React.createClass({ src={'/api/v1/users/' + user.id + '/image?time=' + user.last_picture_update} server_error={serverError} client_error={clientError} - updateSection={function(e) { + updateSection={function clearSection(e) { self.updateSection(''); e.preventDefault(); }} @@ -410,7 +478,7 @@ module.exports = React.createClass({ <SettingItemMin title='Profile Picture' describe={minMessage} - updateSection={function() { + updateSection={function updatePictureSection() { self.updateSection('picture'); }} /> @@ -419,8 +487,21 @@ module.exports = React.createClass({ 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> + <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> @@ -439,4 +520,10 @@ module.exports = React.createClass({ </div> ); } -}); +} + +UserSettingsGeneralTab.propTypes = { + user: React.PropTypes.object, + updateSection: React.PropTypes.func, + activeSection: React.PropTypes.string +}; diff --git a/web/react/components/user_settings_notifications.jsx b/web/react/components/user_settings_notifications.jsx index b89f72987..ba0bda78e 100644 --- a/web/react/components/user_settings_notifications.jsx +++ b/web/react/components/user_settings_notifications.jsx @@ -1,6 +1,7 @@ // Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. // See License.txt for license information. +var ChannelStore = require('../stores/channel_store.jsx'); var UserStore = require('../stores/user_store.jsx'); var SettingItemMin = require('./setting_item_min.jsx'); var SettingItemMax = require('./setting_item_max.jsx'); @@ -67,7 +68,11 @@ function getNotificationsStateFromStores() { } } - return {notifyLevel: desktop, enableEmail: email, soundNeeded: soundNeeded, enableSound: sound, usernameKey: usernameKey, mentionKey: mentionKey, customKeys: customKeys, customKeysChecked: customKeys.length > 0, firstNameKey: firstNameKey, allKey: allKey, channelKey: channelKey}; + var curChannel = ChannelStore.getCurrent().display_name; + + return {notifyLevel: desktop, enableEmail: email, soundNeeded: soundNeeded, enableSound: sound, + usernameKey: usernameKey, mentionKey: mentionKey, customKeys: customKeys, customKeysChecked: customKeys.length > 0, + firstNameKey: firstNameKey, allKey: allKey, channelKey: channelKey, curChannel: curChannel}; } export default class NotificationsTab extends React.Component { @@ -141,10 +146,12 @@ export default class NotificationsTab extends React.Component { } componentDidMount() { UserStore.addChangeListener(this.onListenerChange); + ChannelStore.addChangeListener(this.onListenerChange); $('#user_settings').on('hidden.bs.modal', this.handleClose); } componentWillUnmount() { UserStore.removeChangeListener(this.onListenerChange); + ChannelStore.removeChangeListener(this.onListenerChange); $('#user_settings').off('hidden.bs.modal', this.handleClose); this.props.updateSection(''); } @@ -265,6 +272,12 @@ export default class NotificationsTab extends React.Component { e.preventDefault(); }; + let extraInfo = ( + <div className='setting-list__hint'> + These settings will override the global notification settings for the <b>{this.state.curChannel}</b> channel + </div> + ) + desktopSection = ( <SettingItemMax title='Send desktop notifications' @@ -272,6 +285,7 @@ export default class NotificationsTab extends React.Component { submit={this.handleSubmit} server_error={serverError} updateSection={handleUpdateDesktopSection} + extraInfo={extraInfo} /> ); } else { diff --git a/web/react/stores/channel_store.jsx b/web/react/stores/channel_store.jsx index f7c23841c..678d50bbd 100644 --- a/web/react/stores/channel_store.jsx +++ b/web/react/stores/channel_store.jsx @@ -270,4 +270,5 @@ ChannelStore.dispatchToken = AppDispatcher.register(function(payload) { } }); +ChannelStore.setMaxListeners(11); module.exports = ChannelStore; diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 34a0d55da..a1dc72ae2 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -260,10 +260,40 @@ module.exports.escapeRegExp = function(string) { return string.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, '\\$1'); }; +function handleYoutubeTime(link) { + var timeRegex = /[\\?&]t=([0-9hms]+)/; + + var time = link.trim().match(timeRegex); + if (!time || !time[1]) { + return ''; + } + + var hours = time[1].match(/([0-9]+)h/); + var minutes = time[1].match(/([0-9]+)m/); + var seconds = time[1].match(/([0-9]+)s/); + + var ticks = 0; + + if (hours && hours[1]) { + ticks += parseInt(hours[1], 10) * 3600; + } + + if (minutes && minutes[1]) { + ticks += parseInt(minutes[1], 10) * 60; + } + + if (seconds && seconds[1]) { + ticks += parseInt(seconds[1], 10); + } + + return '&start=' + ticks.toString(); +} + function getYoutubeEmbed(link) { var regex = /.*(?:youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|watch\?(?:[a-zA-Z-_]+=[a-zA-Z0-9-_]+&)+v=)([^#\&\?]*).*/; var youtubeId = link.trim().match(regex)[1]; + var time = handleYoutubeTime(link); function onClick(e) { var div = $(e.target).closest('.video-thumbnail__container')[0]; @@ -271,7 +301,8 @@ function getYoutubeEmbed(link) { iframe.setAttribute('src', 'https://www.youtube.com/embed/' + div.id + - '?autoplay=1&autohide=1&border=0&wmode=opaque&fs=1&enablejsapi=1'); + '?autoplay=1&autohide=1&border=0&wmode=opaque&fs=1&enablejsapi=1' + + time); iframe.setAttribute('width', '480px'); iframe.setAttribute('height', '360px'); iframe.setAttribute('type', 'text/html'); @@ -286,6 +317,7 @@ function getYoutubeEmbed(link) { return; } var metadata = data.items[0].snippet; + $('.video-type.' + youtubeId).html("Youtube - ") $('.video-uploader.' + youtubeId).html(metadata.channelTitle); $('.video-title.' + youtubeId).find('a').html(metadata.title); $('.post-list-holder-by-time').scrollTop($('.post-list-holder-by-time')[0].scrollHeight); @@ -303,9 +335,11 @@ function getYoutubeEmbed(link) { return ( <div className='post-comment'> - <h4 className='video-type'>YouTube</h4> + <h4> + <span className={'video-type ' + youtubeId}>YouTube</span> + <span className={'video-title ' + youtubeId}><a href={link}></a></span> + </h4> <h4 className={'video-uploader ' + youtubeId}></h4> - <h4 className={'video-title ' + youtubeId}><a href={link}></a></h4> <div className='video-div embed-responsive-item' id={youtubeId} onClick={onClick}> <div className='embed-responsive embed-responsive-4by3 video-div__placeholder'> <div id={youtubeId} className='video-thumbnail__container'> @@ -456,9 +490,21 @@ module.exports.textToJsx = function(text, options) { var mentionRegex = /^(?:@)([a-z0-9_]+)$/gi; // looks loop invariant but a weird JS bug needs it to be redefined here var explicitMention = mentionRegex.exec(trimWord); - if ((trimWord.toLowerCase().indexOf(searchTerm) > -1 || word.toLowerCase().indexOf(searchTerm) > -1) && searchTerm != '') { - - highlightSearchClass = ' search-highlight'; + if (searchTerm !== '') { + let searchWords = searchTerm.split(' '); + for (let idx in searchWords) { + let searchWord = searchWords[idx]; + if (searchWord === word.toLowerCase() || searchWord === trimWord.toLowerCase()) { + highlightSearchClass = ' search-highlight'; + break; + } else if (searchWord.charAt(searchWord.length - 1) === '*') { + let searchWordPrefix = searchWord.slice(0,-1); + if (trimWord.toLowerCase().indexOf(searchWordPrefix) > -1 || word.toLowerCase().indexOf(searchWordPrefix) > -1) { + highlightSearchClass = ' search-highlight'; + break; + } + } + } } if (explicitMention && @@ -1001,43 +1047,6 @@ module.exports.isBrowserEdge = function() { return window.naviagtor && navigator.userAgent && navigator.userAgent.toLowerCase().indexOf('edge') > -1; }; -// Gets text length consistent with maxlength property of textarea html tag -module.exports.getLengthOfTextInTextarea = function(messageText) { - // Need to get length with carriage returns counting as two characters to match textbox maxlength behavior - // unless ie10/ie11/edge which already do - - var len = messageText.length; - if (!module.exports.isBrowserIE() && !module.exports.isBrowserEdge()) { - len = messageText.replace(/\r(?!\n)|\n(?!\r)/g, '--').length; - } - - return len; -}; - -module.exports.checkMessageLengthError = function(message, currentError, newError) { - var updatedError = currentError; - var len = module.exports.getLengthOfTextInTextarea(message); - - if (!currentError && len >= Constants.MAX_POST_LEN) { - updatedError = newError; - } else if (currentError === newError && len < Constants.MAX_POST_LEN) { - updatedError = ''; - } - - return updatedError; -}; - -// Necessary due to issues with textarea max length and pasting newlines -module.exports.truncateText = function(message) { - var lengthDifference = module.exports.getLengthOfTextInTextarea(message) - message.length; - - if (lengthDifference > 0) { - return message.substring(0, Constants.MAX_POST_LEN - lengthDifference); - } - - return message.substring(0, Constants.MAX_POST_LEN); -}; - // Used to get the id of the other user from a DM channel module.exports.getUserIdFromChannelName = function(channel) { var ids = channel.name.split('__'); diff --git a/web/sass-files/sass/partials/_command-box.scss b/web/sass-files/sass/partials/_command-box.scss index 44eb9b8df..565296fae 100644 --- a/web/sass-files/sass/partials/_command-box.scss +++ b/web/sass-files/sass/partials/_command-box.scss @@ -4,20 +4,30 @@ width: 100%; border: $border-gray; bottom: 38px; + overflow: auto; @extend %popover-box-shadow; + .sidebar--right & { + bottom: 100px; + } } .command-name { position: relative; width: 100%; background-color: #fff; - height: 37px; - line-height: 37px; - padding: 2px 10px 2px 5px; + line-height: 24px; + padding: 5px 10px 8px; z-index: 101; + font-size: 0.95em; + border-bottom: 1px solid #ddd; &:hover { background-color: #e8eaed; } + .command__desc { + margin-left: 5px; + color: #999; + line-height: normal; + } } .command-desc { diff --git a/web/sass-files/sass/partials/_settings.scss b/web/sass-files/sass/partials/_settings.scss index 99a7eb7bc..8d743dfe2 100644 --- a/web/sass-files/sass/partials/_settings.scss +++ b/web/sass-files/sass/partials/_settings.scss @@ -94,6 +94,11 @@ list-style-type:none; } + .setting-list__hint { + color: #555; + margin-top: 20px; + } + .mentions-input { margin-top: 10px; } diff --git a/web/templates/head.html b/web/templates/head.html index c27597f82..6b6b7f09d 100644 --- a/web/templates/head.html +++ b/web/templates/head.html @@ -7,7 +7,7 @@ <!-- iOS add to homescreen --> <meta name="apple-mobile-web-app-capable" content="yes" /> - <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> + <meta name="apple-mobile-web-app-status-bar-style" content="default"> <meta name="mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-title" content="{{ .Title }}"> <meta name="application-name" content="{{ .Title }}"> |