From b391c90eee7959c369ca7ccb536704dd4bef7652 Mon Sep 17 00:00:00 2001 From: zskhan Date: Thu, 3 Sep 2026 22:56:07 +0200 Subject: [PATCH 1/2] Import participants from an existing group/channel while creating meeting --- apps/webapp/src/i18n/en-US.json | 1 + .../UserSearchableList/UserSearchableList.tsx | 49 ++- ...eetingConversationsSearchableList.test.tsx | 120 ++++++ .../meetingConversationsSearchableList.tsx | 110 ++++++ .../meetingParticipantsPicker.styles.ts | 23 +- .../meetingParticipantsPicker.test.tsx | 345 +++++++++++++++++- .../meetingParticipantsPicker.tsx | 101 +++-- .../participantPickerUtils.ts | 20 + .../useMeetingParticipantsPicker.test.ts | 129 +++++++ .../useMeetingParticipantsPicker.ts | 165 +++++++++ .../startupFeatureToggleNames.ts | 2 + .../startupFeatureToggles.test.ts | 9 +- 12 files changed, 966 insertions(+), 108 deletions(-) create mode 100644 apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingConversationsSearchableList.test.tsx create mode 100644 apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingConversationsSearchableList.tsx create mode 100644 apps/webapp/src/script/components/meeting/meetingParticipantsPicker/useMeetingParticipantsPicker.test.ts create mode 100644 apps/webapp/src/script/components/meeting/meetingParticipantsPicker/useMeetingParticipantsPicker.ts diff --git a/apps/webapp/src/i18n/en-US.json b/apps/webapp/src/i18n/en-US.json index bf77fd5ab02..3b0d735fa97 100644 --- a/apps/webapp/src/i18n/en-US.json +++ b/apps/webapp/src/i18n/en-US.json @@ -1382,6 +1382,7 @@ "meetings.scheduleModal.error.titleTooLong": "Use a title with a maximum of 64 characters", "meetings.scheduleModal.error.updateFailed": "Something went wrong while updating the meeting. Please try again.", "meetings.scheduleModal.error.updateFailedTitle": "Could not update meeting", + "meetings.scheduleModal.groupsAndChannels": "Groups and channels", "meetings.scheduleModal.nextMonthAriaLabel": "Next month", "meetings.scheduleModal.openCalendarAriaLabel": "Open calendar", "meetings.scheduleModal.participantsLabel": "Participants", diff --git a/apps/webapp/src/script/components/UserSearchableList/UserSearchableList.tsx b/apps/webapp/src/script/components/UserSearchableList/UserSearchableList.tsx index aa53fd73b77..a6a57ee0422 100644 --- a/apps/webapp/src/script/components/UserSearchableList/UserSearchableList.tsx +++ b/apps/webapp/src/script/components/UserSearchableList/UserSearchableList.tsx @@ -56,6 +56,10 @@ export type UserListProps = Omit, 'convers filterRemoteTeamUsers?: boolean; /** When true, show every user from `users` after local search — skip conversation/connection visibility gate. */ showAllProvidedUsers?: boolean; + /** When true, suppress the "no matching results" empty state, e.g. when a sibling list already has matches. */ + hideEmptyState?: boolean; + /** When true, keep selected users visible regardless of the current search text. */ + showSelectedUsersRegardlessOfFilter?: boolean; }; const SEARCH_MEMBERS_DEBOUNCE_MILLISECONDS = 300; @@ -64,6 +68,8 @@ export const UserSearchableList = ({ onUpdateSelectedUsers, filterRemoteTeamUsers = false, showAllProvidedUsers = false, + hideEmptyState = false, + showSelectedUsersRegardlessOfFilter = false, dataUieName = '', filter = '', highlightedUsers, @@ -93,7 +99,12 @@ export const UserSearchableList = ({ setRemoteTeamMembers([]); }, [filter]); - const filteredSelectedUsers = selectedUsers ? searchRepository.searchUserInSet(filter, selectedUsers) : undefined; + let filteredSelectedUsers: User[] | undefined; + if (showSelectedUsersRegardlessOfFilter) { + filteredSelectedUsers = selectedUsers; + } else if (selectedUsers) { + filteredSelectedUsers = searchRepository.searchUserInSet(filter, selectedUsers); + } const selfInTeam = teamState.isInTeam(selfUser); @@ -199,27 +210,33 @@ export const UserSearchableList = ({ props.excludeUsers?.some(excludeId => matchQualifiedIds(user.qualifiedId, excludeId)) !== true && user.type === UserType.REGULAR, ); - const isEmptyUserList = userList.length === 0; + const isEmptyUserList = userList.length === 0 && (filteredSelectedUsers?.length ?? 0) === 0; const isSearching = isNonEmptyString(filter); const noResultsDataUieName = !isSearching ? 'status-all-added' : 'status-no-matches'; const noResultsTranslationText = !isSearching ? 'searchListEveryoneParticipates' : 'searchListNoMatches'; + let userListContent: React.ReactNode = null; + if (isEmptyUserList && !hideEmptyState) { + userListContent = ( +

+ {translate(noResultsTranslationText)} +

+ ); + } else if (!isEmptyUserList) { + userListContent = ( + + ); + } return (
- {isEmptyUserList ? ( -

- {translate(noResultsTranslationText)} -

- ) : ( - - )} + {userListContent}
); }; diff --git a/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingConversationsSearchableList.test.tsx b/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingConversationsSearchableList.test.tsx new file mode 100644 index 00000000000..987986fd31b --- /dev/null +++ b/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingConversationsSearchableList.test.tsx @@ -0,0 +1,120 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {fireEvent, render, screen} from '@testing-library/react'; + +import type {Conversation} from 'Repositories/entity/Conversation'; +import { + createRootContextValueForTest, + createRootProviderWrapperForTest, +} from 'src/script/page/testSupport/rootContextTestSupport'; +import {withThemeAndRootContext} from 'src/script/auth/util/test/testUtil'; + +import {MeetingConversationsSearchableList} from './meetingConversationsSearchableList'; +import {translateForTest} from 'Util/test/translateForTest'; + +const rootProviderWrapper = createRootProviderWrapperForTest( + createRootContextValueForTest({ + translate: translateForTest, + }), +); + +const createConversation = (id: string, name: string, channel = false) => + ({ + display_name: () => name, + id, + isChannel: () => channel, + participating_user_ets: () => [], + qualifiedId: {domain: 'example.com', id}, + }) as unknown as Conversation; + +describe('MeetingConversationsSearchableList', () => { + it('renders groups and channels with their selection state', () => { + const conversations = [ + createConversation('group', 'Project group'), + createConversation('channel', 'Project channel', true), + ]; + const onSelectConversation = jest.fn(); + + render( + withThemeAndRootContext( + , + rootProviderWrapper, + ), + ); + + expect(screen.getByText('Project group')).toBeInTheDocument(); + expect(screen.getByText('Project channel')).toBeInTheDocument(); + expect(screen.getByRole('checkbox', {name: 'Project group'})).toBeChecked(); + expect(screen.getByRole('checkbox', {name: 'Project channel'})).not.toBeChecked(); + + fireEvent.click(screen.getByRole('checkbox', {name: 'Project channel'})); + expect(onSelectConversation).toHaveBeenCalledWith(conversations[1]); + }); + + it('collapses and expands the list without changing the selection handler', () => { + const onOpenChange = jest.fn(); + + render( + withThemeAndRootContext( + , + rootProviderWrapper, + ), + ); + + fireEvent.click(screen.getByRole('button')); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('renders nothing when there are no matching conversations', () => { + render( + withThemeAndRootContext( + , + rootProviderWrapper, + ), + ); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByText('Project group')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingConversationsSearchableList.tsx b/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingConversationsSearchableList.tsx new file mode 100644 index 00000000000..c34deb78e5f --- /dev/null +++ b/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingConversationsSearchableList.tsx @@ -0,0 +1,110 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {Checkbox, CheckboxLabel, ChevronDownIcon} from '@wireapp/react-ui-kit'; + +import {ChannelAvatar} from 'Components/avatar/channelAvatar'; +import {GroupAvatar} from 'Components/avatar/groupAvatar'; +import {listItem, listWrapper} from 'Components/participantItemContent/participantItem.styles'; +import {collapseButton, collapseIcon} from 'Components/userList/userList.styles'; +import type {Conversation} from 'Repositories/entity/Conversation'; +import {useApplicationContext} from 'src/script/page/rootProvider'; + +import {conversationIconStyles, conversationListStyles} from './meetingParticipantsPicker.styles'; +import {getConversationKey} from './participantPickerUtils'; + +type MeetingConversationsSearchableListProps = { + id: string; + conversations: Conversation[]; + selectedConversationIds: ReadonlySet; + onSelectConversation: (conversation: Conversation) => void; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + noUnderline: boolean; + dataUieName?: string; +}; + +export const MeetingConversationsSearchableList = ({ + id, + conversations, + selectedConversationIds, + onSelectConversation, + isOpen, + onOpenChange, + noUnderline, + dataUieName, +}: MeetingConversationsSearchableListProps) => { + const {translate} = useApplicationContext(); + + if (conversations.length === 0) { + return null; + } + + return ( + <> + +
+ {isOpen && + conversations.map(conversation => { + const conversationKey = getConversationKey(conversation); + const checkboxId = `${id}-${conversationKey}`; + + return ( +
+ onSelectConversation(conversation)} + labelBeforeCheckbox + aligncenter={false} + outlineOffset="0" + > + +
+ {conversation.isChannel() ? ( + + ) : ( + + )} + {conversation.display_name()} +
+
+
+
+ ); + })} +
+ + ); +}; diff --git a/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingParticipantsPicker.styles.ts b/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingParticipantsPicker.styles.ts index 6b6824c4f55..13c6214771f 100644 --- a/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingParticipantsPicker.styles.ts +++ b/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingParticipantsPicker.styles.ts @@ -25,6 +25,15 @@ export const wrapperStyles: CSSObject = { width: '100%', }; +export const conversationListStyles: CSSObject = { + overflowY: 'auto', +}; + +export const conversationIconStyles: CSSObject = { + flexShrink: 0, + margin: '0 16px', +}; + export const controlStyles = ({ isDisabled, isOpen, @@ -179,17 +188,3 @@ export const listContainerStyles: CSSObject = { }, }, }; - -export const emptyStateStyles: CSSObject = { - color: 'var(--text-input-placeholder)', - fontSize: 'var(--font-size-medium)', - lineHeight: '20px', - padding: '12px 16px', -}; - -export const loadingStateStyles: CSSObject = { - color: 'var(--text-input-placeholder)', - fontSize: 'var(--font-size-medium)', - lineHeight: '20px', - padding: '12px 16px', -}; diff --git a/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingParticipantsPicker.test.tsx b/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingParticipantsPicker.test.tsx index edbfda275c6..9f0d24112f8 100644 --- a/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingParticipantsPicker.test.tsx +++ b/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingParticipantsPicker.test.tsx @@ -22,8 +22,9 @@ import {useState} from 'react'; import {fireEvent, render, screen, waitFor} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import en from 'I18n/en-US.json'; import {User} from 'Repositories/entity/User'; +import type {Conversation} from 'Repositories/entity/Conversation'; +import type {ConversationRepository} from 'Repositories/conversation/ConversationRepository'; import type {SearchRepository} from 'Repositories/search/searchRepository'; import type {TeamRepository} from 'Repositories/team/TeamRepository'; import type {TeamState} from 'Repositories/team/TeamState'; @@ -34,17 +35,20 @@ import { createRootProviderWrapperForTest, } from 'src/script/page/testSupport/rootContextTestSupport'; import {withThemeAndRootContext} from 'src/script/auth/util/test/testUtil'; -import {setStrings, translate} from 'Util/localizerUtil'; import {translateForTest} from 'Util/test/translateForTest'; +import {meetingsM2FeatureToggleName} from 'src/script/featureToggles/startupFeatureToggleNames'; import {formatParticipantsFieldLabel} from './formatParticipantsFieldLabel'; import {MeetingParticipantsPicker} from './meetingParticipantsPicker'; -import {searchUsersByQuery} from './participantPickerUtils'; +import {mergeUsersIntoSelection, searchUsersByQuery} from './participantPickerUtils'; -setStrings({en}); - -const SEARCH_PLACEHOLDER = 'Search by name'; +const SEARCH_PLACEHOLDER = 'meetings.scheduleModal.participantsPlaceholder'; const PARTICIPANTS_LABEL = 'Participants'; +const PARTICIPANTS_LABEL_WITH_COUNT = 'meetings.scheduleModal.participantsLabelWithCount'; +const GROUPS_AND_CHANNELS_LABEL = 'meetings.scheduleModal.groupsAndChannels'; +const CONTACTS_LABEL = 'userListContacts'; +const SELECTED_CONTACTS_LABEL = 'userListSelectedContacts'; +const NO_MATCHES_LABEL = 'searchListNoMatches'; const createUser = (id: string, name: string, handle: string) => { const user = new User(id, 'example.com', translateForTest); @@ -53,6 +57,23 @@ const createUser = (id: string, name: string, handle: string) => { return user; }; +const createConversation = ( + id: string, + name: string, + members: User[], + channel = false, + {removed = false, archived = false, cleared = false}: {removed?: boolean; archived?: boolean; cleared?: boolean} = {}, +) => + ({ + display_name: () => name, + isSelfUserRemoved: () => removed, + is_archived: () => archived, + is_cleared: () => cleared, + isChannel: () => channel, + participating_user_ets: () => members, + qualifiedId: {domain: 'example.com', id}, + }) as unknown as Conversation; + const users = [ createUser('1', 'Thomas Goodwin', 'thomas'), createUser('2', 'Alice Anderson', 'alice'), @@ -96,10 +117,13 @@ const conversationStateDouble = { } satisfies Pick; const fireAndForgetInvoker = createExecutingFireAndForgetInvokerForTest(); +let meetingsM2EnabledForTest = true; + const rootProviderWrapper = createRootProviderWrapperForTest( createRootContextValueForTest({ fireAndForgetInvoker, - translate, + isFeatureToggleEnabled: featureName => featureName === meetingsM2FeatureToggleName && meetingsM2EnabledForTest, + translate: translateForTest, }), ); @@ -109,12 +133,14 @@ const ControlledPicker = ({ searchByName = searchRepositoryDouble.searchByName, availableUsers = users, label = PARTICIPANTS_LABEL, + conversationRepository, }: { initialSelected?: User[]; initialFilter?: string; searchByName?: (query: string, teamId?: string) => Promise; availableUsers?: User[]; label?: string; + conversationRepository?: Pick; }) => { const [selectedUsers, setSelectedUsers] = useState(initialSelected); const [filter, setFilter] = useState(initialFilter); @@ -133,6 +159,7 @@ const ControlledPicker = ({ selfUser={selfUser} searchRepository={{...searchRepositoryDouble, searchByName}} teamRepository={teamRepositoryDouble} + conversationRepository={conversationRepository as ConversationRepository | undefined} conversationState={conversationStateDouble} teamState={teamStateDouble} /> @@ -141,7 +168,15 @@ const ControlledPicker = ({ const getSearchInput = (accessibleName = PARTICIPANTS_LABEL) => screen.getByRole('combobox', {name: accessibleName}); +const setMeetingsM2Enabled = (enabled: boolean) => { + meetingsM2EnabledForTest = enabled; +}; + describe('MeetingParticipantsPicker', () => { + beforeEach(() => setMeetingsM2Enabled(true)); + + afterEach(() => setMeetingsM2Enabled(false)); + it('renders label and search input', () => { render(withThemeAndRootContext(, rootProviderWrapper)); @@ -155,8 +190,8 @@ describe('MeetingParticipantsPicker', () => { render(withThemeAndRootContext(, rootProviderWrapper)); - expect(screen.getByText('Participants (2 selected)')).toBeInTheDocument(); - expect(screen.getByRole('combobox', {name: 'Participants (2 selected)'})).toBeInTheDocument(); + expect(screen.getByText(PARTICIPANTS_LABEL_WITH_COUNT)).toBeInTheDocument(); + expect(screen.getByRole('combobox', {name: PARTICIPANTS_LABEL_WITH_COUNT})).toBeInTheDocument(); expect(screen.queryByRole('combobox', {name: SEARCH_PLACEHOLDER})).not.toBeInTheDocument(); expect(screen.queryByTestId('meeting-participants-picker-summary')).not.toBeInTheDocument(); }); @@ -173,7 +208,7 @@ describe('MeetingParticipantsPicker', () => { ), ); - const input = getSearchInput('Participants (3 selected)'); + const input = getSearchInput(PARTICIPANTS_LABEL_WITH_COUNT); expect(input).toHaveValue(''); expect(input).toHaveAttribute('placeholder', SEARCH_PLACEHOLDER); @@ -191,8 +226,8 @@ describe('MeetingParticipantsPicker', () => { await user.click(screen.getByText('Carol Chen')); - expect(screen.getByRole('combobox', {name: 'Participants (4 selected)'})).toBeInTheDocument(); - expect(screen.getByRole('combobox', {name: 'Participants (4 selected)'})).toHaveValue(''); + expect(screen.getByRole('combobox', {name: PARTICIPANTS_LABEL_WITH_COUNT})).toBeInTheDocument(); + expect(screen.getByRole('combobox', {name: PARTICIPANTS_LABEL_WITH_COUNT})).toHaveValue(''); }); it('opens the menu and filters users locally', async () => { @@ -219,6 +254,273 @@ describe('MeetingParticipantsPicker', () => { }); }); + it('shows local groups and channels below contacts and filters them with the same input', async () => { + const group = createConversation('group', 'Engineering', [users[0]]); + const channel = createConversation('channel', 'Announcements', [users[1]], true); + const getAllGroupConversations = jest.fn(() => [group, channel]); + const user = userEvent.setup(); + + render( + withThemeAndRootContext( + , + rootProviderWrapper, + ), + ); + + await user.click(getSearchInput()); + expect(screen.getByText(CONTACTS_LABEL)).toBeInTheDocument(); + expect(screen.getByText(GROUPS_AND_CHANNELS_LABEL)).toBeInTheDocument(); + expect(screen.getByText('Engineering')).toBeInTheDocument(); + expect(screen.getByText('Announcements')).toBeInTheDocument(); + expect(screen.getByText('Announcements').parentElement?.querySelector('svg')).toBeInTheDocument(); + expect(screen.getByText('Engineering').parentElement?.querySelector('svg')).toBeInTheDocument(); + expect(getAllGroupConversations).toHaveBeenCalled(); + + await user.type(getSearchInput(), 'announce'); + expect(screen.getByText('Announcements')).toBeInTheDocument(); + expect(screen.queryByText('Engineering')).not.toBeInTheDocument(); + expect(screen.queryByText('Thomas Goodwin')).not.toBeInTheDocument(); + }); + + it('does not show removed, archived, or cleared conversations as participant sources', async () => { + const active = createConversation('active', 'Active group', [users[0]]); + const removed = createConversation('removed', 'Removed group', [users[0]], false, {removed: true}); + const archived = createConversation('archived', 'Archived group', [users[0]], false, {archived: true}); + const cleared = createConversation('cleared', 'Cleared group', [users[0]], false, {cleared: true}); + const user = userEvent.setup(); + + render( + withThemeAndRootContext( + [active, removed, archived, cleared]}} + />, + rootProviderWrapper, + ), + ); + + await user.click(getSearchInput()); + + expect(screen.getByText('Active group')).toBeInTheDocument(); + expect(screen.queryByText('Removed group')).not.toBeInTheDocument(); + expect(screen.queryByText('Archived group')).not.toBeInTheDocument(); + expect(screen.queryByText('Cleared group')).not.toBeInTheDocument(); + }); + + it('does not query or render groups and channels when meetings M2 is disabled', async () => { + setMeetingsM2Enabled(false); + const getAllGroupConversations = jest.fn(() => [createConversation('group', 'Engineering', [users[0]])]); + const user = userEvent.setup(); + + render( + withThemeAndRootContext( + , + rootProviderWrapper, + ), + ); + + await user.click(getSearchInput()); + + expect(screen.queryByText(GROUPS_AND_CHANNELS_LABEL)).not.toBeInTheDocument(); + expect(screen.queryByText('Engineering')).not.toBeInTheDocument(); + expect(getAllGroupConversations).not.toHaveBeenCalled(); + }); + + it('keeps contact searching available when meetings M2 is disabled', async () => { + setMeetingsM2Enabled(false); + const user = userEvent.setup(); + + render(withThemeAndRootContext(, rootProviderWrapper)); + + await user.click(getSearchInput()); + await user.type(getSearchInput(), 'alice'); + + expect(screen.getByText('Alice Anderson')).toBeInTheDocument(); + expect(screen.queryByText(GROUPS_AND_CHANNELS_LABEL)).not.toBeInTheDocument(); + }); + + it('allows groups and channels to be collapsed until the search input is focused again', async () => { + const conversation = createConversation('group', 'Engineering', [users[0]]); + const user = userEvent.setup(); + + render( + withThemeAndRootContext( + [conversation]}} />, + rootProviderWrapper, + ), + ); + + const input = getSearchInput(); + await user.click(input); + + expect(screen.getByText('Engineering')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', {name: GROUPS_AND_CHANNELS_LABEL})); + expect(screen.queryByText('Engineering')).not.toBeInTheDocument(); + + await user.click(input); + expect(screen.getByText('Engineering')).toBeInTheDocument(); + }); + + it('opens groups and channels when the picker is opened with the chevron or by typing', async () => { + const conversation = createConversation('group', 'Engineering', [users[0]]); + const user = userEvent.setup(); + + const {unmount} = render( + withThemeAndRootContext( + [conversation]}} />, + rootProviderWrapper, + ), + ); + + await user.click(screen.getByRole('button', {name: PARTICIPANTS_LABEL})); + expect(screen.getByText('Engineering')).toBeInTheDocument(); + + unmount(); + render( + withThemeAndRootContext( + [conversation]}} />, + rootProviderWrapper, + ), + ); + + fireEvent.change(getSearchInput(), {target: {value: 'eng'}}); + expect(screen.getByText('Engineering')).toBeInTheDocument(); + }); + + it('imports all conversation members additively and keeps the dropdown open', async () => { + const selected = users[0]; + const guest = createUser('guest', 'Guest User', 'guest'); + const conversation = createConversation('group', 'Project', [selected, guest]); + const user = userEvent.setup(); + const getAllGroupConversations = jest.fn(() => [conversation]); + + render( + withThemeAndRootContext( + , + rootProviderWrapper, + ), + ); + + await user.click(getSearchInput(PARTICIPANTS_LABEL_WITH_COUNT)); + await user.click(screen.getByText('Project')); + + expect(screen.getByText(PARTICIPANTS_LABEL_WITH_COUNT)).toBeInTheDocument(); + expect(getSearchInput(PARTICIPANTS_LABEL_WITH_COUNT)).toHaveValue(''); + }); + + it('removes only users unique to an unselected conversation', async () => { + const shared = createUser('shared', 'Shared User', 'shared'); + const onlyInFirst = createUser('first', 'First User', 'first'); + const first = createConversation('first', 'First group', [shared, onlyInFirst]); + const second = createConversation('second', 'Second group', [shared]); + const user = userEvent.setup(); + const getAllGroupConversations = jest.fn(() => [first, second]); + + render( + withThemeAndRootContext( + , + rootProviderWrapper, + ), + ); + + await user.click(getSearchInput()); + await user.click(screen.getByText('First group')); + await user.click(getSearchInput(PARTICIPANTS_LABEL_WITH_COUNT)); + await user.click(screen.getByText('Second group')); + await user.click(getSearchInput(PARTICIPANTS_LABEL_WITH_COUNT)); + await user.click(screen.getByRole('checkbox', {name: /First group/})); + + expect(screen.getByText(PARTICIPANTS_LABEL_WITH_COUNT)).toBeInTheDocument(); + }); + + it('preserves manually selected users when a conversation is deselected', async () => { + const manual = users[0]; + const imported = users[1]; + const conversation = createConversation('group', 'Engineering', [imported]); + const user = userEvent.setup(); + + render( + withThemeAndRootContext( + [conversation]}} + />, + rootProviderWrapper, + ), + ); + + await user.click(getSearchInput(PARTICIPANTS_LABEL_WITH_COUNT)); + await user.click(screen.getByText('Engineering')); + await user.click(screen.getByText('Engineering')); + + expect(screen.getByText(PARTICIPANTS_LABEL_WITH_COUNT)).toBeInTheDocument(); + await user.click(screen.getByText(SELECTED_CONTACTS_LABEL)); + expect(screen.getByText('Thomas Goodwin')).toBeInTheDocument(); + }); + + it('hides the groups and channels section when there are no matches', async () => { + const conversation = createConversation('group', 'Engineering', [users[0]]); + const user = userEvent.setup(); + + render( + withThemeAndRootContext( + [conversation]}} />, + rootProviderWrapper, + ), + ); + + await user.click(getSearchInput()); + await user.type(getSearchInput(), 'missing'); + + expect(screen.queryByText(GROUPS_AND_CHANNELS_LABEL)).not.toBeInTheDocument(); + expect(getSearchInput()).toBeEnabled(); + expect(screen.getByText(NO_MATCHES_LABEL)).toBeInTheDocument(); + }); + + it('hides the no matching results message when a group or channel still matches', async () => { + const conversation = createConversation('group', 'Test WPB-21813', [users[0]]); + const user = userEvent.setup(); + + render( + withThemeAndRootContext( + [conversation]}} />, + rootProviderWrapper, + ), + ); + + await user.click(getSearchInput()); + await user.type(getSearchInput(), 'test'); + + expect(screen.getByText('Test WPB-21813')).toBeInTheDocument(); + expect(screen.queryByText(NO_MATCHES_LABEL)).not.toBeInTheDocument(); + }); + + it('keeps already-selected participants visible when the search text no longer matches them', async () => { + const guest = createUser('guest', 'Guest User', 'guest'); + const conversation = createConversation('group', 'Test channel', [guest]); + const user = userEvent.setup(); + + render( + withThemeAndRootContext( + [conversation]}} />, + rootProviderWrapper, + ), + ); + + await user.click(getSearchInput()); + await user.click(screen.getByText('Test channel')); + + await user.click(getSearchInput(PARTICIPANTS_LABEL_WITH_COUNT)); + await user.type(getSearchInput(PARTICIPANTS_LABEL_WITH_COUNT), 'test'); + + expect(screen.getByText(SELECTED_CONTACTS_LABEL)).toBeInTheDocument(); + expect(screen.queryByText(NO_MATCHES_LABEL)).not.toBeInTheDocument(); + + await user.click(screen.getByText(SELECTED_CONTACTS_LABEL)); + + expect(screen.getByText('Guest User')).toBeInTheDocument(); + }); + it('shows all provided users when there are more than the truncated default', async () => { const manyUsers = Array.from({length: 8}, (_, index) => createUser(`user-${index}`, `User ${index}`, `user${index}`), @@ -264,7 +566,7 @@ describe('MeetingParticipantsPicker', () => { await user.click(screen.getByText('Alice Anderson')); - expect(screen.getByText('Participants (1 selected)')).toBeInTheDocument(); + expect(screen.getByText(PARTICIPANTS_LABEL_WITH_COUNT)).toBeInTheDocument(); expect(input).toHaveValue(''); expect(input).toHaveAttribute('placeholder', SEARCH_PLACEHOLDER); expect(screen.queryByTestId('meeting-participants-picker-summary')).not.toBeInTheDocument(); @@ -288,7 +590,7 @@ describe('MeetingParticipantsPicker', () => { expect(input).toHaveValue(''); expect(input).toHaveAttribute('placeholder', SEARCH_PLACEHOLDER); - expect(screen.getByText('Participants (1 selected)')).toBeInTheDocument(); + expect(screen.getByText(PARTICIPANTS_LABEL_WITH_COUNT)).toBeInTheDocument(); }); it('includes remote team search results when filtering', async () => { @@ -366,11 +668,11 @@ describe('MeetingParticipantsPicker', () => { describe('formatParticipantsFieldLabel', () => { it('returns the base label when no participants are selected', () => { - expect(formatParticipantsFieldLabel('Participants', 0, translate)).toBe('Participants'); + expect(formatParticipantsFieldLabel('Participants', 0, translateForTest)).toBe('Participants'); }); it('appends the selected count to the label', () => { - expect(formatParticipantsFieldLabel('Participants', 2, translate)).toBe('Participants (2 selected)'); + expect(formatParticipantsFieldLabel('Participants', 2, translateForTest)).toBe(PARTICIPANTS_LABEL_WITH_COUNT); }); }); @@ -380,4 +682,15 @@ describe('participantPickerUtils', () => { expect(searchUsersByQuery(users, 'bob')).toEqual([users[2]]); expect(searchUsersByQuery(users, '')).toEqual(users); }); + + it('merges imported users with the existing selection without duplicating qualified ids', () => { + const selectedUser = createUser('1', 'Thomas Goodwin', 'thomas'); + const importedGuest = createUser('guest', 'Guest User', 'guest'); + const duplicateWithSameId = createUser('1', 'Updated Thomas', 'thomas-updated'); + + expect(mergeUsersIntoSelection([selectedUser], [duplicateWithSameId, importedGuest])).toEqual([ + selectedUser, + importedGuest, + ]); + }); }); diff --git a/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingParticipantsPicker.tsx b/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingParticipantsPicker.tsx index ed889391828..e5dfabc0bc0 100644 --- a/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingParticipantsPicker.tsx +++ b/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingParticipantsPicker.tsx @@ -17,9 +17,9 @@ * */ -import {useCallback, useEffect, useId, useRef, useState} from 'react'; +import {useId} from 'react'; -import {isNonEmptyString, isNullOrUndefined} from '@sindresorhus/is'; +import {isNonEmptyString} from '@sindresorhus/is'; import {Button, Popover} from 'react-aria-components'; import {ChevronDownIcon, getOverlayPortalContainer, InputLabel, SearchIcon} from '@wireapp/react-ui-kit'; @@ -31,9 +31,11 @@ import type {User} from 'Repositories/entity/User'; import type {SearchRepository} from 'Repositories/search/searchRepository'; import type {TeamRepository} from 'Repositories/team/TeamRepository'; import type {TeamState} from 'Repositories/team/TeamState'; +import {meetingsM2FeatureToggleName} from 'src/script/featureToggles/startupFeatureToggleNames'; import {useApplicationContext} from 'src/script/page/rootProvider'; import {formatParticipantsFieldLabel} from './formatParticipantsFieldLabel'; +import {MeetingConversationsSearchableList} from './meetingConversationsSearchableList'; import { chevronButtonStyles, chevronIconStyles, @@ -47,6 +49,7 @@ import { valueContainerStyles, wrapperStyles, } from './meetingParticipantsPicker.styles'; +import {useMeetingParticipantsPicker} from './useMeetingParticipantsPicker'; export interface MeetingParticipantsPickerProps { id: string; @@ -93,10 +96,7 @@ export const MeetingParticipantsPicker = ({ noUnderline = false, popoverPortalContainer, }: MeetingParticipantsPickerProps) => { - const {translate} = useApplicationContext(); - const [isOpen, setIsOpen] = useState(false); - const triggerRef = useRef(null); - const popoverRef = useRef(null); + const {isFeatureToggleEnabled, translate} = useApplicationContext(); const listboxId = useId(); const portalContainer = popoverPortalContainer ?? getOverlayPortalContainer(); @@ -105,56 +105,27 @@ export const MeetingParticipantsPicker = ({ : undefined; const searchPlaceholder = placeholder ?? translate('meetings.scheduleModal.participantsPlaceholder'); const showPlaceholder = filter.length === 0; - - const handleOpenChange = useCallback( - (open: boolean) => { - if (disabled && open) { - return; - } - - if (!open) { - onFilterChange(''); - } - - setIsOpen(open); - }, - [disabled, onFilterChange], - ); - - const handleSelectedUsersChange = useCallback( - (users: User[]) => { - onSelectedUsersChange(users); - onFilterChange(''); - }, - [onFilterChange, onSelectedUsersChange], - ); - - useEffect(() => { - if (!isOpen) { - return; - } - - const handlePointerDown = (event: PointerEvent) => { - const target = event.target as Node; - const trigger = triggerRef.current; - const popover = popoverRef.current; - - if ( - (!isNullOrUndefined(trigger) && trigger.contains(target)) || - (!isNullOrUndefined(popover) && popover.contains(target)) - ) { - return; - } - - handleOpenChange(false); - }; - - document.addEventListener('pointerdown', handlePointerDown, true); - - return () => { - document.removeEventListener('pointerdown', handlePointerDown, true); - }; - }, [handleOpenChange, isOpen]); + const meetingsM2Enabled = isFeatureToggleEnabled(meetingsM2FeatureToggleName); + const { + handleOpenChange, + handleSelectedUsersChange, + handleSelectConversation, + isConversationsOpen, + isOpen, + matchingConversations, + popoverRef, + selectedConversationIds, + setIsConversationsOpen, + triggerRef, + } = useMeetingParticipantsPicker({ + disabled, + filter, + selectedUsers, + onSelectedUsersChange, + onFilterChange, + conversationRepository, + meetingsM2Enabled, + }); return (
@@ -191,12 +162,12 @@ export const MeetingParticipantsPicker = ({ onChange={event => { onFilterChange(event.target.value); if (!isOpen) { - setIsOpen(true); + handleOpenChange(true); } }} onFocus={() => { if (!disabled) { - setIsOpen(true); + handleOpenChange(true); } }} onKeyDown={event => { @@ -246,15 +217,27 @@ export const MeetingParticipantsPicker = ({ onUpdateSelectedUsers={handleSelectedUsersChange} searchRepository={searchRepository} teamRepository={teamRepository} - conversationRepository={conversationRepository} + conversationRepository={meetingsM2Enabled ? conversationRepository : undefined} conversationState={conversationState} teamState={teamState} noUnderline={noUnderline} allowRemoteSearch filterRemoteTeamUsers showAllProvidedUsers + hideEmptyState={meetingsM2Enabled && matchingConversations.length > 0} + showSelectedUsersRegardlessOfFilter dataUieName={dataUieName ? `${dataUieName}-list` : undefined} /> +
diff --git a/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/participantPickerUtils.ts b/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/participantPickerUtils.ts index 58da3b5ad4b..7d2f5635a38 100644 --- a/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/participantPickerUtils.ts +++ b/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/participantPickerUtils.ts @@ -17,7 +17,12 @@ * */ +import type {Conversation} from 'Repositories/entity/Conversation'; import type {User} from 'Repositories/entity/User'; +import {matchQualifiedIds} from 'Util/qualifiedId'; + +export const getConversationKey = (conversation: Conversation): string => + `${conversation.qualifiedId.domain}-${conversation.qualifiedId.id}`; export const searchUsersByQuery = (users: User[], query: string): User[] => { const normalizedQuery = query.trim().toLowerCase(); @@ -43,3 +48,18 @@ export const toggleUserInSelection = (selectedUsers: User[], user: User): User[] return [...selectedUsers, user]; }; + +export const mergeUsersIntoSelection = (selectedUsers: User[], importedUsers: User[]): User[] => { + const mergedUsers = [...selectedUsers]; + + for (const user of importedUsers) { + if (!mergedUsers.some(selectedUser => matchQualifiedIds(selectedUser.qualifiedId, user.qualifiedId))) { + mergedUsers.push(user); + } + } + + return mergedUsers; +}; + +export const mergeConversationUsersIntoSelection = (selectedUsers: User[], conversation: Conversation): User[] => + mergeUsersIntoSelection(selectedUsers, conversation.participating_user_ets()); diff --git a/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/useMeetingParticipantsPicker.test.ts b/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/useMeetingParticipantsPicker.test.ts new file mode 100644 index 00000000000..0623131f622 --- /dev/null +++ b/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/useMeetingParticipantsPicker.test.ts @@ -0,0 +1,129 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {act, fireEvent, renderHook} from '@testing-library/react'; + +import {User} from 'Repositories/entity/User'; +import type {Conversation} from 'Repositories/entity/Conversation'; + +import {useMeetingParticipantsPicker} from './useMeetingParticipantsPicker'; + +const createUser = (id: string, name: string): User => { + const user = new User(id, 'example.com', key => key); + user.name(name); + return user; +}; + +const createConversation = ( + id: string, + name: string, + members: User[], + {removed = false, archived = false, cleared = false}: {removed?: boolean; archived?: boolean; cleared?: boolean} = {}, +) => + ({ + display_name: () => name, + isSelfUserRemoved: () => removed, + is_archived: () => archived, + is_cleared: () => cleared, + participating_user_ets: () => members, + qualifiedId: {domain: 'example.com', id}, + }) as unknown as Conversation; + +const createOptions = (overrides: Partial[0]> = {}) => ({ + disabled: false, + filter: '', + selectedUsers: [], + onSelectedUsersChange: jest.fn(), + onFilterChange: jest.fn(), + meetingsM2Enabled: true, + ...overrides, +}); + +describe('useMeetingParticipantsPicker', () => { + it('returns active conversations matching the filter', () => { + const members = [createUser('member', 'Member')]; + const active = createConversation('active', 'Engineering', members); + const other = createConversation('other', 'Announcements', members); + const removed = createConversation('removed', 'Engineering old', members, {removed: true}); + const getAllGroupConversations = jest.fn(() => [active, other, removed]); + + const {result} = renderHook(() => + useMeetingParticipantsPicker( + createOptions({ + filter: 'engine', + conversationRepository: {getAllGroupConversations}, + }), + ), + ); + + expect(result.current.matchingConversations).toEqual([active]); + expect(getAllGroupConversations).toHaveBeenCalledTimes(1); + }); + + it('opens and closes the picker while clearing the filter on close', () => { + const onFilterChange = jest.fn(); + const {result} = renderHook(() => useMeetingParticipantsPicker(createOptions({onFilterChange}))); + + act(() => result.current.handleOpenChange(true)); + expect(result.current.isOpen).toBe(true); + + act(() => result.current.handleOpenChange(false)); + expect(result.current.isOpen).toBe(false); + expect(onFilterChange).toHaveBeenCalledWith(''); + }); + + it('does not open when disabled', () => { + const {result} = renderHook(() => useMeetingParticipantsPicker(createOptions({disabled: true}))); + + act(() => result.current.handleOpenChange(true)); + + expect(result.current.isOpen).toBe(false); + }); + + it('imports conversation members and removes only imported users when deselected', () => { + const manual = createUser('manual', 'Manual'); + const imported = createUser('imported', 'Imported'); + const conversation = createConversation('conversation', 'Project', [imported]); + const onSelectedUsersChange = jest.fn(); + const options = createOptions({selectedUsers: [manual], onSelectedUsersChange}); + const {result, rerender} = renderHook(currentOptions => useMeetingParticipantsPicker(currentOptions), { + initialProps: options, + }); + + act(() => result.current.handleSelectConversation(conversation)); + expect(onSelectedUsersChange).toHaveBeenLastCalledWith([manual, imported]); + + rerender({...options, selectedUsers: [manual, imported]}); + act(() => result.current.handleSelectConversation(conversation)); + + expect(onSelectedUsersChange).toHaveBeenLastCalledWith([manual]); + expect(result.current.selectedConversationIds).toEqual(new Set()); + }); + + it('closes and clears the filter when clicking outside', () => { + const onFilterChange = jest.fn(); + const {result} = renderHook(() => useMeetingParticipantsPicker(createOptions({onFilterChange}))); + + act(() => result.current.handleOpenChange(true)); + act(() => fireEvent.pointerDown(document.body)); + + expect(result.current.isOpen).toBe(false); + expect(onFilterChange).toHaveBeenCalledWith(''); + }); +}); diff --git a/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/useMeetingParticipantsPicker.ts b/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/useMeetingParticipantsPicker.ts new file mode 100644 index 00000000000..11b15cef498 --- /dev/null +++ b/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/useMeetingParticipantsPicker.ts @@ -0,0 +1,165 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; + +import {isNullOrUndefined} from '@sindresorhus/is'; + +import type {ConversationRepository} from 'Repositories/conversation/ConversationRepository'; +import type {Conversation} from 'Repositories/entity/Conversation'; +import type {User} from 'Repositories/entity/User'; +import {matchQualifiedIds} from 'Util/qualifiedId'; + +import { + getConversationKey, + mergeConversationUsersIntoSelection, + mergeUsersIntoSelection, +} from './participantPickerUtils'; + +interface UseMeetingParticipantsPickerOptions { + disabled: boolean; + filter: string; + selectedUsers: User[]; + onSelectedUsersChange: (users: User[]) => void; + onFilterChange: (filter: string) => void; + conversationRepository?: Pick; + meetingsM2Enabled: boolean; +} + +export const useMeetingParticipantsPicker = ({ + disabled, + filter, + selectedUsers, + onSelectedUsersChange, + onFilterChange, + conversationRepository, + meetingsM2Enabled, +}: UseMeetingParticipantsPickerOptions) => { + const [isOpen, setIsOpen] = useState(false); + const [isConversationsOpen, setIsConversationsOpen] = useState(true); + const [selectedConversations, setSelectedConversations] = useState>(new Map()); + const triggerRef = useRef(null); + const popoverRef = useRef(null); + + const matchingConversations = useMemo(() => { + if (!meetingsM2Enabled || !conversationRepository) { + return []; + } + + const normalizedFilter = filter.trim().toLowerCase(); + return conversationRepository + .getAllGroupConversations() + .filter( + conversation => + !conversation.isSelfUserRemoved() && + !conversation.is_archived() && + !conversation.is_cleared() && + conversation.display_name().toLowerCase().includes(normalizedFilter), + ); + }, [conversationRepository, filter, meetingsM2Enabled]); + + const handleOpenChange = useCallback( + (open: boolean) => { + if (disabled && open) { + return; + } + + if (!open) { + onFilterChange(''); + } else { + setIsConversationsOpen(true); + } + + setIsOpen(open); + }, + [disabled, onFilterChange], + ); + + const handleSelectedUsersChange = useCallback( + (users: User[]) => { + onSelectedUsersChange(users); + onFilterChange(''); + }, + [onFilterChange, onSelectedUsersChange], + ); + + const handleSelectConversation = useCallback( + (conversation: Conversation) => { + const conversationKey = getConversationKey(conversation); + const nextSelectedConversations = new Map(selectedConversations); + + if (nextSelectedConversations.has(conversationKey)) { + nextSelectedConversations.delete(conversationKey); + const importedUsers = [...selectedConversations.values()].flat(); + const manuallySelectedUsers = selectedUsers.filter( + user => !importedUsers.some(imported => matchQualifiedIds(imported.qualifiedId, user.qualifiedId)), + ); + onSelectedUsersChange( + mergeUsersIntoSelection(manuallySelectedUsers, [...nextSelectedConversations.values()].flat()), + ); + } else { + nextSelectedConversations.set(conversationKey, conversation.participating_user_ets()); + onSelectedUsersChange(mergeConversationUsersIntoSelection(selectedUsers, conversation)); + } + + setSelectedConversations(nextSelectedConversations); + }, + [onSelectedUsersChange, selectedConversations, selectedUsers], + ); + + useEffect(() => { + if (!isOpen) { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as Node; + const trigger = triggerRef.current; + const popover = popoverRef.current; + + if ( + (!isNullOrUndefined(trigger) && trigger.contains(target)) || + (!isNullOrUndefined(popover) && popover.contains(target)) + ) { + return; + } + + handleOpenChange(false); + }; + + document.addEventListener('pointerdown', handlePointerDown, true); + + return () => { + document.removeEventListener('pointerdown', handlePointerDown, true); + }; + }, [handleOpenChange, isOpen]); + + return { + handleOpenChange, + handleSelectedUsersChange, + handleSelectConversation, + isConversationsOpen, + isOpen, + matchingConversations, + popoverRef, + selectedConversationIds: new Set(selectedConversations.keys()), + setIsConversationsOpen, + triggerRef, + }; +}; diff --git a/apps/webapp/src/script/featureToggles/startupFeatureToggleNames.ts b/apps/webapp/src/script/featureToggles/startupFeatureToggleNames.ts index d888c9f0978..a3d888ffcf5 100644 --- a/apps/webapp/src/script/featureToggles/startupFeatureToggleNames.ts +++ b/apps/webapp/src/script/featureToggles/startupFeatureToggleNames.ts @@ -23,6 +23,7 @@ export const viewerPermissionFeatureToggleName = 'viewer-permission'; export const disableMessagePreprocessingFeatureToggleName = 'disable-message-preprocessing'; export const sharedDriveDirectUploadFeatureToggleName = 'shared-drive-direct-upload'; export const reactTranslationRenderingFeatureToggleName = 'react-translation-rendering'; +export const meetingsM2FeatureToggleName = 'meetings-m2'; export const startupFeatureToggleNames = [ applockRefactoredFeatureToggleName, @@ -31,6 +32,7 @@ export const startupFeatureToggleNames = [ disableMessagePreprocessingFeatureToggleName, sharedDriveDirectUploadFeatureToggleName, reactTranslationRenderingFeatureToggleName, + meetingsM2FeatureToggleName, ] as const; export type StartupFeatureToggleName = (typeof startupFeatureToggleNames)[number]; diff --git a/apps/webapp/src/script/featureToggles/startupFeatureToggles.test.ts b/apps/webapp/src/script/featureToggles/startupFeatureToggles.test.ts index 3049755c10f..2027484d965 100644 --- a/apps/webapp/src/script/featureToggles/startupFeatureToggles.test.ts +++ b/apps/webapp/src/script/featureToggles/startupFeatureToggles.test.ts @@ -27,6 +27,7 @@ import { conversationListCollapseFeatureToggleName, disableMessagePreprocessingFeatureToggleName, reactTranslationRenderingFeatureToggleName, + meetingsM2FeatureToggleName, sharedDriveDirectUploadFeatureToggleName, startupFeatureToggleNames, viewerPermissionFeatureToggleName, @@ -39,6 +40,7 @@ const featureToggleNamesWithDedicatedExistenceTests = [ disableMessagePreprocessingFeatureToggleName, sharedDriveDirectUploadFeatureToggleName, reactTranslationRenderingFeatureToggleName, + meetingsM2FeatureToggleName, ] as const; describe('startupFeatureToggles', function () { @@ -75,12 +77,12 @@ describe('startupFeatureToggles', function () { expect(startupFeatureToggles.enabledFeatureToggleNames).toEqual([]); }); - it('does not treat meetings as a startup feature toggle', () => { + it('enables the meetings M2 feature toggle when present in the query parameter', () => { const startupFeatureToggles = createStartupFeatureTogglesFromLocationSearch( - `?${startupFeatureToggleQueryParameterName}=meetings`, + `?${startupFeatureToggleQueryParameterName}=${meetingsM2FeatureToggleName}`, ); - expect(startupFeatureToggles.enabledFeatureToggleNames).toEqual([]); + expect(startupFeatureToggles.isFeatureToggleEnabled(meetingsM2FeatureToggleName)).toBe(true); }); it('keeps only whitelisted feature toggles when known and unknown values are mixed', () => { @@ -184,6 +186,7 @@ describe('startupFeatureToggles', function () { disableMessagePreprocessingFeatureToggleName, sharedDriveDirectUploadFeatureToggleName, reactTranslationRenderingFeatureToggleName, + meetingsM2FeatureToggleName, ]); }); From 10913ee861ae20fbdd64623561b001bfc7a1b50e Mon Sep 17 00:00:00 2001 From: zskhan Date: Tue, 8 Sep 2026 15:07:49 +0200 Subject: [PATCH 2/2] Improve a11y --- .../components/UserSearchableList/UserSearchableList.tsx | 2 +- .../meetingConversationsSearchableList.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/webapp/src/script/components/UserSearchableList/UserSearchableList.tsx b/apps/webapp/src/script/components/UserSearchableList/UserSearchableList.tsx index a6a57ee0422..88716a4100e 100644 --- a/apps/webapp/src/script/components/UserSearchableList/UserSearchableList.tsx +++ b/apps/webapp/src/script/components/UserSearchableList/UserSearchableList.tsx @@ -217,7 +217,7 @@ export const UserSearchableList = ({ let userListContent: React.ReactNode = null; if (isEmptyUserList && !hideEmptyState) { userListContent = ( -

+

{translate(noResultsTranslationText)}

); diff --git a/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingConversationsSearchableList.tsx b/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingConversationsSearchableList.tsx index c34deb78e5f..a2ddee983d7 100644 --- a/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingConversationsSearchableList.tsx +++ b/apps/webapp/src/script/components/meeting/meetingParticipantsPicker/meetingConversationsSearchableList.tsx @@ -63,20 +63,22 @@ export const MeetingConversationsSearchableList = ({ onClick={() => onOpenChange(!isOpen)} css={collapseButton} data-uie-name={dataUieName ? `${dataUieName}-toggle` : undefined} + aria-expanded={isOpen} + aria-controls={`${id}-conversation-list`} > {translate('meetings.scheduleModal.groupsAndChannels')} -
+
{isOpen && conversations.map(conversation => { const conversationKey = getConversationKey(conversation); const checkboxId = `${id}-${conversationKey}`; return ( -
+