diff options
-rw-r--r-- | api/user.go | 1 | ||||
-rw-r--r-- | doc/install/Production-Ubuntu.md | 6 | ||||
-rw-r--r-- | model/preference.go | 1 | ||||
-rw-r--r-- | store/sql_preference_store.go | 47 | ||||
-rw-r--r-- | store/sql_preference_store_test.go | 135 | ||||
-rw-r--r-- | store/sql_store.go | 2 | ||||
-rw-r--r-- | store/store.go | 1 | ||||
-rw-r--r-- | web/react/components/delete_post_modal.jsx | 1 | ||||
-rw-r--r-- | web/react/components/textbox.jsx | 23 | ||||
-rw-r--r-- | web/react/components/user_settings/user_settings_advanced.jsx | 144 | ||||
-rw-r--r-- | web/react/stores/post_store.jsx | 14 | ||||
-rw-r--r-- | web/react/utils/constants.jsx | 7 | ||||
-rw-r--r-- | web/react/utils/utils.jsx | 4 | ||||
-rw-r--r-- | web/sass-files/sass/partials/_base.scss | 2 | ||||
-rw-r--r-- | web/sass-files/sass/partials/_post.scss | 5 |
15 files changed, 359 insertions, 34 deletions
diff --git a/api/user.go b/api/user.go index 3281e83e2..62947d8fd 100644 --- a/api/user.go +++ b/api/user.go @@ -334,6 +334,7 @@ func LoginByEmail(c *Context, w http.ResponseWriter, r *http.Request, email, nam if result := <-Srv.Store.User().GetByEmail(team.Id, email); result.Err != nil { c.Err = result.Err + c.Err.StatusCode = http.StatusForbidden return nil } else { user := result.Data.(*model.User) diff --git a/doc/install/Production-Ubuntu.md b/doc/install/Production-Ubuntu.md index 482c2a0ba..d6b98981c 100644 --- a/doc/install/Production-Ubuntu.md +++ b/doc/install/Production-Ubuntu.md @@ -37,18 +37,18 @@ ## Set up Mattermost Server 1. For the purposes of this guide we will assume this server has an IP address of 10.10.10.2 +1. For the sake of making this guide simple we located the files at `/home/ubuntu/mattermost`. In the future we will give guidance for storing under `/opt`. +1. We have also elected to run the Mattermost Server as the `ubuntu` account for simplicity. We recommend setting up and running the service under a `mattermost` user account with limited permissions. 1. Download the latest Mattermost Server by typing: * ``` wget https://github.com/mattermost/platform/releases/download/v1.2.1/mattermost.tar.gz``` 1. Unzip the Mattermost Server by typing: * ``` tar -xvzf mattermost.tar.gz``` -1. For the sake of making this guide simple we located the files at `/home/ubuntu/mattermost`. In the future we will give guidance for storing under `/opt`. -1. We have also elected to run the Mattermost Server as the `ubuntu` account for simplicity. We recommend setting up and running the service under a `mattermost` user account with limited permissions. 1. Create the storage directory for files. We assume you will have attached a large drive for storage of images and files. For this setup we will assume the directory is located at `/mattermost/data`. * Create the directory by typing: * ``` sudo mkdir -p /mattermost/data``` * Set the ubuntu account as the directory owner by typing: * ``` sudo chown -R ubuntu /mattermost``` -1. Configure Mattermost Server by editing the config.json file at /home/ubuntu/mattermost/config` +1. Configure Mattermost Server by editing the config.json file at `/home/ubuntu/mattermost/config` * ``` cd ~/mattermost/config``` * Edit the file by typing: * ``` vi config.json``` diff --git a/model/preference.go b/model/preference.go index 892ae82aa..4f2ba0099 100644 --- a/model/preference.go +++ b/model/preference.go @@ -12,6 +12,7 @@ import ( const ( PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW = "direct_channel_show" PREFERENCE_CATEGORY_TUTORIAL_STEPS = "tutorial_step" + PREFERENCE_CATEGORY_ADVANCED_SETTINGS = "advanced_settings" ) type Preference struct { diff --git a/store/sql_preference_store.go b/store/sql_preference_store.go index 8454abcbd..f73dad3ac 100644 --- a/store/sql_preference_store.go +++ b/store/sql_preference_store.go @@ -4,6 +4,7 @@ package store import ( + l4g "code.google.com/p/log4go" "github.com/go-gorp/gorp" "github.com/mattermost/platform/model" "github.com/mattermost/platform/utils" @@ -13,6 +14,10 @@ type SqlPreferenceStore struct { *SqlStore } +const ( + FEATURE_TOGGLE_PREFIX = "feature_enabled_" +) + func NewSqlPreferenceStore(sqlStore *SqlStore) PreferenceStore { s := &SqlPreferenceStore{sqlStore} @@ -36,6 +41,23 @@ func (s SqlPreferenceStore) CreateIndexesIfNotExists() { s.CreateIndexIfNotExists("idx_preferences_name", "Preferences", "Name") } +func (s SqlPreferenceStore) DeleteUnusedFeatures() { + l4g.Debug("Deleting any unused pre-release features") + + sql := `DELETE + FROM Preferences + WHERE + Category = :Category + AND Value = :Value + AND Name LIKE '` + FEATURE_TOGGLE_PREFIX + `%'` + + queryParams := map[string]string{ + "Category": model.PREFERENCE_CATEGORY_ADVANCED_SETTINGS, + "Value": "false", + } + s.GetMaster().Exec(sql, queryParams) +} + func (s SqlPreferenceStore) Save(preferences *model.Preferences) StoreChannel { storeChannel := make(StoreChannel) @@ -257,3 +279,28 @@ func (s SqlPreferenceStore) PermanentDeleteByUser(userId string) StoreChannel { return storeChannel } + +func (s SqlPreferenceStore) IsFeatureEnabled(feature, userId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + if value, err := s.GetReplica().SelectStr(`SELECT + value + FROM + Preferences + WHERE + UserId = :UserId + AND Category = :Category + AND Name = :Name`, map[string]interface{}{"UserId": userId, "Category": model.PREFERENCE_CATEGORY_ADVANCED_SETTINGS, "Name": FEATURE_TOGGLE_PREFIX + feature}); err != nil { + result.Err = model.NewAppError("SqlPreferenceStore.IsFeatureEnabled", "We encountered an error while finding a pre release feature preference", err.Error()) + } else { + result.Data = value == "true" + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_preference_store_test.go b/store/sql_preference_store_test.go index 77da71fd6..6f8f44f47 100644 --- a/store/sql_preference_store_test.go +++ b/store/sql_preference_store_test.go @@ -232,3 +232,138 @@ func TestPreferenceDelete(t *testing.T) { t.Fatal(result.Err) } } + +func TestIsFeatureEnabled(t *testing.T) { + Setup() + + feature1 := "testFeat1" + feature2 := "testFeat2" + feature3 := "testFeat3" + + userId := model.NewId() + category := model.PREFERENCE_CATEGORY_ADVANCED_SETTINGS + + features := model.Preferences{ + { + UserId: userId, + Category: category, + Name: FEATURE_TOGGLE_PREFIX + feature1, + Value: "true", + }, + { + UserId: userId, + Category: category, + Name: model.NewId(), + Value: "false", + }, + { + UserId: userId, + Category: model.NewId(), + Name: FEATURE_TOGGLE_PREFIX + feature1, + Value: "false", + }, + { + UserId: model.NewId(), + Category: category, + Name: FEATURE_TOGGLE_PREFIX + feature2, + Value: "false", + }, + { + UserId: model.NewId(), + Category: category, + Name: FEATURE_TOGGLE_PREFIX + feature3, + Value: "foobar", + }, + } + + Must(store.Preference().Save(&features)) + + if result := <-store.Preference().IsFeatureEnabled(feature1, userId); result.Err != nil { + t.Fatal(result.Err) + } else if data := result.Data.(bool); data != true { + t.Fatalf("got incorrect setting for feature1, %v=%v", true, data) + } + + if result := <-store.Preference().IsFeatureEnabled(feature2, userId); result.Err != nil { + t.Fatal(result.Err) + } else if data := result.Data.(bool); data != false { + t.Fatalf("got incorrect setting for feature2, %v=%v", false, data) + } + + // make sure we get false if something different than "true" or "false" has been saved to database + if result := <-store.Preference().IsFeatureEnabled(feature3, userId); result.Err != nil { + t.Fatal(result.Err) + } else if data := result.Data.(bool); data != false { + t.Fatalf("got incorrect setting for feature3, %v=%v", false, data) + } + + // make sure false is returned if a non-existent feature is queried + if result := <-store.Preference().IsFeatureEnabled("someOtherFeature", userId); result.Err != nil { + t.Fatal(result.Err) + } else if data := result.Data.(bool); data != false { + t.Fatalf("got incorrect setting for non-existent feature 'someOtherFeature', %v=%v", false, data) + } +} + +func TestDeleteUnusedFeatures(t *testing.T) { + Setup() + + userId1 := model.NewId() + userId2 := model.NewId() + category := model.PREFERENCE_CATEGORY_ADVANCED_SETTINGS + feature1 := "feature1" + feature2 := "feature2" + + features := model.Preferences{ + { + UserId: userId1, + Category: category, + Name: FEATURE_TOGGLE_PREFIX + feature1, + Value: "true", + }, + { + UserId: userId2, + Category: category, + Name: FEATURE_TOGGLE_PREFIX + feature1, + Value: "false", + }, + { + UserId: userId1, + Category: category, + Name: FEATURE_TOGGLE_PREFIX + feature2, + Value: "false", + }, + { + UserId: userId2, + Category: category, + Name: FEATURE_TOGGLE_PREFIX + feature2, + Value: "true", + }, + } + + Must(store.Preference().Save(&features)) + + store.(*SqlStore).preference.(*SqlPreferenceStore).DeleteUnusedFeatures() + + //make sure features with value "false" have actually been deleted from the database + if val, err := store.(*SqlStore).preference.(*SqlPreferenceStore).GetReplica().SelectInt(`SELECT COUNT(*) + FROM Preferences + WHERE Category = :Category + AND Value = :Val + AND Name LIKE '`+FEATURE_TOGGLE_PREFIX+`%'`, map[string]interface{}{"Category": model.PREFERENCE_CATEGORY_ADVANCED_SETTINGS, "Val": "false"}); err != nil { + t.Fatal(err) + } else if val != 0 { + t.Fatalf("Found %d features with value 'false', expected all to be deleted", val) + } + // + // make sure features with value "true" remain saved + if val, err := store.(*SqlStore).preference.(*SqlPreferenceStore).GetReplica().SelectInt(`SELECT COUNT(*) + FROM Preferences + WHERE Category = :Category + AND Value = :Val + AND Name LIKE '`+FEATURE_TOGGLE_PREFIX+`%'`, map[string]interface{}{"Category": model.PREFERENCE_CATEGORY_ADVANCED_SETTINGS, "Val": "true"}); err != nil { + t.Fatal(err) + } else if val == 0 { + t.Fatalf("Found %d features with value 'true', expected to find at least %d features", val, 2) + } +} diff --git a/store/sql_store.go b/store/sql_store.go index f348db10b..d17a3e8c3 100644 --- a/store/sql_store.go +++ b/store/sql_store.go @@ -148,6 +148,8 @@ func NewSqlStore() Store { sqlStore.webhook.(*SqlWebhookStore).CreateIndexesIfNotExists() sqlStore.preference.(*SqlPreferenceStore).CreateIndexesIfNotExists() + sqlStore.preference.(*SqlPreferenceStore).DeleteUnusedFeatures() + if model.IsPreviousVersion(schemaVersion) || isSchemaVersion07 || isSchemaVersion10 { sqlStore.system.Update(&model.System{Name: "Version", Value: model.CurrentVersion}) l4g.Warn("The database schema has been upgraded to version " + model.CurrentVersion) diff --git a/store/store.go b/store/store.go index 338ae186f..0695ea27f 100644 --- a/store/store.go +++ b/store/store.go @@ -186,4 +186,5 @@ type PreferenceStore interface { GetCategory(userId string, category string) StoreChannel GetAll(userId string) StoreChannel PermanentDeleteByUser(userId string) StoreChannel + IsFeatureEnabled(feature, userId string) StoreChannel } diff --git a/web/react/components/delete_post_modal.jsx b/web/react/components/delete_post_modal.jsx index 3c4b17905..5e89a0893 100644 --- a/web/react/components/delete_post_modal.jsx +++ b/web/react/components/delete_post_modal.jsx @@ -152,6 +152,7 @@ export default class DeletePostModal extends React.Component { type='button' className='btn btn-danger' onClick={this.handleDelete} + autoFocus='autofocus' > {'Delete'} </button> diff --git a/web/react/components/textbox.jsx b/web/react/components/textbox.jsx index e2868e946..10b3c0069 100644 --- a/web/react/components/textbox.jsx +++ b/web/react/components/textbox.jsx @@ -11,6 +11,7 @@ import * as Utils from '../utils/utils.jsx'; import Constants from '../utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; const KeyCodes = Constants.KeyCodes; +const PreReleaseFeatures = Constants.PRE_RELEASE_FEATURES; export default class Textbox extends React.Component { constructor(props) { @@ -303,7 +304,19 @@ export default class Textbox extends React.Component { } render() { - const previewLinkVisible = this.props.messageText.length > 0; + let previewLink = null; + if (Utils.isFeatureEnabled(PreReleaseFeatures.MARKDOWN_PREVIEW)) { + const previewLinkVisible = this.props.messageText.length > 0; + previewLink = ( + <a + style={{visibility: previewLinkVisible ? 'visible' : 'hidden'}} + onClick={this.showPreview} + className='textbox-preview-link' + > + {this.state.preview ? 'Edit message' : 'Preview'} + </a> + ); + } return ( <div @@ -342,19 +355,13 @@ export default class Textbox extends React.Component { dangerouslySetInnerHTML={{__html: this.state.preview ? TextFormatting.formatText(this.props.messageText) : ''}} > </div> + {previewLink} <a onClick={this.showHelp} className='textbox-help-link' > {'Help'} </a> - <a - style={{visibility: previewLinkVisible ? 'visible' : 'hidden'}} - onClick={this.showPreview} - className='textbox-preview-link' - > - {this.state.preview ? 'Edit' : 'Preview'} - </a> </div> ); } diff --git a/web/react/components/user_settings/user_settings_advanced.jsx b/web/react/components/user_settings/user_settings_advanced.jsx index ac82595f5..b4d34c658 100644 --- a/web/react/components/user_settings/user_settings_advanced.jsx +++ b/web/react/components/user_settings/user_settings_advanced.jsx @@ -6,6 +6,7 @@ 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'; +const PreReleaseFeatures = Constants.PRE_RELEASE_FEATURES; export default class AdvancedSettingsDisplay extends React.Component { constructor(props) { @@ -13,21 +14,33 @@ export default class AdvancedSettingsDisplay extends React.Component { this.updateSection = this.updateSection.bind(this); this.updateSetting = this.updateSetting.bind(this); - this.setupInitialState = this.setupInitialState.bind(this); + this.toggleFeature = this.toggleFeature.bind(this); + this.saveEnabledFeatures = this.saveEnabledFeatures.bind(this); - this.state = this.setupInitialState(); - } + const preReleaseFeaturesKeys = Object.keys(PreReleaseFeatures); + const advancedSettings = PreferenceStore.getPreferences(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS); + const settings = { + send_on_ctrl_enter: PreferenceStore.getPreference( + Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, + 'send_on_ctrl_enter', + {value: 'false'} + ).value + }; - setupInitialState() { - const sendOnCtrlEnter = PreferenceStore.getPreference( - Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, - 'send_on_ctrl_enter', - {value: 'false'} - ).value; + let enabledFeatures = 0; + advancedSettings.forEach((setting) => { + preReleaseFeaturesKeys.forEach((key) => { + const feature = PreReleaseFeatures[key]; + if (setting.name === Constants.FeatureTogglePrefix + feature.label) { + settings[setting.name] = setting.value; + if (setting.value === 'true') { + enabledFeatures++; + } + } + }); + }); - return { - settings: {send_on_ctrl_enter: sendOnCtrlEnter} - }; + this.state = {preReleaseFeatures: PreReleaseFeatures, settings, preReleaseFeaturesKeys, enabledFeatures}; } updateSetting(setting, value) { @@ -36,14 +49,45 @@ export default class AdvancedSettingsDisplay extends React.Component { this.setState(settings); } - handleSubmit(setting) { - const preference = PreferenceStore.setPreference( - Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, - setting, - this.state.settings[setting] - ); + toggleFeature(feature, checked) { + const settings = this.state.settings; + settings[Constants.FeatureTogglePrefix + feature] = String(checked); + + let enabledFeatures = 0; + Object.keys(this.state.settings).forEach((setting) => { + if (setting.lastIndexOf(Constants.FeatureTogglePrefix) === 0 && this.state.settings[setting] === 'true') { + enabledFeatures++; + } + }); + + this.setState({settings, enabledFeatures}); + } + + saveEnabledFeatures() { + const features = []; + Object.keys(this.state.settings).forEach((setting) => { + if (setting.lastIndexOf(Constants.FeatureTogglePrefix) === 0) { + features.push(setting); + } + }); + + this.handleSubmit(features); + } - Client.savePreferences([preference], + handleSubmit(settings) { + const preferences = []; + + (Array.isArray(settings) ? settings : [settings]).forEach((setting) => { + preferences.push( + PreferenceStore.setPreference( + Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, + setting, + String(this.state.settings[setting]) + ) + ); + }); + + Client.savePreferences(preferences, () => { PreferenceStore.emitChange(); this.updateSection(''); @@ -118,6 +162,66 @@ export default class AdvancedSettingsDisplay extends React.Component { ); } + let previewFeaturesSection; + let previewFeaturesSectionDivider; + if (this.state.preReleaseFeaturesKeys.length > 0) { + previewFeaturesSectionDivider = ( + <div className='divider-light'/> + ); + + if (this.props.activeSection === 'advancedPreviewFeatures') { + const inputs = []; + + this.state.preReleaseFeaturesKeys.forEach((key) => { + const feature = this.state.preReleaseFeatures[key]; + inputs.push( + <div key={'advancedPreviewFeatures_' + feature.label}> + <div className='checkbox'> + <label> + <input + type='checkbox' + checked={this.state.settings[Constants.FeatureTogglePrefix + feature.label] === 'true'} + onChange={(e) => { + this.toggleFeature(feature.label, e.target.checked); + }} + /> + {feature.description} + </label> + </div> + </div> + ); + }); + + inputs.push( + <div key='advancedPreviewFeatures_helptext'> + <br/> + {'Check any pre-released features you\'d like to preview.'} + </div> + ); + + previewFeaturesSection = ( + <SettingItemMax + title='Preview pre-release features' + inputs={inputs} + submit={this.saveEnabledFeatures} + server_error={serverError} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + previewFeaturesSection = ( + <SettingItemMin + title='Preview pre-release features' + describe={this.state.enabledFeatures + (this.state.enabledFeatures === 1 ? ' Feature ' : ' Features ') + 'enabled'} + updateSection={() => this.props.updateSection('advancedPreviewFeatures')} + /> + ); + } + } + return ( <div> <div className='modal-header'> @@ -145,6 +249,8 @@ export default class AdvancedSettingsDisplay extends React.Component { <h3 className='tab-header'>{'Advanced Settings'}</h3> <div className='divider-dark first'/> {ctrlSendSection} + {previewFeaturesSectionDivider} + {previewFeaturesSection} <div className='divider-dark'/> </div> </div> diff --git a/web/react/stores/post_store.jsx b/web/react/stores/post_store.jsx index 24b0d0dd0..a8f0f9c63 100644 --- a/web/react/stores/post_store.jsx +++ b/web/react/stores/post_store.jsx @@ -39,6 +39,7 @@ class PostStoreClass extends EventEmitter { this.makePostsInfo = this.makePostsInfo.bind(this); + this.getPost = this.getPost.bind(this); this.getAllPosts = this.getAllPosts.bind(this); this.getEarliestPost = this.getEarliestPost.bind(this); this.getLatestPost = this.getLatestPost.bind(this); @@ -160,6 +161,17 @@ class PostStoreClass extends EventEmitter { } } + getPost(channelId, postId) { + const posts = this.postsInfo[channelId].postList; + let post = null; + + if (posts.posts.hasOwnProperty(postId)) { + post = Object.assign({}, posts.posts[postId]); + } + + return post; + } + getAllPosts(id) { if (this.postsInfo.hasOwnProperty(id)) { return Object.assign({}, this.postsInfo[id].postList); @@ -554,7 +566,7 @@ class PostStoreClass extends EventEmitter { return 0; } getCommentCount(post) { - const posts = this.getPosts(post.channel_id).posts; + const posts = this.getAllPosts(post.channel_id).posts; let commentCount = 0; for (const id in posts) { diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index 372e15556..2009e07dd 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -398,5 +398,12 @@ export default { }, NotificationPrefs: { MENTION: 'mention' + }, + FeatureTogglePrefix: 'feature_enabled_', + PRE_RELEASE_FEATURES: { + MARKDOWN_PREVIEW: { + label: 'markdown_preview', // github issue: https://github.com/mattermost/platform/pull/1389 + description: 'Show markdown preview option in message input box' + } } }; diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 9b2f7e057..80c377d7f 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -1221,3 +1221,7 @@ export function getPostTerm(post) { return postTerm; } + +export function isFeatureEnabled(feature) { + return PreferenceStore.getPreference(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, Constants.FeatureTogglePrefix + feature.label, {value: 'false'}).value === 'true'; +} diff --git a/web/sass-files/sass/partials/_base.scss b/web/sass-files/sass/partials/_base.scss index 2fc63443e..7efe70cb4 100644 --- a/web/sass-files/sass/partials/_base.scss +++ b/web/sass-files/sass/partials/_base.scss @@ -116,7 +116,7 @@ a:focus, a:hover { .btn { &.btn-danger { color: #fff; - &:hover, &:active { + &:hover, &:active, &:focus { color: #fff; } } diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss index b7609bb7d..da161e54f 100644 --- a/web/sass-files/sass/partials/_post.scss +++ b/web/sass-files/sass/partials/_post.scss @@ -53,6 +53,7 @@ body.ios { top: 0; left: 0; box-shadow: none; + white-space: normal; } .textbox-preview-link, .textbox-help-link { position: absolute; @@ -283,14 +284,14 @@ body.ios { .custom-textarea { padding-top: 8px; padding-right: 28px; - max-height: 160px; + max-height: 162px !important; overflow: auto; line-height: 1.5; } .textarea-div { padding-top: 8px; padding-right: 30px; - max-height: 160px; + max-height: 163px !important; overflow: auto; line-height: 1.5; } |