From bd5ca1c07ef869be7759692d95f0eb1bbb609a9e Mon Sep 17 00:00:00 2001 From: catalintomai <56169943+catalintomai@users.noreply.github.com> Date: Thu, 19 Jun 2025 07:57:22 +0200 Subject: [PATCH] MM-60640: [Shared Channels] Display remotes' names in Shared With tooltip (#30886) --- api/v4/source/channels.yaml | 38 +++ server/channels/api4/shared_channel.go | 49 ++++ .../api4/shared_channel_remotes_test.go | 123 +++++++++ .../channel_header.test.tsx.snap | 52 +++- .../channel_header/channel_header.test.tsx | 2 + .../channel_header/channel_header.tsx | 18 ++ .../channel_header_title.test.tsx | 178 ++++++++++++ .../channel_header/channel_header_title.tsx | 3 + .../src/components/channel_header/index.ts | 10 + .../shared_channel_indicator.test.tsx | 181 ++++++++++++ .../components/shared_channel_indicator.tsx | 80 +++++- .../components/shared_user_indicator.test.tsx | 206 ++++++++++++++ .../src/components/shared_user_indicator.tsx | 36 +++ .../sidebar_channel_link.test.tsx.snap | 261 ++++++++++++++++++ .../sidebar_channel_link/index.ts | 8 + .../sidebar_channel_link.test.tsx | 86 +++++- .../sidebar_channel_link.tsx | 14 + .../src/components/user_profile/index.ts | 23 +- .../user_profile/user_profile.test.tsx | 4 + .../components/user_profile/user_profile.tsx | 11 +- webapp/channels/src/i18n/en.json | 4 + .../src/action_types/index.ts | 2 + .../src/action_types/shared_channels.ts | 9 + .../actions/__tests__/shared_channels.test.ts | 182 ++++++++++++ .../src/actions/shared_channels.ts | 87 ++++++ .../__tests__/shared_channels.test.ts | 182 ++++++++++++ .../src/reducers/entities/index.ts | 2 + .../src/reducers/entities/shared_channels.ts | 42 +++ .../__tests__/shared_channels.test.ts | 54 ++++ .../src/selectors/entities/shared_channels.ts | 26 ++ webapp/platform/client/src/client4.ts | 16 +- webapp/platform/types/src/shared_channels.ts | 14 + webapp/platform/types/src/store.ts | 5 + 33 files changed, 1982 insertions(+), 26 deletions(-) create mode 100644 server/channels/api4/shared_channel_remotes_test.go create mode 100644 webapp/channels/src/components/channel_header/channel_header_title.test.tsx create mode 100644 webapp/channels/src/components/shared_channel_indicator.test.tsx create mode 100644 webapp/channels/src/components/shared_user_indicator.test.tsx create mode 100644 webapp/channels/src/packages/mattermost-redux/src/action_types/shared_channels.ts create mode 100644 webapp/channels/src/packages/mattermost-redux/src/actions/__tests__/shared_channels.test.ts create mode 100644 webapp/channels/src/packages/mattermost-redux/src/actions/shared_channels.ts create mode 100644 webapp/channels/src/packages/mattermost-redux/src/reducers/entities/__tests__/shared_channels.test.ts create mode 100644 webapp/channels/src/packages/mattermost-redux/src/reducers/entities/shared_channels.ts create mode 100644 webapp/channels/src/packages/mattermost-redux/src/selectors/entities/__tests__/shared_channels.test.ts create mode 100644 webapp/channels/src/packages/mattermost-redux/src/selectors/entities/shared_channels.ts diff --git a/api/v4/source/channels.yaml b/api/v4/source/channels.yaml index 379422a35f..de7a37735c 100644 --- a/api/v4/source/channels.yaml +++ b/api/v4/source/channels.yaml @@ -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" diff --git a/server/channels/api4/shared_channel.go b/server/channels/api4/shared_channel.go index 0cc5eab2d3..b200e5cda3 100644 --- a/server/channels/api4/shared_channel.go +++ b/server/channels/api4/shared_channel.go @@ -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)) + } +} diff --git a/server/channels/api4/shared_channel_remotes_test.go b/server/channels/api4/shared_channel_remotes_test.go new file mode 100644 index 0000000000..8d72a5f533 --- /dev/null +++ b/server/channels/api4/shared_channel_remotes_test.go @@ -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) +} diff --git a/webapp/channels/src/components/channel_header/__snapshots__/channel_header.test.tsx.snap b/webapp/channels/src/components/channel_header/__snapshots__/channel_header.test.tsx.snap index 95efefab30..5c63a8647c 100644 --- a/webapp/channels/src/components/channel_header/__snapshots__/channel_header.test.tsx.snap +++ b/webapp/channels/src/components/channel_header/__snapshots__/channel_header.test.tsx.snap @@ -67,6 +67,7 @@ exports[`components/ChannelHeader should match snapshot with last active display "username": "some-user", } } + remoteNames={Array []} />