Merge branch 'master' into PLT-340

Этот коммит содержится в:
=Corey Hulen
2015-10-27 11:52:13 -07:00
родитель e22b9f5303 e0f69060fa
Коммит a133f82421
58 изменённых файлов: 1895 добавлений и 345 удалений

Просмотреть файл

@@ -101,6 +101,12 @@ Joining the Mattermost community is a great way to build relationships with othe
- Review the [Mattermost Code Contribution Guidelines](http://docs.mattermost.org/developer/Code-Contribution-Guidelines/index.html) to submit patches for the core product - Review the [Mattermost Code Contribution Guidelines](http://docs.mattermost.org/developer/Code-Contribution-Guidelines/index.html) to submit patches for the core product
- Consider building tools that help developers and IT professionals manage Mattermost more effectively (API documentation coming in Beta2) - Consider building tools that help developers and IT professionals manage Mattermost more effectively (API documentation coming in Beta2)
##### Check out some projects for connecting to Mattermost:
- [Matterbridge](https://github.com/42wim/matterbridge) - an IRC bridge connecting to Mattermost
- [GitLab Integration Service for Mattermost](https://github.com/mattermost/mattermost-integration-gitlab) - connecting GitLab to Mattermost via incoming webhooks
- [Giphy Integration Service for Mattermost](https://github.com/mattermost/mattermost-integration-giphy) - connecting Mattermost to Giphy via outgoing webhooks
#### Have other ideas or suggestions? #### Have other ideas or suggestions?
If theres some other way youd like to contribute, please contact us at info@mattermost.com. Wed love to meet you! If theres some other way youd like to contribute, please contact us at info@mattermost.com. Wed love to meet you!

Просмотреть файл

@@ -26,7 +26,7 @@ func InitAdmin(r *mux.Router) {
sr.Handle("/test_email", ApiUserRequired(testEmail)).Methods("POST") sr.Handle("/test_email", ApiUserRequired(testEmail)).Methods("POST")
sr.Handle("/client_props", ApiAppHandler(getClientConfig)).Methods("GET") sr.Handle("/client_props", ApiAppHandler(getClientConfig)).Methods("GET")
sr.Handle("/log_client", ApiAppHandler(logClient)).Methods("POST") sr.Handle("/log_client", ApiAppHandler(logClient)).Methods("POST")
sr.Handle("/analytics/{id:[A-Za-z0-9]+}/{name:[A-Za-z0-9_]+}", ApiAppHandler(getAnalytics)).Methods("GET")
} }
func getLogs(c *Context, w http.ResponseWriter, r *http.Request) { func getLogs(c *Context, w http.ResponseWriter, r *http.Request) {
@@ -142,3 +142,63 @@ func testEmail(c *Context, w http.ResponseWriter, r *http.Request) {
m["SUCCESS"] = "true" m["SUCCESS"] = "true"
w.Write([]byte(model.MapToJson(m))) w.Write([]byte(model.MapToJson(m)))
} }
func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.HasSystemAdminPermissions("getAnalytics") {
return
}
params := mux.Vars(r)
teamId := params["id"]
name := params["name"]
if name == "standard" {
var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 3)
rows[0] = &model.AnalyticsRow{"channel_open_count", 0}
rows[1] = &model.AnalyticsRow{"channel_private_count", 0}
rows[2] = &model.AnalyticsRow{"post_count", 0}
openChan := Srv.Store.Channel().AnalyticsTypeCount(teamId, model.CHANNEL_OPEN)
privateChan := Srv.Store.Channel().AnalyticsTypeCount(teamId, model.CHANNEL_PRIVATE)
postChan := Srv.Store.Post().AnalyticsPostCount(teamId)
if r := <-openChan; r.Err != nil {
c.Err = r.Err
return
} else {
rows[0].Value = float64(r.Data.(int64))
}
if r := <-privateChan; r.Err != nil {
c.Err = r.Err
return
} else {
rows[1].Value = float64(r.Data.(int64))
}
if r := <-postChan; r.Err != nil {
c.Err = r.Err
return
} else {
rows[2].Value = float64(r.Data.(int64))
}
w.Write([]byte(rows.ToJson()))
} else if name == "post_counts_day" {
if r := <-Srv.Store.Post().AnalyticsPostCountsByDay(teamId); r.Err != nil {
c.Err = r.Err
return
} else {
w.Write([]byte(r.Data.(model.AnalyticsRows).ToJson()))
}
} else if name == "user_counts_with_posts_day" {
if r := <-Srv.Store.Post().AnalyticsUserCountsWithPostsByDay(teamId); r.Err != nil {
c.Err = r.Err
return
} else {
w.Write([]byte(r.Data.(model.AnalyticsRows).ToJson()))
}
} else {
c.SetInvalidParam("getAnalytics", "name")
}
}

Просмотреть файл

@@ -150,3 +150,151 @@ func TestEmailTest(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
func TestGetAnalyticsStandard(t *testing.T) {
Setup()
team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN}
team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team)
user := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"}
user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User)
store.Must(Srv.Store.User().VerifyEmail(user.Id))
Client.LoginByEmail(team.Name, user.Email, "pwd")
channel1 := &model.Channel{DisplayName: "TestGetPosts", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_PRIVATE, TeamId: team.Id}
channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel)
post1 := &model.Post{ChannelId: channel1.Id, Message: "a" + model.NewId() + "a"}
post1 = Client.Must(Client.CreatePost(post1)).Data.(*model.Post)
if _, err := Client.GetAnalytics(team.Id, "standard"); err == nil {
t.Fatal("Shouldn't have permissions")
}
c := &Context{}
c.RequestId = model.NewId()
c.IpAddress = "cmd_line"
UpdateRoles(c, user, model.ROLE_SYSTEM_ADMIN)
Client.LoginByEmail(team.Name, user.Email, "pwd")
if result, err := Client.GetAnalytics(team.Id, "standard"); err != nil {
t.Fatal(err)
} else {
rows := result.Data.(model.AnalyticsRows)
if rows[0].Name != "channel_open_count" {
t.Log(rows.ToJson())
t.Fatal()
}
if rows[0].Value != 2 {
t.Log(rows.ToJson())
t.Fatal()
}
if rows[1].Name != "channel_private_count" {
t.Log(rows.ToJson())
t.Fatal()
}
if rows[1].Value != 1 {
t.Log(rows.ToJson())
t.Fatal()
}
if rows[2].Name != "post_count" {
t.Log(rows.ToJson())
t.Fatal()
}
if rows[2].Value != 1 {
t.Log(rows.ToJson())
t.Fatal()
}
}
}
func TestGetPostCount(t *testing.T) {
Setup()
team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN}
team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team)
user := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"}
user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User)
store.Must(Srv.Store.User().VerifyEmail(user.Id))
Client.LoginByEmail(team.Name, user.Email, "pwd")
channel1 := &model.Channel{DisplayName: "TestGetPosts", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_PRIVATE, TeamId: team.Id}
channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel)
post1 := &model.Post{ChannelId: channel1.Id, Message: "a" + model.NewId() + "a"}
post1 = Client.Must(Client.CreatePost(post1)).Data.(*model.Post)
if _, err := Client.GetAnalytics(team.Id, "post_counts_day"); err == nil {
t.Fatal("Shouldn't have permissions")
}
c := &Context{}
c.RequestId = model.NewId()
c.IpAddress = "cmd_line"
UpdateRoles(c, user, model.ROLE_SYSTEM_ADMIN)
Client.LoginByEmail(team.Name, user.Email, "pwd")
if result, err := Client.GetAnalytics(team.Id, "post_counts_day"); err != nil {
t.Fatal(err)
} else {
rows := result.Data.(model.AnalyticsRows)
if rows[0].Value != 1 {
t.Log(rows.ToJson())
t.Fatal()
}
}
}
func TestUserCountsWithPostsByDay(t *testing.T) {
Setup()
team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN}
team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team)
user := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"}
user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User)
store.Must(Srv.Store.User().VerifyEmail(user.Id))
Client.LoginByEmail(team.Name, user.Email, "pwd")
channel1 := &model.Channel{DisplayName: "TestGetPosts", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_PRIVATE, TeamId: team.Id}
channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel)
post1 := &model.Post{ChannelId: channel1.Id, Message: "a" + model.NewId() + "a"}
post1 = Client.Must(Client.CreatePost(post1)).Data.(*model.Post)
if _, err := Client.GetAnalytics(team.Id, "user_counts_with_posts_day"); err == nil {
t.Fatal("Shouldn't have permissions")
}
c := &Context{}
c.RequestId = model.NewId()
c.IpAddress = "cmd_line"
UpdateRoles(c, user, model.ROLE_SYSTEM_ADMIN)
Client.LoginByEmail(team.Name, user.Email, "pwd")
if result, err := Client.GetAnalytics(team.Id, "user_counts_with_posts_day"); err != nil {
t.Fatal(err)
} else {
rows := result.Data.(model.AnalyticsRows)
if rows[0].Value != 1 {
t.Log(rows.ToJson())
t.Fatal()
}
}
}

Просмотреть файл

@@ -52,6 +52,8 @@ const (
RotatedCCW = 6 RotatedCCW = 6
RotatedCCWMirrored = 7 RotatedCCWMirrored = 7
RotatedCW = 8 RotatedCW = 8
MaxImageSize = 4096 * 2160 // 4k resolution
) )
var fileInfoCache *utils.Cache = utils.NewLru(1000) var fileInfoCache *utils.Cache = utils.NewLru(1000)
@@ -125,6 +127,21 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
uid := model.NewId() uid := model.NewId()
if model.IsFileExtImage(filepath.Ext(files[i].Filename)) {
imageNameList = append(imageNameList, uid+"/"+filename)
imageDataList = append(imageDataList, buf.Bytes())
// Decode image config first to check dimensions before loading the whole thing into memory later on
config, _, err := image.DecodeConfig(bytes.NewReader(buf.Bytes()))
if err != nil {
c.Err = model.NewAppError("uploadFile", "Unable to upload image file.", err.Error())
return
} else if config.Width*config.Height > MaxImageSize {
c.Err = model.NewAppError("uploadFile", "Unable to upload image file. File is too large.", err.Error())
return
}
}
path := "teams/" + c.Session.TeamId + "/channels/" + channelId + "/users/" + c.Session.UserId + "/" + uid + "/" + filename path := "teams/" + c.Session.TeamId + "/channels/" + channelId + "/users/" + c.Session.UserId + "/" + uid + "/" + filename
if err := writeFile(buf.Bytes(), path); err != nil { if err := writeFile(buf.Bytes(), path); err != nil {
@@ -132,11 +149,6 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
return return
} }
if model.IsFileExtImage(filepath.Ext(files[i].Filename)) {
imageNameList = append(imageNameList, uid+"/"+filename)
imageDataList = append(imageDataList, buf.Bytes())
}
encName := utils.UrlEncode(filename) encName := utils.UrlEncode(filename)
fileUrl := "/" + channelId + "/" + c.Session.UserId + "/" + uid + "/" + encName fileUrl := "/" + channelId + "/" + c.Session.UserId + "/" + uid + "/" + encName

Просмотреть файл

@@ -820,45 +820,23 @@ func searchPosts(c *Context, w http.ResponseWriter, r *http.Request) {
return return
} }
plainSearchParams, hashtagSearchParams := model.ParseSearchParams(terms) paramsList := model.ParseSearchParams(terms)
channels := []store.StoreChannel{}
var hchan store.StoreChannel for _, params := range paramsList {
if hashtagSearchParams != nil { channels = append(channels, Srv.Store.Post().Search(c.Session.TeamId, c.Session.UserId, params))
hchan = Srv.Store.Post().Search(c.Session.TeamId, c.Session.UserId, hashtagSearchParams)
} }
var pchan store.StoreChannel posts := &model.PostList{}
if plainSearchParams != nil { for _, channel := range channels {
pchan = Srv.Store.Post().Search(c.Session.TeamId, c.Session.UserId, plainSearchParams) if result := <-channel; result.Err != nil {
}
mainList := &model.PostList{}
if hchan != nil {
if result := <-hchan; result.Err != nil {
c.Err = result.Err c.Err = result.Err
return return
} else { } else {
mainList = result.Data.(*model.PostList) data := result.Data.(*model.PostList)
posts.Extend(data)
} }
} }
plainList := &model.PostList{} w.Write([]byte(posts.ToJson()))
if pchan != nil {
if result := <-pchan; result.Err != nil {
c.Err = result.Err
return
} else {
plainList = result.Data.(*model.PostList)
}
}
for _, postId := range plainList.Order {
if _, ok := mainList.Posts[postId]; !ok {
mainList.AddPost(plainList.Posts[postId])
mainList.AddOrder(postId)
}
}
w.Write([]byte(mainList.ToJson()))
} }

Просмотреть файл

@@ -427,12 +427,18 @@ func TestSearchPostsInChannel(t *testing.T) {
channel2 := &model.Channel{DisplayName: "TestGetPosts", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} channel2 := &model.Channel{DisplayName: "TestGetPosts", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
channel2 = Client.Must(Client.CreateChannel(channel2)).Data.(*model.Channel) channel2 = Client.Must(Client.CreateChannel(channel2)).Data.(*model.Channel)
channel3 := &model.Channel{DisplayName: "TestGetPosts", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
channel3 = Client.Must(Client.CreateChannel(channel3)).Data.(*model.Channel)
post2 := &model.Post{ChannelId: channel2.Id, Message: "sgtitlereview\n with return"} post2 := &model.Post{ChannelId: channel2.Id, Message: "sgtitlereview\n with return"}
post2 = Client.Must(Client.CreatePost(post2)).Data.(*model.Post) post2 = Client.Must(Client.CreatePost(post2)).Data.(*model.Post)
post3 := &model.Post{ChannelId: channel2.Id, Message: "other message with no return"} post3 := &model.Post{ChannelId: channel2.Id, Message: "other message with no return"}
post3 = Client.Must(Client.CreatePost(post3)).Data.(*model.Post) post3 = Client.Must(Client.CreatePost(post3)).Data.(*model.Post)
post4 := &model.Post{ChannelId: channel3.Id, Message: "other message with no return"}
post4 = Client.Must(Client.CreatePost(post4)).Data.(*model.Post)
if result := Client.Must(Client.SearchPosts("channel:")).Data.(*model.PostList); len(result.Order) != 0 { if result := Client.Must(Client.SearchPosts("channel:")).Data.(*model.PostList); len(result.Order) != 0 {
t.Fatalf("wrong number of posts returned %v", len(result.Order)) t.Fatalf("wrong number of posts returned %v", len(result.Order))
} }
@@ -476,6 +482,10 @@ func TestSearchPostsInChannel(t *testing.T) {
if result := Client.Must(Client.SearchPosts("sgtitlereview channel: " + channel2.Name)).Data.(*model.PostList); len(result.Order) != 1 { if result := Client.Must(Client.SearchPosts("sgtitlereview channel: " + channel2.Name)).Data.(*model.PostList); len(result.Order) != 1 {
t.Fatalf("wrong number of posts returned %v", len(result.Order)) t.Fatalf("wrong number of posts returned %v", len(result.Order))
} }
if result := Client.Must(Client.SearchPosts("channel: " + channel2.Name + " channel: " + channel3.Name)).Data.(*model.PostList); len(result.Order) != 3 {
t.Fatalf("wrong number of posts returned :) %v :) %v", result.Posts, result.Order)
}
} }
func TestSearchPostsFromUser(t *testing.T) { func TestSearchPostsFromUser(t *testing.T) {
@@ -510,11 +520,12 @@ func TestSearchPostsFromUser(t *testing.T) {
post2 := &model.Post{ChannelId: channel2.Id, Message: "sgtitlereview\n with return"} post2 := &model.Post{ChannelId: channel2.Id, Message: "sgtitlereview\n with return"}
post2 = Client.Must(Client.CreatePost(post2)).Data.(*model.Post) post2 = Client.Must(Client.CreatePost(post2)).Data.(*model.Post)
// includes "X has joined the channel" messages for both user2 and user3
if result := Client.Must(Client.SearchPosts("from: " + user1.Username)).Data.(*model.PostList); len(result.Order) != 1 { if result := Client.Must(Client.SearchPosts("from: " + user1.Username)).Data.(*model.PostList); len(result.Order) != 1 {
t.Fatalf("wrong number of posts returned %v", len(result.Order)) t.Fatalf("wrong number of posts returned %v", len(result.Order))
} }
// note that this includes the "User2 has joined the channel" system messages
if result := Client.Must(Client.SearchPosts("from: " + user2.Username)).Data.(*model.PostList); len(result.Order) != 3 { if result := Client.Must(Client.SearchPosts("from: " + user2.Username)).Data.(*model.PostList); len(result.Order) != 3 {
t.Fatalf("wrong number of posts returned %v", len(result.Order)) t.Fatalf("wrong number of posts returned %v", len(result.Order))
} }
@@ -526,6 +537,33 @@ func TestSearchPostsFromUser(t *testing.T) {
if result := Client.Must(Client.SearchPosts("from: " + user2.Username + " in:" + channel1.Name)).Data.(*model.PostList); len(result.Order) != 1 { if result := Client.Must(Client.SearchPosts("from: " + user2.Username + " in:" + channel1.Name)).Data.(*model.PostList); len(result.Order) != 1 {
t.Fatalf("wrong number of posts returned %v", len(result.Order)) t.Fatalf("wrong number of posts returned %v", len(result.Order))
} }
user3 := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"}
user3 = Client.Must(Client.CreateUser(user3, "")).Data.(*model.User)
store.Must(Srv.Store.User().VerifyEmail(user3.Id))
Client.LoginByEmail(team.Name, user3.Email, "pwd")
Client.Must(Client.JoinChannel(channel1.Id))
Client.Must(Client.JoinChannel(channel2.Id))
// wait for the join/leave messages to be created for user3 since they're done asynchronously
time.Sleep(100 * time.Millisecond)
if result := Client.Must(Client.SearchPosts("from: " + user2.Username)).Data.(*model.PostList); len(result.Order) != 3 {
t.Fatalf("wrong number of posts returned %v", len(result.Order))
}
if result := Client.Must(Client.SearchPosts("from: " + user2.Username + " from: " + user3.Username)).Data.(*model.PostList); len(result.Order) != 5 {
t.Fatalf("wrong number of posts returned %v", len(result.Order))
}
if result := Client.Must(Client.SearchPosts("from: " + user2.Username + " from: " + user3.Username + " in:" + channel2.Name)).Data.(*model.PostList); len(result.Order) != 3 {
t.Fatalf("wrong number of posts returned %v", len(result.Order))
}
if result := Client.Must(Client.SearchPosts("from: " + user2.Username + " from: " + user3.Username + " in:" + channel2.Name + " joined")).Data.(*model.PostList); len(result.Order) != 2 {
t.Fatalf("wrong number of posts returned %v", len(result.Order))
}
} }
func TestGetPostsCache(t *testing.T) { func TestGetPostsCache(t *testing.T) {

Просмотреть файл

@@ -855,6 +855,18 @@ func uploadProfileImage(c *Context, w http.ResponseWriter, r *http.Request) {
return return
} }
// Decode image config first to check dimensions before loading the whole thing into memory later on
config, _, err := image.DecodeConfig(file)
if err != nil {
c.Err = model.NewAppError("uploadProfileFile", "Could not decode profile image config.", err.Error())
return
} else if config.Width*config.Height > MaxImageSize {
c.Err = model.NewAppError("uploadProfileFile", "Unable to upload profile image. File is too large.", err.Error())
return
}
file.Seek(0, 0)
// Decode image into Image object // Decode image into Image object
img, _, err := image.Decode(file) img, _, err := image.Decode(file)
if err != nil { if err != nil {

12
doc/install/Administration.md Обычный файл
Просмотреть файл

@@ -0,0 +1,12 @@
# Administration
This document provides instructions for common administrator tasks
##### Creating System Administrator account from commandline
- If the System Administrator account becomes unavailable, a person leaving the organization for example, you can set a new system admin from the commandline using `./platform -assign_role -team_name="yourteam" -email="you@example.com" -role="system_admin"`.
- After assigning the role the user needs to log out and log back in before the System Administrator role is applied.
##### Deactivating a user
- Team Admin or System Admin can go to **Main Menu** > **Manage Members** > **Make Inactive** to deactivate a user, which removes them from the team.
- To preserve audit history, users are never deleted from the system. It is highly recommended that System Administrators do not attempt to delete users manually from the database, as this may compromise system integrity and ability to upgrade in future.

Просмотреть файл

@@ -8,10 +8,22 @@
#### Common Issues #### Common Issues
##### Error message in logs when attempting to sign-up: `x509: certificate signed by unknown authority`
- This error may appear when attempt to use a self-signed certificate to setup SSL, which is not yet supported by Mattermost. You can resolve this issue by setting up a load balancer like Ngnix. A ticket exists to [add support for self-signed certificates in future](x509: certificate signed by unknown authority).
##### Lost System Administrator account ##### Lost System Administrator account
- If the System Administrator account becomes unavailable, a person leaving the organization for example, you can set a new system admin from the commandline using `./platform -assign_role -team_name="yourteam" -email="you@example.com" -role="system_admin"`. - If the System Administrator account becomes unavailable, a person leaving the organization for example, you can set a new system admin from the commandline using `./platform -assign_role -team_name="yourteam" -email="you@example.com" -role="system_admin"`.
- After assigning the role the user needs to log out and log back in before the System Administrator role is applied. - After assigning the role the user needs to log out and log back in before the System Administrator role is applied.
##### Deactivate a user
- Team Admin or System Admin can go to **Main Menu** > **Manage Members** > **Make Inactive** to deactivate a user, which removes them from the team.
- To preserve audit history, users are never deleted from the system. It is highly recommended that System Administrators do not attempt to delete users manually from the database, as this may compromise system integrity and ability to upgrade in future.
#### Error Messages
The following is a list of common error messages and solutions:
###### `Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.`
- Message appears in blue bar on team site. Check that [your websocket port is properly configured](https://github.com/mattermost/platform/blob/master/doc/install/Production-Ubuntu.md#set-up-nginx-server).
###### `x509: certificate signed by unknown authority` in server logs when attempting to sign-up
- This error may appear when attempt to use a self-signed certificate to setup SSL, which is not yet supported by Mattermost. You can resolve this issue by setting up a load balancer like Ngnix. A ticket exists to [add support for self-signed certificates in future](x509: certificate signed by unknown authority).

Просмотреть файл

@@ -90,8 +90,9 @@ As mentioned above, Mattermost makes it easy to take integrations written for Sl
To see samples and community contributions, please visit <http://mattermost.org/webhooks>. To see samples and community contributions, please visit <http://mattermost.org/webhooks>.
#### Limitations #### Known Issues
- The `attachments` payload used in Slack is not yet supported - The `attachments` payload used in Slack is not yet supported
- Overriding of usernames does not yet apply to notifications - Overriding of usernames does not yet apply to notifications
- Cannot supply `icon_emoji` to override the message icon - Cannot supply `icon_emoji` to override the message icon
- Webhook UI fails when connected to deleted channel

55
model/analytics_row.go Обычный файл
Просмотреть файл

@@ -0,0 +1,55 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package model
import (
"encoding/json"
"io"
)
type AnalyticsRow struct {
Name string `json:"name"`
Value float64 `json:"value"`
}
type AnalyticsRows []*AnalyticsRow
func (me *AnalyticsRow) ToJson() string {
b, err := json.Marshal(me)
if err != nil {
return ""
} else {
return string(b)
}
}
func AnalyticsRowFromJson(data io.Reader) *AnalyticsRow {
decoder := json.NewDecoder(data)
var me AnalyticsRow
err := decoder.Decode(&me)
if err == nil {
return &me
} else {
return nil
}
}
func (me AnalyticsRows) ToJson() string {
if b, err := json.Marshal(me); err != nil {
return "[]"
} else {
return string(b)
}
}
func AnalyticsRowsFromJson(data io.Reader) AnalyticsRows {
decoder := json.NewDecoder(data)
var me AnalyticsRows
err := decoder.Decode(&me)
if err == nil {
return me
} else {
return nil
}
}

37
model/analytics_row_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,37 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package model
import (
"strings"
"testing"
)
func TestAnalyticsRowJson(t *testing.T) {
a1 := AnalyticsRow{}
a1.Name = "2015-10-12"
a1.Value = 12345.0
json := a1.ToJson()
ra1 := AnalyticsRowFromJson(strings.NewReader(json))
if a1.Name != ra1.Name {
t.Fatal("days didn't match")
}
}
func TestAnalyticsRowsJson(t *testing.T) {
a1 := AnalyticsRow{}
a1.Name = "2015-10-12"
a1.Value = 12345.0
var a1s AnalyticsRows = make([]*AnalyticsRow, 1)
a1s[0] = &a1
ljson := a1s.ToJson()
results := AnalyticsRowsFromJson(strings.NewReader(ljson))
if a1s[0].Name != results[0].Name {
t.Fatal("Ids do not match")
}
}

Просмотреть файл

@@ -416,6 +416,15 @@ func (c *Client) TestEmail(config *Config) (*Result, *AppError) {
} }
} }
func (c *Client) GetAnalytics(teamId, name string) (*Result, *AppError) {
if r, err := c.DoApiGet("/admin/analytics/"+teamId+"/"+name, "", ""); err != nil {
return nil, err
} else {
return &Result{r.Header.Get(HEADER_REQUEST_ID),
r.Header.Get(HEADER_ETAG_SERVER), AnalyticsRowsFromJson(r.Body)}, nil
}
}
func (c *Client) CreateChannel(channel *Channel) (*Result, *AppError) { func (c *Client) CreateChannel(channel *Channel) (*Result, *AppError) {
if r, err := c.DoApiPost("/channels/create", channel.ToJson()); err != nil { if r, err := c.DoApiPost("/channels/create", channel.ToJson()); err != nil {
return nil, err return nil, err

Просмотреть файл

@@ -23,6 +23,13 @@ type IncomingWebhook struct {
TeamId string `json:"team_id"` TeamId string `json:"team_id"`
} }
type IncomingWebhookRequest struct {
Text string `json:"text"`
Username string `json:"username"`
IconURL string `json:"icon_url"`
ChannelName string `json:"channel"`
}
func (o *IncomingWebhook) ToJson() string { func (o *IncomingWebhook) ToJson() string {
b, err := json.Marshal(o) b, err := json.Marshal(o)
if err != nil { if err != nil {
@@ -104,3 +111,14 @@ func (o *IncomingWebhook) PreSave() {
func (o *IncomingWebhook) PreUpdate() { func (o *IncomingWebhook) PreUpdate() {
o.UpdateAt = GetMillis() o.UpdateAt = GetMillis()
} }
func IncomingWebhookRequestFromJson(data io.Reader) *IncomingWebhookRequest {
decoder := json.NewDecoder(data)
var o IncomingWebhookRequest
err := decoder.Decode(&o)
if err == nil {
return &o
} else {
return nil
}
}

Просмотреть файл

@@ -54,6 +54,15 @@ func (o *PostList) AddPost(post *Post) {
o.Posts[post.Id] = post o.Posts[post.Id] = post
} }
func (o *PostList) Extend(other *PostList) {
for _, postId := range other.Order {
if _, ok := o.Posts[postId]; !ok {
o.AddPost(other.Posts[postId])
o.AddOrder(postId)
}
}
}
func (o *PostList) Etag() string { func (o *PostList) Etag() string {
id := "0" id := "0"

Просмотреть файл

@@ -34,3 +34,37 @@ func TestPostListJson(t *testing.T) {
t.Fatal("failed to serialize") t.Fatal("failed to serialize")
} }
} }
func TestPostListExtend(t *testing.T) {
l1 := PostList{}
p1 := &Post{Id: NewId(), Message: NewId()}
l1.AddPost(p1)
l1.AddOrder(p1.Id)
p2 := &Post{Id: NewId(), Message: NewId()}
l1.AddPost(p2)
l1.AddOrder(p2.Id)
l2 := PostList{}
p3 := &Post{Id: NewId(), Message: NewId()}
l2.AddPost(p3)
l2.AddOrder(p3.Id)
l2.Extend(&l1)
if len(l1.Posts) != 2 || len(l1.Order) != 2 {
t.Fatal("extending l2 changed l1")
} else if len(l2.Posts) != 3 {
t.Fatal("failed to extend posts l2")
} else if l2.Order[0] != p3.Id || l2.Order[1] != p1.Id || l2.Order[2] != p2.Id {
t.Fatal("failed to extend order of l2")
}
if len(l1.Posts) != 2 || len(l1.Order) != 2 {
t.Fatal("extending l2 again changed l1")
} else if len(l2.Posts) != 3 || len(l2.Order) != 3 {
t.Fatal("extending l2 again changed l2")
}
}

Просмотреть файл

@@ -10,8 +10,8 @@ import (
type SearchParams struct { type SearchParams struct {
Terms string Terms string
IsHashtag bool IsHashtag bool
InChannel string InChannels []string
FromUser string FromUsers []string
} }
var searchFlags = [...]string{"from", "channel", "in"} var searchFlags = [...]string{"from", "channel", "in"}
@@ -31,9 +31,9 @@ func splitWords(text string) []string {
return words return words
} }
func parseSearchFlags(input []string) ([]string, map[string]string) { func parseSearchFlags(input []string) ([]string, [][2]string) {
words := []string{} words := []string{}
flags := make(map[string]string) flags := [][2]string{}
skipNextWord := false skipNextWord := false
for i, word := range input { for i, word := range input {
@@ -52,10 +52,10 @@ func parseSearchFlags(input []string) ([]string, map[string]string) {
// check for case insensitive equality // check for case insensitive equality
if strings.EqualFold(flag, searchFlag) { if strings.EqualFold(flag, searchFlag) {
if value != "" { if value != "" {
flags[searchFlag] = value flags = append(flags, [2]string{searchFlag, value})
isFlag = true isFlag = true
} else if i < len(input)-1 { } else if i < len(input)-1 {
flags[searchFlag] = input[i+1] flags = append(flags, [2]string{searchFlag, input[i+1]})
skipNextWord = true skipNextWord = true
isFlag = true isFlag = true
} }
@@ -75,56 +75,66 @@ func parseSearchFlags(input []string) ([]string, map[string]string) {
return words, flags return words, flags
} }
func ParseSearchParams(text string) (*SearchParams, *SearchParams) { func ParseSearchParams(text string) []*SearchParams {
words, flags := parseSearchFlags(splitWords(text)) words, flags := parseSearchFlags(splitWords(text))
hashtagTerms := []string{} hashtagTermList := []string{}
plainTerms := []string{} plainTermList := []string{}
for _, word := range words { for _, word := range words {
if validHashtag.MatchString(word) { if validHashtag.MatchString(word) {
hashtagTerms = append(hashtagTerms, word) hashtagTermList = append(hashtagTermList, word)
} else { } else {
plainTerms = append(plainTerms, word) plainTermList = append(plainTermList, word)
} }
} }
inChannel := flags["channel"] hashtagTerms := strings.Join(hashtagTermList, " ")
if inChannel == "" { plainTerms := strings.Join(plainTermList, " ")
inChannel = flags["in"]
inChannels := []string{}
fromUsers := []string{}
for _, flagPair := range flags {
flag := flagPair[0]
value := flagPair[1]
if flag == "in" || flag == "channel" {
inChannels = append(inChannels, value)
} else if flag == "from" {
fromUsers = append(fromUsers, value)
}
} }
fromUser := flags["from"] paramsList := []*SearchParams{}
var plainParams *SearchParams
if len(plainTerms) > 0 { if len(plainTerms) > 0 {
plainParams = &SearchParams{ paramsList = append(paramsList, &SearchParams{
Terms: strings.Join(plainTerms, " "), Terms: plainTerms,
IsHashtag: false, IsHashtag: false,
InChannel: inChannel, InChannels: inChannels,
FromUser: fromUser, FromUsers: fromUsers,
} })
} }
var hashtagParams *SearchParams
if len(hashtagTerms) > 0 { if len(hashtagTerms) > 0 {
hashtagParams = &SearchParams{ paramsList = append(paramsList, &SearchParams{
Terms: strings.Join(hashtagTerms, " "), Terms: hashtagTerms,
IsHashtag: true, IsHashtag: true,
InChannel: inChannel, InChannels: inChannels,
FromUser: fromUser, FromUsers: fromUsers,
} })
} }
// special case for when no terms are specified but we still have a filter // special case for when no terms are specified but we still have a filter
if plainParams == nil && hashtagParams == nil && (inChannel != "" || fromUser != "") { if len(plainTerms) == 0 && len(hashtagTerms) == 0 {
plainParams = &SearchParams{ paramsList = append(paramsList, &SearchParams{
Terms: "", Terms: "",
IsHashtag: false, IsHashtag: true,
InChannel: inChannel, InChannels: inChannels,
FromUser: fromUser, FromUsers: fromUsers,
} })
} }
return plainParams, hashtagParams return paramsList
} }

Просмотреть файл

@@ -28,25 +28,25 @@ func TestParseSearchFlags(t *testing.T) {
if words, flags := parseSearchFlags(splitWords("apple banana from:chan")); len(words) != 2 || words[0] != "apple" || words[1] != "banana" { if words, flags := parseSearchFlags(splitWords("apple banana from:chan")); len(words) != 2 || words[0] != "apple" || words[1] != "banana" {
t.Fatalf("got incorrect words %v", words) t.Fatalf("got incorrect words %v", words)
} else if len(flags) != 1 || flags["from"] != "chan" { } else if len(flags) != 1 || flags[0][0] != "from" || flags[0][1] != "chan" {
t.Fatalf("got incorrect flags %v", flags) t.Fatalf("got incorrect flags %v", flags)
} }
if words, flags := parseSearchFlags(splitWords("apple banana from: chan")); len(words) != 2 || words[0] != "apple" || words[1] != "banana" { if words, flags := parseSearchFlags(splitWords("apple banana from: chan")); len(words) != 2 || words[0] != "apple" || words[1] != "banana" {
t.Fatalf("got incorrect words %v", words) t.Fatalf("got incorrect words %v", words)
} else if len(flags) != 1 || flags["from"] != "chan" { } else if len(flags) != 1 || flags[0][0] != "from" || flags[0][1] != "chan" {
t.Fatalf("got incorrect flags %v", flags) t.Fatalf("got incorrect flags %v", flags)
} }
if words, flags := parseSearchFlags(splitWords("apple banana in: chan")); len(words) != 2 || words[0] != "apple" || words[1] != "banana" { if words, flags := parseSearchFlags(splitWords("apple banana in: chan")); len(words) != 2 || words[0] != "apple" || words[1] != "banana" {
t.Fatalf("got incorrect words %v", words) t.Fatalf("got incorrect words %v", words)
} else if len(flags) != 1 || flags["in"] != "chan" { } else if len(flags) != 1 || flags[0][0] != "in" || flags[0][1] != "chan" {
t.Fatalf("got incorrect flags %v", flags) t.Fatalf("got incorrect flags %v", flags)
} }
if words, flags := parseSearchFlags(splitWords("apple banana channel:chan")); len(words) != 2 || words[0] != "apple" || words[1] != "banana" { if words, flags := parseSearchFlags(splitWords("apple banana channel:chan")); len(words) != 2 || words[0] != "apple" || words[1] != "banana" {
t.Fatalf("got incorrect words %v", words) t.Fatalf("got incorrect words %v", words)
} else if len(flags) != 1 || flags["channel"] != "chan" { } else if len(flags) != 1 || flags[0][0] != "channel" || flags[0][1] != "chan" {
t.Fatalf("got incorrect flags %v", flags) t.Fatalf("got incorrect flags %v", flags)
} }
@@ -64,7 +64,14 @@ func TestParseSearchFlags(t *testing.T) {
if words, flags := parseSearchFlags(splitWords("channel: first in: second from:")); len(words) != 1 || words[0] != "from:" { if words, flags := parseSearchFlags(splitWords("channel: first in: second from:")); len(words) != 1 || words[0] != "from:" {
t.Fatalf("got incorrect words %v", words) t.Fatalf("got incorrect words %v", words)
} else if len(flags) != 2 || flags["channel"] != "first" || flags["in"] != "second" { } else if len(flags) != 2 || flags[0][0] != "channel" || flags[0][1] != "first" || flags[1][0] != "in" || flags[1][1] != "second" {
t.Fatalf("got incorrect flags %v", flags)
}
if words, flags := parseSearchFlags(splitWords("channel: first channel: second from: third from: fourth")); len(words) != 0 {
t.Fatalf("got incorrect words %v", words)
} else if len(flags) != 4 || flags[0][0] != "channel" || flags[0][1] != "first" || flags[1][0] != "channel" || flags[1][1] != "second" ||
flags[2][0] != "from" || flags[2][1] != "third" || flags[3][0] != "from" || flags[3][1] != "fourth" {
t.Fatalf("got incorrect flags %v", flags) t.Fatalf("got incorrect flags %v", flags)
} }
} }

Просмотреть файл

@@ -40,7 +40,7 @@ func NewSqlChannelStore(sqlStore *SqlStore) ChannelStore {
func (s SqlChannelStore) UpgradeSchemaIfNeeded() { func (s SqlChannelStore) UpgradeSchemaIfNeeded() {
// BEGIN REMOVE AFTER 1.1.0 // REMOVE AFTER 1.2 SHIP see PLT-828
if s.CreateColumnIfNotExists("ChannelMembers", "NotifyProps", "varchar(2000)", "varchar(2000)", "{}") { if s.CreateColumnIfNotExists("ChannelMembers", "NotifyProps", "varchar(2000)", "varchar(2000)", "{}") {
// populate NotifyProps from existing NotifyLevel field // populate NotifyProps from existing NotifyLevel field
@@ -83,7 +83,6 @@ func (s SqlChannelStore) UpgradeSchemaIfNeeded() {
s.RemoveColumnIfExists("ChannelMembers", "NotifyLevel") s.RemoveColumnIfExists("ChannelMembers", "NotifyLevel")
} }
// END REMOVE AFTER 1.1.0
} }
func (s SqlChannelStore) CreateIndexesIfNotExists() { func (s SqlChannelStore) CreateIndexesIfNotExists() {
@@ -829,3 +828,31 @@ func (s SqlChannelStore) GetForExport(teamId string) StoreChannel {
return storeChannel return storeChannel
} }
func (s SqlChannelStore) AnalyticsTypeCount(teamId string, channelType string) StoreChannel {
storeChannel := make(StoreChannel)
go func() {
result := StoreResult{}
v, err := s.GetReplica().SelectInt(
`SELECT
COUNT(Id) AS Value
FROM
Channels
WHERE
TeamId = :TeamId
AND Type = :ChannelType`,
map[string]interface{}{"TeamId": teamId, "ChannelType": channelType})
if err != nil {
result.Err = model.NewAppError("SqlChannelStore.AnalyticsTypeCount", "We couldn't get channel type counts", err.Error())
} else {
result.Data = v
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}

Просмотреть файл

@@ -561,6 +561,24 @@ func TestChannelStoreGetMoreChannels(t *testing.T) {
if list.Channels[0].Name != o3.Name { if list.Channels[0].Name != o3.Name {
t.Fatal("missing channel") t.Fatal("missing channel")
} }
if r1 := <-store.Channel().AnalyticsTypeCount(o1.TeamId, model.CHANNEL_OPEN); r1.Err != nil {
t.Fatal(r1.Err)
} else {
if r1.Data.(int64) != 2 {
t.Log(r1.Data)
t.Fatal("wrong value")
}
}
if r1 := <-store.Channel().AnalyticsTypeCount(o1.TeamId, model.CHANNEL_PRIVATE); r1.Err != nil {
t.Fatal(r1.Err)
} else {
if r1.Data.(int64) != 2 {
t.Log(r1.Data)
t.Fatal("wrong value")
}
}
} }
func TestChannelStoreGetChannelCounts(t *testing.T) { func TestChannelStoreGetChannelCounts(t *testing.T) {

Просмотреть файл

@@ -6,6 +6,7 @@ package store
import ( import (
"fmt" "fmt"
"regexp" "regexp"
"strconv"
"strings" "strings"
"github.com/mattermost/platform/model" "github.com/mattermost/platform/model"
@@ -413,10 +414,15 @@ func (s SqlPostStore) Search(teamId string, userId string, params *model.SearchP
go func() { go func() {
result := StoreResult{} result := StoreResult{}
queryParams := map[string]interface{}{
"TeamId": teamId,
"UserId": userId,
}
termMap := map[string]bool{} termMap := map[string]bool{}
terms := params.Terms terms := params.Terms
if terms == "" && params.InChannel == "" && params.FromUser == "" { if terms == "" && len(params.InChannels) == 0 && len(params.FromUsers) == 0 {
result.Data = []*model.Post{} result.Data = []*model.Post{}
storeChannel <- result storeChannel <- result
return return
@@ -468,13 +474,45 @@ func (s SqlPostStore) Search(teamId string, userId string, params *model.SearchP
ORDER BY CreateAt DESC ORDER BY CreateAt DESC
LIMIT 100` LIMIT 100`
if params.InChannel != "" { if len(params.InChannels) > 1 {
inClause := ":InChannel0"
queryParams["InChannel0"] = params.InChannels[0]
for i := 1; i < len(params.InChannels); i++ {
paramName := "InChannel" + strconv.FormatInt(int64(i), 10)
inClause += ", :" + paramName
queryParams[paramName] = params.InChannels[i]
}
searchQuery = strings.Replace(searchQuery, "CHANNEL_FILTER", "AND Name IN ("+inClause+")", 1)
} else if len(params.InChannels) == 1 {
queryParams["InChannel"] = params.InChannels[0]
searchQuery = strings.Replace(searchQuery, "CHANNEL_FILTER", "AND Name = :InChannel", 1) searchQuery = strings.Replace(searchQuery, "CHANNEL_FILTER", "AND Name = :InChannel", 1)
} else { } else {
searchQuery = strings.Replace(searchQuery, "CHANNEL_FILTER", "", 1) searchQuery = strings.Replace(searchQuery, "CHANNEL_FILTER", "", 1)
} }
if params.FromUser != "" { if len(params.FromUsers) > 1 {
inClause := ":FromUser0"
queryParams["FromUser0"] = params.FromUsers[0]
for i := 1; i < len(params.FromUsers); i++ {
paramName := "FromUser" + strconv.FormatInt(int64(i), 10)
inClause += ", :" + paramName
queryParams[paramName] = params.FromUsers[i]
}
searchQuery = strings.Replace(searchQuery, "POST_FILTER", `
AND UserId IN (
SELECT
Id
FROM
Users
WHERE
TeamId = :TeamId
AND Username IN (`+inClause+`))`, 1)
} else if len(params.FromUsers) == 1 {
queryParams["FromUser"] = params.FromUsers[0]
searchQuery = strings.Replace(searchQuery, "POST_FILTER", ` searchQuery = strings.Replace(searchQuery, "POST_FILTER", `
AND UserId IN ( AND UserId IN (
SELECT SELECT
@@ -506,13 +544,7 @@ func (s SqlPostStore) Search(teamId string, userId string, params *model.SearchP
searchQuery = strings.Replace(searchQuery, "SEARCH_CLAUSE", searchClause, 1) searchQuery = strings.Replace(searchQuery, "SEARCH_CLAUSE", searchClause, 1)
} }
queryParams := map[string]interface{}{ queryParams["Terms"] = terms
"TeamId": teamId,
"UserId": userId,
"Terms": terms,
"InChannel": params.InChannel,
"FromUser": params.FromUser,
}
_, err := s.GetReplica().Select(&posts, searchQuery, queryParams) _, err := s.GetReplica().Select(&posts, searchQuery, queryParams)
if err != nil { if err != nil {
@@ -571,3 +603,152 @@ func (s SqlPostStore) GetForExport(channelId string) StoreChannel {
return storeChannel return storeChannel
} }
func (s SqlPostStore) AnalyticsUserCountsWithPostsByDay(teamId string) StoreChannel {
storeChannel := make(StoreChannel)
go func() {
result := StoreResult{}
query :=
`SELECT
t1.Name, COUNT(t1.UserId) AS Value
FROM
(SELECT DISTINCT
DATE(FROM_UNIXTIME(Posts.CreateAt / 1000)) AS Name,
Posts.UserId
FROM
Posts, Channels
WHERE
Posts.ChannelId = Channels.Id
AND Channels.TeamId = :TeamId
ORDER BY Name DESC) AS t1
GROUP BY Name
ORDER BY Name DESC
LIMIT 30`
if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES {
query =
`SELECT
TO_CHAR(t1.Name, 'YYYY-MM-DD') AS Name, COUNT(t1.UserId) AS Value
FROM
(SELECT DISTINCT
DATE(TO_TIMESTAMP(Posts.CreateAt / 1000)) AS Name,
Posts.UserId
FROM
Posts, Channels
WHERE
Posts.ChannelId = Channels.Id
AND Channels.TeamId = :TeamId
ORDER BY Name DESC) AS t1
GROUP BY Name
ORDER BY Name DESC
LIMIT 30`
}
var rows model.AnalyticsRows
_, err := s.GetReplica().Select(
&rows,
query,
map[string]interface{}{"TeamId": teamId, "Time": model.GetMillis() - 1000*60*60*24*31})
if err != nil {
result.Err = model.NewAppError("SqlPostStore.AnalyticsUserCountsWithPostsByDay", "We couldn't get user counts with posts", err.Error())
} else {
result.Data = rows
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}
func (s SqlPostStore) AnalyticsPostCountsByDay(teamId string) StoreChannel {
storeChannel := make(StoreChannel)
go func() {
result := StoreResult{}
query :=
`SELECT
Name, COUNT(Value) AS Value
FROM
(SELECT
DATE(FROM_UNIXTIME(Posts.CreateAt / 1000)) AS Name,
'1' AS Value
FROM
Posts, Channels
WHERE
Posts.ChannelId = Channels.Id
AND Channels.TeamId = :TeamId
AND Posts.CreateAt >:Time) AS t1
GROUP BY Name
ORDER BY Name DESC
LIMIT 30`
if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES {
query =
`SELECT
Name, COUNT(Value) AS Value
FROM
(SELECT
TO_CHAR(DATE(TO_TIMESTAMP(Posts.CreateAt / 1000)), 'YYYY-MM-DD') AS Name,
'1' AS Value
FROM
Posts, Channels
WHERE
Posts.ChannelId = Channels.Id
AND Channels.TeamId = :TeamId
AND Posts.CreateAt > :Time) AS t1
GROUP BY Name
ORDER BY Name DESC
LIMIT 30`
}
var rows model.AnalyticsRows
_, err := s.GetReplica().Select(
&rows,
query,
map[string]interface{}{"TeamId": teamId, "Time": model.GetMillis() - 1000*60*60*24*31})
if err != nil {
result.Err = model.NewAppError("SqlPostStore.AnalyticsPostCountsByDay", "We couldn't get post counts by day", err.Error())
} else {
result.Data = rows
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}
func (s SqlPostStore) AnalyticsPostCount(teamId string) StoreChannel {
storeChannel := make(StoreChannel)
go func() {
result := StoreResult{}
v, err := s.GetReplica().SelectInt(
`SELECT
COUNT(Posts.Id) AS Value
FROM
Posts,
Channels
WHERE
Posts.ChannelId = Channels.Id
AND Channels.TeamId = :TeamId`,
map[string]interface{}{"TeamId": teamId})
if err != nil {
result.Err = model.NewAppError("SqlPostStore.AnalyticsPostCount", "We couldn't get post counts", err.Error())
} else {
result.Data = v
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}

Просмотреть файл

@@ -580,3 +580,135 @@ func TestPostStoreSearch(t *testing.T) {
t.Fatal("returned wrong search result") t.Fatal("returned wrong search result")
} }
} }
func TestUserCountsWithPostsByDay(t *testing.T) {
Setup()
t1 := &model.Team{}
t1.DisplayName = "DisplayName"
t1.Name = "a" + model.NewId() + "b"
t1.Email = model.NewId() + "@nowhere.com"
t1.Type = model.TEAM_OPEN
t1 = Must(store.Team().Save(t1)).(*model.Team)
c1 := &model.Channel{}
c1.TeamId = t1.Id
c1.DisplayName = "Channel2"
c1.Name = "a" + model.NewId() + "b"
c1.Type = model.CHANNEL_OPEN
c1 = Must(store.Channel().Save(c1)).(*model.Channel)
o1 := &model.Post{}
o1.ChannelId = c1.Id
o1.UserId = model.NewId()
o1.CreateAt = model.GetMillis()
o1.Message = "a" + model.NewId() + "b"
o1 = Must(store.Post().Save(o1)).(*model.Post)
o1a := &model.Post{}
o1a.ChannelId = c1.Id
o1a.UserId = model.NewId()
o1a.CreateAt = o1.CreateAt
o1a.Message = "a" + model.NewId() + "b"
o1a = Must(store.Post().Save(o1a)).(*model.Post)
o2 := &model.Post{}
o2.ChannelId = c1.Id
o2.UserId = model.NewId()
o2.CreateAt = o1.CreateAt - (1000 * 60 * 60 * 24)
o2.Message = "a" + model.NewId() + "b"
o2 = Must(store.Post().Save(o2)).(*model.Post)
o2a := &model.Post{}
o2a.ChannelId = c1.Id
o2a.UserId = o2.UserId
o2a.CreateAt = o1.CreateAt - (1000 * 60 * 60 * 24)
o2a.Message = "a" + model.NewId() + "b"
o2a = Must(store.Post().Save(o2a)).(*model.Post)
if r1 := <-store.Post().AnalyticsUserCountsWithPostsByDay(t1.Id); r1.Err != nil {
t.Fatal(r1.Err)
} else {
row1 := r1.Data.(model.AnalyticsRows)[0]
if row1.Value != 2 {
t.Fatal("wrong value")
}
row2 := r1.Data.(model.AnalyticsRows)[1]
if row2.Value != 1 {
t.Fatal("wrong value")
}
}
}
func TestPostCountsByDay(t *testing.T) {
Setup()
t1 := &model.Team{}
t1.DisplayName = "DisplayName"
t1.Name = "a" + model.NewId() + "b"
t1.Email = model.NewId() + "@nowhere.com"
t1.Type = model.TEAM_OPEN
t1 = Must(store.Team().Save(t1)).(*model.Team)
c1 := &model.Channel{}
c1.TeamId = t1.Id
c1.DisplayName = "Channel2"
c1.Name = "a" + model.NewId() + "b"
c1.Type = model.CHANNEL_OPEN
c1 = Must(store.Channel().Save(c1)).(*model.Channel)
o1 := &model.Post{}
o1.ChannelId = c1.Id
o1.UserId = model.NewId()
o1.CreateAt = model.GetMillis()
o1.Message = "a" + model.NewId() + "b"
o1 = Must(store.Post().Save(o1)).(*model.Post)
o1a := &model.Post{}
o1a.ChannelId = c1.Id
o1a.UserId = model.NewId()
o1a.CreateAt = o1.CreateAt
o1a.Message = "a" + model.NewId() + "b"
o1a = Must(store.Post().Save(o1a)).(*model.Post)
o2 := &model.Post{}
o2.ChannelId = c1.Id
o2.UserId = model.NewId()
o2.CreateAt = o1.CreateAt - (1000 * 60 * 60 * 24 * 2)
o2.Message = "a" + model.NewId() + "b"
o2 = Must(store.Post().Save(o2)).(*model.Post)
o2a := &model.Post{}
o2a.ChannelId = c1.Id
o2a.UserId = o2.UserId
o2a.CreateAt = o1.CreateAt - (1000 * 60 * 60 * 24 * 2)
o2a.Message = "a" + model.NewId() + "b"
o2a = Must(store.Post().Save(o2a)).(*model.Post)
time.Sleep(1 * time.Second)
t.Log(t1.Id)
if r1 := <-store.Post().AnalyticsPostCountsByDay(t1.Id); r1.Err != nil {
t.Fatal(r1.Err)
} else {
row1 := r1.Data.(model.AnalyticsRows)[0]
if row1.Value != 2 {
t.Log(row1)
t.Fatal("wrong value")
}
row2 := r1.Data.(model.AnalyticsRows)[1]
if row2.Value != 2 {
t.Fatal("wrong value")
}
}
if r1 := <-store.Post().AnalyticsPostCount(t1.Id); r1.Err != nil {
t.Fatal(r1.Err)
} else {
if r1.Data.(int64) != 4 {
t.Fatal("wrong value")
}
}
}

Просмотреть файл

@@ -73,7 +73,8 @@ func NewSqlStore() Store {
} }
schemaVersion := sqlStore.GetCurrentSchemaVersion() schemaVersion := sqlStore.GetCurrentSchemaVersion()
isSchemaVersion07 := false isSchemaVersion07 := false // REMOVE AFTER 1.2 SHIP see PLT-828
isSchemaVersion10 := false // REMOVE AFTER 1.2 SHIP see PLT-828
// If the version is already set then we are potentially in an 'upgrade needed' state // If the version is already set then we are potentially in an 'upgrade needed' state
if schemaVersion != "" { if schemaVersion != "" {
@@ -86,7 +87,11 @@ func NewSqlStore() Store {
isSchemaVersion07 = true isSchemaVersion07 = true
} }
if model.IsPreviousVersion(schemaVersion) || isSchemaVersion07 { if schemaVersion == "1.0.0" {
isSchemaVersion10 = true
}
if model.IsPreviousVersion(schemaVersion) || isSchemaVersion07 || isSchemaVersion10 {
l4g.Warn("The database schema version of " + schemaVersion + " appears to be out of date") l4g.Warn("The database schema version of " + schemaVersion + " appears to be out of date")
l4g.Warn("Attempting to upgrade the database schema version to " + model.CurrentVersion) l4g.Warn("Attempting to upgrade the database schema version to " + model.CurrentVersion)
} else { } else {
@@ -98,7 +103,7 @@ func NewSqlStore() Store {
} }
} }
// REMOVE in 1.2 // REMOVE AFTER 1.2 SHIP see PLT-828
if sqlStore.DoesTableExist("Sessions") { if sqlStore.DoesTableExist("Sessions") {
if sqlStore.DoesColumnExist("Sessions", "AltId") { if sqlStore.DoesColumnExist("Sessions", "AltId") {
sqlStore.GetMaster().Exec("DROP TABLE IF EXISTS Sessions") sqlStore.GetMaster().Exec("DROP TABLE IF EXISTS Sessions")
@@ -140,7 +145,7 @@ func NewSqlStore() Store {
sqlStore.webhook.(*SqlWebhookStore).CreateIndexesIfNotExists() sqlStore.webhook.(*SqlWebhookStore).CreateIndexesIfNotExists()
sqlStore.preference.(*SqlPreferenceStore).CreateIndexesIfNotExists() sqlStore.preference.(*SqlPreferenceStore).CreateIndexesIfNotExists()
if model.IsPreviousVersion(schemaVersion) || isSchemaVersion07 { if model.IsPreviousVersion(schemaVersion) || isSchemaVersion07 || isSchemaVersion10 {
sqlStore.system.Update(&model.System{Name: "Version", Value: model.CurrentVersion}) sqlStore.system.Update(&model.System{Name: "Version", Value: model.CurrentVersion})
l4g.Warn("The database schema has been upgraded to version " + model.CurrentVersion) l4g.Warn("The database schema has been upgraded to version " + model.CurrentVersion)
} }

Просмотреть файл

@@ -29,7 +29,7 @@ func NewSqlTeamStore(sqlStore *SqlStore) TeamStore {
} }
func (s SqlTeamStore) UpgradeSchemaIfNeeded() { func (s SqlTeamStore) UpgradeSchemaIfNeeded() {
// REMOVE in 1.2 // REMOVE AFTER 1.2 SHIP see PLT-828
s.RemoveColumnIfExists("Teams", "AllowValet") s.RemoveColumnIfExists("Teams", "AllowValet")
} }

Просмотреть файл

@@ -41,7 +41,7 @@ func NewSqlUserStore(sqlStore *SqlStore) UserStore {
} }
func (us SqlUserStore) UpgradeSchemaIfNeeded() { func (us SqlUserStore) UpgradeSchemaIfNeeded() {
// REMOVE in 1.2 // REMOVE AFTER 1.2 SHIP see PLT-828
us.CreateColumnIfNotExists("Users", "ThemeProps", "varchar(2000)", "character varying(2000)", "{}") us.CreateColumnIfNotExists("Users", "ThemeProps", "varchar(2000)", "character varying(2000)", "{}")
} }

Просмотреть файл

@@ -75,6 +75,7 @@ type ChannelStore interface {
CheckPermissionsToByName(teamId string, channelName string, userId string) StoreChannel CheckPermissionsToByName(teamId string, channelName string, userId string) StoreChannel
UpdateLastViewedAt(channelId string, userId string) StoreChannel UpdateLastViewedAt(channelId string, userId string) StoreChannel
IncrementMentionCount(channelId string, userId string) StoreChannel IncrementMentionCount(channelId string, userId string) StoreChannel
AnalyticsTypeCount(teamId string, channelType string) StoreChannel
} }
type PostStore interface { type PostStore interface {
@@ -87,6 +88,9 @@ type PostStore interface {
GetEtag(channelId string) StoreChannel GetEtag(channelId string) StoreChannel
Search(teamId string, userId string, params *model.SearchParams) StoreChannel Search(teamId string, userId string, params *model.SearchParams) StoreChannel
GetForExport(channelId string) StoreChannel GetForExport(channelId string) StoreChannel
AnalyticsUserCountsWithPostsByDay(teamId string) StoreChannel
AnalyticsPostCountsByDay(teamId string) StoreChannel
AnalyticsPostCount(teamId string) StoreChannel
} }
type UserStore interface { type UserStore interface {

Просмотреть файл

@@ -159,6 +159,13 @@ func LoadConfig(fileName string) {
configureLog(&config.LogSettings) configureLog(&config.LogSettings)
TestConnection(&config) TestConnection(&config)
if config.FileSettings.DriverName == model.IMAGE_DRIVER_LOCAL {
dir := config.FileSettings.Directory
if len(dir) > 0 && dir[len(dir)-1:] != "/" {
config.FileSettings.Directory += "/"
}
}
Cfg = &config Cfg = &config
SanitizeOptions = getSanitizeOptions(Cfg) SanitizeOptions = getSanitizeOptions(Cfg)
ClientCfg = getClientConfig(Cfg) ClientCfg = getClientConfig(Cfg)

Просмотреть файл

@@ -20,7 +20,8 @@
"globals": { "globals": {
"React": false, "React": false,
"ReactDOM": false, "ReactDOM": false,
"ReactBootstrap": false "ReactBootstrap": false,
"Chart": false
}, },
"rules": { "rules": {
"comma-dangle": [2, "never"], "comma-dangle": [2, "never"],

Просмотреть файл

@@ -18,6 +18,7 @@ var SqlSettingsTab = require('./sql_settings.jsx');
var TeamSettingsTab = require('./team_settings.jsx'); var TeamSettingsTab = require('./team_settings.jsx');
var ServiceSettingsTab = require('./service_settings.jsx'); var ServiceSettingsTab = require('./service_settings.jsx');
var TeamUsersTab = require('./team_users.jsx'); var TeamUsersTab = require('./team_users.jsx');
var TeamAnalyticsTab = require('./team_analytics.jsx');
export default class AdminController extends React.Component { export default class AdminController extends React.Component {
constructor(props) { constructor(props) {
@@ -149,6 +150,10 @@ export default class AdminController extends React.Component {
if (this.state.teams) { if (this.state.teams) {
tab = <TeamUsersTab team={this.state.teams[this.state.selectedTeam]} />; tab = <TeamUsersTab team={this.state.teams[this.state.selectedTeam]} />;
} }
} else if (this.state.selected === 'team_analytics') {
if (this.state.teams) {
tab = <TeamAnalyticsTab team={this.state.teams[this.state.selectedTeam]} />;
}
} }
} }

Просмотреть файл

@@ -24,7 +24,7 @@ export default class AdminSidebar extends React.Component {
handleClick(name, teamId, e) { handleClick(name, teamId, e) {
e.preventDefault(); e.preventDefault();
this.props.selectTab(name, teamId); this.props.selectTab(name, teamId);
history.pushState({name: name, teamId: teamId}, null, `/admin_console/${name}/${teamId || ''}`); history.pushState({name, teamId}, null, `/admin_console/${name}/${teamId || ''}`);
} }
isSelected(name, teamId) { isSelected(name, teamId) {
@@ -121,6 +121,15 @@ export default class AdminSidebar extends React.Component {
{'- Users'} {'- Users'}
</a> </a>
</li> </li>
<li>
<a
href='#'
className={this.isSelected('team_analytics', team.id)}
onClick={this.handleClick.bind(this, 'team_analytics', team.id)}
>
{'- Statistics'}
</a>
</li>
</ul> </ul>
</li> </li>
</ul> </ul>

Просмотреть файл

@@ -0,0 +1,50 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
export default class LineChart extends React.Component {
constructor(props) {
super(props);
this.initChart = this.initChart.bind(this);
this.chart = null;
}
componentDidMount() {
this.initChart(this.props);
}
componentWillReceiveProps(nextProps) {
if (this.chart) {
this.chart.destroy();
this.initChart(nextProps);
}
}
componentWillUnmount() {
if (this.chart) {
this.chart.destroy();
}
}
initChart(props) {
var el = ReactDOM.findDOMNode(this);
var ctx = el.getContext('2d');
this.chart = new Chart(ctx).Line(props.data, props.options || {}); //eslint-disable-line new-cap
}
render() {
return (
<canvas
width={this.props.width}
height={this.props.height}
/>
);
}
}
LineChart.propTypes = {
width: React.PropTypes.string,
height: React.PropTypes.string,
data: React.PropTypes.object,
options: React.PropTypes.object
};

Просмотреть файл

@@ -0,0 +1,353 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
var Client = require('../../utils/client.jsx');
var Utils = require('../../utils/utils.jsx');
var LineChart = require('./line_chart.jsx');
export default class TeamAnalytics extends React.Component {
constructor(props) {
super(props);
this.getData = this.getData.bind(this);
this.state = {
users: null,
serverError: null,
channel_open_count: null,
channel_private_count: null,
post_count: null,
post_counts_day: null,
user_counts_with_posts_day: null,
recent_active_users: null,
newly_created_users: null
};
}
componentDidMount() {
this.getData(this.props.team.id);
}
getData(teamId) {
Client.getAnalytics(
teamId,
'standard',
(data) => {
for (var index in data) {
if (data[index].name === 'channel_open_count') {
this.setState({channel_open_count: data[index].value});
}
if (data[index].name === 'channel_private_count') {
this.setState({channel_private_count: data[index].value});
}
if (data[index].name === 'post_count') {
this.setState({post_count: data[index].value});
}
}
},
(err) => {
this.setState({serverError: err.message});
}
);
Client.getAnalytics(
teamId,
'post_counts_day',
(data) => {
var chartData = {
labels: [],
datasets: [{
label: 'Total Posts',
fillColor: 'rgba(151,187,205,0.2)',
strokeColor: 'rgba(151,187,205,1)',
pointColor: 'rgba(151,187,205,1)',
pointStrokeColor: '#fff',
pointHighlightFill: '#fff',
pointHighlightStroke: 'rgba(151,187,205,1)',
data: []
}]
};
for (var index in data) {
if (data[index]) {
var row = data[index];
chartData.labels.push(row.name);
chartData.datasets[0].data.push(row.value);
}
}
this.setState({post_counts_day: chartData});
},
(err) => {
this.setState({serverError: err.message});
}
);
Client.getAnalytics(
teamId,
'user_counts_with_posts_day',
(data) => {
var chartData = {
labels: [],
datasets: [{
label: 'Active Users With Posts',
fillColor: 'rgba(151,187,205,0.2)',
strokeColor: 'rgba(151,187,205,1)',
pointColor: 'rgba(151,187,205,1)',
pointStrokeColor: '#fff',
pointHighlightFill: '#fff',
pointHighlightStroke: 'rgba(151,187,205,1)',
data: []
}]
};
for (var index in data) {
if (data[index]) {
var row = data[index];
chartData.labels.push(row.name);
chartData.datasets[0].data.push(row.value);
}
}
this.setState({user_counts_with_posts_day: chartData});
},
(err) => {
this.setState({serverError: err.message});
}
);
Client.getProfilesForTeam(
teamId,
(users) => {
this.setState({users});
var usersList = [];
for (var id in users) {
if (users.hasOwnProperty(id)) {
usersList.push(users[id]);
}
}
usersList.sort((a, b) => {
if (a.last_activity_at < b.last_activity_at) {
return 1;
}
if (a.last_activity_at > b.last_activity_at) {
return -1;
}
return 0;
});
var recentActive = [];
for (let i = 0; i < usersList.length; i++) {
recentActive.push(usersList[i]);
if (i > 19) {
break;
}
}
this.setState({recent_active_users: recentActive});
usersList.sort((a, b) => {
if (a.create_at < b.create_at) {
return 1;
}
if (a.create_at > b.create_at) {
return -1;
}
return 0;
});
var newlyCreated = [];
for (let i = 0; i < usersList.length; i++) {
newlyCreated.push(usersList[i]);
if (i > 19) {
break;
}
}
this.setState({newly_created_users: newlyCreated});
},
(err) => {
this.setState({serverError: err.message});
}
);
}
componentWillReceiveProps(newProps) {
this.setState({
users: null,
serverError: null,
channel_open_count: null,
channel_private_count: null,
post_count: null,
post_counts_day: null,
user_counts_with_posts_day: null,
recent_active_users: null,
newly_created_users: null
});
this.getData(newProps.team.id);
}
componentWillUnmount() {
}
render() {
var serverError = '';
if (this.state.serverError) {
serverError = <div className='form-group has-error'><label className='control-label'>{this.state.serverError}</label></div>;
}
var totalCount = (
<div className='total-count text-center'>
<div>{'Total Users'}</div>
<div>{this.state.users == null ? 'Loading...' : Object.keys(this.state.users).length}</div>
</div>
);
var openChannelCount = (
<div className='total-count text-center'>
<div>{'Public Groups'}</div>
<div>{this.state.channel_open_count == null ? 'Loading...' : this.state.channel_open_count}</div>
</div>
);
var openPrivateCount = (
<div className='total-count text-center'>
<div>{'Private Groups'}</div>
<div>{this.state.channel_private_count == null ? 'Loading...' : this.state.channel_private_count}</div>
</div>
);
var postCount = (
<div className='total-count text-center'>
<div>{'Total Posts'}</div>
<div>{this.state.post_count == null ? 'Loading...' : this.state.post_count}</div>
</div>
);
var postCountsByDay = (
<div className='total-count-by-day'>
<div>{'Total Posts'}</div>
<div>{'Loading...'}</div>
</div>
);
if (this.state.post_counts_day != null) {
postCountsByDay = (
<div className='total-count-by-day'>
<div>{'Total Posts'}</div>
<LineChart
data={this.state.post_counts_day}
width='740'
height='225'
/>
</div>
);
}
var usersWithPostsByDay = (
<div className='total-count-by-day'>
<div>{'Total Posts'}</div>
<div>{'Loading...'}</div>
</div>
);
if (this.state.user_counts_with_posts_day != null) {
usersWithPostsByDay = (
<div className='total-count-by-day'>
<div>{'Active Users With Posts'}</div>
<LineChart
data={this.state.user_counts_with_posts_day}
width='740'
height='225'
/>
</div>
);
}
var recentActiveUser = (
<div className='recent-active-users'>
<div>{'Recent Active Users'}</div>
<div>{'Loading...'}</div>
</div>
);
if (this.state.recent_active_users != null) {
recentActiveUser = (
<div className='recent-active-users'>
<div>{'Recent Active Users'}</div>
<table width='90%'>
<tbody>
{
this.state.recent_active_users.map((user) => {
return (
<tr key={user.id}>
<td className='recent-active-users-td'>{user.email}</td>
<td className='recent-active-users-td'>{Utils.displayDateTime(user.last_activity_at)}</td>
</tr>
);
})
}
</tbody>
</table>
</div>
);
}
var newUsers = (
<div className='recent-active-users'>
<div>{'Newly Created Users'}</div>
<div>{'Loading...'}</div>
</div>
);
if (this.state.newly_created_users != null) {
newUsers = (
<div className='recent-active-users'>
<div>{'Newly Created Users'}</div>
<table width='90%'>
<tbody>
{
this.state.newly_created_users.map((user) => {
return (
<tr key={user.id}>
<td className='recent-active-users-td'>{user.email}</td>
<td className='recent-active-users-td'>{Utils.displayDateTime(user.create_at)}</td>
</tr>
);
})
}
</tbody>
</table>
</div>
);
}
return (
<div className='wrapper--fixed'>
<h2>{'Statistics for ' + this.props.team.name}</h2>
{serverError}
{totalCount}
{postCount}
{openChannelCount}
{openPrivateCount}
{postCountsByDay}
{usersWithPostsByDay}
{recentActiveUser}
{newUsers}
</div>
);
}
}
TeamAnalytics.propTypes = {
team: React.PropTypes.object
};

Просмотреть файл

@@ -3,7 +3,7 @@
const ChannelStore = require('../stores/channel_store.jsx'); const ChannelStore = require('../stores/channel_store.jsx');
const UserStore = require('../stores/user_store.jsx'); const UserStore = require('../stores/user_store.jsx');
const PostStore = require('../stores/post_store.jsx'); const SearchStore = require('../stores/search_store.jsx');
const NavbarSearchBox = require('./search_bar.jsx'); const NavbarSearchBox = require('./search_bar.jsx');
const AsyncClient = require('../utils/async_client.jsx'); const AsyncClient = require('../utils/async_client.jsx');
const Client = require('../utils/client.jsx'); const Client = require('../utils/client.jsx');
@@ -35,19 +35,19 @@ export default class ChannelHeader extends React.Component {
memberChannel: ChannelStore.getCurrentMember(), memberChannel: ChannelStore.getCurrentMember(),
memberTeam: UserStore.getCurrentUser(), memberTeam: UserStore.getCurrentUser(),
users: ChannelStore.getCurrentExtraInfo().members, users: ChannelStore.getCurrentExtraInfo().members,
searchVisible: PostStore.getSearchResults() !== null searchVisible: SearchStore.getSearchResults() !== null
}; };
} }
componentDidMount() { componentDidMount() {
ChannelStore.addChangeListener(this.onListenerChange); ChannelStore.addChangeListener(this.onListenerChange);
ChannelStore.addExtraInfoChangeListener(this.onListenerChange); ChannelStore.addExtraInfoChangeListener(this.onListenerChange);
PostStore.addSearchChangeListener(this.onListenerChange); SearchStore.addSearchChangeListener(this.onListenerChange);
UserStore.addChangeListener(this.onListenerChange); UserStore.addChangeListener(this.onListenerChange);
} }
componentWillUnmount() { componentWillUnmount() {
ChannelStore.removeChangeListener(this.onListenerChange); ChannelStore.removeChangeListener(this.onListenerChange);
ChannelStore.removeExtraInfoChangeListener(this.onListenerChange); ChannelStore.removeExtraInfoChangeListener(this.onListenerChange);
PostStore.removeSearchChangeListener(this.onListenerChange); SearchStore.removeSearchChangeListener(this.onListenerChange);
UserStore.addChangeListener(this.onListenerChange); UserStore.addChangeListener(this.onListenerChange);
} }
onListenerChange() { onListenerChange() {

Просмотреть файл

@@ -253,8 +253,14 @@ export default class CreatePost extends React.Component {
this.setState({uploadsInProgress: draft.uploadsInProgress, previews: draft.previews}); this.setState({uploadsInProgress: draft.uploadsInProgress, previews: draft.previews});
} }
handleUploadError(err, clientId) { handleUploadError(err, clientId) {
let message = err;
if (message && typeof message !== 'string') {
// err is an AppError from the server
message = err.message;
}
if (clientId === -1) { if (clientId === -1) {
this.setState({serverError: err}); this.setState({serverError: message});
} else { } else {
const draft = PostStore.getDraft(this.state.channelId); const draft = PostStore.getDraft(this.state.channelId);
@@ -265,7 +271,7 @@ export default class CreatePost extends React.Component {
PostStore.storeDraft(this.state.channelId, draft); PostStore.storeDraft(this.state.channelId, draft);
this.setState({uploadsInProgress: draft.uploadsInProgress, serverError: err}); this.setState({uploadsInProgress: draft.uploadsInProgress, serverError: message});
} }
} }
handleTextDrop(text) { handleTextDrop(text) {

Просмотреть файл

@@ -9,12 +9,8 @@ export default class ErrorBar extends React.Component {
this.onErrorChange = this.onErrorChange.bind(this); this.onErrorChange = this.onErrorChange.bind(this);
this.handleClose = this.handleClose.bind(this); this.handleClose = this.handleClose.bind(this);
this.prevTimer = null;
this.state = ErrorStore.getLastError(); this.state = ErrorStore.getLastError();
if (this.isValidError(this.state)) {
this.prevTimer = setTimeout(this.handleClose, 10000);
}
} }
isValidError(s) { isValidError(s) {
@@ -56,16 +52,8 @@ export default class ErrorBar extends React.Component {
onErrorChange() { onErrorChange() {
var newState = ErrorStore.getLastError(); var newState = ErrorStore.getLastError();
if (this.prevTimer != null) {
clearInterval(this.prevTimer);
this.prevTimer = null;
}
if (newState) { if (newState) {
this.setState(newState); this.setState(newState);
if (!this.isConnectionError(newState)) {
this.prevTimer = setTimeout(this.handleClose, 10000);
}
} else { } else {
this.setState({message: null}); this.setState({message: null});
} }

Просмотреть файл

@@ -2,7 +2,7 @@
// See License.txt for license information. // See License.txt for license information.
var UserStore = require('../stores/user_store.jsx'); var UserStore = require('../stores/user_store.jsx');
var PostStore = require('../stores/post_store.jsx'); var SearchStore = require('../stores/search_store.jsx');
var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
var Mention = require('./mention.jsx'); var Mention = require('./mention.jsx');
@@ -66,7 +66,7 @@ export default class MentionList extends React.Component {
} }
} }
componentDidMount() { componentDidMount() {
PostStore.addMentionDataChangeListener(this.onListenerChange); SearchStore.addMentionDataChangeListener(this.onListenerChange);
$('.post-right__scroll').scroll(this.onScroll); $('.post-right__scroll').scroll(this.onScroll);
@@ -74,7 +74,7 @@ export default class MentionList extends React.Component {
$(document).click(this.onClick); $(document).click(this.onClick);
} }
componentWillUnmount() { componentWillUnmount() {
PostStore.removeMentionDataChangeListener(this.onListenerChange); SearchStore.removeMentionDataChangeListener(this.onListenerChange);
$('body').off('keydown.mentionlist', '#' + this.props.id); $('body').off('keydown.mentionlist', '#' + this.props.id);
} }
@@ -217,12 +217,17 @@ export default class MentionList extends React.Component {
if (this.state.selectedMention === index) { if (this.state.selectedMention === index) {
isFocused = 'mentions-focus'; isFocused = 'mentions-focus';
} }
if (!users[i].secondary_text) {
users[i].secondary_text = Utils.getFullName(users[i]);
}
mentions[index] = ( mentions[index] = (
<Mention <Mention
key={'mention_key_' + index} key={'mention_key_' + index}
ref={'mention' + index} ref={'mention' + index}
username={users[i].username} username={users[i].username}
secondary_text={Utils.getFullName(users[i])} secondary_text={users[i].secondary_text}
id={users[i].id} id={users[i].id}
listId={index} listId={index}
isFocused={isFocused} isFocused={isFocused}

Просмотреть файл

@@ -1,13 +1,7 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information. // See License.txt for license information.
const AsyncClient = require('../utils/async_client.jsx');
const ChannelStore = require('../stores/channel_store.jsx');
const Constants = require('../utils/constants.jsx');
const Client = require('../utils/client.jsx');
const Modal = ReactBootstrap.Modal; const Modal = ReactBootstrap.Modal;
const PreferenceStore = require('../stores/preference_store.jsx');
const TeamStore = require('../stores/team_store.jsx');
const UserStore = require('../stores/user_store.jsx'); const UserStore = require('../stores/user_store.jsx');
const Utils = require('../utils/utils.jsx'); const Utils = require('../utils/utils.jsx');
@@ -70,53 +64,25 @@ export default class MoreDirectChannels extends React.Component {
} }
handleShowDirectChannel(teammate, e) { handleShowDirectChannel(teammate, e) {
e.preventDefault();
if (this.state.loadingDMChannel !== -1) { if (this.state.loadingDMChannel !== -1) {
return; return;
} }
e.preventDefault();
const channelName = Utils.getDirectChannelName(UserStore.getCurrentId(), teammate.id);
let channel = ChannelStore.getByName(channelName);
const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, teammate.id, 'true');
AsyncClient.savePreferences([preference]);
if (channel) {
Utils.switchChannel(channel);
this.handleHide();
} else {
this.setState({loadingDMChannel: teammate.id}); this.setState({loadingDMChannel: teammate.id});
Utils.openDirectChannelToUser(
channel = { teammate,
name: channelName, (channel) => {
last_post_at: 0, Utils.switchChannel(channel);
total_msg_count: 0,
type: 'D',
display_name: teammate.username,
teammate_id: teammate.id,
status: UserStore.getStatus(teammate.id)
};
Client.createDirectChannel(
channel,
teammate.id,
(data) => {
this.setState({loadingDMChannel: -1}); this.setState({loadingDMChannel: -1});
AsyncClient.getChannel(data.id);
Utils.switchChannel(data);
this.handleHide(); this.handleHide();
}, },
() => { () => {
this.setState({loadingDMChannel: -1}); this.setState({loadingDMChannel: -1});
window.location.href = TeamStore.getCurrentTeamUrl() + '/channels/' + channelName;
} }
); );
} }
}
handleUserChange() { handleUserChange() {
this.setState({users: this.getUsersFromStore()}); this.setState({users: this.getUsersFromStore()});

Просмотреть файл

@@ -3,9 +3,23 @@
var UserStore = require('../stores/user_store.jsx'); var UserStore = require('../stores/user_store.jsx');
var Popover = ReactBootstrap.Popover; var Popover = ReactBootstrap.Popover;
var OverlayTrigger = ReactBootstrap.OverlayTrigger; var Overlay = ReactBootstrap.Overlay;
const Utils = require('../utils/utils.jsx');
const ChannelStore = require('../stores/channel_store.jsx');
export default class PopoverListMembers extends React.Component { export default class PopoverListMembers extends React.Component {
constructor(props) {
super(props);
this.handleShowDirectChannel = this.handleShowDirectChannel.bind(this);
this.closePopover = this.closePopover.bind(this);
}
componentWillMount() {
this.setState({showPopover: false});
}
componentDidMount() { componentDidMount() {
const originalLeave = $.fn.popover.Constructor.prototype.leave; const originalLeave = $.fn.popover.Constructor.prototype.leave;
$.fn.popover.Constructor.prototype.leave = function onLeave(obj) { $.fn.popover.Constructor.prototype.leave = function onLeave(obj) {
@@ -27,12 +41,36 @@ export default class PopoverListMembers extends React.Component {
} }
}; };
} }
handleShowDirectChannel(teammate, e) {
e.preventDefault();
Utils.openDirectChannelToUser(
teammate,
(channel, channelAlreadyExisted) => {
Utils.switchChannel(channel);
if (channelAlreadyExisted) {
this.closePopover();
}
},
() => {
this.closePopover();
}
);
}
closePopover() {
this.setState({showPopover: false});
}
render() { render() {
let popoverHtml = []; let popoverHtml = [];
let count = 0; let count = 0;
let countText = '-'; let countText = '-';
const members = this.props.members; const members = this.props.members;
const teamMembers = UserStore.getProfilesUsernameMap(); const teamMembers = UserStore.getProfilesUsernameMap();
const currentUserId = UserStore.getCurrentId();
const ch = ChannelStore.getCurrent();
if (members && teamMembers) { if (members && teamMembers) {
members.sort((a, b) => { members.sort((a, b) => {
@@ -40,14 +78,75 @@ export default class PopoverListMembers extends React.Component {
}); });
members.forEach((m, i) => { 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 = (
<button
type='button'
className='btn btn-primary btn-message'
onClick={(e) => this.handleShowDirectChannel(m, e)}
>
{'Message'}
</button>
);
}
if (teamMembers[m.username] && teamMembers[m.username].delete_at <= 0) { if (teamMembers[m.username] && teamMembers[m.username].delete_at <= 0) {
popoverHtml.push( popoverHtml.push(
<div <div
className='text--nowrap' className='text--nowrap'
key={'popover-member-' + i} key={'popover-member-' + i}
> >
<img
className='profile-img pull-left'
width='38'
height='38'
src={`/api/v1/users/${m.id}/image?time=${m.update_at}&${Utils.getSessionIndex()}`}
/>
<div className='pull-left'>
<div
className='more-name'
>
{m.username} {m.username}
</div> </div>
<div
className='more-description'
>
{details}
</div>
</div>
<div
className='pull-right profile-action'
>
{button}
</div>
</div>
); );
count++; count++;
} }
@@ -61,20 +160,12 @@ export default class PopoverListMembers extends React.Component {
} }
return ( return (
<OverlayTrigger <div>
trigger='click' <div
placement='bottom' id='member_popover'
rootClose={true} ref='member_popover_target'
overlay={ onClick={(e) => this.setState({popoverTarget: e.target, showPopover: !this.state.showPopover})}
<Popover
title='Members'
id='member-list-popover'
> >
{popoverHtml}
</Popover>
}
>
<div id='member_popover'>
<div> <div>
{countText} {countText}
<span <span
@@ -83,7 +174,23 @@ export default class PopoverListMembers extends React.Component {
/> />
</div> </div>
</div> </div>
</OverlayTrigger> <Overlay
rootClose={true}
onHide={this.closePopover}
show={this.state.showPopover}
target={() => this.state.popoverTarget}
placement='bottom'
>
<Popover
title='Members'
id='member-list-popover'
>
<div>
{popoverHtml}
</div>
</Popover>
</Overlay>
</div>
); );
} }
} }

Просмотреть файл

@@ -3,7 +3,7 @@
var client = require('../utils/client.jsx'); var client = require('../utils/client.jsx');
var AsyncClient = require('../utils/async_client.jsx'); var AsyncClient = require('../utils/async_client.jsx');
var PostStore = require('../stores/post_store.jsx'); var SearchStore = require('../stores/search_store.jsx');
var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
var utils = require('../utils/utils.jsx'); var utils = require('../utils/utils.jsx');
var Constants = require('../utils/constants.jsx'); var Constants = require('../utils/constants.jsx');
@@ -30,17 +30,17 @@ export default class SearchBar extends React.Component {
this.state = state; this.state = state;
} }
getSearchTermStateFromStores() { getSearchTermStateFromStores() {
var term = PostStore.getSearchTerm() || ''; var term = SearchStore.getSearchTerm() || '';
return { return {
searchTerm: term searchTerm: term
}; };
} }
componentDidMount() { componentDidMount() {
PostStore.addSearchTermChangeListener(this.onListenerChange); SearchStore.addSearchTermChangeListener(this.onListenerChange);
this.mounted = true; this.mounted = true;
} }
componentWillUnmount() { componentWillUnmount() {
PostStore.removeSearchTermChangeListener(this.onListenerChange); SearchStore.removeSearchTermChangeListener(this.onListenerChange);
this.mounted = false; this.mounted = false;
} }
onListenerChange(doSearch, isMentionSearch) { onListenerChange(doSearch, isMentionSearch) {
@@ -84,8 +84,8 @@ export default class SearchBar extends React.Component {
} }
handleUserInput(e) { handleUserInput(e) {
var term = e.target.value; var term = e.target.value;
PostStore.storeSearchTerm(term); SearchStore.storeSearchTerm(term);
PostStore.emitSearchTermChange(false); SearchStore.emitSearchTermChange(false);
this.setState({searchTerm: term}); this.setState({searchTerm: term});
this.refs.autocomplete.handleInputChange(e.target, term); this.refs.autocomplete.handleInputChange(e.target, term);
@@ -150,8 +150,8 @@ export default class SearchBar extends React.Component {
textbox.value = text; textbox.value = text;
utils.setCaretPosition(textbox, preText.length + word.length); utils.setCaretPosition(textbox, preText.length + word.length);
PostStore.storeSearchTerm(text); SearchStore.storeSearchTerm(text);
PostStore.emitSearchTermChange(false); SearchStore.emitSearchTermChange(false);
this.setState({searchTerm: text}); this.setState({searchTerm: text});
} }

Просмотреть файл

@@ -1,7 +1,7 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information. // See License.txt for license information.
var PostStore = require('../stores/post_store.jsx'); var SearchStore = require('../stores/search_store.jsx');
var UserStore = require('../stores/user_store.jsx'); var UserStore = require('../stores/user_store.jsx');
var SearchBox = require('./search_bar.jsx'); var SearchBox = require('./search_bar.jsx');
var Utils = require('../utils/utils.jsx'); var Utils = require('../utils/utils.jsx');
@@ -9,7 +9,7 @@ var SearchResultsHeader = require('./search_results_header.jsx');
var SearchResultsItem = require('./search_results_item.jsx'); var SearchResultsItem = require('./search_results_item.jsx');
function getStateFromStores() { function getStateFromStores() {
return {results: PostStore.getSearchResults()}; return {results: SearchStore.getSearchResults()};
} }
export default class SearchResults extends React.Component { export default class SearchResults extends React.Component {
@@ -30,7 +30,7 @@ export default class SearchResults extends React.Component {
componentDidMount() { componentDidMount() {
this.mounted = true; this.mounted = true;
PostStore.addSearchChangeListener(this.onChange); SearchStore.addSearchChangeListener(this.onChange);
this.resize(); this.resize();
window.addEventListener('resize', this.handleResize); window.addEventListener('resize', this.handleResize);
} }
@@ -40,7 +40,7 @@ export default class SearchResults extends React.Component {
} }
componentWillUnmount() { componentWillUnmount() {
PostStore.removeSearchChangeListener(this.onChange); SearchStore.removeSearchChangeListener(this.onChange);
this.mounted = false; this.mounted = false;
window.removeEventListener('resize', this.handleResize); window.removeEventListener('resize', this.handleResize);
} }
@@ -78,7 +78,7 @@ export default class SearchResults extends React.Component {
searchForm = <SearchBox />; searchForm = <SearchBox />;
} }
var noResults = (!results || !results.order || !results.order.length); var noResults = (!results || !results.order || !results.order.length);
var searchTerm = PostStore.getSearchTerm(); var searchTerm = SearchStore.getSearchTerm();
var ctls = null; var ctls = null;

Просмотреть файл

@@ -1,7 +1,7 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information. // See License.txt for license information.
var PostStore = require('../stores/post_store.jsx'); var SearchStore = require('../stores/search_store.jsx');
var ChannelStore = require('../stores/channel_store.jsx'); var ChannelStore = require('../stores/channel_store.jsx');
var UserStore = require('../stores/user_store.jsx'); var UserStore = require('../stores/user_store.jsx');
var UserProfile = require('./user_profile.jsx'); var UserProfile = require('./user_profile.jsx');
@@ -32,7 +32,7 @@ export default class SearchResultsItem extends React.Component {
AppDispatcher.handleServerAction({ AppDispatcher.handleServerAction({
type: ActionTypes.RECIEVED_POST_SELECTED, type: ActionTypes.RECIEVED_POST_SELECTED,
post_list: data, post_list: data,
from_search: PostStore.getSearchTerm() from_search: SearchStore.getSearchTerm()
}); });
AppDispatcher.handleServerAction({ AppDispatcher.handleServerAction({

Просмотреть файл

@@ -3,11 +3,12 @@
var SearchResults = require('./search_results.jsx'); var SearchResults = require('./search_results.jsx');
var RhsThread = require('./rhs_thread.jsx'); var RhsThread = require('./rhs_thread.jsx');
var SearchStore = require('../stores/search_store.jsx');
var PostStore = require('../stores/post_store.jsx'); var PostStore = require('../stores/post_store.jsx');
var Utils = require('../utils/utils.jsx'); var Utils = require('../utils/utils.jsx');
function getStateFromStores() { function getStateFromStores() {
return {search_visible: PostStore.getSearchResults() != null, post_right_visible: PostStore.getSelectedPost() != null, is_mention_search: PostStore.getIsMentionSearch()}; return {search_visible: SearchStore.getSearchResults() != null, post_right_visible: PostStore.getSelectedPost() != null, is_mention_search: SearchStore.getIsMentionSearch()};
} }
export default class SidebarRight extends React.Component { export default class SidebarRight extends React.Component {
@@ -22,11 +23,11 @@ export default class SidebarRight extends React.Component {
this.state = getStateFromStores(); this.state = getStateFromStores();
} }
componentDidMount() { componentDidMount() {
PostStore.addSearchChangeListener(this.onSearchChange); SearchStore.addSearchChangeListener(this.onSearchChange);
PostStore.addSelectedPostChangeListener(this.onSelectedChange); PostStore.addSelectedPostChangeListener(this.onSelectedChange);
} }
componentWillUnmount() { componentWillUnmount() {
PostStore.removeSearchChangeListener(this.onSearchChange); SearchStore.removeSearchChangeListener(this.onSearchChange);
PostStore.removeSelectedPostChangeListener(this.onSelectedChange); PostStore.removeSelectedPostChangeListener(this.onSelectedChange);
} }
componentDidUpdate() { componentDidUpdate() {

Просмотреть файл

@@ -2,7 +2,7 @@
// See License.txt for license information. // See License.txt for license information.
const AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); const AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
const PostStore = require('../stores/post_store.jsx'); const SearchStore = require('../stores/search_store.jsx');
const CommandList = require('./command_list.jsx'); const CommandList = require('./command_list.jsx');
const ErrorStore = require('../stores/error_store.jsx'); const ErrorStore = require('../stores/error_store.jsx');
@@ -54,7 +54,7 @@ export default class Textbox extends React.Component {
} }
componentDidMount() { componentDidMount() {
PostStore.addAddMentionListener(this.onListenerChange); SearchStore.addAddMentionListener(this.onListenerChange);
ErrorStore.addChangeListener(this.onRecievedError); ErrorStore.addChangeListener(this.onRecievedError);
this.resize(); this.resize();
@@ -62,7 +62,7 @@ export default class Textbox extends React.Component {
} }
componentWillUnmount() { componentWillUnmount() {
PostStore.removeAddMentionListener(this.onListenerChange); SearchStore.removeAddMentionListener(this.onListenerChange);
ErrorStore.removeChangeListener(this.onRecievedError); ErrorStore.removeChangeListener(this.onRecievedError);
} }

Просмотреть файл

@@ -171,7 +171,7 @@ export default class UserSettingsGeneralTab extends React.Component {
}.bind(this), }.bind(this),
function imageUploadFailure(err) { function imageUploadFailure(err) {
var state = this.setupInitialState(this.props); var state = this.setupInitialState(this.props);
state.serverError = err; state.serverError = err.message;
this.setState(state); this.setState(state);
}.bind(this) }.bind(this)
); );

Просмотреть файл

@@ -12,11 +12,7 @@ var Constants = require('../utils/constants.jsx');
var ActionTypes = Constants.ActionTypes; var ActionTypes = Constants.ActionTypes;
var CHANGE_EVENT = 'change'; var CHANGE_EVENT = 'change';
var SEARCH_CHANGE_EVENT = 'search_change';
var SEARCH_TERM_CHANGE_EVENT = 'search_term_change';
var SELECTED_POST_CHANGE_EVENT = 'selected_post_change'; var SELECTED_POST_CHANGE_EVENT = 'selected_post_change';
var MENTION_DATA_CHANGE_EVENT = 'mention_data_change';
var ADD_MENTION_EVENT = 'add_mention';
var EDIT_POST_EVENT = 'edit_post'; var EDIT_POST_EVENT = 'edit_post';
class PostStoreClass extends EventEmitter { class PostStoreClass extends EventEmitter {
@@ -26,21 +22,15 @@ class PostStoreClass extends EventEmitter {
this.emitChange = this.emitChange.bind(this); this.emitChange = this.emitChange.bind(this);
this.addChangeListener = this.addChangeListener.bind(this); this.addChangeListener = this.addChangeListener.bind(this);
this.removeChangeListener = this.removeChangeListener.bind(this); this.removeChangeListener = this.removeChangeListener.bind(this);
this.emitSearchChange = this.emitSearchChange.bind(this);
this.addSearchChangeListener = this.addSearchChangeListener.bind(this);
this.removeSearchChangeListener = this.removeSearchChangeListener.bind(this);
this.emitSearchTermChange = this.emitSearchTermChange.bind(this);
this.addSearchTermChangeListener = this.addSearchTermChangeListener.bind(this);
this.removeSearchTermChangeListener = this.removeSearchTermChangeListener.bind(this);
this.emitSelectedPostChange = this.emitSelectedPostChange.bind(this); this.emitSelectedPostChange = this.emitSelectedPostChange.bind(this);
this.addSelectedPostChangeListener = this.addSelectedPostChangeListener.bind(this); this.addSelectedPostChangeListener = this.addSelectedPostChangeListener.bind(this);
this.removeSelectedPostChangeListener = this.removeSelectedPostChangeListener.bind(this); this.removeSelectedPostChangeListener = this.removeSelectedPostChangeListener.bind(this);
this.emitMentionDataChange = this.emitMentionDataChange.bind(this);
this.addMentionDataChangeListener = this.addMentionDataChangeListener.bind(this); this.emitEditPost = this.emitEditPost.bind(this);
this.removeMentionDataChangeListener = this.removeMentionDataChangeListener.bind(this); this.addEditPostListener = this.addEditPostListener.bind(this);
this.emitAddMention = this.emitAddMention.bind(this); this.removeEditPostListener = this.removeEditPostListener.bind(this);
this.addAddMentionListener = this.addAddMentionListener.bind(this);
this.removeAddMentionListener = this.removeAddMentionListener.bind(this);
this.getCurrentPosts = this.getCurrentPosts.bind(this); this.getCurrentPosts = this.getCurrentPosts.bind(this);
this.storePosts = this.storePosts.bind(this); this.storePosts = this.storePosts.bind(this);
this.pStorePosts = this.pStorePosts.bind(this); this.pStorePosts = this.pStorePosts.bind(this);
@@ -59,13 +49,8 @@ class PostStoreClass extends EventEmitter {
this.pRemovePendingPost = this.pRemovePendingPost.bind(this); this.pRemovePendingPost = this.pRemovePendingPost.bind(this);
this.clearPendingPosts = this.clearPendingPosts.bind(this); this.clearPendingPosts = this.clearPendingPosts.bind(this);
this.updatePendingPost = this.updatePendingPost.bind(this); this.updatePendingPost = this.updatePendingPost.bind(this);
this.storeSearchResults = this.storeSearchResults.bind(this);
this.getSearchResults = this.getSearchResults.bind(this);
this.getIsMentionSearch = this.getIsMentionSearch.bind(this);
this.storeSelectedPost = this.storeSelectedPost.bind(this); this.storeSelectedPost = this.storeSelectedPost.bind(this);
this.getSelectedPost = this.getSelectedPost.bind(this); this.getSelectedPost = this.getSelectedPost.bind(this);
this.storeSearchTerm = this.storeSearchTerm.bind(this);
this.getSearchTerm = this.getSearchTerm.bind(this);
this.getEmptyDraft = this.getEmptyDraft.bind(this); this.getEmptyDraft = this.getEmptyDraft.bind(this);
this.storeCurrentDraft = this.storeCurrentDraft.bind(this); this.storeCurrentDraft = this.storeCurrentDraft.bind(this);
this.getCurrentDraft = this.getCurrentDraft.bind(this); this.getCurrentDraft = this.getCurrentDraft.bind(this);
@@ -77,9 +62,6 @@ class PostStoreClass extends EventEmitter {
this.clearCommentDraftUploads = this.clearCommentDraftUploads.bind(this); this.clearCommentDraftUploads = this.clearCommentDraftUploads.bind(this);
this.storeLatestUpdate = this.storeLatestUpdate.bind(this); this.storeLatestUpdate = this.storeLatestUpdate.bind(this);
this.getLatestUpdate = this.getLatestUpdate.bind(this); this.getLatestUpdate = this.getLatestUpdate.bind(this);
this.emitEditPost = this.emitEditPost.bind(this);
this.addEditPostListener = this.addEditPostListener.bind(this);
this.removeEditPostListener = this.removeEditPostListener.bind(this);
this.getCurrentUsersLatestPost = this.getCurrentUsersLatestPost.bind(this); this.getCurrentUsersLatestPost = this.getCurrentUsersLatestPost.bind(this);
} }
emitChange() { emitChange() {
@@ -94,30 +76,6 @@ class PostStoreClass extends EventEmitter {
this.removeListener(CHANGE_EVENT, callback); this.removeListener(CHANGE_EVENT, callback);
} }
emitSearchChange() {
this.emit(SEARCH_CHANGE_EVENT);
}
addSearchChangeListener(callback) {
this.on(SEARCH_CHANGE_EVENT, callback);
}
removeSearchChangeListener(callback) {
this.removeListener(SEARCH_CHANGE_EVENT, callback);
}
emitSearchTermChange(doSearch, isMentionSearch) {
this.emit(SEARCH_TERM_CHANGE_EVENT, doSearch, isMentionSearch);
}
addSearchTermChangeListener(callback) {
this.on(SEARCH_TERM_CHANGE_EVENT, callback);
}
removeSearchTermChangeListener(callback) {
this.removeListener(SEARCH_TERM_CHANGE_EVENT, callback);
}
emitSelectedPostChange(fromSearch) { emitSelectedPostChange(fromSearch) {
this.emit(SELECTED_POST_CHANGE_EVENT, fromSearch); this.emit(SELECTED_POST_CHANGE_EVENT, fromSearch);
} }
@@ -130,30 +88,6 @@ class PostStoreClass extends EventEmitter {
this.removeListener(SELECTED_POST_CHANGE_EVENT, callback); this.removeListener(SELECTED_POST_CHANGE_EVENT, callback);
} }
emitMentionDataChange(id, mentionText) {
this.emit(MENTION_DATA_CHANGE_EVENT, id, mentionText);
}
addMentionDataChangeListener(callback) {
this.on(MENTION_DATA_CHANGE_EVENT, callback);
}
removeMentionDataChangeListener(callback) {
this.removeListener(MENTION_DATA_CHANGE_EVENT, callback);
}
emitAddMention(id, username) {
this.emit(ADD_MENTION_EVENT, id, username);
}
addAddMentionListener(callback) {
this.on(ADD_MENTION_EVENT, callback);
}
removeAddMentionListener(callback) {
this.removeListener(ADD_MENTION_EVENT, callback);
}
emitEditPost(post) { emitEditPost(post) {
this.emit(EDIT_POST_EVENT, post); this.emit(EDIT_POST_EVENT, post);
} }
@@ -181,9 +115,9 @@ class PostStoreClass extends EventEmitter {
var postList = makePostListNonNull(this.getPosts(channelId)); var postList = makePostListNonNull(this.getPosts(channelId));
for (let pid in newPostList.posts) { for (const pid in newPostList.posts) {
if (newPostList.posts.hasOwnProperty(pid)) { if (newPostList.posts.hasOwnProperty(pid)) {
var np = newPostList.posts[pid]; const np = newPostList.posts[pid];
if (np.delete_at === 0) { if (np.delete_at === 0) {
postList.posts[pid] = np; postList.posts[pid] = np;
if (postList.order.indexOf(pid) === -1) { if (postList.order.indexOf(pid) === -1) {
@@ -194,7 +128,7 @@ class PostStoreClass extends EventEmitter {
delete postList.posts[pid]; delete postList.posts[pid];
} }
var index = postList.order.indexOf(pid); const index = postList.order.indexOf(pid);
if (index !== -1) { if (index !== -1) {
postList.order.splice(index, 1); postList.order.splice(index, 1);
} }
@@ -202,7 +136,7 @@ class PostStoreClass extends EventEmitter {
} }
} }
postList.order.sort(function postSort(a, b) { postList.order.sort((a, b) => {
if (postList.posts[a].create_at > postList.posts[b].create_at) { if (postList.posts[a].create_at > postList.posts[b].create_at) {
return -1; return -1;
} }
@@ -306,7 +240,7 @@ class PostStoreClass extends EventEmitter {
var posts = postList.posts; var posts = postList.posts;
// sort failed posts to the bottom // sort failed posts to the bottom
postList.order.sort(function postSort(a, b) { postList.order.sort((a, b) => {
if (posts[a].state === Constants.POST_LOADING && posts[b].state === Constants.POST_FAILED) { if (posts[a].state === Constants.POST_LOADING && posts[b].state === Constants.POST_FAILED) {
return 1; return 1;
} }
@@ -371,7 +305,7 @@ class PostStoreClass extends EventEmitter {
this.pStorePendingPosts(channelId, postList); this.pStorePendingPosts(channelId, postList);
} }
clearPendingPosts() { clearPendingPosts() {
BrowserStore.actionOnGlobalItemsWithPrefix('pending_posts_', function clearPending(key) { BrowserStore.actionOnGlobalItemsWithPrefix('pending_posts_', (key) => {
BrowserStore.removeItem(key); BrowserStore.removeItem(key);
}); });
} }
@@ -387,28 +321,12 @@ class PostStoreClass extends EventEmitter {
this.pStorePendingPosts(post.channel_id, postList); this.pStorePendingPosts(post.channel_id, postList);
this.emitChange(); this.emitChange();
} }
storeSearchResults(results, isMentionSearch) {
BrowserStore.setItem('search_results', results);
BrowserStore.setItem('is_mention_search', Boolean(isMentionSearch));
}
getSearchResults() {
return BrowserStore.getItem('search_results');
}
getIsMentionSearch() {
return BrowserStore.getItem('is_mention_search');
}
storeSelectedPost(postList) { storeSelectedPost(postList) {
BrowserStore.setItem('select_post', postList); BrowserStore.setItem('select_post', postList);
} }
getSelectedPost() { getSelectedPost() {
return BrowserStore.getItem('select_post'); return BrowserStore.getItem('select_post');
} }
storeSearchTerm(term) {
BrowserStore.setItem('search_term', term);
}
getSearchTerm() {
return BrowserStore.getItem('search_term');
}
getEmptyDraft() { getEmptyDraft() {
return {message: '', uploadsInProgress: [], previews: []}; return {message: '', uploadsInProgress: [], previews: []};
} }
@@ -433,7 +351,7 @@ class PostStoreClass extends EventEmitter {
return BrowserStore.getGlobalItem('comment_draft_' + parentPostId, this.getEmptyDraft()); return BrowserStore.getGlobalItem('comment_draft_' + parentPostId, this.getEmptyDraft());
} }
clearDraftUploads() { clearDraftUploads() {
BrowserStore.actionOnGlobalItemsWithPrefix('draft_', function clearUploads(key, value) { BrowserStore.actionOnGlobalItemsWithPrefix('draft_', (key, value) => {
if (value) { if (value) {
value.uploadsInProgress = []; value.uploadsInProgress = [];
BrowserStore.setItem(key, value); BrowserStore.setItem(key, value);
@@ -441,7 +359,7 @@ class PostStoreClass extends EventEmitter {
}); });
} }
clearCommentDraftUploads() { clearCommentDraftUploads() {
BrowserStore.actionOnGlobalItemsWithPrefix('comment_draft_', function clearUploads(key, value) { BrowserStore.actionOnGlobalItemsWithPrefix('comment_draft_', (key, value) => {
if (value) { if (value) {
value.uploadsInProgress = []; value.uploadsInProgress = [];
BrowserStore.setItem(key, value); BrowserStore.setItem(key, value);
@@ -458,7 +376,7 @@ class PostStoreClass extends EventEmitter {
var PostStore = new PostStoreClass(); var PostStore = new PostStoreClass();
PostStore.dispatchToken = AppDispatcher.register(function registry(payload) { PostStore.dispatchToken = AppDispatcher.register((payload) => {
var action = payload.action; var action = payload.action;
switch (action.type) { switch (action.type) {
@@ -469,24 +387,10 @@ PostStore.dispatchToken = AppDispatcher.register(function registry(payload) {
PostStore.pStorePost(action.post); PostStore.pStorePost(action.post);
PostStore.emitChange(); PostStore.emitChange();
break; break;
case ActionTypes.RECIEVED_SEARCH:
PostStore.storeSearchResults(action.results, action.is_mention_search);
PostStore.emitSearchChange();
break;
case ActionTypes.RECIEVED_SEARCH_TERM:
PostStore.storeSearchTerm(action.term);
PostStore.emitSearchTermChange(action.do_search, action.is_mention_search);
break;
case ActionTypes.RECIEVED_POST_SELECTED: case ActionTypes.RECIEVED_POST_SELECTED:
PostStore.storeSelectedPost(action.post_list); PostStore.storeSelectedPost(action.post_list);
PostStore.emitSelectedPostChange(action.from_search); PostStore.emitSelectedPostChange(action.from_search);
break; break;
case ActionTypes.RECIEVED_MENTION_DATA:
PostStore.emitMentionDataChange(action.id, action.mention_text);
break;
case ActionTypes.RECIEVED_ADD_MENTION:
PostStore.emitAddMention(action.id, action.username);
break;
case ActionTypes.RECIEVED_EDIT_POST: case ActionTypes.RECIEVED_EDIT_POST:
PostStore.emitEditPost(action); PostStore.emitEditPost(action);
break; break;

153
web/react/stores/search_store.jsx Обычный файл
Просмотреть файл

@@ -0,0 +1,153 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
var EventEmitter = require('events').EventEmitter;
var BrowserStore = require('../stores/browser_store.jsx');
var Constants = require('../utils/constants.jsx');
var ActionTypes = Constants.ActionTypes;
var CHANGE_EVENT = 'change';
var SEARCH_CHANGE_EVENT = 'search_change';
var SEARCH_TERM_CHANGE_EVENT = 'search_term_change';
var MENTION_DATA_CHANGE_EVENT = 'mention_data_change';
var ADD_MENTION_EVENT = 'add_mention';
class SearchStoreClass extends EventEmitter {
constructor() {
super();
this.emitChange = this.emitChange.bind(this);
this.addChangeListener = this.addChangeListener.bind(this);
this.removeChangeListener = this.removeChangeListener.bind(this);
this.emitSearchChange = this.emitSearchChange.bind(this);
this.addSearchChangeListener = this.addSearchChangeListener.bind(this);
this.removeSearchChangeListener = this.removeSearchChangeListener.bind(this);
this.emitSearchTermChange = this.emitSearchTermChange.bind(this);
this.addSearchTermChangeListener = this.addSearchTermChangeListener.bind(this);
this.removeSearchTermChangeListener = this.removeSearchTermChangeListener.bind(this);
this.emitMentionDataChange = this.emitMentionDataChange.bind(this);
this.addMentionDataChangeListener = this.addMentionDataChangeListener.bind(this);
this.removeMentionDataChangeListener = this.removeMentionDataChangeListener.bind(this);
this.getSearchResults = this.getSearchResults.bind(this);
this.getIsMentionSearch = this.getIsMentionSearch.bind(this);
this.storeSearchTerm = this.storeSearchTerm.bind(this);
this.getSearchTerm = this.getSearchTerm.bind(this);
this.storeSearchResults = this.storeSearchResults.bind(this);
}
emitChange() {
this.emit(CHANGE_EVENT);
}
addChangeListener(callback) {
this.on(CHANGE_EVENT, callback);
}
removeChangeListener(callback) {
this.removeListener(CHANGE_EVENT, callback);
}
emitSearchChange() {
this.emit(SEARCH_CHANGE_EVENT);
}
addSearchChangeListener(callback) {
this.on(SEARCH_CHANGE_EVENT, callback);
}
removeSearchChangeListener(callback) {
this.removeListener(SEARCH_CHANGE_EVENT, callback);
}
emitSearchTermChange(doSearch, isMentionSearch) {
this.emit(SEARCH_TERM_CHANGE_EVENT, doSearch, isMentionSearch);
}
addSearchTermChangeListener(callback) {
this.on(SEARCH_TERM_CHANGE_EVENT, callback);
}
removeSearchTermChangeListener(callback) {
this.removeListener(SEARCH_TERM_CHANGE_EVENT, callback);
}
getSearchResults() {
return BrowserStore.getItem('search_results');
}
getIsMentionSearch() {
return BrowserStore.getItem('is_mention_search');
}
storeSearchTerm(term) {
BrowserStore.setItem('search_term', term);
}
getSearchTerm() {
return BrowserStore.getItem('search_term');
}
emitMentionDataChange(id, mentionText) {
this.emit(MENTION_DATA_CHANGE_EVENT, id, mentionText);
}
addMentionDataChangeListener(callback) {
this.on(MENTION_DATA_CHANGE_EVENT, callback);
}
removeMentionDataChangeListener(callback) {
this.removeListener(MENTION_DATA_CHANGE_EVENT, callback);
}
emitAddMention(id, username) {
this.emit(ADD_MENTION_EVENT, id, username);
}
addAddMentionListener(callback) {
this.on(ADD_MENTION_EVENT, callback);
}
removeAddMentionListener(callback) {
this.removeListener(ADD_MENTION_EVENT, callback);
}
storeSearchResults(results, isMentionSearch) {
BrowserStore.setItem('search_results', results);
BrowserStore.setItem('is_mention_search', Boolean(isMentionSearch));
}
}
var SearchStore = new SearchStoreClass();
SearchStore.dispatchToken = AppDispatcher.register((payload) => {
var action = payload.action;
switch (action.type) {
case ActionTypes.RECIEVED_SEARCH:
SearchStore.storeSearchResults(action.results, action.is_mention_search);
SearchStore.emitSearchChange();
break;
case ActionTypes.RECIEVED_SEARCH_TERM:
SearchStore.storeSearchTerm(action.term);
SearchStore.emitSearchTermChange(action.do_search, action.is_mention_search);
break;
case ActionTypes.RECIEVED_MENTION_DATA:
SearchStore.emitMentionDataChange(action.id, action.mention_text);
break;
case ActionTypes.RECIEVED_ADD_MENTION:
SearchStore.emitAddMention(action.id, action.username);
break;
default:
}
});
export default SearchStore;

Просмотреть файл

@@ -86,7 +86,7 @@ class SocketStoreClass extends EventEmitter {
this.failCount = this.failCount + 1; this.failCount = this.failCount + 1;
ErrorStore.storeLastError({connErrorCount: this.failCount, message: 'We cannot reach the Mattermost service. The service may be down or misconfigured. Please contact an administrator to make sure the WebSocket port is configured properly.'}); ErrorStore.storeLastError({connErrorCount: this.failCount, message: 'Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.'});
ErrorStore.emitChange(); ErrorStore.emitChange();
}; };
@@ -160,7 +160,7 @@ function handleNewPostEvent(msg) {
if (window.isActive) { if (window.isActive) {
AsyncClient.updateLastViewedAt(true); AsyncClient.updateLastViewedAt(true);
} }
} else { } else if (UserStore.getCurrentId() !== msg.user_id || post.type !== Constants.POST_TYPE_JOIN_LEAVE) {
AsyncClient.getChannel(msg.channel_id); AsyncClient.getChannel(msg.channel_id);
} }

Просмотреть файл

@@ -132,7 +132,7 @@ export function getChannel(id) {
callTracker['getChannel' + id] = utils.getTimestamp(); callTracker['getChannel' + id] = utils.getTimestamp();
client.getChannel(id, client.getChannel(id,
function getChannelSuccess(data, textStatus, xhr) { (data, textStatus, xhr) => {
callTracker['getChannel' + id] = 0; callTracker['getChannel' + id] = 0;
if (xhr.status === 304 || !data) { if (xhr.status === 304 || !data) {
@@ -145,7 +145,7 @@ export function getChannel(id) {
member: data.member member: data.member
}); });
}, },
function getChannelFailure(err) { (err) => {
callTracker['getChannel' + id] = 0; callTracker['getChannel' + id] = 0;
dispatchError(err, 'getChannel'); dispatchError(err, 'getChannel');
} }

Просмотреть файл

@@ -34,7 +34,7 @@ function handleError(methodName, xhr, status, err) {
if (oldError && oldError.connErrorCount) { if (oldError && oldError.connErrorCount) {
errorCount += oldError.connErrorCount; errorCount += oldError.connErrorCount;
connectError = 'We cannot reach the Mattermost service. The service may be down or misconfigured. Please contact an administrator to make sure the WebSocket port is configured properly.'; connectError = 'Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.';
} }
e = {message: connectError, connErrorCount: errorCount}; e = {message: connectError, connErrorCount: errorCount};
@@ -328,6 +328,20 @@ export function getConfig(success, error) {
}); });
} }
export function getAnalytics(teamId, name, success, error) {
$.ajax({
url: '/api/v1/admin/analytics/' + teamId + '/' + name,
dataType: 'json',
contentType: 'application/json',
type: 'GET',
success,
error: (xhr, status, err) => {
var e = handleError('getAnalytics', xhr, status, err);
error(e);
}
});
}
export function saveConfig(config, success, error) { export function saveConfig(config, success, error) {
$.ajax({ $.ajax({
url: '/api/v1/admin/save_config', url: '/api/v1/admin/save_config',

Просмотреть файл

@@ -98,6 +98,7 @@ module.exports = {
POST_LOADING: 'loading', POST_LOADING: 'loading',
POST_FAILED: 'failed', POST_FAILED: 'failed',
POST_DELETED: 'deleted', POST_DELETED: 'deleted',
POST_TYPE_JOIN_LEAVE: 'join_leave',
RESERVED_TEAM_NAMES: [ RESERVED_TEAM_NAMES: [
'www', 'www',
'web', 'web',

Просмотреть файл

@@ -121,8 +121,11 @@ export class MattermostMarkdownRenderer extends marked.Renderer {
paragraph(text) { paragraph(text) {
let outText = text; let outText = text;
// required so markdown does not strip '_' from @user_names
outText = TextFormatting.doFormatMentions(text);
if (!('emoticons' in this.options) || this.options.emoticon) { if (!('emoticons' in this.options) || this.options.emoticon) {
outText = TextFormatting.doFormatEmoticons(text); outText = TextFormatting.doFormatEmoticons(outText);
} }
if (this.formattingOptions.singleline) { if (this.formattingOptions.singleline) {
@@ -136,7 +139,7 @@ export class MattermostMarkdownRenderer extends marked.Renderer {
return `<table class="markdown__table"><thead>${header}</thead><tbody>${body}</tbody></table>`; return `<table class="markdown__table"><thead>${header}</thead><tbody>${body}</tbody></table>`;
} }
text(text) { text(txt) {
return TextFormatting.doFormatText(text, this.formattingOptions); return TextFormatting.doFormatText(txt, this.formattingOptions);
} }
} }

Просмотреть файл

@@ -47,8 +47,8 @@ export function doFormatText(text, options) {
const tokens = new Map(); const tokens = new Map();
// replace important words and phrases with tokens // replace important words and phrases with tokens
output = autolinkUrls(output, tokens);
output = autolinkAtMentions(output, tokens); output = autolinkAtMentions(output, tokens);
output = autolinkUrls(output, tokens);
output = autolinkHashtags(output, tokens); output = autolinkHashtags(output, tokens);
if (!('emoticons' in options) || options.emoticon) { if (!('emoticons' in options) || options.emoticon) {
@@ -78,6 +78,13 @@ export function doFormatEmoticons(text) {
return output; return output;
} }
export function doFormatMentions(text) {
const tokens = new Map();
let output = autolinkAtMentions(text, tokens);
output = replaceTokens(output, tokens);
return output;
}
export function sanitizeHtml(text) { export function sanitizeHtml(text) {
let output = text; let output = text;
@@ -188,6 +195,7 @@ function autolinkAtMentions(text, tokens) {
let output = text; let output = text;
output = output.replace(/(^|\s)(@([a-z0-9.\-_]*))/gi, replaceAtMentionWithToken); output = output.replace(/(^|\s)(@([a-z0-9.\-_]*))/gi, replaceAtMentionWithToken);
return output; return output;
} }

Просмотреть файл

@@ -8,6 +8,7 @@ var PreferenceStore = require('../stores/preference_store.jsx');
var TeamStore = require('../stores/team_store.jsx'); var TeamStore = require('../stores/team_store.jsx');
var Constants = require('../utils/constants.jsx'); var Constants = require('../utils/constants.jsx');
var ActionTypes = Constants.ActionTypes; var ActionTypes = Constants.ActionTypes;
var Client = require('./client.jsx');
var AsyncClient = require('./async_client.jsx'); var AsyncClient = require('./async_client.jsx');
var client = require('./client.jsx'); var client = require('./client.jsx');
var Autolinker = require('autolinker'); var Autolinker = require('autolinker');
@@ -1009,3 +1010,44 @@ export function windowWidth() {
export function windowHeight() { export function windowHeight() {
return $(window).height(); return $(window).height();
} }
export function openDirectChannelToUser(user, successCb, errorCb) {
const channelName = this.getDirectChannelName(UserStore.getCurrentId(), user.id);
let channel = ChannelStore.getByName(channelName);
const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, user.id, 'true');
AsyncClient.savePreferences([preference]);
if (channel) {
if ($.isFunction(successCb)) {
successCb(channel, true);
}
} else {
channel = {
name: channelName,
last_post_at: 0,
total_msg_count: 0,
type: 'D',
display_name: user.username,
teammate_id: user.id,
status: UserStore.getStatus(user.id)
};
Client.createDirectChannel(
channel,
user.id,
(data) => {
AsyncClient.getChannel(data.id);
if ($.isFunction(successCb)) {
successCb(data, false);
}
},
() => {
window.location.href = TeamStore.getCurrentTeamUrl() + '/channels/' + channelName;
if ($.isFunction(errorCb)) {
errorCb();
}
}
);
}
}

Просмотреть файл

@@ -5,6 +5,63 @@
.table { .table {
background: #fff; background: #fff;
} }
.total-count {
width: 175px;
height: 100px;
border: 1px solid #ddd;
padding: 22px 10px 10px 10px;
margin: 10px 10px 10px 10px;
background: #fff;
float: left;
> div {
font-size: 18px;
font-weight: 300;
}
}
.total-count-by-day {
width: 760px;
height: 275px;
border: 1px solid #ddd;
padding: 5px 10px 10px 10px;
margin: 10px 10px 10px 10px;
background: #fff;
clear: both;
> div {
font-size: 18px;
font-weight: 300;
}
}
.recent-active-users {
width: 365px;
height: 375px;
border: 1px solid #ddd;
padding: 5px 10px 10px 10px;
margin: 10px 10px 10px 10px;
background: #fff;
float: left;
> div {
font-size: 18px;
font-weight: 300;
}
> table {
margin: 10px 10px 10px 10px;
}
.recent-active-users-td {
font-size: 14px;
font-weight: 300;
border: 1px solid #ddd;
padding: 3px 3px 3px 5px;
}
}
.sidebar--left { .sidebar--left {
&.sidebar--collapsable { &.sidebar--collapsable {
background: #333; background: #333;

Просмотреть файл

@@ -61,3 +61,36 @@
@include opacity(1); @include opacity(1);
} }
} }
#member-list-popover {
max-width: initial;
.popover-content > div {
max-height: 350px;
overflow-y: auto;
overflow-x: hidden;
> div {
border-bottom: 1px solid rgba(51,51,51,0.1);
padding: 8px 8px 8px 15px;
width: 100%;
box-sizing: content-box;
@include clearfix;
.profile-img {
border-radius: 50px;
margin-right: 8px;
}
.more-name {
font-weight: 600;
font-size: 0.95em;
overflow: hidden;
text-overflow: ellipsis;
}
.more-description {
@include opacity(0.7);
}
.profile-action {
margin-left: 8px;
margin-right: 18px;
}
}
}
}

11
web/static/js/Chart.min.js поставляемый Обычный файл

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

Просмотреть файл

@@ -4,6 +4,7 @@
<html> <html>
{{template "head" . }} {{template "head" . }}
<body> <body>
<script src="/static/js/Chart.min.js"></script>
<div id='error_bar'></div> <div id='error_bar'></div>

Просмотреть файл

@@ -969,20 +969,20 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) {
r.ParseForm() r.ParseForm()
var props map[string]string var parsedRequest *model.IncomingWebhookRequest
if r.Header.Get("Content-Type") == "application/json" { if r.Header.Get("Content-Type") == "application/json" {
props = model.MapFromJson(r.Body) parsedRequest = model.IncomingWebhookRequestFromJson(r.Body)
} else { } else {
props = model.MapFromJson(strings.NewReader(r.FormValue("payload"))) parsedRequest = model.IncomingWebhookRequestFromJson(strings.NewReader(r.FormValue("payload")))
} }
text := props["text"] text := parsedRequest.Text
if len(text) == 0 { if len(text) == 0 {
c.Err = model.NewAppError("incomingWebhook", "No text specified", "") c.Err = model.NewAppError("incomingWebhook", "No text specified", "")
return return
} }
channelName := props["channel"] channelName := parsedRequest.ChannelName
var hook *model.IncomingWebhook var hook *model.IncomingWebhook
if result := <-hchan; result.Err != nil { if result := <-hchan; result.Err != nil {
@@ -1012,8 +1012,8 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) {
cchan = api.Srv.Store.Channel().Get(hook.ChannelId) cchan = api.Srv.Store.Channel().Get(hook.ChannelId)
} }
overrideUsername := props["username"] overrideUsername := parsedRequest.Username
overrideIconUrl := props["icon_url"] overrideIconUrl := parsedRequest.IconURL
if result := <-cchan; result.Err != nil { if result := <-cchan; result.Err != nil {
c.Err = model.NewAppError("incomingWebhook", "Couldn't find the channel", "err="+result.Err.Message) c.Err = model.NewAppError("incomingWebhook", "Couldn't find the channel", "err="+result.Err.Message)