diff --git a/api/v4/source/sharedchannels.yaml b/api/v4/source/sharedchannels.yaml index 2153806071f..18566f2caf9 100644 --- a/api/v4/source/sharedchannels.yaml +++ b/api/v4/source/sharedchannels.yaml @@ -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" diff --git a/server/channels/api4/shared_channel.go b/server/channels/api4/shared_channel.go index 9f1bd04a23b..7fe8830092f 100644 --- a/server/channels/api4/shared_channel.go +++ b/server/channels/api4/shared_channel.go @@ -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)) + } +} diff --git a/server/channels/app/shared_channel_service_iface.go b/server/channels/app/shared_channel_service_iface.go index 1c1623bf98c..1f5b591ab6d 100644 --- a/server/channels/app/shared_channel_service_iface.go +++ b/server/channels/app/shared_channel_service_iface.go @@ -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 == "" +} diff --git a/server/channels/app/user_test.go b/server/channels/app/user_test.go index 66bf36ac9df..4368d60c948 100644 --- a/server/channels/app/user_test.go +++ b/server/channels/app/user_test.go @@ -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) + }) +} diff --git a/server/channels/web/context.go b/server/channels/web/context.go index 81fc9ec00ab..ff1693898cb 100644 --- a/server/channels/web/context.go +++ b/server/channels/web/context.go @@ -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 diff --git a/server/channels/web/params.go b/server/channels/web/params.go index ad38e822fb0..efe21d0746c 100644 --- a/server/channels/web/params.go +++ b/server/channels/web/params.go @@ -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"] diff --git a/server/platform/services/sharedchannel/service.go b/server/platform/services/sharedchannel/service.go index 4085ba64ed7..cde1e9254e7 100644 --- a/server/platform/services/sharedchannel/service.go +++ b/server/platform/services/sharedchannel/service.go @@ -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 { diff --git a/server/platform/services/sharedchannel/sync_recv.go b/server/platform/services/sharedchannel/sync_recv.go index 9ed176d1238..41f2e7ccabc 100644 --- a/server/platform/services/sharedchannel/sync_recv.go +++ b/server/platform/services/sharedchannel/sync_recv.go @@ -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 } diff --git a/server/public/model/shared_channel.go b/server/public/model/shared_channel.go index 13980223516..cccba8ea013 100644 --- a/server/public/model/shared_channel.go +++ b/server/public/model/shared_channel.go @@ -12,8 +12,10 @@ import ( ) const ( - UserPropsKeyRemoteUsername = "RemoteUsername" - UserPropsKeyRemoteEmail = "RemoteEmail" + UserPropsKeyRemoteUsername = "RemoteUsername" + UserPropsKeyRemoteEmail = "RemoteEmail" + UserPropsKeyOriginalRemoteId = "OriginalRemoteId" + UserOriginalRemoteIdUnknown = "UNKNOWN" ) var ( diff --git a/server/public/model/user.go b/server/public/model/user.go index ec1137b4381..f4006c33075 100644 --- a/server/public/model/user.go +++ b/server/public/model/user.go @@ -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) } diff --git a/webapp/channels/src/actions/user_actions.ts b/webapp/channels/src/actions/user_actions.ts index 073f4fa5417..1af48cf4b66 100644 --- a/webapp/channels/src/actions/user_actions.ts +++ b/webapp/channels/src/actions/user_actions.ts @@ -428,6 +428,13 @@ export function autocompleteUsers(username: string): ThunkActionFunc { + return async (doDispatch) => { + const {data} = await doDispatch(UserActions.canUserDirectMessage(userId, otherUserId)); + return {data}; + }; +} + export function autoResetStatus(): ActionFuncAsync { return async (doDispatch) => { const state = getState(); diff --git a/webapp/channels/src/components/more_direct_channels/index.ts b/webapp/channels/src/components/more_direct_channels/index.ts index 4bdfb3c6786..f840e0639fd 100644 --- a/webapp/channels/src/components/more_direct_channels/index.ts +++ b/webapp/channels/src/components/more_direct_channels/index.ts @@ -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), }; } diff --git a/webapp/channels/src/components/more_direct_channels/more_direct_channels.test.tsx b/webapp/channels/src/components/more_direct_channels/more_direct_channels.test.tsx index 0865b516099..c59046ef4fa 100644 --- a/webapp/channels/src/components/more_direct_channels/more_direct_channels.test.tsx +++ b/webapp/channels/src/components/more_direct_channels/more_direct_channels.test.tsx @@ -73,6 +73,7 @@ describe('components/MoreDirectChannels', () => { process.nextTick(() => resolve()); }); }), + canUserDirectMessage: jest.fn().mockResolvedValue({data: {can_dm: true}}), }, }; diff --git a/webapp/channels/src/components/more_direct_channels/more_direct_channels.tsx b/webapp/channels/src/components/more_direct_channels/more_direct_channels.tsx index 9833e330256..b518eb037cc 100644 --- a/webapp/channels/src/components/more_direct_channels/more_direct_channels.tsx +++ b/webapp/channels/src/components/more_direct_channels/more_direct_channels.tsx @@ -58,6 +58,7 @@ export type Props = { searchProfiles: (term: string, options: any) => Promise>; searchGroupChannels: (term: string) => Promise>; setModalSearchTerm: (term: string) => void; + canUserDirectMessage: (userId: string, otherUserId: string) => Promise>; }; focusOriginElement: string; } @@ -68,6 +69,7 @@ type State = { search: boolean; saving: boolean; loadingUsers: boolean; + directMessageCapabilityCache: Record; } export default class MoreDirectChannels extends React.PureComponent { @@ -100,6 +102,7 @@ export default class MoreDirectChannels extends React.PureComponent { + 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 { + 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 = ( ); diff --git a/webapp/channels/src/components/profile_popover/profile_popover.test.tsx b/webapp/channels/src/components/profile_popover/profile_popover.test.tsx index 39db587d006..d11acb9b032 100644 --- a/webapp/channels/src/components/profile_popover/profile_popover.test.tsx +++ b/webapp/channels/src/components/profile_popover/profile_popover.test.tsx @@ -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) { + // 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 ( +
+ +
+ + +
+
+ ); + } + + // Default - enabled button (Test 2 and others) + return ( +
+ +
+ + +
+
+ ); + }; +}); + type Props = ComponentProps; function renderWithPluginReducers( @@ -167,12 +263,85 @@ function getBasePropsAndState(): [Props, DeepPartial] { 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(, initialState); - expect(await screen.findByLabelText('shared user indicator')).toBeInTheDocument(); + const {unmount} = renderWithPluginReducers(, 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(, 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(, 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 ({`${status} ${user.id}`}); }; - initialState.plugins!.components!.PopoverUserAttributes = [{component: mockPluginComponent as any}]; + initialState.plugins!.components!.PopoverUserAttributes = [{component: mockPluginComponent}]; renderWithPluginReducers(, 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 ({`${status} ${user.id}`}); }; - initialState.plugins!.components!.PopoverUserActions = [{component: mockPluginComponent as any}]; + initialState.plugins!.components!.PopoverUserActions = [{component: mockPluginComponent}]; renderWithPluginReducers(, 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 & { + 'plugins-com.mattermost.calls': { + sessions: Record; + channels?: Record; + callsConfig?: {DefaultEnabled: boolean}; + }; + })['plugins-com.mattermost.calls'].sessions = {dmChannelId: {currentUser: {user_id: 'currentUser'}}}; renderWithPluginReducers(, 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 & { + 'plugins-com.mattermost.calls': { + sessions?: Record; + channels: Record; + callsConfig?: {DefaultEnabled: boolean}; + }; + })['plugins-com.mattermost.calls'].channels = {dmChannelId: {enabled: false}}; renderWithPluginReducers(, 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 & { + 'plugins-com.mattermost.calls': { + sessions?: Record; + channels?: Record; + callsConfig: {DefaultEnabled: boolean}; + }; + })['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false}; renderWithPluginReducers(, 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 & { + 'plugins-com.mattermost.calls': { + sessions?: Record; + channels?: Record; + callsConfig: {DefaultEnabled: boolean}; + }; + })['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false}; + + // Set channels + (initialState as DeepPartial & { + 'plugins-com.mattermost.calls': { + sessions?: Record; + channels: Record; + callsConfig?: {DefaultEnabled: boolean}; + }; + })['plugins-com.mattermost.calls'].channels = {dmChannelId: {enabled: true}}; renderWithPluginReducers(, 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 & { + 'plugins-com.mattermost.calls': { + sessions?: Record; + channels?: Record; + callsConfig: {DefaultEnabled: boolean}; + }; + })['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false}; initialState.entities = { ...initialState.entities!, users: { diff --git a/webapp/channels/src/components/profile_popover/profile_popover_other_user_row.test.tsx b/webapp/channels/src/components/profile_popover/profile_popover_other_user_row.test.tsx index 8e8b64432de..9b2e8ead6c5 100644 --- a/webapp/channels/src/components/profile_popover/profile_popover_other_user_row.test.tsx +++ b/webapp/channels/src/components/profile_popover/profile_popover_other_user_row.test.tsx @@ -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; + 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(); }); }); diff --git a/webapp/channels/src/components/profile_popover/profile_popover_other_user_row.tsx b/webapp/channels/src/components/profile_popover/profile_popover_other_user_row.tsx index 39e969d14e9..b192eb1f802 100644 --- a/webapp/channels/src/components/profile_popover/profile_popover_other_user_row.tsx +++ b/webapp/channels/src/components/profile_popover/profile_popover_other_user_row.tsx @@ -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(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 (
- {showMessageButton && ( -