summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Godeps/Godeps.json4
-rw-r--r--README.md64
-rw-r--r--api/channel.go59
-rw-r--r--api/channel_test.go23
-rw-r--r--api/command.go25
-rw-r--r--api/command_test.go2
-rw-r--r--api/file.go1
-rw-r--r--api/post.go34
-rw-r--r--api/post_test.go2
-rw-r--r--api/team.go103
-rw-r--r--api/team_test.go119
-rw-r--r--api/templates/invite_body.html2
-rw-r--r--api/templates/post_body.html2
-rw-r--r--api/user.go19
-rw-r--r--api/web_socket_test.go2
-rw-r--r--config/config.json2
-rw-r--r--config/config_docker.json2
-rw-r--r--manualtesting/manual_testing.go2
-rw-r--r--model/channel_list.go6
-rw-r--r--model/channel_member.go5
-rw-r--r--model/client.go18
-rw-r--r--model/team.go1
-rw-r--r--model/user.go13
-rw-r--r--model/utils.go4
-rw-r--r--store/sql_channel_store.go12
-rw-r--r--store/sql_post_store.go16
-rw-r--r--store/sql_team_store.go6
-rw-r--r--utils/config.go2
-rw-r--r--utils/mail.go3
-rw-r--r--web/react/components/channel_header.jsx43
-rw-r--r--web/react/components/channel_info_modal.jsx15
-rw-r--r--web/react/components/channel_loader.jsx1
-rw-r--r--web/react/components/channel_notifications.jsx208
-rw-r--r--web/react/components/create_comment.jsx28
-rw-r--r--web/react/components/create_post.jsx34
-rw-r--r--web/react/components/file_preview.jsx2
-rw-r--r--web/react/components/file_upload.jsx18
-rw-r--r--web/react/components/invite_member_modal.jsx14
-rw-r--r--web/react/components/login.jsx16
-rw-r--r--web/react/components/mention.jsx10
-rw-r--r--web/react/components/mention_list.jsx15
-rw-r--r--web/react/components/navbar.jsx2
-rw-r--r--web/react/components/post_body.jsx2
-rw-r--r--web/react/components/post_list.jsx43
-rw-r--r--web/react/components/setting_item_max.jsx2
-rw-r--r--web/react/components/settings_sidebar.jsx13
-rw-r--r--web/react/components/sidebar_header.jsx3
-rw-r--r--web/react/components/sidebar_right_menu.jsx3
-rw-r--r--web/react/components/signup_team_complete.jsx26
-rw-r--r--web/react/components/signup_user_complete.jsx10
-rw-r--r--web/react/components/team_settings.jsx161
-rw-r--r--web/react/components/team_settings_modal.jsx (renamed from web/react/components/settings_modal.jsx)16
-rw-r--r--web/react/components/textbox.jsx2
-rw-r--r--web/react/components/user_profile.jsx10
-rw-r--r--web/react/components/user_settings.jsx82
-rw-r--r--web/react/components/user_settings_modal.jsx68
-rw-r--r--web/react/components/view_image.jsx2
-rw-r--r--web/react/pages/channel.jsx15
-rw-r--r--web/react/stores/team_store.jsx100
-rw-r--r--web/react/stores/user_store.jsx3
-rw-r--r--web/react/utils/async_client.jsx24
-rw-r--r--web/react/utils/client.jsx31
-rw-r--r--web/react/utils/constants.jsx6
-rw-r--r--web/react/utils/utils.jsx38
-rw-r--r--web/sass-files/sass/partials/_mentions.scss30
-rw-r--r--web/sass-files/sass/partials/_modal.scss35
-rw-r--r--web/sass-files/sass/partials/_post.scss68
-rw-r--r--web/sass-files/sass/partials/_responsive.scss30
-rw-r--r--web/static/config/config.js1
-rw-r--r--web/templates/channel.html3
-rw-r--r--web/web.go3
71 files changed, 1417 insertions, 372 deletions
diff --git a/Godeps/Godeps.json b/Godeps/Godeps.json
index 0faccd1ea..093b2a1fc 100644
--- a/Godeps/Godeps.json
+++ b/Godeps/Godeps.json
@@ -136,6 +136,10 @@
"ImportPath": "gopkg.in/redis.v2",
"Comment": "v2.3.2",
"Rev": "e6179049628164864e6e84e973cfb56335748dea"
+ },
+ {
+ "ImportPath": "golang.org/x/image/bmp",
+ "Rev": "eb11b45157c1b71f30b3cec66306f1cd779a689e"
}
]
}
diff --git a/README.md b/README.md
index c2b0fbf2d..08d2f4210 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ Installing the Mattermost
You're installing "Mattermost Preview", a pre-released 0.50 version intended for an early look at what we're building. While SpinPunch runs this version internally, it's not recommended for production deployments since we can't guarantee API stability or backwards compatibility until our 1.0 version release.
-That said, any issues at all, please let us know on the Mattermost forum at: http://bit.ly/1MY1kul
+That said, any issues at all, please let us know on the Mattermost forum at: http://discourse.mattermost.org
Local Machine Setup (Docker)
-----------------------------
@@ -33,38 +33,38 @@ Local Machine Setup (Docker)
6. When docker is done fetching the image, open http://dockerhost:8065/ in your browser
### Ubuntu ###
-1. Follow the instructions at https://docs.docker.com/installation/ubuntulinux/ or use the summery below.
-
-`sudo apt-get update`
-
-`sudo apt-get install wget`
-
-`wget -qO- https://get.docker.com/ | sh`
-
-`sudo usermod -aG docker <username>`
-
-`sudo service docker start`
-
-`newgrp docker`
-
-2. Run `docker run --name mattermost-dev -d --publish 8065:80 mattermost/platform:helium
+1. Follow the instructions at https://docs.docker.com/installation/ubuntulinux/ or use the summary below.
+
+ ``` bash
+ sudo apt-get update
+ sudo apt-get install wget
+ wget -qO- https://get.docker.com/ | sh
+ sudo usermod -aG docker <username>
+ sudo service docker start
+ newgrp docker
+ ```
+
+2. Run `docker run --name mattermost-dev -d --publish 8065:80 mattermost/platform:helium`
3. When docker is done fetching the image, open http://localhost:8065/ in your browser
### Arch ###
-1. Install docker using the following commands
-
-`pacman -S docker`
-
-`systemctl enable docker.service`
+1. Install docker using the following commands:
-`systemctl start docker.service`
+ ``` bash
+ pacman -S docker
+ systemctl enable docker.service
+ systemctl start docker.service
+ gpasswd -a <username> docker
+ newgrp docker
+ ```
-`gpasswd -a <username> docker`
+2. Start docker container:
-`newgrp docker`
+ ``` bash
+ docker run --name mattermost-dev -d --publish 8065:80 mattermost/platform:helium
+ ```
-2. docker run --name mattermost-dev -d --publish 8065:80 mattermost/platform:helium
-3. When docker is done fetching the image, open http://localhost:8065/ in your browser
+3. When docker is done fetching the image, open http://localhost:8065/ in your browser.
### Notes ###
If your ISP blocks port 25 then you may install locally but email will not be sent.
@@ -80,6 +80,8 @@ If you wish to remove mattermost-dev use the following commands
1. `docker stop mattermost-dev`
2. `docker rm -v mattermost-dev`
+If you wish to gain access to the container use the following commands
+1. `docker exec -ti mattermost-dev /bin/bash`
AWS Elastic Beanstalk Setup (Docker)
------------------------------------
@@ -89,7 +91,7 @@ AWS Elastic Beanstalk Setup (Docker)
2. Select "Create New Application" from the top right.
3. Name the application and press next
4. Select "Create a web server" environment.
- 5. If asked, select create and AIM role and instance profile and press next.
+ 5. If asked, select create and IAM role and instance profile and press next.
6. For predefined configuration select docker. For environment type select single instance.
7. For application source, select upload your own and upload Dockerrun.aws.json from docker/Dockerrun.aws.json. Everything else may be left at default.
8. Select an environment name, this is how you will refer to your environment. Make sure the URL is available then press next.
@@ -107,7 +109,7 @@ AWS Elastic Beanstalk Setup (Docker)
18. Modify an existing CNAME record set or create a new one with the name * and the value of the domain you copied in step 1.13.
19. Save the record set
-3. Set the enviroment variable "MATTERMOST\_DOMAIN" to the domain you mapped above (example.com not www.example.com)
+3. Set the environment variable "MATTERMOST\_DOMAIN" to the domain you mapped above (example.com not www.example.com)
20. Return the Elastic Beanstalk from the AWS console.
21. Select the environment you created.
22. Select configuration from the sidebar.
@@ -119,6 +121,11 @@ AWS Elastic Beanstalk Setup (Docker)
26. Return to the dashboard on the sidebar and wait for beanstalk update the environment.
27. Try it out by entering the domain you mapped into your browser.
+Contributing
+------------
+
+To contribute to this open source project please review the Mattermost Contribution Guidelines at http://www.mattermost.org/contribute-to-mattermost/.
+
License
-------
@@ -126,3 +133,4 @@ Most Mattermost source files are made available under the terms of the GNU Affer
As an exception, Admin Tools and Configuration Files are are made available under the terms of the Apache License, version 2.0. See LICENSE.txt for more information.
+
diff --git a/api/channel.go b/api/channel.go
index d3f6ca2de..c0c2d1548 100644
--- a/api/channel.go
+++ b/api/channel.go
@@ -57,7 +57,7 @@ func createChannel(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
- if sc, err := CreateChannel(c, channel, r.URL.Path, true); err != nil {
+ if sc, err := CreateChannel(c, channel, true); err != nil {
c.Err = err
return
} else {
@@ -65,7 +65,7 @@ func createChannel(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
-func CreateChannel(c *Context, channel *model.Channel, path string, addMember bool) (*model.Channel, *model.AppError) {
+func CreateChannel(c *Context, channel *model.Channel, addMember bool) (*model.Channel, *model.AppError) {
if result := <-Srv.Store.Channel().Save(channel); result.Err != nil {
return nil, result.Err
} else {
@@ -100,7 +100,7 @@ func createDirectChannel(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
- if sc, err := CreateDirectChannel(c, userId, r.URL.Path); err != nil {
+ if sc, err := CreateDirectChannel(c, userId); err != nil {
c.Err = err
return
} else {
@@ -108,7 +108,7 @@ func createDirectChannel(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
-func CreateDirectChannel(c *Context, otherUserId string, path string) (*model.Channel, *model.AppError) {
+func CreateDirectChannel(c *Context, otherUserId string) (*model.Channel, *model.AppError) {
if len(otherUserId) != 26 {
return nil, model.NewAppError("CreateDirectChannel", "Invalid other user id ", otherUserId)
}
@@ -132,7 +132,7 @@ func CreateDirectChannel(c *Context, otherUserId string, path string) (*model.Ch
return nil, model.NewAppError("CreateDirectChannel", "Invalid other user id ", otherUserId)
}
- if sc, err := CreateChannel(c, channel, path, true); err != nil {
+ if sc, err := CreateChannel(c, channel, true); err != nil {
return nil, err
} else {
cm := &model.ChannelMember{ChannelId: sc.Id, UserId: otherUserId,
@@ -146,6 +146,23 @@ func CreateDirectChannel(c *Context, otherUserId string, path string) (*model.Ch
}
}
+func CreateDefaultChannels(c *Context, teamId string) ([]*model.Channel, *model.AppError) {
+ townSquare := &model.Channel{DisplayName: "Town Square", Name: "town-square", Type: model.CHANNEL_OPEN, TeamId: teamId}
+
+ if _, err := CreateChannel(c, townSquare, false); err != nil {
+ return nil, err
+ }
+
+ offTopic := &model.Channel{DisplayName: "Off-Topic", Name: "off-topic", Type: model.CHANNEL_OPEN, TeamId: teamId}
+
+ if _, err := CreateChannel(c, offTopic, false); err != nil {
+ return nil, err
+ }
+
+ channels := []*model.Channel{townSquare, offTopic}
+ return channels, nil
+}
+
func updateChannel(c *Context, w http.ResponseWriter, r *http.Request) {
channel := model.ChannelFromJson(r.Body)
@@ -303,7 +320,7 @@ func joinChannel(c *Context, w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
channelId := params["id"]
- JoinChannel(c, channelId, r.URL.Path)
+ JoinChannel(c, channelId, "")
if c.Err != nil {
return
@@ -314,7 +331,7 @@ func joinChannel(c *Context, w http.ResponseWriter, r *http.Request) {
w.Write([]byte(model.MapToJson(result)))
}
-func JoinChannel(c *Context, channelId string, path string) {
+func JoinChannel(c *Context, channelId string, role string) {
sc := Srv.Store.Channel().Get(channelId)
uc := Srv.Store.User().Get(c.Session.UserId)
@@ -340,7 +357,7 @@ func JoinChannel(c *Context, channelId string, path string) {
}
if channel.Type == model.CHANNEL_OPEN {
- cm := &model.ChannelMember{ChannelId: channel.Id, UserId: c.Session.UserId, NotifyLevel: model.CHANNEL_NOTIFY_ALL}
+ cm := &model.ChannelMember{ChannelId: channel.Id, UserId: c.Session.UserId, NotifyLevel: model.CHANNEL_NOTIFY_ALL, Roles: role}
if cmresult := <-Srv.Store.Channel().SaveMember(cm); cmresult.Err != nil {
c.Err = cmresult.Err
@@ -363,6 +380,32 @@ func JoinChannel(c *Context, channelId string, path string) {
}
}
+func JoinDefaultChannels(c *Context, user *model.User, channelRole string) *model.AppError {
+ // We don't call JoinChannel here since c.Session is not populated on user creation
+
+ var err *model.AppError = nil
+
+ if result := <-Srv.Store.Channel().GetByName(user.TeamId, "town-square"); result.Err != nil {
+ err = result.Err
+ } else {
+ cm := &model.ChannelMember{ChannelId: result.Data.(*model.Channel).Id, UserId: user.Id, NotifyLevel: model.CHANNEL_NOTIFY_ALL, Roles: channelRole}
+ if cmResult := <-Srv.Store.Channel().SaveMember(cm); cmResult.Err != nil {
+ err = cmResult.Err
+ }
+ }
+
+ if result := <-Srv.Store.Channel().GetByName(user.TeamId, "off-topic"); result.Err != nil {
+ err = result.Err
+ } else {
+ cm := &model.ChannelMember{ChannelId: result.Data.(*model.Channel).Id, UserId: user.Id, NotifyLevel: model.CHANNEL_NOTIFY_ALL, Roles: channelRole}
+ if cmResult := <-Srv.Store.Channel().SaveMember(cm); cmResult.Err != nil {
+ err = cmResult.Err
+ }
+ }
+
+ return err
+}
+
func leaveChannel(c *Context, w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
diff --git a/api/channel_test.go b/api/channel_test.go
index e8aaf4e3f..dfae840dc 100644
--- a/api/channel_test.go
+++ b/api/channel_test.go
@@ -35,8 +35,15 @@ func TestCreateChannel(t *testing.T) {
}
rget := Client.Must(Client.GetChannels("")).Data.(*model.ChannelList)
- if rget.Channels[0].Name != channel.Name {
- t.Fatal("full name didn't match")
+ nameMatch := false
+ for _, c := range rget.Channels {
+ if c.Name == channel.Name {
+ nameMatch = true
+ }
+ }
+
+ if !nameMatch {
+ t.Fatal("Did not create channel with correct name")
}
if _, err := Client.CreateChannel(rchannel.Data.(*model.Channel)); err == nil {
@@ -679,6 +686,8 @@ func TestUpdateNotifyLevel(t *testing.T) {
data["user_id"] = user.Id
data["notify_level"] = model.CHANNEL_NOTIFY_MENTION
+ timeBeforeUpdate := model.GetMillis()
+
if _, err := Client.UpdateNotifyLevel(data); err != nil {
t.Fatal(err)
}
@@ -689,6 +698,10 @@ func TestUpdateNotifyLevel(t *testing.T) {
t.Fatal("NotifyLevel did not update properly")
}
+ if rdata.Members[channel1.Id].LastUpdateAt <= timeBeforeUpdate {
+ t.Fatal("LastUpdateAt did not update")
+ }
+
data["user_id"] = "junk"
if _, err := Client.UpdateNotifyLevel(data); err == nil {
t.Fatal("Should have errored - bad user id")
@@ -735,7 +748,7 @@ func TestUpdateNotifyLevel(t *testing.T) {
}
func TestFuzzyChannel(t *testing.T) {
- Setup();
+ Setup()
team := &model.Team{Name: "Name", Domain: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN}
team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team)
@@ -747,9 +760,9 @@ func TestFuzzyChannel(t *testing.T) {
Client.LoginByEmail(team.Domain, user.Email, "pwd")
// Strings that should pass as acceptable channel names
- var fuzzyStringsPass = []string {
+ var fuzzyStringsPass = []string{
"*", "?", ".", "}{][)(><", "{}[]()<>",
-
+
"qahwah ( قهوة)",
"שָׁלוֹם עֲלֵיכֶם",
"Ramen チャーシュー chāshū",
diff --git a/api/command.go b/api/command.go
index 449483bbf..810a8a07e 100644
--- a/api/command.go
+++ b/api/command.go
@@ -9,6 +9,8 @@ import (
"github.com/mattermost/platform/model"
"github.com/mattermost/platform/utils"
"net/http"
+ "reflect"
+ "runtime"
"strconv"
"strings"
)
@@ -19,16 +21,13 @@ var commands = []commandHandler{
logoutCommand,
joinCommand,
loadTestCommand,
+ echoCommand,
}
func InitCommand(r *mux.Router) {
l4g.Debug("Initializing command api routes")
r.Handle("/command", ApiUserRequired(command)).Methods("POST")
- if utils.Cfg.TeamSettings.AllowValet {
- commands = append(commands, echoCommand)
- }
-
hub.Start()
}
@@ -59,6 +58,8 @@ func checkCommand(c *Context, command *model.Command) bool {
return false
}
+ tchan := Srv.Store.Team().Get(c.Session.TeamId)
+
if len(command.ChannelId) > 0 {
cchan := Srv.Store.Channel().CheckPermissionsTo(c.Session.TeamId, command.ChannelId, c.Session.UserId)
@@ -67,7 +68,21 @@ func checkCommand(c *Context, command *model.Command) bool {
}
}
+ allowValet := false
+ if tResult := <-tchan; tResult.Err != nil {
+ c.Err = model.NewAppError("checkCommand", "Could not find the team for this session, team_id="+c.Session.TeamId, "")
+ return false
+ } else {
+ allowValet = tResult.Data.(*model.Team).AllowValet
+ }
+
+ ec := runtime.FuncForPC(reflect.ValueOf(echoCommand).Pointer()).Name()
+
for _, v := range commands {
+ if !allowValet && ec == runtime.FuncForPC(reflect.ValueOf(v).Pointer()).Name() {
+ continue
+ }
+
if v(c, command) {
return true
} else if c.Err != nil {
@@ -182,7 +197,7 @@ func joinCommand(c *Context, command *model.Command) bool {
return false
}
- JoinChannel(c, v.Id, "/command")
+ JoinChannel(c, v.Id, "")
if c.Err != nil {
return false
diff --git a/api/command_test.go b/api/command_test.go
index d3b0da455..5b7734628 100644
--- a/api/command_test.go
+++ b/api/command_test.go
@@ -129,7 +129,7 @@ func TestJoinCommands(t *testing.T) {
c1 := Client.Must(Client.GetChannels("")).Data.(*model.ChannelList)
- if len(c1.Channels) != 3 { // 3 because of town-square and direct
+ if len(c1.Channels) != 4 { // 4 because of town-square, off-topic and direct
t.Fatal("didn't join channel")
}
diff --git a/api/file.go b/api/file.go
index 10167c6ff..0e08567d6 100644
--- a/api/file.go
+++ b/api/file.go
@@ -15,6 +15,7 @@ import (
"github.com/nfnt/resize"
"image"
_ "image/gif"
+ _ "golang.org/x/image/bmp"
"image/jpeg"
"io"
"net/http"
diff --git a/api/post.go b/api/post.go
index 3acc95551..2d812e8de 100644
--- a/api/post.go
+++ b/api/post.go
@@ -58,11 +58,7 @@ func createPost(c *Context, w http.ResponseWriter, r *http.Request) {
}
func createValetPost(c *Context, w http.ResponseWriter, r *http.Request) {
- if !utils.Cfg.TeamSettings.AllowValet {
- c.Err = model.NewAppError("createValetPost", "The valet feature is currently turned off. Please contact your system administrator for details.", "")
- c.Err.StatusCode = http.StatusNotImplemented
- return
- }
+ tchan := Srv.Store.Team().Get(c.Session.TeamId)
post := model.PostFromJson(r.Body)
if post == nil {
@@ -70,13 +66,25 @@ func createValetPost(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
- // Any one with access to the team can post as valet to any open channel
cchan := Srv.Store.Channel().CheckOpenChannelPermissions(c.Session.TeamId, post.ChannelId)
+ // Any one with access to the team can post as valet to any open channel
if !c.HasPermissionsToChannel(cchan, "createValetPost") {
return
}
+ // Make sure this team has the valet feature enabled
+ if tResult := <-tchan; tResult.Err != nil {
+ c.Err = model.NewAppError("createValetPost", "Could not find the team for this session, team_id="+c.Session.TeamId, "")
+ return
+ } else {
+ if !tResult.Data.(*model.Team).AllowValet {
+ c.Err = model.NewAppError("createValetPost", "The valet feature is currently turned off. Please contact your team administrator for details.", "")
+ c.Err.StatusCode = http.StatusNotImplemented
+ return
+ }
+ }
+
if rp, err := CreateValetPost(c, post); err != nil {
c.Err = err
@@ -265,7 +273,7 @@ func fireAndForgetNotifications(post *model.Post, teamId, teamUrl string) {
} else {
- // Find out who is a member of the channel only keep those profiles
+ // Find out who is a member of the channel, only keep those profiles
if eResult := <-echan; eResult.Err != nil {
l4g.Error("Failed to get channel members channel_id=%v err=%v", post.ChannelId, eResult.Err.Message)
return
@@ -298,13 +306,23 @@ func fireAndForgetNotifications(post *model.Post, teamId, teamUrl string) {
}
}
}
+
+ // Add @all to keywords if user has them turned on
+ if profile.NotifyProps["all"] == "true" {
+ keywordMap["@all"] = append(keywordMap["@all"], profile.Id)
+ }
+
+ // Add @channel to keywords if user has them turned on
+ if profile.NotifyProps["channel"] == "true" {
+ keywordMap["@channel"] = append(keywordMap["@channel"], profile.Id)
+ }
}
// Build a map as a list of unique user_ids that are mentioned in this post
splitF := func(c rune) bool {
return model.SplitRunes[c]
}
- splitMessage := strings.FieldsFunc(strings.Replace(post.Message, "<br>", " ", -1), splitF)
+ splitMessage := strings.FieldsFunc(post.Message, splitF)
for _, word := range splitMessage {
// Non-case-sensitive check for regular keys
diff --git a/api/post_test.go b/api/post_test.go
index b322a5017..03f70bff7 100644
--- a/api/post_test.go
+++ b/api/post_test.go
@@ -147,7 +147,7 @@ func TestCreateValetPost(t *testing.T) {
channel2 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
channel2 = Client.Must(Client.CreateChannel(channel2)).Data.(*model.Channel)
- if utils.Cfg.TeamSettings.AllowValet {
+ if utils.Cfg.TeamSettings.AllowValetDefault {
post1 := &model.Post{ChannelId: channel1.Id, Message: "#hashtag a" + model.NewId() + "a"}
rpost1, err := Client.CreateValetPost(post1)
if err != nil {
diff --git a/api/team.go b/api/team.go
index cb60602c6..15e4e2c17 100644
--- a/api/team.go
+++ b/api/team.go
@@ -29,6 +29,8 @@ func InitTeam(r *mux.Router) {
sr.Handle("/email_teams", ApiAppHandler(emailTeams)).Methods("POST")
sr.Handle("/invite_members", ApiUserRequired(inviteMembers)).Methods("POST")
sr.Handle("/update_name", ApiUserRequired(updateTeamName)).Methods("POST")
+ sr.Handle("/update_valet_feature", ApiUserRequired(updateValetFeature)).Methods("POST")
+ sr.Handle("/me", ApiUserRequired(getMyTeam)).Methods("GET")
}
func signupTeam(c *Context, w http.ResponseWriter, r *http.Request) {
@@ -136,16 +138,16 @@ func createTeamFromSignup(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
+ teamSignup.Team.AllowValet = utils.Cfg.TeamSettings.AllowValetDefault
+
if result := <-Srv.Store.Team().Save(&teamSignup.Team); result.Err != nil {
c.Err = result.Err
return
} else {
rteam := result.Data.(*model.Team)
- channel := &model.Channel{DisplayName: "Town Square", Name: "town-square", Type: model.CHANNEL_OPEN, TeamId: rteam.Id}
-
- if _, err := CreateChannel(c, channel, r.URL.Path, false); err != nil {
- c.Err = err
+ if _, err := CreateDefaultChannels(c, rteam.Id); err != nil {
+ c.Err = nil
return
}
@@ -157,7 +159,7 @@ func createTeamFromSignup(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
- if utils.Cfg.TeamSettings.AllowValet {
+ if teamSignup.Team.AllowValet {
CreateValet(c, rteam)
if c.Err != nil {
return
@@ -193,13 +195,18 @@ func createTeam(c *Context, w http.ResponseWriter, r *http.Request) {
} else {
rteam := result.Data.(*model.Team)
- channel := &model.Channel{DisplayName: "Town Square", Name: "town-square", Type: model.CHANNEL_OPEN, TeamId: rteam.Id}
-
- if _, err := CreateChannel(c, channel, r.URL.Path, false); err != nil {
- c.Err = err
+ if _, err := CreateDefaultChannels(c, rteam.Id); err != nil {
+ c.Err = nil
return
}
+ if rteam.AllowValet {
+ CreateValet(c, rteam)
+ if c.Err != nil {
+ return
+ }
+ }
+
w.Write([]byte(rteam.ToJson()))
}
}
@@ -477,12 +484,21 @@ func InviteMembers(team *model.Team, user *model.User, invites []string) {
} else {
sender = user.FullName
}
+
+ senderRole := ""
+ if strings.Contains(user.Roles, model.ROLE_ADMIN) || strings.Contains(user.Roles, model.ROLE_SYSTEM_ADMIN) {
+ senderRole = "administrator"
+ } else {
+ senderRole = "member"
+ }
+
subjectPage := NewServerTemplatePage("invite_subject", teamUrl)
subjectPage.Props["SenderName"] = sender
subjectPage.Props["TeamName"] = team.Name
bodyPage := NewServerTemplatePage("invite_body", teamUrl)
bodyPage.Props["TeamName"] = team.Name
bodyPage.Props["SenderName"] = sender
+ bodyPage.Props["SenderStatus"] = senderRole
bodyPage.Props["Email"] = invite
@@ -542,3 +558,72 @@ func updateTeamName(c *Context, w http.ResponseWriter, r *http.Request) {
w.Write([]byte(model.MapToJson(props)))
}
+
+func updateValetFeature(c *Context, w http.ResponseWriter, r *http.Request) {
+
+ props := model.MapFromJson(r.Body)
+
+ allowValetStr := props["allow_valet"]
+ if len(allowValetStr) == 0 {
+ c.SetInvalidParam("updateValetFeature", "allow_valet")
+ return
+ }
+
+ allowValet := allowValetStr == "true"
+
+ teamId := props["team_id"]
+ if len(teamId) > 0 && len(teamId) != 26 {
+ c.SetInvalidParam("updateValetFeature", "team_id")
+ return
+ } else if len(teamId) == 0 {
+ teamId = c.Session.TeamId
+ }
+
+ tchan := Srv.Store.Team().Get(teamId)
+
+ if !c.HasPermissionsToTeam(teamId, "updateValetFeature") {
+ return
+ }
+
+ if !strings.Contains(c.Session.Roles, model.ROLE_ADMIN) {
+ c.Err = model.NewAppError("updateValetFeature", "You do not have the appropriate permissions", "userId="+c.Session.UserId)
+ c.Err.StatusCode = http.StatusForbidden
+ return
+ }
+
+ var team *model.Team
+ if tResult := <-tchan; tResult.Err != nil {
+ c.Err = tResult.Err
+ return
+ } else {
+ team = tResult.Data.(*model.Team)
+ }
+
+ team.AllowValet = allowValet
+
+ if result := <-Srv.Store.Team().Update(team); result.Err != nil {
+ c.Err = result.Err
+ return
+ }
+
+ w.Write([]byte(model.MapToJson(props)))
+}
+
+func getMyTeam(c *Context, w http.ResponseWriter, r *http.Request) {
+
+ if len(c.Session.TeamId) == 0 {
+ return
+ }
+
+ if result := <-Srv.Store.Team().Get(c.Session.TeamId); result.Err != nil {
+ c.Err = result.Err
+ return
+ } else if HandleEtag(result.Data.(*model.Team).Etag(), w, r) {
+ return
+ } else {
+ w.Header().Set(model.HEADER_ETAG_SERVER, result.Data.(*model.Team).Etag())
+ w.Header().Set("Expires", "-1")
+ w.Write([]byte(result.Data.(*model.Team).ToJson()))
+ return
+ }
+}
diff --git a/api/team_test.go b/api/team_test.go
index 74a184634..bb77d43a0 100644
--- a/api/team_test.go
+++ b/api/team_test.go
@@ -55,6 +55,11 @@ func TestCreateFromSignupTeam(t *testing.T) {
}
}
+ c1 := Client.Must(Client.GetChannels("")).Data.(*model.ChannelList)
+ if len(c1.Channels) != 2 {
+ t.Fatal("default channels not created")
+ }
+
ts.Data = "garbage"
_, err = Client.CreateTeamFromSignup(&ts)
if err == nil {
@@ -71,6 +76,17 @@ func TestCreateTeam(t *testing.T) {
t.Fatal(err)
}
+ user := &model.User{TeamId: rteam.Data.(*model.Team).Id, Email: model.NewId() + "corey@test.com", FullName: "Corey Hulen", Password: "pwd"}
+ user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User)
+ Srv.Store.User().VerifyEmail(user.Id)
+
+ Client.LoginByEmail(team.Domain, user.Email, "pwd")
+
+ c1 := Client.Must(Client.GetChannels("")).Data.(*model.ChannelList)
+ if len(c1.Channels) != 2 {
+ t.Fatal("default channels not created")
+ }
+
if rteam.Data.(*model.Team).Name != team.Name {
t.Fatal("full name didn't match")
}
@@ -286,3 +302,106 @@ func TestFuzzyTeamCreate(t *testing.T) {
}
}
}
+
+func TestGetMyTeam(t *testing.T) {
+ Setup()
+
+ team := model.Team{Name: "Name", Domain: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN}
+ rteam, _ := Client.CreateTeam(&team)
+
+ user := model.User{TeamId: rteam.Data.(*model.Team).Id, Email: strings.ToLower(model.NewId()) + "corey@test.com", FullName: "Corey Hulen", Password: "pwd"}
+ ruser, _ := Client.CreateUser(&user, "")
+ Srv.Store.User().VerifyEmail(ruser.Data.(*model.User).Id)
+
+ Client.LoginByEmail(team.Domain, user.Email, user.Password)
+
+ if result, err := Client.GetMyTeam(""); err != nil {
+ t.Fatal("Failed to get user")
+ } else {
+ if result.Data.(*model.Team).Name != team.Name {
+ t.Fatal("team names did not match")
+ }
+ if result.Data.(*model.Team).Domain != team.Domain {
+ t.Fatal("team domains did not match")
+ }
+ if result.Data.(*model.Team).Type != team.Type {
+ t.Fatal("team types did not match")
+ }
+ }
+}
+
+func TestUpdateValetFeature(t *testing.T) {
+ Setup()
+
+ team := &model.Team{Name: "Name", Domain: "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: "test@nowhere.com", FullName: "Corey Hulen", Password: "pwd"}
+ user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User)
+ Srv.Store.User().VerifyEmail(user.Id)
+
+ user2 := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", FullName: "Corey Hulen", Password: "pwd"}
+ user2 = Client.Must(Client.CreateUser(user2, "")).Data.(*model.User)
+ Srv.Store.User().VerifyEmail(user2.Id)
+
+ team2 := &model.Team{Name: "Name", Domain: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN}
+ team2 = Client.Must(Client.CreateTeam(team2)).Data.(*model.Team)
+
+ user3 := &model.User{TeamId: team2.Id, Email: model.NewId() + "corey@test.com", FullName: "Corey Hulen", Password: "pwd"}
+ user3 = Client.Must(Client.CreateUser(user3, "")).Data.(*model.User)
+ Srv.Store.User().VerifyEmail(user3.Id)
+
+ Client.LoginByEmail(team.Domain, user2.Email, "pwd")
+
+ data := make(map[string]string)
+ data["allow_valet"] = "true"
+ if _, err := Client.UpdateValetFeature(data); err == nil {
+ t.Fatal("Should have errored, not admin")
+ }
+
+ Client.LoginByEmail(team.Domain, user.Email, "pwd")
+
+ data["allow_valet"] = ""
+ if _, err := Client.UpdateValetFeature(data); err == nil {
+ t.Fatal("Should have errored, empty allow_valet field")
+ }
+
+ data["allow_valet"] = "true"
+ if _, err := Client.UpdateValetFeature(data); err != nil {
+ t.Fatal(err)
+ }
+
+ rteam := Client.Must(Client.GetMyTeam("")).Data.(*model.Team)
+ if rteam.AllowValet != true {
+ t.Fatal("Should have errored - allow valet property not updated")
+ }
+
+ data["team_id"] = "junk"
+ if _, err := Client.UpdateValetFeature(data); err == nil {
+ t.Fatal("Should have errored, junk team id")
+ }
+
+ data["team_id"] = "12345678901234567890123456"
+ if _, err := Client.UpdateValetFeature(data); err == nil {
+ t.Fatal("Should have errored, bad team id")
+ }
+
+ data["team_id"] = team.Id
+ data["allow_valet"] = "false"
+ if _, err := Client.UpdateValetFeature(data); err != nil {
+ t.Fatal(err)
+ }
+
+ rteam = Client.Must(Client.GetMyTeam("")).Data.(*model.Team)
+ if rteam.AllowValet != false {
+ t.Fatal("Should have errored - allow valet property not updated")
+ }
+
+ Client.LoginByEmail(team2.Domain, user3.Email, "pwd")
+
+ data["team_id"] = team.Id
+ data["allow_valet"] = "true"
+ if _, err := Client.UpdateValetFeature(data); err == nil {
+ t.Fatal("Should have errored, not part of team")
+ }
+}
diff --git a/api/templates/invite_body.html b/api/templates/invite_body.html
index 06f48759c..8be2ac0df 100644
--- a/api/templates/invite_body.html
+++ b/api/templates/invite_body.html
@@ -18,7 +18,7 @@
<tr>
<td style="border-bottom: 1px solid #ddd; padding: 0 0 20px;">
<h2 style="font-weight: normal; margin-top: 10px;">You've been invited</h2>
- <p>{{.Props.TeamName}} started using {{.SiteName}}.<br> The team administrator <strong>{{.Props.SenderName}}</strong>, has invited you to join <strong>{{.Props.TeamName}}</strong>.</p>
+ <p>{{.Props.TeamName}} started using {{.SiteName}}.<br> The team {{.Props.SenderStatus}} <strong>{{.Props.SenderName}}</strong>, has invited you to join <strong>{{.Props.TeamName}}</strong>.</p>
<p style="margin: 20px 0 15px">
<a href="{{.Props.Link}}" style="background: #2389D7; border-radius: 3px; color: #fff; border: none; outline: none; min-width: 200px; padding: 15px 25px; font-size: 14px; font-family: inherit; cursor: pointer; -webkit-appearance: none;text-decoration: none;">Join Team</a>
</p>
diff --git a/api/templates/post_body.html b/api/templates/post_body.html
index 663ec66d2..069cdf1fb 100644
--- a/api/templates/post_body.html
+++ b/api/templates/post_body.html
@@ -20,7 +20,7 @@
<h2 style="font-weight: normal; margin-top: 10px;">You were mentioned</h2>
<p>CHANNEL: {{.Props.ChannelName}}<br>{{.Props.SenderName}} - {{.Props.Hour}}:{{.Props.Minute}} GMT, {{.Props.Month}} {{.Props.Day}}<br><pre style="text-align:left;font-family: 'Lato', sans-serif;">{{.Props.PostMessage}}</pre></p>
<p style="margin: 20px 0 15px">
- <a href="{{.Props.TeamLink}}" style="background: #2389D7; border-radius: 3px; color: #fff; border: none; outline: none; min-width: 200px; padding: 15px 25px; font-size: 14px; font-family: inherit; cursor: pointer; -webkit-appearance: none;text-decoration: none;">Go To Channel</a>
+ <a href="{{.Props.TeamLink}}" style="background: #2389D7; display: inline-block; border-radius: 3px; color: #fff; border: none; outline: none; min-width: 170px; padding: 15px 25px; font-size: 14px; font-family: inherit; cursor: pointer; -webkit-appearance: none;text-decoration: none;">Go To Channel</a>
</p>
</td>
</tr>
diff --git a/api/user.go b/api/user.go
index 79d4bb32c..292d2b61b 100644
--- a/api/user.go
+++ b/api/user.go
@@ -145,10 +145,6 @@ func createUser(c *Context, w http.ResponseWriter, r *http.Request) {
}
func CreateValet(c *Context, team *model.Team) *model.User {
- if !utils.Cfg.TeamSettings.AllowValet {
- return &model.User{}
- }
-
valet := &model.User{}
valet.TeamId = team.Id
valet.Email = utils.Cfg.EmailSettings.FeedbackEmail
@@ -180,21 +176,16 @@ func CreateUser(c *Context, team *model.Team, user *model.User) *model.User {
} else {
ruser := result.Data.(*model.User)
- // Do not error if user cannot be added to the town-square channel
- if cresult := <-Srv.Store.Channel().GetByName(team.Id, "town-square"); cresult.Err != nil {
- l4g.Error("Failed to get town-square err=%v", cresult.Err)
- } else {
- cm := &model.ChannelMember{ChannelId: cresult.Data.(*model.Channel).Id, UserId: ruser.Id, NotifyLevel: model.CHANNEL_NOTIFY_ALL, Roles: channelRole}
- if cmresult := <-Srv.Store.Channel().SaveMember(cm); cmresult.Err != nil {
- l4g.Error("Failed to add member town-square err=%v", cmresult.Err)
- }
+ // Soft error if there is an issue joining the default channels
+ if err := JoinDefaultChannels(c, ruser, channelRole); err != nil {
+ l4g.Error("Encountered an issue joining default channels user_id=%s, team_id=%s, err=%v", ruser.Id, ruser.TeamId, err)
}
//fireAndForgetWelcomeEmail(strings.Split(ruser.FullName, " ")[0], ruser.Email, team.Name, c.TeamUrl+"/channels/town-square")
if user.EmailVerified {
if cresult := <-Srv.Store.User().VerifyEmail(ruser.Id); cresult.Err != nil {
- l4g.Error("Failed to get town-square err=%v", cresult.Err)
+ l4g.Error("Failed to set email verified err=%v", cresult.Err)
}
} else {
FireAndForgetVerifyEmail(result.Data.(*model.User).Id, strings.Split(ruser.FullName, " ")[0], ruser.Email, team.Name, c.TeamUrl)
@@ -202,7 +193,7 @@ func CreateUser(c *Context, team *model.Team, user *model.User) *model.User {
ruser.Sanitize(map[string]bool{})
- //This message goes to every channel, so the channelId is irrelevant
+ // This message goes to every channel, so the channelId is irrelevant
message := model.NewMessage(team.Id, "", ruser.Id, model.ACTION_NEW_USER)
store.PublishAndForget(message)
diff --git a/api/web_socket_test.go b/api/web_socket_test.go
index c7b612cde..15bc3baeb 100644
--- a/api/web_socket_test.go
+++ b/api/web_socket_test.go
@@ -119,7 +119,7 @@ func TestSocket(t *testing.T) {
}
-func TestZZWebScoketTearDown(t *testing.T) {
+func TestZZWebSocketTearDown(t *testing.T) {
// *IMPORTANT* - Kind of hacky
// This should be the last function in any test file
// that calls Setup()
diff --git a/config/config.json b/config/config.json
index c0c236735..e38f1701a 100644
--- a/config/config.json
+++ b/config/config.json
@@ -73,7 +73,7 @@
"TeamSettings": {
"MaxUsersPerTeam": 150,
"AllowPublicLink": true,
- "AllowValet": false,
+ "AllowValetDefault": false,
"TermsLink": "/static/help/configure_links.html",
"PrivacyLink": "/static/help/configure_links.html",
"AboutLink": "/static/help/configure_links.html",
diff --git a/config/config_docker.json b/config/config_docker.json
index 6936f619a..85f0d9c73 100644
--- a/config/config_docker.json
+++ b/config/config_docker.json
@@ -10,7 +10,7 @@
"ServiceSettings": {
"SiteName": "Mattermost",
"Domain": "",
- "Mode" : "prod",
+ "Mode" : "dev",
"AllowTesting" : false,
"UseSSL": false,
"Port": "80",
diff --git a/manualtesting/manual_testing.go b/manualtesting/manual_testing.go
index ead441108..929f7ab5d 100644
--- a/manualtesting/manual_testing.go
+++ b/manualtesting/manual_testing.go
@@ -78,7 +78,7 @@ func manualTest(c *api.Context, w http.ResponseWriter, r *http.Request) {
createdTeam := result.Data.(*model.Team)
channel := &model.Channel{DisplayName: "Town Square", Name: "town-square", Type: model.CHANNEL_OPEN, TeamId: createdTeam.Id}
- if _, err := api.CreateChannel(c, channel, r.URL.Path, false); err != nil {
+ if _, err := api.CreateChannel(c, channel, false); err != nil {
c.Err = err
return
}
diff --git a/model/channel_list.go b/model/channel_list.go
index 088dbea2a..09f14a986 100644
--- a/model/channel_list.go
+++ b/model/channel_list.go
@@ -53,6 +53,12 @@ func (o *ChannelList) Etag() string {
t = member.LastViewedAt
id = v.Id
}
+
+ if member.LastUpdateAt > t {
+ t = member.LastUpdateAt
+ id = v.Id
+ }
+
}
}
diff --git a/model/channel_member.go b/model/channel_member.go
index 720ac4c42..50f51304b 100644
--- a/model/channel_member.go
+++ b/model/channel_member.go
@@ -25,6 +25,7 @@ type ChannelMember struct {
MsgCount int64 `json:"msg_count"`
MentionCount int64 `json:"mention_count"`
NotifyLevel string `json:"notify_level"`
+ LastUpdateAt int64 `json:"last_update_at"`
}
func (o *ChannelMember) ToJson() string {
@@ -70,6 +71,10 @@ func (o *ChannelMember) IsValid() *AppError {
return nil
}
+func (o *ChannelMember) PreSave() {
+ o.LastUpdateAt = GetMillis()
+}
+
func IsChannelNotifyLevelValid(notifyLevel string) bool {
return notifyLevel == CHANNEL_NOTIFY_ALL || notifyLevel == CHANNEL_NOTIFY_MENTION || notifyLevel == CHANNEL_NOTIFY_NONE || notifyLevel == CHANNEL_NOTIFY_QUIET
}
diff --git a/model/client.go b/model/client.go
index 0448828bb..ab01e7d62 100644
--- a/model/client.go
+++ b/model/client.go
@@ -186,6 +186,15 @@ func (c *Client) UpdateTeamName(data map[string]string) (*Result, *AppError) {
}
}
+func (c *Client) UpdateValetFeature(data map[string]string) (*Result, *AppError) {
+ if r, err := c.DoPost("/teams/update_valet_feature", MapToJson(data)); err != nil {
+ return nil, err
+ } else {
+ return &Result{r.Header.Get(HEADER_REQUEST_ID),
+ r.Header.Get(HEADER_ETAG_SERVER), MapFromJson(r.Body)}, nil
+ }
+}
+
func (c *Client) CreateUser(user *User, hash string) (*Result, *AppError) {
if r, err := c.DoPost("/users/create", user.ToJson()); err != nil {
return nil, err
@@ -647,6 +656,15 @@ func (c *Client) GetStatuses() (*Result, *AppError) {
}
}
+func (c *Client) GetMyTeam(etag string) (*Result, *AppError) {
+ if r, err := c.DoGet("/teams/me", "", etag); err != nil {
+ return nil, err
+ } else {
+ return &Result{r.Header.Get(HEADER_REQUEST_ID),
+ r.Header.Get(HEADER_ETAG_SERVER), TeamFromJson(r.Body)}, nil
+ }
+}
+
func (c *Client) MockSession(sessionToken string) {
c.AuthToken = sessionToken
}
diff --git a/model/team.go b/model/team.go
index a510cde78..5c66f3b1f 100644
--- a/model/team.go
+++ b/model/team.go
@@ -24,6 +24,7 @@ type Team struct {
Type string `json:"type"`
CompanyName string `json:"company_name"`
AllowedDomains string `json:"allowed_domains"`
+ AllowValet bool `json:"allow_valet"`
}
type Invites struct {
diff --git a/model/user.go b/model/user.go
index 794adcad4..18fbb0d2a 100644
--- a/model/user.go
+++ b/model/user.go
@@ -147,10 +147,13 @@ func (u *User) SetDefaultNotifications() {
u.NotifyProps["email"] = "true"
u.NotifyProps["desktop"] = USER_NOTIFY_ALL
u.NotifyProps["desktop_sound"] = "true"
- u.NotifyProps["mention_keys"] = u.Username
- u.NotifyProps["first_name"] = "true"
+ u.NotifyProps["mention_keys"] = u.Username + ",@" + u.Username
+ u.NotifyProps["first_name"] = "false"
+ u.NotifyProps["all"] = "true"
+ u.NotifyProps["channel"] = "true"
splitName := strings.Split(u.FullName, " ")
if len(splitName) > 0 && splitName[0] != "" {
+ u.NotifyProps["first_name"] = "true"
u.NotifyProps["mention_keys"] += "," + splitName[0]
}
}
@@ -277,17 +280,17 @@ func ComparePassword(hash string, password string) bool {
func IsUsernameValid(username string) bool {
- var restrictedUsernames = []string {
+ var restrictedUsernames = []string{
BOT_USERNAME,
"all",
"channel",
}
- for _,restrictedUsername := range restrictedUsernames {
+ for _, restrictedUsername := range restrictedUsernames {
if username == restrictedUsername {
return false
}
- }
+ }
return true
}
diff --git a/model/utils.go b/model/utils.go
index 262bda319..50e427694 100644
--- a/model/utils.go
+++ b/model/utils.go
@@ -17,7 +17,7 @@ import (
)
const (
- ETAG_ROOT_VERSION = "10"
+ ETAG_ROOT_VERSION = "11"
)
type StringMap map[string]string
@@ -260,7 +260,7 @@ func Etag(parts ...interface{}) string {
return etag
}
-var validHashtag = regexp.MustCompile(`^(#[A-Za-z]+[A-Za-z0-9_]*[A-Za-z0-9])$`)
+var validHashtag = regexp.MustCompile(`^(#[A-Za-z]+[A-Za-z0-9_\-]*[A-Za-z0-9])$`)
var puncStart = regexp.MustCompile(`^[.,()&$!\[\]{}"':;\\]+`)
var puncEnd = regexp.MustCompile(`[.,()&$#!\[\]{}"':;\\]+$`)
diff --git a/store/sql_channel_store.go b/store/sql_channel_store.go
index 592657c1c..463fce16f 100644
--- a/store/sql_channel_store.go
+++ b/store/sql_channel_store.go
@@ -37,6 +37,7 @@ func NewSqlChannelStore(sqlStore *SqlStore) ChannelStore {
}
func (s SqlChannelStore) UpgradeSchemaIfNeeded() {
+ s.CreateColumnIfNotExists("ChannelMembers", "LastUpdateAt", "NotifyLevel", "bigint(20)", "0") // Remove after 6/7/2015 prod push
}
func (s SqlChannelStore) CreateIndexesIfNotExists() {
@@ -273,6 +274,7 @@ func (s SqlChannelStore) SaveMember(member *model.ChannelMember) StoreChannel {
go func() {
result := StoreResult{}
+ member.PreSave()
if result.Err = member.IsValid(); result.Err != nil {
storeChannel <- result
return
@@ -484,7 +486,8 @@ func (s SqlChannelStore) UpdateLastViewedAt(channelId string, userId string) Sto
SET
ChannelMembers.MentionCount = 0,
ChannelMembers.MsgCount = Channels.TotalMsgCount,
- ChannelMembers.LastViewedAt = Channels.LastPostAt
+ ChannelMembers.LastViewedAt = Channels.LastPostAt,
+ ChannelMembers.LastUpdateAt = Channels.LastPostAt
WHERE
Channels.Id = ChannelMembers.ChannelId
AND UserId = ?
@@ -533,15 +536,18 @@ func (s SqlChannelStore) UpdateNotifyLevel(channelId, userId, notifyLevel string
go func() {
result := StoreResult{}
+ updateAt := model.GetMillis()
+
_, err := s.GetMaster().Exec(
`UPDATE
ChannelMembers
SET
- NotifyLevel = ?
+ NotifyLevel = ?,
+ LastUpdateAt = ?
WHERE
UserId = ?
AND ChannelId = ?`,
- notifyLevel, userId, channelId)
+ notifyLevel, updateAt, userId, channelId)
if err != nil {
result.Err = model.NewAppError("SqlChannelStore.UpdateNotifyLevel", "We couldn't update the notify level", "channel_id="+channelId+", user_id="+userId+", "+err.Error())
}
diff --git a/store/sql_post_store.go b/store/sql_post_store.go
index 0ceebc02f..01900023f 100644
--- a/store/sql_post_store.go
+++ b/store/sql_post_store.go
@@ -356,9 +356,14 @@ func (s SqlPostStore) Search(teamId string, userId string, terms string, isHasht
go func() {
result := StoreResult{}
+ termMap := map[string]bool{}
+
searchType := "Message"
if isHashtagSearch {
searchType = "Hashtags"
+ for _,term := range strings.Split(terms, " ") {
+ termMap[term] = true;
+ }
}
// @ has a speical meaning in INNODB FULLTEXT indexes and
@@ -394,6 +399,17 @@ func (s SqlPostStore) Search(teamId string, userId string, terms string, isHasht
list := &model.PostList{Order: make([]string, 0, len(posts))}
for _, p := range posts {
+ if searchType == "Hashtags" {
+ exactMatch := false
+ for _, tag := range strings.Split(p.Hashtags, " ") {
+ if termMap[tag] {
+ exactMatch = true
+ }
+ }
+ if !exactMatch {
+ continue
+ }
+ }
list.AddPost(p)
list.AddOrder(p.Id)
}
diff --git a/store/sql_team_store.go b/store/sql_team_store.go
index 6e7fc1c1e..ffb9f8093 100644
--- a/store/sql_team_store.go
+++ b/store/sql_team_store.go
@@ -5,6 +5,7 @@ package store
import (
"github.com/mattermost/platform/model"
+ "github.com/mattermost/platform/utils"
"strings"
)
@@ -29,6 +30,11 @@ func NewSqlTeamStore(sqlStore *SqlStore) TeamStore {
}
func (s SqlTeamStore) UpgradeSchemaIfNeeded() {
+ defaultValue := "0"
+ if utils.Cfg.TeamSettings.AllowValetDefault {
+ defaultValue = "1"
+ }
+ s.CreateColumnIfNotExists("Teams", "AllowValet", "AllowedDomains", "tinyint(1)", defaultValue)
}
func (s SqlTeamStore) CreateIndexesIfNotExists() {
diff --git a/utils/config.go b/utils/config.go
index 6a7e4589c..eb2ae3050 100644
--- a/utils/config.go
+++ b/utils/config.go
@@ -98,7 +98,7 @@ type PrivacySettings struct {
type TeamSettings struct {
MaxUsersPerTeam int
AllowPublicLink bool
- AllowValet bool
+ AllowValetDefault bool
TermsLink string
PrivacyLink string
AboutLink string
diff --git a/utils/mail.go b/utils/mail.go
index 2fb7f801d..3cd37ffef 100644
--- a/utils/mail.go
+++ b/utils/mail.go
@@ -11,6 +11,7 @@ import (
"net"
"net/mail"
"net/smtp"
+ "html"
)
func CheckMailSettings() *model.AppError {
@@ -84,7 +85,7 @@ func SendMail(to, subject, body string) *model.AppError {
headers := make(map[string]string)
headers["From"] = fromMail.String()
headers["To"] = toMail.String()
- headers["Subject"] = subject
+ headers["Subject"] = html.UnescapeString(subject)
headers["MIME-version"] = "1.0"
headers["Content-Type"] = "text/html"
diff --git a/web/react/components/channel_header.jsx b/web/react/components/channel_header.jsx
index 006c168ba..48cb4d13b 100644
--- a/web/react/components/channel_header.jsx
+++ b/web/react/components/channel_header.jsx
@@ -15,17 +15,8 @@ var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
var Constants = require('../utils/constants.jsx');
var ActionTypes = Constants.ActionTypes;
-function getExtraInfoStateFromStores() {
- return {
- extra_info: ChannelStore.getCurrentExtraInfo()
- };
-}
-
var ExtraMembers = React.createClass({
componentDidMount: function() {
- ChannelStore.addExtraInfoChangeListener(this._onChange);
- ChannelStore.addChangeListener(this._onChange);
-
var originalLeave = $.fn.popover.Constructor.prototype.leave;
$.fn.popover.Constructor.prototype.leave = function(obj) {
var self = obj instanceof this.constructor ? obj : $(obj.currentTarget)[this.type](this.getDelegateOptions()).data('bs.' + this.type);
@@ -49,27 +40,21 @@ var ExtraMembers = React.createClass({
});
},
- componentWillUnmount: function() {
- ChannelStore.removeExtraInfoChangeListener(this._onChange);
- ChannelStore.removeChangeListener(this._onChange);
- },
- _onChange: function() {
- var newState = getExtraInfoStateFromStores();
- if (!utils.areStatesEqual(newState, this.state)) {
- this.setState(newState);
- }
- },
- getInitialState: function() {
- return getExtraInfoStateFromStores();
- },
render: function() {
- var count = this.state.extra_info.members.length == 0 ? "-" : this.state.extra_info.members.length;
- count = this.state.extra_info.members.length > 19 ? "20+" : count;
+ var count = this.props.members.length == 0 ? "-" : this.props.members.length;
+ count = this.props.members.length > 19 ? "20+" : count;
var data_content = "";
+ var sortedMembers = this.props.members;
- this.state.extra_info.members.forEach(function(m) {
- data_content += "<div style='white-space: nowrap'>" + m.username + "</div>";
- });
+ if(sortedMembers) {
+ sortedMembers.sort(function(a,b) {
+ return a.username.localeCompare(b.username);
+ })
+
+ sortedMembers.forEach(function(m) {
+ data_content += "<div style='white-space: nowrap'>" + m.username + "</div>";
+ });
+ }
return (
<div style={{"cursor" : "pointer"}} id="member_popover" data-toggle="popover" data-content={data_content} data-original-title="Members" >
@@ -201,7 +186,7 @@ module.exports = React.createClass({
</a>
<ul className="dropdown-menu" role="menu" aria-labelledby="channel_header_dropdown">
<li role="presentation"><a role="menuitem" data-toggle="modal" data-target="#channel_info" data-channelid={this.state.channel.id} href="#">View Info</a></li>
- <li role="presentation"><a role="menuitem" data-toggle="modal" data-target="#channel_invite" href="#">Invite Members</a></li>
+ <li role="presentation"><a role="menuitem" data-toggle="modal" data-target="#channel_invite" href="#">Add Members</a></li>
{ isAdmin ?
<li role="presentation"><a role="menuitem" data-toggle="modal" data-target="#channel_members" href="#">Manage Members</a></li>
: ""
@@ -228,7 +213,7 @@ module.exports = React.createClass({
<a href="#"><strong className="heading">{channelTitle}</strong></a>
}
</th>
- <th><ExtraMembers channelId={this.state.channel.id} /></th>
+ <th><ExtraMembers members={this.state.users} channelId={this.state.channel.id} /></th>
{ searchForm }
<th>
<div className="dropdown" style={{"marginLeft":"5px", "marginRight":"10px"}}>
diff --git a/web/react/components/channel_info_modal.jsx b/web/react/components/channel_info_modal.jsx
index 191297ce4..18addb52f 100644
--- a/web/react/components/channel_info_modal.jsx
+++ b/web/react/components/channel_info_modal.jsx
@@ -35,9 +35,18 @@ module.exports = React.createClass({
<h4 className="modal-title" id="myModalLabel">{channel.display_name}</h4>
</div>
<div className="modal-body">
- <p><strong>Channel Name: </strong>{channel.display_name}</p>
- <p><strong>Channel Handle: </strong>{channel.name}</p>
- <p><strong>Channel ID: </strong>{channel.id}</p>
+ <div className="row form-group">
+ <div className="col-sm-3 info__label">Channel Name: </div>
+ <div className="col-sm-9">{channel.display_name}</div>
+ </div>
+ <div className="row form-group">
+ <div className="col-sm-3 info__label">Channel Handle:</div>
+ <div className="col-sm-9">{channel.name}</div>
+ </div>
+ <div className="row">
+ <div className="col-sm-3 info__label">Channel ID:</div>
+ <div className="col-sm-9">{channel.id}</div>
+ </div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-default" data-dismiss="modal">Close</button>
diff --git a/web/react/components/channel_loader.jsx b/web/react/components/channel_loader.jsx
index 5252f275c..537a41d03 100644
--- a/web/react/components/channel_loader.jsx
+++ b/web/react/components/channel_loader.jsx
@@ -18,6 +18,7 @@ module.exports = React.createClass({
AsyncClient.getChannelExtraInfo(true);
AsyncClient.findTeams();
AsyncClient.getStatuses();
+ AsyncClient.getMyTeam();
/* End of async loads */
diff --git a/web/react/components/channel_notifications.jsx b/web/react/components/channel_notifications.jsx
index 085536a0a..fa9ab42ae 100644
--- a/web/react/components/channel_notifications.jsx
+++ b/web/react/components/channel_notifications.jsx
@@ -1,6 +1,8 @@
// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
// See License.txt for license information.
+var SettingItemMin = require('./setting_item_min.jsx');
+var SettingItemMax = require('./setting_item_max.jsx');
var utils = require('../utils/utils.jsx');
var client = require('../utils/client.jsx');
@@ -9,26 +11,50 @@ var ChannelStore = require('../stores/channel_store.jsx');
module.exports = React.createClass({
componentDidMount: function() {
+ ChannelStore.addChangeListener(this._onChange);
+
var self = this;
$(this.refs.modal.getDOMNode()).on('show.bs.modal', function(e) {
var button = e.relatedTarget;
var channel_id = button.dataset.channelid;
var notifyLevel = ChannelStore.getMember(channel_id).notify_level;
- self.setState({ notify_level: notifyLevel, title: button.dataset.title, channel_id: channel_id });
+ var quietMode = false;
+ if (notifyLevel === "quiet") quietMode = true;
+ self.setState({ notify_level: notifyLevel, quiet_mode: quietMode, title: button.dataset.title, channel_id: channel_id });
});
},
+ componentWillUnmount: function() {
+ ChannelStore.removeChangeListener(this._onChange);
+ },
+ _onChange: function() {
+ if (!this.state.channel_id) return;
+ var notifyLevel = ChannelStore.getMember(this.state.channel_id).notify_level;
+ var quietMode = false;
+ if (notifyLevel === "quiet") quietMode = true;
+
+ var newState = this.state;
+ newState.notify_level = notifyLevel;
+ newState.quiet_mode = quietMode;
+
+ if (!utils.areStatesEqual(this.state, newState)) {
+ this.setState(newState);
+ }
+ },
+ updateSection: function(section) {
+ this.setState({ activeSection: section });
+ },
getInitialState: function() {
- return { notify_level: "", title: "", channel_id: "" };
+ return { notify_level: "", title: "", channel_id: "", activeSection: "" };
},
- handleUpdate: function(e) {
+ handleUpdate: function() {
var channel_id = this.state.channel_id;
- var notify_level = this.state.notify_level;
+ var notify_level = this.state.quiet_mode ? "quiet" : this.state.notify_level;
var data = {};
data["channel_id"] = channel_id;
data["user_id"] = UserStore.getCurrentId();
- data["notify_level"] = this.state.notify_level;
+ data["notify_level"] = notify_level;
if (!data["notify_level"] || data["notify_level"].length === 0) return;
@@ -37,7 +63,7 @@ module.exports = React.createClass({
var member = ChannelStore.getMember(channel_id);
member.notify_level = notify_level;
ChannelStore.setChannelMember(member);
- $(this.refs.modal.getDOMNode()).modal('hide');
+ this.updateSection("");
}.bind(this),
function(err) {
this.setState({ server_error: err.message });
@@ -45,42 +71,138 @@ module.exports = React.createClass({
);
},
handleRadioClick: function(notifyLevel) {
- this.setState({ notify_level: notifyLevel });
+ this.setState({ notify_level: notifyLevel, quiet_mode: false });
this.refs.modal.getDOMNode().focus();
},
- handleQuietToggle: function() {
- if (this.state.notify_level === "quiet") {
- this.setState({ notify_level: "none" });
- this.refs.modal.getDOMNode().focus();
- } else {
- this.setState({ notify_level: "quiet" });
- this.refs.modal.getDOMNode().focus();
- }
+ handleQuietToggle: function(quietMode) {
+ this.setState({ notify_level: "none", quiet_mode: quietMode });
+ this.refs.modal.getDOMNode().focus();
},
render: function() {
var server_error = this.state.server_error ? <div className='form-group has-error'><label className='control-label'>{ this.state.server_error }</label></div> : null;
- var allActive = "";
- var mentionActive = "";
- var noneActive = "";
- var quietActive = "";
- var desktopHidden = "";
-
- if (this.state.notify_level === "quiet") {
- desktopHidden = "hidden";
- quietActive = "active";
- } else if (this.state.notify_level === "mention") {
- mentionActive = "active";
- } else if (this.state.notify_level === "none") {
- noneActive = "active";
+ var self = this;
+
+ var desktopSection;
+ if (this.state.activeSection === 'desktop') {
+ var notifyActive = [false, false, false];
+ if (this.state.notify_level === "mention") {
+ notifyActive[1] = true;
+ } else if (this.state.notify_level === "all") {
+ notifyActive[0] = true;
+ } else {
+ notifyActive[2] = true;
+ }
+
+ var inputs = [];
+
+ inputs.push(
+ <div className="col-sm-12">
+ <div className="radio">
+ <label>
+ <input type="radio" checked={notifyActive[0]} onClick={function(){self.handleRadioClick("all")}}>For all activity</input>
+ </label>
+ <br/>
+ </div>
+ <div className="radio">
+ <label>
+ <input type="radio" checked={notifyActive[1]} onClick={function(){self.handleRadioClick("mention")}}>Only for mentions</input>
+ </label>
+ <br/>
+ </div>
+ <div className="radio">
+ <label>
+ <input type="radio" checked={notifyActive[2]} onClick={function(){self.handleRadioClick("none")}}>Never</input>
+ </label>
+ </div>
+ </div>
+ );
+
+ desktopSection = (
+ <SettingItemMax
+ title="Send desktop notifications"
+ inputs={inputs}
+ submit={this.handleUpdate}
+ server_error={server_error}
+ updateSection={function(e){self.updateSection("");self._onChange();e.preventDefault();}}
+ />
+ );
+ } else {
+ var describe = "";
+ if (this.state.notify_level === "mention") {
+ describe = "Only for mentions";
+ } else if (this.state.notify_level === "all") {
+ describe = "For all activity";
+ } else {
+ describe = "Never";
+ }
+
+ desktopSection = (
+ <SettingItemMin
+ title="Send desktop notifications"
+ describe={describe}
+ updateSection={function(e){self.updateSection("desktop");e.preventDefault();}}
+ />
+ );
+ }
+
+ var quietSection;
+ if (this.state.activeSection === 'quiet') {
+ var quietActive = ["",""];
+ if (this.state.quiet_mode) {
+ quietActive[0] = "active";
+ } else {
+ quietActive[1] = "active";
+ }
+
+ var inputs = [];
+
+ inputs.push(
+ <div className="col-sm-12">
+ <div className="btn-group" data-toggle="buttons-radio">
+ <button className={"btn btn-default "+quietActive[0]} onClick={function(){self.handleQuietToggle(true)}}>On</button>
+ <button className={"btn btn-default "+quietActive[1]} onClick={function(){self.handleQuietToggle(false)}}>Off</button>
+ </div>
+ </div>
+ );
+
+ inputs.push(
+ <div className="col-sm-12">
+ <br/>
+ Enabling quiet mode will turn off desktop notifications and only mark the channel as unread if you have been mentioned.
+ </div>
+ );
+
+ quietSection = (
+ <SettingItemMax
+ title="Quiet mode"
+ inputs={inputs}
+ submit={this.handleUpdate}
+ server_error={server_error}
+ updateSection={function(e){self.updateSection("");self._onChange();e.preventDefault();}}
+ />
+ );
} else {
- allActive = "active";
+ var describe = "";
+ if (this.state.quiet_mode) {
+ describe = "On";
+ } else {
+ describe = "Off";
+ }
+
+ quietSection = (
+ <SettingItemMin
+ title="Quiet mode"
+ describe={describe}
+ updateSection={function(e){self.updateSection("quiet");e.preventDefault();}}
+ />
+ );
}
var self = this;
return (
<div className="modal fade" id="channel_notifications" ref="modal" tabIndex="-1" role="dialog" aria-hidden="true">
- <div className="modal-dialog">
+ <div className="modal-dialog settings-modal">
<div className="modal-content">
<div className="modal-header">
<button type="button" className="close" data-dismiss="modal">
@@ -90,31 +212,23 @@ module.exports = React.createClass({
<h4 className="modal-title">{"Notification Preferences for " + this.state.title}</h4>
</div>
<div className="modal-body">
- <div className={desktopHidden}>
- <span>Desktop Notifications</span>
- <br/>
- <div className="btn-group" data-toggle="buttons-radio">
- <button className={"btn btn-default "+allActive} onClick={function(){self.handleRadioClick("all")}}>Any activity (default)</button>
- <button className={"btn btn-default "+mentionActive} onClick={function(){self.handleRadioClick("mention")}}>Mentions of my name</button>
- <button className={"btn btn-default "+noneActive} onClick={function(){self.handleRadioClick("none")}}>Nothing</button>
+ <div className="settings-table">
+ <div className="settings-content">
+ <div ref="wrapper" className="user-settings">
+ <br/>
+ <div className="divider-dark first"/>
+ {desktopSection}
+ <div className="divider-light"/>
+ {quietSection}
+ <div className="divider-dark"/>
</div>
- <br/>
- <br/>
</div>
- <span>Quiet Mode</span>
- <br/>
- <div className="btn-group" data-toggle="buttons-checkbox">
- <button className={"btn btn-default "+quietActive} onClick={this.handleQuietToggle}>Quiet Mode</button>
</div>
{ server_error }
</div>
- <div className="modal-footer">
- <button type="button" className="btn btn-primary" onClick={this.handleUpdate}>Done</button>
- </div>
</div>
</div>
</div>
-
);
}
});
diff --git a/web/react/components/create_comment.jsx b/web/react/components/create_comment.jsx
index cb7aa371c..9e3feb25c 100644
--- a/web/react/components/create_comment.jsx
+++ b/web/react/components/create_comment.jsx
@@ -112,13 +112,28 @@ module.exports = React.createClass({
return { messageText: '', uploadsInProgress: 0, previews: [], submitting: false };
},
setUploads: function(val) {
- var num = this.state.uploadsInProgress + val;
- this.setState({uploadsInProgress: num});
+ var oldInProgress = this.state.uploadsInProgress
+ var newInProgress = oldInProgress + val;
+
+ if (newInProgress + this.state.previews.length > Constants.MAX_UPLOAD_FILES) {
+ newInProgress = Constants.MAX_UPLOAD_FILES - this.state.previews.length;
+ this.setState({limit_error: "Uploads limited to " + Constants.MAX_UPLOAD_FILES + " files maximum. Please use additional comments for more files."});
+ } else {
+ this.setState({limit_error: null});
+ }
+
+ var numToUpload = newInProgress - oldInProgress;
+ if (numToUpload <= 0) return 0;
+
+ this.setState({uploadsInProgress: newInProgress});
+
+ return numToUpload;
},
render: function() {
var server_error = this.state.server_error ? <div className='form-group has-error'><label className='control-label'>{ this.state.server_error }</label></div> : null;
var post_error = this.state.post_error ? <label className='control-label'>{this.state.post_error}</label> : null;
+ var limit_error = this.state.limit_error ? <div className='has-error'><label className='control-label'>{this.state.limit_error}</label></div> : null;
var preview = <div/>;
if (this.state.previews.length > 0 || this.state.uploadsInProgress > 0) {
@@ -129,13 +144,6 @@ module.exports = React.createClass({
uploadsInProgress={this.state.uploadsInProgress} />
);
}
- var limit_previews = ""
- if (this.state.previews.length > 5) {
- limit_previews = <div className='has-error'><label className='control-label'>{ "Note: While all files will be available, only first five will show thumbnails." }</label></div>
- }
- if (this.state.previews.length > 20) {
- limit_previews = <div className='has-error'><label className='control-label'>{ "Note: Uploads limited to 20 files maximum. Please use additional posts for more files." }</label></div>
- }
return (
<form onSubmit={this.handleSubmit}>
@@ -159,7 +167,7 @@ module.exports = React.createClass({
<input type="button" className="btn btn-primary comment-btn pull-right" value="Add Comment" onClick={this.handleSubmit} />
{ post_error }
{ server_error }
- { limit_previews }
+ { limit_error }
</div>
</div>
{ preview }
diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx
index 5a0b6f85f..0c23dcfac 100644
--- a/web/react/components/create_post.jsx
+++ b/web/react/components/create_post.jsx
@@ -51,7 +51,7 @@ module.exports = React.createClass({
false,
function(data) {
PostStore.storeDraft(data.channel_id, user_id, null);
- this.setState({ messageText: '', submitting: false, post_error: null, previews: [], server_error: null });
+ this.setState({ messageText: '', submitting: false, post_error: null, previews: [], server_error: null, limit_error: null });
if (data.goto_location.length > 0) {
window.location.href = data.goto_location;
@@ -71,7 +71,7 @@ module.exports = React.createClass({
client.createPost(post, ChannelStore.getCurrent(),
function(data) {
PostStore.storeDraft(data.channel_id, data.user_id, null);
- this.setState({ messageText: '', submitting: false, post_error: null, previews: [], server_error: null });
+ this.setState({ messageText: '', submitting: false, post_error: null, previews: [], server_error: null, limit_error: null });
this.resizePostHolder();
AsyncClient.getPosts(true);
@@ -207,21 +207,36 @@ module.exports = React.createClass({
return { channel_id: ChannelStore.getCurrentId(), messageText: messageText, uploadsInProgress: 0, previews: previews, submitting: false, initialText: messageText };
},
setUploads: function(val) {
- var num = this.state.uploadsInProgress + val;
+ var oldInProgress = this.state.uploadsInProgress
+ var newInProgress = oldInProgress + val;
+
+ if (newInProgress + this.state.previews.length > Constants.MAX_UPLOAD_FILES) {
+ newInProgress = Constants.MAX_UPLOAD_FILES - this.state.previews.length;
+ this.setState({limit_error: "Uploads limited to " + Constants.MAX_UPLOAD_FILES + " files maximum. Please use additional posts for more files."});
+ } else {
+ this.setState({limit_error: null});
+ }
+
+ var numToUpload = newInProgress - oldInProgress;
+ if (numToUpload <= 0) return 0;
+
var draft = PostStore.getCurrentDraft();
if (!draft) {
draft = {}
draft['message'] = '';
draft['previews'] = [];
}
- draft['uploadsInProgress'] = num;
+ draft['uploadsInProgress'] = newInProgress;
PostStore.storeCurrentDraft(draft);
- this.setState({uploadsInProgress: num});
+ this.setState({uploadsInProgress: newInProgress});
+
+ return numToUpload;
},
render: function() {
var server_error = this.state.server_error ? <div className='form-group has-error'><label className='control-label'>{ this.state.server_error }</label></div> : null;
var post_error = this.state.post_error ? <label className='control-label'>{this.state.post_error}</label> : null;
+ var limit_error = this.state.limit_error ? <div className='has-error'><label className='control-label'>{this.state.limit_error}</label></div> : null;
var preview = <div/>;
if (this.state.previews.length > 0 || this.state.uploadsInProgress > 0) {
@@ -232,13 +247,6 @@ module.exports = React.createClass({
uploadsInProgress={this.state.uploadsInProgress} />
);
}
- var limit_previews = ""
- if (this.state.previews.length > 5) {
- limit_previews = <div className='has-error'><label className='control-label'>{ "Note: While all files will be available, only first five will show thumbnails." }</label></div>
- }
- if (this.state.previews.length > 20) {
- limit_previews = <div className='has-error'><label className='control-label'>{ "Note: Uploads limited to 20 files maximum. Please use additional posts for more files." }</label></div>
- }
return (
<form id="create_post" ref="topDiv" role="form" onSubmit={this.handleSubmit}>
@@ -260,7 +268,7 @@ module.exports = React.createClass({
<div className={post_error ? 'post-create-footer has-error' : 'post-create-footer'}>
{ post_error }
{ server_error }
- { limit_previews }
+ { limit_error }
{ preview }
<MsgTyping channelId={this.state.channel_id} parentId=""/>
</div>
diff --git a/web/react/components/file_preview.jsx b/web/react/components/file_preview.jsx
index 99327c22f..17a1e2bc2 100644
--- a/web/react/components/file_preview.jsx
+++ b/web/react/components/file_preview.jsx
@@ -10,7 +10,7 @@ var Constants = require('../utils/constants.jsx');
module.exports = React.createClass({
handleRemove: function(e) {
var previewDiv = e.target.parentNode.parentNode;
- this.props.onRemove(previewDiv.dataset.filename);
+ this.props.onRemove(previewDiv.getAttribute('data-filename'));
},
render: function() {
var previews = [];
diff --git a/web/react/components/file_upload.jsx b/web/react/components/file_upload.jsx
index c03a61c63..f2429f17e 100644
--- a/web/react/components/file_upload.jsx
+++ b/web/react/components/file_upload.jsx
@@ -12,18 +12,18 @@ module.exports = React.createClass({
this.props.onUploadError(null);
- //This looks redundant, but must be done this way due to
- //setState being an asynchronous call
+ // This looks redundant, but must be done this way due to
+ // setState being an asynchronous call
var numFiles = 0;
- for(var i = 0; i < files.length && i <= 20 ; i++) {
+ for(var i = 0; i < files.length && i < Constants.MAX_UPLOAD_FILES; i++) {
if (files[i].size <= Constants.MAX_FILE_SIZE) {
numFiles++;
}
}
- this.props.setUploads(numFiles);
+ var numToUpload = this.props.setUploads(numFiles);
- for (var i = 0; i < files.length && i <= 20; i++) {
+ for (var i = 0; i < files.length && i < numToUpload; i++) {
if (files[i].size > Constants.MAX_FILE_SIZE) {
this.props.onUploadError("Files must be no more than " + Constants.MAX_FILE_SIZE/1000000 + " MB");
continue;
@@ -70,8 +70,8 @@ module.exports = React.createClass({
self.props.onUploadError(null);
- //This looks redundant, but must be done this way due to
- //setState being an asynchronous call
+ // This looks redundant, but must be done this way due to
+ // setState being an asynchronous call
var items = e.clipboardData.items;
var numItems = 0;
if (items) {
@@ -87,9 +87,9 @@ module.exports = React.createClass({
}
}
- self.props.setUploads(numItems);
+ var numToUpload = self.props.setUploads(numItems);
- for (var i = 0; i < items.length; i++) {
+ for (var i = 0; i < items.length && i < numToUpload; i++) {
if (items[i].type.indexOf("image") !== -1) {
var file = items[i].getAsFile();
diff --git a/web/react/components/invite_member_modal.jsx b/web/react/components/invite_member_modal.jsx
index 5980664de..d1672126d 100644
--- a/web/react/components/invite_member_modal.jsx
+++ b/web/react/components/invite_member_modal.jsx
@@ -57,7 +57,7 @@ module.exports = React.createClass({
if (config.AllowInviteNames) {
invite.first_name = this.refs["first_name"+index].getDOMNode().value.trim();
- if (!invite.first_name ) {
+ if (!invite.first_name && config.RequireInviteNames) {
first_name_errors[index] = "This is a required field";
valid = false;
} else {
@@ -65,7 +65,7 @@ module.exports = React.createClass({
}
invite.last_name = this.refs["last_name"+index].getDOMNode().value.trim();
- if (!invite.last_name ) {
+ if (!invite.last_name && config.RequireInviteNames) {
last_name_errors[index] = "This is a required field";
valid = false;
} else {
@@ -125,10 +125,12 @@ module.exports = React.createClass({
});
},
removeInviteFields: function(index) {
+ var count = this.state.id_count;
var invite_ids = this.state.invite_ids;
var i = invite_ids.indexOf(index);
- if (index > -1) invite_ids.splice(i, 1);
- this.setState({ invite_ids: invite_ids });
+ if (i > -1) invite_ids.splice(i, 1);
+ if (!invite_ids.length) invite_ids.push(++count);
+ this.setState({ invite_ids: invite_ids, id_count: count });
},
getInitialState: function() {
return {
@@ -154,11 +156,9 @@ module.exports = React.createClass({
invite_sections[index] = (
<div key={"key" + index}>
- { i ?
<div>
- <button type="button" className="btn remove__member" onClick={function(){self.removeInviteFields(index);}}>×</button>
+ <button type="button" className="btn remove__member" onClick={this.removeInviteFields.bind(this, index)}>×</button>
</div>
- : ""}
<div className={ email_error ? "form-group invite has-error" : "form-group invite" }>
<input onKeyUp={this.displayNameKeyUp} type="text" ref={"email"+index} className="form-control" placeholder="email@domain.com" maxLength="64" />
{ email_error }
diff --git a/web/react/components/login.jsx b/web/react/components/login.jsx
index 65f1da1f8..3b6f96c2d 100644
--- a/web/react/components/login.jsx
+++ b/web/react/components/login.jsx
@@ -21,6 +21,12 @@ var FindTeamDomain = React.createClass({
return;
}
+ if (!utils.isLocalStorageSupported()) {
+ state.server_error = "This service requires local storage to be enabled. Please enable it or exit private browsing.";
+ this.setState(state);
+ return;
+ }
+
state.server_error = "";
this.setState(state);
@@ -31,7 +37,7 @@ var FindTeamDomain = React.createClass({
window.location.href = window.location.protocol + "//" + domain + "." + utils.getDomainWithOutSub();
}
else {
- this.state.server_error = "We couldn't find your " + strings.TeamPlural + ".";
+ this.state.server_error = "We couldn't find your " + strings.Team + ".";
this.setState(this.state);
}
}.bind(this),
@@ -94,7 +100,7 @@ module.exports = React.createClass({
return;
}
- var email = this.refs.email.getDOMNode().value.trim();
+ var email = this.refs.email.getDOMNode().value.trim();
if (!email) {
state.server_error = "An email is required"
this.setState(state);
@@ -108,6 +114,12 @@ module.exports = React.createClass({
return;
}
+ if (!utils.isLocalStorageSupported()) {
+ state.server_error = "This service requires local storage to be enabled. Please enable it or exit private browsing.";
+ this.setState(state);
+ return;
+ }
+
state.server_error = "";
this.setState(state);
diff --git a/web/react/components/mention.jsx b/web/react/components/mention.jsx
index ba758688b..3c33ddf49 100644
--- a/web/react/components/mention.jsx
+++ b/web/react/components/mention.jsx
@@ -6,10 +6,16 @@ module.exports = React.createClass({
this.props.handleClick(this.props.username);
},
render: function() {
+ var icon;
+ if (this.props.id != null) {
+ icon = <span><img className="mention-img" src={"/api/v1/users/" + this.props.id + "/image"}/></span>;
+ } else {
+ icon = <span><i className="mention-img fa fa-users fa-2x"></i></span>;
+ }
return (
<div className="mentions-name" onClick={this.handleClick}>
- <img className="pull-left mention-img" src={"/api/v1/users/" + this.props.id + "/image"}/>
- <span>@{this.props.username}</span><span style={{'color':'grey', 'marginLeft':'10px'}}>{this.props.name}</span>
+ <div className="pull-left">{icon}</div>
+ <div className="pull-left mention-align"><span>@{this.props.username}</span><span className="mention-fullname">{this.props.secondary_text}</span></div>
</div>
);
}
diff --git a/web/react/components/mention_list.jsx b/web/react/components/mention_list.jsx
index 8b7e25b04..eb21e0efe 100644
--- a/web/react/components/mention_list.jsx
+++ b/web/react/components/mention_list.jsx
@@ -74,6 +74,18 @@ module.exports = React.createClass({
users.push(profiles[id]);
}
+ var all = {};
+ all.username = "all";
+ all.full_name = "";
+ all.secondary_text = "Notifies everyone in the team";
+ users.push(all);
+
+ var channel = {};
+ channel.username = "channel";
+ channel.full_name = "";
+ channel.secondary_text = "Notifies everyone in the channel";
+ users.push(channel);
+
users.sort(function(a,b) {
if (a.username < b.username) return -1;
if (a.username > b.username) return 1;
@@ -91,6 +103,7 @@ module.exports = React.createClass({
var splitName = users[i].full_name.split(' ');
firstName = splitName[0].toLowerCase();
lastName = splitName.length > 1 ? splitName[splitName.length-1].toLowerCase() : "";
+ users[i].secondary_text = users[i].full_name;
}
if (firstName.lastIndexOf(mentionText,0) === 0
@@ -99,7 +112,7 @@ module.exports = React.createClass({
<Mention
ref={'mention' + index}
username={users[i].username}
- name={users[i].full_name}
+ secondary_text={users[i].secondary_text}
id={users[i].id}
handleClick={this.handleClick} />
);
diff --git a/web/react/components/navbar.jsx b/web/react/components/navbar.jsx
index 3821c2772..35f7d9044 100644
--- a/web/react/components/navbar.jsx
+++ b/web/react/components/navbar.jsx
@@ -301,7 +301,7 @@ module.exports = React.createClass({
<span className="glyphicon glyphicon-chevron-down header-dropdown__icon"></span>
</a>
<ul className="dropdown-menu" role="menu" aria-labelledby="channel_header_dropdown">
- <li role="presentation"><a role="menuitem" data-toggle="modal" data-target="#channel_invite" href="#">Invite Members</a></li>
+ <li role="presentation"><a role="menuitem" data-toggle="modal" data-target="#channel_invite" href="#">Add Members</a></li>
{ isAdmin ?
<li role="presentation"><a role="menuitem" data-toggle="modal" data-target="#channel_members" href="#">Manage Members</a></li>
: ""
diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx
index 3079917ec..7d5ef4d33 100644
--- a/web/react/components/post_body.jsx
+++ b/web/react/components/post_body.jsx
@@ -102,7 +102,7 @@ module.exports = React.createClass({
images.push(filenames[i]);
} else if (i < Constants.MAX_DISPLAY_FILES) {
postFiles.push(
- <div className="post-image__column custom-file" key={fileInfo.name}>
+ <div className="post-image__column custom-file" key={fileInfo.name+i}>
<a href={fileInfo.path+"."+fileInfo.ext} download={fileInfo.name+"."+fileInfo.ext}>
<div className={"file-icon "+utils.getIconClassName(type)}/>
</a>
diff --git a/web/react/components/post_list.jsx b/web/react/components/post_list.jsx
index 65247b705..e37de8d88 100644
--- a/web/react/components/post_list.jsx
+++ b/web/react/components/post_list.jsx
@@ -125,12 +125,12 @@ module.exports = React.createClass({
$('body').on('mouseenter mouseleave', '.post', function(ev){
if(ev.type === 'mouseenter'){
- $(this).parent('div').prev('.date-seperator').addClass('hovered--after');
- $(this).parent('div').next('.date-seperator').addClass('hovered--before');
+ $(this).parent('div').prev('.date-separator, .new-separator').addClass('hovered--after');
+ $(this).parent('div').next('.date-separator, .new-separator').addClass('hovered--before');
}
else {
- $(this).parent('div').prev('.date-seperator').removeClass('hovered--after');
- $(this).parent('div').next('.date-seperator').removeClass('hovered--before');
+ $(this).parent('div').prev('.date-separator, .new-separator').removeClass('hovered--after');
+ $(this).parent('div').next('.date-separator, .new-separator').removeClass('hovered--before');
}
});
@@ -356,6 +356,7 @@ module.exports = React.createClass({
var ui_name = channel.display_name
var members = ChannelStore.getCurrentExtraInfo().members;
var creator_name = "";
+ var userStyle = { color: UserStore.getCurrentUser().props.theme }
for (var i = 0; i < members.length; i++) {
if (members[i].roles.indexOf('admin') > -1) {
@@ -382,14 +383,24 @@ module.exports = React.createClass({
</p>
</div>
);
+ } else if (channel.name === Constants.OFFTOPIC_CHANNEL) {
+ more_messages = (
+ <div className="channel-intro">
+ <h4 className="channel-intro-title">Welcome</h4>
+ <p>
+ {"This is the start of " + ui_name + ", a channel for conversations you’d prefer out of more focused channels."}
+ <br/>
+ <a className="intro-links" href="#" style={userStyle} data-toggle="modal" data-target="#edit_channel" data-desc={channel.description} data-title={ui_name} data-channelid={channel.id}><i className="fa fa-pencil"></i>Set a description</a>
+ </p>
+ </div>
+ );
} else {
- var userStyle = { color: UserStore.getCurrentUser().props.theme }
var ui_type = channel.type === 'P' ? "private group" : "channel";
more_messages = (
<div className="channel-intro">
<h4 className="channel-intro-title">Welcome</h4>
<p>
- { creator_name != "" ? "This is the start of the " + ui_name + " " + ui_type + ", created by " + creator_name + " on " + utils.displayDate(channel.create_at) + "."
+ { creator_name != "" ? "This is the start of the " + ui_name + " " + ui_type + ", created by " + creator_name + " on " + utils.displayDate(channel.create_at) + "."
: "This is the start of the " + ui_name + " " + ui_type + ", created on "+ utils.displayDate(channel.create_at) + "." }
{ channel.type === 'P' ? " Only invited members can see this private group." : " Any member can join and read this channel." }
<br/>
@@ -434,9 +445,9 @@ module.exports = React.createClass({
currentPostDay = utils.getDateForUnixTicks(post.create_at);
if(currentPostDay.getDate() !== previousPostDay.getDate() || currentPostDay.getMonth() !== previousPostDay.getMonth() || currentPostDay.getFullYear() !== previousPostDay.getFullYear()) {
postCtls.push(
- <div className="date-seperator">
- <hr className="date-seperator__hr" />
- <div className="date-seperator__text">{currentPostDay.toDateString()}</div>
+ <div className="date-separator">
+ <hr className="separator__hr" />
+ <div className="separator__text">{currentPostDay.toDateString()}</div>
</div>
);
}
@@ -444,17 +455,13 @@ module.exports = React.createClass({
if (post.create_at > last_viewed && !rendered_last_viewed) {
rendered_last_viewed = true;
postCtls.push(
- <div>
- <div className="new-seperator">
- <hr id="new_message" className="new-seperator__hr" />
- <div className="new-seperator__text">New Messages</div>
- </div>
- {postCtl}
- </div>
+ <div className="new-separator">
+ <hr id="new_message" className="separator__hr" />
+ <div className="separator__text">New Messages</div>
+ </div>
);
- } else {
- postCtls.push(postCtl);
}
+ postCtls.push(postCtl);
previousPostDay = utils.getDateForUnixTicks(post.create_at);
}
diff --git a/web/react/components/setting_item_max.jsx b/web/react/components/setting_item_max.jsx
index 03f05b0cf..b8b667e1a 100644
--- a/web/react/components/setting_item_max.jsx
+++ b/web/react/components/setting_item_max.jsx
@@ -13,7 +13,7 @@ module.exports = React.createClass({
<li className="col-sm-12 section-title">{this.props.title}</li>
<li className="col-sm-9 col-sm-offset-3">
<ul className="setting-list">
- <li className="row setting-list-item form-group">
+ <li className="setting-list-item">
{inputs}
</li>
<li className="setting-list-item">
diff --git a/web/react/components/settings_sidebar.jsx b/web/react/components/settings_sidebar.jsx
index a1546890f..ae8510cf2 100644
--- a/web/react/components/settings_sidebar.jsx
+++ b/web/react/components/settings_sidebar.jsx
@@ -1,6 +1,8 @@
// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
// See License.txt for license information.
+var utils = require('../utils/utils.jsx');
+
module.exports = React.createClass({
updateTab: function(tab) {
this.props.updateTab(tab);
@@ -11,16 +13,11 @@ module.exports = React.createClass({
return (
<div className="">
<ul className="nav nav-pills nav-stacked">
- <li className={this.props.activeTab == 'general' ? 'active' : ''}><a href="#" onClick={function(){self.updateTab("general");}}><i className="glyphicon glyphicon-cog"></i>General</a></li>
- <li className={this.props.activeTab == 'security' ? 'active' : ''}><a href="#" onClick={function(){self.updateTab("security");}}><i className="glyphicon glyphicon-lock"></i>Security</a></li>
- <li className={this.props.activeTab == 'notifications' ? 'active' : ''}><a href="#" onClick={function(){self.updateTab("notifications");}}><i className="glyphicon glyphicon-exclamation-sign"></i>Notifications</a></li>
- <li className={this.props.activeTab == 'appearance' ? 'active' : ''}><a href="#" onClick={function(){self.updateTab("appearance");}}><i className="glyphicon glyphicon-wrench"></i>Appearance</a></li>
+ {this.props.tabs.map(function(tab) {
+ return <li className={self.props.activeTab == tab.name ? 'active' : ''}><a href="#" onClick={function(){self.updateTab(tab.name);}}><i className={tab.icon}></i>{tab.ui_name}</a></li>
+ })}
</ul>
</div>
);
- /* Temporarily removing sessions and activity logs
- <li className={this.props.activeTab == 'sessions' ? 'active' : ''}><a href="#" onClick={function(){self.updateTab("sessions");}}><i className="glyphicon glyphicon-globe"></i>Sessions</a></li>
- <li className={this.props.activeTab == 'activity_log' ? 'active' : ''}><a href="#" onClick={function(){self.updateTab("activity_log");}}><i className="glyphicon glyphicon-time"></i>Activity Log</a></li>
- */
}
});
diff --git a/web/react/components/sidebar_header.jsx b/web/react/components/sidebar_header.jsx
index 5a872b7a0..0b59d2036 100644
--- a/web/react/components/sidebar_header.jsx
+++ b/web/react/components/sidebar_header.jsx
@@ -94,7 +94,8 @@ var NavbarDropdown = React.createClass({
<i className="dropdown__icon"></i>
</a>
<ul className="dropdown-menu" role="menu">
- <li><a href="#" data-toggle="modal" data-target="#settings_modal">Account Settings</a></li>
+ <li><a href="#" data-toggle="modal" data-target="#user_settings1">Account Settings</a></li>
+ { isAdmin ? <li><a href="#" data-toggle="modal" data-target="#team_settings">Team Settings</a></li> : "" }
{ invite_link }
{ team_link }
{ manage_link }
diff --git a/web/react/components/sidebar_right_menu.jsx b/web/react/components/sidebar_right_menu.jsx
index d0c139d1a..22d1d9ad2 100644
--- a/web/react/components/sidebar_right_menu.jsx
+++ b/web/react/components/sidebar_right_menu.jsx
@@ -59,7 +59,8 @@ module.exports = React.createClass({
<div className="nav-pills__container">
<ul className="nav nav-pills nav-stacked">
- <li><a href="#" data-toggle="modal" data-target="#settings_modal"><i className="glyphicon glyphicon-cog"></i>Account Settings</a></li>
+ <li><a href="#" data-toggle="modal" data-target="#user_settings1"><i className="glyphicon glyphicon-cog"></i>Account Settings</a></li>
+ { isAdmin ? <li><a href="#" data-toggle="modal" data-target="#team_settings"><i className="glyphicon glyphicon-globe"></i>Team Settings</a></li> : "" }
{ invite_link }
{ team_link }
{ manage_link }
diff --git a/web/react/components/signup_team_complete.jsx b/web/react/components/signup_team_complete.jsx
index b038679e6..587d8cb82 100644
--- a/web/react/components/signup_team_complete.jsx
+++ b/web/react/components/signup_team_complete.jsx
@@ -9,6 +9,10 @@ var constants = require('../utils/constants.jsx')
WelcomePage = React.createClass({
submitNext: function (e) {
+ if (!utils.isLocalStorageSupported()) {
+ this.setState({ storage_error: "This service requires local storage to be enabled. Please enable it or exit private browsing."} );
+ return;
+ }
e.preventDefault();
this.props.state.wizard = "team_name";
this.props.updateParent(this.props.state);
@@ -26,6 +30,12 @@ WelcomePage = React.createClass({
if (!email || !utils.isEmail(email)) {
state.email_error = "Please enter a valid email address";
this.setState(state);
+ return;
+ }
+ else if (!utils.isLocalStorageSupported()) {
+ state.email_error = "This service requires local storage to be enabled. Please enable it or exit private browsing.";
+ this.setState(state);
+ return;
}
else {
state.email_error = "";
@@ -50,6 +60,7 @@ WelcomePage = React.createClass({
client.track('signup', 'signup_team_01_welcome');
+ var storage_error = this.state.storage_error ? <label className="control-label">{ this.state.storage_error }</label> : null;
var email_error = this.state.email_error ? <label className="control-label">{ this.state.email_error }</label> : null;
var server_error = this.state.server_error ? <div className={ "form-group has-error" }><label className="control-label">{ this.state.server_error }</label></div> : null;
@@ -66,6 +77,7 @@ WelcomePage = React.createClass({
</p>
<div className="form-group">
<button className="btn-primary btn form-group" onClick={this.submitNext}><i className="glyphicon glyphicon-ok"></i>Yes, this address is correct</button>
+ { storage_error }
</div>
<hr />
<p>If this is not correct, you can switch to a different email. We'll send you a new invite right away.</p>
@@ -152,7 +164,9 @@ TeamUrlPage = React.createClass({
}
var cleaned_name = utils.cleanUpUrlable(name);
- if (cleaned_name != name) {
+
+ var urlRegex = /^[a-z0-9]+([a-z\-0-9]+|(__)?)[a-z0-9]+$/g;
+ if (cleaned_name != name || !urlRegex.test(name)) {
this.setState({name_error: "Must be lowercase alphanumeric characters"});
return;
}
@@ -312,7 +326,7 @@ EmailItem = React.createClass({
getValue: function() {
return this.refs.email.getDOMNode().value.trim()
},
- validate: function() {
+ validate: function(teamEmail) {
var email = this.refs.email.getDOMNode().value.trim().toLowerCase();
if (!email) {
@@ -324,6 +338,11 @@ EmailItem = React.createClass({
this.setState(this.state);
return false;
}
+ else if (email === teamEmail) {
+ this.state.email_error = "Please use a different email than the one used at signup";
+ this.setState(this.state);
+ return false;
+ }
else {
this.state.email_error = "";
this.setState(this.state);
@@ -363,7 +382,7 @@ SendInivtesPage = React.createClass({
var emails = [];
for (var i = 0; i < this.props.state.invites.length; i++) {
- if (!this.refs['email_' + i].validate()) {
+ if (!this.refs['email_' + i].validate(this.props.state.team.email)) {
valid = false;
} else {
emails.push(this.refs['email_' + i].getValue());
@@ -491,6 +510,7 @@ PasswordPage = React.createClass({
return;
}
+ this.setState({name_error: ""});
$('#finish-button').button('loading');
var teamSignup = JSON.parse(JSON.stringify(this.props.state));
teamSignup.user.password = password;
diff --git a/web/react/components/signup_user_complete.jsx b/web/react/components/signup_user_complete.jsx
index 146419cf5..b9f32f0bc 100644
--- a/web/react/components/signup_user_complete.jsx
+++ b/web/react/components/signup_user_complete.jsx
@@ -13,16 +13,16 @@ module.exports = React.createClass({
this.state.user.username = this.refs.name.getDOMNode().value.trim();
if (!this.state.user.username) {
- this.setState({name_error: "This field is required", email_error: "", password_error: ""});
+ this.setState({name_error: "This field is required", email_error: "", password_error: "", server_error: ""});
return;
}
var username_error = utils.isValidUsername(this.state.user.username)
if (username_error === "Cannot use a reserved word as a username.") {
- this.setState({name_error: "This username is reserved, please choose a new one." });
+ this.setState({name_error: "This username is reserved, please choose a new one.", email_error: "", password_error: "", server_error: ""});
return;
} else if (username_error) {
- this.setState({name_error: "Username must begin with a letter, and contain between 3 to 15 lowercase characters made up of numbers, letters, and the symbols '.', '-' and '_'." });
+ this.setState({name_error: "Username must begin with a letter, and contain between 3 to 15 lowercase characters made up of numbers, letters, and the symbols '.', '-' and '_'.", email_error: "", password_error: "", server_error: ""});
return;
}
@@ -34,10 +34,12 @@ module.exports = React.createClass({
this.state.user.password = this.refs.password.getDOMNode().value.trim();
if (!this.state.user.password || this.state.user.password .length < 5) {
- this.setState({name_error: "", email_error: "", password_error: "Please enter at least 5 characters"});
+ this.setState({name_error: "", email_error: "", password_error: "Please enter at least 5 characters", server_error: ""});
return;
}
+ this.setState({name_error: "", email_error: "", password_error: "", server_error: ""});
+
this.state.user.allow_marketing = this.refs.email_service.getDOMNode().checked;
client.createUser(this.state.user, this.state.data, this.state.hash,
diff --git a/web/react/components/team_settings.jsx b/web/react/components/team_settings.jsx
new file mode 100644
index 000000000..a43e5d2f0
--- /dev/null
+++ b/web/react/components/team_settings.jsx
@@ -0,0 +1,161 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var UserStore = require('../stores/user_store.jsx');
+var TeamStore = require('../stores/team_store.jsx');
+var SettingItemMin = require('./setting_item_min.jsx');
+var SettingItemMax = require('./setting_item_max.jsx');
+var SettingPicture = require('./setting_picture.jsx');
+var utils = require('../utils/utils.jsx');
+
+var client = require('../utils/client.jsx');
+var AsyncClient = require('../utils/async_client.jsx');
+var Constants = require('../utils/constants.jsx');
+
+var FeatureTab = React.createClass({
+ submitValetFeature: function() {
+ data = {};
+ data['allow_valet'] = this.state.allow_valet;
+
+ client.updateValetFeature(data,
+ function(data) {
+ this.props.updateSection("");
+ AsyncClient.getMyTeam();
+ }.bind(this),
+ function(err) {
+ state = this.getInitialState();
+ state.server_error = err;
+ this.setState(state);
+ }.bind(this)
+ );
+ },
+ handleValetRadio: function(val) {
+ this.setState({ allow_valet: val });
+ this.refs.wrapper.getDOMNode().focus();
+ },
+ componentWillReceiveProps: function(newProps) {
+ var team = newProps.team;
+
+ var allow_valet = "false";
+ if (team && team.allow_valet) {
+ allow_valet = "true";
+ }
+
+ this.setState({ allow_valet: allow_valet });
+ },
+ getInitialState: function() {
+ var team = this.props.team;
+
+ var allow_valet = "false";
+ if (team && team.allow_valet) {
+ allow_valet = "true";
+ }
+
+ return { allow_valet: allow_valet };
+ },
+ render: function() {
+ var team = this.props.team;
+
+ var client_error = this.state.client_error ? this.state.client_error : null;
+ var server_error = this.state.server_error ? this.state.server_error : null;
+
+ var valetSection;
+ var self = this;
+
+ if (this.props.activeSection === 'valet') {
+ var valetActive = ["",""];
+ if (this.state.allow_valet === "false") {
+ valetActive[1] = "active";
+ } else {
+ valetActive[0] = "active";
+ }
+
+ var inputs = [];
+
+ inputs.push(
+ <div className="col-sm-12">
+ <div className="btn-group" data-toggle="buttons-radio">
+ <button className={"btn btn-default "+valetActive[0]} onClick={function(){self.handleValetRadio("true")}}>On</button>
+ <button className={"btn btn-default "+valetActive[1]} onClick={function(){self.handleValetRadio("false")}}>Off</button>
+ </div>
+ <div><br/>Warning: Turning on the Valet feature and using it with any third party software increases the risk of a security breach.</div>
+ </div>
+ );
+
+ valetSection = (
+ <SettingItemMax
+ title="Valet"
+ inputs={inputs}
+ submit={this.submitValetFeature}
+ server_error={server_error}
+ client_error={client_error}
+ updateSection={function(e){self.props.updateSection("");e.preventDefault();}}
+ />
+ );
+ } else {
+ var describe = "";
+ if (this.state.allow_valet === "false") {
+ describe = "Off";
+ } else {
+ describe = "On";
+ }
+
+ valetSection = (
+ <SettingItemMin
+ title="Valet"
+ describe={describe}
+ updateSection={function(){self.props.updateSection("valet");}}
+ />
+ );
+ }
+
+ return (
+ <div>
+ <div className="modal-header">
+ <button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+ <h4 className="modal-title" ref="title"><i className="modal-back"></i>Feature Settings</h4>
+ </div>
+ <div ref="wrapper" className="user-settings">
+ <h3 className="tab-header">Feature Settings</h3>
+ <div className="divider-dark first"/>
+ {valetSection}
+ <div className="divider-dark"/>
+ </div>
+ </div>
+ );
+ }
+});
+
+module.exports = React.createClass({
+ componentDidMount: function() {
+ TeamStore.addChangeListener(this._onChange);
+ },
+ componentWillUnmount: function() {
+ TeamStore.removeChangeListener(this._onChange);
+ },
+ _onChange: function () {
+ var team = TeamStore.getCurrent();
+ if (!utils.areStatesEqual(this.state.team, team)) {
+ this.setState({ team: team });
+ }
+ },
+ getInitialState: function() {
+ return { team: TeamStore.getCurrent() };
+ },
+ render: function() {
+ if (this.props.activeTab === 'general') {
+ return (
+ <div>
+ </div>
+ );
+ } else if (this.props.activeTab === 'feature') {
+ return (
+ <div>
+ <FeatureTab team={this.state.team} activeSection={this.props.activeSection} updateSection={this.props.updateSection} />
+ </div>
+ );
+ } else {
+ return <div/>;
+ }
+ }
+});
diff --git a/web/react/components/settings_modal.jsx b/web/react/components/team_settings_modal.jsx
index 57a869f93..e50378b7f 100644
--- a/web/react/components/settings_modal.jsx
+++ b/web/react/components/team_settings_modal.jsx
@@ -2,7 +2,7 @@
// See License.txt for license information.
var SettingsSidebar = require('./settings_sidebar.jsx');
-var UserSettings = require('./user_settings.jsx');
+var TeamSettings = require('./team_settings.jsx');
module.exports = React.createClass({
componentDidMount: function() {
@@ -22,27 +22,31 @@ module.exports = React.createClass({
this.setState({ active_section: section });
},
getInitialState: function() {
- return { active_tab: "general", active_section: "" };
+ return { active_tab: "feature", active_section: "" };
},
render: function() {
+ var tabs = [];
+ tabs.push({name: "feature", ui_name: "Features", icon: "glyphicon glyphicon-wrench"});
+
return (
- <div className="modal fade" ref="modal" id="settings_modal" role="dialog" aria-hidden="true">
+ <div className="modal fade" ref="modal" id="team_settings" role="dialog" aria-hidden="true">
<div className="modal-dialog settings-modal">
<div className="modal-content">
<div className="modal-header">
<button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
- <h4 className="modal-title" ref="title">Account Settings</h4>
+ <h4 className="modal-title" ref="title">Team Settings</h4>
</div>
<div className="modal-body">
<div className="settings-table">
<div className="settings-links">
<SettingsSidebar
+ tabs={tabs}
activeTab={this.state.active_tab}
updateTab={this.updateTab}
/>
</div>
- <div className="settings-content">
- <UserSettings
+ <div className="settings-content minimize-settings">
+ <TeamSettings
activeTab={this.state.active_tab}
activeSection={this.state.active_section}
updateSection={this.updateSection}
diff --git a/web/react/components/textbox.jsx b/web/react/components/textbox.jsx
index 7a4762e07..934e863a2 100644
--- a/web/react/components/textbox.jsx
+++ b/web/react/components/textbox.jsx
@@ -153,7 +153,7 @@ module.exports = React.createClass({
var mentions = [];
for (var i = 0; i < matches.length; i++) {
var m = matches[i].substring(1,matches[i].length).trim();
- if (m in profileMap && mentions.indexOf(m) === -1) {
+ if ((m in profileMap && mentions.indexOf(m) === -1) || Constants.SPECIAL_MENTIONS.indexOf(m) !== -1) {
mentions.push(m);
}
}
diff --git a/web/react/components/user_profile.jsx b/web/react/components/user_profile.jsx
index 8ffad737d..648960471 100644
--- a/web/react/components/user_profile.jsx
+++ b/web/react/components/user_profile.jsx
@@ -10,8 +10,7 @@ function getStateFromStores(userId) {
if (profile == null) {
return { profile: { id: "0", username: "..."} };
- }
- else {
+ } else {
return { profile: profile };
}
}
@@ -54,12 +53,11 @@ module.exports = React.createClass({
var name = this.props.overwriteName ? this.props.overwriteName : this.state.profile.username;
- var data_content = ""
- data_content += "<img style='margin: 10px' src='/api/v1/users/" + this.state.profile.id + "/image' height='128' width='128' />"
+ var data_content = "<img style='margin: 10px' src='/api/v1/users/" + this.state.profile.id + "/image' height='128' width='128' />";
if (!config.ShowEmail) {
- data_content += "<div><span style='white-space:nowrap;'>Email not shared</span></div>";
+ data_content += "<div class='text-nowrap'>Email not shared</div>";
} else {
- data_content += "<div><a href='mailto:'" + this.state.profile.email + "'' style='white-space:nowrap;text-transform:lowercase;'>" + this.state.profile.email + "</a></div>";
+ data_content += "<div><a href='mailto:" + this.state.profile.email + "' class='text-nowrap text-lowercase'>" + this.state.profile.email + "</a></div>";
}
return (
diff --git a/web/react/components/user_settings.jsx b/web/react/components/user_settings.jsx
index 110634b50..f97a06db3 100644
--- a/web/react/components/user_settings.jsx
+++ b/web/react/components/user_settings.jsx
@@ -20,11 +20,10 @@ function getNotificationsStateFromStores() {
var mention_key = false;
var custom_keys = "";
var first_name_key = false;
+ var all_key = false;
+ var channel_key = false;
- if (!user.notify_props) {
- mention_keys = user.username;
- if (user.full_name.length > 0) mention_keys += ","+ user.full_name.split(" ")[0];
- } else {
+ if (user.notify_props) {
if (user.notify_props.mention_keys !== undefined) {
var keys = user.notify_props.mention_keys.split(',');
@@ -48,9 +47,17 @@ function getNotificationsStateFromStores() {
if (user.notify_props.first_name !== undefined) {
first_name_key = user.notify_props.first_name === "true";
}
+
+ if (user.notify_props.all !== undefined) {
+ all_key = user.notify_props.all === "true";
+ }
+
+ if (user.notify_props.channel !== undefined) {
+ channel_key = user.notify_props.channel === "true";
+ }
}
- return { notify_level: desktop, enable_email: email, enable_sound: sound, username_key: username_key, mention_key: mention_key, custom_keys: custom_keys, custom_keys_checked: custom_keys.length > 0, first_name_key: first_name_key };
+ return { notify_level: desktop, enable_email: email, enable_sound: sound, username_key: username_key, mention_key: mention_key, custom_keys: custom_keys, custom_keys_checked: custom_keys.length > 0, first_name_key: first_name_key, all_key: all_key, channel_key: channel_key };
}
@@ -73,6 +80,8 @@ var NotificationsTab = React.createClass({
data["mention_keys"] = string_keys;
data["first_name"] = this.state.first_name_key ? "true" : "false";
+ data["all"] = this.state.all_key ? "true" : "false";
+ data["channel"] = this.state.channel_key ? "true" : "false";
client.updateUserNotifyProps(data,
function(data) {
@@ -120,6 +129,12 @@ var NotificationsTab = React.createClass({
updateFirstNameKey: function(val) {
this.setState({ first_name_key: val });
},
+ updateAllKey: function(val) {
+ this.setState({ all_key: val });
+ },
+ updateChannelKey: function(val) {
+ this.setState({ channel_key: val });
+ },
updateCustomMentionKeys: function() {
var checked = this.refs.customcheck.getDOMNode().checked;
@@ -155,7 +170,7 @@ var NotificationsTab = React.createClass({
var inputs = [];
inputs.push(
- <div className="col-sm-12">
+ <div>
<div className="radio">
<label>
<input type="radio" checked={notifyActive[0]} onClick={function(){self.handleNotifyRadio("all")}}>For all activity</input>
@@ -216,7 +231,7 @@ var NotificationsTab = React.createClass({
var inputs = [];
inputs.push(
- <div className="col-sm-12">
+ <div>
<div className="btn-group" data-toggle="buttons-radio">
<button className={"btn btn-default "+soundActive[0]} onClick={function(){self.handleSoundRadio("true")}}>On</button>
<button className={"btn btn-default "+soundActive[1]} onClick={function(){self.handleSoundRadio("false")}}>Off</button>
@@ -262,7 +277,7 @@ var NotificationsTab = React.createClass({
var inputs = [];
inputs.push(
- <div className="col-sm-12">
+ <div>
<div className="btn-group" data-toggle="buttons-radio">
<button className={"btn btn-default "+emailActive[0]} onClick={function(){self.handleEmailRadio("true")}}>On</button>
<button className={"btn btn-default "+emailActive[1]} onClick={function(){self.handleEmailRadio("false")}}>Off</button>
@@ -309,7 +324,7 @@ var NotificationsTab = React.createClass({
if (first_name != "") {
inputs.push(
- <div className="col-sm-12">
+ <div>
<div className="checkbox">
<label>
<input type="checkbox" checked={this.state.first_name_key} onChange={function(e){self.updateFirstNameKey(e.target.checked);}}>{'Your case sensitive first name "' + first_name + '"'}</input>
@@ -320,7 +335,7 @@ var NotificationsTab = React.createClass({
}
inputs.push(
- <div className="col-sm-12">
+ <div>
<div className="checkbox">
<label>
<input type="checkbox" checked={this.state.username_key} onChange={function(e){self.updateUsernameKey(e.target.checked);}}>{'Your non-case sensitive username "' + user.username + '"'}</input>
@@ -330,7 +345,7 @@ var NotificationsTab = React.createClass({
);
inputs.push(
- <div className="col-sm-12">
+ <div>
<div className="checkbox">
<label>
<input type="checkbox" checked={this.state.mention_key} onChange={function(e){self.updateMentionKey(e.target.checked);}}>{'Your username mentioned "@' + user.username + '"'}</input>
@@ -340,7 +355,27 @@ var NotificationsTab = React.createClass({
);
inputs.push(
- <div className="col-sm-12">
+ <div>
+ <div className="checkbox">
+ <label>
+ <input type="checkbox" checked={this.state.all_key} onChange={function(e){self.updateAllKey(e.target.checked);}}>{'Team-wide mentions "@all"'}</input>
+ </label>
+ </div>
+ </div>
+ );
+
+ inputs.push(
+ <div>
+ <div className="checkbox">
+ <label>
+ <input type="checkbox" checked={this.state.channel_key} onChange={function(e){self.updateChannelKey(e.target.checked);}}>{'Channel-wide mentions "@channel"'}</input>
+ </label>
+ </div>
+ </div>
+ );
+
+ inputs.push(
+ <div>
<div className="checkbox">
<label>
<input ref="customcheck" type="checkbox" checked={this.state.custom_keys_checked} onChange={this.updateCustomMentionKeys}>{'Other non-case sensitive words, separated by commas:'}</input>
@@ -369,6 +404,8 @@ var NotificationsTab = React.createClass({
}
if (this.state.username_key) keys.push(this.props.user.username);
if (this.state.mention_key) keys.push('@'+this.props.user.username);
+ if (this.state.all_key) keys.push('@all');
+ if (this.state.channel_key) keys.push('@channel');
if (this.state.custom_keys.length > 0) keys = keys.concat(this.state.custom_keys.split(','));
var describe = "";
@@ -622,7 +659,7 @@ var SecurityTab = React.createClass({
var inputs = [];
inputs.push(
- <div>
+ <div className="form-group">
<label className="col-sm-5 control-label">Current Password</label>
<div className="col-sm-7">
<input className="form-control" type="password" onChange={this.updateCurrentPassword} value={this.state.current_password}/>
@@ -630,7 +667,7 @@ var SecurityTab = React.createClass({
</div>
);
inputs.push(
- <div>
+ <div className="form-group">
<label className="col-sm-5 control-label">New Password</label>
<div className="col-sm-7">
<input className="form-control" type="password" onChange={this.updateNewPassword} value={this.state.new_password}/>
@@ -638,7 +675,7 @@ var SecurityTab = React.createClass({
</div>
);
inputs.push(
- <div>
+ <div className="form-group">
<label className="col-sm-5 control-label">Retype New Password</label>
<div className="col-sm-7">
<input className="form-control" type="password" onChange={this.updateConfirmPassword} value={this.state.confirm_password}/>
@@ -658,9 +695,10 @@ var SecurityTab = React.createClass({
);
} else {
var d = new Date(this.props.user.last_password_update);
- var hour = d.getHours() < 10 ? "0" + d.getHours() : String(d.getHours());
+ var hour = d.getHours() % 12 ? String(d.getHours() % 12) : "12";
var min = d.getMinutes() < 10 ? "0" + d.getMinutes() : String(d.getMinutes());
- var dateStr = "Last updated " + Constants.MONTHS[d.getMonth()] + " " + d.getDate() + ", " + d.getFullYear() + " at " + hour + ":" + min;
+ var timeOfDay = d.getHours() >= 12 ? " pm" : " am";
+ var dateStr = "Last updated " + Constants.MONTHS[d.getMonth()] + " " + d.getDate() + ", " + d.getFullYear() + " at " + hour + ":" + min + timeOfDay;
passwordSection = (
<SettingItemMin
@@ -836,7 +874,7 @@ var GeneralTab = React.createClass({
var inputs = [];
inputs.push(
- <div>
+ <div className="form-group">
<label className="col-sm-5 control-label">First Name</label>
<div className="col-sm-7">
<input className="form-control" type="text" onChange={this.updateFirstName} value={this.state.first_name}/>
@@ -845,7 +883,7 @@ var GeneralTab = React.createClass({
);
inputs.push(
- <div>
+ <div className="form-group">
<label className="col-sm-5 control-label">Last Name</label>
<div className="col-sm-7">
<input className="form-control" type="text" onChange={this.updateLastName} value={this.state.last_name}/>
@@ -878,7 +916,7 @@ var GeneralTab = React.createClass({
var inputs = [];
inputs.push(
- <div>
+ <div className="form-group">
<label className="col-sm-5 control-label">{utils.isMobile() ? "": "Username"}</label>
<div className="col-sm-7">
<input className="form-control" type="text" onChange={this.updateUsername} value={this.state.username}/>
@@ -910,7 +948,7 @@ var GeneralTab = React.createClass({
var inputs = [];
inputs.push(
- <div>
+ <div className="form-group">
<label className="col-sm-5 control-label">Primary Email</label>
<div className="col-sm-7">
<input className="form-control" type="text" onChange={this.updateEmail} value={this.state.email}/>
@@ -1047,7 +1085,7 @@ var AppearanceTab = React.createClass({
var inputs = [];
inputs.push(
- <li className="row setting-list-item form-group">
+ <li className="setting-list-item">
<div className="btn-group" data-toggle="buttons-radio">
{ theme_buttons }
</div>
diff --git a/web/react/components/user_settings_modal.jsx b/web/react/components/user_settings_modal.jsx
new file mode 100644
index 000000000..1761e575a
--- /dev/null
+++ b/web/react/components/user_settings_modal.jsx
@@ -0,0 +1,68 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var SettingsSidebar = require('./settings_sidebar.jsx');
+var UserSettings = require('./user_settings.jsx');
+
+module.exports = React.createClass({
+ componentDidMount: function() {
+ $('body').on('click', '.modal-back', function(){
+ $(this).closest('.modal-dialog').removeClass('display--content');
+ });
+ $('body').on('click', '.modal-header .close', function(){
+ setTimeout(function() {
+ $('.modal-dialog.display--content').removeClass('display--content');
+ }, 500);
+ });
+ },
+ updateTab: function(tab) {
+ this.setState({ active_tab: tab });
+ },
+ updateSection: function(section) {
+ this.setState({ active_section: section });
+ },
+ getInitialState: function() {
+ return { active_tab: "general", active_section: "" };
+ },
+ render: function() {
+ var tabs = [];
+ tabs.push({name: "general", ui_name: "General", icon: "glyphicon glyphicon-cog"});
+ tabs.push({name: "security", ui_name: "Security", icon: "glyphicon glyphicon-lock"});
+ tabs.push({name: "notifications", ui_name: "Notifications", icon: "glyphicon glyphicon-exclamation-sign"});
+ tabs.push({name: "appearance", ui_name: "Appearance", icon: "glyphicon glyphicon-wrench"});
+ //tabs.push({name: "sessions", ui_name: "Sessions", icon: "glyphicon glyphicon-globe"});
+ //tabs.push({name: "activity_log", ui_name: "Activity Log", icon: "glyphicon glyphicon-time"});
+
+ return (
+ <div className="modal fade" ref="modal" id="user_settings1" role="dialog" aria-hidden="true">
+ <div className="modal-dialog settings-modal">
+ <div className="modal-content">
+ <div className="modal-header">
+ <button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+ <h4 className="modal-title" ref="title">Account Settings</h4>
+ </div>
+ <div className="modal-body">
+ <div className="settings-table">
+ <div className="settings-links">
+ <SettingsSidebar
+ tabs={tabs}
+ activeTab={this.state.active_tab}
+ updateTab={this.updateTab}
+ />
+ </div>
+ <div className="settings-content minimize-settings">
+ <UserSettings
+ activeTab={this.state.active_tab}
+ activeSection={this.state.active_section}
+ updateSection={this.updateSection}
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+});
+
diff --git a/web/react/components/view_image.jsx b/web/react/components/view_image.jsx
index 4cb30e1d3..c573e9dbb 100644
--- a/web/react/components/view_image.jsx
+++ b/web/react/components/view_image.jsx
@@ -124,7 +124,7 @@ module.exports = React.createClass({
<div key={name+"_loading"}>
<img ref="placeholder" className="loader-image" src="/static/images/load.gif" />
{ percentage > 0 ?
- <span className="loader-percent" >{"Downloading " + percentage + "%"}</span>
+ <span className="loader-percent" >{"Previewing " + percentage + "%"}</span>
: ""}
</div>
);
diff --git a/web/react/pages/channel.jsx b/web/react/pages/channel.jsx
index df67d4360..3aa985863 100644
--- a/web/react/pages/channel.jsx
+++ b/web/react/pages/channel.jsx
@@ -22,7 +22,8 @@ var MoreChannelsModal = require('../components/more_channels.jsx');
var NewChannelModal = require('../components/new_channel.jsx');
var PostDeletedModal = require('../components/post_deleted_modal.jsx');
var ChannelNotificationsModal = require('../components/channel_notifications.jsx');
-var UserSettingsModal = require('../components/settings_modal.jsx');
+var UserSettingsModal = require('../components/user_settings_modal.jsx');
+var TeamSettingsModal = require('../components/team_settings_modal.jsx');
var ChannelMembersModal = require('../components/channel_members.jsx');
var ChannelInviteModal = require('../components/channel_invite_modal.jsx');
var TeamMembersModal = require('../components/team_members.jsx');
@@ -36,7 +37,7 @@ var ChannelInfoModal = require('../components/channel_info_modal.jsx');
var Constants = require('../utils/constants.jsx');
var ActionTypes = Constants.ActionTypes;
-global.window.setup_channel_page = function(team_name, team_type, channel_name, channel_id) {
+global.window.setup_channel_page = function(team_name, team_type, team_id, channel_name, channel_id) {
AppDispatcher.handleViewAction({
type: ActionTypes.CLICK_CHANNEL,
@@ -44,6 +45,11 @@ global.window.setup_channel_page = function(team_name, team_type, channel_name,
id: channel_id
});
+ AppDispatcher.handleViewAction({
+ type: ActionTypes.CLICK_TEAM,
+ id: team_id
+ });
+
React.render(
<ErrorBar/>,
document.getElementById('error_bar')
@@ -80,6 +86,11 @@ global.window.setup_channel_page = function(team_name, team_type, channel_name,
);
React.render(
+ <TeamSettingsModal />,
+ document.getElementById('team_settings_modal')
+ );
+
+ React.render(
<TeamMembersModal teamName={team_name} />,
document.getElementById('team_members_modal')
);
diff --git a/web/react/stores/team_store.jsx b/web/react/stores/team_store.jsx
new file mode 100644
index 000000000..e95daeeba
--- /dev/null
+++ b/web/react/stores/team_store.jsx
@@ -0,0 +1,100 @@
+// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+var EventEmitter = require('events').EventEmitter;
+var assign = require('object-assign');
+
+var Constants = require('../utils/constants.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+
+var CHANGE_EVENT = 'change';
+
+var TeamStore = assign({}, EventEmitter.prototype, {
+ emitChange: function() {
+ this.emit(CHANGE_EVENT);
+ },
+ addChangeListener: function(callback) {
+ this.on(CHANGE_EVENT, callback);
+ },
+ removeChangeListener: function(callback) {
+ this.removeListener(CHANGE_EVENT, callback);
+ },
+ get: function(id) {
+ var c = this._getTeams();
+ return c[id];
+ },
+ getByName: function(name) {
+ var current = null;
+ var t = this._getTeams();
+
+ for (id in t) {
+ if (t[id].name == name) {
+ return t[id];
+ }
+ }
+
+ return null;
+ },
+ getAll: function() {
+ return this._getTeams();
+ },
+ setCurrentId: function(id) {
+ if (id == null)
+ sessionStorage.removeItem("current_team_id");
+ else
+ sessionStorage.setItem("current_team_id", id);
+ },
+ getCurrentId: function() {
+ return sessionStorage.getItem("current_team_id");
+ },
+ getCurrent: function() {
+ var currentId = TeamStore.getCurrentId();
+
+ if (currentId != null)
+ return this.get(currentId);
+ else
+ return null;
+ },
+ storeTeam: function(team) {
+ var teams = this._getTeams();
+ teams[team.id] = team;
+ this._storeTeams(teams);
+ },
+ _storeTeams: function(teams) {
+ sessionStorage.setItem("user_teams", JSON.stringify(teams));
+ },
+ _getTeams: function() {
+ var teams = {};
+
+ try {
+ teams = JSON.parse(sessionStorage.user_teams);
+ }
+ catch (err) {
+ }
+
+ return teams;
+ }
+});
+
+TeamStore.dispatchToken = AppDispatcher.register(function(payload) {
+ var action = payload.action;
+
+ switch(action.type) {
+
+ case ActionTypes.CLICK_TEAM:
+ TeamStore.setCurrentId(action.id);
+ TeamStore.emitChange();
+ break;
+
+ case ActionTypes.RECIEVED_TEAM:
+ TeamStore.storeTeam(action.team);
+ TeamStore.emitChange();
+ break;
+
+ default:
+ }
+});
+
+module.exports = TeamStore;
diff --git a/web/react/stores/user_store.jsx b/web/react/stores/user_store.jsx
index bbca92c84..e1df4879f 100644
--- a/web/react/stores/user_store.jsx
+++ b/web/react/stores/user_store.jsx
@@ -240,6 +240,9 @@ var UserStore = assign({}, EventEmitter.prototype, {
if (first.length > 0) keys.push(first);
}
+ if (user.notify_props.all === "true") keys.push('@all');
+ if (user.notify_props.channel === "true") keys.push('@channel');
+
return keys;
} else {
return [];
diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx
index bb7ca458f..9383057c3 100644
--- a/web/react/utils/async_client.jsx
+++ b/web/react/utils/async_client.jsx
@@ -15,6 +15,8 @@ var ActionTypes = Constants.ActionTypes;
var callTracker = {};
var dispatchError = function(err, method) {
+ if (err.message === "There appears to be a problem with your internet connection") return;
+
AppDispatcher.handleServerAction({
type: ActionTypes.RECIEVED_ERROR,
err: err,
@@ -355,3 +357,25 @@ module.exports.getStatuses = function() {
}
);
}
+
+module.exports.getMyTeam = function() {
+ if (isCallInProgress("getMyTeam")) return;
+
+ callTracker["getMyTeam"] = utils.getTimestamp();
+ client.getMyTeam(
+ function(data, textStatus, xhr) {
+ callTracker["getMyTeam"] = 0;
+
+ if (xhr.status === 304 || !data) return;
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_TEAM,
+ team: data
+ });
+ },
+ function(err) {
+ callTracker["getMyTeam"] = 0;
+ dispatchError(err, "getMyTeam");
+ }
+ );
+}
diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx
index 786e6dcea..15b6ace91 100644
--- a/web/react/utils/client.jsx
+++ b/web/react/utils/client.jsx
@@ -811,3 +811,34 @@ module.exports.getStatuses = function(success, error) {
}
});
};
+
+module.exports.getMyTeam = function(success, error) {
+ $.ajax({
+ url: "/api/v1/teams/me",
+ dataType: 'json',
+ type: 'GET',
+ success: success,
+ ifModified: true,
+ error: function(xhr, status, err) {
+ e = handleError("getMyTeam", xhr, status, err);
+ error(e);
+ }
+ });
+};
+
+module.exports.updateValetFeature = function(data, success, error) {
+ $.ajax({
+ url: "/api/v1/teams/update_valet_feature",
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(data),
+ success: success,
+ error: function(xhr, status, err) {
+ e = handleError("updateValetFeature", xhr, status, err);
+ error(e);
+ }
+ });
+
+ module.exports.track('api', 'api_teams_update_valet_feature');
+};
diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx
index deb07409b..3aadfb4b0 100644
--- a/web/react/utils/constants.jsx
+++ b/web/react/utils/constants.jsx
@@ -27,12 +27,16 @@ module.exports = {
RECIEVED_STATUSES: null,
RECIEVED_MSG: null,
+
+ CLICK_TEAM: null,
+ RECIEVED_TEAM: null,
}),
PayloadSources: keyMirror({
SERVER_ACTION: null,
VIEW_ACTION: null
}),
+ SPECIAL_MENTIONS: ['all', 'channel'],
CHARACTER_LIMIT: 4000,
IMAGE_TYPES: ['jpg', 'gif', 'bmp', 'png'],
AUDIO_TYPES: ['mp3', 'wav', 'wma', 'm4a', 'flac', 'aac'],
@@ -45,8 +49,10 @@ module.exports = {
PATCH_TYPES: ['patch'],
ICON_FROM_TYPE: {'audio': 'audio', 'video': 'video', 'spreadsheet': 'ppt', 'pdf': 'pdf', 'code': 'code' , 'word': 'word' , 'excel': 'excel' , 'patch': 'patch', 'other': 'generic'},
MAX_DISPLAY_FILES: 5,
+ MAX_UPLOAD_FILES: 5,
MAX_FILE_SIZE: 50000000, // 50 MB
DEFAULT_CHANNEL: 'town-square',
+ OFFTOPIC_CHANNEL: 'off-topic',
POST_CHUNK_SIZE: 60,
RESERVED_DOMAINS: [
"www",
diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx
index fb4f3a34e..70a47742f 100644
--- a/web/react/utils/utils.jsx
+++ b/web/react/utils/utils.jsx
@@ -96,6 +96,21 @@ module.exports.getCookie = function(name) {
if (parts.length == 2) return parts.pop().split(";").shift();
}
+module.exports.isLocalStorageSupported = function() {
+ try {
+ sessionStorage.setItem("testSession", '1');
+ sessionStorage.removeItem("testSession");
+
+ localStorage.setItem("testLocal", '1');
+ localStorage.removeItem("testLocal", '1');
+
+ return true;
+ }
+ catch (e) {
+ return false;
+ }
+}
+
module.exports.notifyMe = function(title, body, channel) {
if ("Notification" in window && Notification.permission !== 'denied') {
Notification.requestPermission(function (permission) {
@@ -380,14 +395,10 @@ module.exports.textToJsx = function(text, options) {
var inner = [];
- // Function specific regexes
- var hashRegex = /^href="#[^"]+"|(#[A-Za-z]+[A-Za-z0-9_]*[A-Za-z0-9])$/g;
+ // Function specific regex
+ var hashRegex = /^href="#[^"]+"|(#[A-Za-z]+[A-Za-z0-9_\-]*[A-Za-z0-9])$/g;
- var implicitKeywords = {};
- var keywordArray = UserStore.getCurrentMentionKeys();
- for (var i = 0; i < keywordArray.length; i++) {
- implicitKeywords[keywordArray[i]] = true;
- }
+ var implicitKeywords = UserStore.getCurrentMentionKeys();
var lines = text.split("\n");
var urlMatcher = new LinkifyIt();
@@ -406,10 +417,13 @@ module.exports.textToJsx = function(text, options) {
highlightSearchClass = " search-highlight";
}
- if (explicitMention && UserStore.getProfileByUsername(explicitMention[1])) {
+ if (explicitMention &&
+ (UserStore.getProfileByUsername(explicitMention[1]) ||
+ Constants.SPECIAL_MENTIONS.indexOf(explicitMention[1]) !== -1))
+ {
var name = explicitMention[1];
// do both a non-case sensitive and case senstive check
- var mClass = (name.toLowerCase() in implicitKeywords || name in implicitKeywords) ? mentionClass : "";
+ var mClass = implicitKeywords.indexOf('@'+name.toLowerCase()) !== -1 || implicitKeywords.indexOf('@'+name) !== -1 ? mentionClass : "";
var suffix = word.match(puncEndRegex);
var prefix = word.match(puncStartRegex);
@@ -418,7 +432,7 @@ module.exports.textToJsx = function(text, options) {
highlightSearchClass = " search-highlight";
}
- inner.push(<span key={name+i+z+"_span"}>{prefix}<a className={mClass + highlightSearchClass + " mention-link"} key={name+i+z+"_link"} href="#" onClick={function() {module.exports.searchForTerm(name);}}>@{name}</a>{suffix} </span>);
+ inner.push(<span key={name+i+z+"_span"}>{prefix}<a className={mClass + highlightSearchClass + " mention-link"} key={name+i+z+"_link"} href="#" onClick={function(value) { return function() { module.exports.searchForTerm(value); } }(name)}>@{name}</a>{suffix} </span>);
} else if (urlMatcher.test(word)) {
var match = urlMatcher.match(word)[0];
var link = match.url;
@@ -431,7 +445,7 @@ module.exports.textToJsx = function(text, options) {
} else if (trimWord.match(hashRegex)) {
var suffix = word.match(puncEndRegex);
var prefix = word.match(puncStartRegex);
- var mClass = trimWord in implicitKeywords ? mentionClass : "";
+ var mClass = implicitKeywords.indexOf(trimWord) !== -1 || implicitKeywords.indexOf(trimWord.toLowerCase()) !== -1 ? mentionClass : "";
if (searchTerm === trimWord.substring(1).toLowerCase() || searchTerm === trimWord.toLowerCase()) {
highlightSearchClass = " search-highlight";
@@ -439,7 +453,7 @@ module.exports.textToJsx = function(text, options) {
inner.push(<span key={word+i+z+"_span"}>{prefix}<a key={word+i+z+"_hash"} className={"theme " + mClass + highlightSearchClass} href="#" onClick={function(value) { return function() { module.exports.searchForTerm(value); } }(trimWord)}>{trimWord}</a>{suffix} </span>);
- } else if (trimWord in implicitKeywords) {
+ } else if (implicitKeywords.indexOf(trimWord) !== -1 || implicitKeywords.indexOf(trimWord.toLowerCase()) !== -1) {
var suffix = word.match(puncEndRegex);
var prefix = word.match(puncStartRegex);
diff --git a/web/sass-files/sass/partials/_mentions.scss b/web/sass-files/sass/partials/_mentions.scss
index 11cd4e9e4..ee254b546 100644
--- a/web/sass-files/sass/partials/_mentions.scss
+++ b/web/sass-files/sass/partials/_mentions.scss
@@ -3,21 +3,19 @@
background: $primary-color;
position: relative;
z-index: 10;
- padding-bottom: 1px;
+ padding-bottom: 2px;
@include border-radius(3px);
- -moz-box-sizing: border-box;
- -webkit-box-sizing: border-box;
- box-sizing: border-box;
}
.mentions--top {
position: absolute;
- z-index:99999;
+ z-index: 1040;
.mentions-box {
position:absolute;
background-color:#fff;
- border:1px solid #ddd;
- overflow:scroll;
+ border: $border-gray;
+ overflow-x: hidden;
+ overflow-y: scroll;
bottom:0;
}
}
@@ -29,10 +27,10 @@
height:37px;
padding:2px;
z-index:101;
-}
-
-.mentions-name:hover {
- background-color:#e8eaed;
+ cursor: pointer;
+ &:hover {
+ background-color:#e8eaed;
+ }
}
.mentions-text {
@@ -46,6 +44,11 @@
border-radius: 10%;
}
+.mention-fullname {
+ color: grey;
+ padding-left: 10px;
+}
+
.mention-highlight {
background-color:#fff2bb;
}
@@ -53,3 +56,8 @@
.mention-link {
color:$primary-color;
}
+
+.mention-align {
+ position:relative;
+ top:5px;
+}
diff --git a/web/sass-files/sass/partials/_modal.scss b/web/sass-files/sass/partials/_modal.scss
index 43dbdc077..4427cb7dd 100644
--- a/web/sass-files/sass/partials/_modal.scss
+++ b/web/sass-files/sass/partials/_modal.scss
@@ -1,9 +1,17 @@
+.modal-body {
+ padding: 20px 15px;
+}
.modal {
&.image_modal {
.modal-backdrop.in {
@include opacity(0.7);
}
}
+ .info__label {
+ font-weight: bold;
+ text-align: right;
+ padding-right: 0;
+ }
.remove__member {
float: right;
}
@@ -29,7 +37,7 @@
border-radius: 0;
background: $primary-color;
color: #FFF;
- padding: 15px 15px 11px;
+ padding: 15px 15px 11px;
border: none;
min-height: 56px;
@include clearfix;
@@ -41,11 +49,21 @@
margin: 0;
}
button.close {
- margin-top: 0;
+ margin: -2px -2px 0 0;
color: #fff;
@include opacity(1);
z-index: 5;
+ width: 30px;
+ height: 30px;
+ line-height: 30px;
+ @include single-transition(all, 0.25s, ease-in);
position: relative;
+ &:hover {
+ background: rgba(0, 0, 0, 0.1);
+ }
+ span {
+ line-height: 10px;
+ }
}
.btn {
margin-right: 10px;
@@ -129,15 +147,16 @@
width:100%;
margin: 0 auto;
.image-wrapper {
- padding: 4px;
background: #FFF;
position: relative;
max-width: 80%;
- min-height: 280px;
min-width: 280px;
- @include border-radius(4px);
+ @include border-radius(3px);
display: table;
margin: 0 auto;
+ &:hover {
+ @include border-radius(3px 3px 0 0);
+ }
&:hover .modal-close {
@include opacity(1);
}
@@ -217,10 +236,11 @@
}
.modal-button-bar {
position:absolute;
- bottom:0px;
+ bottom:-40px;
left:0px;
right:0px;
- background-color:rgba(0, 0, 0, 0.8);
+ background-color: #222;
+ @include border-radius(0 0 3px 3px);
@include opacity(0);
-webkit-transition: opacity 0.6s;
-moz-transition: opacity 0.6s;
@@ -228,7 +248,6 @@
transition: opacity 0.6s;
line-height: 40px;
padding: 0 10px;
- margin: 4px;
&.footer--show {
@include opacity(1);
}
diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss
index 6da516cf9..745d50173 100644
--- a/web/sass-files/sass/partials/_post.scss
+++ b/web/sass-files/sass/partials/_post.scss
@@ -42,49 +42,63 @@ body.ios {
}
#post-list {
- .new-seperator {
+ .date-separator, .new-separator {
text-align: center;
- padding: 1em 0;
- .new-seperator__hr {
- border-color: #FFAF53;
- margin: 0;
+ height: 2em;
+ margin: 0;
+ position: relative;
+ &:before, &:after {
+ content: "";
+ height: 1em;
+ position: absolute;
+ left: 0;
+ width: 100%;
+ display: none;
}
- .new-seperator__text {
- margin-top: -11px;
- color: #FF8800;
- background: #FFF;
- display: inline-block;
- padding: 0 1em;
- font-weight: normal;
+ &:before {
+ bottom: 0;
+ }
+ &:after {
+ top: 0;
}
- }
- .date-seperator {
- text-align: center;
- margin: 1em 0;
- height: 0;
&.hovered--after {
- margin-bottom: 0;
- padding-bottom: 1em;
- background: #f6f6f6;
+ &:before {
+ background: #f6f6f6;
+ display: block;
+ }
}
&.hovered--before {
- margin-top: 0;
- padding-top: 1em;
- background: #f6f6f6;
+ &:after {
+ background: #f6f6f6;
+ display: block;
+ }
}
- .date-seperator__hr {
+ .separator__hr {
border-color: #ccc;
margin: 0;
+ position: relative;
+ z-index: 5;
+ top: 1em;
}
- .date-seperator__text {
- margin-top: -13px;
- line-height: 24px;
+ .separator__text {
+ line-height: 2em;
color: #555;
background: #FFF;
display: inline-block;
padding: 0 1em;
font-weight: 900;
@include border-radius(50px);
+ position: relative;
+ z-index: 5;
+ }
+ }
+ .new-separator {
+ .separator__hr {
+ border-color: #FFAF53;
+ }
+ .separator__text {
+ color: #F80;
+ font-weight: normal;
}
}
.post-list-holder-by-time {
diff --git a/web/sass-files/sass/partials/_responsive.scss b/web/sass-files/sass/partials/_responsive.scss
index bed2f6324..25533c770 100644
--- a/web/sass-files/sass/partials/_responsive.scss
+++ b/web/sass-files/sass/partials/_responsive.scss
@@ -231,17 +231,14 @@
}
}
.modal {
+ .info__label {
+ text-align: left;
+ padding-bottom: 5px;
+ }
.modal-header {
- padding-left: 20px;
- padding-right: 20px;
.modal-action {
margin-top: 10px;
}
- button.close {
- width: 35px;
- height: 32px;
- margin: -5px -10px 0;
- }
.modal-title {
float: none;
}
@@ -261,9 +258,11 @@
.settings-table {
display: block;
.settings-content {
- display: block;
- .section-edit {
- text-align: left;
+ &.minimize-settings {
+ display: block;
+ .section-edit {
+ text-align: left;
+ }
}
}
.settings-links {
@@ -287,10 +286,12 @@
}
.settings-table {
.settings-content {
- padding: 0;
- display: none;
- .user-settings {
- padding: 70px 20px 30px;
+ &.minimize-settings {
+ padding: 0;
+ display: none;
+ .user-settings {
+ padding: 70px 20px 30px;
+ }
}
}
.settings-links {
@@ -538,7 +539,6 @@
.modal {
.modal-image {
.image-wrapper {
- padding-bottom: 40px;
.modal-close {
@include opacity(1);
}
diff --git a/web/static/config/config.js b/web/static/config/config.js
index 82d4bbf70..45c713da2 100644
--- a/web/static/config/config.js
+++ b/web/static/config/config.js
@@ -13,6 +13,7 @@ var config = {
// Feature switches
AllowPublicLink: true,
AllowInviteNames: true,
+ RequireInviteNames: false,
AllowSignupDomainsWizard: false,
// Privacy switches
diff --git a/web/templates/channel.html b/web/templates/channel.html
index d313b5395..d10ae2304 100644
--- a/web/templates/channel.html
+++ b/web/templates/channel.html
@@ -26,6 +26,7 @@
<div id="edit_mention_tab"></div>
<div id="get_link_modal"></div>
<div id="user_settings_modal"></div>
+ <div id="team_settings_modal"></div>
<div id="invite_member_modal"></div>
<div id="edit_channel_modal"></div>
<div id="delete_channel_modal"></div>
@@ -43,7 +44,7 @@
<div id="direct_channel_modal"></div>
<div id="channel_info_modal"></div>
<script>
-window.setup_channel_page('{{ .Props.TeamName }}', '{{ .Props.TeamType }}', '{{ .Props.ChannelName }}', '{{ .Props.ChannelId }}');
+window.setup_channel_page('{{ .Props.TeamName }}', '{{ .Props.TeamType }}', '{{ .Props.TeamId }}', '{{ .Props.ChannelName }}', '{{ .Props.ChannelId }}');
</script>
</body>
</html>
diff --git a/web/web.go b/web/web.go
index 3210ede1e..443a75916 100644
--- a/web/web.go
+++ b/web/web.go
@@ -285,7 +285,7 @@ func getChannel(c *api.Context, w http.ResponseWriter, r *http.Request) {
otherUserId = ids[0]
}
- if sc, err := api.CreateDirectChannel(c, otherUserId, r.URL.Path); err != nil {
+ if sc, err := api.CreateDirectChannel(c, otherUserId); err != nil {
api.Handle404(w, r)
return
} else {
@@ -319,6 +319,7 @@ func getChannel(c *api.Context, w http.ResponseWriter, r *http.Request) {
page.Title = name + " - " + team.Name + " " + page.SiteName
page.Props["TeamName"] = team.Name
page.Props["TeamType"] = team.Type
+ page.Props["TeamId"] = team.Id
page.Props["ChannelName"] = name
page.Props["ChannelId"] = channelId
page.Props["UserId"] = c.Session.UserId