snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,174 @@
import type { IRoomWithRetentionPolicy } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import EditRoomInfo from './EditRoomInfo';
import { createFakeRoom } from '../../../../../../tests/mocks/data';
jest.mock('../../../../../lib/rooms/roomCoordinator', () => ({
roomCoordinator: {
getRoomDirectives: () => ({
allowRoomSettingChange: () => true,
}),
getRoomName: (_t: string, room: { name?: string }) => room.name ?? 'test-room',
},
}));
jest.mock('../../../../../components/avatar/RoomAvatarEditor', () => ({
__esModule: true,
default: () => null,
}));
const noop = () => undefined;
const createRoom = (sysMes?: string[]) =>
createFakeRoom<IRoomWithRetentionPolicy>({
t: 'c',
name: 'test-room',
...(sysMes && { sysMes: sysMes as IRoomWithRetentionPolicy['sysMes'] }),
});
const buildAppRoot = (saveFn: jest.Mock, room: IRoomWithRetentionPolicy) =>
mockAppRoot()
.withJohnDoe()
.withPermission('edit-room')
.withEndpoint('POST', '/v1/rooms.saveRoomSettings', saveFn as never)
.withEndpoint('POST', '/v1/rooms.changeArchivationState', () => ({ success: true }) as never)
.withEndpoint('GET', '/v1/rooms.nameExists', () => ({ exists: false }) as never)
.withSetting('UTF8_Channel_Names_Validation', '[0-9a-zA-Z-_.]+')
.withSetting('UI_Allow_room_names_with_special_chars', true)
.withSetting('RetentionPolicy_Enabled', false)
.withTranslations('en', 'core', {
Save: 'Save',
Hide_System_Messages: 'Hide system messages',
Advanced_settings: 'Advanced settings',
Select_messages_to_hide: 'Select messages to hide',
})
.withRoom(room)
.build();
const openAdvancedSettings = async () => {
await userEvent.click(await screen.findByRole('button', { name: /Advanced settings/i }));
};
const findHideSysMesToggle = () => screen.findByLabelText(/Hide system messages/i);
const clickSave = async () => {
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
};
const removeSystemMessageChip = async (translationKey: string) => {
const chip = screen.getByRole('button', { name: new RegExp(translationKey, 'i') });
await userEvent.click(chip);
};
const selectSystemMessageOption = async (translationKey: string) => {
const trigger = screen.getByRole('combobox', { name: /Select messages to hide/i });
await userEvent.click(trigger);
const listboxes = await screen.findAllByRole('listbox', { hidden: true });
const listbox = listboxes.find((el) => within(el).queryAllByRole('option', { hidden: true }).length > 0);
if (!listbox) {
throw new Error('Could not find a listbox containing options');
}
const optionEl = within(listbox).getByText(translationKey).closest('li[role="option"]');
if (!optionEl) {
throw new Error(`Could not find option with text "${translationKey}"`);
}
await userEvent.click(optionEl);
};
it('should send systemMessages: [] when hideSysMes is toggled OFF', async () => {
const saveFn = jest.fn((_data: Record<string, unknown>) => ({ rid: 'room-id', success: true }));
const room = createRoom(['au', 'ru']);
render(<EditRoomInfo room={room} onClickClose={noop} onClickBack={noop} />, { wrapper: buildAppRoot(saveFn, room) });
await openAdvancedSettings();
const toggle = await findHideSysMesToggle();
expect(toggle).toBeChecked();
await userEvent.click(toggle);
expect(toggle).not.toBeChecked();
await clickSave();
await waitFor(() => expect(saveFn).toHaveBeenCalled());
expect(saveFn.mock.calls[0][0]).toEqual(expect.objectContaining({ systemMessages: [] }));
});
it('should send selected systemMessages when user selects message types to hide', async () => {
const saveFn = jest.fn((_data: Record<string, unknown>) => ({ rid: 'room-id', success: true }));
const room = createRoom();
render(<EditRoomInfo room={room} onClickClose={noop} onClickBack={noop} />, { wrapper: buildAppRoot(saveFn, room) });
await openAdvancedSettings();
const toggle = await findHideSysMesToggle();
await userEvent.click(toggle);
expect(toggle).toBeChecked();
await selectSystemMessageOption('Message_HideType_au');
await clickSave();
await waitFor(() => expect(saveFn).toHaveBeenCalled());
expect(saveFn.mock.calls[0][0]).toEqual(expect.objectContaining({ systemMessages: expect.arrayContaining(['au']) }));
});
it('should send existing systemMessages when toggle is turned ON without changing selection', async () => {
const saveFn = jest.fn((_data: Record<string, unknown>) => ({ rid: 'room-id', success: true }));
const room = createRoom();
render(<EditRoomInfo room={room} onClickClose={noop} onClickBack={noop} />, { wrapper: buildAppRoot(saveFn, room) });
await openAdvancedSettings();
const toggle = await findHideSysMesToggle();
expect(toggle).not.toBeChecked();
await userEvent.click(toggle);
expect(toggle).toBeChecked();
await clickSave();
await waitFor(() => expect(saveFn).toHaveBeenCalled());
expect(saveFn.mock.calls[0][0]).toEqual(expect.objectContaining({ systemMessages: [] }));
});
it('should send updated systemMessages when only the multi-select is changed', async () => {
const saveFn = jest.fn((_data: Record<string, unknown>) => ({ rid: 'room-id', success: true }));
const room = createRoom(['ru']);
render(<EditRoomInfo room={room} onClickClose={noop} onClickBack={noop} />, { wrapper: buildAppRoot(saveFn, room) });
await openAdvancedSettings();
const toggle = await findHideSysMesToggle();
expect(toggle).toBeChecked();
await removeSystemMessageChip('Message_HideType_ru');
await selectSystemMessageOption('Message_HideType_au');
await clickSave();
await waitFor(() => expect(saveFn).toHaveBeenCalled());
expect(saveFn.mock.calls[0][0]).toEqual(expect.objectContaining({ systemMessages: expect.arrayContaining(['au']) }));
});
it('should NOT send systemMessages when something else changed', async () => {
const saveFn = jest.fn((_data: Record<string, unknown>) => ({ rid: 'room-id', success: true }));
const room = createRoom(['au']);
render(<EditRoomInfo room={room} onClickClose={noop} onClickBack={noop} />, { wrapper: buildAppRoot(saveFn, room) });
await userEvent.type(await screen.findByRole('textbox', { name: 'Topic' }), 'new topic');
await clickSave();
await waitFor(() => expect(saveFn).toHaveBeenCalled());
expect(saveFn.mock.calls[0][0]).not.toHaveProperty('systemMessages');
});

View File

@@ -0,0 +1,621 @@
/* eslint-disable complexity */
import type { IRoomWithRetentionPolicy } from '@rocket.chat/core-typings';
import { isRoomFederated } from '@rocket.chat/core-typings';
import type { SelectOption } from '@rocket.chat/fuselage';
import {
FieldError,
Field,
FieldRow,
FieldLabel,
FieldHint,
TextInput,
PasswordInput,
ToggleSwitch,
MultiSelect,
Accordion,
Callout,
NumberInput,
FieldGroup,
Button,
ButtonGroup,
Box,
TextAreaInput,
AccordionItem,
} from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import {
ContextualbarHeader,
ContextualbarBack,
ContextualbarTitle,
ContextualbarClose,
ContextualbarScrollableContent,
ContextualbarFooter,
ContextualbarDialog,
} from '@rocket.chat/ui-client';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useSetting, useTranslation, useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import type { ChangeEvent } from 'react';
import { useId, useMemo } from 'react';
import { useForm, Controller } from 'react-hook-form';
import type { EditRoomInfoFormData } from './useEditRoomInitialValues';
import { useEditRoomInitialValues } from './useEditRoomInitialValues';
import { useEditRoomPermissions } from './useEditRoomPermissions';
import { MessageTypesValues } from '../../../../../../app/lib/lib/MessageTypes';
import RawText from '../../../../../components/RawText';
import RoomAvatarEditor from '../../../../../components/avatar/RoomAvatarEditor';
import { msToTimeUnit, TIMEUNIT } from '../../../../../lib/convertTimeUnit';
import { getDirtyFields } from '../../../../../lib/getDirtyFields';
import { links } from '../../../../../lib/links';
import { roomsQueryKeys } from '../../../../../lib/queryKeys';
import { useIsABACManagedRoom } from '../../../../admin/ABAC/hooks/useIsABACManagedRoom';
import { useArchiveRoom } from '../../../../hooks/roomActions/useArchiveRoom';
import { useRetentionPolicy } from '../../../hooks/useRetentionPolicy';
export type EditRoomInfoProps = {
room: IRoomWithRetentionPolicy;
onClickClose: () => void;
onClickBack: () => void;
};
const title = {
team: 'Edit_team',
channel: 'Edit_channel',
discussion: 'Edit_discussion',
} as const;
const getRetentionSetting = (roomType: IRoomWithRetentionPolicy['t']): string => {
switch (roomType) {
case 'd':
return 'RetentionPolicy_TTL_DMs';
case 'p':
return 'RetentionPolicy_TTL_Groups';
case 'c':
default:
return 'RetentionPolicy_TTL_Channels';
}
};
const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => {
const query = useQueryClient();
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const isFederated = isRoomFederated(room);
const isAbacManaged = useIsABACManagedRoom(room);
// eslint-disable-next-line no-nested-ternary
const roomType = 'prid' in room ? 'discussion' : room.teamMain ? 'team' : 'channel';
const retentionPolicy = useRetentionPolicy(room);
const retentionMaxAgeDefault = msToTimeUnit(TIMEUNIT.days, useSetting(getRetentionSetting(room.t), 2592000000)) ?? 30;
const defaultValues = useEditRoomInitialValues(room);
const namesValidation = useSetting('UTF8_Channel_Names_Validation');
const allowSpecialNames = useSetting('UI_Allow_room_names_with_special_chars');
const checkTeamNameExists = useEndpoint('GET', '/v1/rooms.nameExists');
const teamNameRegex = useMemo(() => {
if (allowSpecialNames) {
return null;
}
return new RegExp(`^${namesValidation}$`);
}, [allowSpecialNames, namesValidation]);
const {
watch,
reset,
control,
handleSubmit,
getFieldState,
formState: { isDirty, dirtyFields, errors, isSubmitting },
} = useForm<EditRoomInfoFormData>({ defaultValues });
const sysMesOptions: SelectOption[] = useMemo(
() => MessageTypesValues.map(({ key, i18nLabel }) => [key, t(i18nLabel as TranslationKey)]),
[t],
);
const { isDirty: isRoomNameDirty } = getFieldState('roomName');
const {
readOnly,
archived,
joinCodeRequired,
hideSysMes,
retentionEnabled,
retentionOverrideGlobal,
roomType: roomTypeP,
reactWhenReadOnly,
} = watch();
const {
canChangeType,
canSetReadOnly,
canSetReactWhenReadOnly,
canEditRoomRetentionPolicy,
canArchiveOrUnarchive,
canViewName,
canViewTopic,
canViewAnnouncement,
canViewArchived,
canViewDescription,
canViewType,
canViewReadOnly,
canViewHideSysMes,
canViewJoinCode,
} = useEditRoomPermissions(room);
const changeArchiving = archived !== !!room.archived;
const saveAction = useEndpoint('POST', '/v1/rooms.saveRoomSettings');
const handleArchive = useArchiveRoom(room);
// TODO: add payload validation
const handleUpdateRoomData = useStableCallback(
async ({
hideSysMes,
joinCodeRequired,
retentionEnabled,
retentionOverrideGlobal,
retentionMaxAge,
retentionExcludePinned,
retentionFilesOnly,
retentionIgnoreThreads,
...formData
}: EditRoomInfoFormData) => {
const data = getDirtyFields<Partial<typeof defaultValues>>(formData, dirtyFields);
delete data.archived;
try {
await saveAction({
rid: room._id,
...data,
...((data.joinCode || 'joinCodeRequired' in data) && { joinCode: joinCodeRequired ? data.joinCode : '' }),
...((dirtyFields.hideSysMes || dirtyFields.systemMessages) && {
systemMessages: hideSysMes ? (data.systemMessages ?? defaultValues.systemMessages) : [],
}),
retentionEnabled,
retentionOverrideGlobal,
...(retentionEnabled &&
retentionOverrideGlobal && {
retentionMaxAge,
retentionExcludePinned,
retentionFilesOnly,
retentionIgnoreThreads,
}),
});
await query.invalidateQueries({ queryKey: roomsQueryKeys.info(room._id) });
dispatchToastMessage({ type: 'success', message: t('Room_updated_successfully') });
onClickClose();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
},
);
const handleSave = useStableCallback((data: EditRoomInfoFormData) =>
Promise.all([isDirty && handleUpdateRoomData(data), changeArchiving && handleArchive()].filter(Boolean)),
);
const validateName = async (name: string): Promise<string | undefined> => {
if (!name || !isRoomNameDirty) return;
if (roomType === 'discussion') return;
if (teamNameRegex && !teamNameRegex?.test(name)) {
return t('Name_cannot_have_special_characters');
}
const { exists } = await checkTeamNameExists({ roomName: name });
if (exists) {
return t('Teams_Errors_Already_exists', { name });
}
};
const formId = useId();
const roomNameField = useId();
const roomDescriptionField = useId();
const roomAnnouncementField = useId();
const roomTopicField = useId();
const roomTypeField = useId();
const readOnlyField = useId();
const reactWhenReadOnlyField = useId();
const archivedField = useId();
const joinCodeRequiredField = useId();
const hideSysMesField = useId();
const retentionEnabledField = useId();
const retentionOverrideGlobalField = useId();
const retentionMaxAgeField = useId();
const retentionExcludePinnedField = useId();
const retentionFilesOnlyField = useId();
const retentionIgnoreThreads = useId();
const showAdvancedSettings = canViewReadOnly || readOnly || canViewArchived || canViewJoinCode || canViewHideSysMes;
const showRetentionPolicy = canEditRoomRetentionPolicy && retentionPolicy?.enabled;
const showAccordion = showAdvancedSettings || showRetentionPolicy;
return (
<ContextualbarDialog>
<ContextualbarHeader>
{onClickBack && <ContextualbarBack onClick={onClickBack} />}
<ContextualbarTitle>{t(`${title[roomType]}`)}</ContextualbarTitle>
{onClickClose && <ContextualbarClose onClick={onClickClose} />}
</ContextualbarHeader>
<ContextualbarScrollableContent padding={24}>
<form id={formId} onSubmit={handleSubmit(handleSave)}>
<Box display='flex' justifyContent='center'>
<Controller
control={control}
name='roomAvatar'
render={({ field: { onChange, value } }) => <RoomAvatarEditor room={room} roomAvatar={value} onChangeAvatar={onChange} />}
/>
</Box>
<FieldGroup>
<Field>
<FieldLabel htmlFor={roomNameField} required>
{t('Name')}
</FieldLabel>
<FieldRow>
<Controller
name='roomName'
control={control}
rules={{
required: t('Required_field', { field: t('Name') }),
validate: (value) => validateName(value),
}}
render={({ field }) => (
<TextInput
id={roomNameField}
{...field}
disabled={!canViewName}
aria-invalid={errors.roomName ? 'true' : 'false'}
aria-describedby={`${roomNameField}-error`}
aria-required='true'
/>
)}
/>
</FieldRow>
{errors.roomName && <FieldError id={`${roomNameField}-error`}>{errors.roomName.message}</FieldError>}
</Field>
{canViewTopic && (
<Field>
<FieldLabel htmlFor={roomTopicField}>{t('Topic')}</FieldLabel>
<FieldRow>
<Controller
name='roomTopic'
control={control}
render={({ field }) => (
<TextInput id={roomTopicField} aria-describedby={`${roomTopicField}-hint`} {...field} disabled={isAbacManaged} />
)}
/>
</FieldRow>
<FieldRow>
<FieldHint id={`${roomTopicField}-hint`}>{t('Displayed_next_to_name')}</FieldHint>
</FieldRow>
</Field>
)}
{canViewAnnouncement && (
<Field>
<FieldLabel htmlFor={roomAnnouncementField}>{t('Announcement')}</FieldLabel>
<FieldRow>
<Controller
name='roomAnnouncement'
control={control}
render={({ field }) => (
<TextInput
id={roomAnnouncementField}
aria-describedby={`${roomAnnouncementField}-hint`}
{...field}
disabled={isFederated || isAbacManaged}
/>
)}
/>
</FieldRow>
<FieldRow>
<FieldHint id={`${roomAnnouncementField}-hint`}>{t('Information_to_keep_top_of_mind')}</FieldHint>
</FieldRow>
</Field>
)}
{canViewDescription && (
<Field>
<FieldLabel htmlFor={roomDescriptionField}>{t('Description')}</FieldLabel>
<FieldRow>
<Controller
name='roomDescription'
control={control}
render={({ field }) => (
<TextAreaInput id={roomDescriptionField} {...field} disabled={isFederated || isAbacManaged} rows={4} />
)}
/>
</FieldRow>
</Field>
)}
{canViewType && (
<Field>
<FieldRow>
<FieldLabel htmlFor={roomTypeField}>{t('Private')}</FieldLabel>
<Controller
control={control}
name='roomType'
render={({ field: { name, onBlur, onChange, value, ref } }) => (
<ToggleSwitch
id={roomTypeField}
ref={ref}
name={name}
onBlur={onBlur}
disabled={!canChangeType || isFederated}
checked={value === 'p'}
onChange={() => onChange(value === 'p' ? 'c' : 'p')}
aria-describedby={`${roomTypeField}-hint`}
/>
)}
/>
</FieldRow>
<FieldRow>
<FieldHint id={`${roomTypeField}-hint`}>
{roomTypeP === 'p' ? t('Only_invited_people') : t('Anyone_can_access')}
</FieldHint>
</FieldRow>
</Field>
)}
</FieldGroup>
{showAccordion && (
<Accordion>
{showAdvancedSettings && (
<AccordionItem title={t('Advanced_settings')}>
<FieldGroup>
<Box is='h5' fontScale='h5' color='titles-labels'>
{t('Security_and_permissions')}
</Box>
{canViewReadOnly && (
<Field>
<FieldRow>
<FieldLabel htmlFor={readOnlyField}>{t('Read_only')}</FieldLabel>
<Controller
control={control}
name='readOnly'
render={({ field: { value, ...field } }) => (
<ToggleSwitch
id={readOnlyField}
{...field}
checked={value}
disabled={!canSetReadOnly || isFederated}
aria-describedby={`${readOnlyField}-hint`}
/>
)}
/>
</FieldRow>
<FieldHint id={`${readOnlyField}-hint`}>
{readOnly ? t('Read_only_field_hint_enabled', { roomType }) : t('Read_only_field_hint_disabled')}
</FieldHint>
</Field>
)}
{readOnly && (
<Field>
<FieldRow>
<FieldLabel htmlFor={reactWhenReadOnlyField}>{t('React_when_read_only')}</FieldLabel>
<Controller
control={control}
name='reactWhenReadOnly'
render={({ field: { value, ...field } }) => (
<ToggleSwitch
id={reactWhenReadOnlyField}
{...field}
disabled={!canSetReactWhenReadOnly}
checked={value}
aria-describedby={`${reactWhenReadOnlyField}-hint`}
/>
)}
/>
</FieldRow>
<FieldRow>
<FieldHint id={`${reactWhenReadOnlyField}-hint`}>
{reactWhenReadOnly ? t('Anyone_can_react_to_messages') : t('Only_authorized_users_can_react_to_messages')}
</FieldHint>
</FieldRow>
</Field>
)}
{canViewArchived && (
<Field>
<FieldRow>
<FieldLabel htmlFor={archivedField}>{t('Room_archivation_state_true')}</FieldLabel>
<Controller
control={control}
name='archived'
render={({ field: { value, ...field } }) => (
<ToggleSwitch
id={archivedField}
aria-describedby={`${archivedField}-hint`}
{...field}
disabled={!canArchiveOrUnarchive}
checked={value}
/>
)}
/>
</FieldRow>
{archived && (
<FieldRow>
<FieldHint id={`${archivedField}-hint`}>{t('New_messages_cannot_be_sent')}</FieldHint>
</FieldRow>
)}
</Field>
)}
{canViewJoinCode && (
<Field>
<FieldRow>
<FieldLabel htmlFor={joinCodeRequiredField}>{t('Password_to_access')}</FieldLabel>
<Controller
control={control}
name='joinCodeRequired'
render={({ field: { value, ...field } }) => (
<ToggleSwitch id={joinCodeRequiredField} {...field} disabled={isFederated} checked={value} />
)}
/>
</FieldRow>
{joinCodeRequired && (
<FieldRow>
<Controller
name='joinCode'
control={control}
render={({ field }) => (
<PasswordInput {...field} placeholder={t('Reset_password')} disabled={!joinCodeRequired} />
)}
/>
</FieldRow>
)}
</Field>
)}
{canViewHideSysMes && (
<Field>
<FieldRow>
<FieldLabel htmlFor={hideSysMesField}>{t('Hide_System_Messages')}</FieldLabel>
<Controller
control={control}
name='hideSysMes'
render={({ field: { value, ...field } }) => (
<ToggleSwitch
id={hideSysMesField}
{...field}
checked={value}
disabled={isFederated}
aria-describedby={`${hideSysMesField}-hint`}
/>
)}
/>
</FieldRow>
<FieldRow>
<Controller
control={control}
name='systemMessages'
render={({ field }) => (
<MultiSelect
{...field}
options={sysMesOptions}
disabled={!hideSysMes || isFederated}
placeholder={t('Select_messages_to_hide')}
aria-label={t('Select_messages_to_hide')}
aria-describedby={`${hideSysMesField}-hint`}
/>
)}
/>
</FieldRow>
<FieldRow>
<FieldHint id={`${hideSysMesField}-hint`}>{t('Hide_System_Messages_Hint')}</FieldHint>
</FieldRow>
</Field>
)}
</FieldGroup>
</AccordionItem>
)}
{showRetentionPolicy && (
<AccordionItem title={t('Prune')}>
<FieldGroup>
<Field>
<FieldRow>
<FieldLabel htmlFor={retentionEnabledField}>{t('RetentionPolicyRoom_Enabled')}</FieldLabel>
<Controller
control={control}
name='retentionEnabled'
render={({ field: { value, ...field } }) => (
<ToggleSwitch id={retentionEnabledField} {...field} checked={value} />
)}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel htmlFor={retentionOverrideGlobalField}>{t('RetentionPolicyRoom_OverrideGlobal')}</FieldLabel>
<Controller
control={control}
name='retentionOverrideGlobal'
render={({ field: { value, ...field } }) => (
<ToggleSwitch id={retentionOverrideGlobalField} {...field} disabled={!retentionEnabled} checked={value} />
)}
/>
</FieldRow>
</Field>
{retentionOverrideGlobal && (
<>
<Callout type='danger'>
<RawText>{t('RetentionPolicyRoom_ReadTheDocs', { retentionPolicyUrl: links.retentionPolicy })}</RawText>
</Callout>
<Field>
<FieldLabel htmlFor={retentionMaxAgeField}>
{t('RetentionPolicyRoom_MaxAge', { max: retentionMaxAgeDefault })}
</FieldLabel>
<FieldRow>
<Controller
control={control}
name='retentionMaxAge'
render={({ field: { onChange, ...field } }) => (
<NumberInput
id={retentionMaxAgeField}
{...field}
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(Number(e.currentTarget.value))}
/>
)}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel htmlFor={retentionExcludePinnedField}>{t('RetentionPolicyRoom_ExcludePinned')}</FieldLabel>
<Controller
control={control}
name='retentionExcludePinned'
render={({ field: { value, ...field } }) => (
<ToggleSwitch id={retentionExcludePinnedField} {...field} checked={value} />
)}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel htmlFor={retentionFilesOnlyField}>{t('RetentionPolicyRoom_FilesOnly')}</FieldLabel>
<Controller
control={control}
name='retentionFilesOnly'
render={({ field: { value, ...field } }) => (
<ToggleSwitch id={retentionFilesOnlyField} {...field} checked={value} />
)}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel htmlFor={retentionIgnoreThreads}>{t('RetentionPolicy_DoNotPruneThreads')}</FieldLabel>
<Controller
control={control}
name='retentionIgnoreThreads'
render={({ field: { value, ...field } }) => (
<ToggleSwitch id={retentionIgnoreThreads} {...field} checked={value} />
)}
/>
</FieldRow>
</Field>
</>
)}
</FieldGroup>
</AccordionItem>
)}
</Accordion>
)}
</form>
</ContextualbarScrollableContent>
<ContextualbarFooter>
<ButtonGroup stretch>
<Button type='reset' disabled={!isDirty || isSubmitting} onClick={() => reset(defaultValues)}>
{t('Reset')}
</Button>
<Button form={formId} type='submit' primary loading={isSubmitting} disabled={!isDirty}>
{t('Save')}
</Button>
</ButtonGroup>
</ContextualbarFooter>
</ContextualbarDialog>
);
};
export default EditRoomInfo;

View File

@@ -0,0 +1,14 @@
import type { IRoomWithRetentionPolicy } from '@rocket.chat/core-typings';
import { useRoomToolbox } from '@rocket.chat/ui-contexts';
import EditRoomInfo from './EditRoomInfo';
import { useRoom } from '../../../contexts/RoomContext';
const EditRoomInfoWithData = ({ onClickBack }: { onClickBack: () => void }) => {
const room = useRoom() as IRoomWithRetentionPolicy;
const { closeTab } = useRoomToolbox();
return <EditRoomInfo onClickClose={closeTab} onClickBack={onClickBack} room={room} />;
};
export default EditRoomInfoWithData;

View File

@@ -0,0 +1 @@
export { default } from './EditRoomInfoWithData';

View File

@@ -0,0 +1,83 @@
import type { IRoomWithRetentionPolicy, RoomType, MessageTypesValues } from '@rocket.chat/core-typings';
import { usePermission } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { msToTimeUnit, TIMEUNIT } from '../../../../../lib/convertTimeUnit';
import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator';
import { useRetentionPolicy } from '../../../hooks/useRetentionPolicy';
export type EditRoomInfoFormData = {
roomName: string;
roomTopic: string;
roomAnnouncement: string;
roomDescription: string;
roomType: RoomType;
roomAvatar?: string;
readOnly: boolean;
reactWhenReadOnly: boolean;
archived: boolean;
joinCodeRequired: boolean;
hideSysMes: boolean;
encrypted: boolean;
retentionEnabled: boolean;
retentionOverrideGlobal: boolean;
retentionMaxAge: number;
retentionExcludePinned: boolean;
retentionFilesOnly: boolean;
retentionIgnoreThreads: boolean;
showChannels: boolean;
showDiscussions: boolean;
joinCode: string;
systemMessages: MessageTypesValues[];
};
export const useEditRoomInitialValues = (room: IRoomWithRetentionPolicy): Partial<EditRoomInfoFormData> => {
const retentionPolicy = useRetentionPolicy(room);
const canEditRoomRetentionPolicy = usePermission('edit-room-retention-policy', room._id);
const { t, ro, archived, topic, description, announcement, joinCodeRequired, sysMes, encrypted, retention, reactWhenReadOnly } = room;
return useMemo(
(): Partial<EditRoomInfoFormData> => ({
roomName: t === 'd' && room.usernames ? room.usernames.join(' x ') : roomCoordinator.getRoomName(t, room),
roomType: t,
readOnly: !!ro,
reactWhenReadOnly,
archived: !!archived,
roomTopic: topic ?? '',
roomDescription: description ?? '',
roomAnnouncement: announcement ?? '',
roomAvatar: undefined,
joinCode: '',
joinCodeRequired: !!joinCodeRequired,
systemMessages: Array.isArray(sysMes) ? sysMes : [],
hideSysMes: Array.isArray(sysMes) ? !!sysMes?.length : !!sysMes,
encrypted,
...(canEditRoomRetentionPolicy &&
retentionPolicy?.enabled && {
retentionEnabled: retention?.enabled ?? retentionPolicy.isActive,
retentionOverrideGlobal: !!retention?.overrideGlobal,
retentionMaxAge: retention?.maxAge ?? msToTimeUnit(TIMEUNIT.days, retentionPolicy.maxAge),
retentionExcludePinned: retention?.excludePinned ?? retentionPolicy.excludePinned,
retentionFilesOnly: retention?.filesOnly ?? retentionPolicy.filesOnly,
retentionIgnoreThreads: retention?.ignoreThreads ?? retentionPolicy.ignoreThreads,
}),
}),
[
announcement,
archived,
description,
joinCodeRequired,
retention,
retentionPolicy,
ro,
room,
sysMes,
t,
topic,
encrypted,
reactWhenReadOnly,
canEditRoomRetentionPolicy,
],
);
};

View File

@@ -0,0 +1,188 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook } from '@testing-library/react';
import { useEditRoomPermissions } from './useEditRoomPermissions';
import { createFakeRoom } from '../../../../../../tests/mocks/data';
import { useTeamInfoQuery } from '../../../../../hooks/useTeamInfoQuery';
jest.mock('../../../../../lib/rooms/roomCoordinator', () => ({
roomCoordinator: {
getRoomDirectives: () => ({
allowRoomSettingChange: () => true,
}),
},
}));
jest.mock('../../../../../hooks/useTeamInfoQuery');
const mockUseTeamInfoQuery = useTeamInfoQuery as jest.MockedFunction<typeof useTeamInfoQuery>;
beforeEach(() => {
mockUseTeamInfoQuery.mockReturnValue({ data: undefined } as ReturnType<typeof useTeamInfoQuery>);
});
describe('useEditRoomPermissions', () => {
describe('canChangeType - non-team room', () => {
it('should allow changing type from private to channel when user has create-c permission', () => {
const room = createFakeRoom({ t: 'p', default: false });
const wrapper = mockAppRoot().withPermission('create-c').build();
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
expect(result.current.canChangeType).toBe(true);
});
it('should allow changing type from channel to private when user has create-p permission', () => {
const room = createFakeRoom({ t: 'c', default: false });
const wrapper = mockAppRoot().withPermission('create-p').build();
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
expect(result.current.canChangeType).toBe(true);
});
it('should not allow changing type when user lacks the required permission', () => {
const room = createFakeRoom({ t: 'p', default: false });
const wrapper = mockAppRoot().build();
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
expect(result.current.canChangeType).toBe(false);
});
});
describe('canChangeType - team main room', () => {
const teamRoomId = 'team-room-id';
beforeEach(() => {
mockUseTeamInfoQuery.mockReturnValue({ data: { roomId: teamRoomId } } as ReturnType<typeof useTeamInfoQuery>);
});
it('should allow changing type from private to channel when user has create-c', () => {
const room = createFakeRoom({ t: 'p', default: false, teamId: 'team1', teamMain: true });
const wrapper = mockAppRoot().withPermission('create-c').build();
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
expect(result.current.canChangeType).toBe(true);
});
it('should not allow changing type from private to channel when user lacks create-c', () => {
const room = createFakeRoom({ t: 'p', default: false, teamId: 'team1', teamMain: true });
const wrapper = mockAppRoot().build();
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
expect(result.current.canChangeType).toBe(false);
});
});
describe('canChangeType - team channel room', () => {
const teamRoomId = 'team-room-id';
beforeEach(() => {
mockUseTeamInfoQuery.mockReturnValue({ data: { roomId: teamRoomId } } as ReturnType<typeof useTeamInfoQuery>);
});
it('should allow changing type from private to channel when user has create-team-channel', () => {
const room = createFakeRoom({ t: 'p', default: false, teamId: 'team1' });
const wrapper = mockAppRoot().withPermission('create-team-channel').build();
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
expect(result.current.canChangeType).toBe(true);
});
it('should not allow changing type from private to channel when user lacks create-team-channel', () => {
const room = createFakeRoom({ t: 'p', default: false, teamId: 'team1' });
const wrapper = mockAppRoot().build();
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
expect(result.current.canChangeType).toBe(false);
});
it('should allow changing type from channel to private when user has create-team-group', () => {
const room = createFakeRoom({ t: 'c', default: false, teamId: 'team1' });
const wrapper = mockAppRoot().withPermission('create-team-group').build();
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
expect(result.current.canChangeType).toBe(true);
});
it('should not allow changing type from channel to private when user lacks create-team-group', () => {
const room = createFakeRoom({ t: 'c', default: false, teamId: 'team1' });
const wrapper = mockAppRoot().build();
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
expect(result.current.canChangeType).toBe(false);
});
});
describe('other permissions', () => {
it('should reflect canSetReadOnly based on set-readonly permission', () => {
const room = createFakeRoom({ t: 'c', default: false });
const wrapperWith = mockAppRoot().withPermission('set-readonly').build();
const wrapperWithout = mockAppRoot().build();
const { result: withPerm } = renderHook(() => useEditRoomPermissions(room), { wrapper: wrapperWith });
const { result: withoutPerm } = renderHook(() => useEditRoomPermissions(room), { wrapper: wrapperWithout });
expect(withPerm.current.canSetReadOnly).toBe(true);
expect(withoutPerm.current.canSetReadOnly).toBe(false);
});
it('should reflect canSetReactWhenReadOnly based on set-react-when-readonly permission', () => {
const room = createFakeRoom({ t: 'c', default: false });
const wrapperWith = mockAppRoot().withPermission('set-react-when-readonly').build();
const wrapperWithout = mockAppRoot().build();
const { result: withPerm } = renderHook(() => useEditRoomPermissions(room), { wrapper: wrapperWith });
const { result: withoutPerm } = renderHook(() => useEditRoomPermissions(room), { wrapper: wrapperWithout });
expect(withPerm.current.canSetReactWhenReadOnly).toBe(true);
expect(withoutPerm.current.canSetReactWhenReadOnly).toBe(false);
});
it('should reflect canEditRoomRetentionPolicy based on edit-room-retention-policy permission', () => {
const room = createFakeRoom({ t: 'c', default: false });
const wrapperWith = mockAppRoot().withPermission('edit-room-retention-policy').build();
const wrapperWithout = mockAppRoot().build();
const { result: withPerm } = renderHook(() => useEditRoomPermissions(room), { wrapper: wrapperWith });
const { result: withoutPerm } = renderHook(() => useEditRoomPermissions(room), { wrapper: wrapperWithout });
expect(withPerm.current.canEditRoomRetentionPolicy).toBe(true);
expect(withoutPerm.current.canEditRoomRetentionPolicy).toBe(false);
});
it('should reflect canArchiveOrUnarchive when user has archive-room permission', () => {
const room = createFakeRoom({ t: 'c', default: false });
const wrapper = mockAppRoot().withPermission('archive-room').build();
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
expect(result.current.canArchiveOrUnarchive).toBe(true);
});
it('should reflect canArchiveOrUnarchive when user has unarchive-room permission', () => {
const room = createFakeRoom({ t: 'c', default: false });
const wrapper = mockAppRoot().withPermission('unarchive-room').build();
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
expect(result.current.canArchiveOrUnarchive).toBe(true);
});
it('should return false for canArchiveOrUnarchive when user has neither permission', () => {
const room = createFakeRoom({ t: 'c', default: false });
const wrapper = mockAppRoot().build();
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
expect(result.current.canArchiveOrUnarchive).toBe(false);
});
});
});

View File

@@ -0,0 +1,86 @@
import type { IRoom, IRoomWithRetentionPolicy } from '@rocket.chat/core-typings';
import { usePermission, useAtLeastOnePermission, useRole } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { RoomSettingsEnum } from '../../../../../../definition/IRoomTypeConfig';
import { useTeamInfoQuery } from '../../../../../hooks/useTeamInfoQuery';
import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator';
const getCanChangeType = (room: IRoom | IRoomWithRetentionPolicy, canCreateChannel: boolean, canCreateGroup: boolean, isAdmin: boolean) =>
(!room.default || isAdmin) && ((room.t === 'p' && canCreateChannel) || (room.t === 'c' && canCreateGroup));
export const useEditRoomPermissions = (room: IRoom | IRoomWithRetentionPolicy) => {
const isAdmin = useRole('admin');
const canCreateChannel = usePermission('create-c');
const canCreateGroup = usePermission('create-p');
const teamId = room.teamId || '';
const teamMain = room.teamMain || '';
const { data: teamInfo } = useTeamInfoQuery(teamId);
const canCreateTeamChannel = usePermission('create-team-channel', teamInfo?.roomId);
const canCreateTeamGroup = usePermission('create-team-group', teamInfo?.roomId);
const canChangeType = getCanChangeType(
room,
teamId && !teamMain ? canCreateTeamChannel : canCreateChannel,
teamId && !teamMain ? canCreateTeamGroup : canCreateGroup,
isAdmin,
);
const canSetReadOnly = usePermission('set-readonly', room._id);
const canSetReactWhenReadOnly = usePermission('set-react-when-readonly', room._id);
const canEditRoomRetentionPolicy = usePermission('edit-room-retention-policy', room._id);
const canArchiveOrUnarchive = useAtLeastOnePermission(
useMemo(() => ['archive-room', 'unarchive-room'], []),
room._id,
);
const [
canViewName,
canViewTopic,
canViewAnnouncement,
canViewArchived,
canViewDescription,
canViewType,
canViewReadOnly,
canViewHideSysMes,
canViewJoinCode,
canViewReactWhenReadOnly,
] = useMemo(() => {
const isAllowed =
roomCoordinator.getRoomDirectives(room.t)?.allowRoomSettingChange ||
(() => {
undefined;
});
return [
isAllowed(room, RoomSettingsEnum.NAME),
isAllowed(room, RoomSettingsEnum.TOPIC),
isAllowed(room, RoomSettingsEnum.ANNOUNCEMENT),
isAllowed(room, RoomSettingsEnum.ARCHIVE_OR_UNARCHIVE),
isAllowed(room, RoomSettingsEnum.DESCRIPTION),
isAllowed(room, RoomSettingsEnum.TYPE),
isAllowed(room, RoomSettingsEnum.READ_ONLY),
isAllowed(room, RoomSettingsEnum.SYSTEM_MESSAGES),
isAllowed(room, RoomSettingsEnum.JOIN_CODE),
isAllowed(room, RoomSettingsEnum.REACT_WHEN_READ_ONLY),
];
}, [room]);
return {
canChangeType,
canSetReadOnly,
canSetReactWhenReadOnly,
canEditRoomRetentionPolicy,
canArchiveOrUnarchive,
canViewName,
canViewTopic,
canViewAnnouncement,
canViewArchived,
canViewDescription,
canViewType,
canViewReadOnly,
canViewHideSysMes,
canViewJoinCode,
canViewReactWhenReadOnly,
};
};