* Implement server changes for group messaging

* Majority of client-side implementation

* Some server updates

* Added new React multiselect component

* Fix style issues

* Add custom renderer for options

* Fix model test

* Update ENTER functionality for multiselect control

* Remove buttons from multiselect UI control

* Updating group messaging UI (#5524)

* Move filter controls up a component level

* Scroll with arrow keys

* Updating mobile layout for multiselect (#5534)

* Fix race condition when backspacing quickly

* Hidden or new GMs show up for regular messages

* Add overriding of number remaining text

* Add UI filtering for team if config setting set

* Add icon to channel switcher and class prop to status icon

* Minor updates per feedback

* Improving group messaging UI (#5563)

* UX changes per feedback

* Update email for group messages

* UI fixes for group messaging (#5587)

* Fix missing localization string

* Add maximum users message when adding members to GM

* Fix input clearing on Android

* Updating group messaging UI (#5603)

* Updating UI for group messaging (#5604)
Этот коммит содержится в:
Joram Wilander
2017-03-02 17:48:56 -05:00
коммит произвёл GitHub
родитель 8c5cee9521
Коммит 3a91d4e5e4
48 изменённых файлов: 1596 добавлений и 293 удалений

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

@@ -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
}

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

@@ -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

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

@@ -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

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

@@ -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
}

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

@@ -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

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

@@ -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

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

@@ -199,6 +199,14 @@
"id": "api.channel.create_channel.direct_channel.app_error",
"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"
@@ -887,6 +895,10 @@
"id": "api.email_batching.render_batched_post.direct_message",
"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"
@@ -2151,6 +2163,14 @@
"id": "api.templates.post_subject_in_direct_message",
"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"
@@ -3059,6 +3079,14 @@
"id": "app.import.import_line.null_post.error",
"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"

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

@@ -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,
},

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

@@ -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))
}

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

@@ -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")
}
}

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

@@ -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

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

@@ -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"

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

@@ -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

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

@@ -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');

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

@@ -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();
}
}

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

@@ -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) {

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

@@ -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.GM_CHANNEL) {
continue;
}
if (UserStore.getProfileListInChannel(channel.id).length >= Constants.MIN_USERS_IN_GM) {
continue;
}
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 member = channelMembers[channel.id];
if (!member) {
continue;
}
const teammateId = channel.name.replace(UserStore.getCurrentId(), '').replace('__', '');
const isVisible = PreferenceStore.getBool(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, teammateId);
const teammateId = channel.name.replace(member.user_id, '').replace('__', '');
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);
}
}
);
}

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

@@ -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) {

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

@@ -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`).

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

@@ -76,4 +76,4 @@ MultiSelectSetting.propTypes = {
noResultText: React.PropTypes.node,
errorText: React.PropTypes.node,
notPresent: React.PropTypes.node
};
};

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

@@ -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

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

@@ -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);
}
}

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

@@ -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,10 +140,17 @@ 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));
}
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({
@@ -118,72 +158,51 @@ export default class MoreDirectChannels extends React.Component {
});
}
toggleList(e) {
const listType = e.target.value;
let users;
if (listType === 'any') {
users = UserStore.getProfileList(true);
} else {
users = UserStore.getProfileListInTeam(TeamStore.getCurrentId(), true, true);
}
this.setState({
users,
listType
});
}
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>
</div>
);
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='Youve 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 youre 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
};
};

257
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
};

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

@@ -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
};

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

@@ -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>

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

@@ -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) {

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

@@ -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();

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

@@ -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>

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

@@ -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
};

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

@@ -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);
}
}

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

@@ -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 youre adding a lot of people, consider creating a private group instead.",
"more_direct_channels.new_convo_note.full": "Youve 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...",

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

@@ -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",

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

@@ -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();
},

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

@@ -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 {

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

@@ -9,6 +9,7 @@
@import 'links';
@import 'mentions';
@import 'modal';
@import 'multi-select';
@import 'oauth';
@import 'popover';
@import 'save-button';

59
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;
}
}
}

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

@@ -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;
}

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

@@ -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

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

@@ -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;

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

@@ -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;
}

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

@@ -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;
}

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

@@ -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);
}
}

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

@@ -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();

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

@@ -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);

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

@@ -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));
}

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

@@ -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',

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

@@ -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));