summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--api/channel.go37
-rw-r--r--api/channel_test.go40
-rw-r--r--app/channel.go58
-rw-r--r--app/email_batching.go2
-rw-r--r--app/notification.go38
-rw-r--r--app/team.go2
-rw-r--r--i18n/en.json28
-rw-r--r--model/authorization.go7
-rw-r--r--model/channel.go41
-rw-r--r--model/channel_test.go21
-rw-r--r--model/client.go10
-rw-r--r--model/websocket_message.go1
-rw-r--r--store/sql_channel_store.go2
-rw-r--r--webapp/actions/channel_actions.jsx63
-rw-r--r--webapp/actions/global_actions.jsx4
-rw-r--r--webapp/actions/post_actions.jsx10
-rw-r--r--webapp/actions/user_actions.jsx193
-rw-r--r--webapp/actions/websocket_actions.jsx4
-rw-r--r--webapp/client/client.jsx10
-rw-r--r--webapp/components/admin_console/multiselect_settings.jsx2
-rw-r--r--webapp/components/channel_header.jsx78
-rw-r--r--webapp/components/channel_switch_modal.jsx4
-rw-r--r--webapp/components/more_direct_channels.jsx308
-rw-r--r--webapp/components/multiselect/multiselect.jsx257
-rw-r--r--webapp/components/multiselect/multiselect_list.jsx169
-rw-r--r--webapp/components/navbar.jsx69
-rw-r--r--webapp/components/popover_list_members.jsx2
-rw-r--r--webapp/components/profile_popover.jsx2
-rw-r--r--webapp/components/sidebar.jsx77
-rw-r--r--webapp/components/status_icon.jsx7
-rw-r--r--webapp/components/suggestion/switch_channel_provider.jsx13
-rw-r--r--webapp/i18n/en.json8
-rw-r--r--webapp/package.json2
-rw-r--r--webapp/routes/route_team.jsx6
-rw-r--r--webapp/sass/components/_modal.scss27
-rw-r--r--webapp/sass/components/_module.scss1
-rw-r--r--webapp/sass/components/_multi-select.scss59
-rw-r--r--webapp/sass/components/_status-icon.scss22
-rw-r--r--webapp/sass/layout/_headers.scss12
-rw-r--r--webapp/sass/responsive/_mobile.scss17
-rw-r--r--webapp/stores/modal_store.jsx1
-rw-r--r--webapp/stores/notification_store.jsx3
-rw-r--r--webapp/stores/user_store.jsx7
-rw-r--r--webapp/tests/client_channel.test.jsx31
-rw-r--r--webapp/utils/channel_intro_messages.jsx63
-rw-r--r--webapp/utils/channel_utils.jsx54
-rw-r--r--webapp/utils/constants.jsx5
-rw-r--r--webapp/utils/utils.jsx4
48 files changed, 1592 insertions, 289 deletions
diff --git a/api/channel.go b/api/channel.go
index 6eda72dd9..2604cc5ce 100644
--- a/api/channel.go
+++ b/api/channel.go
@@ -25,6 +25,7 @@ func InitChannel() {
BaseRoutes.Channels.Handle("/create", ApiUserRequired(createChannel)).Methods("POST")
BaseRoutes.Channels.Handle("/view", ApiUserRequired(viewChannel)).Methods("POST")
BaseRoutes.Channels.Handle("/create_direct", ApiUserRequired(createDirectChannel)).Methods("POST")
+ BaseRoutes.Channels.Handle("/create_group", ApiUserRequired(createGroupChannel)).Methods("POST")
BaseRoutes.Channels.Handle("/update", ApiUserRequired(updateChannel)).Methods("POST")
BaseRoutes.Channels.Handle("/update_header", ApiUserRequired(updateChannelHeader)).Methods("POST")
BaseRoutes.Channels.Handle("/update_purpose", ApiUserRequired(updateChannelPurpose)).Methods("POST")
@@ -98,6 +99,38 @@ func createDirectChannel(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
+func createGroupChannel(c *Context, w http.ResponseWriter, r *http.Request) {
+ if !app.SessionHasPermissionTo(c.Session, model.PERMISSION_CREATE_GROUP_CHANNEL) {
+ c.SetPermissionError(model.PERMISSION_CREATE_GROUP_CHANNEL)
+ return
+ }
+
+ userIds := model.ArrayFromJson(r.Body)
+ if len(userIds) == 0 {
+ c.SetInvalidParam("createGroupChannel", "user_ids")
+ return
+ }
+
+ found := false
+ for _, id := range userIds {
+ if id == c.Session.UserId {
+ found = true
+ break
+ }
+ }
+
+ if !found {
+ userIds = append(userIds, c.Session.UserId)
+ }
+
+ if sc, err := app.CreateGroupChannel(userIds); err != nil {
+ c.Err = err
+ return
+ } else {
+ w.Write([]byte(sc.ToJson()))
+ }
+}
+
func CanManageChannel(c *Context, channel *model.Channel) bool {
if channel.Type == model.CHANNEL_OPEN && !app.SessionHasPermissionToChannel(c.Session, channel.Id, model.PERMISSION_MANAGE_PUBLIC_CHANNEL_PROPERTIES) {
c.SetPermissionError(model.PERMISSION_MANAGE_PUBLIC_CHANNEL_PROPERTIES)
@@ -457,7 +490,7 @@ func getChannel(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
- if channel.TeamId != c.TeamId && channel.Type != model.CHANNEL_DIRECT {
+ if channel.TeamId != c.TeamId && !channel.IsGroupOrDirect() {
c.Err = model.NewLocAppError("getChannel", "api.channel.get_channel.wrong_team.app_error", map[string]interface{}{"ChannelId": id, "TeamId": c.TeamId}, "")
return
}
@@ -493,7 +526,7 @@ func getChannelByName(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
- if channel.TeamId != c.TeamId && channel.Type != model.CHANNEL_DIRECT {
+ if channel.TeamId != c.TeamId && !channel.IsGroupOrDirect() {
c.Err = model.NewLocAppError("getChannel", "api.channel.get_channel.wrong_team.app_error", map[string]interface{}{"ChannelName": channelName, "TeamId": c.TeamId}, "")
return
}
diff --git a/api/channel_test.go b/api/channel_test.go
index 2e6484dfa..d8da3374b 100644
--- a/api/channel_test.go
+++ b/api/channel_test.go
@@ -219,6 +219,46 @@ func TestCreateDirectChannel(t *testing.T) {
}
}
+func TestCreateGroupChannel(t *testing.T) {
+ th := Setup().InitBasic()
+ Client := th.BasicClient
+ user := th.BasicUser
+ user2 := th.BasicUser2
+ user3 := th.CreateUser(Client)
+
+ userIds := []string{user.Id, user2.Id, user3.Id}
+
+ var channel *model.Channel
+ if result, err := Client.CreateGroupChannel(userIds); err != nil {
+ t.Fatal(err)
+ } else {
+ channel = result.Data.(*model.Channel)
+ }
+
+ if channel.Type != model.CHANNEL_GROUP {
+ t.Fatal("channel type was not group")
+ }
+
+ // Don't fail on group channels already existing and return the original channel again
+ if result, err := Client.CreateGroupChannel(userIds); err != nil {
+ t.Fatal(err)
+ } else if result.Data.(*model.Channel).Id != channel.Id {
+ t.Fatal("didn't return original group channel when saving a duplicate")
+ }
+
+ if _, err := Client.CreateGroupChannel([]string{user.Id}); err == nil {
+ t.Fatal("should have failed with not enough users")
+ }
+
+ if _, err := Client.CreateGroupChannel([]string{}); err == nil {
+ t.Fatal("should have failed with not enough users")
+ }
+
+ if _, err := Client.CreateGroupChannel([]string{user.Id, user2.Id, user3.Id, "junk"}); err == nil {
+ t.Fatal("should have failed with non-existent user")
+ }
+}
+
func TestUpdateChannel(t *testing.T) {
th := Setup().InitBasic().InitSystemAdmin()
Client := th.SystemAdminClient
diff --git a/app/channel.go b/app/channel.go
index 533a2f0bb..f037e64c3 100644
--- a/app/channel.go
+++ b/app/channel.go
@@ -77,7 +77,7 @@ func JoinDefaultChannels(teamId string, user *model.User, channelRole string) *m
}
func CreateChannelWithUser(channel *model.Channel, userId string) (*model.Channel, *model.AppError) {
- if channel.Type == model.CHANNEL_DIRECT {
+ if channel.IsGroupOrDirect() {
return nil, model.NewAppError("CreateChannelWithUser", "api.channel.create_channel.direct_channel.app_error", nil, "", http.StatusBadRequest)
}
@@ -197,6 +197,60 @@ func WaitForChannelMembership(channelId string, userId string) {
}
}
+func CreateGroupChannel(userIds []string) (*model.Channel, *model.AppError) {
+ if len(userIds) > model.CHANNEL_GROUP_MAX_USERS || len(userIds) < model.CHANNEL_GROUP_MIN_USERS {
+ return nil, model.NewAppError("CreateGroupChannel", "api.channel.create_group.bad_size.app_error", nil, "", http.StatusBadRequest)
+ }
+
+ var users []*model.User
+ if result := <-Srv.Store.User().GetProfileByIds(userIds, true); result.Err != nil {
+ return nil, result.Err
+ } else {
+ users = result.Data.([]*model.User)
+ }
+
+ if len(users) != len(userIds) {
+ return nil, model.NewAppError("CreateGroupChannel", "api.channel.create_group.bad_user.app_error", nil, "user_ids="+model.ArrayToJson(userIds), http.StatusBadRequest)
+ }
+
+ group := &model.Channel{
+ Name: model.GetGroupNameFromUserIds(userIds),
+ DisplayName: model.GetGroupDisplayNameFromUsers(users, true),
+ Type: model.CHANNEL_GROUP,
+ }
+
+ if result := <-Srv.Store.Channel().Save(group); result.Err != nil {
+ if result.Err.Id == store.CHANNEL_EXISTS_ERROR {
+ return result.Data.(*model.Channel), nil
+ } else {
+ return nil, result.Err
+ }
+ } else {
+ channel := result.Data.(*model.Channel)
+
+ for _, user := range users {
+ cm := &model.ChannelMember{
+ UserId: user.Id,
+ ChannelId: group.Id,
+ NotifyProps: model.GetDefaultChannelNotifyProps(),
+ Roles: model.ROLE_CHANNEL_USER.Id,
+ }
+
+ if result := <-Srv.Store.Channel().SaveMember(cm); result.Err != nil {
+ return nil, result.Err
+ }
+
+ InvalidateCacheForUser(user.Id)
+ }
+
+ message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_GROUP_ADDED, "", group.Id, "", nil)
+ message.Add("teammate_ids", model.ArrayToJson(userIds))
+ Publish(message)
+
+ return channel, nil
+ }
+}
+
func UpdateChannel(channel *model.Channel) (*model.Channel, *model.AppError) {
if result := <-Srv.Store.Channel().Update(channel); result.Err != nil {
return nil, result.Err
@@ -702,7 +756,7 @@ func LeaveChannel(channelId string, userId string) *model.AppError {
user := uresult.Data.(*model.User)
membersCount := ccmresult.Data.(int64)
- if channel.Type == model.CHANNEL_DIRECT {
+ if channel.IsGroupOrDirect() {
err := model.NewLocAppError("LeaveChannel", "api.channel.leave.direct.app_error", nil, "")
err.StatusCode = http.StatusBadRequest
return err
diff --git a/app/email_batching.go b/app/email_batching.go
index fc2fb1cea..055656f30 100644
--- a/app/email_batching.go
+++ b/app/email_batching.go
@@ -244,6 +244,8 @@ func renderBatchedPost(template *utils.HTMLTemplate, post *model.Post, teamName
return ""
} else if channel := result.Data.(*model.Channel); channel.Type == model.CHANNEL_DIRECT {
template.Props["ChannelName"] = translateFunc("api.email_batching.render_batched_post.direct_message")
+ } else if channel.Type == model.CHANNEL_GROUP {
+ template.Props["ChannelName"] = translateFunc("api.email_batching.render_batched_post.group_message")
} else {
template.Props["ChannelName"] = channel.DisplayName
}
diff --git a/app/notification.go b/app/notification.go
index e0c5d3d5b..4869560da 100644
--- a/app/notification.go
+++ b/app/notification.go
@@ -118,6 +118,7 @@ func SendNotifications(post *model.Post, team *model.Team, channel *model.Channe
}
senderName := make(map[string]string)
+ channelName := make(map[string]string)
for _, id := range mentionedUsersList {
senderName[id] = ""
if post.IsSystemMessage() {
@@ -135,6 +136,19 @@ func SendNotifications(post *model.Post, team *model.Team, channel *model.Channe
}
}
}
+
+ if channel.Type == model.CHANNEL_GROUP {
+ userList := []*model.User{}
+ for _, u := range profileMap {
+ if u.Id != sender.Id && u.Id != id {
+ userList = append(userList, u)
+ }
+ }
+ userList = append(userList, sender)
+ channelName[id] = model.GetGroupDisplayNameFromUsers(userList, false)
+ } else {
+ channelName[id] = channel.DisplayName
+ }
}
var senderUsername string
@@ -259,7 +273,7 @@ func SendNotifications(post *model.Post, team *model.Team, channel *model.Channe
}
if ShouldSendPushNotification(profileMap[id], channelMemberNotifyPropsMap[id], true, status, post) {
- sendPushNotification(post, profileMap[id], channel, senderName[id], true)
+ sendPushNotification(post, profileMap[id], channel, senderName[id], channelName[id], true)
}
}
@@ -272,7 +286,7 @@ func SendNotifications(post *model.Post, team *model.Team, channel *model.Channe
}
if ShouldSendPushNotification(profileMap[id], channelMemberNotifyPropsMap[id], false, status, post) {
- sendPushNotification(post, profileMap[id], channel, senderName[id], false)
+ sendPushNotification(post, profileMap[id], channel, senderName[id], channelName[id], false)
}
}
}
@@ -313,8 +327,8 @@ func SendNotifications(post *model.Post, team *model.Team, channel *model.Channe
}
func sendNotificationEmail(post *model.Post, user *model.User, channel *model.Channel, team *model.Team, senderName string, sender *model.User) *model.AppError {
- if channel.Type == model.CHANNEL_DIRECT && channel.TeamId != team.Id {
- // this message is a cross-team DM so it we need to find a team that the recipient is on to use in the link
+ if channel.IsGroupOrDirect() && channel.TeamId != team.Id {
+ // this message is a cross-team DM/GM so we need to find a team that the recipient is on to use in the link
if result := <-Srv.Store.Team().GetTeamsByUserId(user.Id); result.Err != nil {
return result.Err
} else {
@@ -381,6 +395,14 @@ func sendNotificationEmail(post *model.Post, user *model.User, channel *model.Ch
mailTemplate = "api.templates.post_subject_in_direct_message"
mailParameters = map[string]interface{}{"SubjectText": subjectText, "TeamDisplayName": team.DisplayName,
"SenderDisplayName": senderDisplayName, "Month": month, "Day": day, "Year": year}
+ } else if channel.Type == model.CHANNEL_GROUP {
+ bodyText = userLocale("api.post.send_notifications_and_forget.mention_body")
+
+ senderDisplayName := senderName
+
+ mailTemplate = "api.templates.post_subject_in_group_message"
+ mailParameters = map[string]interface{}{"SenderDisplayName": senderDisplayName, "Month": month, "Day": day, "Year": year}
+ channelName = userLocale("api.templates.channel_name.group")
} else {
bodyText = userLocale("api.post.send_notifications_and_forget.mention_body")
subjectText = userLocale("api.post.send_notifications_and_forget.mention_subject")
@@ -456,18 +478,14 @@ func GetMessageForNotification(post *model.Post, translateFunc i18n.TranslateFun
}
}
-func sendPushNotification(post *model.Post, user *model.User, channel *model.Channel, senderName string, wasMentioned bool) *model.AppError {
+func sendPushNotification(post *model.Post, user *model.User, channel *model.Channel, senderName, channelName string, wasMentioned bool) *model.AppError {
sessions, err := getMobileAppSessions(user.Id)
if err != nil {
return err
}
- var channelName string
-
if channel.Type == model.CHANNEL_DIRECT {
channelName = senderName
- } else {
- channelName = channel.DisplayName
}
userLocale := utils.GetUserTranslations(user.Locale)
@@ -495,7 +513,7 @@ func sendPushNotification(post *model.Post, user *model.User, channel *model.Cha
if channel.Type == model.CHANNEL_DIRECT {
msg.Category = model.CATEGORY_DM
msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_message")
- } else if wasMentioned {
+ } else if wasMentioned || channel.Type == model.CHANNEL_GROUP {
msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_mention") + channelName
} else {
msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_non_mention") + channelName
diff --git a/app/team.go b/app/team.go
index b1e0f1362..60a2f4220 100644
--- a/app/team.go
+++ b/app/team.go
@@ -438,7 +438,7 @@ func LeaveTeam(team *model.Team, user *model.User) *model.AppError {
}
for _, channel := range *channelList {
- if channel.Type != model.CHANNEL_DIRECT {
+ if !channel.IsGroupOrDirect() {
InvalidateCacheForChannelMembers(channel.Id)
if result := <-Srv.Store.Channel().RemoveMember(channel.Id, user.Id); result.Err != nil {
return result.Err
diff --git a/i18n/en.json b/i18n/en.json
index c372b6d0a..677a5c289 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -200,6 +200,14 @@
"translation": "Must use createDirectChannel API service for direct message channel creation"
},
{
+ "id": "api.channel.create_group.bad_size.app_error",
+ "translation": "Group message channels must contain at least 3 and no more than 8 users"
+ },
+ {
+ "id": "api.channel.create_group.bad_user.app_error",
+ "translation": "One of the provided users does not exist"
+ },
+ {
"id": "api.channel.create_channel.invalid_character.app_error",
"translation": "Invalid character '__' in channel name for non-direct channel"
},
@@ -888,6 +896,10 @@
"translation": "Direct Message"
},
{
+ "id": "api.email_batching.render_batched_post.group_message",
+ "translation": "Group Message"
+ },
+ {
"id": "api.email_batching.render_batched_post.go_to_post",
"translation": "Go to Post"
},
@@ -2152,6 +2164,14 @@
"translation": "{{.SubjectText}} in {{.TeamDisplayName}} from {{.SenderDisplayName}} on {{.Month}} {{.Day}}, {{.Year}}"
},
{
+ "id": "api.templates.channel_name.group",
+ "translation": "Group Message"
+ },
+ {
+ "id": "api.templates.post_subject_in_group_message",
+ "translation": "New Group Message from {{ .SenderDisplayName}} on {{.Month}} {{.Day}}, {{.Year}}"
+ },
+ {
"id": "api.templates.reset_body.button",
"translation": "Reset Password"
},
@@ -3060,6 +3080,14 @@
"translation": "Import data line has type \"post\" but the post object is null."
},
{
+ "id": "authentication.permissions.create_group_channel.description",
+ "translation": "Ability to create new group message channels"
+ },
+ {
+ "id": "authentication.permissions.create_group_channel.name",
+ "translation": "Create Group Message"
+ },
+ {
"id": "authentication.permissions.create_team_roles.description",
"translation": "Ability to create new teams"
},
diff --git a/model/authorization.go b/model/authorization.go
index ed38bf9b7..a7a6f374d 100644
--- a/model/authorization.go
+++ b/model/authorization.go
@@ -30,6 +30,7 @@ var PERMISSION_MANAGE_ROLES *Permission
var PERMISSION_MANAGE_TEAM_ROLES *Permission
var PERMISSION_MANAGE_CHANNEL_ROLES *Permission
var PERMISSION_CREATE_DIRECT_CHANNEL *Permission
+var PERMISSION_CREATE_GROUP_CHANNEL *Permission
var PERMISSION_MANAGE_PUBLIC_CHANNEL_PROPERTIES *Permission
var PERMISSION_MANAGE_PRIVATE_CHANNEL_PROPERTIES *Permission
var PERMISSION_LIST_TEAM_CHANNELS *Permission
@@ -149,6 +150,11 @@ func InitalizePermissions() {
"authentication.permissions.create_direct_channel.name",
"authentication.permissions.create_direct_channel.description",
}
+ PERMISSION_CREATE_GROUP_CHANNEL = &Permission{
+ "create_group_channel",
+ "authentication.permissions.create_group_channel.name",
+ "authentication.permissions.create_group_channel.description",
+ }
PERMISSION_MANAGE_PUBLIC_CHANNEL_PROPERTIES = &Permission{
"manage__publicchannel_properties",
"authentication.permissions.manage_public_channel_properties.name",
@@ -350,6 +356,7 @@ func InitalizeRoles() {
"authentication.roles.global_user.description",
[]string{
PERMISSION_CREATE_DIRECT_CHANNEL.Id,
+ PERMISSION_CREATE_GROUP_CHANNEL.Id,
PERMISSION_PERMANENT_DELETE_USER.Id,
PERMISSION_MANAGE_OAUTH.Id,
},
diff --git a/model/channel.go b/model/channel.go
index c89680194..d24fdb2b4 100644
--- a/model/channel.go
+++ b/model/channel.go
@@ -4,8 +4,12 @@
package model
import (
+ "crypto/sha1"
+ "encoding/hex"
"encoding/json"
"io"
+ "sort"
+ "strings"
"unicode/utf8"
)
@@ -13,6 +17,9 @@ const (
CHANNEL_OPEN = "O"
CHANNEL_PRIVATE = "P"
CHANNEL_DIRECT = "D"
+ CHANNEL_GROUP = "G"
+ CHANNEL_GROUP_MAX_USERS = 8
+ CHANNEL_GROUP_MIN_USERS = 3
DEFAULT_CHANNEL = "town-square"
CHANNEL_DISPLAY_NAME_MAX_RUNES = 64
CHANNEL_NAME_MIN_LENGTH = 2
@@ -89,7 +96,7 @@ func (o *Channel) IsValid() *AppError {
return NewLocAppError("Channel.IsValid", "model.channel.is_valid.2_or_more.app_error", nil, "id="+o.Id)
}
- if !(o.Type == CHANNEL_OPEN || o.Type == CHANNEL_PRIVATE || o.Type == CHANNEL_DIRECT) {
+ if !(o.Type == CHANNEL_OPEN || o.Type == CHANNEL_PRIVATE || o.Type == CHANNEL_DIRECT || o.Type == CHANNEL_GROUP) {
return NewLocAppError("Channel.IsValid", "model.channel.is_valid.type.app_error", nil, "id="+o.Id)
}
@@ -126,6 +133,10 @@ func (o *Channel) ExtraUpdated() {
o.ExtraUpdateAt = GetMillis()
}
+func (o *Channel) IsGroupOrDirect() bool {
+ return o.Type == CHANNEL_DIRECT || o.Type == CHANNEL_GROUP
+}
+
func GetDMNameFromIds(userId1, userId2 string) string {
if userId1 > userId2 {
return userId2 + "__" + userId1
@@ -133,3 +144,31 @@ func GetDMNameFromIds(userId1, userId2 string) string {
return userId1 + "__" + userId2
}
}
+
+func GetGroupDisplayNameFromUsers(users []*User, truncate bool) string {
+ usernames := make([]string, len(users))
+ for index, user := range users {
+ usernames[index] = user.Username
+ }
+
+ sort.Strings(usernames)
+
+ name := strings.Join(usernames, ", ")
+
+ if truncate && len(name) > CHANNEL_NAME_MAX_LENGTH {
+ name = name[:CHANNEL_NAME_MAX_LENGTH]
+ }
+
+ return name
+}
+
+func GetGroupNameFromUserIds(userIds []string) string {
+ sort.Strings(userIds)
+
+ h := sha1.New()
+ for _, id := range userIds {
+ io.WriteString(h, id)
+ }
+
+ return hex.EncodeToString(h.Sum(nil))
+}
diff --git a/model/channel_test.go b/model/channel_test.go
index af4e3c16d..deb36633c 100644
--- a/model/channel_test.go
+++ b/model/channel_test.go
@@ -104,3 +104,24 @@ func TestChannelPreUpdate(t *testing.T) {
o := Channel{Name: "test"}
o.PreUpdate()
}
+
+func TestGetGroupDisplayNameFromUsers(t *testing.T) {
+ users := make([]*User, 4)
+ users[0] = &User{Username: NewId()}
+ users[1] = &User{Username: NewId()}
+ users[2] = &User{Username: NewId()}
+ users[3] = &User{Username: NewId()}
+
+ name := GetGroupDisplayNameFromUsers(users, true)
+ if len(name) > CHANNEL_NAME_MAX_LENGTH {
+ t.Fatal("name too long")
+ }
+}
+
+func TestGetGroupNameFromUserIds(t *testing.T) {
+ name := GetGroupNameFromUserIds([]string{NewId(), NewId(), NewId(), NewId(), NewId()})
+
+ if len(name) > CHANNEL_NAME_MAX_LENGTH {
+ t.Fatal("name too long")
+ }
+}
diff --git a/model/client.go b/model/client.go
index 820386aa4..24ee2c2bf 100644
--- a/model/client.go
+++ b/model/client.go
@@ -1121,6 +1121,16 @@ func (c *Client) CreateDirectChannel(userId string) (*Result, *AppError) {
}
}
+func (c *Client) CreateGroupChannel(userIds []string) (*Result, *AppError) {
+ if r, err := c.DoApiPost(c.GetTeamRoute()+"/channels/create_group", ArrayToJson(userIds)); err != nil {
+ return nil, err
+ } else {
+ defer closeBody(r)
+ return &Result{r.Header.Get(HEADER_REQUEST_ID),
+ r.Header.Get(HEADER_ETAG_SERVER), ChannelFromJson(r.Body)}, nil
+ }
+}
+
func (c *Client) UpdateChannel(channel *Channel) (*Result, *AppError) {
if r, err := c.DoApiPost(c.GetTeamRoute()+"/channels/update", channel.ToJson()); err != nil {
return nil, err
diff --git a/model/websocket_message.go b/model/websocket_message.go
index 491998ebb..23820470b 100644
--- a/model/websocket_message.go
+++ b/model/websocket_message.go
@@ -16,6 +16,7 @@ const (
WEBSOCKET_EVENT_CHANNEL_DELETED = "channel_deleted"
WEBSOCKET_EVENT_CHANNEL_CREATED = "channel_created"
WEBSOCKET_EVENT_DIRECT_ADDED = "direct_added"
+ WEBSOCKET_EVENT_GROUP_ADDED = "group_added"
WEBSOCKET_EVENT_NEW_USER = "new_user"
WEBSOCKET_EVENT_LEAVE_TEAM = "leave_team"
WEBSOCKET_EVENT_UPDATE_TEAM = "update_team"
diff --git a/store/sql_channel_store.go b/store/sql_channel_store.go
index ea32317a3..ff1716957 100644
--- a/store/sql_channel_store.go
+++ b/store/sql_channel_store.go
@@ -211,7 +211,7 @@ func (s SqlChannelStore) saveChannelT(transaction *gorp.Transaction, channel *mo
return result
}
- if channel.Type != model.CHANNEL_DIRECT {
+ if channel.Type != model.CHANNEL_DIRECT && channel.Type != model.CHANNEL_GROUP {
if count, err := transaction.SelectInt("SELECT COUNT(0) FROM Channels WHERE TeamId = :TeamId AND DeleteAt = 0 AND (Type = 'O' OR Type = 'P')", map[string]interface{}{"TeamId": channel.TeamId}); err != nil {
result.Err = model.NewLocAppError("SqlChannelStore.Save", "store.sql_channel.save_channel.current_count.app_error", nil, "teamId="+channel.TeamId+", "+err.Error())
return result
diff --git a/webapp/actions/channel_actions.jsx b/webapp/actions/channel_actions.jsx
index 582de54cc..df7bacac6 100644
--- a/webapp/actions/channel_actions.jsx
+++ b/webapp/actions/channel_actions.jsx
@@ -9,7 +9,7 @@ import ChannelStore from 'stores/channel_store.jsx';
import * as ChannelUtils from 'utils/channel_utils.jsx';
import PreferenceStore from 'stores/preference_store.jsx';
-import {loadProfilesAndTeamMembersForDMSidebar} from 'actions/user_actions.jsx';
+import {loadProfilesForSidebar} from 'actions/user_actions.jsx';
import {trackEvent} from 'actions/diagnostics_actions.jsx';
import Client from 'client/web_client.jsx';
@@ -22,8 +22,12 @@ import {browserHistory} from 'react-router/es6';
export function goToChannel(channel) {
if (channel.fake) {
+ const user = UserStore.getProfileByUsername(channel.display_name);
+ if (!user) {
+ return;
+ }
openDirectChannelToUser(
- UserStore.getProfileByUsername(channel.display_name),
+ user.id,
() => {
browserHistory.push(TeamStore.getCurrentTeamRelativeUrl() + '/channels/' + channel.name);
},
@@ -167,18 +171,18 @@ export function makeUserChannelMember(channelId, userId, success, error) {
);
}
-export function openDirectChannelToUser(user, success, error) {
- const channelName = Utils.getDirectChannelName(UserStore.getCurrentId(), user.id);
+export function openDirectChannelToUser(userId, success, error) {
+ const channelName = Utils.getDirectChannelName(UserStore.getCurrentId(), userId);
const channel = ChannelStore.getByName(channelName);
if (channel) {
trackEvent('api', 'api_channels_join_direct');
- PreferenceStore.setPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, user.id, 'true');
- loadProfilesAndTeamMembersForDMSidebar();
+ PreferenceStore.setPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, userId, 'true');
+ loadProfilesForSidebar();
AsyncClient.savePreference(
Preferences.CATEGORY_DIRECT_CHANNEL_SHOW,
- user.id,
+ userId,
'true'
);
@@ -190,7 +194,7 @@ export function openDirectChannelToUser(user, success, error) {
}
Client.createDirectChannel(
- user.id,
+ userId,
(data) => {
Client.getChannel(
data.id,
@@ -201,12 +205,12 @@ export function openDirectChannelToUser(user, success, error) {
member: data2.member
});
- PreferenceStore.setPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, user.id, 'true');
- loadProfilesAndTeamMembersForDMSidebar();
+ PreferenceStore.setPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, userId, 'true');
+ loadProfilesForSidebar();
AsyncClient.savePreference(
Preferences.CATEGORY_DIRECT_CHANNEL_SHOW,
- user.id,
+ userId,
'true'
);
@@ -225,6 +229,43 @@ export function openDirectChannelToUser(user, success, error) {
);
}
+export function openGroupChannelToUsers(userIds, success, error) {
+ Client.createGroupChannel(
+ userIds,
+ (data) => {
+ Client.getChannelMember(
+ data.id,
+ UserStore.getCurrentId(),
+ (data2) => {
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECEIVED_CHANNEL,
+ channel: data,
+ member: data2
+ });
+
+ PreferenceStore.setPreference(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, data.id, 'true');
+ loadProfilesForSidebar();
+
+ AsyncClient.savePreference(
+ Preferences.CATEGORY_GROUP_CHANNEL_SHOW,
+ data.id,
+ 'true'
+ );
+
+ if (success) {
+ success(data);
+ }
+ }
+ );
+ },
+ () => {
+ if (error) {
+ error();
+ }
+ }
+ );
+}
+
export function markFavorite(channelId) {
trackEvent('api', 'api_channels_favorited');
AsyncClient.savePreference(Preferences.CATEGORY_FAVORITE_CHANNEL, channelId, 'true');
diff --git a/webapp/actions/global_actions.jsx b/webapp/actions/global_actions.jsx
index 37020f500..90805d057 100644
--- a/webapp/actions/global_actions.jsx
+++ b/webapp/actions/global_actions.jsx
@@ -13,7 +13,7 @@ import PreferenceStore from 'stores/preference_store.jsx';
import SearchStore from 'stores/search_store.jsx';
import {handleNewPost, loadPosts, loadPostsBefore, loadPostsAfter} from 'actions/post_actions.jsx';
-import {loadProfilesAndTeamMembersForDMSidebar} from 'actions/user_actions.jsx';
+import {loadProfilesForSidebar} from 'actions/user_actions.jsx';
import {loadChannelsForCurrentUser} from 'actions/channel_actions.jsx';
import {stopPeriodicStatusUpdates} from 'actions/status_actions.jsx';
import * as WebsocketActions from 'actions/websocket_actions.jsx';
@@ -387,7 +387,7 @@ export function emitPreferenceChangedEvent(preference) {
});
if (preference.category === Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW) {
- loadProfilesAndTeamMembersForDMSidebar();
+ loadProfilesForSidebar();
}
}
diff --git a/webapp/actions/post_actions.jsx b/webapp/actions/post_actions.jsx
index ad05a69db..cbcddfc7c 100644
--- a/webapp/actions/post_actions.jsx
+++ b/webapp/actions/post_actions.jsx
@@ -8,7 +8,7 @@ import PostStore from 'stores/post_store.jsx';
import UserStore from 'stores/user_store.jsx';
import {loadStatusesForChannel} from 'actions/status_actions.jsx';
-import {loadNewDMIfNeeded} from 'actions/user_actions.jsx';
+import {loadNewDMIfNeeded, loadNewGMIfNeeded} from 'actions/user_actions.jsx';
import {trackEvent} from 'actions/diagnostics_actions.jsx';
import Client from 'client/web_client.jsx';
@@ -24,8 +24,12 @@ export function handleNewPost(post, msg) {
websocketMessageProps = msg.data;
}
- if (msg && msg.data && msg.data.channel_type === Constants.DM_CHANNEL) {
- loadNewDMIfNeeded(post.user_id);
+ if (msg && msg.data) {
+ if (msg.data.channel_type === Constants.DM_CHANNEL) {
+ loadNewDMIfNeeded(post.user_id);
+ } else if (msg.data.channel_type === Constants.GM_CHANNEL) {
+ loadNewGMIfNeeded(post.channel_id, post.user_id);
+ }
}
if (post.root_id && PostStore.getPost(post.channel_id, post.root_id) == null) {
diff --git a/webapp/actions/user_actions.jsx b/webapp/actions/user_actions.jsx
index cf5241511..0f6ac3e9f 100644
--- a/webapp/actions/user_actions.jsx
+++ b/webapp/actions/user_actions.jsx
@@ -225,63 +225,150 @@ function populateDMChannelsWithProfiles(userIds) {
}
}
+function populateChannelWithProfiles(channelId, userIds) {
+ for (let i = 0; i < userIds.length; i++) {
+ UserStore.saveUserIdInChannel(channelId, userIds[i]);
+ }
+ UserStore.emitInChannelChange();
+}
+
export function loadNewDMIfNeeded(userId) {
if (userId === UserStore.getCurrentId()) {
return;
}
- const pref = PreferenceStore.get(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, userId, 'false');
- if (pref === 'false') {
+ const pref = PreferenceStore.getBool(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, userId, false);
+ if (pref === false) {
PreferenceStore.setPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, userId, 'true');
AsyncClient.savePreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, userId, 'true');
- loadProfilesAndTeamMembersForDMSidebar();
+ loadProfilesForDM();
}
}
-export function loadProfilesAndTeamMembersForDMSidebar() {
- const dmPrefs = PreferenceStore.getCategory(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW);
- const teamId = TeamStore.getCurrentId();
- const profilesToLoad = [];
- const membersToLoad = [];
+export function loadNewGMIfNeeded(channelId, userId) {
+ if (userId === UserStore.getCurrentId()) {
+ return;
+ }
- for (const [key, value] of dmPrefs) {
- if (value === 'true') {
- if (!UserStore.hasProfile(key)) {
- profilesToLoad.push(key);
- }
- membersToLoad.push(key);
+ function checkPreference() {
+ const pref = PreferenceStore.getBool(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channelId, false);
+ if (pref === false) {
+ PreferenceStore.setPreference(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channelId, 'true');
+ AsyncClient.savePreference(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channelId, 'true');
+ loadProfilesForGM();
}
}
- const channelMembers = ChannelStore.getMyMembers();
+ const channel = ChannelStore.get(channelId);
+ if (channel) {
+ checkPreference();
+ } else {
+ Client.getChannel(
+ channelId,
+ (data) => {
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECEIVED_CHANNEL,
+ channel: data.channel,
+ member: data.member
+ });
+
+ checkPreference();
+ },
+ (err) => {
+ AsyncClient.dispatchError(err, 'getChannel');
+ }
+ );
+ }
+}
+
+export function loadProfilesForSidebar() {
+ loadProfilesForDM();
+ loadProfilesForGM();
+}
+
+export function loadProfilesForGM() {
const channels = ChannelStore.getChannels();
const newPreferences = [];
+
for (let i = 0; i < channels.length; i++) {
const channel = channels[i];
- if (channel.type !== Constants.DM_CHANNEL) {
+ if (channel.type !== Constants.GM_CHANNEL) {
continue;
}
- const member = channelMembers[channel.id];
- if (!member) {
+ if (UserStore.getProfileListInChannel(channel.id).length >= Constants.MIN_USERS_IN_GM) {
continue;
}
- const teammateId = channel.name.replace(member.user_id, '').replace('__', '');
+ const isVisible = PreferenceStore.getBool(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channel.id);
+
+ if (!isVisible) {
+ const member = ChannelStore.getMyMember(channel.id);
+ if (!member || (member.mention_count === 0 && member.msg_count < member.total_msg_count)) {
+ continue;
+ }
+
+ newPreferences.push({
+ user_id: UserStore.getCurrentId(),
+ category: Preferences.CATEGORY_GROUP_CHANNEL_SHOW,
+ name: channel.id,
+ value: 'true'
+ });
+ }
+
+ Client.getProfilesInChannel(
+ channel.id,
+ 0,
+ Constants.MAX_USERS_IN_GM,
+ (data) => {
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECEIVED_PROFILES,
+ profiles: data
+ });
+
+ populateChannelWithProfiles(channel.id, Object.keys(data));
+ }
+ );
+ }
+
+ if (newPreferences.length > 0) {
+ AsyncClient.savePreferences(newPreferences);
+ }
+}
+
+export function loadProfilesForDM() {
+ const channels = ChannelStore.getChannels();
+ const newPreferences = [];
+ const profilesToLoad = [];
+ const profileIds = [];
+
+ for (let i = 0; i < channels.length; i++) {
+ const channel = channels[i];
+ if (channel.type !== Constants.DM_CHANNEL) {
+ continue;
+ }
+
+ const teammateId = channel.name.replace(UserStore.getCurrentId(), '').replace('__', '');
+ const isVisible = PreferenceStore.getBool(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, teammateId);
+
+ if (!isVisible) {
+ const member = ChannelStore.getMyMember(channel.id);
+ if (!member || member.mention_count === 0) {
+ continue;
+ }
- if (member.mention_count > 0 && membersToLoad.indexOf(teammateId) === -1) {
- membersToLoad.push(teammateId);
newPreferences.push({
user_id: UserStore.getCurrentId(),
category: Preferences.CATEGORY_DIRECT_CHANNEL_SHOW,
name: teammateId,
value: 'true'
});
+ }
- if (!UserStore.hasProfile(teammateId)) {
- profilesToLoad.push(teammateId);
- }
+ if (!UserStore.hasProfile(teammateId)) {
+ profilesToLoad.push(teammateId);
}
+ profileIds.push(teammateId);
}
if (newPreferences.length > 0) {
@@ -298,44 +385,14 @@ export function loadProfilesAndTeamMembersForDMSidebar() {
});
// Use membersToLoad so we get all the DM profiles even if they were already loaded
- populateDMChannelsWithProfiles(membersToLoad);
+ populateDMChannelsWithProfiles(profileIds);
},
(err) => {
AsyncClient.dispatchError(err, 'getProfilesByIds');
}
);
} else {
- populateDMChannelsWithProfiles(membersToLoad);
- }
-
- if (membersToLoad.length > 0) {
- Client.getTeamMembersByIds(
- teamId,
- membersToLoad,
- (data) => {
- const memberMap = {};
- for (let i = 0; i < data.length; i++) {
- memberMap[data[i].user_id] = data[i];
- }
-
- const nonMembersMap = {};
- for (let i = 0; i < membersToLoad.length; i++) {
- if (!memberMap[membersToLoad[i]]) {
- nonMembersMap[membersToLoad[i]] = true;
- }
- }
-
- AppDispatcher.handleServerAction({
- type: ActionTypes.RECEIVED_MEMBERS_IN_TEAM,
- team_id: teamId,
- team_members: memberMap,
- non_team_members: nonMembersMap
- });
- },
- (err) => {
- AsyncClient.dispatchError(err, 'getTeamMembersByIds');
- }
- );
+ populateDMChannelsWithProfiles(profileIds);
}
}
@@ -801,3 +858,27 @@ export function uploadProfileImage(userPicture, success, error) {
}
);
}
+
+export function loadProfiles(offset = UserStore.getPagingOffset(), limit = Constants.PROFILE_CHUNK_SIZE, success, error) {
+ Client.getProfiles(
+ offset,
+ limit,
+ (data) => {
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECEIVED_PROFILES,
+ profiles: data
+ });
+
+ if (success) {
+ success(data);
+ }
+ },
+ (err) => {
+ AsyncClient.dispatchError(err, 'getProfiles');
+
+ if (error) {
+ error(err);
+ }
+ }
+ );
+}
diff --git a/webapp/actions/websocket_actions.jsx b/webapp/actions/websocket_actions.jsx
index c181a5e3a..b442b9083 100644
--- a/webapp/actions/websocket_actions.jsx
+++ b/webapp/actions/websocket_actions.jsx
@@ -22,7 +22,7 @@ import {getSiteURL} from 'utils/url.jsx';
import * as GlobalActions from 'actions/global_actions.jsx';
import {handleNewPost, loadPosts, loadProfilesForPosts} from 'actions/post_actions.jsx';
-import {loadProfilesAndTeamMembersForDMSidebar} from 'actions/user_actions.jsx';
+import {loadProfilesForSidebar} from 'actions/user_actions.jsx';
import {loadChannelsForCurrentUser} from 'actions/channel_actions.jsx';
import * as StatusActions from 'actions/status_actions.jsx';
@@ -244,7 +244,7 @@ function handleUpdateTeamEvent(msg) {
function handleDirectAddedEvent(msg) {
AsyncClient.getChannel(msg.broadcast.channel_id);
PreferenceStore.setPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, msg.data.teammate_id, 'true');
- loadProfilesAndTeamMembersForDMSidebar();
+ loadProfilesForSidebar();
}
function handleUserAddedEvent(msg) {
diff --git a/webapp/client/client.jsx b/webapp/client/client.jsx
index 390c07d13..eaffd9ff4 100644
--- a/webapp/client/client.jsx
+++ b/webapp/client/client.jsx
@@ -1278,6 +1278,16 @@ export default class Client {
this.trackEvent('api', 'api_channels_create_direct', {team_id: this.getTeamId()});
}
+ createGroupChannel(userIds, success, error) {
+ request.
+ post(`${this.getChannelsRoute()}/create_group`).
+ set(this.defaultHeaders).
+ type('application/json').
+ accept('application/json').
+ send(userIds).
+ end(this.handleResponse.bind(this, 'createGroupChannel', success, error));
+ }
+
updateChannel(channel, success, error) {
request.
post(`${this.getChannelsRoute()}/update`).
diff --git a/webapp/components/admin_console/multiselect_settings.jsx b/webapp/components/admin_console/multiselect_settings.jsx
index 8aad5d6eb..2beebb337 100644
--- a/webapp/components/admin_console/multiselect_settings.jsx
+++ b/webapp/components/admin_console/multiselect_settings.jsx
@@ -76,4 +76,4 @@ MultiSelectSetting.propTypes = {
noResultText: React.PropTypes.node,
errorText: React.PropTypes.node,
notPresent: React.PropTypes.node
-}; \ No newline at end of file
+};
diff --git a/webapp/components/channel_header.jsx b/webapp/components/channel_header.jsx
index 341c9be1b..9be2d5b58 100644
--- a/webapp/components/channel_header.jsx
+++ b/webapp/components/channel_header.jsx
@@ -32,7 +32,9 @@ import {getSiteURL} from 'utils/url.jsx';
import * as TextFormatting from 'utils/text_formatting.jsx';
import {getFlaggedPosts} from 'actions/post_actions.jsx';
-import {Constants, Preferences, UserStatuses} from 'utils/constants.jsx';
+import AppDispatcher from 'dispatcher/app_dispatcher.jsx';
+
+import {Constants, Preferences, UserStatuses, ActionTypes} from 'utils/constants.jsx';
import React from 'react';
import {FormattedMessage} from 'react-intl';
@@ -53,6 +55,7 @@ export default class ChannelHeader extends React.Component {
this.getFlagged = this.getFlagged.bind(this);
this.initWebrtc = this.initWebrtc.bind(this);
this.onBusy = this.onBusy.bind(this);
+ this.openDirectMessageModal = this.openDirectMessageModal.bind(this);
const state = this.getStateFromStores();
state.showEditChannelPurposeModal = false;
@@ -198,6 +201,14 @@ export default class ChannelHeader extends React.Component {
this.setState({isBusy});
}
+ openDirectMessageModal() {
+ AppDispatcher.handleViewAction({
+ type: ActionTypes.TOGGLE_DM_MODAL,
+ value: true,
+ startingUsers: UserStore.getProfileListInChannel(this.props.channelId, true)
+ });
+ }
+
render() {
const flagIcon = Constants.FLAG_ICON_SVG;
@@ -246,7 +257,8 @@ export default class ChannelHeader extends React.Component {
const isAdmin = TeamStore.isTeamAdminForCurrentTeam() || UserStore.isSystemAdminForCurrentUser();
const isSystemAdmin = UserStore.isSystemAdminForCurrentUser();
const isChannelAdmin = ChannelStore.isChannelAdminForCurrentChannel();
- const isDirect = (this.state.channel.type === 'D');
+ const isDirect = (this.state.channel.type === Constants.DM_CHANNEL);
+ const isGroup = (this.state.channel.type === Constants.GM_CHANNEL);
let webrtc;
if (isDirect) {
@@ -319,6 +331,10 @@ export default class ChannelHeader extends React.Component {
}
}
+ if (isGroup) {
+ channelTitle = ChannelUtils.buildGroupChannelName(channel.id);
+ }
+
let channelTerm = (
<FormattedMessage
id='channel_header.channel'
@@ -364,6 +380,64 @@ export default class ChannelHeader extends React.Component {
</ToggleModalButton>
</li>
);
+ } else if (isGroup) {
+ dropdownContents.push(
+ <li
+ key='edit_header_direct'
+ role='presentation'
+ >
+ <ToggleModalButton
+ role='menuitem'
+ dialogType={EditChannelHeaderModal}
+ dialogProps={{channel}}
+ >
+ <FormattedMessage
+ id='channel_header.channelHeader'
+ defaultMessage='Edit Channel Header'
+ />
+ </ToggleModalButton>
+ </li>
+ );
+
+ dropdownContents.push(
+ <li
+ key='notification_preferences'
+ role='presentation'
+ >
+ <ToggleModalButton
+ role='menuitem'
+ dialogType={ChannelNotificationsModal}
+ dialogProps={{
+ channel,
+ channelMember: this.state.memberChannel,
+ currentUser: this.state.currentUser
+ }}
+ >
+ <FormattedMessage
+ id='channel_header.notificationPreferences'
+ defaultMessage='Notification Preferences'
+ />
+ </ToggleModalButton>
+ </li>
+ );
+
+ dropdownContents.push(
+ <li
+ key='add_members'
+ role='presentation'
+ >
+ <a
+ role='menuitem'
+ href='#'
+ onClick={this.openDirectMessageModal}
+ >
+ <FormattedMessage
+ id='channel_header.addMembers'
+ defaultMessage='Add Members'
+ />
+ </a>
+ </li>
+ );
} else {
dropdownContents.push(
<li
diff --git a/webapp/components/channel_switch_modal.jsx b/webapp/components/channel_switch_modal.jsx
index fc66e06b1..2b08ee239 100644
--- a/webapp/components/channel_switch_modal.jsx
+++ b/webapp/components/channel_switch_modal.jsx
@@ -105,7 +105,7 @@ export default class SwitchChannelModal extends React.Component {
if (user) {
openDirectChannelToUser(
- user,
+ user.id,
(ch) => {
channel = ch;
this.switchToChannel(channel);
@@ -117,7 +117,7 @@ export default class SwitchChannelModal extends React.Component {
);
}
} else {
- channel = ChannelStore.getByName(this.selected.name);
+ channel = ChannelStore.get(this.selected.id);
this.switchToChannel(channel);
}
}
diff --git a/webapp/components/more_direct_channels.jsx b/webapp/components/more_direct_channels.jsx
index 13ee50b4d..c4a3a3526 100644
--- a/webapp/components/more_direct_channels.jsx
+++ b/webapp/components/more_direct_channels.jsx
@@ -1,19 +1,19 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-import SearchableUserList from 'components/searchable_user_list.jsx';
-import SpinnerButton from 'components/spinner_button.jsx';
+import MultiSelect from 'components/multiselect/multiselect.jsx';
+import ProfilePicture from 'components/profile_picture.jsx';
import {searchUsers} from 'actions/user_actions.jsx';
-import {openDirectChannelToUser} from 'actions/channel_actions.jsx';
+import {openDirectChannelToUser, openGroupChannelToUsers} from 'actions/channel_actions.jsx';
import UserStore from 'stores/user_store.jsx';
import TeamStore from 'stores/team_store.jsx';
import * as AsyncClient from 'utils/async_client.jsx';
-import * as UserAgent from 'utils/user_agent.jsx';
-import {localizeMessage} from 'utils/utils.jsx';
import Constants from 'utils/constants.jsx';
+import {displayUsernameForUser} from 'utils/utils.jsx';
+import Client from 'client/web_client.jsx';
import React from 'react';
import {Modal} from 'react-bootstrap';
@@ -21,6 +21,7 @@ import {FormattedMessage} from 'react-intl';
import {browserHistory} from 'react-router/es6';
const USERS_PER_PAGE = 50;
+const MAX_SELECTABLE_VALUES = Constants.MAX_USERS_IN_GM - 1;
export default class MoreDirectChannels extends React.Component {
constructor(props) {
@@ -28,21 +29,31 @@ export default class MoreDirectChannels extends React.Component {
this.handleHide = this.handleHide.bind(this);
this.handleExit = this.handleExit.bind(this);
- this.handleShowDirectChannel = this.handleShowDirectChannel.bind(this);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ this.handleDelete = this.handleDelete.bind(this);
this.onChange = this.onChange.bind(this);
- this.createJoinDirectChannelButton = this.createJoinDirectChannelButton.bind(this);
- this.toggleList = this.toggleList.bind(this);
- this.nextPage = this.nextPage.bind(this);
this.search = this.search.bind(this);
+ this.addValue = this.addValue.bind(this);
this.searchTimeoutId = 0;
+ this.listType = global.window.mm_config.RestrictDirectMessage;
+
+ const values = [];
+ if (props.startingUsers) {
+ for (let i = 0; i < props.startingUsers.length; i++) {
+ const user = Object.assign({}, props.startingUsers[i]);
+ user.value = user.id;
+ user.label = '@' + user.username;
+ values.push(user);
+ }
+ }
this.state = {
users: null,
- loadingDMChannel: -1,
- listType: 'team',
+ values,
show: true,
- search: false
+ search: false,
+ loadingChannel: -1
};
}
@@ -50,17 +61,18 @@ export default class MoreDirectChannels extends React.Component {
UserStore.addChangeListener(this.onChange);
UserStore.addInTeamChangeListener(this.onChange);
UserStore.addStatusesChangeListener(this.onChange);
- TeamStore.addChangeListener(this.onChange);
- AsyncClient.getProfiles(0, Constants.PROFILE_CHUNK_SIZE);
- AsyncClient.getProfilesInTeam(TeamStore.getCurrentId(), 0, Constants.PROFILE_CHUNK_SIZE);
+ if (this.listType === 'any') {
+ AsyncClient.getProfiles(0, USERS_PER_PAGE * 2);
+ } else {
+ AsyncClient.getProfilesInTeam(TeamStore.getCurrentId(), 0, USERS_PER_PAGE * 2);
+ }
}
componentWillUnmount() {
UserStore.removeChangeListener(this.onChange);
UserStore.removeInTeamChangeListener(this.onChange);
UserStore.removeStatusesChangeListener(this.onChange);
- TeamStore.removeChangeListener(this.onChange);
}
handleHide() {
@@ -68,8 +80,8 @@ export default class MoreDirectChannels extends React.Component {
}
handleExit() {
- if (this.exitToDirectChannel) {
- browserHistory.push(this.exitToDirectChannel);
+ if (this.exitToChannel) {
+ browserHistory.push(this.exitToChannel);
}
if (this.props.onModalDismissed) {
@@ -77,28 +89,49 @@ export default class MoreDirectChannels extends React.Component {
}
}
- handleShowDirectChannel(teammate, e) {
- e.preventDefault();
+ handleSubmit(e) {
+ if (e) {
+ e.preventDefault();
+ }
- if (this.state.loadingDMChannel !== -1) {
+ if (this.state.loadingChannel !== -1) {
return;
}
- this.setState({loadingDMChannel: teammate.id});
- openDirectChannelToUser(
- teammate,
- (channel) => {
- // Due to how react-overlays Modal handles focus, we delay pushing
- // the new channel information until the modal is fully exited.
- // The channel information will be pushed in `handleExit`
- this.exitToDirectChannel = TeamStore.getCurrentTeamRelativeUrl() + '/channels/' + channel.name;
- this.setState({loadingDMChannel: -1});
- this.handleHide();
- },
- () => {
- this.setState({loadingDMChannel: -1});
- }
- );
+ const userIds = this.state.values.map((v) => v.id);
+ if (userIds.length === 0) {
+ return;
+ }
+
+ this.setState({loadingChannel: 1});
+
+ const success = (channel) => {
+ // Due to how react-overlays Modal handles focus, we delay pushing
+ // the new channel information until the modal is fully exited.
+ // The channel information will be pushed in `handleExit`
+ this.exitToChannel = TeamStore.getCurrentTeamRelativeUrl() + '/channels/' + channel.name;
+ this.setState({loadingChannel: -1});
+ this.handleHide();
+ };
+
+ const error = () => {
+ this.setState({loadingChannel: -1});
+ };
+
+ if (userIds.length === 1) {
+ openDirectChannelToUser(userIds[0], success, error);
+ } else {
+ openGroupChannelToUsers(userIds, success, error);
+ }
+ }
+
+ addValue(value) {
+ const values = Object.assign([], this.state.values);
+ if (values.indexOf(value) === -1) {
+ values.push(value);
+ }
+
+ this.setState({values});
}
onChange(force) {
@@ -107,83 +140,69 @@ export default class MoreDirectChannels extends React.Component {
}
let users;
- if (this.state.listType === 'any') {
- users = UserStore.getProfileList(true);
+ if (this.listType === 'any') {
+ users = Object.assign([], UserStore.getProfileList(true));
} else {
- users = UserStore.getProfileListInTeam(TeamStore.getCurrentId(), true, true);
+ users = Object.assign([], UserStore.getProfileListInTeam(TeamStore.getCurrentId(), true));
}
- this.setState({
- users
- });
- }
-
- toggleList(e) {
- const listType = e.target.value;
- let users;
- if (listType === 'any') {
- users = UserStore.getProfileList(true);
- } else {
- users = UserStore.getProfileListInTeam(TeamStore.getCurrentId(), true, true);
+ for (let i = 0; i < users.length; i++) {
+ const user = Object.assign({}, users[i]);
+ user.value = user.id;
+ user.label = '@' + user.username;
+ users[i] = user;
}
this.setState({
- users,
- listType
+ users
});
}
- createJoinDirectChannelButton({user}) {
- return (
- <SpinnerButton
- className='btn btm-sm btn-primary'
- spinning={this.state.loadingDMChannel === user.id}
- onClick={this.handleShowDirectChannel.bind(this, user)}
- >
- <FormattedMessage
- id='more_direct_channels.message'
- defaultMessage='Message'
- />
- </SpinnerButton>
- );
- }
-
- nextPage(page) {
- if (this.state.listType === 'any') {
+ handlePageChange(page, prevPage) {
+ if (page > prevPage) {
AsyncClient.getProfiles((page + 1) * USERS_PER_PAGE, USERS_PER_PAGE);
- } else {
- AsyncClient.getProfilesInTeam(TeamStore.getCurrentId(), (page + 1) * USERS_PER_PAGE, USERS_PER_PAGE);
}
}
search(term) {
+ clearTimeout(this.searchTimeoutId);
+
if (term === '') {
this.onChange(true);
this.setState({search: false});
+ this.searchTimeoutId = '';
return;
}
let teamId;
- if (this.state.listType === 'any') {
+ if (this.listType === 'any') {
teamId = '';
} else {
teamId = TeamStore.getCurrentId();
}
- clearTimeout(this.searchTimeoutId);
-
- this.searchTimeoutId = setTimeout(
+ const searchTimeoutId = setTimeout(
() => {
searchUsers(
term,
teamId,
{},
(users) => {
+ if (searchTimeoutId !== this.searchTimeoutId) {
+ return;
+ }
+
+ let indexToDelete = -1;
for (let i = 0; i < users.length; i++) {
if (users[i].id === UserStore.getCurrentId()) {
- users.splice(i, 1);
- break;
+ indexToDelete = i;
}
+ users[i].value = users[i].id;
+ users[i].label = '@' + users[i].username;
+ }
+
+ if (indexToDelete !== -1) {
+ users.splice(indexToDelete, 1);
}
this.setState({search: true, users});
}
@@ -191,44 +210,88 @@ export default class MoreDirectChannels extends React.Component {
},
Constants.SEARCH_TIMEOUT_MILLISECONDS
);
+
+ this.searchTimeoutId = searchTimeoutId;
}
- render() {
- let teamToggle;
- let memberClass = '';
- if (global.window.mm_config.RestrictDirectMessage === 'any') {
- memberClass = 'more-system-members';
- teamToggle = (
- <div className='member-select__container'>
- <select
- className='form-control'
- id='restrictList'
- ref='restrictList'
- defaultValue='team'
- onChange={this.toggleList}
- >
- <option value='any'>
- {localizeMessage('filtered_user_list.any_team', 'All Users')}
- </option>
- <option value='team'>
- {localizeMessage('filtered_user_list.team_only', 'Members of this Team')}
- </option>
- </select>
- <span
- className='member-show'
- >
- <FormattedMessage
- id='filtered_user_list.show'
- defaultMessage='Filter:'
- />
- </span>
+ handleDelete(values) {
+ this.setState({values});
+ }
+
+ renderOption(option, isSelected, onAdd) {
+ var rowSelected = '';
+ if (isSelected) {
+ rowSelected = 'more-modal__row--selected';
+ }
+
+ return (
+ <div
+ key={option.id}
+ ref={isSelected ? 'selected' : option.id}
+ className={'more-modal__row clickable ' + rowSelected}
+ onClick={() => onAdd(option)}
+ >
+ <ProfilePicture
+ src={`${Client.getUsersRoute()}/${option.id}/image?time=${option.last_picture_update}`}
+ width='32'
+ height='32'
+ />
+ <div
+ className='more-modal__details'
+ >
+ <div className='more-modal__name'>
+ {displayUsernameForUser(option)}
+ </div>
+ <div className='more-modal__description'>
+ {option.email}
+ </div>
</div>
- );
+ <div className='more-modal__actions'>
+ <div className='more-modal__actions--round'>
+ <i className='fa fa-plus'/>
+ </div>
+ </div>
+ </div>
+ );
+ }
+
+ renderValue(user) {
+ return user.username;
+ }
+
+ render() {
+ let note;
+ if (this.props.startingUsers) {
+ if (this.state.values && this.state.values.length >= MAX_SELECTABLE_VALUES) {
+ note = (
+ <FormattedMessage
+ id='more_direct_channels.new_convo_note.full'
+ defaultMessage='You’ve reached the maximum number of people for this conversation. Consider creating a private group instead.'
+ />
+ );
+ } else {
+ note = (
+ <FormattedMessage
+ id='more_direct_channels.new_convo_note'
+ defaultMessage='This will start a new conversation. If you’re adding a lot of people, consider creating a private group instead.'
+ />
+ );
+ }
}
+ const numRemainingText = (
+ <FormattedMessage
+ id='multiselect.numPeopleRemaining'
+ defaultMessage='You can add {num, number} more {num, plural, =0 {people} one {person} other {people}}. '
+ values={{
+ num: MAX_SELECTABLE_VALUES - this.state.values.length
+ }}
+ />
+ );
+
return (
<Modal
- dialogClassName={'more-modal more-direct-channels ' + memberClass}
+ dialogClassName={'more-modal more-direct-channels'}
show={this.state.show}
onHide={this.handleHide}
onExited={this.handleExit}
@@ -242,15 +305,21 @@ export default class MoreDirectChannels extends React.Component {
</Modal.Title>
</Modal.Header>
<Modal.Body>
- {teamToggle}
- <SearchableUserList
- key={'moreDirectChannelsList_' + this.state.listType}
- users={this.state.users}
- usersPerPage={USERS_PER_PAGE}
- nextPage={this.nextPage}
- search={this.search}
- actions={[this.createJoinDirectChannelButton]}
- focusOnMount={!UserAgent.isMobile()}
+ <MultiSelect
+ key='moreDirectChannelsList'
+ options={this.state.users}
+ optionRenderer={this.renderOption}
+ values={this.state.values}
+ valueRenderer={this.renderValue}
+ perPage={USERS_PER_PAGE}
+ handlePageChange={this.handlePageChange}
+ handleInput={this.search}
+ handleDelete={this.handleDelete}
+ handleAdd={this.addValue}
+ handleSubmit={this.handleSubmit}
+ noteText={note}
+ maxValues={MAX_SELECTABLE_VALUES}
+ numRemainingText={numRemainingText}
/>
</Modal.Body>
</Modal>
@@ -259,5 +328,6 @@ export default class MoreDirectChannels extends React.Component {
}
MoreDirectChannels.propTypes = {
+ startingUsers: React.PropTypes.arrayOf(React.PropTypes.object),
onModalDismissed: React.PropTypes.func
-}; \ No newline at end of file
+};
diff --git a/webapp/components/multiselect/multiselect.jsx b/webapp/components/multiselect/multiselect.jsx
new file mode 100644
index 000000000..a3e32dccf
--- /dev/null
+++ b/webapp/components/multiselect/multiselect.jsx
@@ -0,0 +1,257 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import MultiSelectList from './multiselect_list.jsx';
+
+import {localizeMessage} from 'utils/utils.jsx';
+import Constants from 'utils/constants.jsx';
+const KeyCodes = Constants.KeyCodes;
+
+import React from 'react';
+import ReactSelect from 'react-select';
+import {FormattedMessage} from 'react-intl';
+
+export default class MultiSelect extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.onChange = this.onChange.bind(this);
+ this.onSelect = this.onSelect.bind(this);
+ this.onAdd = this.onAdd.bind(this);
+ this.onInput = this.onInput.bind(this);
+ this.handleEnterPress = this.handleEnterPress.bind(this);
+ this.nextPage = this.nextPage.bind(this);
+ this.prevPage = this.prevPage.bind(this);
+
+ this.selected = null;
+
+ this.state = {
+ page: 0
+ };
+ }
+
+ componentDidMount() {
+ document.addEventListener('keydown', this.handleEnterPress);
+ this.refs.select.focus();
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('keydown', this.handleEnterPress);
+ }
+
+ nextPage() {
+ if (this.props.handlePageChange) {
+ this.props.handlePageChange(this.state.page + 1, this.state.page);
+ }
+ this.refs.list.setSelected(0);
+ this.setState({page: this.state.page + 1});
+ }
+
+ prevPage() {
+ if (this.state.page === 0) {
+ return;
+ }
+
+ if (this.props.handlePageChange) {
+ this.props.handlePageChange(this.state.page - 1, this.state.page);
+ }
+ this.refs.list.setSelected(0);
+ this.setState({page: this.state.page - 1});
+ }
+
+ onSelect(selected) {
+ this.selected = selected;
+ }
+
+ onAdd(value) {
+ if (this.props.maxValues && this.props.values.length >= this.props.maxValues) {
+ return;
+ }
+
+ for (let i = 0; i < this.props.values.length; i++) {
+ if (this.props.values[i].value === value.value) {
+ return;
+ }
+ }
+
+ this.props.handleAdd(value);
+ this.selected = null;
+ this.refs.select.handleInputChange({target: {value: ''}});
+ this.onInput('');
+ this.refs.select.focus();
+ }
+
+ onInput(input) {
+ if (input === '') {
+ this.refs.list.setSelected(-1);
+ } else {
+ this.refs.list.setSelected(0);
+ }
+ this.selected = null;
+
+ this.props.handleInput(input);
+ }
+
+ handleEnterPress(e) {
+ switch (e.keyCode) {
+ case KeyCodes.ENTER:
+ if (this.selected == null) {
+ this.props.handleSubmit();
+ return;
+ }
+ this.onAdd(this.selected);
+ break;
+ }
+ }
+
+ onChange(values) {
+ if (values.length < this.props.values.length) {
+ this.props.handleDelete(values);
+ }
+ }
+
+ render() {
+ const options = this.props.options;
+
+ let numRemainingText;
+ if (this.props.numRemainingText) {
+ numRemainingText = this.props.numRemainingText;
+ } else if (this.props.maxValues != null) {
+ numRemainingText = (
+ <FormattedMessage
+ id='multiselect.numRemaining'
+ defaultMessage='You can add {num, number} more. '
+ values={{
+ num: this.props.maxValues - this.props.values.length
+ }}
+ />
+ );
+ }
+
+ let optionsToDisplay = [];
+ let nextButton;
+ let previousButton;
+ let noteTextContainer;
+
+ if (this.props.noteText) {
+ noteTextContainer = (
+ <div className='multi-select__note'>
+ <div className='note__icon'><span className='fa fa-info'/></div>
+ <div>{this.props.noteText}</div>
+ </div>
+ );
+ }
+
+ if (options && options.length > this.props.perPage) {
+ const pageStart = this.state.page * this.props.perPage;
+ const pageEnd = pageStart + this.props.perPage;
+ optionsToDisplay = options.slice(pageStart, pageEnd);
+
+ if (options.length > pageEnd) {
+ nextButton = (
+ <button
+ className='btn btn-default filter-control filter-control__next'
+ onClick={this.nextPage}
+ >
+ <FormattedMessage
+ id='filtered_user_list.next'
+ defaultMessage='Next'
+ />
+ </button>
+ );
+ }
+
+ if (this.state.page > 0) {
+ previousButton = (
+ <button
+ className='btn btn-default filter-control filter-control__prev'
+ onClick={this.prevPage}
+ >
+ <FormattedMessage
+ id='filtered_user_list.prev'
+ defaultMessage='Previous'
+ />
+ </button>
+ );
+ }
+ } else {
+ optionsToDisplay = options;
+ }
+
+ return (
+ <div className='filtered-user-list'>
+ <div className='filter-row filter-row--full'>
+ <div className='multi-select__container'>
+ <ReactSelect
+ ref='select'
+ multi={true}
+ options={this.props.options}
+ joinValues={true}
+ clearable={false}
+ openOnFocus={true}
+ onInputChange={this.onInput}
+ onBlurResetsInput={false}
+ onCloseResetsInput={false}
+ onChange={this.onChange}
+ value={this.props.values}
+ valueRenderer={this.props.valueRenderer}
+ menuRenderer={() => null}
+ arrowRenderer={() => null}
+ noResultsText={null}
+ placeholder={localizeMessage('multiselect.placeholder', 'Search and add members')}
+ />
+ <button
+ className='btn btn-primary btn-sm'
+ onClick={this.props.handleSubmit}
+ >
+ <FormattedMessage
+ id='multiselect.go'
+ defaultMessage='Go'
+ />
+ </button>
+ </div>
+ <div className='multi-select__help'>
+ <div className='hidden-xs'>
+ <FormattedMessage
+ id='multiselect.instructions'
+ defaultMessage='Use up/down arrows to navigate and enter to select'
+ />
+ </div>
+ {numRemainingText}
+ {noteTextContainer}
+ </div>
+ </div>
+ <MultiSelectList
+ ref='list'
+ options={optionsToDisplay}
+ optionRenderer={this.props.optionRenderer}
+ page={this.state.page}
+ perPage={this.props.perPage}
+ onPageChange={this.props.handlePageChange}
+ onAdd={this.onAdd}
+ onSelect={this.onSelect}
+ />
+ <div className='filter-controls'>
+ {previousButton}
+ {nextButton}
+ </div>
+ </div>
+ );
+ }
+}
+
+MultiSelect.propTypes = {
+ options: React.PropTypes.arrayOf(React.PropTypes.object),
+ optionRenderer: React.PropTypes.func,
+ values: React.PropTypes.arrayOf(React.PropTypes.object),
+ valueRenderer: React.PropTypes.func,
+ handleInput: React.PropTypes.func,
+ handleDelete: React.PropTypes.func,
+ perPage: React.PropTypes.number,
+ handlePageChange: React.PropTypes.func,
+ handleAdd: React.PropTypes.func,
+ handleSubmit: React.PropTypes.func,
+ noteText: React.PropTypes.node,
+ maxValues: React.PropTypes.number,
+ numRemainingText: React.PropTypes.node
+};
diff --git a/webapp/components/multiselect/multiselect_list.jsx b/webapp/components/multiselect/multiselect_list.jsx
new file mode 100644
index 000000000..ff9f68bf8
--- /dev/null
+++ b/webapp/components/multiselect/multiselect_list.jsx
@@ -0,0 +1,169 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import {cmdOrCtrlPressed} from 'utils/utils.jsx';
+import Constants from 'utils/constants.jsx';
+const KeyCodes = Constants.KeyCodes;
+
+import React from 'react';
+import {FormattedMessage} from 'react-intl';
+
+export default class MultiSelectList extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.defaultOptionRenderer = this.defaultOptionRenderer.bind(this);
+ this.handleArrowPress = this.handleArrowPress.bind(this);
+ this.setSelected = this.setSelected.bind(this);
+
+ this.toSelect = -1;
+
+ this.state = {
+ selected: -1
+ };
+ }
+
+ componentDidMount() {
+ document.addEventListener('keydown', this.handleArrowPress);
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('keydown', this.handleArrowPress);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ this.setState({selected: this.toSelect});
+
+ const options = nextProps.options;
+
+ if (options && options.length > 0 && this.toSelect >= 0) {
+ this.props.onSelect(options[this.toSelect]);
+ }
+ }
+
+ componentDidUpdate() {
+ if (this.refs.list && this.refs.selected) {
+ const elemTop = this.refs.selected.getBoundingClientRect().top;
+ const elemBottom = this.refs.selected.getBoundingClientRect().bottom;
+ const listTop = this.refs.list.getBoundingClientRect().top;
+ const listBottom = this.refs.list.getBoundingClientRect().bottom;
+ if (elemBottom > listBottom) {
+ this.refs.selected.scrollIntoView(false);
+ } else if (elemTop < listTop) {
+ this.refs.selected.scrollIntoView(true);
+ }
+ }
+ }
+
+ setSelected(selected) {
+ this.toSelect = selected;
+ }
+
+ handleArrowPress(e) {
+ if (cmdOrCtrlPressed(e) && e.shiftKey) {
+ return;
+ }
+
+ const options = this.props.options;
+ if (options.length === 0) {
+ return;
+ }
+
+ let selected;
+ switch (e.keyCode) {
+ case KeyCodes.DOWN:
+ if (this.state.selected === -1) {
+ selected = 0;
+ break;
+ }
+ selected = Math.min(this.state.selected + 1, options.length - 1);
+ break;
+ case KeyCodes.UP:
+ if (this.state.selected === -1) {
+ selected = 0;
+ break;
+ }
+ selected = Math.max(this.state.selected - 1, 0);
+ break;
+ default:
+ return;
+ }
+
+ e.preventDefault();
+ this.setState({selected});
+ this.props.onSelect(options[selected]);
+ }
+
+ defaultOptionRenderer(option, isSelected, onAdd) {
+ var rowSelected = '';
+ if (isSelected) {
+ rowSelected = 'more-modal__row--selected';
+ }
+
+ return (
+ <div
+ ref={isSelected ? 'selected' : option.value}
+ className={rowSelected}
+ key={'multiselectoption' + option.value}
+ onClick={() => onAdd(option)}
+ >
+ {option.label}
+ </div>
+ );
+ }
+
+ render() {
+ const options = this.props.options;
+
+ if (options == null || options.length === 0) {
+ return (
+ <div
+ key='no-users-found'
+ className='no-channel-message'
+ >
+ <p className='primary-message'>
+ <FormattedMessage
+ id='multiselect.list.notFound'
+ defaultMessage='No items found'
+ />
+ </p>
+ </div>
+ );
+ }
+
+ let renderer;
+ if (this.props.optionRenderer) {
+ renderer = this.props.optionRenderer;
+ } else {
+ renderer = this.defaultOptionRenderer;
+ }
+
+ const optionControls = options.map((o, i) => renderer(o, this.state.selected === i, this.props.onAdd));
+
+ return (
+ <div className='more-modal__list'>
+ <div
+ ref='list'
+ >
+ {optionControls}
+ </div>
+ </div>
+ );
+ }
+}
+
+MultiSelectList.defaultProps = {
+ options: [],
+ perPage: 50,
+ onAction: () => null
+};
+
+MultiSelectList.propTypes = {
+ options: React.PropTypes.arrayOf(React.PropTypes.object),
+ optionRenderer: React.PropTypes.func,
+ page: React.PropTypes.number,
+ perPage: React.PropTypes.number,
+ onPageChange: React.PropTypes.func,
+ onAdd: React.PropTypes.func,
+ onSelect: React.PropTypes.func
+};
diff --git a/webapp/components/navbar.jsx b/webapp/components/navbar.jsx
index a805a9de4..dee32416b 100644
--- a/webapp/components/navbar.jsx
+++ b/webapp/components/navbar.jsx
@@ -61,6 +61,8 @@ export default class Navbar extends React.Component {
this.showChannelSwitchModal = this.showChannelSwitchModal.bind(this);
this.hideChannelSwitchModal = this.hideChannelSwitchModal.bind(this);
+ this.openDirectMessageModal = this.openDirectMessageModal.bind(this);
+
const state = this.getStateFromStores();
state.showEditChannelPurposeModal = false;
state.showEditChannelHeaderModal = false;
@@ -206,6 +208,14 @@ export default class Navbar extends React.Component {
});
}
+ openDirectMessageModal() {
+ AppDispatcher.handleViewAction({
+ type: ActionTypes.TOGGLE_DM_MODAL,
+ value: true,
+ startingUsers: UserStore.getProfileListInChannel(this.state.channel.id, true)
+ });
+ }
+
toggleFavorite = (e) => {
e.preventDefault();
@@ -216,7 +226,7 @@ export default class Navbar extends React.Component {
}
};
- createDropdown(channel, channelTitle, isAdmin, isSystemAdmin, isChannelAdmin, isDirect, popoverContent) {
+ createDropdown(channel, channelTitle, isAdmin, isSystemAdmin, isChannelAdmin, isDirect, isGroup, popoverContent) {
if (channel) {
let channelTerm = (
<FormattedMessage
@@ -258,6 +268,57 @@ export default class Navbar extends React.Component {
</a>
</li>
);
+ } else if (isGroup) {
+ setChannelHeaderOption = (
+ <li role='presentation'>
+ <a
+ role='menuitem'
+ href='#'
+ onClick={this.showEditChannelHeaderModal}
+ >
+ <FormattedMessage
+ id='channel_header.channelHeader'
+ defaultMessage='Set Channel Header...'
+ />
+ </a>
+ </li>
+ );
+
+ notificationPreferenceOption = (
+ <li role='presentation'>
+ <ToggleModalButton
+ role='menuitem'
+ dialogType={ChannelNotificationsModal}
+ dialogProps={{
+ channel,
+ channelMember: this.state.member,
+ currentUser: this.state.currentUser
+ }}
+ >
+ <FormattedMessage
+ id='navbar.preferences'
+ defaultMessage='Notification Preferences'
+ />
+ </ToggleModalButton>
+ </li>
+ );
+
+ addMembersOption = (
+ <li
+ role='presentation'
+ >
+ <a
+ role='menuitem'
+ href='#'
+ onClick={this.openDirectMessageModal}
+ >
+ <FormattedMessage
+ id='navbar.addMembers'
+ defaultMessage='Add Members'
+ />
+ </a>
+ </li>
+ );
} else {
viewInfoOption = (
<li role='presentation'>
@@ -621,6 +682,7 @@ export default class Navbar extends React.Component {
var isSystemAdmin = false;
var isChannelAdmin = false;
var isDirect = false;
+ let isGroup = false;
var editChannelHeaderModal = null;
var editChannelPurposeModal = null;
@@ -660,6 +722,9 @@ export default class Navbar extends React.Component {
isDirect = true;
const teammateId = Utils.getUserIdFromChannelName(channel);
channelTitle = Utils.displayUsername(teammateId);
+ } else if (channel.type === Constants.GM_CHANNEL) {
+ isGroup = true;
+ channelTitle = ChannelUtils.buildGroupChannelName(channel.id);
}
if (channel.header.length === 0) {
@@ -757,7 +822,7 @@ export default class Navbar extends React.Component {
</button>
);
- var channelMenuDropdown = this.createDropdown(channel, channelTitle, isAdmin, isSystemAdmin, isChannelAdmin, isDirect, popoverContent);
+ var channelMenuDropdown = this.createDropdown(channel, channelTitle, isAdmin, isSystemAdmin, isChannelAdmin, isDirect, isGroup, popoverContent);
return (
<div>
diff --git a/webapp/components/popover_list_members.jsx b/webapp/components/popover_list_members.jsx
index 5ffcb687a..6d4ed056c 100644
--- a/webapp/components/popover_list_members.jsx
+++ b/webapp/components/popover_list_members.jsx
@@ -48,7 +48,7 @@ export default class PopoverListMembers extends React.Component {
e.preventDefault();
openDirectChannelToUser(
- teammate,
+ teammate.id,
(channel, channelAlreadyExisted) => {
browserHistory.push(TeamStore.getCurrentTeamRelativeUrl() + '/channels/' + channel.name);
if (channelAlreadyExisted) {
diff --git a/webapp/components/profile_popover.jsx b/webapp/components/profile_popover.jsx
index fc22c1314..c7d45474f 100644
--- a/webapp/components/profile_popover.jsx
+++ b/webapp/components/profile_popover.jsx
@@ -81,7 +81,7 @@ export default class ProfilePopover extends React.Component {
this.setState({loadingDMChannel: user.id});
openDirectChannelToUser(
- user,
+ user.id,
(channel) => {
if (Utils.isMobile()) {
GlobalActions.emitCloseRightHandSide();
diff --git a/webapp/components/sidebar.jsx b/webapp/components/sidebar.jsx
index c4c5f0517..ce584d477 100644
--- a/webapp/components/sidebar.jsx
+++ b/webapp/components/sidebar.jsx
@@ -15,6 +15,7 @@ import ChannelStore from 'stores/channel_store.jsx';
import UserStore from 'stores/user_store.jsx';
import TeamStore from 'stores/team_store.jsx';
import PreferenceStore from 'stores/preference_store.jsx';
+import ModalStore from 'stores/modal_store.jsx';
import * as AsyncClient from 'utils/async_client.jsx';
import * as Utils from 'utils/utils.jsx';
@@ -22,7 +23,7 @@ import * as ChannelUtils from 'utils/channel_utils.jsx';
import * as ChannelActions from 'actions/channel_actions.jsx';
import {trackEvent} from 'actions/diagnostics_actions.jsx';
-import Constants from 'utils/constants.jsx';
+import {ActionTypes, Constants} from 'utils/constants.jsx';
import {FormattedMessage, FormattedHTMLMessage} from 'react-intl';
@@ -49,6 +50,8 @@ export default class Sidebar extends React.Component {
this.getStateFromStores = this.getStateFromStores.bind(this);
this.onChange = this.onChange.bind(this);
+ this.onModalChange = this.onModalChange.bind(this);
+ this.onInChannelChange = this.onInChannelChange.bind(this);
this.onScroll = this.onScroll.bind(this);
this.updateUnreadIndicators = this.updateUnreadIndicators.bind(this);
this.handleLeaveDirectChannel = this.handleLeaveDirectChannel.bind(this);
@@ -77,6 +80,7 @@ export default class Sidebar extends React.Component {
state.showDirectChannelsModal = false;
state.showMoreChannelsModal = false;
state.loadingDMChannel = -1;
+ state.inChannelChange = false;
this.state = state;
}
@@ -96,7 +100,7 @@ export default class Sidebar extends React.Component {
Object.keys(unreadCounts).forEach((chId) => {
const channel = ChannelStore.get(chId);
- if (channel && (channel.type === 'D' || channel.team_id === this.state.currentTeam.id)) {
+ if (channel && (channel.type === Constants.DM_CHANNEL || channel.type === Constants.GM_CHANNEL || channel.team_id === this.state.currentTeam.id)) {
msgs += unreadCounts[chId].msgs;
mentions += unreadCounts[chId].mentions;
}
@@ -128,13 +132,19 @@ export default class Sidebar extends React.Component {
};
}
+ onInChannelChange() {
+ this.setState({inChannelChange: !this.state.inChannelChange});
+ }
+
componentDidMount() {
ChannelStore.addChangeListener(this.onChange);
UserStore.addChangeListener(this.onChange);
UserStore.addInTeamChangeListener(this.onChange);
+ UserStore.addInChannelChangeListener(this.onInChannelChange);
UserStore.addStatusesChangeListener(this.onChange);
TeamStore.addChangeListener(this.onChange);
PreferenceStore.addChangeListener(this.onChange);
+ ModalStore.addModalListener(ActionTypes.TOGGLE_DM_MODAL, this.onModalChange);
this.updateTitle();
this.updateUnreadIndicators();
@@ -179,13 +189,19 @@ export default class Sidebar extends React.Component {
ChannelStore.removeChangeListener(this.onChange);
UserStore.removeChangeListener(this.onChange);
UserStore.removeInTeamChangeListener(this.onChange);
+ UserStore.removeInChannelChangeListener(this.onChange);
UserStore.removeStatusesChangeListener(this.onChange);
TeamStore.removeChangeListener(this.onChange);
PreferenceStore.removeChangeListener(this.onChange);
+ ModalStore.removeModalListener(ActionTypes.TOGGLE_DM_MODAL, this.onModalChange);
document.removeEventListener('keydown', this.navigateChannelShortcut);
document.removeEventListener('keydown', this.navigateUnreadChannelShortcut);
}
+ onModalChange(value, args) {
+ this.showMoreDirectChannelsModal(args.startingUsers);
+ }
+
onChange() {
if (this.state.currentTeam.id !== TeamStore.getCurrentId()) {
ChannelStore.clear();
@@ -203,11 +219,13 @@ export default class Sidebar extends React.Component {
}
let currentChannelName = channel.display_name;
- if (channel.type === 'D') {
+ if (channel.type === Constants.DM_CHANNEL) {
const teammate = Utils.getDirectTeammate(channel.id);
if (teammate != null) {
currentChannelName = teammate.username;
}
+ } else if (channel.type === Constants.GM_CHANNEL) {
+ currentChannelName = ChannelUtils.buildGroupChannelName(channel.id);
}
const unread = this.getTotalUnreadCount();
@@ -331,7 +349,7 @@ export default class Sidebar extends React.Component {
}
getDisplayedChannels() {
- return this.state.favoriteChannels.concat(this.state.publicChannels).concat(this.state.privateChannels).concat(this.state.directChannels).concat(this.state.directNonTeamChannels);
+ return this.state.favoriteChannels.concat(this.state.publicChannels).concat(this.state.privateChannels).concat(this.state.directAndGroupChannels).concat(this.state.directNonTeamChannels);
}
handleLeaveDirectChannel(e, channel) {
@@ -340,9 +358,19 @@ export default class Sidebar extends React.Component {
if (!this.isLeaving.get(channel.id)) {
this.isLeaving.set(channel.id, true);
+ let id;
+ let category;
+ if (channel.type === Constants.DM_CHANNEL) {
+ id = channel.teammate_id;
+ category = Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW;
+ } else {
+ id = channel.id;
+ category = Constants.Preferences.CATEGORY_GROUP_CHANNEL_SHOW;
+ }
+
AsyncClient.savePreference(
- Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW,
- channel.teammate_id,
+ category,
+ id,
'false',
() => {
this.isLeaving.set(channel.id, false);
@@ -382,13 +410,13 @@ export default class Sidebar extends React.Component {
this.setState({newChannelModalType: ''});
}
- showMoreDirectChannelsModal() {
+ showMoreDirectChannelsModal(startingUsers) {
trackEvent('ui', 'ui_channels_more_direct');
- this.setState({showDirectChannelsModal: true});
+ this.setState({showDirectChannelsModal: true, startingUsers});
}
hideMoreDirectChannelsModal() {
- this.setState({showDirectChannelsModal: false});
+ this.setState({showDirectChannelsModal: false, startingUsers: null});
}
openLeftSidebar() {
@@ -509,11 +537,16 @@ export default class Sidebar extends React.Component {
rowClass += ' has-badge';
}
+ let displayName = channel.display_name;
+
var icon = null;
if (channel.type === Constants.OPEN_CHANNEL) {
icon = <div className='status'><i className='fa fa-globe'/></div>;
} else if (channel.type === Constants.PRIVATE_CHANNEL) {
icon = <div className='status'><i className='fa fa-lock'/></div>;
+ } else if (channel.type === Constants.GM_CHANNEL) {
+ displayName = ChannelUtils.buildGroupChannelName(channel.id);
+ icon = <div className='status status--group'>{UserStore.getProfileListInChannel(channel.id, true).length}</div>;
} else {
// set up status icon for direct message channels (status is null for other channel types)
icon = (
@@ -576,7 +609,7 @@ export default class Sidebar extends React.Component {
onClick={this.trackChannelSelectedEvent}
>
{icon}
- {channel.display_name}
+ {displayName}
{badge}
{closeButton}
</Link>
@@ -615,27 +648,10 @@ export default class Sidebar extends React.Component {
const privateChannelItems = this.state.privateChannels.map(this.createChannelElement);
- const directMessageItems = this.state.directChannels.map((channel, index, arr) => {
+ const directMessageItems = this.state.directAndGroupChannels.map((channel, index, arr) => {
return this.createChannelElement(channel, index, arr, this.handleLeaveDirectChannel);
});
- const directMessageNonTeamItems = this.state.directNonTeamChannels.map((channel, index, arr) => {
- return this.createChannelElement(channel, index, arr, this.handleLeaveDirectChannel);
- });
-
- let directDivider;
- if (directMessageNonTeamItems.length !== 0) {
- directDivider =
- (<div className='sidebar__divider'>
- <div className='sidebar__divider__text'>
- <FormattedMessage
- id='sidebar.otherMembers'
- defaultMessage='Outside this team'
- />
- </div>
- </div>);
- }
-
// update the favicon to show if there are any notifications
if (this.lastBadgesActive !== this.badgesActive) {
var link = document.createElement('link');
@@ -659,7 +675,7 @@ export default class Sidebar extends React.Component {
<li key='more'>
<a
href='#'
- onClick={this.showMoreDirectChannelsModal}
+ onClick={() => this.showMoreDirectChannelsModal()}
>
<FormattedMessage
id='sidebar.moreElips'
@@ -753,6 +769,7 @@ export default class Sidebar extends React.Component {
moreDirectChannelsModal = (
<MoreDirectChannels
onModalDismissed={this.hideMoreDirectChannelsModal}
+ startingUsers={this.state.startingUsers}
/>
);
}
@@ -866,8 +883,6 @@ export default class Sidebar extends React.Component {
</h4>
</li>
{directMessageItems}
- {directDivider}
- {directMessageNonTeamItems}
{directMessageMore}
</ul>
</div>
diff --git a/webapp/components/status_icon.jsx b/webapp/components/status_icon.jsx
index 3e71344d9..cf5ef6947 100644
--- a/webapp/components/status_icon.jsx
+++ b/webapp/components/status_icon.jsx
@@ -33,7 +33,7 @@ export default class StatusIcon extends React.Component {
return (
<span
- className='status'
+ className={'status ' + this.props.className}
dangerouslySetInnerHTML={{__html: statusIcon}}
/>
);
@@ -41,7 +41,12 @@ export default class StatusIcon extends React.Component {
}
+StatusIcon.defaultProps = {
+ className: ''
+};
+
StatusIcon.propTypes = {
status: React.PropTypes.string,
+ className: React.PropTypes.string,
type: React.PropTypes.string
};
diff --git a/webapp/components/suggestion/switch_channel_provider.jsx b/webapp/components/suggestion/switch_channel_provider.jsx
index 3b7bec319..6d4340780 100644
--- a/webapp/components/suggestion/switch_channel_provider.jsx
+++ b/webapp/components/suggestion/switch_channel_provider.jsx
@@ -12,7 +12,7 @@ import Client from 'client/web_client.jsx';
import AppDispatcher from 'dispatcher/app_dispatcher.jsx';
import {Constants, ActionTypes} from 'utils/constants.jsx';
import * as Utils from 'utils/utils.jsx';
-import {sortChannelsByDisplayName} from 'utils/channel_utils.jsx';
+import {sortChannelsByDisplayName, buildGroupChannelName} from 'utils/channel_utils.jsx';
import React from 'react';
@@ -25,12 +25,15 @@ class SwitchChannelSuggestion extends Suggestion {
className += ' suggestion--selected';
}
- const displayName = item.display_name;
+ let displayName = item.display_name;
let icon = null;
if (item.type === Constants.OPEN_CHANNEL) {
icon = <div className='status'><i className='fa fa-globe'/></div>;
} else if (item.type === Constants.PRIVATE_CHANNEL) {
icon = <div className='status'><i className='fa fa-lock'/></div>;
+ } else if (item.type === Constants.GM_CHANNEL) {
+ displayName = buildGroupChannelName(item.id);
+ icon = <div className='status status--group'>{UserStore.getProfileListInChannel(item.id, true).length}</div>;
} else {
icon = (
<div className='pull-left'>
@@ -74,7 +77,11 @@ export default class SwitchChannelProvider extends Provider {
for (const id of Object.keys(allChannels)) {
const channel = allChannels[id];
if (channel.display_name.toLowerCase().indexOf(channelPrefix.toLowerCase()) !== -1) {
- channels.push(channel);
+ const newChannel = Object.assign({}, channel);
+ if (newChannel.type === Constants.GM_CHANNEL) {
+ newChannel.name = buildGroupChannelName(newChannel.id);
+ }
+ channels.push(newChannel);
}
}
diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json
index a0ae76c72..8196d1aa1 100644
--- a/webapp/i18n/en.json
+++ b/webapp/i18n/en.json
@@ -1,4 +1,10 @@
{
+ "multiselect.go": "Go",
+ "multiselect.instructions": "Use up/down arrows to navigate and enter to select",
+ "multiselect.placeholder": "Search and add members",
+ "multiselect.numRemaining": "You can add {num, number} more",
+ "multiselect.numPeopleRemaining": "You can add {num, number} more {num, plural, =0 {people} one {person} other {people}}. ",
+ "filtered_channels_list.count": "{count} {count, plural, =0 {0 channels} one {channel} other {channels}}",
"about.close": "Close",
"about.copyright": "Copyright 2016 Mattermost, Inc. All rights reserved",
"about.database": "Database:",
@@ -1638,6 +1644,8 @@
"more_channels.prev": "Previous",
"more_channels.title": "More Channels",
"more_direct_channels.close": "Close",
+ "more_direct_channels.new_convo_note": "This will start a new conversation. If you’re adding a lot of people, consider creating a private group instead.",
+ "more_direct_channels.new_convo_note.full": "You’ve reached the maximum number of people for this conversation. Consider creating a private group instead.",
"more_direct_channels.message": "Message",
"more_direct_channels.title": "Direct Messages",
"msg_typing.areTyping": "{users} and {last} are typing...",
diff --git a/webapp/package.json b/webapp/package.json
index b667a187c..08ea8a882 100644
--- a/webapp/package.json
+++ b/webapp/package.json
@@ -29,7 +29,7 @@
"react-dom": "15.4.2",
"react-intl": "2.2.3",
"react-router": "2.8.1",
- "react-select": "1.0.0-rc.3",
+ "react-select": "1.0.0-rc.2",
"superagent": "3.4.1",
"twemoji": "2.2.3",
"velocity-animate": "1.4.2",
diff --git a/webapp/routes/route_team.jsx b/webapp/routes/route_team.jsx
index f7d12f40f..768d84dba 100644
--- a/webapp/routes/route_team.jsx
+++ b/webapp/routes/route_team.jsx
@@ -21,7 +21,7 @@ import BrowserStore from 'stores/browser_store.jsx';
import emojiRoute from 'routes/route_emoji.jsx';
import integrationsRoute from 'routes/route_integrations.jsx';
-import {loadNewDMIfNeeded, loadProfilesAndTeamMembersForDMSidebar} from 'actions/user_actions.jsx';
+import {loadNewDMIfNeeded, loadNewGMIfNeeded, loadProfilesForSidebar} from 'actions/user_actions.jsx';
function onChannelEnter(nextState, replace, callback) {
doChannelChange(nextState, replace, callback);
@@ -36,6 +36,8 @@ function doChannelChange(state, replace, callback) {
if (channel && channel.type === Constants.DM_CHANNEL) {
loadNewDMIfNeeded(Utils.getUserIdFromChannelName(channel));
+ } else if (channel && channel.type === Constants.GM_CHANNEL) {
+ loadNewGMIfNeeded(channel.id);
}
if (!channel) {
@@ -120,7 +122,7 @@ function preNeedsTeam(nextState, replace, callback) {
});
loadStatusesForChannelAndSidebar();
- loadProfilesAndTeamMembersForDMSidebar();
+ loadProfilesForSidebar();
d1.resolve();
},
diff --git a/webapp/sass/components/_modal.scss b/webapp/sass/components/_modal.scss
index 0078ddec4..03a8ad1e8 100644
--- a/webapp/sass/components/_modal.scss
+++ b/webapp/sass/components/_modal.scss
@@ -170,6 +170,10 @@
float: left;
font-size: 17px;
line-height: 27px;
+ max-width: calc(100% - 80px);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
.name {
color: $white;
@@ -479,6 +483,10 @@
.filter-row {
margin: 5px 0 10px;
width: 300px;
+
+ &.filter-row--full {
+ width: 100%;
+ }
}
.member-count {
@@ -582,6 +590,24 @@
border-bottom: 1px solid $light-gray;
display: flex;
padding: 10px 15px;
+
+ &.clickable {
+ cursor: pointer;
+ }
+
+ &:hover {
+ .more-modal__actions--round {
+ opacity: .5;
+ }
+ }
+ }
+
+ .more-modal__actions--round {
+ height: 32px;
+ line-height: 32px;
+ opacity: 0;
+ text-align: center;
+ width: 32px;
}
p {
@@ -611,6 +637,7 @@
.more-modal__list {
flex-grow: 500;
+ height: 1px;
}
.filter-button {
diff --git a/webapp/sass/components/_module.scss b/webapp/sass/components/_module.scss
index 3e587707d..c1114bbcb 100644
--- a/webapp/sass/components/_module.scss
+++ b/webapp/sass/components/_module.scss
@@ -9,6 +9,7 @@
@import 'links';
@import 'mentions';
@import 'modal';
+@import 'multi-select';
@import 'oauth';
@import 'popover';
@import 'save-button';
diff --git a/webapp/sass/components/_multi-select.scss b/webapp/sass/components/_multi-select.scss
new file mode 100644
index 000000000..a33116aa4
--- /dev/null
+++ b/webapp/sass/components/_multi-select.scss
@@ -0,0 +1,59 @@
+@charset 'UTF-8';
+
+.multi-select__container {
+ display: table;
+ padding: 0 15px;
+ width: 100%;
+
+ .Select {
+ display: table-cell;
+ padding-right: 15px;
+ vertical-align: top;
+ width: 100%;
+ }
+
+ .btn {
+ display: table-cell;
+ height: 36px;
+ min-width: 60px;
+ vertical-align: top;
+ }
+
+ .Select-control {
+ border-radius: 1px;
+ }
+
+ .Select-value {
+ white-space: nowrap;
+ }
+
+ .Select-value-label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
+
+.multi-select__help {
+ padding: 10px 15px 0;
+
+ > div:not(.multi-select__note),
+ > span {
+ @include opacity(.6);
+ }
+}
+
+.multi-select__note {
+ @include border-radius(3px);
+ display: table;
+ margin-top: 5px;
+ padding: 8px 13px;
+ width: 100%;
+
+ > div {
+ display: table-cell;
+
+ &.note__icon {
+ width: 15px;
+ }
+ }
+}
diff --git a/webapp/sass/components/_status-icon.scss b/webapp/sass/components/_status-icon.scss
index 5bd68f362..c2f8bca5b 100644
--- a/webapp/sass/components/_status-icon.scss
+++ b/webapp/sass/components/_status-icon.scss
@@ -36,6 +36,28 @@
top: 1px;
width: 12px;
+ &.status--group {
+ border-radius: 2px;
+ font-size: 11px;
+ font-weight: 600;
+ height: 16px;
+ left: 1px;
+ line-height: 15px;
+ margin-left: -3px;
+ text-align: center;
+ top: -1px;
+ width: 16px;
+
+ .mentions__name & {
+ height: 20px;
+ left: 0;
+ line-height: 20px;
+ margin-left: 0;
+ text-align: center;
+ width: 20px;
+ }
+ }
+
svg {
max-height: 14px;
}
diff --git a/webapp/sass/layout/_headers.scss b/webapp/sass/layout/_headers.scss
index e6dc6bb68..8ee6e8fdc 100644
--- a/webapp/sass/layout/_headers.scss
+++ b/webapp/sass/layout/_headers.scss
@@ -121,7 +121,7 @@
&.dropdown {
float: left;
- max-width: 100%;
+ max-width: 90%;
padding-right: 1em;
.header-dropdown__icon {
@@ -182,7 +182,6 @@
}
.channel-intro-profile {
- margin-left: 63px;
margin-top: 5px;
.user-popover {
@@ -193,7 +192,10 @@
}
.channel-intro-img {
- float: left;
+ .status-wrapper {
+ height: 50px;
+ margin: 0 10px 10px 0;
+ }
img {
@include border-radius(100px);
@@ -211,10 +213,6 @@
background: $bg--gray;
padding: 10px 15px;
}
-
- .channel-intro-text {
- margin-top: 35px;
- }
}
// Team Header in Sidebar
diff --git a/webapp/sass/responsive/_mobile.scss b/webapp/sass/responsive/_mobile.scss
index 3170fb0d4..ee26045ac 100644
--- a/webapp/sass/responsive/_mobile.scss
+++ b/webapp/sass/responsive/_mobile.scss
@@ -1,6 +1,23 @@
@charset 'UTF-8';
@media screen and (max-width: 768px) {
+ .multi-select__container {
+ .btn {
+ display: block;
+ min-width: 50px;
+ }
+
+ .Select-value-label {
+ max-width: 190px;
+ }
+ }
+
+ .more-modal__list {
+ .more-modal__actions--round {
+ @include opacity(.5);
+ }
+ }
+
.post-create__container {
padding-bottom: 10px;
diff --git a/webapp/stores/modal_store.jsx b/webapp/stores/modal_store.jsx
index 9961475b2..b1c2887df 100644
--- a/webapp/stores/modal_store.jsx
+++ b/webapp/stores/modal_store.jsx
@@ -38,6 +38,7 @@ class ModalStoreClass extends EventEmitter {
case ActionTypes.TOGGLE_GET_POST_LINK_MODAL:
case ActionTypes.TOGGLE_GET_TEAM_INVITE_LINK_MODAL:
case ActionTypes.TOGGLE_GET_PUBLIC_LINK_MODAL:
+ case ActionTypes.TOGGLE_DM_MODAL:
this.emit(type, value, args);
break;
}
diff --git a/webapp/stores/notification_store.jsx b/webapp/stores/notification_store.jsx
index 58e4433ec..28e5ea65d 100644
--- a/webapp/stores/notification_store.jsx
+++ b/webapp/stores/notification_store.jsx
@@ -8,6 +8,7 @@ import UserStore from './user_store.jsx';
import ChannelStore from './channel_store.jsx';
import * as UserAgent from 'utils/user_agent.jsx';
import * as Utils from 'utils/utils.jsx';
+import {buildGroupChannelName} from 'utils/channel_utils.jsx';
import * as PostUtils from 'utils/post_utils.jsx';
const ActionTypes = Constants.ActionTypes;
@@ -74,6 +75,8 @@ class NotificationStoreClass extends EventEmitter {
};
} else if (channel.type === Constants.DM_CHANNEL) {
title = Utils.localizeMessage('notification.dm', 'Direct Message');
+ } else if (channel.type === Constants.GM_CHANNEL) {
+ title = buildGroupChannelName(channel.id);
} else {
title = channel.display_name;
}
diff --git a/webapp/stores/user_store.jsx b/webapp/stores/user_store.jsx
index bcc59b690..007d8a5a7 100644
--- a/webapp/stores/user_store.jsx
+++ b/webapp/stores/user_store.jsx
@@ -414,13 +414,18 @@ class UserStoreClass extends EventEmitter {
userIds.splice(index, 1);
}
- getProfileListInChannel(channelId = ChannelStore.getCurrentId()) {
+ getProfileListInChannel(channelId = ChannelStore.getCurrentId(), skipCurrent = false) {
const userIds = this.profiles_in_channel[channelId] || [];
+ const currentId = this.getCurrentId();
const profiles = [];
for (let i = 0; i < userIds.length; i++) {
const profile = this.getProfile(userIds[i]);
if (profile) {
+ if (skipCurrent && profile.id === currentId) {
+ continue;
+ }
+
profiles.push(profile);
}
}
diff --git a/webapp/tests/client_channel.test.jsx b/webapp/tests/client_channel.test.jsx
index 154f70fef..e1ee6cf6d 100644
--- a/webapp/tests/client_channel.test.jsx
+++ b/webapp/tests/client_channel.test.jsx
@@ -48,6 +48,37 @@ describe('Client.Channels', function() {
});
});
+ it('createGroupChannel', function(done) {
+ TestHelper.initBasic(() => {
+ TestHelper.basicClient().createUser(
+ TestHelper.fakeUser(),
+ (user1) => {
+ TestHelper.basicClient().createUser(
+ TestHelper.fakeUser(),
+ function(user2) {
+ TestHelper.basicClient().createGroupChannel(
+ [user2.id, user1.id],
+ function(data) {
+ assert.equal(data.id.length > 0, true);
+ done();
+ },
+ function(err) {
+ done(new Error(err.message));
+ }
+ );
+ },
+ function(err) {
+ done(new Error(err.message));
+ }
+ );
+ },
+ function(err) {
+ done(new Error(err.message));
+ }
+ );
+ });
+ });
+
it('updateChannel', function(done) {
TestHelper.initBasic(() => {
var channel = TestHelper.basicChannel();
diff --git a/webapp/utils/channel_intro_messages.jsx b/webapp/utils/channel_intro_messages.jsx
index 991bf54e8..390ce6d28 100644
--- a/webapp/utils/channel_intro_messages.jsx
+++ b/webapp/utils/channel_intro_messages.jsx
@@ -23,8 +23,10 @@ export function createChannelIntroMessage(channel, fullWidthIntro) {
centeredIntro = 'channel-intro--centered';
}
- if (channel.type === 'D') {
+ if (channel.type === Constants.DM_CHANNEL) {
return createDMIntroMessage(channel, centeredIntro);
+ } else if (channel.type === Constants.GM_CHANNEL) {
+ return createGMIntroMessage(channel, centeredIntro);
} else if (ChannelStore.isDefault(channel)) {
return createDefaultIntroMessage(channel, centeredIntro);
} else if (channel.name === Constants.OFFTOPIC_CHANNEL) {
@@ -35,6 +37,65 @@ export function createChannelIntroMessage(channel, fullWidthIntro) {
return null;
}
+export function createGMIntroMessage(channel, centeredIntro) {
+ const profiles = UserStore.getProfileListInChannel(channel.id, true);
+
+ if (profiles.length > 0) {
+ const pictures = [];
+ let names = '';
+ for (let i = 0; i < profiles.length; i++) {
+ const profile = profiles[i];
+
+ pictures.push(
+ <ProfilePicture
+ key={'introprofilepicture' + profile.id}
+ src={Client.getUsersRoute() + '/' + profile.id + '/image?time=' + profile.last_picture_update}
+ width='50'
+ height='50'
+ user={profile}
+ />
+ );
+
+ if (i === profiles.length - 1) {
+ names += Utils.displayUsernameForUser(profile);
+ } else if (i === profiles.length - 2) {
+ names += Utils.displayUsernameForUser(profile) + ' and ';
+ } else {
+ names += Utils.displayUsernameForUser(profile) + ', ';
+ }
+ }
+
+ return (
+ <div className={'channel-intro ' + centeredIntro}>
+ <div className='post-profile-img__container channel-intro-img'>
+ {pictures}
+ </div>
+ <p className='channel-intro-text'>
+ <FormattedHTMLMessage
+ id='intro_messages.GM'
+ defaultMessage='This is the start of your group message history with {names}.<br />Messages and files shared here are not shown to people outside this area.'
+ values={{
+ names
+ }}
+ />
+ </p>
+ {createSetHeaderButton(channel)}
+ </div>
+ );
+ }
+
+ return (
+ <div className={'channel-intro ' + centeredIntro}>
+ <p className='channel-intro-text'>
+ <FormattedMessage
+ id='intro_messages.group_message'
+ defaultMessage='This is the start of your group message history with these teammates. Messages and files shared here are not shown to people outside this area.'
+ />
+ </p>
+ </div>
+ );
+}
+
export function createDMIntroMessage(channel, centeredIntro) {
var teammate = Utils.getDirectTeammate(channel.id);
diff --git a/webapp/utils/channel_utils.jsx b/webapp/utils/channel_utils.jsx
index 22c428cb8..2bb30af5c 100644
--- a/webapp/utils/channel_utils.jsx
+++ b/webapp/utils/channel_utils.jsx
@@ -5,7 +5,6 @@ const Preferences = Constants.Preferences;
import * as Utils from 'utils/utils.jsx';
import UserStore from 'stores/user_store.jsx';
-import TeamStore from 'stores/team_store.jsx';
import PreferenceStore from 'stores/preference_store.jsx';
import LocalizationStore from 'stores/localization_store.jsx';
@@ -15,30 +14,28 @@ import LocalizationStore from 'stores/localization_store.jsx';
* Example: {
* publicChannels: [...],
* privateChannels: [...],
- * directChannels: [...],
- * directNonTeamChannels: [...],
+ * directAndGroupChannels: [...],
* favoriteChannels: [...]
* }
*/
export function buildDisplayableChannelList(persistentChannels) {
- const missingDMChannels = createMissingDirectChannels(persistentChannels);
+ const missingDirectChannels = createMissingDirectChannels(persistentChannels);
const channels = persistentChannels.
- concat(missingDMChannels).
+ concat(missingDirectChannels).
map(completeDirectChannelInfo).
filter(isNotDeletedChannel).
sort(sortChannelsByDisplayName);
const favoriteChannels = channels.filter(isFavoriteChannel);
const notFavoriteChannels = channels.filter(not(isFavoriteChannel));
- const directChannels = notFavoriteChannels.filter(andX(isDirectChannel, isDirectChannelVisible));
+ const directAndGroupChannels = notFavoriteChannels.filter(orX(andX(isGroupChannel, isGroupChannelVisible), andX(isDirectChannel, isDirectChannelVisible)));
return {
favoriteChannels,
publicChannels: notFavoriteChannels.filter(isOpenChannel),
privateChannels: notFavoriteChannels.filter(isPrivateChannel),
- directChannels: directChannels.filter(isConnectedToTeamMember),
- directNonTeamChannels: directChannels.filter(isNotConnectedToTeamMember)
+ directAndGroupChannels
};
}
@@ -62,6 +59,14 @@ export function isPrivateChannel(channel) {
return channel.type === Constants.PRIVATE_CHANNEL;
}
+export function isGroupChannel(channel) {
+ return channel.type === Constants.GM_CHANNEL;
+}
+
+export function isGroupChannelVisible(channel) {
+ return PreferenceStore.getBool(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channel.id);
+}
+
export function isDirectChannel(channel) {
return channel.type === Constants.DM_CHANNEL;
}
@@ -88,12 +93,12 @@ export function completeDirectChannelInfo(channel) {
}
const defaultPrefix = 'D'; // fallback for future types
-const typeToPrefixMap = {[Constants.OPEN_CHANNEL]: 'A', [Constants.PRIVATE_CHANNEL]: 'B', [Constants.DM_CHANNEL]: 'C'};
+const typeToPrefixMap = {[Constants.OPEN_CHANNEL]: 'A', [Constants.PRIVATE_CHANNEL]: 'B', [Constants.DM_CHANNEL]: 'C', [Constants.GM_CHANNEL]: 'C'};
export function sortChannelsByDisplayName(a, b) {
const locale = LocalizationStore.getLocale();
- if (a.type !== b.type) {
+ if (a.type !== b.type && typeToPrefixMap[a.type] !== typeToPrefixMap[b.type]) {
return (typeToPrefixMap[a.type] || defaultPrefix).localeCompare((typeToPrefixMap[b.type] || defaultPrefix), locale);
}
@@ -186,6 +191,19 @@ export function showDeleteOption(channel, isAdmin, isSystemAdmin, isChannelAdmin
return true;
}
+export function buildGroupChannelName(channelId) {
+ const profiles = UserStore.getProfileListInChannel(channelId, true);
+ let displayName = '';
+ for (let i = 0; i < profiles.length; i++) {
+ displayName += Utils.displayUsernameForUser(profiles[i]);
+ if (i !== profiles.length - 1) {
+ displayName += ', ';
+ }
+ }
+
+ return displayName;
+}
+
/*
* not exported helpers
*/
@@ -215,22 +233,14 @@ function createFakeChannelCurried(userId) {
return (otherUserId) => createFakeChannel(userId, otherUserId);
}
-function isConnectedToTeamMember(channel) {
- return isTeamMember(channel.teammate_id);
-}
-
-function isTeamMember(userId) {
- return TeamStore.hasActiveMemberInTeam(TeamStore.getCurrentId(), userId);
-}
-
-function isNotConnectedToTeamMember(channel) {
- return TeamStore.hasMemberNotInTeam(TeamStore.getCurrentId(), channel.teammate_id);
-}
-
function not(f) {
return (...args) => !f(...args);
}
+function orX(...fns) {
+ return (...args) => fns.some((f) => f(...args));
+}
+
function andX(...fns) {
return (...args) => fns.every((f) => f(...args));
}
diff --git a/webapp/utils/constants.jsx b/webapp/utils/constants.jsx
index 0f3e217b9..fafad9f44 100644
--- a/webapp/utils/constants.jsx
+++ b/webapp/utils/constants.jsx
@@ -35,6 +35,7 @@ import windows10ThemeImage from 'images/themes/windows_dark.png';
export const Preferences = {
CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show',
+ CATEGORY_GROUP_CHANNEL_SHOW: 'group_channel_show',
CATEGORY_DISPLAY_SETTINGS: 'display_settings',
DISPLAY_PREFER_NICKNAME: 'nickname_full_name',
DISPLAY_PREFER_FULL_NAME: 'full_name',
@@ -164,6 +165,7 @@ export const ActionTypes = keyMirror({
TOGGLE_GET_POST_LINK_MODAL: null,
TOGGLE_GET_TEAM_INVITE_LINK_MODAL: null,
TOGGLE_GET_PUBLIC_LINK_MODAL: null,
+ TOGGLE_DM_MODAL: null,
SUGGESTION_PRETEXT_CHANGED: null,
SUGGESTION_RECEIVED_SUGGESTIONS: null,
@@ -390,8 +392,11 @@ export const Constants = {
],
MONTHS: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
MAX_DMS: 20,
+ MAX_USERS_IN_GM: 8,
+ MIN_USERS_IN_GM: 3,
MAX_CHANNEL_POPOVER_COUNT: 100,
DM_CHANNEL: 'D',
+ GM_CHANNEL: 'G',
OPEN_CHANNEL: 'O',
PRIVATE_CHANNEL: 'P',
INVITE_TEAM: 'I',
diff --git a/webapp/utils/utils.jsx b/webapp/utils/utils.jsx
index 3d7941158..7573eb887 100644
--- a/webapp/utils/utils.jsx
+++ b/webapp/utils/utils.jsx
@@ -477,6 +477,7 @@ export function applyTheme(theme) {
changeCss('.sidebar--left .nav-pills__container li>h4, .sidebar--left .add-channel-btn', 'color:' + changeOpacity(theme.sidebarText, 0.6));
changeCss('.sidebar--left .add-channel-btn:hover, .sidebar--left .add-channel-btn:focus', 'color:' + theme.sidebarText);
changeCss('.sidebar--left .status .offline--icon', 'fill:' + theme.sidebarText);
+ changeCss('.sidebar--left .status.status--group', 'background:' + changeOpacity(theme.sidebarText, 0.3));
changeCss('@media(max-width: 768px){.app__body .modal .settings-modal .settings-table .nav>li>a, .app__body .sidebar--menu .divider', 'border-color:' + changeOpacity(theme.sidebarText, 0.2));
changeCss('@media(max-width: 768px){.sidebar--left .add-channel-btn:hover, .sidebar--left .add-channel-btn:focus', 'color:' + changeOpacity(theme.sidebarText, 0.6));
}
@@ -569,6 +570,7 @@ export function applyTheme(theme) {
}
if (theme.centerChannelColor) {
+ changeCss('.app__body .mentions__name .status.status--group, .app__body .multi-select__note', 'background:' + changeOpacity(theme.centerChannelColor, 0.12));
changeCss('.app__body .post-list__arrows, .app__body .post .flag-icon__container', 'fill:' + changeOpacity(theme.centerChannelColor, 0.3));
changeCss('.app__body .modal .status .offline--icon, .app__body .channel-header__links .icon, .app__body .sidebar--right .sidebar--right__subheader .usage__icon', 'fill:' + theme.centerChannelColor);
changeCss('@media(min-width: 768px){.app__body .post:hover .post__header .col__reply, .app__body .post.post--hovered .post__header .col__reply', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2));
@@ -618,7 +620,7 @@ export function applyTheme(theme) {
changeCss('@media(max-width: 1800px){.app__body .inner-wrap.move--left .post.post--comment.same--root', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.07));
changeCss('.app__body .post.post--hovered', 'background:' + changeOpacity(theme.centerChannelColor, 0.08));
changeCss('@media(min-width: 768px){.app__body .post:hover, .app__body .more-modal__list .more-modal__row:hover, .app__body .modal .settings-modal .settings-table .settings-content .section-min:hover', 'background:' + changeOpacity(theme.centerChannelColor, 0.08));
- changeCss('.app__body .date-separator.hovered--before:after, .app__body .date-separator.hovered--after:before, .app__body .new-separator.hovered--after:before, .app__body .new-separator.hovered--before:after', 'background:' + changeOpacity(theme.centerChannelColor, 0.07));
+ changeCss('.app__body .more-modal__row.more-modal__row--selected, .app__body .date-separator.hovered--before:after, .app__body .date-separator.hovered--after:before, .app__body .new-separator.hovered--after:before, .app__body .new-separator.hovered--before:after', 'background:' + changeOpacity(theme.centerChannelColor, 0.07));
changeCss('@media(min-width: 768px){.app__body .suggestion-list__content .command:hover, .app__body .mentions__name:hover, .app__body .dropdown-menu>li>a:focus, .app__body .dropdown-menu>li>a:hover', 'background:' + changeOpacity(theme.centerChannelColor, 0.15));
changeCss('.app__body .suggestion--selected, .app__body .bot-indicator', 'background:' + changeOpacity(theme.centerChannelColor, 0.15), 1);
changeCss('code, .app__body .form-control[disabled], .app__body .form-control[readonly], .app__body fieldset[disabled] .form-control', 'background:' + changeOpacity(theme.centerChannelColor, 0.1));