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
18 changes: 18 additions & 0 deletions apps/webapp/src/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,11 @@
"dataSharingModalDecline": "Decline",
"dataSharingModalDescription": "Help to improve Wire by sharing your usage data via a pseudonymous ID. The data is neither linked to your personal information nor shared with third parties besides Wire Group. It includes, for example, when you use a feature, your app version, device type, or your operating system. This data will be deleted at the latest after 365 days. <br /> Find further details in our [link]Privacy Policy[/link]. You can revoke your consent at any time.",
"dataSharingModalTitle": "Consent to share user data",
"deleteGroupModalCancelAction": "Cancel",
"deleteGroupModalClose": "Close window 'Delete {name}'",
"deleteGroupModalConfirmAction": "Delete",
"deleteGroupModalMessage": "This will delete the group and all content for all participants on all devices. There is no option to restore the content. All participants will be notified.",
"deleteGroupModalTitle": "Delete {name}?",
"deletedUser": "Deleted User",
"deletedUserBadge": "Deleted",
"downloadLatestMLS": "Download the latest MLS Wire version",
Expand Down Expand Up @@ -1205,6 +1210,19 @@
"layoutSidebarContent": "Connect, message, and share files with ease, protected by the industry's most secure end-to-end encryption",
"layoutSidebarHeader": "Collaborate without Compromise",
"layoutSidebarLink": "Learn more",
"leaveGroupAdminModalCancelAction": "Cancel",
"leaveGroupAdminModalClearContent": "Also clear the content",
"leaveGroupAdminModalClose": "Close window 'Leave {name}'",
"leaveGroupAdminModalDeleteAction": "Delete group",
"leaveGroupAdminModalLeaveAction": "Leave",
"leaveGroupAdminModalMessageNoEligibleFirstPart": "You're currently the only admin in this group with no other participant eligible to be promoted as admin.",
"leaveGroupAdminModalMessageNoEligibleSecondPart": "Add at least one additional admin, after that you can leave this group. If you no longer need the group or its content, consider deleting it instead.",
"leaveGroupAdminModalMessageWithEligibleFirstPart": "You're currently the only admin in this group. To prevent the group from being left without management after you leave, promote another participant to admin.",
"leaveGroupAdminModalMessageWithEligibleSecondPart": "If you no longer need the group or its content, consider deleting it instead.",
"leaveGroupAdminModalNewAdminLabel": "New admin",
"leaveGroupAdminModalPromoteAction": "Leave Group",
"leaveGroupAdminModalSearchPlaceholder": "Enter a name",
"leaveGroupAdminModalTitle": "Leave {name}?",
"legalHoldActivated": "This conversation is under legal hold",
"legalHoldActivatedLearnMore": "Learn more",
"legalHoldDeactivated": "Legal hold deactivated for this conversation",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {StyledApp, THEME_ID} from '@wireapp/react-ui-kit';
import {WebAppEvents} from '@wireapp/webapp-events';

import {DetachedCallingCell} from 'Components/calling/DetachedCallingCell';
import {LeaveGroupAdminModal} from 'Components/Modals/LeaveGroupAdminModal/LeaveGroupAdminModal';
import {PrimaryModalComponent} from 'Components/Modals/PrimaryModal/PrimaryModal';
import {QualityFeedbackModal} from 'Components/Modals/QualityFeedbackModal';
import {PROPERTIES_TYPE} from 'Repositories/properties/propertiesType';
Expand Down Expand Up @@ -135,6 +136,7 @@ export const AppContainer = (properties: AppProps) => {

<StyledApp themeId={themeId} css={{backgroundColor: 'unset', height: '100%'}}>
<PrimaryModalComponent />
<LeaveGroupAdminModal />
<QualityFeedbackModal callingRepository={app.repository.calling} />
</StyledApp>

Expand Down
Original file line number Diff line number Diff line change
@@ -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 is from '@sindresorhus/is';
import {FormatOptionLabelMeta} from 'react-select';

import {Option, Select} from '@wireapp/react-ui-kit';

import {Avatar, AVATAR_SIZE} from 'Components/Avatar';
import type {User} from 'Repositories/entity/User';
import {t} from 'Util/localizerUtil';

import {
checkboxStyles,
clearContentLabelStyles,
clearContentRowStyles,
newAdminLabelStyles,
optionAvatarStyles,
optionRowStyles,
optionTextColumnStyles,
searchSectionStyles,
selectMenuPortalStyles,
selectWrapperStyles,
userHandleStyles,
userNameStyles,
} from './styles';

interface AdminSearchInputProps {
clearContent: boolean;
eligibleUsers: User[];
selectedUser: User | null;
onClearContentChange: (checked: boolean) => void;
onUserSelect: (user: User | null) => void;
}

export const AdminSearchInput = ({
clearContent,
eligibleUsers,
selectedUser,
onClearContentChange,
onUserSelect,
}: AdminSearchInputProps) => {
const options: Option[] = eligibleUsers.map(user => ({value: user.id, label: user.name()}));
const selectedOption = selectedUser ? (options.find(opt => opt.value === selectedUser.id) ?? null) : null;

const handleChange = (option: Option | null) => {
if (is.nullOrUndefined(option)) {
onUserSelect(null);
return;
}
const user = eligibleUsers.find(usr => usr.id === option.value);
if (!is.nullOrUndefined(user)) {
onUserSelect(user);
}
};

const formatOptionLabel = (option: Option, meta: FormatOptionLabelMeta<Option>) => {
const user = eligibleUsers.find(usr => usr.id === option.value);
if (!user || meta.context === 'value') {
return <span>{option.label}</span>;
}

return (
<div style={optionRowStyles}>
<Avatar avatarSize={AVATAR_SIZE.SMALL} participant={user} aria-hidden="true" css={optionAvatarStyles} />
<span style={optionTextColumnStyles}>
<span style={userNameStyles}>{user.name()}</span>
<span style={userHandleStyles}>{user.handle}</span>
</span>
</div>
);
};

return (
<div style={searchSectionStyles}>
<label style={newAdminLabelStyles} data-uie-name="leave-group-admin-new-admin-label">
{t('leaveGroupAdminModalNewAdminLabel')}
</label>

<Select
id="leave-group-admin-select"
dataUieName="input-leave-group-admin-search"
options={options}
value={selectedOption}
onChange={option => handleChange(option)}
formatOptionLabel={formatOptionLabel}
isSearchable
placeholder={t('leaveGroupAdminModalSearchPlaceholder')}
wrapperCSS={selectWrapperStyles}
menuPortalTarget={document.body}
menuPosition="fixed"
selectMenuPortalCSS={selectMenuPortalStyles}
isClearable
/>

{!is.nullOrUndefined(selectedUser) && (
<div style={clearContentRowStyles} data-uie-name="leave-group-admin-clear-content">
<input
type="checkbox"
id="leave-group-admin-clear-content-checkbox"
checked={clearContent}
onChange={event => onClearContentChange(event.target.checked)}
style={checkboxStyles}
data-uie-name="input-leave-group-clear-content"
/>
<label htmlFor="leave-group-admin-clear-content-checkbox" style={clearContentLabelStyles}>
{t('leaveGroupAdminModalClearContent')}
</label>
</div>
)}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* 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 React from 'react';

import {act, fireEvent, render, waitFor} from '@testing-library/react';

import {User} from 'Repositories/entity/User';
import {generateConversation} from 'test/helper/ConversationGenerator';
import {withTheme} from 'src/script/auth/util/test/TestUtil';
import {t} from 'Util/localizerUtil';

import {LeaveGroupAdminModal} from './LeaveGroupAdminModal';
import {useLeaveGroupAdminModalStore} from './useLeaveGroupAdminModalStore';

jest.mock('./AdminSearchInput', () => ({
AdminSearchInput: () => <div data-uie-name="admin-search-input" />,
}));

const renderModal = () => render(withTheme(<LeaveGroupAdminModal />));

const createEligibleUser = (id: string) => {
const user = new User(id, 'example.com');
user.name(`User ${id}`);
user.username(`user.${id}`);
return user;
};

describe('LeaveGroupAdminModal', () => {
afterEach(() => {
act(() => {
useLeaveGroupAdminModalStore.getState().hide();
});
jest.clearAllMocks();
});

it('shows the no-eligible-users variant and does not show leave/promote action', () => {
const conversation = generateConversation({name: 'Team Group'});

act(() => {
useLeaveGroupAdminModalStore.getState().show({
conversation,
eligibleUsers: [],
onDelete: jest.fn(),
onLeave: jest.fn().mockResolvedValue(undefined),
});
});

const {getByTestId, queryByTestId} = renderModal();

expect(getByTestId('leave-group-admin-modal-message')).toHaveTextContent(
t('leaveGroupAdminModalMessageNoEligibleFirstPart'),
);
expect(getByTestId('leave-group-admin-modal-message')).toHaveTextContent(
t('leaveGroupAdminModalMessageNoEligibleSecondPart'),
);
expect(queryByTestId('do-leave-group-and-promote-admin')).toBeNull();
expect(getByTestId('do-delete-group-from-leave-modal')).toBeInTheDocument();
});

it('keeps leave blocked when role assignment fails and resets loading state', async () => {
const conversation = generateConversation({name: 'Team Group'});
const selectedUser = createEligibleUser('selected-user');
const onDelete = jest.fn();
const onLeave = jest.fn().mockRejectedValue(new Error('Role assignment failed'));

act(() => {
useLeaveGroupAdminModalStore.getState().show({
conversation,
eligibleUsers: [selectedUser],
onDelete,
onLeave,
});
useLeaveGroupAdminModalStore.getState().setSelectedUser(selectedUser);
});

const {getByTestId} = renderModal();
const leaveButton = getByTestId('do-leave-group-and-promote-admin') as HTMLButtonElement;

expect(leaveButton).not.toBeDisabled();

fireEvent.click(leaveButton);

await waitFor(() => {
expect(onLeave).toHaveBeenCalledTimes(1);
});

await waitFor(() => {
expect(useLeaveGroupAdminModalStore.getState().isLoading).toBe(false);
});

expect(leaveButton).not.toBeDisabled();
expect(useLeaveGroupAdminModalStore.getState().isOpen).toBe(true);
});
});
Loading
Loading