diff options
-rw-r--r-- | api/api.go | 1 | ||||
-rw-r--r-- | api/preference.go | 121 | ||||
-rw-r--r-- | api/preference_test.go | 201 | ||||
-rw-r--r-- | doc/developer/Code-Contribution-Guidelines.md | 4 | ||||
-rw-r--r-- | doc/help/Slack-Import.md | 14 | ||||
-rw-r--r-- | doc/integrations/webhooks/Incoming-Webhooks.md | 2 | ||||
-rw-r--r-- | model/client.go | 26 | ||||
-rw-r--r-- | model/preference.go | 60 | ||||
-rw-r--r-- | model/preference_test.go | 56 | ||||
-rw-r--r-- | model/preferences.go | 31 | ||||
-rw-r--r-- | store/sql_preference_store.go | 214 | ||||
-rw-r--r-- | store/sql_preference_store_test.go | 146 | ||||
-rw-r--r-- | store/sql_store.go | 30 | ||||
-rw-r--r-- | store/store.go | 7 | ||||
-rw-r--r-- | web/react/components/more_direct_channels.jsx | 59 | ||||
-rw-r--r-- | web/react/components/sidebar.jsx | 248 | ||||
-rw-r--r-- | web/react/components/team_import_tab.jsx | 6 | ||||
-rw-r--r-- | web/react/stores/preference_store.jsx | 122 | ||||
-rw-r--r-- | web/react/utils/async_client.jsx | 52 | ||||
-rw-r--r-- | web/react/utils/client.jsx | 28 | ||||
-rw-r--r-- | web/react/utils/constants.jsx | 6 | ||||
-rw-r--r-- | web/react/utils/markdown.jsx | 38 | ||||
-rw-r--r-- | web/react/utils/text_formatting.jsx | 9 |
23 files changed, 1283 insertions, 198 deletions
diff --git a/api/api.go b/api/api.go index 5c3c0d8c6..4da1de62d 100644 --- a/api/api.go +++ b/api/api.go @@ -45,6 +45,7 @@ func InitApi() { InitAdmin(r) InitOAuth(r) InitWebhook(r) + InitPreference(r) templatesDir := utils.FindDir("api/templates") l4g.Debug("Parsing server templates at %v", templatesDir) diff --git a/api/preference.go b/api/preference.go new file mode 100644 index 000000000..88cb132f8 --- /dev/null +++ b/api/preference.go @@ -0,0 +1,121 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package api + +import ( + l4g "code.google.com/p/log4go" + "github.com/gorilla/mux" + "github.com/mattermost/platform/model" + "net/http" +) + +func InitPreference(r *mux.Router) { + l4g.Debug("Initializing preference api routes") + + sr := r.PathPrefix("/preferences").Subrouter() + 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 savePreferences(c *Context, w http.ResponseWriter, r *http.Request) { + preferences, err := model.PreferencesFromJson(r.Body) + if err != nil { + c.Err = model.NewAppError("savePreferences", "Unable to decode preferences from request", err.Error()) + c.Err.StatusCode = http.StatusBadRequest + return + } + + for _, preference := range preferences { + if c.Session.UserId != preference.UserId { + c.Err = model.NewAppError("savePreferences", "Unable to set preferences for other user", "session.user_id="+c.Session.UserId+", preference.user_id="+preference.UserId) + c.Err.StatusCode = http.StatusUnauthorized + return + } + } + + if result := <-Srv.Store.Preference().Save(&preferences); result.Err != nil { + c.Err = result.Err + return + } + + w.Write([]byte("true")) +} + +func getPreferenceCategory(c *Context, w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + category := params["category"] + + if result := <-Srv.Store.Preference().GetCategory(c.Session.UserId, category); result.Err != nil { + c.Err = result.Err + } 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"] + name := params["name"] + + if result := <-Srv.Store.Preference().Get(c.Session.UserId, category, name); result.Err != nil { + c.Err = result.Err + } else { + data := result.Data.(model.Preference) + w.Write([]byte(data.ToJson())) + } +} diff --git a/api/preference_test.go b/api/preference_test.go new file mode 100644 index 000000000..318ce9582 --- /dev/null +++ b/api/preference_test.go @@ -0,0 +1,201 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package api + +import ( + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/store" + "testing" +) + +func TestSetPreferences(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") + + // save 10 preferences + var preferences model.Preferences + for i := 0; i < 10; i++ { + preference := model.Preference{ + UserId: user1.Id, + Category: model.PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW, + Name: model.NewId(), + } + preferences = append(preferences, preference) + } + + if _, err := Client.SetPreferences(&preferences); err != nil { + t.Fatal(err) + } + + // update 10 preferences + for _, preference := range preferences { + preference.Value = "1234garbage" + } + + if _, err := Client.SetPreferences(&preferences); err != nil { + t.Fatal(err) + } + + // not able to update as a different user + 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)) + + Client.LoginByEmail(team.Name, user2.Email, "pwd") + + if _, err := Client.SetPreferences(&preferences); err == nil { + t.Fatal("shouldn't have been able to update another user's preferences") + } +} + +func TestGetPreferenceCategory(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)) + + Client.LoginByEmail(team.Name, user1.Email, "pwd") + + if result, err := Client.GetPreferenceCategory(category); err != nil { + t.Fatal(err) + } else if data := result.Data.(model.Preferences); len(data) != 2 { + 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])) { + t.Fatal("received incorrect preferences") + } + + Client.LoginByEmail(team.Name, user2.Email, "pwd") + + if result, err := Client.GetPreferenceCategory(category); err != nil { + t.Fatal(err) + } else if data := result.Data.(model.Preferences); len(data) != 0 { + t.Fatal("received the wrong number of preferences") + } +} + +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() + + 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) + + user := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"} + user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User) + store.Must(Srv.Store.User().VerifyEmail(user.Id)) + + Client.LoginByEmail(team.Name, user.Email, "pwd") + + preferences := model.Preferences{ + { + UserId: user.Id, + Category: model.PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW, + Name: model.NewId(), + Value: model.NewId(), + }, + } + + Client.Must(Client.SetPreferences(&preferences)) + + if result, err := Client.GetPreference(preferences[0].Category, preferences[0].Name); err != nil { + t.Fatal(err) + } else if data := result.Data.(*model.Preference); *data != preferences[0] { + t.Fatal("preference saved incorrectly") + } + + preferences[0].Value = model.NewId() + Client.Must(Client.SetPreferences(&preferences)) + + if result, err := Client.GetPreference(preferences[0].Category, preferences[0].Name); err != nil { + t.Fatal(err) + } else if data := result.Data.(*model.Preference); *data != preferences[0] { + t.Fatal("preference updated incorrectly") + } +} diff --git a/doc/developer/Code-Contribution-Guidelines.md b/doc/developer/Code-Contribution-Guidelines.md index 453c154f9..48bbf2491 100644 --- a/doc/developer/Code-Contribution-Guidelines.md +++ b/doc/developer/Code-Contribution-Guidelines.md @@ -35,11 +35,11 @@ git checkout -b <branch name> 1. Please add yourself to the Mattermost [approved contributor list](https://docs.google.com/spreadsheets/d/1NTCeG-iL_VS9bFqtmHSfwETo5f-8MQ7oMDE5IUYJi_Y/pubhtml?gid=0&single=true) prior to submitting by completing the [contributor license agreement](http://www.mattermost.org/mattermost-contributor-agreement/). -2. When you submit your pull request please include the Ticket ID at the beginning of your pull request comment, followed by a colon. +2. When you submit your pull request please make it against `master` and include the Ticket ID at the beginning of your pull request comment, followed by a colon. For example, for a ticket ID `PLT-394` start your comment with: `PLT-394:`. See [previously closed pull requests](https://github.com/mattermost/platform/pulls?q=is%3Apr+is%3Aclosed) for examples. -3. Once submitted, your pull request will be checked via an automated build process and will be reviewed by the Mattermost core team, who may either accept the PR or follow-up with feedback. +3. Once submitted, your pull request will be checked via an automated build process and will be reviewed by at least two members of the Mattermost core team, who may either accept the PR or follow-up with feedback. It would then get merged into `master` for the next release. 4. If you've included your mailing address in Step 1, you'll be receiving a [Limited Edition Mattermost Mug](http://forum.mattermost.org/t/limited-edition-mattermost-mugs/143) as a thank you gift after your first pull request has been accepted. diff --git a/doc/help/Slack-Import.md b/doc/help/Slack-Import.md index a3b672b04..a2914570b 100644 --- a/doc/help/Slack-Import.md +++ b/doc/help/Slack-Import.md @@ -1,18 +1,22 @@ -#### Slack Import (Beta) +### Slack Import (Beta) *Note: As a SaaS service, Slack is able to change its export format quickly. If you encounter issues not mentioned in the documentation below, please let us know by [filing an issue](https://github.com/mattermost/platform/issues).* +#### Usage + The Slack Import feature in Mattermost is in "Beta" and focus is on supporting migration of teams of less than 100 registered users. The feature can be accessed from by Team Administrators and Team Owners via the `Team Settings -> Import` menu option. +- **It is highly recommended that you test Slack import before applying it to an instance intended for production.** If you use Docker, you can spin up a test instance in one line (`docker run --name mattermost-dev -d --publish 8065:80 mattermost/platform`). If you don't use Docker, there are [step-by-step instructions to install Mattermost in preview mode in less than 5 minutes](../install/Docker-Single-Container.md). + Mattermost currently supports the processing of an "Export" file from Slack containing account information and public channel archives from a Slack team. -- In the feature preview, emails and usernames from Slack are used to create new Mattermost accounts, connected to messages history in imported Slack channels. Users can activate these accounts and by going to the Password Reset screen in Mattermost to set new credentials. -- Once logged in, users will have access to previous Slack messages shared in public channels, now imported to Mattermost. +- Emails and usernames from Slack are used to create new Mattermost accounts, connected to messages history in imported Slack channels. Users can activate these accounts and by going to the Password Reset screen in Mattermost to set new credentials. +- Once logged in, users will have access to previous Slack messages shared in public channels, now imported to Mattermost. -Limitations: +#### Limitations: - Newly added markdown suppport in Slack's Posts 2.0 feature announced on September 28, 2015 is not yet supported. - Slack does not export files or images your team has stored in Slack's database. Mattermost will provide links to the location of your assets in Slack's web UI. - Slack does not export any content from private groups or direct messages that your team has stored in Slack's database. -- The Preview release of Slack Import does not offer pre-checks or roll-back and will not import Slack accounts with username or email address collisions with existing Mattermost accounts. Also, Slack channel names with underscores will not import. Also, mentions do not yet resolve as Mattermost usernames (still shows Slack ID). +- The Beta release of Slack Import does not offer pre-checks or roll-back and will not import Slack accounts with username or email address collisions with existing Mattermost accounts. Also, Slack channel names with underscores will not import. Also, mentions do not yet resolve as Mattermost usernames (still shows Slack ID). diff --git a/doc/integrations/webhooks/Incoming-Webhooks.md b/doc/integrations/webhooks/Incoming-Webhooks.md index f01db90a4..c6323a24a 100644 --- a/doc/integrations/webhooks/Incoming-Webhooks.md +++ b/doc/integrations/webhooks/Incoming-Webhooks.md @@ -88,5 +88,5 @@ As mentioned above, Mattermost makes it easy to take integrations written for Sl 3. Like Slack, by overriding the channel name with an @username, such as payload={"text": "Hi", channel: "@jim"}, you can send the message to a user through your direct message chat. 4. Channel names can be prepended with a #, like they are in Slack incoming webhooks, and the message will still be sent to the correct channel. -To learn more about Incoming Webhooks and to see samples and community contributions, please visit <http://mattermost.org/webhooks> +To see samples and community contributions, please visit <http://mattermost.org/webhooks>. diff --git a/model/client.go b/model/client.go index 11beb9a87..77b0aaad2 100644 --- a/model/client.go +++ b/model/client.go @@ -844,6 +844,32 @@ func (c *Client) ListIncomingWebhooks() (*Result, *AppError) { } } +func (c *Client) SetPreferences(preferences *Preferences) (*Result, *AppError) { + if r, err := c.DoApiPost("/preferences/save", preferences.ToJson()); err != nil { + return nil, err + } else { + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), preferences}, nil + } +} + +func (c *Client) GetPreference(category string, name string) (*Result, *AppError) { + if r, err := c.DoApiGet("/preferences/"+category+"/"+name, "", ""); err != nil { + return nil, err + } else { + return &Result{r.Header.Get(HEADER_REQUEST_ID), r.Header.Get(HEADER_ETAG_SERVER), PreferenceFromJson(r.Body)}, nil + } +} + +func (c *Client) GetPreferenceCategory(category string) (*Result, *AppError) { + if r, err := c.DoApiGet("/preferences/"+category, "", ""); 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) MockSession(sessionToken string) { c.AuthToken = sessionToken c.AuthType = HEADER_BEARER diff --git a/model/preference.go b/model/preference.go new file mode 100644 index 000000000..44279f71a --- /dev/null +++ b/model/preference.go @@ -0,0 +1,60 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +const ( + PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW = "direct_channel_show" +) + +type Preference struct { + UserId string `json:"user_id"` + Category string `json:"category"` + Name string `json:"name"` + Value string `json:"value"` +} + +func (o *Preference) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func PreferenceFromJson(data io.Reader) *Preference { + decoder := json.NewDecoder(data) + var o Preference + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} + +func (o *Preference) IsValid() *AppError { + if len(o.UserId) != 26 { + return NewAppError("Preference.IsValid", "Invalid user id", "user_id="+o.UserId) + } + + if len(o.Category) == 0 || len(o.Category) > 32 { + return NewAppError("Preference.IsValid", "Invalid category", "category="+o.Category) + } + + if len(o.Name) == 0 || len(o.Name) > 32 { + return NewAppError("Preference.IsValid", "Invalid name", "name="+o.Name) + } + + if len(o.Value) > 128 { + return NewAppError("Preference.IsValid", "Value is too long", "value="+o.Value) + } + + return nil +} diff --git a/model/preference_test.go b/model/preference_test.go new file mode 100644 index 000000000..66b7ac50b --- /dev/null +++ b/model/preference_test.go @@ -0,0 +1,56 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "strings" + "testing" +) + +func TestPreferenceIsValid(t *testing.T) { + preference := Preference{ + UserId: "1234garbage", + Category: PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW, + Name: NewId(), + } + + if err := preference.IsValid(); err == nil { + t.Fatal() + } + + preference.UserId = NewId() + if err := preference.IsValid(); err != nil { + t.Fatal(err) + } + + preference.Category = strings.Repeat("01234567890", 20) + if err := preference.IsValid(); err == nil { + t.Fatal() + } + + preference.Category = PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW + if err := preference.IsValid(); err != nil { + t.Fatal() + } + + preference.Name = strings.Repeat("01234567890", 20) + if err := preference.IsValid(); err == nil { + t.Fatal() + } + + preference.Name = NewId() + if err := preference.IsValid(); err != nil { + t.Fatal() + } + + preference.Value = strings.Repeat("01234567890", 20) + if err := preference.IsValid(); err == nil { + t.Fatal() + } + + preference.Value = "1234garbage" + if err := preference.IsValid(); err != nil { + t.Fatal() + } +} diff --git a/model/preferences.go b/model/preferences.go new file mode 100644 index 000000000..1ef16151f --- /dev/null +++ b/model/preferences.go @@ -0,0 +1,31 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +type Preferences []Preference + +func (o *Preferences) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func PreferencesFromJson(data io.Reader) (Preferences, error) { + decoder := json.NewDecoder(data) + var o Preferences + err := decoder.Decode(&o) + if err == nil { + return o, nil + } else { + return nil, err + } +} diff --git a/store/sql_preference_store.go b/store/sql_preference_store.go new file mode 100644 index 000000000..46cef38b1 --- /dev/null +++ b/store/sql_preference_store.go @@ -0,0 +1,214 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package store + +import ( + "github.com/go-gorp/gorp" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +type SqlPreferenceStore struct { + *SqlStore +} + +func NewSqlPreferenceStore(sqlStore *SqlStore) PreferenceStore { + s := &SqlPreferenceStore{sqlStore} + + for _, db := range sqlStore.GetAllConns() { + table := db.AddTableWithName(model.Preference{}, "Preferences").SetKeys(false, "UserId", "Category", "Name") + table.ColMap("UserId").SetMaxSize(26) + table.ColMap("Category").SetMaxSize(32) + table.ColMap("Name").SetMaxSize(32) + table.ColMap("Value").SetMaxSize(128) + } + + return s +} + +func (s SqlPreferenceStore) UpgradeSchemaIfNeeded() { +} + +func (s SqlPreferenceStore) CreateIndexesIfNotExists() { + s.CreateIndexIfNotExists("idx_preferences_user_id", "Preferences", "UserId") + s.CreateIndexIfNotExists("idx_preferences_category", "Preferences", "Category") + s.CreateIndexIfNotExists("idx_preferences_name", "Preferences", "Name") +} + +func (s SqlPreferenceStore) Save(preferences *model.Preferences) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + // wrap in a transaction so that if one fails, everything fails + transaction, err := s.GetReplica().Begin() + if err != nil { + result.Err = model.NewAppError("SqlPreferenceStore.Save", "Unable to open transaction to save preferences", err.Error()) + } else { + for _, preference := range *preferences { + if upsertResult := s.save(transaction, &preference); upsertResult.Err != nil { + result = upsertResult + break + } + } + + if result.Err == nil { + if err := transaction.Commit(); err != nil { + // don't need to rollback here since the transaction is already closed + result.Err = model.NewAppError("SqlPreferenceStore.Save", "Unable to commit transaction to save preferences", err.Error()) + } else { + result.Data = len(*preferences) + } + } else { + if err := transaction.Rollback(); err != nil { + result.Err = model.NewAppError("SqlPreferenceStore.Save", "Unable to rollback transaction to save preferences", err.Error()) + } + } + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlPreferenceStore) save(transaction *gorp.Transaction, preference *model.Preference) StoreResult { + result := StoreResult{} + + if result.Err = preference.IsValid(); result.Err != nil { + return result + } + + params := map[string]interface{}{ + "UserId": preference.UserId, + "Category": preference.Category, + "Name": preference.Name, + "Value": preference.Value, + } + + if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_MYSQL { + if _, err := transaction.Exec( + `INSERT INTO + Preferences + (UserId, Category, Name, Value) + VALUES + (:UserId, :Category, :Name, :Value) + ON DUPLICATE KEY UPDATE + Value = :Value`, params); err != nil { + result.Err = model.NewAppError("SqlPreferenceStore.save", "We encountered an error while updating preferences", err.Error()) + } + } else if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES { + // postgres has no way to upsert values until version 9.5 and trying inserting and then updating causes transactions to abort + count, err := transaction.SelectInt( + `SELECT + count(0) + FROM + Preferences + WHERE + UserId = :UserId + AND Category = :Category + AND Name = :Name`, params) + if err != nil { + result.Err = model.NewAppError("SqlPreferenceStore.save", "We encountered an error while updating preferences", err.Error()) + return result + } + + if count == 1 { + s.update(transaction, preference) + } else { + s.insert(transaction, preference) + } + } else { + result.Err = model.NewAppError("SqlPreferenceStore.save", "We encountered an error while updating preferences", + "Failed to update preference because of missing driver") + } + + return result +} + +func (s SqlPreferenceStore) insert(transaction *gorp.Transaction, preference *model.Preference) StoreResult { + result := StoreResult{} + + if err := transaction.Insert(preference); err != nil { + if IsUniqueConstraintError(err.Error(), "UserId", "preferences_pkey") { + result.Err = model.NewAppError("SqlPreferenceStore.insert", "A preference with that user id, category, and name already exists", + "user_id="+preference.UserId+", category="+preference.Category+", name="+preference.Name+", "+err.Error()) + } else { + result.Err = model.NewAppError("SqlPreferenceStore.insert", "We couldn't save the preference", + "user_id="+preference.UserId+", category="+preference.Category+", name="+preference.Name+", "+err.Error()) + } + } + + return result +} + +func (s SqlPreferenceStore) update(transaction *gorp.Transaction, preference *model.Preference) StoreResult { + result := StoreResult{} + + if _, err := transaction.Update(preference); err != nil { + result.Err = model.NewAppError("SqlPreferenceStore.update", "We couldn't update the preference", + "user_id="+preference.UserId+", category="+preference.Category+", name="+preference.Name+", "+err.Error()) + } + + return result +} + +func (s SqlPreferenceStore) Get(userId string, category string, name string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var preference model.Preference + + if err := s.GetReplica().SelectOne(&preference, + `SELECT + * + FROM + Preferences + WHERE + UserId = :UserId + AND Category = :Category + AND Name = :Name`, map[string]interface{}{"UserId": userId, "Category": category, "Name": name}); err != nil { + result.Err = model.NewAppError("SqlPreferenceStore.Get", "We encounted an error while finding preferences", err.Error()) + } else { + result.Data = preference + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlPreferenceStore) GetCategory(userId string, category 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 + AND Category = :Category`, map[string]interface{}{"UserId": userId, "Category": category}); err != nil { + result.Err = model.NewAppError("SqlPreferenceStore.GetCategory", "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 new file mode 100644 index 000000000..76b1bcb17 --- /dev/null +++ b/store/sql_preference_store_test.go @@ -0,0 +1,146 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package store + +import ( + "github.com/mattermost/platform/model" + "testing" +) + +func TestPreferenceSave(t *testing.T) { + Setup() + + id := model.NewId() + + preferences := model.Preferences{ + { + UserId: id, + Category: model.PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW, + Name: model.NewId(), + Value: "value1a", + }, + { + UserId: id, + Category: model.PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW, + Name: model.NewId(), + Value: "value1b", + }, + } + if count := Must(store.Preference().Save(&preferences)); count != 2 { + t.Fatal("got incorrect number of rows saved") + } + + for _, preference := range preferences { + if data := Must(store.Preference().Get(preference.UserId, preference.Category, preference.Name)).(model.Preference); preference != data { + t.Fatal("got incorrect preference after first Save") + } + } + + preferences[0].Value = "value2a" + preferences[1].Value = "value2b" + if count := Must(store.Preference().Save(&preferences)); count != 2 { + t.Fatal("got incorrect number of rows saved") + } + + for _, preference := range preferences { + if data := Must(store.Preference().Get(preference.UserId, preference.Category, preference.Name)).(model.Preference); preference != data { + t.Fatal("got incorrect preference after second Save") + } + } +} + +func TestPreferenceGet(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, + }, + { + UserId: userId, + Category: category, + Name: model.NewId(), + }, + { + UserId: userId, + Category: model.NewId(), + Name: name, + }, + { + UserId: model.NewId(), + Category: category, + Name: name, + }, + } + + Must(store.Preference().Save(&preferences)) + + if result := <-store.Preference().Get(userId, category, name); result.Err != nil { + t.Fatal(result.Err) + } else if data := result.Data.(model.Preference); data != preferences[0] { + t.Fatal("got incorrect preference") + } + + // make sure getting a missing preference fails + if result := <-store.Preference().Get(model.NewId(), model.NewId(), model.NewId()); result.Err == nil { + t.Fatal("no error on getting a missing preference") + } +} + +func TestPreferenceGetCategory(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().GetCategory(userId, category); result.Err != nil { + t.Fatal(result.Err) + } else if data := result.Data.(model.Preferences); len(data) != 2 { + t.Fatal("got the wrong number of preferences") + } else if !((data[0] == preferences[0] && data[1] == preferences[1]) || (data[0] == preferences[1] && data[1] == preferences[0])) { + t.Fatal("got incorrect preferences") + } + + // make sure getting a missing preference category doesn't fail + if result := <-store.Preference().GetCategory(model.NewId(), model.NewId()); result.Err != nil { + t.Fatal(result.Err) + } else if data := result.Data.(model.Preferences); len(data) != 0 { + t.Fatal("shouldn't have got any preferences") + } +} diff --git a/store/sql_store.go b/store/sql_store.go index 900543460..4b055e455 100644 --- a/store/sql_store.go +++ b/store/sql_store.go @@ -30,17 +30,18 @@ import ( ) type SqlStore struct { - master *gorp.DbMap - replicas []*gorp.DbMap - team TeamStore - channel ChannelStore - post PostStore - user UserStore - audit AuditStore - session SessionStore - oauth OAuthStore - system SystemStore - webhook WebhookStore + master *gorp.DbMap + replicas []*gorp.DbMap + team TeamStore + channel ChannelStore + post PostStore + user UserStore + audit AuditStore + session SessionStore + oauth OAuthStore + system SystemStore + webhook WebhookStore + preference PreferenceStore } func NewSqlStore() Store { @@ -93,6 +94,7 @@ func NewSqlStore() Store { sqlStore.oauth = NewSqlOAuthStore(sqlStore) sqlStore.system = NewSqlSystemStore(sqlStore) sqlStore.webhook = NewSqlWebhookStore(sqlStore) + sqlStore.preference = NewSqlPreferenceStore(sqlStore) sqlStore.master.CreateTablesIfNotExists() @@ -105,6 +107,7 @@ func NewSqlStore() Store { sqlStore.oauth.(*SqlOAuthStore).UpgradeSchemaIfNeeded() sqlStore.system.(*SqlSystemStore).UpgradeSchemaIfNeeded() sqlStore.webhook.(*SqlWebhookStore).UpgradeSchemaIfNeeded() + sqlStore.preference.(*SqlPreferenceStore).UpgradeSchemaIfNeeded() sqlStore.team.(*SqlTeamStore).CreateIndexesIfNotExists() sqlStore.channel.(*SqlChannelStore).CreateIndexesIfNotExists() @@ -115,6 +118,7 @@ func NewSqlStore() Store { sqlStore.oauth.(*SqlOAuthStore).CreateIndexesIfNotExists() sqlStore.system.(*SqlSystemStore).CreateIndexesIfNotExists() sqlStore.webhook.(*SqlWebhookStore).CreateIndexesIfNotExists() + sqlStore.preference.(*SqlPreferenceStore).CreateIndexesIfNotExists() if model.IsPreviousVersion(schemaVersion) { sqlStore.system.Update(&model.System{Name: "Version", Value: model.CurrentVersion}) @@ -472,6 +476,10 @@ func (ss SqlStore) Webhook() WebhookStore { return ss.webhook } +func (ss SqlStore) Preference() PreferenceStore { + return ss.preference +} + type mattermConverter struct{} func (me mattermConverter) ToDb(val interface{}) (interface{}, error) { diff --git a/store/store.go b/store/store.go index e539bc98a..6e1614ccb 100644 --- a/store/store.go +++ b/store/store.go @@ -37,6 +37,7 @@ type Store interface { OAuth() OAuthStore System() SystemStore Webhook() WebhookStore + Preference() PreferenceStore Close() } @@ -149,3 +150,9 @@ type WebhookStore interface { GetIncomingByUser(userId string) StoreChannel DeleteIncoming(webhookId string, time int64) StoreChannel } + +type PreferenceStore interface { + Save(preferences *model.Preferences) StoreChannel + Get(userId string, category string, name string) StoreChannel + GetCategory(userId string, category string) StoreChannel +} diff --git a/web/react/components/more_direct_channels.jsx b/web/react/components/more_direct_channels.jsx index 31ecb4c5d..bc610cd60 100644 --- a/web/react/components/more_direct_channels.jsx +++ b/web/react/components/more_direct_channels.jsx @@ -1,10 +1,11 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var ChannelStore = require('../stores/channel_store.jsx'); var TeamStore = require('../stores/team_store.jsx'); var Client = require('../utils/client.jsx'); +var Constants = require('../utils/constants.jsx'); var AsyncClient = require('../utils/async_client.jsx'); +var PreferenceStore = require('../stores/preference_store.jsx'); var utils = require('../utils/utils.jsx'); export default class MoreDirectChannels extends React.Component { @@ -15,27 +16,28 @@ export default class MoreDirectChannels extends React.Component { } componentDidMount() { - var self = this; - $(React.findDOMNode(this.refs.modal)).on('show.bs.modal', function showModal(e) { + $(React.findDOMNode(this.refs.modal)).on('show.bs.modal', (e) => { var button = e.relatedTarget; - self.setState({channels: $(button).data('channels')}); + this.setState({channels: $(button).data('channels')}); // eslint-disable-line react/no-did-mount-set-state }); } - render() { - var self = this; + handleJoinDirectChannel(channel) { + const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, channel.teammate_id, 'true'); + AsyncClient.savePreferences([preference]); + } - var directMessageItems = this.state.channels.map(function mapActivityToChannel(channel, index) { + render() { + var directMessageItems = this.state.channels.map((channel, index) => { var badge = ''; var titleClass = ''; - var active = ''; var handleClick = null; if (channel.fake) { // It's a direct message channel that doesn't exist yet so let's create it now var otherUserId = utils.getUserIdFromChannelName(channel); - if (self.state.loadingDMChannel === index) { + if (this.state.loadingDMChannel === index) { badge = ( <img className='channel-loading-gif pull-right' @@ -44,47 +46,42 @@ export default class MoreDirectChannels extends React.Component { ); } - if (self.state.loadingDMChannel === -1) { - handleClick = function clickHandler(e) { + if (this.state.loadingDMChannel === -1) { + handleClick = (e) => { e.preventDefault(); - self.setState({loadingDMChannel: index}); + this.setState({loadingDMChannel: index}); + this.handleJoinDirectChannel(channel); Client.createDirectChannel(channel, otherUserId, - function success(data) { - $(React.findDOMNode(self.refs.modal)).modal('hide'); - self.setState({loadingDMChannel: -1}); + (data) => { + $(React.findDOMNode(this.refs.modal)).modal('hide'); + this.setState({loadingDMChannel: -1}); AsyncClient.getChannel(data.id); utils.switchChannel(data); }, - function error() { - self.setState({loadingDMChannel: -1}); + () => { + this.setState({loadingDMChannel: -1}); window.location.href = TeamStore.getCurrentTeamUrl() + '/channels/' + channel.name; } ); }; } } else { - if (channel.id === ChannelStore.getCurrentId()) { - active = 'active'; - } - if (channel.unread) { badge = <span className='badge pull-right small'>{channel.unread}</span>; titleClass = 'unread-title'; } - handleClick = function clickHandler(e) { + handleClick = (e) => { e.preventDefault(); + this.handleJoinDirectChannel(channel); utils.switchChannel(channel); - $(React.findDOMNode(self.refs.modal)).modal('hide'); + $(React.findDOMNode(this.refs.modal)).modal('hide'); }; } return ( - <li - key={channel.name} - className={active} - > + <li key={channel.name}> <a className={'sidebar-channel ' + titleClass} href='#' @@ -111,10 +108,10 @@ export default class MoreDirectChannels extends React.Component { className='close' data-dismiss='modal' > - <span aria-hidden='true'>×</span> - <span className='sr-only'>Close</span> + <span aria-hidden='true'>{'×'}</span> + <span className='sr-only'>{'Close'}</span> </button> - <h4 className='modal-title'>More Direct Messages</h4> + <h4 className='modal-title'>{'More Direct Messages'}</h4> </div> <div className='modal-body'> <ul className='nav nav-pills nav-stacked'> @@ -126,7 +123,7 @@ export default class MoreDirectChannels extends React.Component { type='button' className='btn btn-default' data-dismiss='modal' - >Close</button> + >{'Close'}</button> </div> </div> </div> diff --git a/web/react/components/sidebar.jsx b/web/react/components/sidebar.jsx index 4ac1fd4a0..1caf4caa5 100644 --- a/web/react/components/sidebar.jsx +++ b/web/react/components/sidebar.jsx @@ -1,19 +1,20 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var ChannelStore = require('../stores/channel_store.jsx'); -var Client = require('../utils/client.jsx'); -var AsyncClient = require('../utils/async_client.jsx'); -var SocketStore = require('../stores/socket_store.jsx'); -var UserStore = require('../stores/user_store.jsx'); -var TeamStore = require('../stores/team_store.jsx'); -var BrowserStore = require('../stores/browser_store.jsx'); -var Utils = require('../utils/utils.jsx'); -var SidebarHeader = require('./sidebar_header.jsx'); -var SearchBox = require('./search_bar.jsx'); -var Constants = require('../utils/constants.jsx'); -var NewChannelFlow = require('./new_channel_flow.jsx'); -var UnreadChannelIndicator = require('./unread_channel_indicator.jsx'); +const AsyncClient = require('../utils/async_client.jsx'); +const BrowserStore = require('../stores/browser_store.jsx'); +const ChannelStore = require('../stores/channel_store.jsx'); +const Client = require('../utils/client.jsx'); +const Constants = require('../utils/constants.jsx'); +const PreferenceStore = require('../stores/preference_store.jsx'); +const NewChannelFlow = require('./new_channel_flow.jsx'); +const SearchBox = require('./search_bar.jsx'); +const SidebarHeader = require('./sidebar_header.jsx'); +const SocketStore = require('../stores/socket_store.jsx'); +const TeamStore = require('../stores/team_store.jsx'); +const UnreadChannelIndicator = require('./unread_channel_indicator.jsx'); +const UserStore = require('../stores/user_store.jsx'); +const Utils = require('../utils/utils.jsx'); export default class Sidebar extends React.Component { constructor(props) { @@ -23,12 +24,17 @@ export default class Sidebar extends React.Component { this.firstUnreadChannel = null; this.lastUnreadChannel = null; + this.getStateFromStores = this.getStateFromStores.bind(this); + this.onChange = this.onChange.bind(this); this.onScroll = this.onScroll.bind(this); this.onResize = this.onResize.bind(this); this.updateUnreadIndicators = this.updateUnreadIndicators.bind(this); + this.handleLeaveDirectChannel = this.handleLeaveDirectChannel.bind(this); this.createChannelElement = this.createChannelElement.bind(this); + this.isLeaving = new Map(); + const state = this.getStateFromStores(); state.modal = ''; state.loadingDMChannel = -1; @@ -36,7 +42,7 @@ export default class Sidebar extends React.Component { this.state = state; } getStateFromStores() { - var members = ChannelStore.getAllMembers(); + const members = ChannelStore.getAllMembers(); var teamMemberMap = UserStore.getActiveOnlyProfiles(); var currentId = ChannelStore.getCurrentId(); @@ -48,11 +54,13 @@ export default class Sidebar extends React.Component { teammates.push(teamMemberMap[id]); } + const preferences = PreferenceStore.getPreferences(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW); + // Create lists of all read and unread direct channels - var showDirectChannels = []; - var readDirectChannels = []; + var visibleDirectChannels = []; + var hiddenDirectChannels = []; for (var i = 0; i < teammates.length; i++) { - var teammate = teammates[i]; + const teammate = teammates[i]; if (teammate.id === UserStore.getCurrentId()) { continue; @@ -65,90 +73,63 @@ export default class Sidebar extends React.Component { channelName = teammate.id + '__' + UserStore.getCurrentId(); } - var channel = ChannelStore.getByName(channelName); - - if (channel == null) { - var tempChannel = {}; - tempChannel.fake = true; - tempChannel.name = channelName; - tempChannel.display_name = teammate.username; - tempChannel.teammate_username = teammate.username; - tempChannel.status = UserStore.getStatus(teammate.id); - tempChannel.last_post_at = 0; - tempChannel.total_msg_count = 0; - tempChannel.type = 'D'; - readDirectChannels.push(tempChannel); - } else { - channel.display_name = teammate.username; - channel.teammate_username = teammate.username; + let forceShow = false; + let channel = ChannelStore.getByName(channelName); - channel.status = UserStore.getStatus(teammate.id); + if (channel) { + const member = members[channel.id]; + const msgCount = channel.total_msg_count - member.msg_count; - var channelMember = members[channel.id]; - var msgCount = channel.total_msg_count - channelMember.msg_count; - if (msgCount > 0) { - showDirectChannels.push(channel); - } else if (currentId === channel.id) { - showDirectChannels.push(channel); - } else { - readDirectChannels.push(channel); - } + // always show a channel if either it is the current one or if it is unread, but it is not currently being left + forceShow = (currentId === channel.id || msgCount > 0) && !this.isLeaving.get(channel.id); + } else { + channel = {}; + channel.fake = true; + channel.name = channelName; + channel.last_post_at = 0; + channel.total_msg_count = 0; + channel.type = 'D'; } - } - // If we don't have MAX_DMS unread channels, sort the read list by last_post_at - if (showDirectChannels.length < Constants.MAX_DMS) { - readDirectChannels.sort(function sortByLastPost(a, b) { - // sort by last_post_at first - if (a.last_post_at > b.last_post_at) { - return -1; - } - if (a.last_post_at < b.last_post_at) { - return 1; - } + channel.display_name = teammate.username; + channel.teammate_id = teammate.id; + channel.status = UserStore.getStatus(teammate.id); - // if last_post_at is equal, sort by name - if (a.display_name < b.display_name) { - return -1; - } - if (a.display_name > b.display_name) { - return 1; - } - return 0; - }); + if (preferences.some((preference) => (preference.name === teammate.id && preference.value !== 'false'))) { + visibleDirectChannels.push(channel); + } else if (forceShow) { + // make sure that unread direct channels are visible + const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, teammate.id, 'true'); + AsyncClient.savePreferences([preference]); - var index = 0; - while (showDirectChannels.length < Constants.MAX_DMS && index < readDirectChannels.length) { - showDirectChannels.push(readDirectChannels[index]); - index++; + visibleDirectChannels.push(channel); + } else { + hiddenDirectChannels.push(channel); } - readDirectChannels = readDirectChannels.slice(index); - - showDirectChannels.sort(function directSort(a, b) { - if (a.display_name < b.display_name) { - return -1; - } - if (a.display_name > b.display_name) { - return 1; - } - return 0; - }); } + visibleDirectChannels.sort(this.sortChannelsByDisplayName); + hiddenDirectChannels.sort(this.sortChannelsByDisplayName); + return { activeId: currentId, channels: ChannelStore.getAll(), - members: members, - showDirectChannels: showDirectChannels, - hideDirectChannels: readDirectChannels + members, + visibleDirectChannels, + hiddenDirectChannels }; } + componentDidMount() { ChannelStore.addChangeListener(this.onChange); UserStore.addChangeListener(this.onChange); UserStore.addStatusesChangeListener(this.onChange); TeamStore.addChangeListener(this.onChange); SocketStore.addChangeListener(this.onSocketChange); + PreferenceStore.addChangeListener(this.onChange); + + AsyncClient.getDirectChannelPreferences(); + $('.nav-pills__container').perfectScrollbar(); this.updateTitle(); @@ -178,6 +159,7 @@ export default class Sidebar extends React.Component { UserStore.removeStatusesChangeListener(this.onChange); TeamStore.removeChangeListener(this.onChange); SocketStore.removeChangeListener(this.onSocketChange); + PreferenceStore.removeChangeListener(this.onChange); } onChange() { var newState = this.getStateFromStores(); @@ -322,7 +304,37 @@ export default class Sidebar extends React.Component { showBottomUnread }); } - createChannelElement(channel, index) { + + handleLeaveDirectChannel(channel) { + if (!this.isLeaving.get(channel.id)) { + this.isLeaving.set(channel.id, true); + + const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, channel.teammate_id, 'false'); + + // bypass AsyncClient since we've already saved the updated preferences + Client.savePreferences( + [preference], + () => { + this.isLeaving.set(channel.id, false); + }, + () => { + this.isLeaving.set(channel.id, false); + } + ); + + this.setState(this.getStateFromStores()); + } + + if (channel.id === this.state.activeId) { + Utils.switchChannel(ChannelStore.getByName(Constants.DEFAULT_CHANNEL)); + } + } + + sortChannelsByDisplayName(a, b) { + return a.display_name.localeCompare(b.display_name); + } + + createChannelElement(channel, index, arr, handleClose) { var members = this.state.members; var activeId = this.state.activeId; var channelMember = members[channel.id]; @@ -405,8 +417,13 @@ export default class Sidebar extends React.Component { if (!channel.fake) { handleClick = function clickHandler(e) { + if (e.target.attributes.getNamedItem('data-close')) { + handleClose(channel); + } else { + Utils.switchChannel(channel); + } + e.preventDefault(); - Utils.switchChannel(channel); }; } else if (channel.fake && teamURL) { // It's a direct message channel that doesn't exist yet so let's create it now @@ -415,23 +432,40 @@ export default class Sidebar extends React.Component { if (this.state.loadingDMChannel === -1) { handleClick = function clickHandler(e) { e.preventDefault(); - this.setState({loadingDMChannel: index}); - - Client.createDirectChannel(channel, otherUserId, - function success(data) { - this.setState({loadingDMChannel: -1}); - AsyncClient.getChannel(data.id); - Utils.switchChannel(data); - }.bind(this), - function error() { - this.setState({loadingDMChannel: -1}); - window.location.href = TeamStore.getCurrentTeamUrl() + '/channels/' + channel.name; - }.bind(this) - ); + + if (e.target.attributes.getNamedItem('data-close')) { + handleClose(channel); + } else { + this.setState({loadingDMChannel: index}); + + Client.createDirectChannel(channel, otherUserId, + (data) => { + this.setState({loadingDMChannel: -1}); + AsyncClient.getChannel(data.id); + Utils.switchChannel(data); + }, + () => { + this.setState({loadingDMChannel: -1}); + window.location.href = TeamStore.getCurrentTeamUrl() + '/channels/' + channel.name; + } + ); + } }.bind(this); } } + let closeButton = null; + if (handleClose && !badge) { + closeButton = ( + <span + className='sidebar-channel__close pull-right' + data-close='true' + > + {'×'} + </span> + ); + } + return ( <li key={channel.name} @@ -446,6 +480,7 @@ export default class Sidebar extends React.Component { {status} {channel.display_name} {badge} + {closeButton} </a> </li> ); @@ -464,7 +499,9 @@ export default class Sidebar extends React.Component { const privateChannels = this.state.channels.filter((channel) => channel.type === 'P'); const privateChannelItems = privateChannels.map(this.createChannelElement); - const directMessageItems = this.state.showDirectChannels.map(this.createChannelElement); + const directMessageItems = this.state.visibleDirectChannels.map((channel, index, arr) => { + return this.createChannelElement(channel, index, arr, this.handleLeaveDirectChannel); + }); // update the favicon to show if there are any notifications var link = document.createElement('link'); @@ -484,17 +521,18 @@ export default class Sidebar extends React.Component { head.appendChild(link); var directMessageMore = null; - if (this.state.hideDirectChannels.length > 0) { + if (this.state.hiddenDirectChannels.length > 0) { directMessageMore = ( - <li> + <li key='more'> <a + key={`more${this.state.hiddenDirectChannels.length}`} href='#' data-toggle='modal' className='nav-more' data-target='#more_direct_channels' - data-channels={JSON.stringify(this.state.hideDirectChannels)} + data-channels={JSON.stringify(this.state.hiddenDirectChannels)} > - {'More (' + this.state.hideDirectChannels.length + ')'} + {'More (' + this.state.hiddenDirectChannels.length + ')'} </a> </li> ); @@ -538,7 +576,7 @@ export default class Sidebar extends React.Component { <ul className='nav nav-pills nav-stacked'> <li> <h4> - Channels + {'Channels'} <a className='add-channel-btn' href='#' @@ -557,7 +595,7 @@ export default class Sidebar extends React.Component { data-target='#more_channels' data-channeltype='O' > - More... + {'More...'} </a> </li> </ul> @@ -565,7 +603,7 @@ export default class Sidebar extends React.Component { <ul className='nav nav-pills nav-stacked'> <li> <h4> - Private Groups + {'Private Groups'} <a className='add-channel-btn' href='#' @@ -578,7 +616,7 @@ export default class Sidebar extends React.Component { {privateChannelItems} </ul> <ul className='nav nav-pills nav-stacked'> - <li><h4>Direct Messages</h4></li> + <li><h4>{'Direct Messages'}</h4></li> {directMessageItems} {directMessageMore} </ul> diff --git a/web/react/components/team_import_tab.jsx b/web/react/components/team_import_tab.jsx index 40f06c382..a80b1a472 100644 --- a/web/react/components/team_import_tab.jsx +++ b/web/react/components/team_import_tab.jsx @@ -34,14 +34,14 @@ export default class TeamImportTab extends React.Component { render() { var uploadHelpText = ( <div> - <p>{'Slack does not allow you to export files, images, private groups or direct messages stored in Slack. Therefore, Slack import to Mattermost only supports importing of text messages in your Slack team\'\s public channels.'}</p> - <p>{'The Slack import to Mattermost is in "Preview". Slack bot posts do not yet import and Slack @mentions are not currently supported.'}</p> + <p>{'To import a team from Slack go to Slack > Team Settings > Import/Export Data > Export > Start Export. Slack does not allow you to export files, images, private groups or direct messages stored in Slack. Therefore, Slack import to Mattermost only supports importing of text messages in your Slack team\'\s public channels.'}</p> + <p>{'The Slack import to Mattermost is in "Beta". Slack bot posts do not yet import and Slack @mentions are not currently supported.'}</p> </div> ); var uploadSection = ( <SettingUpload - title='Import from Slack' + title='Import from Slack (Beta)' submit={this.doImportSlack} helpText={uploadHelpText} fileTypesAccepted='.zip' diff --git a/web/react/stores/preference_store.jsx b/web/react/stores/preference_store.jsx new file mode 100644 index 000000000..d71efa10f --- /dev/null +++ b/web/react/stores/preference_store.jsx @@ -0,0 +1,122 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +const ActionTypes = require('../utils/constants.jsx').ActionTypes; +const AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); +const BrowserStore = require('./browser_store.jsx'); +const EventEmitter = require('events').EventEmitter; +const UserStore = require('../stores/user_store.jsx'); + +const CHANGE_EVENT = 'change'; + +function getPreferenceKey(category, name) { + return `${category}-${name}`; +} + +function getPreferenceKeyForModel(preference) { + return `${preference.category}-${preference.name}`; +} + +class PreferenceStoreClass extends EventEmitter { + constructor() { + super(); + + this.getAllPreferences = this.getAllPreferences.bind(this); + this.getPreference = this.getPreference.bind(this); + this.getPreferences = this.getPreferences.bind(this); + this.getPreferencesWhere = this.getPreferencesWhere.bind(this); + this.setAllPreferences = this.setAllPreferences.bind(this); + this.setPreference = this.setPreference.bind(this); + + this.emitChange = this.emitChange.bind(this); + this.addChangeListener = this.addChangeListener.bind(this); + this.removeChangeListener = this.removeChangeListener.bind(this); + + this.handleEventPayload = this.handleEventPayload.bind(this); + this.dispatchToken = AppDispatcher.register(this.handleEventPayload); + } + + getAllPreferences() { + return new Map(BrowserStore.getItem('preferences', [])); + } + + getPreference(category, name, defaultValue = '') { + return this.getAllPreferences().get(getPreferenceKey(category, name)) || defaultValue; + } + + getPreferences(category) { + return this.getPreferencesWhere((preference) => (preference.category === category)); + } + + getPreferencesWhere(pred) { + const all = this.getAllPreferences(); + const preferences = []; + + for (const [, preference] of all) { + if (pred(preference)) { + preferences.push(preference); + } + } + + return preferences; + } + + setAllPreferences(preferences) { + // note that we store the preferences as an array of key-value pairs so that we can deserialize + // it as a proper Map instead of an object + BrowserStore.setItem('preferences', [...preferences]); + } + + setPreference(category, name, value) { + const preferences = this.getAllPreferences(); + + const key = getPreferenceKey(category, name); + let preference = preferences.get(key); + + if (!preference) { + preference = { + user_id: UserStore.getCurrentId(), + category, + name + }; + } + preference.value = value; + + preferences.set(key, preference); + + this.setAllPreferences(preferences); + + return preference; + } + + emitChange(preferences) { + this.emit(CHANGE_EVENT, preferences); + } + + addChangeListener(callback) { + this.on(CHANGE_EVENT, callback); + } + + removeChangeListener(callback) { + this.removeListener(CHANGE_EVENT, callback); + } + + handleEventPayload(payload) { + const action = payload.action; + + switch (action.type) { + case ActionTypes.RECIEVED_PREFERENCES: + const preferences = this.getAllPreferences(); + + for (const preference of action.preferences) { + preferences.set(getPreferenceKeyForModel(preference), preference); + } + + this.setAllPreferences(preferences); + this.emitChange(preferences); + } + } +} + +const PreferenceStore = new PreferenceStoreClass(); +export default PreferenceStore; diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx index a903f055b..1bf8a6fee 100644 --- a/web/react/utils/async_client.jsx +++ b/web/react/utils/async_client.jsx @@ -637,3 +637,55 @@ export function getMyTeam() { } ); } + +export function getDirectChannelPreferences() { + if (isCallInProgress('getDirectChannelPreferences')) { + return; + } + + callTracker.getDirectChannelPreferences = utils.getTimestamp(); + client.getPreferenceCategory( + Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, + (data, textStatus, xhr) => { + callTracker.getDirectChannelPreferences = 0; + + if (xhr.status === 304 || !data) { + return; + } + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_PREFERENCES, + preferences: data + }); + }, + (err) => { + callTracker.getDirectChannelPreferences = 0; + dispatchError(err, 'getDirectChannelPreferences'); + } + ); +} + +export function savePreferences(preferences, success, error) { + client.savePreferences( + preferences, + (data, textStatus, xhr) => { + if (xhr.status !== 304) { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_PREFERENCES, + preferences + }); + } + + if (success) { + success(data); + } + }, + (err) => { + dispatchError(err, 'savePreferences'); + + if (error) { + error(); + } + } + ); +} diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index 6dccfcdeb..76a402855 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -1141,3 +1141,31 @@ export function listIncomingHooks(success, error) { } }); } + +export function getPreferenceCategory(category, success, error) { + $.ajax({ + url: `/api/v1/preferences/${category}`, + dataType: 'json', + type: 'GET', + success, + error: (xhr, status, err) => { + var e = handleError('getPreferenceCategory', xhr, status, err); + error(e); + } + }); +} + +export function savePreferences(preferences, success, error) { + $.ajax({ + url: '/api/v1/preferences/save', + dataType: 'json', + contentType: 'application/json', + type: 'POST', + data: JSON.stringify(preferences), + success, + error: (xhr, status, err) => { + var e = handleError('savePreferences', xhr, status, err); + error(e); + } + }); +} diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index e3cbfccde..cee2ec114 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -28,6 +28,7 @@ module.exports = { RECIEVED_AUDITS: null, RECIEVED_TEAMS: null, RECIEVED_STATUSES: null, + RECIEVED_PREFERENCES: null, RECIEVED_MSG: null, @@ -285,5 +286,8 @@ module.exports = { id: 'mentionHighlightLink', uiName: 'Mention Highlight Link' } - ] + ], + Preferences: { + CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show' + } }; diff --git a/web/react/utils/markdown.jsx b/web/react/utils/markdown.jsx index 848b1ea75..12d6dd424 100644 --- a/web/react/utils/markdown.jsx +++ b/web/react/utils/markdown.jsx @@ -6,31 +6,7 @@ const Utils = require('./utils.jsx'); const marked = require('marked'); -class MattermostInlineLexer extends marked.InlineLexer { - constructor(links, options) { - super(links, options); - - // modified version of the regex that doesn't break up words in snake_case - // the original is /^[\s\S]+?(?=[\\<!\[_*`]| {2,}\n|$)/ - this.rules.text = /^[\s\S]+?(?=__|\b_|[\\<!\[*`]| {2,}\n|$)/; - } -} - -class MattermostParser extends marked.Parser { - parse(src) { - this.inline = new MattermostInlineLexer(src.links, this.options, this.renderer); - this.tokens = src.reverse(); - - var out = ''; - while (this.next()) { - out += this.tok(); - } - - return out; - } -} - -class MattermostMarkdownRenderer extends marked.Renderer { +export class MattermostMarkdownRenderer extends marked.Renderer { constructor(options, formattingOptions = {}) { super(options); @@ -92,15 +68,3 @@ class MattermostMarkdownRenderer extends marked.Renderer { return TextFormatting.doFormatText(text, this.formattingOptions); } } - -export function format(text, options) { - const markdownOptions = { - renderer: new MattermostMarkdownRenderer(null, options), - sanitize: true - }; - - const tokens = marked.lexer(text, markdownOptions); - - return new MattermostParser(markdownOptions).parse(tokens); -} - diff --git a/web/react/utils/text_formatting.jsx b/web/react/utils/text_formatting.jsx index 6778d341a..2b6e6e14e 100644 --- a/web/react/utils/text_formatting.jsx +++ b/web/react/utils/text_formatting.jsx @@ -8,6 +8,8 @@ const Markdown = require('./markdown.jsx'); const UserStore = require('../stores/user_store.jsx'); const Utils = require('./utils.jsx'); +const marked = require('marked'); + // Performs formatting of user posts including highlighting mentions and search terms and converting urls, hashtags, and // @mentions to links by taking a user's message and returning a string of formatted html. Also takes a number of options // as part of the second parameter: @@ -20,8 +22,11 @@ export function formatText(text, options = {}) { let output; if (!('markdown' in options) || options.markdown) { - // the markdown renderer will call doFormatText as necessary - output = Markdown.format(text, options); + // the markdown renderer will call doFormatText as necessary so just call marked + output = marked(text, { + renderer: new Markdown.MattermostMarkdownRenderer(null, options), + sanitize: true + }); } else { output = sanitizeHtml(text); output = doFormatText(output, options); |