diff options
43 files changed, 553 insertions, 202 deletions
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 aedbe07cc..810a8a07e 100644 --- a/api/command.go +++ b/api/command.go @@ -197,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/post.go b/api/post.go index 99cbdcb85..2d812e8de 100644 --- a/api/post.go +++ b/api/post.go @@ -273,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 @@ -306,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/team.go b/api/team.go index 775bc29ae..15e4e2c17 100644 --- a/api/team.go +++ b/api/team.go @@ -146,10 +146,8 @@ func createTeamFromSignup(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 } @@ -197,10 +195,8 @@ 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 } @@ -488,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 diff --git a/api/team_test.go b/api/team_test.go index 042c0a2e9..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") } 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 f8382cf2f..292d2b61b 100644 --- a/api/user.go +++ b/api/user.go @@ -176,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) @@ -198,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/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/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_store.go b/store/sql_store.go index a2deea6ba..bef8b4867 100644 --- a/store/sql_store.go +++ b/store/sql_store.go @@ -7,6 +7,9 @@ import ( l4g "code.google.com/p/log4go" "crypto/aes" "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "crypto/sha512" crand "crypto/rand" dbsql "database/sql" "encoding/base64" @@ -327,20 +330,26 @@ func encrypt(key []byte, text string) (string, error) { } plaintext := []byte(text) + skey := sha512.Sum512(key) + ekey, akey := skey[:32], skey[32:] - block, err := aes.NewCipher(key) + block, err := aes.NewCipher(ekey) if err != nil { return "", err } - ciphertext := make([]byte, aes.BlockSize+len(plaintext)) + macfn := hmac.New(sha256.New, akey) + ciphertext := make([]byte, aes.BlockSize+macfn.Size()+len(plaintext)) iv := ciphertext[:aes.BlockSize] if _, err := io.ReadFull(crand.Reader, iv); err != nil { return "", err } stream := cipher.NewCFBEncrypter(block, iv) - stream.XORKeyStream(ciphertext[aes.BlockSize:], plaintext) + stream.XORKeyStream(ciphertext[aes.BlockSize+macfn.Size():], plaintext) + macfn.Write(ciphertext[aes.BlockSize+macfn.Size():]) + mac := macfn.Sum(nil) + copy(ciphertext[aes.BlockSize:aes.BlockSize+macfn.Size()], mac) return base64.URLEncoding.EncodeToString(ciphertext), nil } @@ -351,9 +360,26 @@ func decrypt(key []byte, cryptoText string) (string, error) { return "{}", nil } - ciphertext, _ := base64.URLEncoding.DecodeString(cryptoText) + ciphertext, err := base64.URLEncoding.DecodeString(cryptoText) + if err != nil { + return "", err + } + + skey := sha512.Sum512(key) + ekey, akey := skey[:32], skey[32:] + macfn := hmac.New(sha256.New, akey) + if len(ciphertext) < aes.BlockSize+macfn.Size() { + return "", errors.New("short ciphertext") + } + + macfn.Write(ciphertext[aes.BlockSize+macfn.Size():]) + expectedMac := macfn.Sum(nil) + mac := ciphertext[aes.BlockSize:aes.BlockSize+macfn.Size()] + if hmac.Equal(expectedMac, mac) != true { + return "", errors.New("Incorrect MAC for the given ciphertext") + } - block, err := aes.NewCipher(key) + block, err := aes.NewCipher(ekey) if err != nil { return "", err } @@ -362,7 +388,7 @@ func decrypt(key []byte, cryptoText string) (string, error) { return "", errors.New("ciphertext too short") } iv := ciphertext[:aes.BlockSize] - ciphertext = ciphertext[aes.BlockSize:] + ciphertext = ciphertext[aes.BlockSize+macfn.Size():] stream := cipher.NewCFBDecrypter(block, iv) 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 ade58a10a..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" > @@ -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_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/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/login.jsx b/web/react/components/login.jsx index 85df5f797..3b6f96c2d 100644 --- a/web/react/components/login.jsx +++ b/web/react/components/login.jsx @@ -37,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), diff --git a/web/react/components/mention.jsx b/web/react/components/mention.jsx index 86a423138..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="mention-img" src={"/api/v1/users/" + this.props.id + "/image"}/> - <span>@{this.props.username}</span><span className="mention-fullname">{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/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 37e3faef2..e37de8d88 100644 --- a/web/react/components/post_list.jsx +++ b/web/react/components/post_list.jsx @@ -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,8 +383,18 @@ 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"> 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/sidebar_right_menu.jsx b/web/react/components/sidebar_right_menu.jsx index c523ce554..22d1d9ad2 100644 --- a/web/react/components/sidebar_right_menu.jsx +++ b/web/react/components/sidebar_right_menu.jsx @@ -60,6 +60,7 @@ module.exports = React.createClass({ <div className="nav-pills__container"> <ul className="nav nav-pills nav-stacked"> <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 30fe92af5..587d8cb82 100644 --- a/web/react/components/signup_team_complete.jsx +++ b/web/react/components/signup_team_complete.jsx @@ -164,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; } @@ -337,7 +339,7 @@ EmailItem = React.createClass({ return false; } else if (email === teamEmail) { - this.state.email_error = "Please use an a different email than the one used at signup"; + this.state.email_error = "Please use a different email than the one used at signup"; this.setState(this.state); return false; } diff --git a/web/react/components/team_settings.jsx b/web/react/components/team_settings.jsx index 0cec30f3e..a43e5d2f0 100644 --- a/web/react/components/team_settings.jsx +++ b/web/react/components/team_settings.jsx @@ -113,7 +113,7 @@ var FeatureTab = React.createClass({ <div> <div className="modal-header"> <button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button> - <h4 className="modal-title" ref="title"><i className="modal-back"></i>General Settings</h4> + <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> diff --git a/web/react/components/team_settings_modal.jsx b/web/react/components/team_settings_modal.jsx index 08a952d2e..e50378b7f 100644 --- a/web/react/components/team_settings_modal.jsx +++ b/web/react/components/team_settings_modal.jsx @@ -45,7 +45,7 @@ module.exports = React.createClass({ updateTab={this.updateTab} /> </div> - <div className="settings-content"> + <div className="settings-content minimize-settings"> <TeamSettings activeTab={this.state.active_tab} activeSection={this.state.active_section} 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_settings.jsx b/web/react/components/user_settings.jsx index 7d542a8b7..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}/> @@ -837,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}/> @@ -846,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}/> @@ -879,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}/> @@ -911,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}/> @@ -1048,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 index ff001611d..1761e575a 100644 --- a/web/react/components/user_settings_modal.jsx +++ b/web/react/components/user_settings_modal.jsx @@ -50,7 +50,7 @@ module.exports = React.createClass({ updateTab={this.updateTab} /> </div> - <div className="settings-content"> + <div className="settings-content minimize-settings"> <UserSettings activeTab={this.state.active_tab} activeSection={this.state.active_section} 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/constants.jsx b/web/react/utils/constants.jsx index e5f42c8a0..3aadfb4b0 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -36,6 +36,7 @@ module.exports = { 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'], @@ -51,6 +52,7 @@ module.exports = { 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 75c583c8f..70a47742f 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -395,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(); @@ -421,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); @@ -446,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 || trimWord.toLowerCase() 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"; @@ -454,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 || trimWord.toLowerCase() 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 86fe400c9..d6e2ab368 100644 --- a/web/sass-files/sass/partials/_mentions.scss +++ b/web/sass-files/sass/partials/_mentions.scss @@ -56,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/_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/web.go b/web/web.go index 7357124b5..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 { |