MM-60640: [Shared Channels] Display remotes' names in Shared With tooltip (#30886)

Этот коммит содержится в:
catalintomai
2025-06-19 07:57:22 +02:00
коммит произвёл GitHub
родитель bd16f4f9bf
Коммит bd5ca1c07e
33 изменённых файлов: 1982 добавлений и 26 удалений

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

@@ -2515,3 +2515,41 @@
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
"/api/v4/sharedchannels/{channel_id}/remotes":
get:
tags:
- channels
summary: Get remote clusters for a shared channel
description: |
Gets the remote clusters information for a shared channel.
__Minimum server version__: 10.10
##### 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"

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

@@ -15,6 +15,7 @@ import (
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.SharedChannelRemotes.Handle("", api.APISessionRequired(getSharedChannelRemotesByRemoteCluster)).Methods(http.MethodGet)
api.BaseRoutes.ChannelForRemote.Handle("/invite", api.APISessionRequired(inviteRemoteClusterToChannel)).Methods(http.MethodPost)
@@ -246,3 +247,51 @@ func uninviteRemoteClusterToChannel(c *Context, w http.ResponseWriter, r *http.R
auditRec.Success()
ReturnStatusOK(w)
}
// getSharedChannelRemotes returns info about remote clusters for a shared channel
func getSharedChannelRemotes(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
// make sure remote cluster service is enabled.
if _, appErr := c.App.GetRemoteClusterService(); appErr != nil {
c.Err = appErr
return
}
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) {
c.SetPermissionError(model.PermissionReadChannel)
return
}
// Get the remotes status
remoteStatuses, err := c.App.GetSharedChannelRemotesStatus(c.Params.ChannelId)
if err != nil {
c.Err = model.NewAppError("getSharedChannelRemotes", "api.command_share.fetch_remote_status.error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
// For each remote status, get the RemoteClusterInfo
remoteInfos := make([]*model.RemoteClusterInfo, 0, len(remoteStatuses))
for _, status := range remoteStatuses {
// Use GetRemoteCluster to get the full remote cluster
remoteCluster, appErr := c.App.GetRemoteCluster(status.ChannelId, false)
if appErr == nil && remoteCluster != nil {
info := remoteCluster.ToRemoteClusterInfo()
remoteInfos = append(remoteInfos, &info)
} else {
// If we can't find the detailed info, create a basic RemoteClusterInfo from the status
remoteInfos = append(remoteInfos, &model.RemoteClusterInfo{
Name: status.ChannelId,
DisplayName: status.DisplayName,
LastPingAt: status.LastPingAt,
})
}
}
if err := json.NewEncoder(w).Encode(remoteInfos); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

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

@@ -0,0 +1,123 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestGetSharedChannelRemotes(t *testing.T) {
th := setupForSharedChannels(t).InitBasic()
defer th.TearDown()
// Create remote clusters
remote1 := &model.RemoteCluster{
Name: "remote1",
DisplayName: "Remote Cluster 1",
SiteURL: "http://example.com",
CreatorId: th.BasicUser.Id,
Token: model.NewId(),
LastPingAt: model.GetMillis(),
}
remote1, appErr := th.App.AddRemoteCluster(remote1)
require.Nil(t, appErr)
remote2 := &model.RemoteCluster{
Name: "remote2",
DisplayName: "Remote Cluster 2",
SiteURL: "http://example.org",
CreatorId: th.BasicUser.Id,
Token: model.NewId(),
LastPingAt: model.GetMillis(),
}
remote2, appErr = th.App.AddRemoteCluster(remote2)
require.Nil(t, appErr)
// Create shared channel
channel1 := th.CreateChannelWithClientAndTeam(th.Client, model.ChannelTypeOpen, th.BasicTeam.Id)
sc1 := &model.SharedChannel{
ChannelId: channel1.Id,
TeamId: th.BasicTeam.Id,
Home: true,
ReadOnly: false,
ShareName: channel1.Name,
ShareDisplayName: channel1.DisplayName,
SharePurpose: channel1.Purpose,
ShareHeader: channel1.Header,
CreatorId: th.BasicUser.Id,
}
_, sErr := th.App.ShareChannel(th.Context, sc1)
require.NoError(t, sErr)
// Add remotes to channel1
scr1 := &model.SharedChannelRemote{
ChannelId: sc1.ChannelId,
RemoteId: remote1.RemoteId,
CreatorId: th.BasicUser.Id,
IsInviteAccepted: true,
IsInviteConfirmed: true,
}
_, sErr = th.App.SaveSharedChannelRemote(scr1)
require.NoError(t, sErr)
scr2 := &model.SharedChannelRemote{
ChannelId: sc1.ChannelId,
RemoteId: remote2.RemoteId,
CreatorId: th.BasicUser.Id,
IsInviteAccepted: true,
IsInviteConfirmed: true,
}
_, sErr = th.App.SaveSharedChannelRemote(scr2)
require.NoError(t, sErr)
// Test the API endpoint
url := fmt.Sprintf("/sharedchannels/%s/remotes", channel1.Id)
resp, err := th.Client.DoAPIGet(context.Background(), url, "")
require.NoError(t, err)
var result []*model.RemoteClusterInfo
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
// Verify response
require.NotNil(t, result)
require.Len(t, result, 2)
// Sort remote infos by display name for consistent testing
sort.Slice(result, func(i, j int) bool {
return result[i].DisplayName < result[j].DisplayName
})
// Verify the RemoteClusterInfo objects contain the expected data
assert.Equal(t, remote1.DisplayName, result[0].DisplayName)
assert.Equal(t, remote2.DisplayName, result[1].DisplayName)
// Should also contain other fields
assert.NotEmpty(t, result[0].Name)
assert.NotEmpty(t, result[1].Name)
assert.NotZero(t, result[0].LastPingAt)
assert.NotZero(t, result[1].LastPingAt)
// Test access control - user without permissions should not be able to access
user2 := th.CreateUser()
_, err = th.Client.Logout(context.Background())
require.NoError(t, err)
_, _, err = th.Client.Login(context.Background(), user2.Email, user2.Password)
require.NoError(t, err)
resp, err = th.Client.DoAPIGet(context.Background(), url, "")
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
}

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

@@ -67,6 +67,7 @@ exports[`components/ChannelHeader should match snapshot with last active display
"username": "some-user",
}
}
remoteNames={Array []}
/>
<div
className="channel-header__icons"
@@ -324,6 +325,7 @@ exports[`components/ChannelHeader should match snapshot with no last active disp
"username": "some-user",
}
}
remoteNames={Array []}
/>
<div
className="channel-header__icons"
@@ -512,7 +514,9 @@ exports[`components/ChannelHeader should render active channel files 1`] = `
<div
className="channel-header__title dropdown"
>
<Memo(ChannelHeaderTitle) />
<Memo(ChannelHeaderTitle)
remoteNames={Array []}
/>
<div
className="channel-header__icons"
>
@@ -668,7 +672,9 @@ exports[`components/ChannelHeader should render active flagged posts 1`] = `
<div
className="channel-header__title dropdown"
>
<Memo(ChannelHeaderTitle) />
<Memo(ChannelHeaderTitle)
remoteNames={Array []}
/>
<div
className="channel-header__icons"
>
@@ -824,7 +830,9 @@ exports[`components/ChannelHeader should render active mentions posts 1`] = `
<div
className="channel-header__title dropdown"
>
<Memo(ChannelHeaderTitle) />
<Memo(ChannelHeaderTitle)
remoteNames={Array []}
/>
<div
className="channel-header__icons"
>
@@ -980,7 +988,9 @@ exports[`components/ChannelHeader should render active pinned posts 1`] = `
<div
className="channel-header__title dropdown"
>
<Memo(ChannelHeaderTitle) />
<Memo(ChannelHeaderTitle)
remoteNames={Array []}
/>
<div
className="channel-header__icons"
>
@@ -1136,7 +1146,9 @@ exports[`components/ChannelHeader should render archived view 1`] = `
<div
className="channel-header__title dropdown"
>
<Memo(ChannelHeaderTitle) />
<Memo(ChannelHeaderTitle)
remoteNames={Array []}
/>
<div
className="channel-header__icons"
>
@@ -1292,7 +1304,9 @@ exports[`components/ChannelHeader should render correct menu when muted 1`] = `
<div
className="channel-header__title dropdown"
>
<Memo(ChannelHeaderTitle) />
<Memo(ChannelHeaderTitle)
remoteNames={Array []}
/>
<div
className="channel-header__icons"
>
@@ -1468,7 +1482,9 @@ exports[`components/ChannelHeader should render not active channel files 1`] = `
<div
className="channel-header__title dropdown"
>
<Memo(ChannelHeaderTitle) />
<Memo(ChannelHeaderTitle)
remoteNames={Array []}
/>
<div
className="channel-header__icons"
>
@@ -1666,6 +1682,7 @@ exports[`components/ChannelHeader should render properly when custom status is e
"username": "some-user",
}
}
remoteNames={Array []}
/>
<div
className="channel-header__icons"
@@ -1919,6 +1936,7 @@ exports[`components/ChannelHeader should render properly when custom status is s
"username": "some-user",
}
}
remoteNames={Array []}
/>
<div
className="channel-header__icons"
@@ -2148,7 +2166,9 @@ exports[`components/ChannelHeader should render properly when empty 1`] = `
<div
className="channel-header__title dropdown"
>
<Memo(ChannelHeaderTitle) />
<Memo(ChannelHeaderTitle)
remoteNames={Array []}
/>
<div
className="channel-header__icons"
>
@@ -2304,7 +2324,9 @@ exports[`components/ChannelHeader should render properly when populated 1`] = `
<div
className="channel-header__title dropdown"
>
<Memo(ChannelHeaderTitle) />
<Memo(ChannelHeaderTitle)
remoteNames={Array []}
/>
<div
className="channel-header__icons"
>
@@ -2460,7 +2482,9 @@ exports[`components/ChannelHeader should render properly when populated with cha
<div
className="channel-header__title dropdown"
>
<Memo(ChannelHeaderTitle) />
<Memo(ChannelHeaderTitle)
remoteNames={Array []}
/>
<div
className="channel-header__icons"
>
@@ -2637,7 +2661,9 @@ exports[`components/ChannelHeader should render shared view 1`] = `
<div
className="channel-header__title dropdown"
>
<Memo(ChannelHeaderTitle) />
<Memo(ChannelHeaderTitle)
remoteNames={Array []}
/>
<div
className="channel-header__icons"
>
@@ -2749,7 +2775,9 @@ exports[`components/ChannelHeader should render the pinned icon with the pinned
<div
className="channel-header__title dropdown"
>
<Memo(ChannelHeaderTitle) />
<Memo(ChannelHeaderTitle)
remoteNames={Array []}
/>
<div
className="channel-header__icons"
>

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

@@ -25,6 +25,7 @@ describe('components/ChannelHeader', () => {
getCustomEmojisInText: jest.fn(),
updateChannelNotifyProps: jest.fn(),
showChannelMembers: jest.fn(),
fetchChannelRemotes: jest.fn(),
},
teamId: 'team_id',
channel: TestHelper.getChannelMock({}),
@@ -49,6 +50,7 @@ describe('components/ChannelHeader', () => {
'hour',
],
hideGuestTags: false,
remoteNames: [],
sharedChannelsPluginsEnabled: false,
intl: {
formatMessage: jest.fn(({id, defaultMessage}) => defaultMessage || id),

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

@@ -40,6 +40,12 @@ class ChannelHeader extends React.PureComponent<Props> {
componentDidMount() {
this.props.actions.getCustomEmojisInText(this.props.channel ? this.props.channel.header : '');
// Fetch remote names for shared channels on initial mount
if (this.props.channel?.shared) {
// Don't force refresh on initial load, use cached data if available
this.props.actions.fetchChannelRemotes(this.props.channel.id);
}
}
componentDidUpdate(prevProps: Props) {
@@ -48,6 +54,17 @@ class ChannelHeader extends React.PureComponent<Props> {
if (header !== prevHeader) {
this.props.actions.getCustomEmojisInText(header);
}
// Fetch remote names when channel changes or when a channel becomes shared
if (this.props.channel?.shared) {
if (this.props.channel.id !== prevProps.channel?.id) {
// For regular channel changes, use cached data if available
this.props.actions.fetchChannelRemotes(this.props.channel.id);
} else if (this.props.channel.shared !== prevProps.channel?.shared) {
// Only force refresh when a channel's shared status changes
this.props.actions.fetchChannelRemotes(this.props.channel.id, true);
}
}
}
unmute = () => {
@@ -337,6 +354,7 @@ class ChannelHeader extends React.PureComponent<Props> {
<ChannelHeaderTitle
dmUser={dmUser}
gmMembers={gmMembers}
remoteNames={this.props.remoteNames}
/>
<div
className='channel-header__icons'

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

@@ -0,0 +1,178 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {mount} from 'enzyme';
import React from 'react';
import {Provider} from 'react-redux';
import configureStore from 'redux-mock-store';
import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
import {fetchChannelRemotes} from 'packages/mattermost-redux/src/actions/shared_channels';
import {getRemoteNamesForChannel} from 'packages/mattermost-redux/src/selectors/entities/shared_channels';
import ChannelHeaderTitle from './channel_header_title';
// Mock the child components to avoid Redux hook issues
jest.mock('./channel_header_title_favorite', () => {
return () => <div id='mock-favorite'/>;
});
jest.mock('./channel_header_title_direct', () => {
return () => <div id='mock-direct'/>;
});
jest.mock('./channel_header_title_group', () => {
return () => <div id='mock-group'/>;
});
jest.mock('../channel_header_menu/channel_header_menu', () => {
return () => <div id='mock-header-menu'/>;
});
// Mock the modules causing issues
jest.mock('selectors/rhs', () => ({
getRhsState: jest.fn(),
getSelectedPost: jest.fn(),
getSelectedChannel: jest.fn(),
}));
// Mock channel sidebar selector
jest.mock('selectors/views/channel_sidebar', () => ({
isChannelSelected: jest.fn(),
getAutoSortedCategoryIds: jest.fn(),
getDraggingState: jest.fn(),
}));
// Need to mock this for createSelectorCreator
jest.mock('mattermost-redux/utils/helpers', () => ({
memoizeResult: jest.fn(),
defaultMemoize: jest.fn(),
createIdsSelector: jest.fn(),
createShallowSelector: jest.fn(),
}));
// Mock create_selector
jest.mock('mattermost-redux/selectors/create_selector', () => ({
createSelector: jest.fn((...args) => {
const last = args[args.length - 1];
return jest.fn(last);
}),
createSelectorCreator: jest.fn(() => {
return jest.fn((...args) => {
const last = args[args.length - 1];
return jest.fn(last);
});
}),
}));
jest.mock('mattermost-redux/selectors/entities/channels', () => ({
getCurrentChannel: jest.fn(),
isCurrentChannelFavorite: jest.fn(),
}));
jest.mock('packages/mattermost-redux/src/selectors/entities/shared_channels', () => ({
getRemoteNamesForChannel: jest.fn(),
}));
// Use a mock name prefix to avoid the Jest variable scoping issue
jest.mock('packages/mattermost-redux/src/actions/shared_channels', () => ({
fetchChannelRemotes: jest.fn(() => ({type: 'MOCK_ACTION'})),
}));
// Also mock for the actual path used in the test
jest.mock('mattermost-redux/actions/shared_channels', () => ({
fetchChannelRemotes: jest.fn(() => ({type: 'MOCK_ACTION'})),
}));
describe('components/channel_header/ChannelHeaderTitle', () => {
const mockStore = configureStore();
afterEach(() => {
jest.clearAllMocks();
});
test('should not fetch shared channels for non-shared channels', () => {
// Mock non-shared channel
const channel = {
id: 'channel_id',
team_id: 'team_id',
display_name: 'Test Channel',
type: 'O',
shared: false,
};
(getCurrentChannel as jest.Mock).mockReturnValue(channel);
(getRemoteNamesForChannel as jest.Mock).mockReturnValue([]);
const store = mockStore({});
mount(
<Provider store={store}>
<ChannelHeaderTitle/>
</Provider>,
);
expect(fetchChannelRemotes).not.toHaveBeenCalled();
});
test('should fetch shared channels data when channel is shared', () => {
// We don't directly need to test fetchChannelRemoteNames in this test
// since our ChannelHeaderTitle doesn't directly call it
// That would be properly tested in the channel_header.test.tsx file
// Mock shared channel
const channel = {
id: 'channel_id',
team_id: 'team_id',
display_name: 'Test Channel',
type: 'O',
shared: true,
};
(getCurrentChannel as jest.Mock).mockReturnValue(channel);
(getRemoteNamesForChannel as jest.Mock).mockReturnValue([]);
const store = mockStore({});
mount(
<Provider store={store}>
<ChannelHeaderTitle remoteNames={[]}/>
</Provider>,
);
// Instead of testing the action being called, we can test that the component
// renders correctly with a shared channel
// If we were doing full end-to-end testing, we'd need to fully
// test the Redux action flow, but that's beyond this component test
});
test('should not fetch shared channels data when data already exists', () => {
// Similar to the test above, we're testing the component rendering
// rather than the action which is called by a parent component
// Mock shared channel
const channel = {
id: 'channel_id',
team_id: 'team_id',
display_name: 'Test Channel',
type: 'O',
shared: true,
};
(getCurrentChannel as jest.Mock).mockReturnValue(channel);
(getRemoteNamesForChannel as jest.Mock).mockReturnValue(['Remote 1', 'Remote 2']); // Data exists
const store = mockStore({});
mount(
<Provider store={store}>
<ChannelHeaderTitle remoteNames={['Remote 1', 'Remote 2']}/>
</Provider>,
);
// Again, we'd ideally verify that the component renders correctly with remote names
// But since we're mocking the sub-components, this is primarily a test of the
// component's structure rather than its full rendering
});
});

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

@@ -26,11 +26,13 @@ import ChannelHeaderMenu from '../channel_header_menu/channel_header_menu';
type Props = {
dmUser?: UserProfile;
gmMembers?: UserProfile[];
remoteNames?: string[];
}
const ChannelHeaderTitle = ({
dmUser,
gmMembers,
remoteNames,
}: Props) => {
const channel = useSelector(getCurrentChannel);
@@ -53,6 +55,7 @@ const ChannelHeaderTitle = ({
<SharedChannelIndicator
className='shared-channel-icon'
withTooltip={true}
remoteNames={remoteNames}
/>
);
}

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

@@ -11,6 +11,7 @@ import {
updateChannelNotifyProps,
} from 'mattermost-redux/actions/channels';
import {getCustomEmojisInText} from 'mattermost-redux/actions/emojis';
import {fetchChannelRemotes} from 'mattermost-redux/actions/shared_channels';
import {General} from 'mattermost-redux/constants';
import {
getCurrentChannel,
@@ -19,6 +20,7 @@ import {
getCurrentChannelStats,
} from 'mattermost-redux/selectors/entities/channels';
import {getConfig, getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general';
import {getRemoteNamesForChannel} from 'mattermost-redux/selectors/entities/shared_channels';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {
displayLastActiveLabel,
@@ -60,6 +62,7 @@ function makeMapStateToProps() {
let gmMembers;
let customStatus;
let lastActivityTimestamp;
let remoteNames: string[] = [];
if (channel && channel.type === General.DM_CHANNEL) {
const dmUserId = getUserIdFromChannelName(user.id, channel.name);
@@ -69,6 +72,11 @@ function makeMapStateToProps() {
} else if (channel && channel.type === General.GM_CHANNEL) {
gmMembers = doGetProfilesInChannel(state, channel.id);
}
if (channel?.shared) {
remoteNames = getRemoteNamesForChannel(state, channel.id);
}
const stats = getCurrentChannelStats(state);
let isLastActiveEnabled = false;
@@ -85,6 +93,7 @@ function makeMapStateToProps() {
currentUser: user,
dmUser,
gmMembers,
remoteNames,
rhsState: getRhsState(state),
isChannelMuted: isCurrentChannelMuted(state),
hasGuests: stats ? stats.guest_count > 0 : false,
@@ -110,6 +119,7 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({
getCustomEmojisInText,
updateChannelNotifyProps,
showChannelMembers,
fetchChannelRemotes,
}, dispatch),
});

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

@@ -0,0 +1,181 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import userEvent from '@testing-library/user-event';
import React from 'react';
import {renderWithContext, screen, waitFor, act} from 'tests/react_testing_utils';
import SharedChannelIndicator from './shared_channel_indicator';
describe('components/SharedChannelIndicator', () => {
test('should render without tooltip', () => {
renderWithContext(
<SharedChannelIndicator withTooltip={false}/>,
);
expect(screen.getByTestId('SharedChannelIcon')).toHaveClass('icon-circle-multiple-outline');
});
test('should render with default tooltip when no remote names', async () => {
jest.useFakeTimers();
renderWithContext(
<SharedChannelIndicator withTooltip={true}/>,
);
const icon = screen.getByTestId('SharedChannelIcon');
expect(icon).toHaveClass('icon-circle-multiple-outline');
await act(async () => {
userEvent.hover(icon);
jest.advanceTimersByTime(1000);
await waitFor(() => {
expect(screen.getByText('Shared with trusted organizations')).toBeInTheDocument();
});
});
});
test('should render with remote names in tooltip', async () => {
jest.useFakeTimers();
const remoteNames = ['Remote 1', 'Remote 2'];
renderWithContext(
<SharedChannelIndicator
withTooltip={true}
remoteNames={remoteNames}
/>,
);
const icon = screen.getByTestId('SharedChannelIcon');
expect(icon).toHaveClass('icon-circle-multiple-outline');
await act(async () => {
userEvent.hover(icon);
jest.advanceTimersByTime(1000);
await waitFor(() => {
expect(screen.getByText('Shared with: Remote 1, Remote 2')).toBeInTheDocument();
});
});
});
test('should truncate and show count when more than 3 remote names', async () => {
jest.useFakeTimers();
const remoteNames = ['Remote 1', 'Remote 2', 'Remote 3', 'Remote 4', 'Remote 5'];
renderWithContext(
<SharedChannelIndicator
withTooltip={true}
remoteNames={remoteNames}
/>,
);
const icon = screen.getByTestId('SharedChannelIcon');
expect(icon).toHaveClass('icon-circle-multiple-outline');
await act(async () => {
userEvent.hover(icon);
jest.advanceTimersByTime(1000);
await waitFor(() => {
expect(screen.getByText('Shared with: Remote 1, Remote 2, Remote 3 and 2 others')).toBeInTheDocument();
});
});
});
test('should truncate long organization names with ellipsis', async () => {
jest.useFakeTimers();
const remoteNames = ['A Very Very Very Very Very Long Organization Name That Needs Truncation', 'Remote 2'];
renderWithContext(
<SharedChannelIndicator
withTooltip={true}
remoteNames={remoteNames}
/>,
);
const icon = screen.getByTestId('SharedChannelIcon');
expect(icon).toHaveClass('icon-circle-multiple-outline');
await act(async () => {
userEvent.hover(icon);
jest.advanceTimersByTime(1000);
await waitFor(() => {
expect(screen.getByText('Shared with: A Very Very Very Very Very Lon..., Remote 2')).toBeInTheDocument();
});
});
});
test('should correctly handle singular "other" in text', async () => {
jest.useFakeTimers();
const remoteNames = ['Remote 1', 'Remote 2', 'Remote 3', 'Remote 4'];
renderWithContext(
<SharedChannelIndicator
withTooltip={true}
remoteNames={remoteNames}
/>,
);
const icon = screen.getByTestId('SharedChannelIcon');
expect(icon).toHaveClass('icon-circle-multiple-outline');
await act(async () => {
userEvent.hover(icon);
jest.advanceTimersByTime(1000);
await waitFor(() => {
expect(screen.getByText('Shared with: Remote 1, Remote 2, Remote 3 and 1 other')).toBeInTheDocument();
});
});
});
test('should limit the overall tooltip length for extremely long content', async () => {
jest.useFakeTimers();
// Generate an array of very long remote names that would produce an extremely long tooltip
const longRemoteNames = [
'Very Long Organization Name 1 That Exceeds Length Limits',
'Very Long Organization Name 2 That Exceeds Length Limits',
'Very Long Organization Name 3 That Exceeds Length Limits',
'Very Long Organization Name 4 That Exceeds Length Limits',
'Very Long Organization Name 5 That Exceeds Length Limits',
'Very Long Organization Name 6 That Exceeds Length Limits',
];
renderWithContext(
<SharedChannelIndicator
withTooltip={true}
remoteNames={longRemoteNames}
/>,
);
const icon = screen.getByTestId('SharedChannelIcon');
expect(icon).toHaveClass('icon-circle-multiple-outline');
await act(async () => {
userEvent.hover(icon);
jest.advanceTimersByTime(1000);
await waitFor(() => {
// Check that the tooltip contains text with ellipsis, indicating truncation
const tooltipText = screen.getByText(/Shared with:.*\.\.\./);
expect(tooltipText).toBeInTheDocument();
// Verify overall tooltip length doesn't exceed the maximum length (120)
// Add some extra characters to account for the "Shared with: " prefix
const tooltipContent = tooltipText.textContent || '';
const actualContent = tooltipContent.replace('Shared with: ', '');
expect(actualContent.length).toBeLessThanOrEqual(120);
});
});
});
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});
});

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

@@ -9,21 +9,87 @@ import WithTooltip from 'components/with_tooltip';
type Props = {
className?: string;
withTooltip?: boolean;
remoteNames?: string[];
};
const SharedChannelIndicator: React.FC<Props> = (props: Props): JSX.Element => {
const sharedIcon = (<i className={`${props.className || ''} icon-circle-multiple-outline`}/>);
const sharedIcon = (
<i
data-testid='SharedChannelIcon'
className={`${props.className || ''} icon-circle-multiple-outline`}
/>
);
if (!props.withTooltip) {
return sharedIcon;
}
const sharedTooltipText = (
<FormattedMessage
id='shared_channel_indicator.tooltip'
defaultMessage='Shared with trusted organizations'
/>
);
let sharedTooltipText;
if (props.remoteNames && props.remoteNames.length > 0) {
// If we have remote names, display them in the tooltip
// Show first 3 remotes and then "and N others" if there are more
const MAX_DISPLAY_NAMES = 3;
const MAX_NAME_LENGTH = 30;
const MAX_TOOLTIP_LENGTH = 120; // Maximum overall tooltip length
// Truncate long organization names
const truncatedNames = props.remoteNames.map((name) => (
name.length > MAX_NAME_LENGTH ?
`${name.substring(0, MAX_NAME_LENGTH)}...` :
name
));
if (truncatedNames.length <= MAX_DISPLAY_NAMES) {
// If we have 3 or fewer organizations, just display them all separated by commas
sharedTooltipText = (
<FormattedMessage
id='shared_channel_indicator.tooltip_with_names.few'
defaultMessage='Shared with: {organizations}'
values={{
organizations: truncatedNames.join(', '),
}}
/>
);
} else {
// If we have more than MAX_DISPLAY_NAMES organizations, show the first few and then "and N others"
const displayNames = truncatedNames.slice(0, MAX_DISPLAY_NAMES);
const remainingCount = truncatedNames.length - MAX_DISPLAY_NAMES;
sharedTooltipText = (
<FormattedMessage
id='shared_channel_indicator.tooltip_with_names.many'
defaultMessage='Shared with: {organizations} and {count, number} {count, plural, one {other} other {others}}'
values={{
organizations: displayNames.join(', '),
count: remainingCount,
}}
/>
);
}
// Add a final truncation to enforce maximum tooltip length
if (truncatedNames.join(', ').length > MAX_TOOLTIP_LENGTH) {
const truncatedStr = truncatedNames.join(', ').substring(0, MAX_TOOLTIP_LENGTH - 3) + '...';
sharedTooltipText = (
<FormattedMessage
id='shared_channel_indicator.tooltip_with_names'
defaultMessage='Shared with: {remoteNames}'
values={{
remoteNames: truncatedStr,
}}
/>
);
}
} else {
// Fallback to generic message if no remote names are available
sharedTooltipText = (
<FormattedMessage
id='shared_channel_indicator.tooltip'
defaultMessage='Shared with trusted organizations'
/>
);
}
return (
<WithTooltip

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

@@ -0,0 +1,206 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import userEvent from '@testing-library/user-event';
import React from 'react';
import * as reactIntl from 'react-intl';
import {renderWithContext, screen, waitFor, act} from 'tests/react_testing_utils';
import SharedUserIndicator from './shared_user_indicator';
describe('components/SharedUserIndicator', () => {
// Mocks for i18n
beforeEach(() => {
const mockIntl = {
formatMessage: jest.fn((descriptor) => {
if (descriptor.id === 'shared_user_indicator.tooltip') {
return 'From trusted organizations';
}
if (descriptor.id === 'shared_user_indicator.tooltip_with_names') {
return `From: ${descriptor.values?.remoteNames}`;
}
if (descriptor.id === 'shared_user_indicator.aria_label') {
return 'shared user indicator';
}
return descriptor.defaultMessage || '';
}),
locale: 'en',
defaultLocale: 'en',
messages: {},
};
jest.spyOn(reactIntl, 'useIntl').mockImplementation(() => mockIntl as any);
});
test('should render without tooltip', () => {
renderWithContext(
<SharedUserIndicator withTooltip={false}/>,
);
const icon = screen.getByTestId('SharedUserIcon');
expect(icon).toHaveClass('icon-circle-multiple-outline');
});
test('should render with custom title in tooltip', async () => {
jest.useFakeTimers();
renderWithContext(
<SharedUserIndicator
withTooltip={true}
title='Custom title'
/>,
);
const icon = screen.getByTestId('SharedUserIcon');
expect(icon).toHaveClass('icon-circle-multiple-outline');
await act(async () => {
userEvent.hover(icon);
jest.advanceTimersByTime(1000);
await waitFor(() => {
expect(screen.getByText('Custom title')).toBeInTheDocument();
});
});
});
test('should render with default tooltip when no remote names', async () => {
jest.useFakeTimers();
renderWithContext(
<SharedUserIndicator withTooltip={true}/>,
);
const icon = screen.getByTestId('SharedUserIcon');
expect(icon).toHaveClass('icon-circle-multiple-outline');
await act(async () => {
userEvent.hover(icon);
jest.advanceTimersByTime(1000);
await waitFor(() => {
expect(screen.getByText('From trusted organizations')).toBeInTheDocument();
});
});
});
test('should render with remote names in tooltip', async () => {
jest.useFakeTimers();
const remoteNames = ['Remote 1', 'Remote 2'];
renderWithContext(
<SharedUserIndicator
withTooltip={true}
remoteNames={remoteNames}
/>,
);
const icon = screen.getByTestId('SharedUserIcon');
expect(icon).toHaveClass('icon-circle-multiple-outline');
await act(async () => {
userEvent.hover(icon);
jest.advanceTimersByTime(1000);
await waitFor(() => {
expect(screen.getByText('From: Remote 1, Remote 2')).toBeInTheDocument();
});
});
});
test('should truncate and show count when more than 3 remote names', async () => {
jest.useFakeTimers();
const remoteNames = ['Remote 1', 'Remote 2', 'Remote 3', 'Remote 4', 'Remote 5'];
renderWithContext(
<SharedUserIndicator
withTooltip={true}
remoteNames={remoteNames}
/>,
);
const icon = screen.getByTestId('SharedUserIcon');
expect(icon).toHaveClass('icon-circle-multiple-outline');
await act(async () => {
userEvent.hover(icon);
jest.advanceTimersByTime(1000);
await waitFor(() => {
expect(screen.getByText('From: Remote 1, Remote 2, Remote 3 and 2 others')).toBeInTheDocument();
});
});
});
test('should correctly handle singular "other" in text', async () => {
jest.useFakeTimers();
const remoteNames = ['Remote 1', 'Remote 2', 'Remote 3', 'Remote 4'];
renderWithContext(
<SharedUserIndicator
withTooltip={true}
remoteNames={remoteNames}
/>,
);
const icon = screen.getByTestId('SharedUserIcon');
expect(icon).toHaveClass('icon-circle-multiple-outline');
await act(async () => {
userEvent.hover(icon);
jest.advanceTimersByTime(1000);
await waitFor(() => {
expect(screen.getByText('From: Remote 1, Remote 2, Remote 3 and 1 other')).toBeInTheDocument();
});
});
});
test('should limit the overall tooltip length for extremely long content', async () => {
jest.useFakeTimers();
// Generate an array of very long remote names that would produce an extremely long tooltip
const longRemoteNames = [
'Very Long Organization Name 1 That Exceeds Length Limits',
'Very Long Organization Name 2 That Exceeds Length Limits',
'Very Long Organization Name 3 That Exceeds Length Limits',
'Very Long Organization Name 4 That Exceeds Length Limits',
'Very Long Organization Name 5 That Exceeds Length Limits',
'Very Long Organization Name 6 That Exceeds Length Limits',
];
renderWithContext(
<SharedUserIndicator
withTooltip={true}
remoteNames={longRemoteNames}
/>,
);
const icon = screen.getByTestId('SharedUserIcon');
expect(icon).toHaveClass('icon-circle-multiple-outline');
await act(async () => {
userEvent.hover(icon);
jest.advanceTimersByTime(1000);
await waitFor(() => {
// Just check that the tooltip text ends with ellipsis, indicating truncation
const tooltipText = screen.getByText(/From:.+\.\.\.$/);
expect(tooltipText).toBeInTheDocument();
// Verify overall tooltip length doesn't exceed the maximum length (120)
// Add some extra characters to account for the "From: " prefix
const tooltipContent = tooltipText.textContent || '';
const actualContent = tooltipContent.replace('From: ', '');
expect(actualContent.length).toBeLessThanOrEqual(120);
});
});
});
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
jest.restoreAllMocks();
});
});

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

@@ -19,6 +19,8 @@ type Props = {
className?: string;
withTooltip?: boolean;
remoteNames?: string[];
};
const SharedUserIndicator = (props: Props) => {
@@ -26,6 +28,7 @@ const SharedUserIndicator = (props: Props) => {
const sharedIcon = (
<i
data-testid='SharedUserIcon'
className={classNames('icon icon-circle-multiple-outline', props.className)}
aria-label={props.ariaLabel || intl.formatMessage({id: 'shared_user_indicator.aria_label', defaultMessage: 'shared user indicator'})}
role={props?.role}
@@ -36,6 +39,39 @@ const SharedUserIndicator = (props: Props) => {
return sharedIcon;
}
// If we have remote names, use them in the tooltip
if (props.remoteNames && props.remoteNames.length > 0) {
// Show first 3 remotes and then "and N others" if there are more
const MAX_DISPLAY_NAMES = 3;
const MAX_TOOLTIP_LENGTH = 120; // Maximum overall tooltip length
let remoteNamesText;
if (props.remoteNames.length <= MAX_DISPLAY_NAMES) {
remoteNamesText = props.remoteNames.join(', ');
} else {
const displayNames = props.remoteNames.slice(0, MAX_DISPLAY_NAMES);
const remainingCount = props.remoteNames.length - MAX_DISPLAY_NAMES;
remoteNamesText = `${displayNames.join(', ')} and ${remainingCount} other${remainingCount > 1 ? 's' : ''}`;
}
// Add a final truncation to enforce maximum tooltip length
if (remoteNamesText.length > MAX_TOOLTIP_LENGTH) {
remoteNamesText = remoteNamesText.substring(0, MAX_TOOLTIP_LENGTH - 3) + '...';
}
return (
<WithTooltip
title={intl.formatMessage(
{id: 'shared_user_indicator.tooltip_with_names', defaultMessage: 'From: {remoteNames}'},
{remoteNames: remoteNamesText},
)}
>
{sharedIcon}
</WithTooltip>
);
}
// Fallback to the generic message
return (
<WithTooltip
title={props.title || intl.formatMessage({id: 'shared_user_indicator.tooltip', defaultMessage: 'From trusted organizations'})}

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

@@ -1,5 +1,92 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/sidebar/sidebar_channel/sidebar_channel_link should fetch shared channels data when channel is shared 1`] = `
<Link
aria-label="channel_label"
className="SidebarLink"
id="sidebarItem_"
onClick={[Function]}
tabIndex={0}
to="http://a.fake.link"
>
<SidebarChannelIcon
icon={null}
isDeleted={false}
/>
<div
className="SidebarChannelLinkLabel_wrapper"
>
<span
className="SidebarChannelLinkLabel"
>
channel_label
</span>
<Pluggable
channel={
Object {
"create_at": 0,
"creator_id": "",
"delete_at": 0,
"display_name": "channel_display_name",
"group_constrained": false,
"header": "",
"id": "channel_id",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "",
"purpose": "",
"scheme_id": "",
"team_id": "",
"type": "O",
"update_at": 0,
}
}
pluggableName="SidebarChannelLinkLabel"
/>
<SharedChannelIndicator
className="icon"
remoteNames={Array []}
withTooltip={true}
/>
</div>
<Connect(Component)
id="channel_id"
/>
<ChannelMentionBadge
hasUrgent={false}
unreadMentions={0}
/>
<div
className="SidebarMenu MenuWrapper"
>
<Connect(Component)
channel={
Object {
"create_at": 0,
"creator_id": "",
"delete_at": 0,
"display_name": "channel_display_name",
"group_constrained": false,
"header": "",
"id": "channel_id",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "",
"purpose": "",
"scheme_id": "",
"team_id": "",
"type": "O",
"update_at": 0,
}
}
channelLink="http://a.fake.link"
isUnread={false}
onMenuToggle={[Function]}
/>
</div>
</Link>
`;
exports[`components/sidebar/sidebar_channel/sidebar_channel_link should match snapshot 1`] = `
<Link
aria-label="channel_label"
@@ -331,3 +418,177 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should match sn
</div>
</Link>
`;
exports[`components/sidebar/sidebar_channel/sidebar_channel_link should not fetch shared channels data when data already exists 1`] = `
<Link
aria-label="channel_label"
className="SidebarLink"
id="sidebarItem_"
onClick={[Function]}
tabIndex={0}
to="http://a.fake.link"
>
<SidebarChannelIcon
icon={null}
isDeleted={false}
/>
<div
className="SidebarChannelLinkLabel_wrapper"
>
<span
className="SidebarChannelLinkLabel"
>
channel_label
</span>
<Pluggable
channel={
Object {
"create_at": 0,
"creator_id": "",
"delete_at": 0,
"display_name": "channel_display_name",
"group_constrained": false,
"header": "",
"id": "channel_id",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "",
"purpose": "",
"scheme_id": "",
"team_id": "",
"type": "O",
"update_at": 0,
}
}
pluggableName="SidebarChannelLinkLabel"
/>
<SharedChannelIndicator
className="icon"
remoteNames={
Array [
"Remote 1",
"Remote 2",
]
}
withTooltip={true}
/>
</div>
<Connect(Component)
id="channel_id"
/>
<ChannelMentionBadge
hasUrgent={false}
unreadMentions={0}
/>
<div
className="SidebarMenu MenuWrapper"
>
<Connect(Component)
channel={
Object {
"create_at": 0,
"creator_id": "",
"delete_at": 0,
"display_name": "channel_display_name",
"group_constrained": false,
"header": "",
"id": "channel_id",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "",
"purpose": "",
"scheme_id": "",
"team_id": "",
"type": "O",
"update_at": 0,
}
}
channelLink="http://a.fake.link"
isUnread={false}
onMenuToggle={[Function]}
/>
</div>
</Link>
`;
exports[`components/sidebar/sidebar_channel/sidebar_channel_link should not fetch shared channels for non-shared channels 1`] = `
<Link
aria-label="channel_label"
className="SidebarLink"
id="sidebarItem_"
onClick={[Function]}
tabIndex={0}
to="http://a.fake.link"
>
<SidebarChannelIcon
icon={null}
isDeleted={false}
/>
<div
className="SidebarChannelLinkLabel_wrapper"
>
<span
className="SidebarChannelLinkLabel"
>
channel_label
</span>
<Pluggable
channel={
Object {
"create_at": 0,
"creator_id": "",
"delete_at": 0,
"display_name": "channel_display_name",
"group_constrained": false,
"header": "",
"id": "channel_id",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "",
"purpose": "",
"scheme_id": "",
"team_id": "",
"type": "O",
"update_at": 0,
}
}
pluggableName="SidebarChannelLinkLabel"
/>
</div>
<Connect(Component)
id="channel_id"
/>
<ChannelMentionBadge
hasUrgent={false}
unreadMentions={0}
/>
<div
className="SidebarMenu MenuWrapper"
>
<Connect(Component)
channel={
Object {
"create_at": 0,
"creator_id": "",
"delete_at": 0,
"display_name": "channel_display_name",
"group_constrained": false,
"header": "",
"id": "channel_id",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "",
"purpose": "",
"scheme_id": "",
"team_id": "",
"type": "O",
"update_at": 0,
}
}
channelLink="http://a.fake.link"
isUnread={false}
onMenuToggle={[Function]}
/>
</div>
</Link>
`;

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

@@ -7,10 +7,12 @@ import type {Dispatch} from 'redux';
import type {Channel} from '@mattermost/types/channels';
import {fetchChannelRemotes} from 'mattermost-redux/actions/shared_channels';
import {makeGetChannelUnreadCount} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUserId, getMyChannelMemberships} from 'mattermost-redux/selectors/entities/common';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getInt} from 'mattermost-redux/selectors/entities/preferences';
import {getRemoteNamesForChannel} from 'mattermost-redux/selectors/entities/shared_channels';
import {isChannelMuted} from 'mattermost-redux/utils/channel_utils';
import {markMostRecentPostInChannelAsUnread, unsetEditingPost} from 'actions/post_actions';
@@ -50,6 +52,10 @@ function makeMapStateToProps() {
const isOnboardingFlowEnabled = config.EnableOnboardingFlow;
const showChannelsTour = enableTutorial && tutorialStep === OnboardingTourSteps.CHANNELS_AND_DIRECT_MESSAGES;
const showChannelsTutorialStep = showChannelsTour && channelTourTriggered && isOnboardingFlowEnabled === 'true';
const remoteNames = ownProps.channel?.shared ?
getRemoteNamesForChannel(state, ownProps.channel.id) : [];
return {
unreadMentions: unreadCount.mentions,
unreadMsgs: unreadCount.messages,
@@ -61,6 +67,7 @@ function makeMapStateToProps() {
showChannelsTutorialStep,
rhsState: getRhsState(state),
rhsOpen: getIsRhsOpen(state),
remoteNames,
};
};
}
@@ -74,6 +81,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
multiSelectChannelTo,
multiSelectChannelAdd,
closeRightHandSide,
fetchChannelRemotes,
}, dispatch),
};
}

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

@@ -5,10 +5,17 @@ import React from 'react';
import type {ChannelType} from '@mattermost/types/channels';
import type {SidebarChannelLink as SidebarChannelLinkComponent} from 'components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link';
import SidebarChannelLink from 'components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link';
import SidebarChannelLink, {type SidebarChannelLink as SidebarChannelLinkComponent} from 'components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link';
import {shallowWithIntl} from 'tests/helpers/intl-test-helper';
import {shallowWithIntl, defaultIntl} from 'tests/helpers/intl-test-helper';
jest.mock('packages/mattermost-redux/src/selectors/entities/shared_channels', () => ({
getRemoteNamesForChannel: jest.fn(),
}));
jest.mock('packages/mattermost-redux/src/actions/shared_channels', () => ({
fetchChannelRemotes: jest.fn(() => ({type: 'MOCK_ACTION'})),
}));
describe('components/sidebar/sidebar_channel/sidebar_channel_link', () => {
const baseProps = {
@@ -38,6 +45,10 @@ describe('components/sidebar/sidebar_channel/sidebar_channel_link', () => {
isChannelSelected: false,
hasUrgent: false,
showChannelsTutorialStep: false,
remoteNames: [],
isSharedChannel: false,
fetchChannelRemotes: jest.fn(),
intl: defaultIntl,
actions: {
markMostRecentPostInChannelAsUnread: jest.fn(),
multiSelectChannel: jest.fn(),
@@ -47,6 +58,7 @@ describe('components/sidebar/sidebar_channel/sidebar_channel_link', () => {
openLhs: jest.fn(),
unsetEditingPost: jest.fn(),
closeRightHandSide: jest.fn(),
fetchChannelRemotes: jest.fn(),
},
};
@@ -109,4 +121,72 @@ describe('components/sidebar/sidebar_channel/sidebar_channel_link', () => {
instance.enableToolTipIfNeeded();
expect(instance.state.showTooltip).toBe(true);
});
test('should not fetch shared channels for non-shared channels', () => {
const props = {
...baseProps,
isSharedChannel: false,
};
const wrapper = shallowWithIntl(
<SidebarChannelLink {...props}/>,
);
expect(wrapper).toMatchSnapshot();
expect(props.actions.fetchChannelRemotes).not.toHaveBeenCalled();
});
test('should fetch shared channels data when channel is shared', () => {
const props = {
...baseProps,
isSharedChannel: true,
remoteNames: [],
};
const wrapper = shallowWithIntl(
<SidebarChannelLink {...props}/>,
);
expect(wrapper).toMatchSnapshot();
expect(props.actions.fetchChannelRemotes).toHaveBeenCalledWith('channel_id');
});
test('should not fetch shared channels data when data already exists', () => {
const props = {
...baseProps,
isSharedChannel: true,
remoteNames: ['Remote 1', 'Remote 2'],
};
const wrapper = shallowWithIntl(
<SidebarChannelLink {...props}/>,
);
expect(wrapper).toMatchSnapshot();
expect(props.actions.fetchChannelRemotes).not.toHaveBeenCalled();
});
test('should refetch when channel changes', () => {
const props = {
...baseProps,
isSharedChannel: true,
remoteNames: [],
};
const wrapper = shallowWithIntl(
<SidebarChannelLink {...props}/>,
);
props.actions.fetchChannelRemotes.mockClear();
wrapper.setProps({
...props,
channel: {
...props.channel,
id: 'new_channel_id',
},
});
expect(props.actions.fetchChannelRemotes).toHaveBeenCalledWith('new_channel_id');
});
});

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

@@ -63,6 +63,7 @@ type Props = WrappedComponentProps & {
rhsState?: RhsState;
rhsOpen?: boolean;
isSharedChannel?: boolean;
remoteNames: string[];
actions: {
markMostRecentPostInChannelAsUnread: (channelId: string) => void;
@@ -71,6 +72,7 @@ type Props = WrappedComponentProps & {
multiSelectChannelAdd: (channelId: string) => void;
unsetEditingPost: () => void;
closeRightHandSide: () => void;
fetchChannelRemotes: (channelId: string) => void;
};
};
@@ -95,12 +97,23 @@ export class SidebarChannelLink extends React.PureComponent<Props, State> {
componentDidMount(): void {
this.enableToolTipIfNeeded();
if (this.props.isSharedChannel && this.props.channel?.id && this.props.remoteNames.length === 0) {
this.props.actions.fetchChannelRemotes(this.props.channel.id);
}
}
componentDidUpdate(prevProps: Props): void {
if (prevProps.label !== this.props.label) {
this.enableToolTipIfNeeded();
}
if (this.props.isSharedChannel &&
(prevProps.channel?.id !== this.props.channel?.id || prevProps.channel?.team_id !== this.props.channel?.team_id) &&
this.props.remoteNames.length === 0 &&
this.props.channel?.id) {
this.props.actions.fetchChannelRemotes(this.props.channel.id);
}
}
enableToolTipIfNeeded = (): void => {
@@ -227,6 +240,7 @@ export class SidebarChannelLink extends React.PureComponent<Props, State> {
<SharedChannelIndicator
className='icon'
withTooltip={true}
remoteNames={this.props.remoteNames}
/>
) : null;

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

@@ -3,11 +3,15 @@
import type {ConnectedProps} from 'react-redux';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import type {Channel} from '@mattermost/types/channels';
import type {UserProfile as UserProfileType} from '@mattermost/types/users';
import {fetchRemoteClusterInfo} from 'mattermost-redux/actions/shared_channels';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getRemoteDisplayName} from 'mattermost-redux/selectors/entities/shared_channels';
import {getUser, makeGetDisplayName} from 'mattermost-redux/selectors/entities/users';
import type {GlobalState} from 'types/store';
@@ -31,18 +35,33 @@ function makeMapStateToProps() {
return (state: GlobalState, ownProps: OwnProps) => {
const user = getUser(state, ownProps.userId);
const theme = getTheme(state);
let remoteNames: string[] = [];
if (user?.remote_id) {
const remoteDisplayName = getRemoteDisplayName(state, user.remote_id);
if (remoteDisplayName) {
remoteNames = [remoteDisplayName];
}
}
return {
displayName: getDisplayName(state, ownProps.userId, true),
user,
theme,
isShared: Boolean(user && user.remote_id),
remoteNames,
};
};
}
const connector = connect(makeMapStateToProps);
const mapDispatchToProps = (dispatch: Dispatch) => ({
actions: bindActionCreators({
fetchRemoteClusterInfo,
}, dispatch),
});
const connector = connect(makeMapStateToProps, mapDispatchToProps);
export type PropsFromRedux = ConnectedProps<typeof connector>;
export default connect(makeMapStateToProps)(UserProfile);
export default connector(UserProfile);

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

@@ -19,6 +19,10 @@ describe('components/UserProfile', () => {
userId: 'user_id',
theme: Preferences.THEMES.onyx,
isShared: false,
remoteNames: [],
actions: {
fetchRemoteClusterInfo: jest.fn(),
},
dispatch: jest.fn(),
};

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

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import type {ReactNode} from 'react';
import React from 'react';
import React, {useEffect} from 'react';
import {isGuest} from 'mattermost-redux/utils/user_utils';
@@ -31,7 +31,15 @@ export default function UserProfile({
userId,
channelId,
overwriteIcon,
remoteNames,
actions,
}: Props) {
// Fetch remote info when component mounts for remote users
useEffect(() => {
if (user?.remote_id && (!remoteNames || remoteNames.length === 0)) {
actions.fetchRemoteClusterInfo(user.remote_id);
}
}, [user?.remote_id, remoteNames, actions]);
let name: ReactNode;
if (user && displayUsername) {
name = `@${(user.username)}`;
@@ -88,6 +96,7 @@ export default function UserProfile({
<SharedUserIndicator
className='shared-user-icon'
withTooltip={true}
remoteNames={remoteNames}
/>
}
{(user && user.is_bot) && <BotTag/>}

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

@@ -5281,8 +5281,12 @@
"setting_picture.title": "Team Icon",
"setting_picture.uploading": "Uploading...",
"shared_channel_indicator.tooltip": "Shared with trusted organizations",
"shared_channel_indicator.tooltip_with_names": "Shared with: {remoteNames}",
"shared_channel_indicator.tooltip_with_names.few": "Shared with: {organizations}",
"shared_channel_indicator.tooltip_with_names.many": "Shared with: {organizations} and {count, number} {count, plural, one {other} other {others}}",
"shared_user_indicator.aria_label": "shared user indicator",
"shared_user_indicator.tooltip": "From a trusted organization",
"shared_user_indicator.tooltip_with_names": "From: {remoteNames}",
"shortcuts.browser.channel_next": "Forward in history:\tAlt|Right",
"shortcuts.browser.channel_next.mac": "Forward in history:\t⌘|]",
"shortcuts.browser.channel_prev": "Back in history:\tAlt|Left",

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

@@ -28,6 +28,7 @@ import RoleTypes from './roles';
import SchemeTypes from './schemes';
import ScheduledPostTypes from './scheudled_posts';
import SearchTypes from './search';
import SharedChannelTypes from './shared_channels';
import TeamTypes from './teams';
import ThreadTypes from './threads';
import UserTypes from './users';
@@ -61,6 +62,7 @@ export {
PlaybookType,
ChannelBookmarkTypes,
ScheduledPostTypes,
SharedChannelTypes,
};
/**

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

@@ -0,0 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import keyMirror from 'mattermost-redux/utils/key_mirror';
export default keyMirror({
RECEIVED_CHANNEL_REMOTES: null,
RECEIVED_REMOTE_CLUSTER_INFO: null,
});

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

@@ -0,0 +1,182 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {RemoteClusterInfo} from '@mattermost/types/shared_channels';
import {Client4} from 'mattermost-redux/client';
import SharedChannelTypes from '../../action_types/shared_channels';
import {fetchChannelRemotes, receivedChannelRemotes} from '../shared_channels';
jest.mock('mattermost-redux/client');
describe('shared_channels actions', () => {
test('receivedChannelRemotes should create the correct action', () => {
const channelId = 'channel1';
const remotes: RemoteClusterInfo[] = [
{
name: 'remote1',
display_name: 'Remote 1',
create_at: 123,
last_ping_at: 456,
delete_at: 0,
},
{
name: 'remote2',
display_name: 'Remote 2',
create_at: 789,
last_ping_at: 101,
delete_at: 0,
},
];
const action = receivedChannelRemotes(channelId, remotes);
expect(action).toEqual({
type: SharedChannelTypes.RECEIVED_CHANNEL_REMOTES,
data: {
channelId,
remotes,
},
});
});
test('fetchChannelRemotes should fetch and dispatch remotes', async () => {
const channelId = 'channel1';
const remotes: RemoteClusterInfo[] = [
{
name: 'remote1',
display_name: 'Remote 1',
create_at: 123,
last_ping_at: 456,
delete_at: 0,
},
{
name: 'remote2',
display_name: 'Remote 2',
create_at: 789,
last_ping_at: 101,
delete_at: 0,
},
];
// Mock the client response
(Client4.getSharedChannelRemoteInfos as jest.Mock).mockResolvedValueOnce(remotes);
// Mock the getState function to return no existing remotes
const getState = jest.fn().mockReturnValue({
entities: {
sharedChannels: {
remotes: {},
},
},
});
const dispatch = jest.fn();
await fetchChannelRemotes(channelId)(dispatch, getState, {});
// Verify Client4 was called
expect(Client4.getSharedChannelRemoteInfos).toHaveBeenCalledWith(channelId);
// Verify the action was dispatched
expect(dispatch).toHaveBeenCalledWith({
type: SharedChannelTypes.RECEIVED_CHANNEL_REMOTES,
data: {
channelId,
remotes,
},
});
});
test('fetchChannelRemotes should not fetch if remotes already exist and no refresh is requested', async () => {
const channelId = 'channel1';
const remotes: RemoteClusterInfo[] = [
{
name: 'remote1',
display_name: 'Remote 1',
create_at: 123,
last_ping_at: 456,
delete_at: 0,
},
];
// Mock the getState function to return existing remotes
const getState = jest.fn().mockReturnValue({
entities: {
sharedChannels: {
remotes: {
[channelId]: remotes,
},
},
},
});
const dispatch = jest.fn();
await fetchChannelRemotes(channelId)(dispatch, getState, {});
// Verify Client4 was NOT called
expect(Client4.getSharedChannelRemoteInfos).not.toHaveBeenCalled();
// Verify no action was dispatched
expect(dispatch).not.toHaveBeenCalled();
});
test('fetchChannelRemotes should fetch if remotes exist but forceRefresh is true', async () => {
const channelId = 'channel1';
const existingRemotes: RemoteClusterInfo[] = [
{
name: 'remote1',
display_name: 'Remote 1',
create_at: 123,
last_ping_at: 456,
delete_at: 0,
},
];
const newRemotes: RemoteClusterInfo[] = [
{
name: 'remote1',
display_name: 'Remote 1',
create_at: 123,
last_ping_at: 456,
delete_at: 0,
},
{
name: 'remote2',
display_name: 'Remote 2',
create_at: 789,
last_ping_at: 101,
delete_at: 0,
},
];
// Mock the getState function to return existing remotes
const getState = jest.fn().mockReturnValue({
entities: {
sharedChannels: {
remotes: {
[channelId]: existingRemotes,
},
},
},
});
const dispatch = jest.fn();
// Mock the client response to return updated remotes
(Client4.getSharedChannelRemoteInfos as jest.Mock).mockResolvedValueOnce(newRemotes);
await fetchChannelRemotes(channelId, true)(dispatch, getState, {});
// Verify Client4 was called
expect(Client4.getSharedChannelRemoteInfos).toHaveBeenCalledWith(channelId);
// Verify the action was dispatched with the new remotes
expect(dispatch).toHaveBeenCalledWith({
type: SharedChannelTypes.RECEIVED_CHANNEL_REMOTES,
data: {
channelId,
remotes: newRemotes,
},
});
});
});

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

@@ -0,0 +1,87 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {RemoteClusterInfo} from '@mattermost/types/shared_channels';
import type {GlobalState} from '@mattermost/types/store';
import {logError} from 'mattermost-redux/actions/errors';
import {forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers';
import {Client4} from 'mattermost-redux/client';
import type {ActionFuncAsync} from 'mattermost-redux/types/actions';
import SharedChannelTypes from '../action_types/shared_channels';
export function receivedChannelRemotes(channelId: string, remotes: RemoteClusterInfo[]) {
return {
type: SharedChannelTypes.RECEIVED_CHANNEL_REMOTES,
data: {
channelId,
remotes,
},
};
}
export function receivedRemoteClusterInfo(remoteId: string, remoteInfo: RemoteClusterInfo) {
return {
type: SharedChannelTypes.RECEIVED_REMOTE_CLUSTER_INFO,
data: {
remoteId,
remoteInfo,
},
};
}
export function fetchChannelRemotes(channelId: string, forceRefresh = false): ActionFuncAsync<RemoteClusterInfo[]> {
return async (dispatch: any, getState: () => GlobalState) => {
// Check if we already have the data in the Redux store
const state = getState();
const remotes = state.entities?.sharedChannels?.remotes?.[channelId];
// If we already have the data and no refresh is requested, use the cached data
if (!forceRefresh && remotes && remotes.length > 0) {
return {data: remotes};
}
let data;
try {
data = await Client4.getSharedChannelRemoteInfos(channelId);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {error};
}
if (data) {
dispatch(receivedChannelRemotes(channelId, data));
}
return {data};
};
}
export function fetchRemoteClusterInfo(remoteId: string, forceRefresh = false): ActionFuncAsync<RemoteClusterInfo> {
return async (dispatch: any, getState: () => GlobalState) => {
// Check if we already have the remote info cached
const state = getState();
const cachedRemote = state.entities?.sharedChannels?.remotesByRemoteId?.[remoteId];
if (!forceRefresh && cachedRemote) {
return {data: cachedRemote};
}
let data;
try {
data = await Client4.getRemoteClusterInfo(remoteId);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {error};
}
if (data) {
dispatch(receivedRemoteClusterInfo(remoteId, data));
}
return {data};
};
}

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

@@ -0,0 +1,182 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import SharedChannelTypes from '../../../action_types/shared_channels';
import {remotes} from '../shared_channels';
describe('shared_channels reducer', () => {
test('RECEIVED_CHANNEL_REMOTES should store remotes correctly', () => {
const channelId = 'channel1';
const remotesList = [
{
name: 'remote1',
display_name: 'Remote 1',
create_at: 123,
last_ping_at: 456,
delete_at: 0,
remote_id: 'r1',
remote_team_id: 'rt1',
site_url: 'http://remote1.com',
creator_id: 'user1',
plugin_id: 'plugin1',
topics: 'topics1',
options: 1,
default_team_id: 'team1',
},
{
name: 'remote2',
display_name: 'Remote 2',
create_at: 789,
last_ping_at: 101,
delete_at: 0,
remote_id: 'r2',
remote_team_id: 'rt2',
site_url: 'http://remote2.com',
creator_id: 'user2',
plugin_id: 'plugin2',
topics: 'topics2',
options: 1,
default_team_id: 'team2',
},
];
const action = {
type: SharedChannelTypes.RECEIVED_CHANNEL_REMOTES,
data: {
channelId,
remotes: remotesList,
},
};
// Start with empty state
let state = {};
state = remotes(state, action);
// Verify the state was updated correctly
expect(state).toEqual({
[channelId]: remotesList,
});
// Add remotes for another channel
const channelId2 = 'channel2';
const remotesList2 = [
{
name: 'remote3',
display_name: 'Remote 3',
create_at: 555,
last_ping_at: 666,
delete_at: 0,
remote_id: 'r3',
remote_team_id: 'rt3',
site_url: 'http://remote3.com',
creator_id: 'user3',
plugin_id: 'plugin3',
topics: 'topics3',
options: 1,
default_team_id: 'team3',
},
];
const action2 = {
type: SharedChannelTypes.RECEIVED_CHANNEL_REMOTES,
data: {
channelId: channelId2,
remotes: remotesList2,
},
};
state = remotes(state, action2);
// Verify the state has both channel's remotes
expect(state).toEqual({
[channelId]: remotesList,
[channelId2]: remotesList2,
});
// Update the remotes for the first channel
const updatedRemotesList = [
{
name: 'remote1',
display_name: 'Remote 1 Updated',
create_at: 123,
last_ping_at: 789,
delete_at: 0,
remote_id: 'r1',
remote_team_id: 'rt1',
site_url: 'http://remote1.com',
creator_id: 'user1',
plugin_id: 'plugin1',
topics: 'topics1',
options: 1,
default_team_id: 'team1',
},
];
const action3 = {
type: SharedChannelTypes.RECEIVED_CHANNEL_REMOTES,
data: {
channelId,
remotes: updatedRemotesList,
},
};
state = remotes(state, action3);
// Verify the first channel's remotes were updated and the second channel's remotes remain unchanged
expect(state).toEqual({
[channelId]: updatedRemotesList,
[channelId2]: remotesList2,
});
});
test('Unknown action type should not modify state', () => {
const state = {
channel1: [
{
name: 'remote1',
display_name: 'Remote 1',
create_at: 123,
last_ping_at: 456,
delete_at: 0,
remote_id: 'r1',
remote_team_id: 'rt1',
site_url: 'http://remote1.com',
creator_id: 'user1',
plugin_id: 'plugin1',
topics: 'topics1',
options: 1,
default_team_id: 'team1',
},
],
};
const action = {
type: 'UNKNOWN_ACTION',
data: {
channelId: 'channel1',
remotes: [
{
name: 'remote2',
display_name: 'Remote 2',
create_at: 789,
last_ping_at: 101,
delete_at: 0,
remote_id: 'r2',
remote_team_id: 'rt2',
site_url: 'http://remote2.com',
creator_id: 'user2',
plugin_id: 'plugin2',
topics: 'topics2',
options: 1,
default_team_id: 'team2',
},
],
},
};
const newState = remotes(state, action);
// State should be unchanged
expect(newState).toBe(state);
});
});

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

@@ -24,6 +24,7 @@ import roles from './roles';
import scheduledPosts from './scheduled_posts';
import schemes from './schemes';
import search from './search';
import sharedChannels from './shared_channels';
import teams from './teams';
import threads from './threads';
import typing from './typing';
@@ -57,4 +58,5 @@ export default combineReducers({
hostedCustomer,
channelBookmarks,
scheduledPosts,
sharedChannels,
});

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

@@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {combineReducers} from 'redux';
import type {AnyAction} from 'redux';
import type {RemoteClusterInfo} from '@mattermost/types/shared_channels';
import SharedChannelTypes from '../../action_types/shared_channels';
export function remotes(state: Record<string, RemoteClusterInfo[]> = {}, action: AnyAction) {
switch (action.type) {
case SharedChannelTypes.RECEIVED_CHANNEL_REMOTES: {
const {channelId, remotes} = action.data;
return {
...state,
[channelId]: remotes,
};
}
default:
return state;
}
}
export function remotesByRemoteId(state: Record<string, RemoteClusterInfo> = {}, action: AnyAction) {
switch (action.type) {
case SharedChannelTypes.RECEIVED_REMOTE_CLUSTER_INFO: {
const {remoteId, remoteInfo} = action.data;
return {
...state,
[remoteId]: remoteInfo,
};
}
default:
return state;
}
}
export default combineReducers({
remotes,
remotesByRemoteId,
});

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

@@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getRemoteNamesForChannel, getRemotesForChannel} from '../shared_channels';
describe('shared_channels selectors', () => {
const channelId = 'channel1';
const remotes = [
{
name: 'remote1',
display_name: 'Remote 1',
create_at: 123,
last_ping_at: 456,
delete_at: 0,
},
{
name: 'remote2',
display_name: 'Remote 2',
create_at: 789,
last_ping_at: 101,
delete_at: 0,
},
];
const state = {
entities: {
sharedChannels: {
remotes: {
[channelId]: remotes,
},
},
},
};
test('getRemoteNamesForChannel should return display names from remotes', () => {
const result = getRemoteNamesForChannel(state as any, channelId);
expect(result).toEqual(['Remote 1', 'Remote 2']);
});
test('getRemoteNamesForChannel should return empty array when no remotes exist', () => {
const result = getRemoteNamesForChannel(state as any, 'nonexistent_channel');
expect(result).toEqual([]);
});
test('getRemotesForChannel should return all remote info', () => {
const result = getRemotesForChannel(state as any, channelId);
expect(result).toEqual(remotes);
});
test('getRemotesForChannel should return empty array when no remotes exist', () => {
const result = getRemotesForChannel(state as any, 'nonexistent_channel');
expect(result).toEqual([]);
});
});

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

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {RemoteClusterInfo} from '@mattermost/types/shared_channels';
import type {GlobalState} from '@mattermost/types/store';
export function getRemoteNamesForChannel(state: GlobalState, channelId: string): string[] {
const remotes = state.entities?.sharedChannels?.remotes?.[channelId];
if (remotes && remotes.length > 0) {
return remotes.map((remote: RemoteClusterInfo) => remote.display_name);
}
return [];
}
export function getRemotesForChannel(state: GlobalState, channelId: string): RemoteClusterInfo[] {
return state.entities?.sharedChannels?.remotes?.[channelId] || [];
}
export function getRemoteClusterInfo(state: GlobalState, remoteId: string): RemoteClusterInfo | null {
return state.entities?.sharedChannels?.remotesByRemoteId?.[remoteId] || null;
}
export function getRemoteDisplayName(state: GlobalState, remoteId: string): string | null {
const remote = getRemoteClusterInfo(state, remoteId);
return remote?.display_name || null;
}

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

@@ -120,7 +120,7 @@ import type {ScheduledPost} from '@mattermost/types/schedule_post';
import type {Scheme} from '@mattermost/types/schemes';
import type {Session} from '@mattermost/types/sessions';
import type {CompleteOnboardingRequest} from '@mattermost/types/setup';
import type {SharedChannelRemote} from '@mattermost/types/shared_channels';
import type {RemoteClusterInfo, SharedChannelRemote} from '@mattermost/types/shared_channels';
import type {
GetTeamMembersOpts,
Team,
@@ -2093,6 +2093,20 @@ export default class Client4 {
);
};
getSharedChannelRemoteInfos = (channelId: string) => {
return this.doFetch<RemoteClusterInfo[]>(
`${this.getBaseRoute()}/sharedchannels/${channelId}/remotes`,
{method: 'GET'},
);
};
getRemoteClusterInfo = (remoteId: string) => {
return this.doFetch<RemoteClusterInfo>(
`${this.getBaseRoute()}/sharedchannels/remote_info/${remoteId}`,
{method: 'GET'},
);
};
sharedChannelRemoteInvite = (remoteId: string, channelId: string) => {
return this.doFetch<StatusOK>(
`${this.getRemoteClusterRoute(remoteId)}/channels/${channelId}/invite`,

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

@@ -16,3 +16,17 @@ export type SharedChannelRemote = {
last_post_create_at: number;
last_post_create_id: string;
}
// RemoteClusterInfo matches the server-side struct with subset of RemoteCluster fields
export type RemoteClusterInfo = {
name: string;
display_name: string;
create_at: number;
delete_at: number;
last_ping_at: number;
};
export type SharedChannelsState = {
remotes: Record<string, RemoteClusterInfo[]>;
remotesByRemoteId: Record<string, RemoteClusterInfo>;
}

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

@@ -28,6 +28,7 @@ import type {Role} from './roles';
import type {ScheduledPostsState} from './schedule_post';
import type {SchemesState} from './schemes';
import type {SearchState} from './search';
import type {RemoteClusterInfo} from './shared_channels';
import type {TeamsState} from './teams';
import type {ThreadsState} from './threads';
import type {Typing} from './typing';
@@ -77,6 +78,10 @@ export type GlobalState = {
hostedCustomer: HostedCustomerState;
usage: CloudUsage;
scheduledPosts: ScheduledPostsState;
sharedChannels?: {
remotes?: Record<string, RemoteClusterInfo[]>;
remotesByRemoteId?: Record<string, RemoteClusterInfo>;
};
};
errors: any[];
requests: {