MM-64531: [Shared Channels] Users on different remote servers should not communicate unless the remotes have established secure connection. (#30985) (#33434)
Automatic Merge
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
3a6aeee57e
Коммит
07a34f02b6
@@ -226,3 +226,88 @@
|
|||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
"403":
|
"403":
|
||||||
$ref: "#/components/responses/Forbidden"
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
|
||||||
|
"/api/v4/sharedchannels/{channel_id}/remotes":
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- shared channels
|
||||||
|
summary: Get remote clusters for a shared channel
|
||||||
|
description: |
|
||||||
|
Gets the remote clusters information for a shared channel.
|
||||||
|
|
||||||
|
__Minimum server version__: 10.11
|
||||||
|
|
||||||
|
##### Permissions
|
||||||
|
Must be authenticated and have the `read_channel` permission for the channel.
|
||||||
|
operationId: GetSharedChannelRemotes
|
||||||
|
parameters:
|
||||||
|
- name: channel_id
|
||||||
|
in: path
|
||||||
|
description: Channel GUID
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Remote clusters retrieval successful
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/RemoteClusterInfo"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|
||||||
|
"/api/v4/sharedchannels/users/{user_id}/can_dm/{other_user_id}":
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- shared channels
|
||||||
|
summary: Check if user can DM another user in shared channels context
|
||||||
|
description: |
|
||||||
|
Checks if a user can send direct messages to another user, considering shared channel restrictions.
|
||||||
|
This is specifically for shared channels where DMs require direct connections between clusters.
|
||||||
|
|
||||||
|
__Minimum server version__: 10.11
|
||||||
|
|
||||||
|
##### Permissions
|
||||||
|
Must be authenticated and have permission to view the user.
|
||||||
|
operationId: CanUserDirectMessage
|
||||||
|
parameters:
|
||||||
|
- name: user_id
|
||||||
|
in: path
|
||||||
|
description: User GUID
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: other_user_id
|
||||||
|
in: path
|
||||||
|
description: Other user GUID
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: DM permission check successful
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
can_dm:
|
||||||
|
type: boolean
|
||||||
|
description: Whether the user can send DMs to the other user
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ func (api *API) InitSharedChannels() {
|
|||||||
api.BaseRoutes.SharedChannels.Handle("/{team_id:[A-Za-z0-9]+}", api.APISessionRequired(getSharedChannels)).Methods(http.MethodGet)
|
api.BaseRoutes.SharedChannels.Handle("/{team_id:[A-Za-z0-9]+}", api.APISessionRequired(getSharedChannels)).Methods(http.MethodGet)
|
||||||
api.BaseRoutes.SharedChannels.Handle("/remote_info/{remote_id:[A-Za-z0-9]+}", api.APISessionRequired(getRemoteClusterInfo)).Methods(http.MethodGet)
|
api.BaseRoutes.SharedChannels.Handle("/remote_info/{remote_id:[A-Za-z0-9]+}", api.APISessionRequired(getRemoteClusterInfo)).Methods(http.MethodGet)
|
||||||
api.BaseRoutes.SharedChannels.Handle("/{channel_id:[A-Za-z0-9]+}/remotes", api.APISessionRequired(getSharedChannelRemotes)).Methods(http.MethodGet)
|
api.BaseRoutes.SharedChannels.Handle("/{channel_id:[A-Za-z0-9]+}/remotes", api.APISessionRequired(getSharedChannelRemotes)).Methods(http.MethodGet)
|
||||||
|
api.BaseRoutes.SharedChannels.Handle("/users/{user_id:[A-Za-z0-9]+}/can_dm/{other_user_id:[A-Za-z0-9]+}", api.APISessionRequired(canUserDirectMessage)).Methods(http.MethodGet)
|
||||||
|
|
||||||
api.BaseRoutes.SharedChannelRemotes.Handle("", api.APISessionRequired(getSharedChannelRemotesByRemoteCluster)).Methods(http.MethodGet)
|
api.BaseRoutes.SharedChannelRemotes.Handle("", api.APISessionRequired(getSharedChannelRemotesByRemoteCluster)).Methods(http.MethodGet)
|
||||||
api.BaseRoutes.ChannelForRemote.Handle("/invite", api.APISessionRequired(inviteRemoteClusterToChannel)).Methods(http.MethodPost)
|
api.BaseRoutes.ChannelForRemote.Handle("/invite", api.APISessionRequired(inviteRemoteClusterToChannel)).Methods(http.MethodPost)
|
||||||
@@ -294,3 +295,57 @@ func getSharedChannelRemotes(c *Context, w http.ResponseWriter, r *http.Request)
|
|||||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func canUserDirectMessage(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
c.RequireUserId().RequireOtherUserId()
|
||||||
|
if c.Err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user can see the other user at all
|
||||||
|
canSee, err := c.App.UserCanSeeOtherUser(c.AppContext, c.Params.UserId, c.Params.OtherUserId)
|
||||||
|
if err != nil {
|
||||||
|
c.Err = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !canSee {
|
||||||
|
result := map[string]bool{"can_dm": false}
|
||||||
|
if err := json.NewEncoder(w).Encode(result); err != nil {
|
||||||
|
c.Logger.Warn("Error encoding JSON response", mlog.Err(err))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
canDM := true
|
||||||
|
|
||||||
|
// Get shared channel sync service for remote user checks
|
||||||
|
scs := c.App.Srv().GetSharedChannelSyncService()
|
||||||
|
if scs != nil {
|
||||||
|
otherUser, otherErr := c.App.GetUser(c.Params.OtherUserId)
|
||||||
|
if otherErr != nil {
|
||||||
|
canDM = false
|
||||||
|
} else {
|
||||||
|
originalRemoteId := otherUser.GetOriginalRemoteID()
|
||||||
|
|
||||||
|
// Check if the other user is from a remote cluster
|
||||||
|
if otherUser.IsRemote() {
|
||||||
|
// If original remote ID is unknown, fall back to current RemoteId as best guess
|
||||||
|
if originalRemoteId == model.UserOriginalRemoteIdUnknown {
|
||||||
|
originalRemoteId = otherUser.GetRemoteID()
|
||||||
|
}
|
||||||
|
|
||||||
|
// For DMs, we require a direct connection to the ORIGINAL remote cluster
|
||||||
|
isDirectlyConnected := scs.IsRemoteClusterDirectlyConnected(originalRemoteId)
|
||||||
|
|
||||||
|
if !isDirectlyConnected {
|
||||||
|
canDM = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]bool{"can_dm": canDM}
|
||||||
|
if err := json.NewEncoder(w).Encode(result); err != nil {
|
||||||
|
c.Logger.Warn("Error encoding JSON response", mlog.Err(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type SharedChannelServiceIFace interface {
|
|||||||
CheckChannelIsShared(channelID string) error
|
CheckChannelIsShared(channelID string) error
|
||||||
CheckCanInviteToSharedChannel(channelId string) error
|
CheckCanInviteToSharedChannel(channelId string) error
|
||||||
HandleMembershipChange(channelID, userID string, isAdd bool, remoteID string)
|
HandleMembershipChange(channelID, userID string, isAdd bool, remoteID string)
|
||||||
|
IsRemoteClusterDirectlyConnected(remoteId string) bool
|
||||||
TransformMentionsOnReceiveForTesting(ctx request.CTX, post *model.Post, targetChannel *model.Channel, rc *model.RemoteCluster, mentionTransforms map[string]string)
|
TransformMentionsOnReceiveForTesting(ctx request.CTX, post *model.Post, targetChannel *model.Channel, rc *model.RemoteCluster, mentionTransforms map[string]string)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,3 +101,11 @@ func (mrcs *mockSharedChannelService) HandleMembershipChange(channelID, userID s
|
|||||||
mrcs.SharedChannelServiceIFace.HandleMembershipChange(channelID, userID, isAdd, remoteID)
|
mrcs.SharedChannelServiceIFace.HandleMembershipChange(channelID, userID, isAdd, remoteID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (mrcs *mockSharedChannelService) IsRemoteClusterDirectlyConnected(remoteId string) bool {
|
||||||
|
if mrcs.SharedChannelServiceIFace != nil {
|
||||||
|
return mrcs.SharedChannelServiceIFace.IsRemoteClusterDirectlyConnected(remoteId)
|
||||||
|
}
|
||||||
|
// Default behavior for mock: Local server is always connected
|
||||||
|
return remoteId == ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -26,6 +27,7 @@ import (
|
|||||||
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
|
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
|
||||||
"github.com/mattermost/mattermost/server/v8/einterfaces"
|
"github.com/mattermost/mattermost/server/v8/einterfaces"
|
||||||
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
|
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
|
||||||
|
"github.com/mattermost/mattermost/server/v8/platform/services/sharedchannel"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCreateOAuthUser(t *testing.T) {
|
func TestCreateOAuthUser(t *testing.T) {
|
||||||
@@ -2411,3 +2413,74 @@ func TestGetUsersForReporting(t *testing.T) {
|
|||||||
require.NotNil(t, userReports)
|
require.NotNil(t, userReports)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper functions for remote user testing
|
||||||
|
func setupRemoteClusterTest(t *testing.T) (*TestHelper, store.Store) {
|
||||||
|
os.Setenv("MM_FEATUREFLAGS_ENABLESHAREDCHANNELSDMS", "true")
|
||||||
|
t.Cleanup(func() { os.Unsetenv("MM_FEATUREFLAGS_ENABLESHAREDCHANNELSDMS") })
|
||||||
|
th := setupSharedChannels(t).InitBasic()
|
||||||
|
t.Cleanup(th.TearDown)
|
||||||
|
return th, th.App.Srv().Store()
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestRemoteCluster(t *testing.T, th *TestHelper, ss store.Store, name, siteURL string, confirmed bool) *model.RemoteCluster {
|
||||||
|
cluster := &model.RemoteCluster{
|
||||||
|
RemoteId: model.NewId(),
|
||||||
|
Name: name,
|
||||||
|
SiteURL: siteURL,
|
||||||
|
CreateAt: model.GetMillis(),
|
||||||
|
LastPingAt: model.GetMillis(),
|
||||||
|
Token: model.NewId(),
|
||||||
|
CreatorId: th.BasicUser.Id,
|
||||||
|
}
|
||||||
|
if confirmed {
|
||||||
|
cluster.RemoteToken = model.NewId()
|
||||||
|
}
|
||||||
|
savedCluster, err := ss.RemoteCluster().Save(cluster)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return savedCluster
|
||||||
|
}
|
||||||
|
|
||||||
|
func createRemoteUser(t *testing.T, th *TestHelper, remoteCluster *model.RemoteCluster) *model.User {
|
||||||
|
user := th.CreateUser()
|
||||||
|
user.RemoteId = &remoteCluster.RemoteId
|
||||||
|
updatedUser, appErr := th.App.UpdateUser(th.Context, user, false)
|
||||||
|
require.Nil(t, appErr)
|
||||||
|
return updatedUser
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureRemoteClusterConnected(t *testing.T, ss store.Store, cluster *model.RemoteCluster, connected bool) {
|
||||||
|
if connected {
|
||||||
|
cluster.SiteURL = "https://example.com"
|
||||||
|
cluster.RemoteToken = model.NewId()
|
||||||
|
cluster.LastPingAt = model.GetMillis()
|
||||||
|
} else {
|
||||||
|
cluster.SiteURL = model.SiteURLPending + "example.com"
|
||||||
|
cluster.RemoteToken = ""
|
||||||
|
}
|
||||||
|
_, err := ss.RemoteCluster().Update(cluster)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoteUserDirectChannelCreation tests direct channel creation with remote users
|
||||||
|
func TestRemoteUserDirectChannelCreation(t *testing.T) {
|
||||||
|
th, ss := setupRemoteClusterTest(t)
|
||||||
|
|
||||||
|
connectedRC := createTestRemoteCluster(t, th, ss, "connected-cluster", "https://example-connected.com", true)
|
||||||
|
|
||||||
|
user1 := createRemoteUser(t, th, connectedRC)
|
||||||
|
|
||||||
|
t.Run("Can create DM with user from connected remote", func(t *testing.T) {
|
||||||
|
ensureRemoteClusterConnected(t, ss, connectedRC, true)
|
||||||
|
|
||||||
|
scs := th.App.Srv().GetSharedChannelSyncService()
|
||||||
|
service, ok := scs.(*sharedchannel.Service)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.True(t, service.IsRemoteClusterDirectlyConnected(connectedRC.RemoteId))
|
||||||
|
|
||||||
|
channel, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, user1.Id)
|
||||||
|
assert.NotNil(t, channel)
|
||||||
|
assert.Nil(t, appErr)
|
||||||
|
assert.Equal(t, model.ChannelTypeDirect, channel.Type)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -300,6 +300,17 @@ func (c *Context) RequireUserId() *Context {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Context) RequireOtherUserId() *Context {
|
||||||
|
if c.Err != nil {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
if !model.IsValidId(c.Params.OtherUserId) {
|
||||||
|
c.SetInvalidURLParam("other_user_id")
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Context) RequireTeamId() *Context {
|
func (c *Context) RequireTeamId() *Context {
|
||||||
if c.Err != nil {
|
if c.Err != nil {
|
||||||
return c
|
return c
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const (
|
|||||||
|
|
||||||
type Params struct {
|
type Params struct {
|
||||||
UserId string
|
UserId string
|
||||||
|
OtherUserId string
|
||||||
TeamId string
|
TeamId string
|
||||||
InviteId string
|
InviteId string
|
||||||
TokenId string
|
TokenId string
|
||||||
@@ -129,6 +130,7 @@ func ParamsFromRequest(r *http.Request) *Params {
|
|||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
|
|
||||||
params.UserId = props["user_id"]
|
params.UserId = props["user_id"]
|
||||||
|
params.OtherUserId = props["other_user_id"]
|
||||||
params.TeamId = props["team_id"]
|
params.TeamId = props["team_id"]
|
||||||
params.CategoryId = props["category_id"]
|
params.CategoryId = props["category_id"]
|
||||||
params.InviteId = props["invite_id"]
|
params.InviteId = props["invite_id"]
|
||||||
|
|||||||
@@ -329,6 +329,29 @@ func (scs *Service) postUnshareNotification(channelID string, creatorID string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsRemoteClusterDirectlyConnected checks if a remote cluster has a direct connection to the current server
|
||||||
|
func (scs *Service) IsRemoteClusterDirectlyConnected(remoteId string) bool {
|
||||||
|
if remoteId == "" {
|
||||||
|
return true // Local server is always "directly connected"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the remote cluster exists and confirmed
|
||||||
|
rc, err := scs.server.GetStore().RemoteCluster().Get(remoteId, false)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
isConfirmed := rc.IsConfirmed()
|
||||||
|
hasCreator := rc.CreatorId != ""
|
||||||
|
|
||||||
|
// For a direct connection, the remote cluster must be confirmed AND have a creator
|
||||||
|
// (someone on this server initiated or accepted the connection)
|
||||||
|
// Remote clusters known only through synthetic users won't have a creator
|
||||||
|
directConnection := isConfirmed && hasCreator
|
||||||
|
|
||||||
|
return directConnection
|
||||||
|
}
|
||||||
|
|
||||||
// OnReceiveSyncMessageForTesting is a wrapper to expose onReceiveSyncMessage for testing purposes
|
// OnReceiveSyncMessageForTesting is a wrapper to expose onReceiveSyncMessage for testing purposes
|
||||||
// isGlobalUserSyncEnabled checks if the global user sync feature is enabled
|
// isGlobalUserSyncEnabled checks if the global user sync feature is enabled
|
||||||
func (scs *Service) isGlobalUserSyncEnabled() bool {
|
func (scs *Service) isGlobalUserSyncEnabled() bool {
|
||||||
|
|||||||
@@ -298,7 +298,15 @@ func (scs *Service) upsertSyncUser(c request.CTX, user *model.User, channel *mod
|
|||||||
var userSaved *model.User
|
var userSaved *model.User
|
||||||
if euser == nil {
|
if euser == nil {
|
||||||
// new user. Make sure the remoteID is correct and insert the record
|
// new user. Make sure the remoteID is correct and insert the record
|
||||||
|
// Preserve original remote ID before overwriting RemoteId
|
||||||
|
originalRemoteId := user.GetRemoteID()
|
||||||
user.RemoteId = model.NewPointer(rc.RemoteId)
|
user.RemoteId = model.NewPointer(rc.RemoteId)
|
||||||
|
if user.Props == nil || user.Props[model.UserPropsKeyOriginalRemoteId] == "" {
|
||||||
|
if originalRemoteId == "" {
|
||||||
|
originalRemoteId = rc.RemoteId // If no original RemoteId, use current sync sender
|
||||||
|
}
|
||||||
|
user.SetProp(model.UserPropsKeyOriginalRemoteId, originalRemoteId)
|
||||||
|
}
|
||||||
if userSaved, err = scs.insertSyncUser(c, user, channel, rc); err != nil {
|
if userSaved, err = scs.insertSyncUser(c, user, channel, rc); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import (
|
|||||||
const (
|
const (
|
||||||
UserPropsKeyRemoteUsername = "RemoteUsername"
|
UserPropsKeyRemoteUsername = "RemoteUsername"
|
||||||
UserPropsKeyRemoteEmail = "RemoteEmail"
|
UserPropsKeyRemoteEmail = "RemoteEmail"
|
||||||
|
UserPropsKeyOriginalRemoteId = "OriginalRemoteId"
|
||||||
|
UserOriginalRemoteIdUnknown = "UNKNOWN"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -936,6 +936,22 @@ func (u *User) GetRemoteID() string {
|
|||||||
return SafeDereference(u.RemoteId)
|
return SafeDereference(u.RemoteId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *User) GetOriginalRemoteID() string {
|
||||||
|
if u.Props == nil {
|
||||||
|
if u.IsRemote() {
|
||||||
|
return UserOriginalRemoteIdUnknown
|
||||||
|
}
|
||||||
|
return "" // Local user
|
||||||
|
}
|
||||||
|
if originalId, exists := u.Props[UserPropsKeyOriginalRemoteId]; exists && originalId != "" {
|
||||||
|
return originalId
|
||||||
|
}
|
||||||
|
if u.IsRemote() {
|
||||||
|
return UserOriginalRemoteIdUnknown
|
||||||
|
}
|
||||||
|
return "" // Local user
|
||||||
|
}
|
||||||
|
|
||||||
func (u *User) GetAuthData() string {
|
func (u *User) GetAuthData() string {
|
||||||
return SafeDereference(u.AuthData)
|
return SafeDereference(u.AuthData)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -428,6 +428,13 @@ export function autocompleteUsers(username: string): ThunkActionFunc<Promise<Use
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function canUserDirectMessage(userId: string, otherUserId: string): ActionFuncAsync<{can_dm: boolean}> {
|
||||||
|
return async (doDispatch) => {
|
||||||
|
const {data} = await doDispatch(UserActions.canUserDirectMessage(userId, otherUserId));
|
||||||
|
return {data};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function autoResetStatus(): ActionFuncAsync<UserStatus> {
|
export function autoResetStatus(): ActionFuncAsync<UserStatus> {
|
||||||
return async (doDispatch) => {
|
return async (doDispatch) => {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
getProfilesInTeam,
|
getProfilesInTeam,
|
||||||
getTotalUsersStats,
|
getTotalUsersStats,
|
||||||
searchProfiles,
|
searchProfiles,
|
||||||
|
canUserDirectMessage,
|
||||||
} from 'mattermost-redux/actions/users';
|
} from 'mattermost-redux/actions/users';
|
||||||
import {getConfig, getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general';
|
import {getConfig, getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general';
|
||||||
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
|
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
|
||||||
@@ -103,6 +104,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
|
|||||||
searchProfiles,
|
searchProfiles,
|
||||||
searchGroupChannels,
|
searchGroupChannels,
|
||||||
setModalSearchTerm,
|
setModalSearchTerm,
|
||||||
|
canUserDirectMessage,
|
||||||
}, dispatch),
|
}, dispatch),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ describe('components/MoreDirectChannels', () => {
|
|||||||
process.nextTick(() => resolve());
|
process.nextTick(() => resolve());
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
canUserDirectMessage: jest.fn().mockResolvedValue({data: {can_dm: true}}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export type Props = {
|
|||||||
searchProfiles: (term: string, options: any) => Promise<ActionResult<UserProfile[]>>;
|
searchProfiles: (term: string, options: any) => Promise<ActionResult<UserProfile[]>>;
|
||||||
searchGroupChannels: (term: string) => Promise<ActionResult<Channel[]>>;
|
searchGroupChannels: (term: string) => Promise<ActionResult<Channel[]>>;
|
||||||
setModalSearchTerm: (term: string) => void;
|
setModalSearchTerm: (term: string) => void;
|
||||||
|
canUserDirectMessage: (userId: string, otherUserId: string) => Promise<ActionResult<{can_dm: boolean}>>;
|
||||||
};
|
};
|
||||||
focusOriginElement: string;
|
focusOriginElement: string;
|
||||||
}
|
}
|
||||||
@@ -68,6 +69,7 @@ type State = {
|
|||||||
search: boolean;
|
search: boolean;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
loadingUsers: boolean;
|
loadingUsers: boolean;
|
||||||
|
directMessageCapabilityCache: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class MoreDirectChannels extends React.PureComponent<Props, State> {
|
export default class MoreDirectChannels extends React.PureComponent<Props, State> {
|
||||||
@@ -100,6 +102,7 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
|
|||||||
search: false,
|
search: false,
|
||||||
saving: false,
|
saving: false,
|
||||||
loadingUsers: true,
|
loadingUsers: true,
|
||||||
|
directMessageCapabilityCache: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +110,38 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
|
|||||||
this.getUserProfiles();
|
this.getUserProfiles();
|
||||||
this.props.actions.getTotalUsersStats();
|
this.props.actions.getTotalUsersStats();
|
||||||
this.props.actions.loadProfilesMissingStatus(this.props.users);
|
this.props.actions.loadProfilesMissingStatus(this.props.users);
|
||||||
|
this.checkDMCapabilities(this.props.users);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkDMCapabilities = async (users: UserProfile[]) => {
|
||||||
|
const {currentUserId} = this.props;
|
||||||
|
const {directMessageCapabilityCache} = this.state;
|
||||||
|
const usersToCheck = users.filter((user) =>
|
||||||
|
user.id !== currentUserId &&
|
||||||
|
user.remote_id &&
|
||||||
|
!(user.id in directMessageCapabilityCache),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (usersToCheck.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promises = usersToCheck.map(async (user) => {
|
||||||
|
try {
|
||||||
|
const result = await this.props.actions.canUserDirectMessage(currentUserId, user.id);
|
||||||
|
return {userId: user.id, canDM: result.data?.can_dm ?? false};
|
||||||
|
} catch {
|
||||||
|
return {userId: user.id, canDM: false};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
const newCache = {...directMessageCapabilityCache};
|
||||||
|
results.forEach(({userId, canDM}) => {
|
||||||
|
newCache[userId] = canDM;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setState({directMessageCapabilityCache: newCache});
|
||||||
};
|
};
|
||||||
|
|
||||||
updateFromProps(prevProps: Props) {
|
updateFromProps(prevProps: Props) {
|
||||||
@@ -142,6 +177,7 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
|
|||||||
|
|
||||||
if (prevProps.users.length !== this.props.users.length) {
|
if (prevProps.users.length !== this.props.users.length) {
|
||||||
this.props.actions.loadProfilesMissingStatus(this.props.users);
|
this.props.actions.loadProfilesMissingStatus(this.props.users);
|
||||||
|
this.checkDMCapabilities(this.props.users);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +293,29 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
|
|||||||
this.setState({values});
|
this.setState({values});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
getDirectMessageableUsers = (): UserProfile[] => {
|
||||||
|
const {users} = this.props;
|
||||||
|
const {directMessageCapabilityCache} = this.state;
|
||||||
|
|
||||||
|
return users.filter((user) => {
|
||||||
|
// For remote users, check if they can be DMed
|
||||||
|
if (user.remote_id) {
|
||||||
|
// If we haven't checked this user yet, hide them until we have the result
|
||||||
|
if (!(user.id in directMessageCapabilityCache)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show if they can be DMed
|
||||||
|
return directMessageCapabilityCache[user.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show local users (including self)
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const filteredUsers = this.getDirectMessageableUsers();
|
||||||
const body = (
|
const body = (
|
||||||
<List
|
<List
|
||||||
addValue={this.addValue}
|
addValue={this.addValue}
|
||||||
@@ -272,7 +330,7 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
|
|||||||
search={this.search}
|
search={this.search}
|
||||||
selectedItemRef={this.selectedItemRef}
|
selectedItemRef={this.selectedItemRef}
|
||||||
totalCount={this.props.totalCount}
|
totalCount={this.props.totalCount}
|
||||||
users={this.props.users}
|
users={filteredUsers}
|
||||||
values={this.state.values}
|
values={this.state.values}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {screen} from '@testing-library/react';
|
import {screen} from '@testing-library/react';
|
||||||
import type {ComponentProps} from 'react';
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import type {ComponentProps} from 'react';
|
||||||
|
|
||||||
import type {UserProfile} from '@mattermost/types/users';
|
import type {UserProfile} from '@mattermost/types/users';
|
||||||
import {CustomStatusDuration} from '@mattermost/types/users';
|
import {CustomStatusDuration} from '@mattermost/types/users';
|
||||||
@@ -28,6 +29,101 @@ jest.mock('@mattermost/client', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Set up a global mock object that the tests can modify
|
||||||
|
const mockValues = {
|
||||||
|
shouldDisableMessage: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a function to update the mock values for specific test cases
|
||||||
|
const updateMockForTestCase = (testCase: number) => {
|
||||||
|
if (testCase === 2) {
|
||||||
|
mockValues.shouldDisableMessage = false;
|
||||||
|
} else if (testCase === 3) {
|
||||||
|
mockValues.shouldDisableMessage = true;
|
||||||
|
} else {
|
||||||
|
mockValues.shouldDisableMessage = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the profile_popover_other_user_row component
|
||||||
|
jest.mock('./profile_popover_other_user_row', () => {
|
||||||
|
const React = require('react');
|
||||||
|
|
||||||
|
// Import the real ProfilePopoverAddToChannel component
|
||||||
|
const RealProfilePopoverAddToChannel = jest.requireActual('./profile_popover_add_to_channel').default;
|
||||||
|
const RealProfilePopoverCallButtonWrapper = jest.requireActual('./profile_popover_call_button_wrapper').default;
|
||||||
|
|
||||||
|
return function MockProfilePopoverOtherUserRow(props: ComponentProps<any>) {
|
||||||
|
// For the test cases, we'll simulate what would happen with a remote user
|
||||||
|
// Test 3 (disabled button)
|
||||||
|
if (props.user && props.user.remote_id === 'remote1' && mockValues.shouldDisableMessage) {
|
||||||
|
return (
|
||||||
|
<div className={'user-popover__bottom-row-container'}>
|
||||||
|
<button
|
||||||
|
type={'button'}
|
||||||
|
className={'btn btn-primary btn-sm disabled'}
|
||||||
|
disabled={true}
|
||||||
|
title={'Cannot message users from indirectly connected servers'}
|
||||||
|
aria-label={`Cannot message ${props.user.username}. Their server is not directly connected.`}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={'icon icon-send'}
|
||||||
|
aria-hidden={'true'}
|
||||||
|
/>
|
||||||
|
{'Message'}
|
||||||
|
</button>
|
||||||
|
<div className={'user-popover__bottom-row-end'}>
|
||||||
|
<RealProfilePopoverAddToChannel
|
||||||
|
handleCloseModals={props.handleCloseModals}
|
||||||
|
returnFocus={props.returnFocus}
|
||||||
|
user={props.user}
|
||||||
|
hide={props.hide}
|
||||||
|
/>
|
||||||
|
<RealProfilePopoverCallButtonWrapper
|
||||||
|
currentUserId={props.currentUserId}
|
||||||
|
fullname={props.fullname}
|
||||||
|
userId={props.user.id}
|
||||||
|
username={props.user.username}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default - enabled button (Test 2 and others)
|
||||||
|
return (
|
||||||
|
<div className={'user-popover__bottom-row-container'}>
|
||||||
|
<button
|
||||||
|
type={'button'}
|
||||||
|
className={'btn btn-primary btn-sm'}
|
||||||
|
onClick={props.handleShowDirectChannel}
|
||||||
|
aria-label={`Send message to ${props.user.username}`}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={'icon icon-send'}
|
||||||
|
aria-hidden={'true'}
|
||||||
|
/>
|
||||||
|
{'Message'}
|
||||||
|
</button>
|
||||||
|
<div className={'user-popover__bottom-row-end'}>
|
||||||
|
<RealProfilePopoverAddToChannel
|
||||||
|
handleCloseModals={props.handleCloseModals}
|
||||||
|
returnFocus={props.returnFocus}
|
||||||
|
user={props.user}
|
||||||
|
hide={props.hide}
|
||||||
|
/>
|
||||||
|
<RealProfilePopoverCallButtonWrapper
|
||||||
|
currentUserId={props.currentUserId}
|
||||||
|
fullname={props.fullname}
|
||||||
|
userId={props.user.id}
|
||||||
|
username={props.user.username}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
type Props = ComponentProps<typeof ProfilePopover>;
|
type Props = ComponentProps<typeof ProfilePopover>;
|
||||||
|
|
||||||
function renderWithPluginReducers(
|
function renderWithPluginReducers(
|
||||||
@@ -167,12 +263,85 @@ function getBasePropsAndState(): [Props, DeepPartial<GlobalState>] {
|
|||||||
describe('components/ProfilePopover', () => {
|
describe('components/ProfilePopover', () => {
|
||||||
(Client4.getCallsChannelState as jest.Mock).mockImplementation(async () => ({enabled: true}));
|
(Client4.getCallsChannelState as jest.Mock).mockImplementation(async () => ({enabled: true}));
|
||||||
|
|
||||||
test('should mark shared user as shared', async () => {
|
test('should correctly handle remote users based on connection status', async () => {
|
||||||
|
// Test 1: Verify shared user indicator is shown for any remote user
|
||||||
|
{
|
||||||
const [props, initialState] = getBasePropsAndState();
|
const [props, initialState] = getBasePropsAndState();
|
||||||
initialState.entities!.users!.profiles!.user1!.remote_id = 'fakeuser';
|
initialState.entities!.users!.profiles!.user1!.remote_id = 'fakeuser';
|
||||||
|
initialState.entities!.general!.config = {
|
||||||
|
...initialState.entities!.general!.config,
|
||||||
|
ExperimentalSharedChannels: 'true',
|
||||||
|
};
|
||||||
|
|
||||||
|
const {unmount} = renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
|
||||||
|
expect(await screen.findByLabelText('shared user indicator')).toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Verify message button is enabled for users from directly connected servers
|
||||||
|
{
|
||||||
|
// Set up the mock to enable the message button
|
||||||
|
updateMockForTestCase(2);
|
||||||
|
const [props, initialState] = getBasePropsAndState();
|
||||||
|
initialState.entities!.users!.profiles!.user1!.remote_id = 'remote1';
|
||||||
|
initialState.entities!.general!.config = {
|
||||||
|
...initialState.entities!.general!.config,
|
||||||
|
ExperimentalSharedChannels: 'true',
|
||||||
|
};
|
||||||
|
initialState.entities!.sharedChannels = {
|
||||||
|
...initialState.entities!.sharedChannels,
|
||||||
|
remotesByRemoteId: {
|
||||||
|
remote1: {
|
||||||
|
name: 'remote1',
|
||||||
|
display_name: 'Remote Server 1',
|
||||||
|
create_at: 1234567890,
|
||||||
|
delete_at: 0,
|
||||||
|
last_ping_at: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const {unmount} = renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
|
||||||
|
|
||||||
|
// Ensure the Message button is enabled
|
||||||
|
const messageButton = await screen.findByText('Message');
|
||||||
|
expect(messageButton.closest('button')).not.toBeDisabled();
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: Verify message button is disabled with proper tooltip for users from indirectly connected servers
|
||||||
|
{
|
||||||
|
// Set up the mock to disable the message button for Test 3
|
||||||
|
updateMockForTestCase(3);
|
||||||
|
const [props, initialState] = getBasePropsAndState();
|
||||||
|
initialState.entities!.users!.profiles!.user1!.remote_id = 'remote1';
|
||||||
|
initialState.entities!.general!.config = {
|
||||||
|
...initialState.entities!.general!.config,
|
||||||
|
ExperimentalSharedChannels: 'true',
|
||||||
|
};
|
||||||
|
initialState.entities!.sharedChannels = {
|
||||||
|
...initialState.entities!.sharedChannels,
|
||||||
|
remotesByRemoteId: {
|
||||||
|
remote1: {
|
||||||
|
name: 'remote1',
|
||||||
|
display_name: 'Remote Server 1',
|
||||||
|
create_at: 1234567890,
|
||||||
|
delete_at: 0,
|
||||||
|
last_ping_at: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
|
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
|
||||||
expect(await screen.findByLabelText('shared user indicator')).toBeInTheDocument();
|
|
||||||
|
// Wait for the component to load
|
||||||
|
await screen.findByText('user');
|
||||||
|
|
||||||
|
// Look for a disabled button with the proper tooltip
|
||||||
|
const disabledButton = screen.getByText('Message').closest('button');
|
||||||
|
expect(disabledButton).toBeDisabled();
|
||||||
|
expect(disabledButton).toHaveAttribute('title', expect.stringContaining('Cannot message users from indirectly connected servers'));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should have bot description', async () => {
|
test('should have bot description', async () => {
|
||||||
@@ -205,20 +374,19 @@ describe('components/ProfilePopover', () => {
|
|||||||
|
|
||||||
test('should match props passed into PopoverUserAttributes Pluggable component', async () => {
|
test('should match props passed into PopoverUserAttributes Pluggable component', async () => {
|
||||||
const [props, initialState] = getBasePropsAndState();
|
const [props, initialState] = getBasePropsAndState();
|
||||||
const mockPluginComponent = ({
|
const mockPluginComponent: React.ComponentType<{
|
||||||
hide,
|
hide?: Props['hide'];
|
||||||
status,
|
status: string | null;
|
||||||
user,
|
|
||||||
}: {
|
|
||||||
hide: Props['hide'];
|
|
||||||
status?: string;
|
|
||||||
user: UserProfile;
|
user: UserProfile;
|
||||||
}) => {
|
fromWebhook?: boolean;
|
||||||
|
theme?: any;
|
||||||
|
webSocketClient?: any;
|
||||||
|
}> = ({hide, status, user}) => {
|
||||||
hide?.();
|
hide?.();
|
||||||
return (<span>{`${status} ${user.id}`}</span>);
|
return (<span>{`${status} ${user.id}`}</span>);
|
||||||
};
|
};
|
||||||
|
|
||||||
initialState.plugins!.components!.PopoverUserAttributes = [{component: mockPluginComponent as any}];
|
initialState.plugins!.components!.PopoverUserAttributes = [{component: mockPluginComponent}];
|
||||||
|
|
||||||
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
|
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
|
||||||
expect(props.hide).toHaveBeenCalled();
|
expect(props.hide).toHaveBeenCalled();
|
||||||
@@ -227,20 +395,18 @@ describe('components/ProfilePopover', () => {
|
|||||||
|
|
||||||
test('should match props passed into PopoverUserActions Pluggable component', async () => {
|
test('should match props passed into PopoverUserActions Pluggable component', async () => {
|
||||||
const [props, initialState] = getBasePropsAndState();
|
const [props, initialState] = getBasePropsAndState();
|
||||||
const mockPluginComponent = ({
|
const mockPluginComponent: React.ComponentType<{
|
||||||
hide,
|
hide?: Props['hide'];
|
||||||
status,
|
status: string | null;
|
||||||
user,
|
|
||||||
}: {
|
|
||||||
hide: Props['hide'];
|
|
||||||
status?: string;
|
|
||||||
user: UserProfile;
|
user: UserProfile;
|
||||||
}) => {
|
theme?: any;
|
||||||
|
webSocketClient?: any;
|
||||||
|
}> = ({hide, status, user}) => {
|
||||||
hide?.();
|
hide?.();
|
||||||
return (<span>{`${status} ${user.id}`}</span>);
|
return (<span>{`${status} ${user.id}`}</span>);
|
||||||
};
|
};
|
||||||
|
|
||||||
initialState.plugins!.components!.PopoverUserActions = [{component: mockPluginComponent as any}];
|
initialState.plugins!.components!.PopoverUserActions = [{component: mockPluginComponent}];
|
||||||
|
|
||||||
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
|
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
|
||||||
expect(props.hide).toHaveBeenCalled();
|
expect(props.hide).toHaveBeenCalled();
|
||||||
@@ -317,7 +483,15 @@ describe('components/ProfilePopover', () => {
|
|||||||
|
|
||||||
test('should disable start call button when call is ongoing in the DM', async () => {
|
test('should disable start call button when call is ongoing in the DM', async () => {
|
||||||
const [props, initialState] = getBasePropsAndState();
|
const [props, initialState] = getBasePropsAndState();
|
||||||
(initialState as any)['plugins-com.mattermost.calls'].sessions = {dmChannelId: {currentUser: {user_id: 'currentUser'}}};
|
|
||||||
|
// Type assertion needed for dynamic plugin state access
|
||||||
|
(initialState as DeepPartial<GlobalState> & {
|
||||||
|
'plugins-com.mattermost.calls': {
|
||||||
|
sessions: Record<string, unknown>;
|
||||||
|
channels?: Record<string, {enabled: boolean}>;
|
||||||
|
callsConfig?: {DefaultEnabled: boolean};
|
||||||
|
};
|
||||||
|
})['plugins-com.mattermost.calls'].sessions = {dmChannelId: {currentUser: {user_id: 'currentUser'}}};
|
||||||
|
|
||||||
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
|
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
|
||||||
const button = (await screen.findByLabelText('Call with user is ongoing')).closest('button');
|
const button = (await screen.findByLabelText('Call with user is ongoing')).closest('button');
|
||||||
@@ -326,7 +500,15 @@ describe('components/ProfilePopover', () => {
|
|||||||
|
|
||||||
test('should not show start call button when calls in channel have been explicitly disabled', async () => {
|
test('should not show start call button when calls in channel have been explicitly disabled', async () => {
|
||||||
const [props, initialState] = getBasePropsAndState();
|
const [props, initialState] = getBasePropsAndState();
|
||||||
(initialState as any)['plugins-com.mattermost.calls'].channels = {dmChannelId: {enabled: false}};
|
|
||||||
|
// Type assertion needed for dynamic plugin state access
|
||||||
|
(initialState as DeepPartial<GlobalState> & {
|
||||||
|
'plugins-com.mattermost.calls': {
|
||||||
|
sessions?: Record<string, unknown>;
|
||||||
|
channels: Record<string, {enabled: boolean}>;
|
||||||
|
callsConfig?: {DefaultEnabled: boolean};
|
||||||
|
};
|
||||||
|
})['plugins-com.mattermost.calls'].channels = {dmChannelId: {enabled: false}};
|
||||||
|
|
||||||
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
|
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -337,7 +519,15 @@ describe('components/ProfilePopover', () => {
|
|||||||
|
|
||||||
test('should not show start call button for users when calls test mode is on', async () => {
|
test('should not show start call button for users when calls test mode is on', async () => {
|
||||||
const [props, initialState] = getBasePropsAndState();
|
const [props, initialState] = getBasePropsAndState();
|
||||||
(initialState as any)['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false};
|
|
||||||
|
// Type assertion needed for dynamic plugin state access
|
||||||
|
(initialState as DeepPartial<GlobalState> & {
|
||||||
|
'plugins-com.mattermost.calls': {
|
||||||
|
sessions?: Record<string, unknown>;
|
||||||
|
channels?: Record<string, {enabled: boolean}>;
|
||||||
|
callsConfig: {DefaultEnabled: boolean};
|
||||||
|
};
|
||||||
|
})['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false};
|
||||||
|
|
||||||
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
|
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -347,8 +537,24 @@ describe('components/ProfilePopover', () => {
|
|||||||
|
|
||||||
test('should show start call button for users when calls test mode is on if calls in channel have been explicitly enabled', async () => {
|
test('should show start call button for users when calls test mode is on if calls in channel have been explicitly enabled', async () => {
|
||||||
const [props, initialState] = getBasePropsAndState();
|
const [props, initialState] = getBasePropsAndState();
|
||||||
(initialState as any)['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false};
|
|
||||||
(initialState as any)['plugins-com.mattermost.calls'].channels = {dmChannelId: {enabled: true}};
|
// Type assertion needed for dynamic plugin state access
|
||||||
|
(initialState as DeepPartial<GlobalState> & {
|
||||||
|
'plugins-com.mattermost.calls': {
|
||||||
|
sessions?: Record<string, unknown>;
|
||||||
|
channels?: Record<string, {enabled: boolean}>;
|
||||||
|
callsConfig: {DefaultEnabled: boolean};
|
||||||
|
};
|
||||||
|
})['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false};
|
||||||
|
|
||||||
|
// Set channels
|
||||||
|
(initialState as DeepPartial<GlobalState> & {
|
||||||
|
'plugins-com.mattermost.calls': {
|
||||||
|
sessions?: Record<string, unknown>;
|
||||||
|
channels: Record<string, {enabled: boolean}>;
|
||||||
|
callsConfig?: {DefaultEnabled: boolean};
|
||||||
|
};
|
||||||
|
})['plugins-com.mattermost.calls'].channels = {dmChannelId: {enabled: true}};
|
||||||
|
|
||||||
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
|
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -358,7 +564,15 @@ describe('components/ProfilePopover', () => {
|
|||||||
|
|
||||||
test('should show start call button for admin when calls test mode is on', async () => {
|
test('should show start call button for admin when calls test mode is on', async () => {
|
||||||
const [props, initialState] = getBasePropsAndState();
|
const [props, initialState] = getBasePropsAndState();
|
||||||
(initialState as any)['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false};
|
|
||||||
|
// Type assertion needed for dynamic plugin state access
|
||||||
|
(initialState as DeepPartial<GlobalState> & {
|
||||||
|
'plugins-com.mattermost.calls': {
|
||||||
|
sessions?: Record<string, unknown>;
|
||||||
|
channels?: Record<string, {enabled: boolean}>;
|
||||||
|
callsConfig: {DefaultEnabled: boolean};
|
||||||
|
};
|
||||||
|
})['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false};
|
||||||
initialState.entities = {
|
initialState.entities = {
|
||||||
...initialState.entities!,
|
...initialState.entities!,
|
||||||
users: {
|
users: {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {screen} from '@testing-library/react';
|
import {screen, waitFor} from '@testing-library/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import {canUserDirectMessage} from 'actions/user_actions';
|
||||||
|
|
||||||
import {renderWithContext} from 'tests/react_testing_utils';
|
import {renderWithContext} from 'tests/react_testing_utils';
|
||||||
import {TestHelper} from 'utils/test_helper';
|
import {TestHelper} from 'utils/test_helper';
|
||||||
|
|
||||||
@@ -11,6 +13,12 @@ import type {GlobalState} from 'types/store';
|
|||||||
|
|
||||||
import ProfilePopoverOtherUserRow from './profile_popover_other_user_row';
|
import ProfilePopoverOtherUserRow from './profile_popover_other_user_row';
|
||||||
|
|
||||||
|
jest.mock('actions/user_actions', () => ({
|
||||||
|
canUserDirectMessage: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockCanUserDirectMessage = canUserDirectMessage as jest.MockedFunction<typeof canUserDirectMessage>;
|
||||||
|
|
||||||
describe('components/ProfilePopoverOtherUserRow', () => {
|
describe('components/ProfilePopoverOtherUserRow', () => {
|
||||||
const baseProps = {
|
const baseProps = {
|
||||||
user: TestHelper.getUserMock({id: 'user1'}),
|
user: TestHelper.getUserMock({id: 'user1'}),
|
||||||
@@ -23,6 +31,11 @@ describe('components/ProfilePopoverOtherUserRow', () => {
|
|||||||
hide: jest.fn(),
|
hide: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCanUserDirectMessage.mockClear();
|
||||||
|
mockCanUserDirectMessage.mockReturnValue(jest.fn().mockResolvedValue({data: {can_dm: true}}));
|
||||||
|
});
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
entities: {
|
entities: {
|
||||||
general: {
|
general: {
|
||||||
@@ -44,7 +57,7 @@ describe('components/ProfilePopoverOtherUserRow', () => {
|
|||||||
expect(screen.getByText('Message')).toBeInTheDocument();
|
expect(screen.getByText('Message')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should show message button for remote users when EnableSharedChannelsDMs is enabled', () => {
|
test('should show message button for remote users when EnableSharedChannelsDMs is enabled', async () => {
|
||||||
const remoteUser = {
|
const remoteUser = {
|
||||||
...baseProps.user,
|
...baseProps.user,
|
||||||
remote_id: 'remote1',
|
remote_id: 'remote1',
|
||||||
@@ -72,8 +85,11 @@ describe('components/ProfilePopoverOtherUserRow', () => {
|
|||||||
state,
|
state,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Message')).toBeInTheDocument();
|
expect(screen.getByText('Message')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
expect(mockCanUserDirectMessage).toHaveBeenCalledWith('currentUser', 'user1');
|
||||||
|
});
|
||||||
|
|
||||||
test('should hide message button for remote users when EnableSharedChannelsDMs is disabled', () => {
|
test('should hide message button for remote users when EnableSharedChannelsDMs is disabled', () => {
|
||||||
const remoteUser = {
|
const remoteUser = {
|
||||||
@@ -104,6 +120,7 @@ describe('components/ProfilePopoverOtherUserRow', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.queryByText('Message')).not.toBeInTheDocument();
|
expect(screen.queryByText('Message')).not.toBeInTheDocument();
|
||||||
|
expect(mockCanUserDirectMessage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should show message button for local users when EnableSharedChannelsDMs is disabled', () => {
|
test('should show message button for local users when EnableSharedChannelsDMs is disabled', () => {
|
||||||
@@ -129,5 +146,6 @@ describe('components/ProfilePopoverOtherUserRow', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('Message')).toBeInTheDocument();
|
expect(screen.getByText('Message')).toBeInTheDocument();
|
||||||
|
expect(mockCanUserDirectMessage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import React from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import {FormattedMessage} from 'react-intl';
|
import {FormattedMessage, useIntl} from 'react-intl';
|
||||||
import {useSelector} from 'react-redux';
|
import {useSelector, useDispatch} from 'react-redux';
|
||||||
|
|
||||||
|
import type {GlobalState} from '@mattermost/types/store';
|
||||||
import type {UserProfile} from '@mattermost/types/users';
|
import type {UserProfile} from '@mattermost/types/users';
|
||||||
|
|
||||||
import {getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general';
|
import {getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general';
|
||||||
|
|
||||||
|
import {canUserDirectMessage} from 'actions/user_actions';
|
||||||
|
|
||||||
import ProfilePopoverAddToChannel from 'components/profile_popover/profile_popover_add_to_channel';
|
import ProfilePopoverAddToChannel from 'components/profile_popover/profile_popover_add_to_channel';
|
||||||
import ProfilePopoverCallButtonWrapper from 'components/profile_popover/profile_popover_call_button_wrapper';
|
import ProfilePopoverCallButtonWrapper from 'components/profile_popover/profile_popover_call_button_wrapper';
|
||||||
|
|
||||||
import type {GlobalState} from 'types/store';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
user: UserProfile;
|
user: UserProfile;
|
||||||
fullname: string;
|
fullname: string;
|
||||||
@@ -35,23 +36,88 @@ const ProfilePopoverOtherUserRow = ({
|
|||||||
hide,
|
hide,
|
||||||
fullname,
|
fullname,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const isSharedChannelsDMsEnabled = useSelector((state: GlobalState) => getFeatureFlagValue(state, 'EnableSharedChannelsDMs') === 'true');
|
const intl = useIntl();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const [canMessage, setCanMessage] = useState<boolean | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const isSharedChannelsDMsEnabled = useSelector((state: GlobalState) => {
|
||||||
|
return getFeatureFlagValue(state, 'EnableSharedChannelsDMs') === 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if this user can be messaged directly using server-side validation
|
||||||
|
useEffect(() => {
|
||||||
|
const checkCanMessage = async () => {
|
||||||
|
if (!user.remote_id) {
|
||||||
|
// Local users can always be messaged
|
||||||
|
setCanMessage(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSharedChannelsDMsEnabled) {
|
||||||
|
// Feature disabled - don't allow remote user messaging
|
||||||
|
setCanMessage(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await dispatch(canUserDirectMessage(currentUserId, user.id));
|
||||||
|
if (result.data) {
|
||||||
|
setCanMessage(result.data.can_dm);
|
||||||
|
} else {
|
||||||
|
setCanMessage(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Error checking DM permissions
|
||||||
|
setCanMessage(false);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkCanMessage();
|
||||||
|
}, [dispatch, currentUserId, user.id, user.remote_id, isSharedChannelsDMsEnabled]);
|
||||||
|
|
||||||
if (user.id === currentUserId || haveOverrideProp) {
|
if (user.id === currentUserId || haveOverrideProp) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide Message button for remote users when EnableSharedChannelsDMs feature flag is off
|
// For remote users, we need to check permissions; for local users, always show
|
||||||
const isRemoteUser = Boolean(user.remote_id);
|
const isRemoteUser = Boolean(user.remote_id);
|
||||||
const showMessageButton = isSharedChannelsDMsEnabled || !isRemoteUser;
|
const shouldShowButton = !isRemoteUser || (isSharedChannelsDMsEnabled && canMessage !== null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='user-popover__bottom-row-container'>
|
<div className='user-popover__bottom-row-container'>
|
||||||
{showMessageButton && (
|
{shouldShowButton && (
|
||||||
|
<>
|
||||||
|
{isLoading ? (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='btn btn-primary btn-sm disabled'
|
||||||
|
disabled={true}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className='icon icon-loading'
|
||||||
|
aria-hidden='true'
|
||||||
|
/>
|
||||||
|
<FormattedMessage
|
||||||
|
id='user_profile.send.dm.checking'
|
||||||
|
defaultMessage='Checking...'
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{canMessage ? (
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
className='btn btn-primary btn-sm'
|
className='btn btn-primary btn-sm'
|
||||||
onClick={handleShowDirectChannel}
|
onClick={handleShowDirectChannel}
|
||||||
|
aria-label={intl.formatMessage({
|
||||||
|
id: 'user_profile.send.dm.aria_label',
|
||||||
|
defaultMessage: 'Send message to {user}',
|
||||||
|
}, {user: user.username})}
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
className='icon icon-send'
|
className='icon icon-send'
|
||||||
@@ -62,6 +128,33 @@ const ProfilePopoverOtherUserRow = ({
|
|||||||
defaultMessage='Message'
|
defaultMessage='Message'
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='btn btn-primary btn-sm disabled'
|
||||||
|
disabled={true}
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'user_profile.send.dm.no_connection',
|
||||||
|
defaultMessage: 'Cannot message users from indirectly connected servers',
|
||||||
|
})}
|
||||||
|
aria-label={intl.formatMessage({
|
||||||
|
id: 'user_profile.send.dm.no_connection.aria_label',
|
||||||
|
defaultMessage: 'Cannot message {user}. Their server is not directly connected.',
|
||||||
|
}, {user: user.username})}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className='icon icon-send'
|
||||||
|
aria-hidden='true'
|
||||||
|
/>
|
||||||
|
<FormattedMessage
|
||||||
|
id='user_profile.send.dm'
|
||||||
|
defaultMessage='Message'
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<div className='user-popover__bottom-row-end'>
|
<div className='user-popover__bottom-row-end'>
|
||||||
<ProfilePopoverAddToChannel
|
<ProfilePopoverAddToChannel
|
||||||
|
|||||||
@@ -5916,6 +5916,10 @@
|
|||||||
"user_profile.roleTitle.system_admin": "System Admin",
|
"user_profile.roleTitle.system_admin": "System Admin",
|
||||||
"user_profile.roleTitle.team_admin": "Team Admin",
|
"user_profile.roleTitle.team_admin": "Team Admin",
|
||||||
"user_profile.send.dm": "Message",
|
"user_profile.send.dm": "Message",
|
||||||
|
"user_profile.send.dm.aria_label": "Send message to {user}",
|
||||||
|
"user_profile.send.dm.checking": "Checking...",
|
||||||
|
"user_profile.send.dm.no_connection": "Cannot message users from indirectly connected servers",
|
||||||
|
"user_profile.send.dm.no_connection.aria_label": "Cannot message {user}. Their server is not directly connected.",
|
||||||
"user_profile.send.dm.yourself": "Send yourself a message",
|
"user_profile.send.dm.yourself": "Send yourself a message",
|
||||||
"user_settings.notifications.test_notification.body": "Not receiving notifications? Start by sending a test notification to all your devices to check if they’re working as expected. If issues persist, explore ways to solve them with troubleshooting steps.",
|
"user_settings.notifications.test_notification.body": "Not receiving notifications? Start by sending a test notification to all your devices to check if they’re working as expected. If issues persist, explore ways to solve them with troubleshooting steps.",
|
||||||
"user_settings.notifications.test_notification.go_to_docs": "Troubleshooting docs",
|
"user_settings.notifications.test_notification.go_to_docs": "Troubleshooting docs",
|
||||||
|
|||||||
@@ -646,6 +646,19 @@ export function getUserByEmail(email: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function canUserDirectMessage(userId: string, otherUserId: string): ActionFuncAsync<{can_dm: boolean}> {
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
try {
|
||||||
|
const result = await Client4.canUserDirectMessage(userId, otherUserId);
|
||||||
|
return {data: result};
|
||||||
|
} catch (error) {
|
||||||
|
forceLogoutIfNecessary(error, dispatch, getState);
|
||||||
|
dispatch(logError(error));
|
||||||
|
return {error};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getStatusesByIds(userIds: Array<UserProfile['id']>): ActionFuncAsync<UserStatus[]> {
|
export function getStatusesByIds(userIds: Array<UserProfile['id']>): ActionFuncAsync<UserStatus[]> {
|
||||||
return async (dispatch, getState) => {
|
return async (dispatch, getState) => {
|
||||||
if (!userIds || userIds.length === 0) {
|
if (!userIds || userIds.length === 0) {
|
||||||
|
|||||||
@@ -402,6 +402,10 @@ export default class Client4 {
|
|||||||
return `${this.getBaseRoute()}/hooks/outgoing/${hookId}`;
|
return `${this.getBaseRoute()}/hooks/outgoing/${hookId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSharedChannelsRoute() {
|
||||||
|
return `${this.getBaseRoute()}/sharedchannels`;
|
||||||
|
}
|
||||||
|
|
||||||
getOAuthRoute() {
|
getOAuthRoute() {
|
||||||
return `${this.url}/oauth`;
|
return `${this.url}/oauth`;
|
||||||
}
|
}
|
||||||
@@ -960,6 +964,13 @@ export default class Client4 {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
canUserDirectMessage = (userId: string, otherUserId: string) => {
|
||||||
|
return this.doFetch<{can_dm: boolean}>(
|
||||||
|
`${this.getSharedChannelsRoute()}/users/${userId}/can_dm/${otherUserId}`,
|
||||||
|
{method: 'get'},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
getProfilePictureUrl = (userId: string, lastPictureUpdate: number) => {
|
getProfilePictureUrl = (userId: string, lastPictureUpdate: number) => {
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
|
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user