summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--Makefile20
-rw-r--r--api/admin_test.go8
-rw-r--r--api/post.go16
-rw-r--r--api/user.go1
-rw-r--r--config/config.json5
-rw-r--r--doc/help/Notifications.md31
-rw-r--r--doc/help/README.md15
-rw-r--r--doc/help/Team-Settings.md3
-rw-r--r--doc/install/Configuration-Settings.md13
-rw-r--r--doc/install/Production-Ubuntu.md6
-rw-r--r--doc/process/overview.md2
-rw-r--r--docker/dev/config_docker.json5
-rw-r--r--docker/local/config_docker.json5
-rw-r--r--mattermost.go26
-rw-r--r--model/config.go18
-rw-r--r--model/push_notification.go45
-rw-r--r--model/push_notification_test.go19
-rw-r--r--store/sql_post_store.go17
-rw-r--r--store/sql_post_store_test.go5
-rw-r--r--utils/apns.go37
-rw-r--r--utils/config.go1
-rw-r--r--utils/time.go23
-rw-r--r--utils/time_test.go50
-rw-r--r--web/react/components/admin_console/email_settings.jsx67
-rw-r--r--web/react/components/delete_post_modal.jsx1
-rw-r--r--web/react/components/member_list_item.jsx2
-rw-r--r--web/react/components/member_list_team_item.jsx2
-rw-r--r--web/react/components/popover_list_members.jsx46
-rw-r--r--web/react/components/post.jsx7
-rw-r--r--web/react/components/posts_view.jsx67
-rw-r--r--web/react/components/posts_view_container.jsx4
-rw-r--r--web/react/components/sidebar.jsx4
-rw-r--r--web/react/components/user_profile.jsx6
-rw-r--r--web/react/components/view_image.jsx34
-rw-r--r--web/react/stores/post_store.jsx16
-rw-r--r--web/react/utils/constants.jsx1
-rw-r--r--web/react/utils/markdown.jsx124
-rw-r--r--web/react/utils/text_formatting.jsx2
-rw-r--r--web/react/utils/utils.jsx18
-rw-r--r--web/sass-files/sass/partials/_base.scss2
-rw-r--r--web/sass-files/sass/partials/_modal.scss40
-rw-r--r--web/sass-files/sass/partials/_post.scss12
-rw-r--r--web/sass-files/sass/partials/_responsive.scss6
44 files changed, 602 insertions, 232 deletions
diff --git a/.gitignore b/.gitignore
index 50cdca100..6e433df3c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,8 @@ web/static/js/libs*.js
# Build Targets
.prepare
+.prepare-go
+.prepare-jsx
# Compiled Object files, Static and Dynamic libs (Shared Objects)
*.o
diff --git a/Makefile b/Makefile
index 87219e68c..098422052 100644
--- a/Makefile
+++ b/Makefile
@@ -33,7 +33,7 @@ dist: | build-server build-client go-test package
mv ./model/version.go.bak ./model/version.go
dist-local: | start-docker dist
-
+
dist-travis: | travis-init build-container
start-docker:
@@ -153,7 +153,7 @@ go-test:
$(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=120s ./utils || exit 1
$(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=120s ./web || exit 1
-test: | start-docker go-test
+test: | start-docker .prepare-go go-test
travis-init:
@echo Setting up enviroment for travis
@@ -217,25 +217,29 @@ clean: stop-docker
rm -rf Godeps/_workspace/pkg/
rm -f mattermost.log
- rm -f .prepare
+ rm -f .prepare-go .prepare-jsx
nuke: | clean clean-docker
rm -rf data
-.prepare:
- @echo Preparation for run step
-
+.prepare-go:
+ @echo Preparation for running go code
go get $(GOFLAGS) github.com/tools/godep
+ touch $@
+
+.prepare-jsx:
+ @echo Preparation for compiling jsx code
+
cd web/react/ && npm install
cd web/react/ && npm run build-libs
touch $@
-run: start-docker .prepare
+run: start-docker .prepare-go .prepare-jsx
mkdir -p web/static/js
- @echo Starting react processor
+ @echo Starting react processo
cd web/react && npm start &
@echo Starting go web server
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/post.go b/api/post.go
index 88d0127d3..81cc9a1c6 100644
--- a/api/post.go
+++ b/api/post.go
@@ -536,7 +536,7 @@ func sendNotificationsAndForget(c *Context, post *model.Post, team *model.Team,
l4g.Error("Failed to send mention email successfully email=%v err=%v", profileMap[id].Email, err)
}
- if len(utils.Cfg.EmailSettings.ApplePushServer) > 0 {
+ if *utils.Cfg.EmailSettings.SendPushNotifications {
sessionChan := Srv.Store.Session().GetSessions(id)
if result := <-sessionChan; result.Err != nil {
l4g.Error("Failed to retrieve sessions in notifications id=%v, err=%v", id, result.Err)
@@ -548,7 +548,19 @@ func sendNotificationsAndForget(c *Context, post *model.Post, team *model.Team,
if len(session.DeviceId) > 0 && alreadySeen[session.DeviceId] == "" && strings.HasPrefix(session.DeviceId, "apple:") {
alreadySeen[session.DeviceId] = session.DeviceId
- utils.SendAppleNotifyAndForget(strings.TrimPrefix(session.DeviceId, "apple:"), subjectPage.Render(), 1)
+ msg := model.PushNotification{}
+ msg.Platform = model.PUSH_NOTIFY_APPLE
+ msg.Message = subjectPage.Render()
+ msg.Badge = 1
+ msg.DeviceId = strings.TrimPrefix(session.DeviceId, "apple:")
+ msg.ServerId = utils.CfgDiagnosticId
+
+ httpClient := http.Client{}
+ request, _ := http.NewRequest("POST", *utils.Cfg.EmailSettings.PushNotificationServer+"/api/v1/send_push", strings.NewReader(msg.ToJson()))
+
+ if _, err := httpClient.Do(request); err != nil {
+ l4g.Error("Failed to send push notificationid=%v, err=%v", id, err)
+ }
}
}
}
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/config/config.json b/config/config.json
index 932bed8a2..999ea8a83 100644
--- a/config/config.json
+++ b/config/config.json
@@ -68,9 +68,8 @@
"ConnectionSecurity": "",
"InviteSalt": "bjlSR4QqkXFBr7TP4oDzlfZmcNuH9YoS",
"PasswordResetSalt": "vZ4DcKyVVRlKHHJpexcuXzojkE5PZ5eL",
- "ApplePushServer": "",
- "ApplePushCertPublic": "",
- "ApplePushCertPrivate": ""
+ "SendPushNotifications": true,
+ "PushNotificationServer": "https://push.mattermost.com"
},
"RateLimitSettings": {
"EnableRateLimiter": true,
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/README.md b/doc/help/README.md
index 3b3c1709b..23c8b192d 100644
--- a/doc/help/README.md
+++ b/doc/help/README.md
@@ -5,13 +5,18 @@
- User Interface
- Main Menu
- - [Team Settings ](https://github.com/mattermost/platform/blob/help-docs-update/doc/help/Team-Settings.md)
- - [General Settings](https://github.com/mattermost/platform/blob/help-docs-update/doc/help/Team-Settings.md#general)
- - [Slack Import](https://github.com/mattermost/platform/blob/help-docs-update/doc/help/Team-Settings.md#import-from-slack-beta)
+ - [Team Settings ](Team-Settings.md)
+ - [General Settings](Team-Settings.md#general)
+ - [Slack Import](Team-Settings.md#import-from-slack-beta)
- [Manage Members](Manage-Members.md)
- - Messaging
- - [Mattermost Markdown Formatting](help/Markdown.md)
+ - [Account Settings](Account-Settings.md)
+ - [Messaging](Messaging.md)
+ - [Mattermost Markdown Formatting](Markdown.md)
- [Search](Search.md)
+ - [Channels](Channels.md)
+ - [Channel Types](Channels.md#channel-types)
+ - [Managing Channels](Channels.md#managing-channels)
+ - [Channel Settings](Channels.md#channel-settings)
- System Console
- Team
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/Configuration-Settings.md b/doc/install/Configuration-Settings.md
index a92893753..44730d40f 100644
--- a/doc/install/Configuration-Settings.md
+++ b/doc/install/Configuration-Settings.md
@@ -29,7 +29,12 @@ Set this key to enable embedding of YouTube video previews based on hyperlinks a
#### Webhooks
```"EnableIncomingWebhooks": true```
-Developers building integrations can create webhook URLs for channels and private groups. Please see http://mattermost.org/webhooks to learn about creating webhooks, view samples, and to let the community know about integrations you have built. "true": Incoming webhooks will be allowed. To manage incoming webhooks, go to Account Settings -> Integrations. The webhook URLs created in Account Settings can be used by external applications to create posts in any channels or private groups that you have access to; “false”: The Integrations tab of Account Settings is hidden and incoming webhooks are disabled.
+Developers building integrations can create webhook URLs for channels and private groups. Please see http://mattermost.org/webhooks to learn about creating webhooks, view samples, and to let the community know about integrations you have built. "true": Incoming webhooks will be allowed. To manage incoming webhooks, go to **Account Settings -> Integrations**. The webhook URLs created in Account Settings can be used by external applications to create posts in any channels or private groups that you have access to; “false”: The Integrations > Incoming Webhooks section of Account Settings is hidden and all incoming webhooks are disabled.
+
+Security note: By enabling this feature, users may be able to perform [phishing attacks](https://en.wikipedia.org/wiki/Phishing) by attempting to impersonate other users. To combat these attacks, a BOT tag appears next to all posts from a webhook. Enable at your own risk.
+
+```"EnableOutgoingWebhooks": true```
+Developers building integrations can create webhook tokens for public channels. Trigger words are used to fire new message events to external integrations. For security reasons, outgoing webhooks are only available in public channels. Please see our [documentation page](https://github.com/mattermost/platform/blob/master/doc/integrations/webhooks/Outgoing-Webhooks.md) to learn about creating webhooks and view samples. "true": Outgoing webhooks will be allowed. To manage outgoing webhooks, go to **Account Settings -> Integrations**; “false”: The Integrations > Outgoing Webhooks section of Account Settings is hidden and all outgoing webhooks are disabled.
Security note: By enabling this feature, users may be able to perform [phishing attacks](https://en.wikipedia.org/wiki/Phishing) by attempting to impersonate other users. To combat these attacks, a BOT tag appears next to all posts from a webhook. Enable at your own risk.
@@ -58,6 +63,12 @@ Maximum number of users per team, including both active and inactive users.
```"RestrictCreationToDomains": ""```
Teams can only be created by a verified email from this list of comma-separated domains (e.g. "corp.mattermost.com, mattermost.org").
+```"RestrictTeamNames": true```
+"true": Newly created team names cannot contain the following restricted words: www, web, admin, support, notify, test, demo, mail, team, channel, internal, localhost, dockerhost, stag, post, cluster, api, oauth; “false”: Newly created team names are not restricted.
+
+```"EnableTeamListing": false```
+"true": Teams that are configured to appear in the team directory will appear on the system main page. Teams can configure this setting from **Team Settings -> Include this team in the Team Directory**; "true": Team directory on the system main page is disabled.
+
### SQL Settings
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/doc/process/overview.md b/doc/process/overview.md
index af632e393..b34908782 100644
--- a/doc/process/overview.md
+++ b/doc/process/overview.md
@@ -64,7 +64,7 @@ Mattermost priorities are managed in Jira tickets, which are created by the core
On non-holiday weekdays new tickets are reviewed in a process called "triage", and assigned a Fix Version of "backlog", indicating the ticket has enough specificity that it can be assigned to a developer to be completed.
-By default, all tickets are created as internal-only, and the triage process reviews them for sufficient specifity and abscense of sensitive information before switching their visibility to public as part of the triage process.
+By default, all tickets are created as public unless they contain sensitive information. The triage process reviews them for sufficient specifity. If the ticket is unclear, triage may reassign the ticket back to the original reporter to add more details.
View [current issues scheduled for the next triage meeting](https://mattermost.atlassian.net/browse/PLT-1203?filter=10105).
diff --git a/docker/dev/config_docker.json b/docker/dev/config_docker.json
index c23a72cd1..4c5502ddd 100644
--- a/docker/dev/config_docker.json
+++ b/docker/dev/config_docker.json
@@ -68,9 +68,8 @@
"ConnectionSecurity": "",
"InviteSalt": "bjlSR4QqkXFBr7TP4oDzlfZmcNuH9YoS",
"PasswordResetSalt": "vZ4DcKyVVRlKHHJpexcuXzojkE5PZ5eL",
- "ApplePushServer": "",
- "ApplePushCertPublic": "",
- "ApplePushCertPrivate": ""
+ "SendPushNotifications": true,
+ "PushNotificationServer": "https://push.mattermost.com"
},
"RateLimitSettings": {
"EnableRateLimiter": true,
diff --git a/docker/local/config_docker.json b/docker/local/config_docker.json
index c23a72cd1..4c5502ddd 100644
--- a/docker/local/config_docker.json
+++ b/docker/local/config_docker.json
@@ -68,9 +68,8 @@
"ConnectionSecurity": "",
"InviteSalt": "bjlSR4QqkXFBr7TP4oDzlfZmcNuH9YoS",
"PasswordResetSalt": "vZ4DcKyVVRlKHHJpexcuXzojkE5PZ5eL",
- "ApplePushServer": "",
- "ApplePushCertPublic": "",
- "ApplePushCertPrivate": ""
+ "SendPushNotifications": true,
+ "PushNotificationServer": "https://push.mattermost.com"
},
"RateLimitSettings": {
"EnableRateLimiter": true,
diff --git a/mattermost.go b/mattermost.go
index 2d5727400..eaab1de88 100644
--- a/mattermost.go
+++ b/mattermost.go
@@ -68,6 +68,7 @@ func main() {
manualtesting.InitManualTesting()
}
+ setDiagnosticId()
runSecurityAndDiagnosticsJobAndForget()
// wait for kill signal before attempting to gracefully shutdown
@@ -80,6 +81,21 @@ func main() {
}
}
+func setDiagnosticId() {
+ if result := <-api.Srv.Store.System().Get(); result.Err == nil {
+ props := result.Data.(model.StringMap)
+
+ id := props[model.SYSTEM_DIAGNOSTIC_ID]
+ if len(id) == 0 {
+ id = model.NewId()
+ systemId := &model.System{Name: model.SYSTEM_DIAGNOSTIC_ID, Value: id}
+ <-api.Srv.Store.System().Save(systemId)
+ }
+
+ utils.CfgDiagnosticId = id
+ }
+}
+
func runSecurityAndDiagnosticsJobAndForget() {
go func() {
for {
@@ -92,15 +108,9 @@ func runSecurityAndDiagnosticsJobAndForget() {
if (currentTime - lastSecurityTime) > 1000*60*60*24*1 {
l4g.Debug("Checking for security update from Mattermost")
- id := props[model.SYSTEM_DIAGNOSTIC_ID]
- if len(id) == 0 {
- id = model.NewId()
- systemId := &model.System{Name: model.SYSTEM_DIAGNOSTIC_ID, Value: id}
- <-api.Srv.Store.System().Save(systemId)
- }
-
v := url.Values{}
- v.Set(utils.PROP_DIAGNOSTIC_ID, id)
+
+ v.Set(utils.PROP_DIAGNOSTIC_ID, utils.CfgDiagnosticId)
v.Set(utils.PROP_DIAGNOSTIC_BUILD, model.CurrentVersion+"."+model.BuildNumber)
v.Set(utils.PROP_DIAGNOSTIC_DATABASE, utils.Cfg.SqlSettings.DriverName)
v.Set(utils.PROP_DIAGNOSTIC_OS, runtime.GOOS)
diff --git a/model/config.go b/model/config.go
index 50a8dc133..195cefae8 100644
--- a/model/config.go
+++ b/model/config.go
@@ -96,11 +96,8 @@ type EmailSettings struct {
ConnectionSecurity string
InviteSalt string
PasswordResetSalt string
-
- // For Future Use
- ApplePushServer string
- ApplePushCertPublic string
- ApplePushCertPrivate string
+ SendPushNotifications *bool
+ PushNotificationServer *string
}
type RateLimitSettings struct {
@@ -181,6 +178,17 @@ func (o *Config) SetDefaults() {
o.TeamSettings.EnableTeamListing = new(bool)
*o.TeamSettings.EnableTeamListing = false
}
+
+ if o.EmailSettings.SendPushNotifications == nil {
+ o.EmailSettings.SendPushNotifications = new(bool)
+ *o.EmailSettings.SendPushNotifications = true
+ }
+
+ if o.EmailSettings.PushNotificationServer == nil {
+ o.EmailSettings.PushNotificationServer = new(string)
+ *o.EmailSettings.PushNotificationServer = "https://push.mattermost.com"
+ }
+
}
func (o *Config) IsValid() *AppError {
diff --git a/model/push_notification.go b/model/push_notification.go
new file mode 100644
index 000000000..76f5bd125
--- /dev/null
+++ b/model/push_notification.go
@@ -0,0 +1,45 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package model
+
+import (
+ "encoding/json"
+ "io"
+)
+
+const (
+ PUSH_NOTIFY_APPLE = "apple"
+ PUSH_NOTIFY_ANDROID = "android"
+)
+
+type PushNotification struct {
+ Platform string `json:"platform"`
+ ServerId string `json:"server_id"`
+ DeviceId string `json:"device_id"`
+ Category string `json:"category"`
+ Sound string `json:"sound"`
+ Message string `json:"message"`
+ Badge int `json:"badge"`
+ ContentAvailable int `json:"cont_ava"`
+}
+
+func (me *PushNotification) ToJson() string {
+ b, err := json.Marshal(me)
+ if err != nil {
+ return ""
+ } else {
+ return string(b)
+ }
+}
+
+func PushNotificationFromJson(data io.Reader) *PushNotification {
+ decoder := json.NewDecoder(data)
+ var me PushNotification
+ err := decoder.Decode(&me)
+ if err == nil {
+ return &me
+ } else {
+ return nil
+ }
+}
diff --git a/model/push_notification_test.go b/model/push_notification_test.go
new file mode 100644
index 000000000..94329f389
--- /dev/null
+++ b/model/push_notification_test.go
@@ -0,0 +1,19 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package model
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestPushNotification(t *testing.T) {
+ msg := PushNotification{Platform: "test"}
+ json := msg.ToJson()
+ result := PushNotificationFromJson(strings.NewReader(json))
+
+ if msg.Platform != result.Platform {
+ t.Fatal("Ids do not match")
+ }
+}
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/utils/apns.go b/utils/apns.go
deleted file mode 100644
index 06e8ce6ef..000000000
--- a/utils/apns.go
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
-// See License.txt for license information.
-
-package utils
-
-import (
- l4g "code.google.com/p/log4go"
- "fmt"
- "github.com/anachronistic/apns"
- "github.com/mattermost/platform/model"
-)
-
-func SendAppleNotifyAndForget(deviceId string, message string, badge int) {
- go func() {
- if err := SendAppleNotify(deviceId, message, badge); err != nil {
- l4g.Error(fmt.Sprintf("%v %v", err.Message, err.DetailedError))
- }
- }()
-}
-
-func SendAppleNotify(deviceId string, message string, badge int) *model.AppError {
- payload := apns.NewPayload()
- payload.Alert = message
- payload.Badge = 1
-
- pn := apns.NewPushNotification()
- pn.DeviceToken = deviceId
- pn.AddPayload(payload)
- client := apns.BareClient(Cfg.EmailSettings.ApplePushServer, Cfg.EmailSettings.ApplePushCertPublic, Cfg.EmailSettings.ApplePushCertPrivate)
- resp := client.Send(pn)
-
- if resp.Error != nil {
- return model.NewAppError("", "Could not send apple push notification", fmt.Sprintf("id=%v err=%v", deviceId, resp.Error))
- } else {
- return nil
- }
-}
diff --git a/utils/config.go b/utils/config.go
index 2fd799cd1..0b292a2ca 100644
--- a/utils/config.go
+++ b/utils/config.go
@@ -24,6 +24,7 @@ const (
)
var Cfg *model.Config = &model.Config{}
+var CfgDiagnosticId = ""
var CfgLastModified int64 = 0
var CfgFileName string = ""
var ClientCfg map[string]string = map[string]string{}
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/admin_console/email_settings.jsx b/web/react/components/admin_console/email_settings.jsx
index d0565a0e0..238ace3da 100644
--- a/web/react/components/admin_console/email_settings.jsx
+++ b/web/react/components/admin_console/email_settings.jsx
@@ -18,6 +18,7 @@ export default class EmailSettings extends React.Component {
this.state = {
sendEmailNotifications: this.props.config.EmailSettings.SendEmailNotifications,
+ sendPushNotifications: this.props.config.EmailSettings.SendPushNotifications,
saveNeeded: false,
serverError: null,
emailSuccess: null,
@@ -36,6 +37,14 @@ export default class EmailSettings extends React.Component {
s.sendEmailNotifications = false;
}
+ if (action === 'sendPushNotifications_true') {
+ s.sendPushNotifications = true;
+ }
+
+ if (action === 'sendPushNotifications_false') {
+ s.sendPushNotifications = false;
+ }
+
this.setState(s);
}
@@ -43,11 +52,12 @@ export default class EmailSettings extends React.Component {
var config = this.props.config;
config.EmailSettings.EnableSignUpWithEmail = ReactDOM.findDOMNode(this.refs.allowSignUpWithEmail).checked;
config.EmailSettings.SendEmailNotifications = ReactDOM.findDOMNode(this.refs.sendEmailNotifications).checked;
+ config.EmailSettings.SendPushlNotifications = ReactDOM.findDOMNode(this.refs.sendPushNotifications).checked;
config.EmailSettings.RequireEmailVerification = ReactDOM.findDOMNode(this.refs.requireEmailVerification).checked;
- config.EmailSettings.SendEmailNotifications = ReactDOM.findDOMNode(this.refs.sendEmailNotifications).checked;
config.EmailSettings.FeedbackName = ReactDOM.findDOMNode(this.refs.feedbackName).value.trim();
config.EmailSettings.FeedbackEmail = ReactDOM.findDOMNode(this.refs.feedbackEmail).value.trim();
config.EmailSettings.SMTPServer = ReactDOM.findDOMNode(this.refs.SMTPServer).value.trim();
+ config.EmailSettings.PushNotificationServer = ReactDOM.findDOMNode(this.refs.PushNotificationServer).value.trim();
config.EmailSettings.SMTPPort = ReactDOM.findDOMNode(this.refs.SMTPPort).value.trim();
config.EmailSettings.SMTPUsername = ReactDOM.findDOMNode(this.refs.SMTPUsername).value.trim();
config.EmailSettings.SMTPPassword = ReactDOM.findDOMNode(this.refs.SMTPPassword).value.trim();
@@ -526,6 +536,61 @@ export default class EmailSettings extends React.Component {
</div>
<div className='form-group'>
+ <label
+ className='control-label col-sm-4'
+ htmlFor='sendPushNotifications'
+ >
+ {'Send Push Notifications: '}
+ </label>
+ <div className='col-sm-8'>
+ <label className='radio-inline'>
+ <input
+ type='radio'
+ name='sendPushNotifications'
+ value='true'
+ ref='sendPushNotifications'
+ defaultChecked={this.props.config.EmailSettings.SendPushNotifications}
+ onChange={this.handleChange.bind(this, 'sendPushNotifications_true')}
+ />
+ {'true'}
+ </label>
+ <label className='radio-inline'>
+ <input
+ type='radio'
+ name='sendPushNotifications'
+ value='false'
+ defaultChecked={!this.props.config.EmailSettings.SendPushNotifications}
+ onChange={this.handleChange.bind(this, 'sendPushNotifications_false')}
+ />
+ {'false'}
+ </label>
+ <p className='help-text'>{'Typically set to true in production. When true, Mattermost attempts to send iOS and Android push notifications through the push notification server.'}</p>
+ </div>
+ </div>
+
+ <div className='form-group'>
+ <label
+ className='control-label col-sm-4'
+ htmlFor='PushNotificationServer'
+ >
+ {'Push Notification Server:'}
+ </label>
+ <div className='col-sm-8'>
+ <input
+ type='text'
+ className='form-control'
+ id='PushNotificationServer'
+ ref='PushNotificationServer'
+ placeholder='E.g.: "https://push.mattermost.com"'
+ defaultValue={this.props.config.EmailSettings.PushNotificationServer}
+ onChange={this.handleChange}
+ disabled={!this.state.sendPushNotifications}
+ />
+ <p className='help-text'>{'Location of the push notification server.'}</p>
+ </div>
+ </div>
+
+ <div className='form-group'>
<div className='col-sm-12'>
{serverError}
<button
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/member_list_item.jsx b/web/react/components/member_list_item.jsx
index 390d25f2e..f5d5ab28b 100644
--- a/web/react/components/member_list_item.jsx
+++ b/web/react/components/member_list_item.jsx
@@ -110,7 +110,7 @@ export default class MemberListItem extends React.Component {
height='36'
width='36'
/>
- <div className='member-name'>{member.username}</div>
+ <div className='member-name'>{Utils.displayUsername(member.id)}</div>
<div className='member-description'>{member.email}</div>
</td>
<td className='td--action lg'>{invite}</td>
diff --git a/web/react/components/member_list_team_item.jsx b/web/react/components/member_list_team_item.jsx
index 27fb6a4c1..316fad01a 100644
--- a/web/react/components/member_list_team_item.jsx
+++ b/web/react/components/member_list_team_item.jsx
@@ -174,7 +174,7 @@ export default class MemberListTeamItem extends React.Component {
height='36'
width='36'
/>
- <span className='member-name'>{Utils.getDisplayName(user)}</span>
+ <span className='member-name'>{Utils.displayUsername(user.id)}</span>
<span className='member-email'>{email}</span>
<div className='dropdown member-drop'>
<a
diff --git a/web/react/components/popover_list_members.jsx b/web/react/components/popover_list_members.jsx
index b5000141a..f4cb542e4 100644
--- a/web/react/components/popover_list_members.jsx
+++ b/web/react/components/popover_list_members.jsx
@@ -5,6 +5,7 @@ import UserStore from '../stores/user_store.jsx';
var Popover = ReactBootstrap.Popover;
var Overlay = ReactBootstrap.Overlay;
import * as Utils from '../utils/utils.jsx';
+import Constants from '../utils/constants.jsx';
import ChannelStore from '../stores/channel_store.jsx';
@@ -68,7 +69,7 @@ export default class PopoverListMembers extends React.Component {
}
render() {
- let popoverHtml = [];
+ const popoverHtml = [];
const members = this.props.members;
const teamMembers = UserStore.getProfilesUsernameMap();
const currentUserId = UserStore.getCurrentId();
@@ -76,35 +77,13 @@ export default class PopoverListMembers extends React.Component {
if (members && teamMembers) {
members.sort((a, b) => {
- return a.username.localeCompare(b.username);
+ const aName = Utils.displayUsername(a.id);
+ const bName = Utils.displayUsername(b.id);
+
+ return aName.localeCompare(bName);
});
members.forEach((m, i) => {
- const details = [];
-
- const fullName = Utils.getFullName(m);
- if (fullName) {
- details.push(
- <span
- key={`${m.id}__full-name`}
- className='full-name'
- >
- {fullName}
- </span>
- );
- }
-
- if (m.nickname) {
- const separator = fullName ? ' - ' : '';
- details.push(
- <span
- key={`${m.nickname}__nickname`}
- >
- {separator + m.nickname}
- </span>
- );
- }
-
let button = '';
if (currentUserId !== m.id && ch.type !== 'D') {
button = (
@@ -118,7 +97,12 @@ export default class PopoverListMembers extends React.Component {
);
}
- if (teamMembers[m.username] && teamMembers[m.username].delete_at <= 0) {
+ let name = '';
+ if (teamMembers[m.username]) {
+ name = Utils.displayUsername(teamMembers[m.username].id);
+ }
+
+ if (name && teamMembers[m.username].delete_at <= 0) {
popoverHtml.push(
<div
className='text-nowrap'
@@ -135,7 +119,7 @@ export default class PopoverListMembers extends React.Component {
<div
className='more-name'
>
- {m.username}
+ {name}
</div>
</div>
<div
@@ -157,8 +141,8 @@ export default class PopoverListMembers extends React.Component {
count = members.length;
}
- if (count > 20) {
- countText = '20+';
+ if (count > Constants.MAX_CHANNEL_POPOVER_COUNT) {
+ countText = Constants.MAX_CHANNEL_POPOVER_COUNT + '+';
} else if (count > 0) {
countText = count.toString();
}
diff --git a/web/react/components/post.jsx b/web/react/components/post.jsx
index 278261e22..66d8c507a 100644
--- a/web/react/components/post.jsx
+++ b/web/react/components/post.jsx
@@ -87,6 +87,10 @@ export default class Post extends React.Component {
return true;
}
+ if (nextProps.displayNameType !== this.props.displayNameType) {
+ return true;
+ }
+
if (this.getCommentCount(nextProps) !== this.getCommentCount(this.props)) {
return true;
}
@@ -224,5 +228,6 @@ Post.propTypes = {
sameRoot: React.PropTypes.bool,
hideProfilePic: React.PropTypes.bool,
isLastComment: React.PropTypes.bool,
- shouldHighlight: React.PropTypes.bool
+ shouldHighlight: React.PropTypes.bool,
+ displayNameType: React.PropTypes.string
};
diff --git a/web/react/components/posts_view.jsx b/web/react/components/posts_view.jsx
index 5e374b877..242b26b91 100644
--- a/web/react/components/posts_view.jsx
+++ b/web/react/components/posts_view.jsx
@@ -2,15 +2,18 @@
// See License.txt for license information.
import UserStore from '../stores/user_store.jsx';
+import PreferenceStore from '../stores/preference_store.jsx';
import * as EventHelpers from '../dispatcher/event_helpers.jsx';
import * as Utils from '../utils/utils.jsx';
import Post from './post.jsx';
import Constants from '../utils/constants.jsx';
+const Preferences = Constants.Preferences;
export default class PostsView extends React.Component {
constructor(props) {
super(props);
+ this.updateState = this.updateState.bind(this);
this.handleScroll = this.handleScroll.bind(this);
this.isAtBottom = this.isAtBottom.bind(this);
this.loadMorePostsTop = this.loadMorePostsTop.bind(this);
@@ -22,6 +25,8 @@ export default class PostsView extends React.Component {
this.jumpToPostNode = null;
this.wasAtBottom = true;
this.scrollHeight = 0;
+
+ this.state = {displayNameType: PreferenceStore.getPreference(Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', {value: 'false'}).value};
}
static get SCROLL_TYPE_FREE() {
return 1;
@@ -38,6 +43,9 @@ export default class PostsView extends React.Component {
static get SCROLL_TYPE_POST() {
return 5;
}
+ updateState() {
+ this.setState({displayNameType: PreferenceStore.getPreference(Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', {value: 'false'}).value});
+ }
isAtBottom() {
return ((this.refs.postlist.scrollHeight - this.refs.postlist.scrollTop) === this.refs.postlist.clientHeight);
}
@@ -94,22 +102,53 @@ export default class PostsView extends React.Component {
const prevPostIsComment = Utils.isComment(prevPost);
const postFromWebhook = Boolean(post.props && post.props.from_webhook);
const prevPostFromWebhook = Boolean(prevPost.props && prevPost.props.from_webhook);
+ let prevWebhookName = '';
+ if (prevPost.props && prevPost.props.override_username) {
+ prevWebhookName = prevPost.props.override_username;
+ }
+ let curWebhookName = '';
+ if (post.props && post.props.override_username) {
+ curWebhookName = post.props.override_username;
+ }
+
+ // consider posts from the same user if:
+ // the previous post was made by the same user as the current post,
+ // the previous post was made within 5 minutes of the current post,
+ // the previous post and current post are both from webhooks or both not,
+ // the previous post and current post have the same webhook usernames
+ if (prevPost.user_id === post.user_id &&
+ post.create_at - prevPost.create_at <= 1000 * 60 * 5 &&
+ postFromWebhook === prevPostFromWebhook &&
+ prevWebhookName === curWebhookName) {
+ sameUser = true;
+ }
- sameUser = prevPost.user_id === post.user_id && postFromWebhook === prevPostFromWebhook &&
- post.create_at - prevPost.create_at <= 1000 * 60 * 5;
- sameRoot = (postIsComment && (prevPost.id === post.root_id || prevPost.root_id === post.root_id)) || (!postIsComment && !prevPostIsComment && sameUser);
+ // consider posts from the same root if:
+ // the current post is a comment,
+ // the current post has the same root as the previous post
+ if (postIsComment && (prevPost.id === post.root_id || prevPost.root_id === post.root_id)) {
+ sameRoot = true;
+ }
+
+ // consider posts from the same root if:
+ // the current post is not a comment,
+ // the previous post is not a comment,
+ // the previous post is from the same user
+ if (!postIsComment && !prevPostIsComment && sameUser) {
+ sameRoot = true;
+ }
// hide the profile pic if:
// the previous post was made by the same user as the current post,
// the previous post is not a comment,
// the current post is not a comment,
- // the current post is not from a webhook
- // and the previous post is not from a webhook
- if ((prevPost.user_id === post.user_id) &&
+ // the previous post and current post are both from webhooks or both not,
+ // the previous post and current post have the same webhook usernames
+ if (prevPost.user_id === post.user_id &&
!prevPostIsComment &&
!postIsComment &&
- !postFromWebhook &&
- !prevPostFromWebhook) {
+ postFromWebhook === prevPostFromWebhook &&
+ prevWebhookName === curWebhookName) {
hideProfilePic = true;
}
}
@@ -135,6 +174,7 @@ export default class PostsView extends React.Component {
isLastComment={isLastComment}
shouldHighlight={shouldHighlight}
onClick={() => EventHelpers.emitPostFocusEvent(post.id)} //eslint-disable-line no-loop-func
+ displayNameType={this.state.displayNameType}
/>
);
@@ -240,16 +280,20 @@ export default class PostsView extends React.Component {
this.updateScrolling();
}
window.addEventListener('resize', this.handleResize);
+ $(this.refs.postlist).perfectScrollbar();
+ PreferenceStore.addChangeListener(this.updateState);
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
+ PreferenceStore.removeChangeListener(this.updateState);
}
componentDidUpdate() {
if (this.props.postList != null) {
this.updateScrolling();
}
+ $(this.refs.postlist).perfectScrollbar('update');
}
- shouldComponentUpdate(nextProps) {
+ shouldComponentUpdate(nextProps, nextState) {
if (this.props.isActive !== nextProps.isActive) {
return true;
}
@@ -268,6 +312,9 @@ export default class PostsView extends React.Component {
if (!Utils.areObjectsEqual(this.props.postList, nextProps.postList)) {
return true;
}
+ if (nextState.displayNameType !== this.state.displayNameType) {
+ return true;
+ }
return false;
}
@@ -326,7 +373,7 @@ export default class PostsView extends React.Component {
return (
<div
ref='postlist'
- className={'post-list-holder-by-time ' + activeClass}
+ className={'ps-container post-list-holder-by-time ' + activeClass}
onScroll={this.handleScroll}
>
<div className='post-list__table'>
diff --git a/web/react/components/posts_view_container.jsx b/web/react/components/posts_view_container.jsx
index 367d3687e..6d6694fec 100644
--- a/web/react/components/posts_view_container.jsx
+++ b/web/react/components/posts_view_container.jsx
@@ -99,10 +99,12 @@ export default class PostsViewContainer extends React.Component {
if (newIndex === -1) {
newIndex = channels.length;
channels.push(channelId);
- postLists[newIndex] = this.getChannelPosts(channelId);
atTop[newIndex] = PostStore.getVisibilityAtTop(channelId);
}
+ // make sure we have the latest posts from the store
+ postLists[newIndex] = this.getChannelPosts(channelId);
+
this.setState({
currentChannelIndex: newIndex,
currentLastViewed: lastViewed,
diff --git a/web/react/components/sidebar.jsx b/web/react/components/sidebar.jsx
index 30422ff7d..b4c037183 100644
--- a/web/react/components/sidebar.jsx
+++ b/web/react/components/sidebar.jsx
@@ -173,6 +173,10 @@ export default class Sidebar extends React.Component {
this.updateScrollbar();
window.addEventListener('resize', this.handleResize);
+
+ if ($(window).width() > 768) {
+ $('.nav-pills__container').perfectScrollbar();
+ }
}
shouldComponentUpdate(nextProps, nextState) {
if (!Utils.areObjectsEqual(nextState, this.state)) {
diff --git a/web/react/components/user_profile.jsx b/web/react/components/user_profile.jsx
index 438c0bc82..ea104fedb 100644
--- a/web/react/components/user_profile.jsx
+++ b/web/react/components/user_profile.jsx
@@ -54,9 +54,11 @@ export default class UserProfile extends React.Component {
}
}
render() {
- var name = this.state.profile.username;
+ var name = Utils.displayUsername(this.state.profile.id);
if (this.props.overwriteName) {
name = this.props.overwriteName;
+ } else if (!name) {
+ name = '...';
}
if (this.props.disablePopover) {
@@ -107,7 +109,7 @@ export default class UserProfile extends React.Component {
rootClose={true}
overlay={
<Popover
- title={this.state.profile.username}
+ title={name}
id='user-profile-popover'
>
{dataContent}
diff --git a/web/react/components/view_image.jsx b/web/react/components/view_image.jsx
index 91f4b3bdc..2b505607e 100644
--- a/web/react/components/view_image.jsx
+++ b/web/react/components/view_image.jsx
@@ -424,23 +424,27 @@ export default class ViewImageModal extends React.Component {
>
<div
className={'image-wrapper ' + bgClass}
- onMouseEnter={this.onMouseEnterImage}
- onMouseLeave={this.onMouseLeaveImage}
- onClick={(e) => e.stopPropagation()}
+ onClick={this.props.onModalDismissed}
>
<div
- className={closeButtonClass}
- onClick={this.props.onModalDismissed}
- />
- {content}
- <ViewImagePopoverBar
- show={this.state.showFooter}
- fileId={this.state.imgId}
- totalFiles={this.props.filenames.length}
- filename={name}
- fileURL={fileUrl}
- getPublicLink={this.getPublicLink}
- />
+ onMouseEnter={this.onMouseEnterImage}
+ onMouseLeave={this.onMouseLeaveImage}
+ onClick={(e) => e.stopPropagation()}
+ >
+ <div
+ className={closeButtonClass}
+ onClick={this.props.onModalDismissed}
+ />
+ {content}
+ <ViewImagePopoverBar
+ show={this.state.showFooter}
+ fileId={this.state.imgId}
+ totalFiles={this.props.filenames.length}
+ filename={name}
+ fileURL={fileUrl}
+ getPublicLink={this.getPublicLink}
+ />
+ </div>
</div>
{leftArrow}
{rightArrow}
diff --git a/web/react/stores/post_store.jsx b/web/react/stores/post_store.jsx
index 24b0d0dd0..2212edadb 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);
@@ -199,7 +211,7 @@ class PostStoreClass extends EventEmitter {
postList.order = this.postsInfo[id].pendingPosts.order.concat(postList.order);
}
- // Add delteted posts
+ // Add deleted posts
if (this.postsInfo[id].hasOwnProperty('deletedPosts')) {
Object.assign(postList.posts, this.postsInfo[id].deletedPosts);
@@ -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 2009e07dd..cbeab18ba 100644
--- a/web/react/utils/constants.jsx
+++ b/web/react/utils/constants.jsx
@@ -137,6 +137,7 @@ export default {
],
MONTHS: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
MAX_DMS: 20,
+ MAX_CHANNEL_POPOVER_COUNT: 20,
DM_CHANNEL: 'D',
OPEN_CHANNEL: 'O',
PRIVATE_CHANNEL: 'P',
diff --git a/web/react/utils/markdown.jsx b/web/react/utils/markdown.jsx
index b0ec64bfd..f2721c81d 100644
--- a/web/react/utils/markdown.jsx
+++ b/web/react/utils/markdown.jsx
@@ -223,6 +223,16 @@ class MattermostMarkdownRenderer extends marked.Renderer {
return `<table class="markdown__table"><thead>${header}</thead><tbody>${body}</tbody></table>`;
}
+ listitem(text) {
+ const taskListReg = /^\[([ |xX])\] /;
+ const isTaskList = taskListReg.exec(text);
+
+ if (isTaskList) {
+ return `<li>${'<input type="checkbox" disabled="disabled" ' + (isTaskList[1] === ' ' ? '' : 'checked="checked" ') + '/> '}${text.replace(taskListReg, '')}</li>`;
+ }
+ return `<li>${text}</li>`;
+ }
+
text(txt) {
return TextFormatting.doFormatText(txt, this.formattingOptions);
}
@@ -361,78 +371,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/text_formatting.jsx b/web/react/utils/text_formatting.jsx
index 3a912fd75..f0bd46f9d 100644
--- a/web/react/utils/text_formatting.jsx
+++ b/web/react/utils/text_formatting.jsx
@@ -188,7 +188,7 @@ function highlightCurrentMentions(text, tokens) {
const newAlias = `MM_SELFMENTION${index}`;
newTokens.set(newAlias, {
- value: `<span class='mention-highlight'>${alias}</span>` + token.extraText,
+ value: `<span class='mention-highlight'>${alias}</span>` + (token.extraText || ''),
originalText: token.originalText
});
output = output.replace(alias, newAlias);
diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx
index 80c377d7f..aa9146183 100644
--- a/web/react/utils/utils.jsx
+++ b/web/react/utils/utils.jsx
@@ -854,7 +854,7 @@ export function isMobile() {
export function isComment(post) {
if ('root_id' in post) {
- return post.root_id !== '';
+ return post.root_id !== '' && post.root_id != null;
}
return false;
}
@@ -981,13 +981,15 @@ export function displayUsername(userId) {
const nameFormat = PreferenceStore.getPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', {value: 'false'}).value;
let username = '';
- if (nameFormat === 'nickname_full_name') {
- username = user.nickname || getFullName(user);
- } else if (nameFormat === 'full_name') {
- username = getFullName(user);
- }
- if (!username.trim().length) {
- username = user.username;
+ if (user) {
+ if (nameFormat === 'nickname_full_name') {
+ username = user.nickname || getFullName(user);
+ } else if (nameFormat === 'full_name') {
+ username = getFullName(user);
+ }
+ if (!username.trim().length) {
+ username = user.username;
+ }
}
return username;
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/_modal.scss b/web/sass-files/sass/partials/_modal.scss
index 6270c8608..4a56bc6c7 100644
--- a/web/sass-files/sass/partials/_modal.scss
+++ b/web/sass-files/sass/partials/_modal.scss
@@ -3,6 +3,7 @@
}
.modal-body {
padding: 20px 15px;
+ overflow: auto;
}
.modal {
width: 100%;
@@ -47,9 +48,6 @@
margin-left: auto;
margin-right: auto;
}
- .modal-body {
- overflow: auto;
- }
.modal-push-down {
margin-top: 5%;
}
@@ -195,21 +193,35 @@
width:100%;
height: 100%;
margin: 0 auto;
+ max-width: 100%;
+
+ .modal-body {
+ @include clearfix;
+ height: 100%;
+ display: table;
+ table-layout: fixed;
+ width: 100%;
+ max-height: 100%;
+ }
+
.image-wrapper {
position: relative;
max-width: 90%;
- min-height: 100px;
- min-width: 320px;
@include border-radius(3px);
- display: table;
- margin: 0 auto;
+ display: table-cell;
+ vertical-align: middle;
+ text-align: center;
+ width: 100%;
+
&:hover {
@include border-radius(3px 3px 0 0);
}
+
&.default {
width: 100%;
height: 80%;
}
+
.modal-close {
background: url("../images/close.png") no-repeat;
@include background-size(100% 100%);
@@ -225,24 +237,31 @@
transition: opacity 0.6s;
cursor: pointer;
z-index: 9999;
+
&.modal-close--show {
@include opacity(1);
}
+
}
- > a {
+
+ > div {
+ min-height: 100px;
+ min-width: 320px;
background: #FFF;
- display: table-cell;
- vertical-align: middle;
+ display: inline-block;
position: relative;
&:hover .file-playback-controls.stop {
@include opacity(1);
}
+
}
+
img {
max-width: 100%;
max-height: 100%;
}
+
.spinner.file__loading {
z-index: 2;
position: absolute;
@@ -259,7 +278,6 @@
height: 100%;
padding: 0;
border: none;
- display: table;
}
.image-body {
vertical-align: middle;
diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss
index da161e54f..ed1632681 100644
--- a/web/sass-files/sass/partials/_post.scss
+++ b/web/sass-files/sass/partials/_post.scss
@@ -374,9 +374,9 @@ body.ios {
ul {
margin: 0;
padding: 0;
- list-style: none;
}
+
p {
margin: 0 0 1em;
line-height: 1.6em;
@@ -603,6 +603,16 @@ body.ios {
padding: 5px 0 0 20px;
}
+ ul, ol {
+ li ul, li ol {
+ padding: 0 0 0 20px
+ }
+
+ li input[type="checkbox"]:disabled {
+ vertical-align: sub;
+ cursor: default;
+ }
+ }
}
.post__link {
diff --git a/web/sass-files/sass/partials/_responsive.scss b/web/sass-files/sass/partials/_responsive.scss
index 00fa7d817..5f5cca89b 100644
--- a/web/sass-files/sass/partials/_responsive.scss
+++ b/web/sass-files/sass/partials/_responsive.scss
@@ -621,8 +621,10 @@
.modal {
.modal-image {
.image-wrapper {
- font-size: 12px;
- min-width: 250px;
+ > div {
+ font-size: 12px;
+ min-width: 250px;
+ }
.modal-close {
@include opacity(1);
}