Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/webapp/src/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ export type UserListProps = Omit<React.ComponentProps<typeof UserList>, '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;
Expand All @@ -64,6 +68,8 @@ export const UserSearchableList = ({
onUpdateSelectedUsers,
filterRemoteTeamUsers = false,
showAllProvidedUsers = false,
hideEmptyState = false,
showSelectedUsersRegardlessOfFilter = false,
dataUieName = '',
filter = '',
highlightedUsers,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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 = (
<p className="user-list__no-results" data-uie-name={noResultsDataUieName} role="status" aria-live="polite">
{translate(noResultsTranslationText)}
</p>
);
} else if (!isEmptyUserList) {
userListContent = (
<UserList
{...userListProps}
users={userList}
selectedUsers={filteredSelectedUsers}
highlightedUsers={highlightedUsers}
onSelectUser={toggleUserSelection}
selfUser={selfUser}
/>
);
}

return (
<div className="user-list-wrapper" data-uie-name={dataUieName} role="list">
{isEmptyUserList ? (
<p className="user-list__no-results" data-uie-name={noResultsDataUieName}>
{translate(noResultsTranslationText)}
</p>
) : (
<UserList
{...userListProps}
users={userList}
selectedUsers={filteredSelectedUsers}
highlightedUsers={highlightedUsers}
onSelectUser={toggleUserSelection}
selfUser={selfUser}
/>
)}
{userListContent}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -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(
<MeetingConversationsSearchableList
id="participants"
conversations={conversations}
selectedConversationIds={new Set(['example.com-group'])}
onSelectConversation={onSelectConversation}
isOpen
onOpenChange={jest.fn()}
noUnderline={false}
/>,
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(
<MeetingConversationsSearchableList
id="participants"
conversations={[createConversation('group', 'Project group')]}
selectedConversationIds={new Set()}
onSelectConversation={jest.fn()}
isOpen
onOpenChange={onOpenChange}
noUnderline={false}
/>,
rootProviderWrapper,
),
);

fireEvent.click(screen.getByRole('button'));
expect(onOpenChange).toHaveBeenCalledWith(false);
});

it('renders nothing when there are no matching conversations', () => {
render(
withThemeAndRootContext(
<MeetingConversationsSearchableList
id="participants"
conversations={[]}
selectedConversationIds={new Set()}
onSelectConversation={jest.fn()}
isOpen
onOpenChange={jest.fn()}
noUnderline={false}
/>,
rootProviderWrapper,
),
);

expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.queryByText('Project group')).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* 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<string>;
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 (
<>
<button
type="button"
Comment thread
zskhan marked this conversation as resolved.
onClick={() => onOpenChange(!isOpen)}
css={collapseButton}
data-uie-name={dataUieName ? `${dataUieName}-toggle` : undefined}
aria-expanded={isOpen}
aria-controls={`${id}-conversation-list`}
>
<span css={collapseIcon(isOpen)} aria-hidden="true">
<ChevronDownIcon width={16} height={16} />
</span>
{translate('meetings.scheduleModal.groupsAndChannels')}
</button>
<div id={`${id}-conversation-list`} css={conversationListStyles} role="list">
{isOpen &&
conversations.map(conversation => {
const conversationKey = getConversationKey(conversation);
const checkboxId = `${id}-${conversationKey}`;

return (
<div key={conversationKey} css={listWrapper({noUnderline})} role="listitem">
<Checkbox
id={checkboxId}
checked={selectedConversationIds.has(conversationKey)}
onChange={() => onSelectConversation(conversation)}
labelBeforeCheckbox
aligncenter={false}
outlineOffset="0"
>
<CheckboxLabel htmlFor={checkboxId}>
<div css={listItem()}>
{conversation.isChannel() ? (
<ChannelAvatar
conversationID={conversation.id}
isLocked={false}
size="large"
css={conversationIconStyles}
/>
) : (
<GroupAvatar conversationID={conversation.id} size="medium" css={conversationIconStyles} />
)}
<span>{conversation.display_name()}</span>
</div>
</CheckboxLabel>
</Checkbox>
</div>
);
})}
</div>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
};
Loading
Loading