mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-18 18:18:23 -05:00
MM-64531: [Shared Channels] Users on different remote servers should not communicate unless the remotes have established secure connection. (#30985)
This commit is contained in:
parent
c90ee268df
commit
69e483f32b
20 changed files with 764 additions and 59 deletions
|
|
@ -226,3 +226,88 @@
|
|||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$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("/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("/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.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))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
CheckCanInviteToSharedChannel(channelId string) error
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -100,3 +101,11 @@ func (mrcs *mockSharedChannelService) HandleMembershipChange(channelID, userID s
|
|||
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"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
|
@ -26,6 +27,7 @@ import (
|
|||
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
|
||||
"github.com/mattermost/mattermost/server/v8/einterfaces"
|
||||
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/services/sharedchannel"
|
||||
)
|
||||
|
||||
func TestCreateOAuthUser(t *testing.T) {
|
||||
|
|
@ -2411,3 +2413,74 @@ func TestGetUsersForReporting(t *testing.T) {
|
|||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const (
|
|||
|
||||
type Params struct {
|
||||
UserId string
|
||||
OtherUserId string
|
||||
TeamId string
|
||||
InviteId string
|
||||
TokenId string
|
||||
|
|
@ -129,6 +130,7 @@ func ParamsFromRequest(r *http.Request) *Params {
|
|||
query := r.URL.Query()
|
||||
|
||||
params.UserId = props["user_id"]
|
||||
params.OtherUserId = props["other_user_id"]
|
||||
params.TeamId = props["team_id"]
|
||||
params.CategoryId = props["category_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
|
||||
// isGlobalUserSyncEnabled checks if the global user sync feature is enabled
|
||||
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
|
||||
if euser == nil {
|
||||
// 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)
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,10 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
UserPropsKeyRemoteUsername = "RemoteUsername"
|
||||
UserPropsKeyRemoteEmail = "RemoteEmail"
|
||||
UserPropsKeyRemoteUsername = "RemoteUsername"
|
||||
UserPropsKeyRemoteEmail = "RemoteEmail"
|
||||
UserPropsKeyOriginalRemoteId = "OriginalRemoteId"
|
||||
UserOriginalRemoteIdUnknown = "UNKNOWN"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
|
|||
|
|
@ -936,6 +936,22 @@ func (u *User) GetRemoteID() string {
|
|||
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 {
|
||||
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> {
|
||||
return async (doDispatch) => {
|
||||
const state = getState();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
getProfilesInTeam,
|
||||
getTotalUsersStats,
|
||||
searchProfiles,
|
||||
canUserDirectMessage,
|
||||
} from 'mattermost-redux/actions/users';
|
||||
import {getConfig, getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general';
|
||||
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
|
||||
|
|
@ -103,6 +104,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
|
|||
searchProfiles,
|
||||
searchGroupChannels,
|
||||
setModalSearchTerm,
|
||||
canUserDirectMessage,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ describe('components/MoreDirectChannels', () => {
|
|||
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[]>>;
|
||||
searchGroupChannels: (term: string) => Promise<ActionResult<Channel[]>>;
|
||||
setModalSearchTerm: (term: string) => void;
|
||||
canUserDirectMessage: (userId: string, otherUserId: string) => Promise<ActionResult<{can_dm: boolean}>>;
|
||||
};
|
||||
focusOriginElement: string;
|
||||
}
|
||||
|
|
@ -68,6 +69,7 @@ type State = {
|
|||
search: boolean;
|
||||
saving: boolean;
|
||||
loadingUsers: boolean;
|
||||
directMessageCapabilityCache: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export default class MoreDirectChannels extends React.PureComponent<Props, State> {
|
||||
|
|
@ -100,6 +102,7 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
|
|||
search: false,
|
||||
saving: false,
|
||||
loadingUsers: true,
|
||||
directMessageCapabilityCache: {},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -107,6 +110,38 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
|
|||
this.getUserProfiles();
|
||||
this.props.actions.getTotalUsersStats();
|
||||
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) {
|
||||
|
|
@ -142,6 +177,7 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
|
|||
|
||||
if (prevProps.users.length !== this.props.users.length) {
|
||||
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});
|
||||
};
|
||||
|
||||
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() {
|
||||
const filteredUsers = this.getDirectMessageableUsers();
|
||||
const body = (
|
||||
<List
|
||||
addValue={this.addValue}
|
||||
|
|
@ -272,7 +330,7 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
|
|||
search={this.search}
|
||||
selectedItemRef={this.selectedItemRef}
|
||||
totalCount={this.props.totalCount}
|
||||
users={this.props.users}
|
||||
users={filteredUsers}
|
||||
values={this.state.values}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
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 type {ComponentProps} from 'react';
|
||||
|
||||
import type {UserProfile} 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>;
|
||||
|
||||
function renderWithPluginReducers(
|
||||
|
|
@ -167,12 +263,85 @@ function getBasePropsAndState(): [Props, DeepPartial<GlobalState>] {
|
|||
describe('components/ProfilePopover', () => {
|
||||
(Client4.getCallsChannelState as jest.Mock).mockImplementation(async () => ({enabled: true}));
|
||||
|
||||
test('should mark shared user as shared', async () => {
|
||||
const [props, initialState] = getBasePropsAndState();
|
||||
initialState.entities!.users!.profiles!.user1!.remote_id = 'fakeuser';
|
||||
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();
|
||||
initialState.entities!.users!.profiles!.user1!.remote_id = 'fakeuser';
|
||||
initialState.entities!.general!.config = {
|
||||
...initialState.entities!.general!.config,
|
||||
ExperimentalSharedChannels: 'true',
|
||||
};
|
||||
|
||||
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
|
||||
expect(await screen.findByLabelText('shared user indicator')).toBeInTheDocument();
|
||||
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);
|
||||
|
||||
// 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 () => {
|
||||
|
|
@ -205,20 +374,19 @@ describe('components/ProfilePopover', () => {
|
|||
|
||||
test('should match props passed into PopoverUserAttributes Pluggable component', async () => {
|
||||
const [props, initialState] = getBasePropsAndState();
|
||||
const mockPluginComponent = ({
|
||||
hide,
|
||||
status,
|
||||
user,
|
||||
}: {
|
||||
hide: Props['hide'];
|
||||
status?: string;
|
||||
const mockPluginComponent: React.ComponentType<{
|
||||
hide?: Props['hide'];
|
||||
status: string | null;
|
||||
user: UserProfile;
|
||||
}) => {
|
||||
fromWebhook?: boolean;
|
||||
theme?: any;
|
||||
webSocketClient?: any;
|
||||
}> = ({hide, status, user}) => {
|
||||
hide?.();
|
||||
return (<span>{`${status} ${user.id}`}</span>);
|
||||
};
|
||||
|
||||
initialState.plugins!.components!.PopoverUserAttributes = [{component: mockPluginComponent as any}];
|
||||
initialState.plugins!.components!.PopoverUserAttributes = [{component: mockPluginComponent}];
|
||||
|
||||
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
|
||||
expect(props.hide).toHaveBeenCalled();
|
||||
|
|
@ -227,20 +395,18 @@ describe('components/ProfilePopover', () => {
|
|||
|
||||
test('should match props passed into PopoverUserActions Pluggable component', async () => {
|
||||
const [props, initialState] = getBasePropsAndState();
|
||||
const mockPluginComponent = ({
|
||||
hide,
|
||||
status,
|
||||
user,
|
||||
}: {
|
||||
hide: Props['hide'];
|
||||
status?: string;
|
||||
const mockPluginComponent: React.ComponentType<{
|
||||
hide?: Props['hide'];
|
||||
status: string | null;
|
||||
user: UserProfile;
|
||||
}) => {
|
||||
theme?: any;
|
||||
webSocketClient?: any;
|
||||
}> = ({hide, status, user}) => {
|
||||
hide?.();
|
||||
return (<span>{`${status} ${user.id}`}</span>);
|
||||
};
|
||||
|
||||
initialState.plugins!.components!.PopoverUserActions = [{component: mockPluginComponent as any}];
|
||||
initialState.plugins!.components!.PopoverUserActions = [{component: mockPluginComponent}];
|
||||
|
||||
renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
|
||||
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 () => {
|
||||
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);
|
||||
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 () => {
|
||||
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);
|
||||
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 () => {
|
||||
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);
|
||||
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 () => {
|
||||
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);
|
||||
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 () => {
|
||||
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!,
|
||||
users: {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {screen} from '@testing-library/react';
|
||||
import {screen, waitFor} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import {canUserDirectMessage} from 'actions/user_actions';
|
||||
|
||||
import {renderWithContext} from 'tests/react_testing_utils';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
|
|
@ -11,6 +13,12 @@ import type {GlobalState} from 'types/store';
|
|||
|
||||
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', () => {
|
||||
const baseProps = {
|
||||
user: TestHelper.getUserMock({id: 'user1'}),
|
||||
|
|
@ -23,6 +31,11 @@ describe('components/ProfilePopoverOtherUserRow', () => {
|
|||
hide: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockCanUserDirectMessage.mockClear();
|
||||
mockCanUserDirectMessage.mockReturnValue(jest.fn().mockResolvedValue({data: {can_dm: true}}));
|
||||
});
|
||||
|
||||
const initialState = {
|
||||
entities: {
|
||||
general: {
|
||||
|
|
@ -44,7 +57,7 @@ describe('components/ProfilePopoverOtherUserRow', () => {
|
|||
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 = {
|
||||
...baseProps.user,
|
||||
remote_id: 'remote1',
|
||||
|
|
@ -72,7 +85,10 @@ describe('components/ProfilePopoverOtherUserRow', () => {
|
|||
state,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Message')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Message')).toBeInTheDocument();
|
||||
});
|
||||
expect(mockCanUserDirectMessage).toHaveBeenCalledWith('currentUser', 'user1');
|
||||
});
|
||||
|
||||
test('should hide message button for remote users when EnableSharedChannelsDMs is disabled', () => {
|
||||
|
|
@ -104,6 +120,7 @@ describe('components/ProfilePopoverOtherUserRow', () => {
|
|||
);
|
||||
|
||||
expect(screen.queryByText('Message')).not.toBeInTheDocument();
|
||||
expect(mockCanUserDirectMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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(mockCanUserDirectMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {useSelector} from 'react-redux';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import {useSelector, useDispatch} from 'react-redux';
|
||||
|
||||
import type {GlobalState} from '@mattermost/types/store';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
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 ProfilePopoverCallButtonWrapper from 'components/profile_popover/profile_popover_call_button_wrapper';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
type Props = {
|
||||
user: UserProfile;
|
||||
fullname: string;
|
||||
|
|
@ -35,33 +36,125 @@ const ProfilePopoverOtherUserRow = ({
|
|||
hide,
|
||||
fullname,
|
||||
}: 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) {
|
||||
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 showMessageButton = isSharedChannelsDMsEnabled || !isRemoteUser;
|
||||
const shouldShowButton = !isRemoteUser || (isSharedChannelsDMsEnabled && canMessage !== null);
|
||||
|
||||
return (
|
||||
<div className='user-popover__bottom-row-container'>
|
||||
{showMessageButton && (
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn-primary btn-sm'
|
||||
onClick={handleShowDirectChannel}
|
||||
>
|
||||
<i
|
||||
className='icon icon-send'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='user_profile.send.dm'
|
||||
defaultMessage='Message'
|
||||
/>
|
||||
</button>
|
||||
{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
|
||||
type='button'
|
||||
className='btn btn-primary btn-sm'
|
||||
onClick={handleShowDirectChannel}
|
||||
aria-label={intl.formatMessage({
|
||||
id: 'user_profile.send.dm.aria_label',
|
||||
defaultMessage: 'Send message to {user}',
|
||||
}, {user: user.username})}
|
||||
>
|
||||
<i
|
||||
className='icon icon-send'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='user_profile.send.dm'
|
||||
defaultMessage='Message'
|
||||
/>
|
||||
</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'>
|
||||
<ProfilePopoverAddToChannel
|
||||
|
|
|
|||
|
|
@ -5916,6 +5916,10 @@
|
|||
"user_profile.roleTitle.system_admin": "System Admin",
|
||||
"user_profile.roleTitle.team_admin": "Team Admin",
|
||||
"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_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",
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
return async (dispatch, getState) => {
|
||||
if (!userIds || userIds.length === 0) {
|
||||
|
|
|
|||
|
|
@ -402,6 +402,10 @@ export default class Client4 {
|
|||
return `${this.getBaseRoute()}/hooks/outgoing/${hookId}`;
|
||||
}
|
||||
|
||||
getSharedChannelsRoute() {
|
||||
return `${this.getBaseRoute()}/sharedchannels`;
|
||||
}
|
||||
|
||||
getOAuthRoute() {
|
||||
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) => {
|
||||
const params: any = {};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue