summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--api/admin_test.go8
-rw-r--r--api/user.go1
-rw-r--r--doc/help/Notifications.md31
-rw-r--r--doc/help/Team-Settings.md3
-rw-r--r--doc/install/Production-Ubuntu.md6
-rw-r--r--model/preference.go1
-rw-r--r--store/sql_post_store.go17
-rw-r--r--store/sql_post_store_test.go5
-rw-r--r--store/sql_preference_store.go47
-rw-r--r--store/sql_preference_store_test.go135
-rw-r--r--store/sql_store.go2
-rw-r--r--store/store.go1
-rw-r--r--utils/time.go23
-rw-r--r--utils/time_test.go50
-rw-r--r--web/react/components/delete_post_modal.jsx1
-rw-r--r--web/react/components/textbox.jsx23
-rw-r--r--web/react/components/user_settings/user_settings_advanced.jsx144
-rw-r--r--web/react/stores/post_store.jsx14
-rw-r--r--web/react/utils/constants.jsx7
-rw-r--r--web/react/utils/markdown.jsx114
-rw-r--r--web/react/utils/utils.jsx4
-rw-r--r--web/sass-files/sass/partials/_base.scss2
-rw-r--r--web/sass-files/sass/partials/_post.scss12
23 files changed, 552 insertions, 99 deletions
diff --git a/api/admin_test.go b/api/admin_test.go
index 0db5caa4c..0a1682a99 100644
--- a/api/admin_test.go
+++ b/api/admin_test.go
@@ -235,6 +235,10 @@ func TestGetPostCount(t *testing.T) {
post1 := &model.Post{ChannelId: channel1.Id, Message: "a" + model.NewId() + "a"}
post1 = Client.Must(Client.CreatePost(post1)).Data.(*model.Post)
+ // manually update creation time, since it's always set to 0 upon saving and we only retrieve posts < today
+ Srv.Store.(*store.SqlStore).GetMaster().Exec("UPDATE Posts SET CreateAt = :CreateAt WHERE ChannelId = :ChannelId",
+ map[string]interface{}{"ChannelId": channel1.Id, "CreateAt": utils.MillisFromTime(utils.Yesterday())})
+
if _, err := Client.GetAnalytics(team.Id, "post_counts_day"); err == nil {
t.Fatal("Shouldn't have permissions")
}
@@ -276,6 +280,10 @@ func TestUserCountsWithPostsByDay(t *testing.T) {
post1 := &model.Post{ChannelId: channel1.Id, Message: "a" + model.NewId() + "a"}
post1 = Client.Must(Client.CreatePost(post1)).Data.(*model.Post)
+ // manually update creation time, since it's always set to 0 upon saving and we only retrieve posts < today
+ Srv.Store.(*store.SqlStore).GetMaster().Exec("UPDATE Posts SET CreateAt = :CreateAt WHERE ChannelId = :ChannelId",
+ map[string]interface{}{"ChannelId": channel1.Id, "CreateAt": utils.MillisFromTime(utils.Yesterday())})
+
if _, err := Client.GetAnalytics(team.Id, "user_counts_with_posts_day"); err == nil {
t.Fatal("Shouldn't have permissions")
}
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/help/Notifications.md b/doc/help/Notifications.md
new file mode 100644
index 000000000..31f06e713
--- /dev/null
+++ b/doc/help/Notifications.md
@@ -0,0 +1,31 @@
+# Notifications
+___
+
+Notifications in Mattermost alert you to unread mentions and messages.
+
+## Types of Notifications
+
+#### Email Notifications
+These are emails sent to your primary email address for any mentions you receive while offline or inactive.
+- Users are offline when they do not have Mattermost open.
+- Users are inactive when they have Mattermost open but haven’t performed an action for a set amount of time.
+- You can change the email to which these notifications are sent in **Account Settings** > **General** > **Email**.
+- You can turn email notifications on or off in **Account Settings** > **Notifications** > **Email Notifications**.
+
+#### Desktop Notifications
+These are browser notifications that are by default sent for all activity.
+- You can adjust this setting in **Account Settings** > **Notifications** > **Send Desktop Notifications**.
+- Channel specific notifications are automatically set to the global default but can be modified in **Channel Settings** > **Notification Preferences** > **Send Desktop Notifications**.
+- Desktop notifications are available on Firefox, Safari, and Chrome.
+
+
+#### Sound Notifications
+These accompany each desktop notification by default.
+- You can change this setting in **Account Settings** > **Notifications** > **Desktop Notification Sounds**.
+
+
+#### Browser Tab Notifications
+These appear in the Mattermost tab and inform you of any unread messages and alert you to the number of mentions you have.
+- Unread messages are denoted by an asterisk (*) next to the Mattermost icon.
+- Mentions and replies are denoted by a red Mattermost icon.
+- The total number of unread mentions and replies are shown in brackets next to the Mattermost icon. For example, if you have 3 unread mentions, you’ll see a (3) in the browser tab.
diff --git a/doc/help/Team-Settings.md b/doc/help/Team-Settings.md
index 7c8665565..fead9f4ca 100644
--- a/doc/help/Team-Settings.md
+++ b/doc/help/Team-Settings.md
@@ -64,7 +64,8 @@ The Slack Import feature in Mattermost is in "Beta" and focus is on supporting m
#### Notes:
-- Newly added markdown suppport in Slack's Posts 2.0 feature announced on September 28, 2015 is not yet supported.
+- Users are not automatically added to channels or groups when importing from Slack.
+- Newly added markdown support 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.
- In Beta, Slack accounts with usernames or email addresses identical to existing Mattermost accounts will not import and mentions do not resolve as Mattermost usernames (still shows Slack ID). No pre-check or roll-back is currently offered.
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_post_store.go b/store/sql_post_store.go
index cc596074f..1831eb23c 100644
--- a/store/sql_post_store.go
+++ b/store/sql_post_store.go
@@ -807,6 +807,7 @@ func (s SqlPostStore) AnalyticsUserCountsWithPostsByDay(teamId string) StoreChan
WHERE
Posts.ChannelId = Channels.Id
AND Channels.TeamId = :TeamId
+ AND Posts.CreateAt <= :EndTime
ORDER BY Name DESC) AS t1
GROUP BY Name
ORDER BY Name DESC
@@ -825,17 +826,20 @@ func (s SqlPostStore) AnalyticsUserCountsWithPostsByDay(teamId string) StoreChan
WHERE
Posts.ChannelId = Channels.Id
AND Channels.TeamId = :TeamId
+ AND Posts.CreateAt <= :EndTime
ORDER BY Name DESC) AS t1
GROUP BY Name
ORDER BY Name DESC
LIMIT 30`
}
+ end := utils.MillisFromTime(utils.EndOfDay(utils.Yesterday()))
+
var rows model.AnalyticsRows
_, err := s.GetReplica().Select(
&rows,
query,
- map[string]interface{}{"TeamId": teamId, "Time": model.GetMillis() - 1000*60*60*24*31})
+ map[string]interface{}{"TeamId": teamId, "EndTime": end})
if err != nil {
result.Err = model.NewAppError("SqlPostStore.AnalyticsUserCountsWithPostsByDay", "We couldn't get user counts with posts", err.Error())
} else {
@@ -867,7 +871,8 @@ func (s SqlPostStore) AnalyticsPostCountsByDay(teamId string) StoreChannel {
WHERE
Posts.ChannelId = Channels.Id
AND Channels.TeamId = :TeamId
- AND Posts.CreateAt >:Time) AS t1
+ AND Posts.CreateAt <= :EndTime
+ AND Posts.CreateAt >= :StartTime) AS t1
GROUP BY Name
ORDER BY Name DESC
LIMIT 30`
@@ -885,17 +890,21 @@ func (s SqlPostStore) AnalyticsPostCountsByDay(teamId string) StoreChannel {
WHERE
Posts.ChannelId = Channels.Id
AND Channels.TeamId = :TeamId
- AND Posts.CreateAt > :Time) AS t1
+ AND Posts.CreateAt <= :EndTime
+ AND Posts.CreateAt >= :StartTime) AS t1
GROUP BY Name
ORDER BY Name DESC
LIMIT 30`
}
+ end := utils.MillisFromTime(utils.EndOfDay(utils.Yesterday()))
+ start := utils.MillisFromTime(utils.StartOfDay(utils.Yesterday().AddDate(0, 0, -31)))
+
var rows model.AnalyticsRows
_, err := s.GetReplica().Select(
&rows,
query,
- map[string]interface{}{"TeamId": teamId, "Time": model.GetMillis() - 1000*60*60*24*31})
+ map[string]interface{}{"TeamId": teamId, "StartTime": start, "EndTime": end})
if err != nil {
result.Err = model.NewAppError("SqlPostStore.AnalyticsPostCountsByDay", "We couldn't get post counts by day", err.Error())
} else {
diff --git a/store/sql_post_store_test.go b/store/sql_post_store_test.go
index d9b087ea7..12b50cad3 100644
--- a/store/sql_post_store_test.go
+++ b/store/sql_post_store_test.go
@@ -9,6 +9,7 @@ import (
"time"
"github.com/mattermost/platform/model"
+ "github.com/mattermost/platform/utils"
)
func TestPostStoreSave(t *testing.T) {
@@ -776,7 +777,7 @@ func TestUserCountsWithPostsByDay(t *testing.T) {
o1 := &model.Post{}
o1.ChannelId = c1.Id
o1.UserId = model.NewId()
- o1.CreateAt = model.GetMillis()
+ o1.CreateAt = utils.MillisFromTime(utils.Yesterday())
o1.Message = "a" + model.NewId() + "b"
o1 = Must(store.Post().Save(o1)).(*model.Post)
@@ -836,7 +837,7 @@ func TestPostCountsByDay(t *testing.T) {
o1 := &model.Post{}
o1.ChannelId = c1.Id
o1.UserId = model.NewId()
- o1.CreateAt = model.GetMillis()
+ o1.CreateAt = utils.MillisFromTime(utils.Yesterday())
o1.Message = "a" + model.NewId() + "b"
o1 = Must(store.Post().Save(o1)).(*model.Post)
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/utils/time.go b/utils/time.go
new file mode 100644
index 000000000..7d5afdf8f
--- /dev/null
+++ b/utils/time.go
@@ -0,0 +1,23 @@
+package utils
+
+import (
+ "time"
+)
+
+func MillisFromTime(t time.Time) int64 {
+ return t.UnixNano() / int64(time.Millisecond)
+}
+
+func StartOfDay(t time.Time) time.Time {
+ year, month, day := t.Date()
+ return time.Date(year, month, day, 0, 0, 0, 0, t.Location())
+}
+
+func EndOfDay(t time.Time) time.Time {
+ year, month, day := t.Date()
+ return time.Date(year, month, day, 23, 59, 59, 999999999, t.Location())
+}
+
+func Yesterday() time.Time {
+ return time.Now().AddDate(0, 0, -1)
+}
diff --git a/utils/time_test.go b/utils/time_test.go
new file mode 100644
index 000000000..7d65046bf
--- /dev/null
+++ b/utils/time_test.go
@@ -0,0 +1,50 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package utils
+
+import (
+ "testing"
+ "time"
+)
+
+var format = "2006-01-02 15:04:05.000000000"
+
+func TestMillisFromTime(t *testing.T) {
+ input, _ := time.Parse(format, "2015-01-01 12:34:00.000000000")
+ actual := MillisFromTime(input)
+ expected := int64(1420115640000)
+
+ if actual != expected {
+ t.Fatalf("TestMillisFromTime failed, %v=%v", expected, actual)
+ }
+}
+
+func TestYesterday(t *testing.T) {
+ actual := Yesterday()
+ expected := time.Now().AddDate(0, 0, -1)
+
+ if actual.Year() != expected.Year() || actual.Day() != expected.Day() || actual.Month() != expected.Month() {
+ t.Fatalf("TestYesterday failed, %v=%v", expected, actual)
+ }
+}
+
+func TestStartOfDay(t *testing.T) {
+ input, _ := time.Parse(format, "2015-01-01 12:34:00.000000000")
+ actual := StartOfDay(input)
+ expected, _ := time.Parse(format, "2015-01-01 00:00:00.000000000")
+
+ if actual != expected {
+ t.Fatalf("TestStartOfDay failed, %v=%v", expected, actual)
+ }
+}
+
+func TestEndOfDay(t *testing.T) {
+ input, _ := time.Parse(format, "2015-01-01 12:34:00.000000000")
+ actual := EndOfDay(input)
+ expected, _ := time.Parse(format, "2015-01-01 23:59:59.999999999")
+
+ if actual != expected {
+ t.Fatalf("TestEndOfDay failed, %v=%v", expected, actual)
+ }
+}
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/markdown.jsx b/web/react/utils/markdown.jsx
index b0ec64bfd..9d9bdfb7a 100644
--- a/web/react/utils/markdown.jsx
+++ b/web/react/utils/markdown.jsx
@@ -361,78 +361,78 @@ class MattermostLexer extends marked.Lexer {
// list
cap = this.rules.list.exec(src);
if (cap) {
+ src = src.substring(cap[0].length);
const bull = cap[2];
- let l = cap[0].length;
+
+ this.tokens.push({
+ type: 'list_start',
+ ordered: bull.length > 1
+ });
// Get each top-level item.
cap = cap[0].match(this.rules.item);
- if (cap.length > 1) {
- src = src.substring(l);
-
- this.tokens.push({
- type: 'list_start',
- ordered: bull.length > 1
- });
-
- let next = false;
- l = cap.length;
-
- for (let i = 0; i < l; i++) {
- let item = cap[i];
-
- // Remove the list item's bullet
- // so it is seen as the next token.
- let space = item.length;
- item = item.replace(/^ *([*+-]|\d+\.) +/, '');
-
- // Outdent whatever the
- // list item contains. Hacky.
- if (~item.indexOf('\n ')) {
- space -= item.length;
- item = this.options.pedantic ? item.replace(/^ {1,4}/gm, '') : item.replace(new RegExp('^ \{1,' + space + '\}', 'gm'), '');
- }
+ let next = false;
+ const l = cap.length;
+ let i = 0;
+
+ for (; i < l; i++) {
+ let item = cap[i];
+
+ // Remove the list item's bullet
+ // so it is seen as the next token.
+ let space = item.length;
+ item = item.replace(/^ *([*+-]|\d+\.) +/, '');
+
+ // Outdent whatever the
+ // list item contains. Hacky.
+ if (~item.indexOf('\n ')) {
+ space -= item.length;
+ item = this.options.pedantic ?
+ item.replace(/^ {1,4}/gm, '') :
+ item.replace(new RegExp('^ {1,' + space + '}', 'gm'), '');
+ }
- // Determine whether the next list item belongs here.
- // Backpedal if it does not belong in this list.
- if (this.options.smartLists && i !== l - 1) {
- const bullet = /(?:[*+-]|\d+\.)/;
- const b = bullet.exec(cap[i + 1])[0];
- if (bull !== b && !(bull.length > 1 && b.length > 1)) {
- src = cap.slice(i + 1).join('\n') + src;
- i = l - 1;
- }
+ // Determine whether the next list item belongs here.
+ // Backpedal if it does not belong in this list.
+ if (this.options.smartLists && i !== l - 1) {
+ const b = this.rules.bullet.exec(cap[i + 1])[0];
+ if (bull !== b && !(bull.length > 1 && b.length > 1)) {
+ src = cap.slice(i + 1).join('\n') + src;
+ i = l - 1;
}
+ }
- // Determine whether item is loose or not.
- // Use: /(^|\n)(?! )[^\n]+\n\n(?!\s*$)/
- // for discount behavior.
- let loose = next || (/\n\n(?!\s*$)/).test(item);
- if (i !== l - 1) {
- next = item.charAt(item.length - 1) === '\n';
- if (!loose) {
- loose = next;
- }
+ // Determine whether item is loose or not.
+ // Use: /(^|\n)(?! )[^\n]+\n\n(?!\s*$)/
+ // for discount behavior.
+ let loose = next || (/\n\n(?!\s*$)/).test(item);
+ if (i !== l - 1) {
+ next = item.charAt(item.length - 1) === '\n';
+ if (!loose) {
+ loose = next;
}
-
- this.tokens.push({
- type: loose ? 'loose_item_start' : 'list_item_start'
- });
-
- // Recurse.
- this.token(item, false, bq);
-
- this.tokens.push({
- type: 'list_item_end'
- });
}
this.tokens.push({
- type: 'list_end'
+ type: loose ?
+ 'loose_item_start' :
+ 'list_item_start'
});
- continue;
+ // Recurse.
+ this.token(item, false, bq);
+
+ this.tokens.push({
+ type: 'list_item_end'
+ });
}
+
+ this.tokens.push({
+ type: 'list_end'
+ });
+
+ continue;
}
// html
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..b7a305427 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;
}
@@ -373,9 +374,9 @@ body.ios {
ul {
margin: 0;
padding: 0;
- list-style: none;
}
+
p {
margin: 0 0 1em;
line-height: 1.6em;
@@ -602,6 +603,11 @@ body.ios {
padding: 5px 0 0 20px;
}
+ ul, ol {
+ li ul, li ol {
+ padding: 0 0 0 20px
+ }
+ }
}
.post__link {