diff --git a/CHANGELOG.md b/CHANGELOG.md index eac1e61e53..425902cf01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,14 @@ # Mattermost Changelog -## UNDER DEVELOPMENT - Release v1.1.0 +## UNDER DEVELOPMENT Release v1.2.0 The "UNDER DEVELOPMENT" section of the Mattermost changelog appears in the product's `master` branch to note key changes committed to master and are on their way to the next stable release. When a stable release is pushed the "UNDER DEVELOPMENT" heading is removed from the final changelog of the release. -- **Final release anticipated:** 2015-10-16 +- **Final release anticipated:** 2015-11-16 + +## Release v1.1.0 + +Released: 2015-10-16 ### Release Highlights @@ -44,6 +48,11 @@ Messaging and Notifications - Fixed bug where System Administrator did not have Team Administrator permissions - Fixed bug causing scrolling to jump when the right hand sidebar opened and closed +### Known Issues + +- Slack import is unstable due to change in Slack export format +- Uploading a .flac file breaks the file previewer on iOS + ### Contributors Many thanks to our external contributors. In no particular order: diff --git a/api/preference.go b/api/preference.go index 88cb132f88..6d6ac1a7ff 100644 --- a/api/preference.go +++ b/api/preference.go @@ -14,11 +14,22 @@ func InitPreference(r *mux.Router) { l4g.Debug("Initializing preference api routes") sr := r.PathPrefix("/preferences").Subrouter() + sr.Handle("/", ApiUserRequired(getAllPreferences)).Methods("GET") sr.Handle("/save", ApiUserRequired(savePreferences)).Methods("POST") sr.Handle("/{category:[A-Za-z0-9_]+}", ApiUserRequired(getPreferenceCategory)).Methods("GET") sr.Handle("/{category:[A-Za-z0-9_]+}/{name:[A-Za-z0-9_]+}", ApiUserRequired(getPreference)).Methods("GET") } +func getAllPreferences(c *Context, w http.ResponseWriter, r *http.Request) { + if result := <-Srv.Store.Preference().GetAll(c.Session.UserId); result.Err != nil { + c.Err = result.Err + } else { + data := result.Data.(model.Preferences) + + w.Write([]byte(data.ToJson())) + } +} + func savePreferences(c *Context, w http.ResponseWriter, r *http.Request) { preferences, err := model.PreferencesFromJson(r.Body) if err != nil { @@ -52,61 +63,10 @@ func getPreferenceCategory(c *Context, w http.ResponseWriter, r *http.Request) { } else { data := result.Data.(model.Preferences) - data = transformPreferences(c, data, category) - w.Write([]byte(data.ToJson())) } } -func transformPreferences(c *Context, preferences model.Preferences, category string) model.Preferences { - if len(preferences) == 0 && category == model.PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW { - // add direct channels for a user that existed before preferences were added - preferences = addDirectChannels(c.Session.UserId, c.Session.TeamId) - } - - return preferences -} - -func addDirectChannels(userId, teamId string) model.Preferences { - var profiles map[string]*model.User - if result := <-Srv.Store.User().GetProfiles(teamId); result.Err != nil { - l4g.Error("Failed to add direct channel preferences for user user_id=%s, team_id=%s, err=%v", userId, teamId, result.Err.Error()) - return model.Preferences{} - } else { - profiles = result.Data.(map[string]*model.User) - } - - var preferences model.Preferences - - for id := range profiles { - if id == userId { - continue - } - - profile := profiles[id] - - preference := model.Preference{ - UserId: userId, - Category: model.PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW, - Name: profile.Id, - Value: "true", - } - - preferences = append(preferences, preference) - - if len(preferences) >= 10 { - break - } - } - - if result := <-Srv.Store.Preference().Save(&preferences); result.Err != nil { - l4g.Error("Failed to add direct channel preferences for user user_id=%s, eam_id=%s, err=%v", userId, teamId, result.Err.Error()) - return model.Preferences{} - } else { - return preferences - } -} - func getPreference(c *Context, w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) category := params["category"] diff --git a/api/preference_test.go b/api/preference_test.go index 318ce95826..eaa92fe471 100644 --- a/api/preference_test.go +++ b/api/preference_test.go @@ -9,6 +9,65 @@ import ( "testing" ) +func TestGetAllPreferences(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)) + + user2 := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"} + user2 = Client.Must(Client.CreateUser(user2, "")).Data.(*model.User) + store.Must(Srv.Store.User().VerifyEmail(user2.Id)) + + category := model.NewId() + + preferences1 := model.Preferences{ + { + UserId: user1.Id, + Category: category, + Name: model.NewId(), + }, + { + UserId: user1.Id, + Category: category, + Name: model.NewId(), + }, + { + UserId: user1.Id, + Category: model.NewId(), + Name: model.NewId(), + }, + } + + Client.LoginByEmail(team.Name, user1.Email, "pwd") + Client.Must(Client.SetPreferences(&preferences1)) + + if result, err := Client.GetAllPreferences(); err != nil { + t.Fatal(err) + } else if data := result.Data.(model.Preferences); len(data) != 3 { + t.Fatal("received the wrong number of preferences") + } else if !((data[0] == preferences1[0] && data[1] == preferences1[1]) || (data[0] == preferences1[1] && data[1] == preferences1[0])) { + for i := 0; i < 3; i++ { + if data[0] != preferences1[i] && data[1] != preferences1[i] && data[2] != preferences1[i] { + t.Fatal("got incorrect preferences") + } + } + } + + Client.LoginByEmail(team.Name, user2.Email, "pwd") + + // note that user2 will automatically have a preference set for them to show user1 for direct messages + if result, err := Client.GetAllPreferences(); err != nil { + t.Fatal(err) + } else if data := result.Data.(model.Preferences); len(data) != 1 { + t.Fatal("received the wrong number of preferences") + } +} + func TestSetPreferences(t *testing.T) { Setup() @@ -113,54 +172,6 @@ func TestGetPreferenceCategory(t *testing.T) { } } -func TestTransformPreferences(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)) - - for i := 0; i < 5; i++ { - user := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"} - Client.Must(Client.CreateUser(user, "")) - } - - Client.Must(Client.LoginByEmail(team.Name, user1.Email, "pwd")) - - if result, err := Client.GetPreferenceCategory(model.PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW); err != nil { - t.Fatal(err) - } else if data := result.Data.(model.Preferences); len(data) != 5 { - t.Fatal("received the wrong number of direct channels") - } - - user2 := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"} - user2 = Client.Must(Client.CreateUser(user2, "")).Data.(*model.User) - store.Must(Srv.Store.User().VerifyEmail(user2.Id)) - - for i := 0; i < 10; i++ { - user := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"} - Client.Must(Client.CreateUser(user, "")) - } - - // make sure user1's preferences don't change - if result, err := Client.GetPreferenceCategory(model.PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW); err != nil { - t.Fatal(err) - } else if data := result.Data.(model.Preferences); len(data) != 5 { - t.Fatal("received the wrong number of direct channels") - } - - Client.Must(Client.LoginByEmail(team.Name, user2.Email, "pwd")) - - if result, err := Client.GetPreferenceCategory(model.PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW); err != nil { - t.Fatal(err) - } else if data := result.Data.(model.Preferences); len(data) != 10 { - t.Fatal("received the wrong number of direct channels") - } -} - func TestGetPreference(t *testing.T) { Setup() diff --git a/api/team.go b/api/team.go index 65dfcc95d0..666fd909b4 100644 --- a/api/team.go +++ b/api/team.go @@ -52,7 +52,7 @@ func signupTeam(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !isTreamCreationAllowed(c, email) { + if !isTeamCreationAllowed(c, email) { return } @@ -100,7 +100,7 @@ func createTeamFromSSO(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !isTreamCreationAllowed(c, team.Email) { + if !isTeamCreationAllowed(c, team.Email) { return } @@ -169,7 +169,7 @@ func createTeamFromSignup(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !isTreamCreationAllowed(c, teamSignup.Team.Email) { + if !isTeamCreationAllowed(c, teamSignup.Team.Email) { return } @@ -257,7 +257,7 @@ func CreateTeam(c *Context, team *model.Team) *model.Team { return nil } - if !isTreamCreationAllowed(c, team.Email) { + if !isTeamCreationAllowed(c, team.Email) { return nil } @@ -276,12 +276,12 @@ func CreateTeam(c *Context, team *model.Team) *model.Team { } } -func isTreamCreationAllowed(c *Context, email string) bool { +func isTeamCreationAllowed(c *Context, email string) bool { email = strings.ToLower(email) if !utils.Cfg.TeamSettings.EnableTeamCreation { - c.Err = model.NewAppError("isTreamCreationAllowed", "Team creation has been disabled. Please ask your systems administrator for details.", "") + c.Err = model.NewAppError("isTeamCreationAllowed", "Team creation has been disabled. Please ask your systems administrator for details.", "") return false } @@ -298,7 +298,7 @@ func isTreamCreationAllowed(c *Context, email string) bool { } if len(utils.Cfg.TeamSettings.RestrictCreationToDomains) > 0 && !matched { - c.Err = model.NewAppError("isTreamCreationAllowed", "Email must be from a specific domain (e.g. @example.com). Please ask your systems administrator for details.", "") + c.Err = model.NewAppError("isTeamCreationAllowed", "Email must be from a specific domain (e.g. @example.com). Please ask your systems administrator for details.", "") return false } @@ -409,14 +409,13 @@ func findTeams(c *Context, w http.ResponseWriter, r *http.Request) { return } else { teams := result.Data.([]*model.Team) - - s := make([]string, 0, len(teams)) - + m := make(map[string]*model.Team) for _, v := range teams { - s = append(s, v.Name) + v.Sanitize() + m[v.Id] = v } - w.Write([]byte(model.ArrayToJson(s))) + w.Write([]byte(model.TeamMapToJson(m))) } } diff --git a/api/team_test.go b/api/team_test.go index 9b701911b9..507f4252a3 100644 --- a/api/team_test.go +++ b/api/team_test.go @@ -121,9 +121,12 @@ func TestFindTeamByEmail(t *testing.T) { if r1, err := Client.FindTeams(user.Email); err != nil { t.Fatal(err) } else { - domains := r1.Data.([]string) - if domains[0] != team.Name { - t.Fatal(domains) + teams := r1.Data.(map[string]*model.Team) + if teams[team.Id].Name != team.Name { + t.Fatal() + } + if teams[team.Id].DisplayName != team.DisplayName { + t.Fatal() } } diff --git a/api/user.go b/api/user.go index 146ede0154..ac33e81a18 100644 --- a/api/user.go +++ b/api/user.go @@ -198,7 +198,9 @@ func CreateUser(c *Context, team *model.Team, user *model.User) *model.User { l4g.Error("Encountered an issue joining default channels user_id=%s, team_id=%s, err=%v", ruser.Id, ruser.TeamId, err) } - fireAndForgetWelcomeEmail(result.Data.(*model.User).Id, ruser.Email, team.Name, team.DisplayName, c.GetSiteURL(), c.GetTeamURLFromTeam(team), user.EmailVerified) + fireAndForgetWelcomeEmail(ruser.Id, ruser.Email, team.Name, team.DisplayName, c.GetSiteURL(), c.GetTeamURLFromTeam(team), user.EmailVerified) + + addDirectChannelsAndForget(ruser) if user.EmailVerified { if cresult := <-Srv.Store.User().VerifyEmail(ruser.Id); cresult.Err != nil { @@ -237,6 +239,45 @@ func fireAndForgetWelcomeEmail(userId, email, teamName, teamDisplayName, siteURL }() } +func addDirectChannelsAndForget(user *model.User) { + go func() { + var profiles map[string]*model.User + if result := <-Srv.Store.User().GetProfiles(user.TeamId); result.Err != nil { + l4g.Error("Failed to add direct channel preferences for user user_id=%s, team_id=%s, err=%v", user.Id, user.TeamId, result.Err.Error()) + return + } else { + profiles = result.Data.(map[string]*model.User) + } + + var preferences model.Preferences + + for id := range profiles { + if id == user.Id { + continue + } + + profile := profiles[id] + + preference := model.Preference{ + UserId: user.Id, + Category: model.PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW, + Name: profile.Id, + Value: "true", + } + + preferences = append(preferences, preference) + + if len(preferences) >= 10 { + break + } + } + + if result := <-Srv.Store.Preference().Save(&preferences); result.Err != nil { + l4g.Error("Failed to add direct channel preferences for new user user_id=%s, eam_id=%s, err=%v", user.Id, user.TeamId, result.Err.Error()) + } + }() +} + func FireAndForgetVerifyEmail(userId, userEmail, teamName, teamDisplayName, siteURL, teamURL string) { go func() { diff --git a/doc/developer/Setup.md b/doc/developer/Setup.md index afaef4ee46..d806b7a9ba 100644 --- a/doc/developer/Setup.md +++ b/doc/developer/Setup.md @@ -61,7 +61,7 @@ Any issues? Please let us know on our forums at: http://forum.mattermost.org 3. Reload your bashrc `source ~/.bashrc` 6. Install Node.js - 1. Download the newest version of the Node.js sources from https://nodejs.org/download/ + 1. Download the newest version of the Node.js sources from https://nodejs.org/en/download/ 2. Extract the contents of the package and cd into the extracted files 3. Compile and install Node.js `./configure` diff --git a/doc/install/Docker-Single-Container.md b/doc/install/Docker-Single-Container.md index 4b952cd710..7c0784ad0c 100644 --- a/doc/install/Docker-Single-Container.md +++ b/doc/install/Docker-Single-Container.md @@ -2,6 +2,13 @@ The following install instructions are for single-container installs of Mattermost using Docker for exploring product functionality and upgrading to newer versions. +### One-line Docker Install ### + +If you have Docker set up, Mattermost installs in one-line: +`docker run --name mattermost-dev -d --publish 8065:80 mattermost/platform` + +Otherwise, see step-by-step available: + ### Mac OSX ### 1. Install Docker Toolbox using instructions at: http://docs.docker.com/installation/mac/ diff --git a/doc/install/Troubleshooting.md b/doc/install/Troubleshooting.md index 8d82100d8c..b87663ab35 100644 --- a/doc/install/Troubleshooting.md +++ b/doc/install/Troubleshooting.md @@ -3,5 +3,5 @@ #### Important notes 1. **DO NOT manipulate the Mattermost database** - - In particular, DO NOT delete data from the database, as this will most likely crash Mattermost in strange ways. Mattermost is designed to archive content continously and generally assumes data is never deleted. + - In particular, DO NOT delete data from the database, as Mattermost is designed to stop working if data integrity has been compromised. The system is designed to archive content continously and generally assumes data is never deleted. diff --git a/model/client.go b/model/client.go index 77b0aaad28..eea65c50ea 100644 --- a/model/client.go +++ b/model/client.go @@ -185,7 +185,7 @@ func (c *Client) FindTeams(email string) (*Result, *AppError) { } else { return &Result{r.Header.Get(HEADER_REQUEST_ID), - r.Header.Get(HEADER_ETAG_SERVER), ArrayFromJson(r.Body)}, nil + r.Header.Get(HEADER_ETAG_SERVER), TeamMapFromJson(r.Body)}, nil } } @@ -844,6 +844,15 @@ func (c *Client) ListIncomingWebhooks() (*Result, *AppError) { } } +func (c *Client) GetAllPreferences() (*Result, *AppError) { + if r, err := c.DoApiGet("/preferences/", "", ""); err != nil { + return nil, err + } else { + preferences, _ := PreferencesFromJson(r.Body) + return &Result{r.Header.Get(HEADER_REQUEST_ID), r.Header.Get(HEADER_ETAG_SERVER), preferences}, nil + } +} + func (c *Client) SetPreferences(preferences *Preferences) (*Result, *AppError) { if r, err := c.DoApiPost("/preferences/save", preferences.ToJson()); err != nil { return nil, err diff --git a/model/team.go b/model/team.go index c0f6524cd5..584c78f8d8 100644 --- a/model/team.go +++ b/model/team.go @@ -219,3 +219,9 @@ func CleanTeamName(s string) string { func (o *Team) PreExport() { } + +func (o *Team) Sanitize() { + o.Email = "" + o.Type = "" + o.AllowedDomains = "" +} diff --git a/model/webhook.go b/model/webhook.go index 3bf034908a..9b9969b968 100644 --- a/model/webhook.go +++ b/model/webhook.go @@ -8,6 +8,11 @@ import ( "io" ) +const ( + DEFAULT_WEBHOOK_USERNAME = "webhook" + DEFAULT_WEBHOOK_ICON = "/static/images/webhook_icon.jpg" +) + type IncomingWebhook struct { Id string `json:"id"` CreateAt int64 `json:"create_at"` diff --git a/store/sql_preference_store.go b/store/sql_preference_store.go index 46cef38b15..bf6e030bfb 100644 --- a/store/sql_preference_store.go +++ b/store/sql_preference_store.go @@ -43,7 +43,7 @@ func (s SqlPreferenceStore) Save(preferences *model.Preferences) StoreChannel { result := StoreResult{} // wrap in a transaction so that if one fails, everything fails - transaction, err := s.GetReplica().Begin() + transaction, err := s.GetMaster().Begin() if err != nil { result.Err = model.NewAppError("SqlPreferenceStore.Save", "Unable to open transaction to save preferences", err.Error()) } else { @@ -212,3 +212,30 @@ func (s SqlPreferenceStore) GetCategory(userId string, category string) StoreCha return storeChannel } + +func (s SqlPreferenceStore) GetAll(userId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var preferences model.Preferences + + if _, err := s.GetReplica().Select(&preferences, + `SELECT + * + FROM + Preferences + WHERE + UserId = :UserId`, map[string]interface{}{"UserId": userId}); err != nil { + result.Err = model.NewAppError("SqlPreferenceStore.GetAll", "We encounted an error while finding preferences", err.Error()) + } else { + result.Data = preferences + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_preference_store_test.go b/store/sql_preference_store_test.go index 76b1bcb17e..e68203cc33 100644 --- a/store/sql_preference_store_test.go +++ b/store/sql_preference_store_test.go @@ -144,3 +144,51 @@ func TestPreferenceGetCategory(t *testing.T) { t.Fatal("shouldn't have got any preferences") } } + +func TestPreferenceGetAll(t *testing.T) { + Setup() + + userId := model.NewId() + category := model.PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW + name := model.NewId() + + preferences := model.Preferences{ + { + UserId: userId, + Category: category, + Name: name, + }, + // same user/category, different name + { + UserId: userId, + Category: category, + Name: model.NewId(), + }, + // same user/name, different category + { + UserId: userId, + Category: model.NewId(), + Name: name, + }, + // same name/category, different user + { + UserId: model.NewId(), + Category: category, + Name: name, + }, + } + + Must(store.Preference().Save(&preferences)) + + if result := <-store.Preference().GetAll(userId); result.Err != nil { + t.Fatal(result.Err) + } else if data := result.Data.(model.Preferences); len(data) != 3 { + t.Fatal("got the wrong number of preferences") + } else { + for i := 0; i < 3; i++ { + if data[0] != preferences[i] && data[1] != preferences[i] && data[2] != preferences[i] { + t.Fatal("got incorrect preferences") + } + } + } +} diff --git a/store/store.go b/store/store.go index b436a5c403..de335cc2b5 100644 --- a/store/store.go +++ b/store/store.go @@ -156,4 +156,5 @@ type PreferenceStore interface { Save(preferences *model.Preferences) StoreChannel Get(userId string, category string, name string) StoreChannel GetCategory(userId string, category string) StoreChannel + GetAll(userId string) StoreChannel } diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index 6203e567a8..2581bdccae 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -16,6 +16,7 @@ const Utils = require('../utils/utils.jsx'); const Constants = require('../utils/constants.jsx'); const ActionTypes = Constants.ActionTypes; +const KeyCodes = Constants.KeyCodes; export default class CreatePost extends React.Component { constructor(props) { @@ -35,6 +36,7 @@ export default class CreatePost extends React.Component { this.removePreview = this.removePreview.bind(this); this.onChange = this.onChange.bind(this); this.getFileCount = this.getFileCount.bind(this); + this.handleArrowUp = this.handleArrowUp.bind(this); PostStore.clearDraftUploads(); @@ -172,7 +174,7 @@ export default class CreatePost extends React.Component { } } postMsgKeyPress(e) { - if (e.which === 13 && !e.shiftKey && !e.altKey) { + if (e.which === KeyCodes.ENTER && !e.shiftKey && !e.altKey) { e.preventDefault(); ReactDOM.findDOMNode(this.refs.textbox).blur(); this.handleSubmit(e); @@ -292,6 +294,27 @@ export default class CreatePost extends React.Component { const draft = PostStore.getDraft(channelId); return draft.previews.length + draft.uploadsInProgress.length; } + handleArrowUp(e) { + if (e.keyCode === KeyCodes.UP && this.state.messageText === '') { + e.preventDefault(); + + const channelId = ChannelStore.getCurrentId(); + const lastPost = PostStore.getCurrentUsersLatestPost(channelId); + if (!lastPost) { + return; + } + var type = (lastPost.root_id && lastPost.root_id.length > 0) ? 'Comment' : 'Post'; + + AppDispatcher.handleViewAction({ + type: ActionTypes.RECIEVED_EDIT_POST, + refocusId: '#post_textbox', + title: type, + message: lastPost.message, + postId: lastPost.id, + channelId: lastPost.channel_id + }); + } + } render() { let serverError = null; if (this.state.serverError) { @@ -336,6 +359,7 @@ export default class CreatePost extends React.Component { { + $(self.state.refocusId).get(0).focus(); + }); + } + }); + + PostStore.addEditPostListener(this.handleEditPostEvent); + } + componentWillUnmount() { + PostStore.removeEditPostListener(this.handleEditPostEvent); } render() { var error = (

); diff --git a/web/react/components/navbar_dropdown.jsx b/web/react/components/navbar_dropdown.jsx index 33a7bf26d8..2b2cc2f950 100644 --- a/web/react/components/navbar_dropdown.jsx +++ b/web/react/components/navbar_dropdown.jsx @@ -11,7 +11,24 @@ var AboutBuildModal = require('./about_build_modal.jsx'); var Constants = require('../utils/constants.jsx'); function getStateFromStores() { - return {teams: UserStore.getTeams()}; + let teams = []; + let teamsObject = UserStore.getTeams(); + for (let teamId in teamsObject) { + if (teamsObject.hasOwnProperty(teamId)) { + teams.push(teamsObject[teamId]); + } + } + teams.sort(function sortByDisplayName(teamA, teamB) { + let teamADisplayName = teamA.display_name.toLowerCase(); + let teamBDisplayName = teamB.display_name.toLowerCase(); + if (teamADisplayName < teamBDisplayName) { + return -1; + } else if (teamADisplayName > teamBDisplayName) { + return 1; + } + return 0; + }); + return {teams}; } export default class NavbarDropdown extends React.Component { @@ -154,9 +171,9 @@ export default class NavbarDropdown extends React.Component { ); - this.state.teams.forEach((teamName) => { - if (teamName !== this.props.teamName) { - teams.push(
  • {'Switch to ' + teamName}
  • ); + this.state.teams.forEach((team) => { + if (team.name !== this.props.teamName) { + teams.push(
  • {'Switch to ' + team.display_name}
  • ); } }); } diff --git a/web/react/components/post_list.jsx b/web/react/components/post_list.jsx index 7e5160c2b9..29728d3684 100644 --- a/web/react/components/post_list.jsx +++ b/web/react/components/post_list.jsx @@ -4,6 +4,7 @@ var PostStore = require('../stores/post_store.jsx'); var ChannelStore = require('../stores/channel_store.jsx'); var UserStore = require('../stores/user_store.jsx'); +var PreferenceStore = require('../stores/preference_store.jsx'); var UserProfile = require('./user_profile.jsx'); var AsyncClient = require('../utils/async_client.jsx'); var Post = require('./post.jsx'); @@ -105,6 +106,7 @@ export default class PostList extends React.Component { PostStore.clearUnseenDeletedPosts(this.props.channelId); PostStore.addChangeListener(this.onChange); UserStore.addStatusesChangeListener(this.onTimeChange); + PreferenceStore.addChangeListener(this.onTimeChange); SocketStore.addChangeListener(this.onSocketChange); const postHolder = $(ReactDOM.findDOMNode(this.refs.postlist)); @@ -156,6 +158,7 @@ export default class PostList extends React.Component { PostStore.removeChangeListener(this.onChange); UserStore.removeStatusesChangeListener(this.onTimeChange); SocketStore.removeChangeListener(this.onSocketChange); + PreferenceStore.removeChangeListener(this.onTimeChange); $('body').off('click.userpopover'); $(window).off('resize'); var postHolder = $(ReactDOM.findDOMNode(this.refs.postlist)); diff --git a/web/react/components/rhs_thread.jsx b/web/react/components/rhs_thread.jsx index 131253aa51..467d74681b 100644 --- a/web/react/components/rhs_thread.jsx +++ b/web/react/components/rhs_thread.jsx @@ -3,6 +3,7 @@ var PostStore = require('../stores/post_store.jsx'); var UserStore = require('../stores/user_store.jsx'); +var PreferenceStore = require('../stores/preference_store.jsx'); var utils = require('../utils/utils.jsx'); var SearchBox = require('./search_bar.jsx'); var CreateComment = require('./create_comment.jsx'); @@ -18,6 +19,7 @@ export default class RhsThread extends React.Component { this.onChange = this.onChange.bind(this); this.onChangeAll = this.onChangeAll.bind(this); + this.forceUpdateInfo = this.forceUpdateInfo.bind(this); this.state = this.getStateFromStores(); } @@ -43,6 +45,7 @@ export default class RhsThread extends React.Component { componentDidMount() { PostStore.addSelectedPostChangeListener(this.onChange); PostStore.addChangeListener(this.onChangeAll); + PreferenceStore.addChangeListener(this.forceUpdateInfo); this.resize(); $(window).resize(function resize() { this.resize(); @@ -57,6 +60,16 @@ export default class RhsThread extends React.Component { componentWillUnmount() { PostStore.removeSelectedPostChangeListener(this.onChange); PostStore.removeChangeListener(this.onChangeAll); + PreferenceStore.removeChangeListener(this.forceUpdateInfo); + } + forceUpdateInfo() { + if (this.state.postList) { + for (var postId in this.state.postList.posts) { + if (this.refs[postId]) { + this.refs[postId].forceUpdate(); + } + } + } } onChange() { var newState = this.getStateFromStores(); @@ -174,6 +187,7 @@ export default class RhsThread extends React.Component { />
    diff --git a/web/react/components/sidebar.jsx b/web/react/components/sidebar.jsx index 5561c5b73b..2c98db7f33 100644 --- a/web/react/components/sidebar.jsx +++ b/web/react/components/sidebar.jsx @@ -132,11 +132,7 @@ export default class Sidebar extends React.Component { SocketStore.addChangeListener(this.onSocketChange); PreferenceStore.addChangeListener(this.onChange); - AsyncClient.getDirectChannelPreferences(); - - if ($(window).width() > 768) { - $('.nav-pills__container').perfectScrollbar(); - } + $('.nav-pills__container').perfectScrollbar(); this.updateTitle(); this.updateUnreadIndicators(); diff --git a/web/react/components/textbox.jsx b/web/react/components/textbox.jsx index d51fb95239..86bb42f62c 100644 --- a/web/react/components/textbox.jsx +++ b/web/react/components/textbox.jsx @@ -9,6 +9,7 @@ const ErrorStore = require('../stores/error_store.jsx'); const Utils = require('../utils/utils.jsx'); const Constants = require('../utils/constants.jsx'); const ActionTypes = Constants.ActionTypes; +const KeyCodes = Constants.KeyCodes; export default class Textbox extends React.Component { constructor(props) { @@ -148,8 +149,10 @@ export default class Textbox extends React.Component { this.doProcessMentions = true; } - if (e.keyCode === 8) { + if (e.keyCode === KeyCodes.BACKSPACE) { this.handleBackspace(e); + } else if (this.props.onKeyDown) { + this.props.onKeyDown(e); } } @@ -318,5 +321,6 @@ Textbox.propTypes = { onUserInput: React.PropTypes.func.isRequired, onKeyPress: React.PropTypes.func.isRequired, onHeightChange: React.PropTypes.func, - createMessage: React.PropTypes.string.isRequired + createMessage: React.PropTypes.string.isRequired, + onKeyDown: React.PropTypes.func }; diff --git a/web/react/components/user_settings/user_settings.jsx b/web/react/components/user_settings/user_settings.jsx index 5ce9b6330a..15bf961d64 100644 --- a/web/react/components/user_settings/user_settings.jsx +++ b/web/react/components/user_settings/user_settings.jsx @@ -9,6 +9,7 @@ var GeneralTab = require('./user_settings_general.jsx'); var AppearanceTab = require('./user_settings_appearance.jsx'); var DeveloperTab = require('./user_settings_developer.jsx'); var IntegrationsTab = require('./user_settings_integrations.jsx'); +var DisplayTab = require('./user_settings_display.jsx'); export default class UserSettings extends React.Component { constructor(props) { @@ -98,6 +99,17 @@ export default class UserSettings extends React.Component { />
    ); + } else if (this.props.activeTab === 'display') { + return ( +
    + +
    + ); } return
    ; diff --git a/web/react/components/user_settings/user_settings_display.jsx b/web/react/components/user_settings/user_settings_display.jsx new file mode 100644 index 0000000000..ec209c2187 --- /dev/null +++ b/web/react/components/user_settings/user_settings_display.jsx @@ -0,0 +1,168 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import { savePreferences } from '../../utils/client.jsx'; +import SettingItemMin from '../setting_item_min.jsx'; +import SettingItemMax from '../setting_item_max.jsx'; +import Constants from '../../utils/constants.jsx'; +import PreferenceStore from '../../stores/preference_store.jsx'; + +function getDisplayStateFromStores() { + const militaryTime = PreferenceStore.getPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', {value: 'false'}); + + return {militaryTime: militaryTime.value}; +} + +export default class UserSettingsDisplay extends React.Component { + constructor(props) { + super(props); + + this.handleSubmit = this.handleSubmit.bind(this); + this.handleClockRadio = this.handleClockRadio.bind(this); + this.updateSection = this.updateSection.bind(this); + this.handleClose = this.handleClose.bind(this); + + this.state = getDisplayStateFromStores(); + } + handleSubmit() { + const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', this.state.militaryTime); + + savePreferences([preference], + () => { + PreferenceStore.emitChange(); + this.updateSection(''); + }, + (err) => { + this.setState({serverError: err.message}); + } + ); + } + handleClockRadio(militaryTime) { + this.setState({militaryTime: militaryTime}); + } + updateSection(section) { + this.setState(getDisplayStateFromStores()); + this.props.updateSection(section); + } + handleClose() { + this.updateSection(''); + } + componentDidMount() { + $('#user_settings').on('hidden.bs.modal', this.handleClose); + } + componentWillUnmount() { + $('#user_settings').off('hidden.bs.modal', this.handleClose); + } + render() { + const serverError = this.state.serverError || null; + let clockSection; + if (this.props.activeSection === 'clock') { + let clockFormat = [false, false]; + if (this.state.militaryTime === 'true') { + clockFormat[1] = true; + } else { + clockFormat[0] = true; + } + + const handleUpdateClockSection = (e) => { + this.updateSection(''); + e.preventDefault(); + }; + + const inputs = [ +
    +
    + +
    +
    +
    + +
    +
    +

    {'Select how you prefer time displayed.'}
    +
    + ]; + + + clockSection = ( + + ); + } else { + let describe = ''; + if (this.state.militaryTime === 'true') { + describe = '24-hour clock (example: 16:00)'; + } else { + describe = '12-hour clock (example: 4:00 PM)'; + } + + const handleUpdateClockSection = () => { + this.props.updateSection('clock'); + }; + + clockSection = ( + + ); + } + + return ( +
    +
    + +

    + + {'Display Settings'} +

    +
    +
    +

    {'Display Settings'}

    +
    + {clockSection} +
    +
    +
    + ); + } +} + +UserSettingsDisplay.propTypes = { + user: React.PropTypes.object, + updateSection: React.PropTypes.func, + updateTab: React.PropTypes.func, + activeSection: React.PropTypes.string +}; diff --git a/web/react/components/user_settings/user_settings_modal.jsx b/web/react/components/user_settings/user_settings_modal.jsx index fbb003fd51..4296d7909b 100644 --- a/web/react/components/user_settings/user_settings_modal.jsx +++ b/web/react/components/user_settings/user_settings_modal.jsx @@ -41,6 +41,7 @@ export default class UserSettingsModal extends React.Component { if (global.window.mm_config.EnableIncomingWebhooks === 'true') { tabs.push({name: 'integrations', uiName: 'Integrations', icon: 'glyphicon glyphicon-transfer'}); } + tabs.push({name: 'display', uiName: 'Display', icon: 'glyphicon glyphicon-eye-open'}); return (
    , diff --git a/web/react/stores/post_store.jsx b/web/react/stores/post_store.jsx index 07e8b461e7..b073ca68de 100644 --- a/web/react/stores/post_store.jsx +++ b/web/react/stores/post_store.jsx @@ -6,6 +6,7 @@ var EventEmitter = require('events').EventEmitter; var ChannelStore = require('../stores/channel_store.jsx'); var BrowserStore = require('../stores/browser_store.jsx'); +var UserStore = require('../stores/user_store.jsx'); var Constants = require('../utils/constants.jsx'); var ActionTypes = Constants.ActionTypes; @@ -16,6 +17,7 @@ var SEARCH_TERM_CHANGE_EVENT = 'search_term_change'; var SELECTED_POST_CHANGE_EVENT = 'selected_post_change'; var MENTION_DATA_CHANGE_EVENT = 'mention_data_change'; var ADD_MENTION_EVENT = 'add_mention'; +var EDIT_POST_EVENT = 'edit_post'; class PostStoreClass extends EventEmitter { constructor() { @@ -75,6 +77,10 @@ class PostStoreClass extends EventEmitter { this.clearCommentDraftUploads = this.clearCommentDraftUploads.bind(this); this.storeLatestUpdate = this.storeLatestUpdate.bind(this); this.getLatestUpdate = this.getLatestUpdate.bind(this); + this.emitEditPost = this.emitEditPost.bind(this); + this.addEditPostListener = this.addEditPostListener.bind(this); + this.removeEditPostListener = this.removeEditPostListener.bind(this); + this.getCurrentUsersLatestPost = this.getCurrentUsersLatestPost.bind(this); } emitChange() { this.emit(CHANGE_EVENT); @@ -148,6 +154,18 @@ class PostStoreClass extends EventEmitter { this.removeListener(ADD_MENTION_EVENT, callback); } + emitEditPost(post) { + this.emit(EDIT_POST_EVENT, post); + } + + addEditPostListener(callback) { + this.on(EDIT_POST_EVENT, callback); + } + + removeEditPostListener(callback) { + this.removeListener(EDIT_POST_EVENT, callback); + } + getCurrentPosts() { var currentId = ChannelStore.getCurrentId(); @@ -212,6 +230,22 @@ class PostStoreClass extends EventEmitter { getPosts(channelId) { return BrowserStore.getItem('posts_' + channelId); } + getCurrentUsersLatestPost(channelId) { + const userId = UserStore.getCurrentId(); + var postList = makePostListNonNull(this.getPosts(channelId)); + var i = 0; + var len = postList.order.length; + var lastPost = null; + + for (i; i < len; i++) { + if (postList.posts[postList.order[i]].user_id === userId) { + lastPost = postList.posts[postList.order[i]]; + break; + } + } + + return lastPost; + } storePost(post) { this.pStorePost(post); this.emitChange(); @@ -446,6 +480,9 @@ PostStore.dispatchToken = AppDispatcher.register(function registry(payload) { case ActionTypes.RECIEVED_ADD_MENTION: PostStore.emitAddMention(action.id, action.username); break; + case ActionTypes.RECIEVED_EDIT_POST: + PostStore.emitEditPost(action); + break; default: } }); diff --git a/web/react/stores/preference_store.jsx b/web/react/stores/preference_store.jsx index d71efa10fc..f630d150de 100644 --- a/web/react/stores/preference_store.jsx +++ b/web/react/stores/preference_store.jsx @@ -120,3 +120,4 @@ class PreferenceStoreClass extends EventEmitter { const PreferenceStore = new PreferenceStoreClass(); export default PreferenceStore; +window.PreferenceStore = PreferenceStore; diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx index 379c2fc19b..fb76311594 100644 --- a/web/react/utils/async_client.jsx +++ b/web/react/utils/async_client.jsx @@ -639,16 +639,15 @@ export function getMyTeam() { ); } -export function getDirectChannelPreferences() { - if (isCallInProgress('getDirectChannelPreferences')) { +export function getAllPreferences() { + if (isCallInProgress('getAllPreferences')) { return; } - callTracker.getDirectChannelPreferences = utils.getTimestamp(); - client.getPreferenceCategory( - Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, + callTracker.getAllPreferences = utils.getTimestamp(); + client.getAllPreferences( (data, textStatus, xhr) => { - callTracker.getDirectChannelPreferences = 0; + callTracker.getAllPreferences = 0; if (xhr.status === 304 || !data) { return; @@ -660,8 +659,8 @@ export function getDirectChannelPreferences() { }); }, (err) => { - callTracker.getDirectChannelPreferences = 0; - dispatchError(err, 'getDirectChannelPreferences'); + callTracker.getAllPreferences = 0; + dispatchError(err, 'getAllPreferences'); } ); } diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index f1df2a7867..3130e92774 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -1142,6 +1142,19 @@ export function listIncomingHooks(success, error) { }); } +export function getAllPreferences(success, error) { + $.ajax({ + url: `/api/v1/preferences/`, + dataType: 'json', + type: 'GET', + success, + error: (xhr, status, err) => { + var e = handleError('getAllPreferences', xhr, status, err); + error(e); + } + }); +} + export function getPreferenceCategory(category, success, error) { $.ajax({ url: `/api/v1/preferences/${category}`, diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index a7b0b159b6..d6da91fe25 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -17,6 +17,7 @@ module.exports = { RECIEVED_POSTS: null, RECIEVED_POST: null, + RECIEVED_EDIT_POST: null, RECIEVED_SEARCH: null, RECIEVED_POST_SELECTED: null, RECIEVED_MENTION_DATA: null, @@ -287,6 +288,16 @@ module.exports = { } ], Preferences: { - CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show' + CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show', + CATEGORY_DISPLAY_SETTINGS: 'display_settings' + }, + KeyCodes: { + UP: 38, + DOWN: 40, + LEFT: 37, + RIGHT: 39, + BACKSPACE: 8, + ENTER: 13, + ESCAPE: 27 } }; diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 0457d620fe..561c2c4c43 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -4,6 +4,7 @@ var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); var ChannelStore = require('../stores/channel_store.jsx'); var UserStore = require('../stores/user_store.jsx'); +var PreferenceStore = require('../stores/preference_store.jsx'); var TeamStore = require('../stores/team_store.jsx'); var Constants = require('../utils/constants.jsx'); var ActionTypes = Constants.ActionTypes; @@ -164,23 +165,29 @@ export function displayDate(ticks) { } export function displayTime(ticks) { - var d = new Date(ticks); - var hours = d.getHours(); - var minutes = d.getMinutes(); + const d = new Date(ticks); + let hours = d.getHours(); + let minutes = d.getMinutes(); + let ampm = ''; - var ampm = 'AM'; - if (hours >= 12) { - ampm = 'PM'; - } - - hours = hours % 12; - if (!hours) { - hours = '12'; - } if (minutes <= 9) { minutes = '0' + minutes; } - return hours + ':' + minutes + ' ' + ampm; + + const useMilitaryTime = PreferenceStore.getPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', {value: 'false'}).value; + if (useMilitaryTime === 'false') { + ampm = ' AM'; + if (hours >= 12) { + ampm = ' PM'; + } + + hours = hours % 12; + if (!hours) { + hours = '12'; + } + } + + return hours + ':' + minutes + ampm; } export function displayDateTime(ticks) { diff --git a/web/sass-files/sass/partials/_post_right.scss b/web/sass-files/sass/partials/_post_right.scss index 9557d75704..91f9355de9 100644 --- a/web/sass-files/sass/partials/_post_right.scss +++ b/web/sass-files/sass/partials/_post_right.scss @@ -93,7 +93,8 @@ .post-right__scroll { position: relative; - overflow: auto; + overflow: scroll; + -webkit-overflow-scrolling: touch; } .post-right-comment-time { diff --git a/web/sass-files/sass/partials/_search.scss b/web/sass-files/sass/partials/_search.scss index ca90ec46d1..2f15a445f1 100644 --- a/web/sass-files/sass/partials/_search.scss +++ b/web/sass-files/sass/partials/_search.scss @@ -67,8 +67,9 @@ } .search-items-container { - overflow: auto; position: relative; + overflow: scroll; + -webkit-overflow-scrolling: touch; } .search-results-header { diff --git a/web/sass-files/sass/partials/_sidebar--left.scss b/web/sass-files/sass/partials/_sidebar--left.scss index 831c19cffc..585a51f080 100644 --- a/web/sass-files/sass/partials/_sidebar--left.scss +++ b/web/sass-files/sass/partials/_sidebar--left.scss @@ -47,7 +47,8 @@ .nav-pills__container { height: 100%; position: relative; - overflow: auto; + overflow: scroll; + -webkit-overflow-scrolling: touch; } .nav-pills__unread-indicator { diff --git a/web/static/images/webhook_icon.jpg b/web/static/images/webhook_icon.jpg new file mode 100644 index 0000000000..af5303421d Binary files /dev/null and b/web/static/images/webhook_icon.jpg differ diff --git a/web/web.go b/web/web.go index 1f7c3ae5e5..83b8c7f343 100644 --- a/web/web.go +++ b/web/web.go @@ -1031,12 +1031,20 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) { post := &model.Post{UserId: hook.UserId, ChannelId: channel.Id, Message: text} post.AddProp("from_webhook", "true") - if len(overrideUsername) != 0 && utils.Cfg.ServiceSettings.EnablePostUsernameOverride { - post.AddProp("override_username", overrideUsername) + if utils.Cfg.ServiceSettings.EnablePostUsernameOverride { + if len(overrideUsername) != 0 { + post.AddProp("override_username", overrideUsername) + } else { + post.AddProp("override_username", model.DEFAULT_WEBHOOK_USERNAME) + } } - if len(overrideIconUrl) != 0 && utils.Cfg.ServiceSettings.EnablePostIconOverride { - post.AddProp("override_icon_url", overrideIconUrl) + if utils.Cfg.ServiceSettings.EnablePostIconOverride { + if len(overrideIconUrl) != 0 { + post.AddProp("override_icon_url", overrideIconUrl) + } else { + post.AddProp("override_icon_url", model.DEFAULT_WEBHOOK_ICON) + } } if !c.HasPermissionsToChannel(pchan, "createIncomingHook") && channel.Type != model.CHANNEL_OPEN {