MM-64531: [Shared Channels] Users on different remote servers should not communicate unless the remotes have established secure connection. (#30985) (#33434)

Automatic Merge
Этот коммит содержится в:
Mattermost Build
2025-07-15 11:58:41 +03:00
коммит произвёл GitHub
родитель 3a6aeee57e
Коммит 07a34f02b6
20 изменённых файлов: 764 добавлений и 59 удалений

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

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

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

@@ -12,8 +12,10 @@ 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 () => {
const [props, initialState] = getBasePropsAndState(); // Test 1: Verify shared user indicator is shown for any remote user
initialState.entities!.users!.profiles!.user1!.remote_id = 'fakeuser'; {
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); const {unmount} = renderWithPluginReducers(<ProfilePopover {...props}/>, initialState);
expect(await screen.findByLabelText('shared user indicator')).toBeInTheDocument(); 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 () => { 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,7 +85,10 @@ describe('components/ProfilePopoverOtherUserRow', () => {
state, 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', () => { 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(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,33 +36,125 @@ 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 && (
<button <>
type='button' {isLoading ? (
className='btn btn-primary btn-sm' <button
onClick={handleShowDirectChannel} type='button'
> className='btn btn-primary btn-sm disabled'
<i disabled={true}
className='icon icon-send' >
aria-hidden='true' <i
/> className='icon icon-loading'
<FormattedMessage aria-hidden='true'
id='user_profile.send.dm' />
defaultMessage='Message' <FormattedMessage
/> id='user_profile.send.dm.checking'
</button> 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'> <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 theyre 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 theyre 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 = {};