From 034799c22194ca624dc29c27b2b11e2a95b292de Mon Sep 17 00:00:00 2001
From: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com>
Date: Thu, 16 Apr 2026 12:18:48 +0530
Subject: [PATCH] Fixed a bug where user profile popover closed automatically
when opened for the first time for a user from channel member list in RHS
(#35918)
* Fixed a bug where user profile popover closed automatically when opened for the first time for a user from channel member list in RHS
* Added tests
* fixed a test
---
.../channel_members_profile_popover.spec.ts | 49 +++++++
.../channel_members_rhs/member_list.tsx | 60 +++-----
.../member_list_item.test.tsx | 130 ++++++++++++++++++
.../channel_members_rhs/member_list_item.tsx | 66 +++++++++
.../profile_popover_custom_attributes.tsx | 2 +-
5 files changed, 264 insertions(+), 43 deletions(-)
create mode 100644 e2e-tests/playwright/specs/functional/channels/sidebar_right/channel_members_profile_popover.spec.ts
create mode 100644 webapp/channels/src/components/channel_members_rhs/member_list_item.test.tsx
create mode 100644 webapp/channels/src/components/channel_members_rhs/member_list_item.tsx
diff --git a/e2e-tests/playwright/specs/functional/channels/sidebar_right/channel_members_profile_popover.spec.ts b/e2e-tests/playwright/specs/functional/channels/sidebar_right/channel_members_profile_popover.spec.ts
new file mode 100644
index 00000000000..3a65f10c6df
--- /dev/null
+++ b/e2e-tests/playwright/specs/functional/channels/sidebar_right/channel_members_profile_popover.spec.ts
@@ -0,0 +1,49 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {expect, test} from '@mattermost/playwright-lib';
+
+test('should open and close profile popover from channel members RHS', async ({pw}) => {
+ // # Initialize setup with two users in the same team and channel
+ const {user, team, adminClient} = await pw.initSetup();
+
+ const channel = await adminClient.createChannel(
+ pw.random.channel({teamId: team.id, displayName: 'Test Channel', name: 'test-channel'}),
+ );
+ await adminClient.addToChannel(user.id, channel.id);
+
+ const testUser = await adminClient.createUser(await pw.random.user(), '', '');
+ await adminClient.addToTeam(team.id, testUser.id);
+ await adminClient.addToChannel(testUser.id, channel.id);
+
+ // # Log in and navigate to the channel
+ const {channelsPage, page} = await pw.testBrowser.login(user);
+ await channelsPage.goto(team.name, channel.name);
+ await channelsPage.toBeVisible();
+
+ // # Open channel members RHS by clicking the Members button in the header
+ await channelsPage.centerView.header.openChannelMenu();
+ const membersMenuItem = page.locator('#channelMembers');
+ await membersMenuItem.click();
+
+ // * Verify the channel members RHS is visible
+ await channelsPage.sidebarRight.toBeVisible();
+
+ // # Find the test user in the member list and click their display name to open profile popover
+ const memberEntry = page.getByTestId(`memberline-${testUser.id}`);
+ await expect(memberEntry).toBeVisible();
+
+ const displayName = memberEntry.locator('.channel-members-rhs__display-name');
+ await displayName.click();
+
+ // * Verify the profile popover is visible
+ const popover = channelsPage.userProfilePopover;
+ await popover.toBeVisible();
+ await expect(popover.container.getByText(`@${testUser.username}`)).toBeVisible();
+
+ // # Click outside the popover to close it
+ await page.mouse.click(1, 1);
+
+ // * Verify the profile popover is no longer visible
+ await expect(popover.container).not.toBeVisible();
+});
diff --git a/webapp/channels/src/components/channel_members_rhs/member_list.tsx b/webapp/channels/src/components/channel_members_rhs/member_list.tsx
index c4c15b83fd6..02f2b984895 100644
--- a/webapp/channels/src/components/channel_members_rhs/member_list.tsx
+++ b/webapp/channels/src/components/channel_members_rhs/member_list.tsx
@@ -1,16 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
-import React, {memo, useEffect, useRef, useState} from 'react';
+import React, {memo, useEffect, useMemo, useRef, useState} from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import {VariableSizeList} from 'react-window';
-import type {ListChildComponentProps} from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
import type {Channel, ChannelMembership} from '@mattermost/types/channels';
import type {UserProfile} from '@mattermost/types/users';
-import Member from './member';
+import MemberListItem from './member_list_item';
+import type {ItemData} from './member_list_item';
export interface ChannelMember {
user: UserProfile;
@@ -92,44 +92,20 @@ const MemberList = ({
return 48;
};
- const Item = ({index, style}: ListChildComponentProps) => {
- if (isItemLoaded(index)) {
- switch (members[index].type) {
- case ListItemType.Member:
- // eslint-disable-next-line no-case-declarations
- const member = members[index].data as ChannelMember;
- return (
-
- l.type === ListItemType.Member).length}
- member={member}
- editing={editing}
- actions={{openDirectMessage, fetchRemoteClusterInfo}}
- />
-
- );
- case ListItemType.Separator:
- case ListItemType.FirstSeparator:
- return (
-
- {members[index].data as JSX.Element}
-
- );
- default:
- return null;
- }
- }
+ const totalMemberCount = useMemo(
+ () => members.filter((l) => l.type === ListItemType.Member).length,
+ [members],
+ );
- return null;
- };
+ const itemData: ItemData = useMemo(() => ({
+ members,
+ hasNextPage,
+ channel,
+ editing,
+ totalMemberCount,
+ openDirectMessage,
+ fetchRemoteClusterInfo,
+ }), [members, hasNextPage, channel, editing, totalMemberCount, openDirectMessage, fetchRemoteClusterInfo]);
if (members.length === 0) {
return null;
@@ -153,12 +129,12 @@ const MemberList = ({
ref(list);
variableSizeListRef.current = list;
}}
-
+ itemData={itemData}
itemSize={getItemSize}
height={height}
width={width}
>
- {Item}
+ {MemberListItem}
)}
diff --git a/webapp/channels/src/components/channel_members_rhs/member_list_item.test.tsx b/webapp/channels/src/components/channel_members_rhs/member_list_item.test.tsx
new file mode 100644
index 00000000000..8d7c04abb47
--- /dev/null
+++ b/webapp/channels/src/components/channel_members_rhs/member_list_item.test.tsx
@@ -0,0 +1,130 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+
+import type {ChannelType} from '@mattermost/types/channels';
+
+import {renderWithContext, screen} from 'tests/react_testing_utils';
+import {TestHelper} from 'utils/test_helper';
+
+import type {ChannelMember} from './member_list';
+import {ListItemType} from './member_list';
+import type {ItemData} from './member_list_item';
+import MemberListItem from './member_list_item';
+
+jest.mock('./member', () => {
+ return (props: any) => (
+
+ {props.member.displayName}
+
+ );
+});
+
+describe('components/channel_members_rhs/MemberListItem', () => {
+ const mockChannel = TestHelper.getChannelMock({
+ id: 'channel_id',
+ display_name: 'Test Channel',
+ name: 'test-channel',
+ type: 'O' as ChannelType,
+ team_id: 'team_id',
+ });
+
+ const mockUser = TestHelper.getUserMock({
+ id: 'user_id_1',
+ username: 'testuser',
+ nickname: 'Test User',
+ roles: 'system_user',
+ });
+
+ const mockMembership = TestHelper.getChannelMembershipMock({
+ channel_id: 'channel_id',
+ user_id: 'user_id_1',
+ });
+
+ const mockMember: ChannelMember = {
+ user: mockUser,
+ membership: mockMembership,
+ status: 'online',
+ displayName: 'Test User',
+ };
+
+ const baseItemData: ItemData = {
+ members: [
+ {type: ListItemType.Member, data: mockMember},
+ ],
+ hasNextPage: false,
+ channel: mockChannel,
+ editing: false,
+ totalMemberCount: 1,
+ openDirectMessage: jest.fn(),
+ fetchRemoteClusterInfo: jest.fn(),
+ };
+
+ const baseStyle = {top: 0, left: 0, width: '100%', height: 48, position: 'absolute' as const};
+
+ test('should render a Member component for a member item', () => {
+ const separatorData: ItemData = {
+ ...baseItemData,
+ members: [
+ {type: ListItemType.Separator, data: {'Separator Label'}},
+ ],
+ };
+
+ renderWithContext(
+ ,
+ );
+
+ expect(screen.getByText('Separator Label')).toBeVisible();
+ });
+
+ test('should render multiple members at different indices', () => {
+ const secondUser = TestHelper.getUserMock({
+ id: 'user_id_2',
+ username: 'seconduser',
+ nickname: 'Second User',
+ });
+
+ const secondMember: ChannelMember = {
+ ...mockMember,
+ user: secondUser,
+ displayName: 'Second User',
+ };
+
+ const multiMemberData: ItemData = {
+ ...baseItemData,
+ members: [
+ {type: ListItemType.Member, data: mockMember},
+ {type: ListItemType.Member, data: secondMember},
+ ],
+ totalMemberCount: 2,
+ };
+
+ const {rerender} = renderWithContext(
+ ,
+ );
+
+ expect(screen.getByText('Test User')).toBeVisible();
+
+ rerender(
+ ,
+ );
+
+ expect(screen.getByText('Second User')).toBeVisible();
+ });
+});
diff --git a/webapp/channels/src/components/channel_members_rhs/member_list_item.tsx b/webapp/channels/src/components/channel_members_rhs/member_list_item.tsx
new file mode 100644
index 00000000000..d3a6b40b543
--- /dev/null
+++ b/webapp/channels/src/components/channel_members_rhs/member_list_item.tsx
@@ -0,0 +1,66 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {memo} from 'react';
+import type {ListChildComponentProps} from 'react-window';
+
+import type {Channel} from '@mattermost/types/channels';
+import type {UserProfile} from '@mattermost/types/users';
+
+import Member from './member';
+import type {ChannelMember, ListItem} from './member_list';
+import {ListItemType} from './member_list';
+
+export interface ItemData {
+ members: ListItem[];
+ hasNextPage: boolean;
+ channel: Channel;
+ editing: boolean;
+ totalMemberCount: number;
+ openDirectMessage: (user: UserProfile) => void;
+ fetchRemoteClusterInfo: (remoteId: string, includeDeleted?: boolean, forceRefresh?: boolean) => void;
+}
+
+const MemberListItem = memo(({index, style, data}: ListChildComponentProps) => {
+ const {members, hasNextPage, channel, editing, totalMemberCount, openDirectMessage, fetchRemoteClusterInfo} = data;
+ const isItemLoaded = !hasNextPage || index < members.length;
+
+ if (isItemLoaded) {
+ switch (members[index].type) {
+ case ListItemType.Member: {
+ const member = members[index].data as ChannelMember;
+ return (
+
+
+
+ );
+ }
+ case ListItemType.Separator:
+ case ListItemType.FirstSeparator:
+ return (
+
+ {members[index].data as JSX.Element}
+
+ );
+ default:
+ return null;
+ }
+ }
+
+ return null;
+});
+
+export default MemberListItem;
diff --git a/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.tsx b/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.tsx
index 199c282781b..d46fac1e0e4 100644
--- a/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.tsx
+++ b/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.tsx
@@ -33,7 +33,7 @@ const ProfilePopoverCustomAttributes = ({
if (!userProfile.custom_profile_attributes) {
dispatch(getCustomProfileAttributeValues(userID));
}
- });
+ }, [dispatch, userID, userProfile.custom_profile_attributes]);
const attributeSections = customProfileAttributeFields.map((attribute) => {
if (!hideStatus && userProfile.custom_profile_attributes) {