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,28 @@
import { Box } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
export type ChannelToTeamConfirmationProps = {
onCancel: () => void;
onConfirm: () => void;
};
const ChannelToTeamConfirmation = ({ onCancel, onConfirm }: ChannelToTeamConfirmationProps) => {
const { t } = useTranslation();
return (
<GenericModal
variant='warning'
icon='warning'
title={t('Confirmation')}
confirmText={t('Yes')}
onClose={onCancel}
onCancel={onCancel}
onConfirm={onConfirm}
>
<Box>{t('Teams_move_channel_to_team_confirm_description')}</Box>
</GenericModal>
);
};
export default ChannelToTeamConfirmation;

View File

@@ -0,0 +1,41 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useState } from 'react';
import ChannelToTeamConfirmation from './ChannelToTeamConfirmation';
import ChannelToTeamSelection from './ChannelToTeamSelection';
export type ChannelToTeamModalProps = {
onCancel: () => void;
onConfirm: (teamId: IRoom['_id']) => void;
};
const CHANNEL_TO_TEAM_STEPS = {
SELECTION: 'selection',
CONFIRMATION: 'confirmation',
};
const ChannelToTeamModal = ({ onCancel, onConfirm }: ChannelToTeamModalProps) => {
const [step, setStep] = useState(CHANNEL_TO_TEAM_STEPS.SELECTION);
const [teamId, setTeamId] = useState<string>();
if (step === CHANNEL_TO_TEAM_STEPS.CONFIRMATION && teamId) {
return <ChannelToTeamConfirmation onCancel={onCancel} onConfirm={() => onConfirm(teamId)} />;
}
const handleChange = (value: string | string[]) => {
if (typeof value === 'string') {
setTeamId(value);
}
};
return (
<ChannelToTeamSelection
onCancel={onCancel}
onConfirm={() => setStep(CHANNEL_TO_TEAM_STEPS.CONFIRMATION)}
onChange={handleChange}
teamId={teamId}
/>
);
};
export default ChannelToTeamModal;

View File

@@ -0,0 +1,43 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { Box, Margins } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import TeamAutocomplete from '../../../../teams/contextualBar/TeamAutocomplete';
export type ChannelToTeamSelectionProps = {
teamId: IRoom['teamId'];
onChange: (value: string | string[]) => void;
onCancel: () => void;
onConfirm: () => void;
};
const ChannelToTeamSelection = ({ teamId, onCancel, onChange, onConfirm }: ChannelToTeamSelectionProps) => {
const { t } = useTranslation();
return (
<GenericModal
variant='warning'
cancelText={t('Cancel')}
confirmText={t('Continue')}
title={t('Teams_Select_a_team')}
onClose={onCancel}
onCancel={onCancel}
onConfirm={onConfirm}
confirmDisabled={!teamId}
>
<Margins blockEnd={20}>
<Box>{t('Teams_move_channel_to_team_description_first')}</Box>
<Box>{t('Teams_move_channel_to_team_description_second')}</Box>
<Box>{t('Teams_move_channel_to_team_description_third')}</Box>
<Box>{t('Teams_move_channel_to_team_description_fourth')}</Box>
</Margins>
<Box display='flex' width='100%'>
<TeamAutocomplete onChange={onChange} value={teamId} />
</Box>
</GenericModal>
);
};
export default ChannelToTeamSelection;

View File

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

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,
};
};

View File

@@ -0,0 +1,79 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import { axe } from 'jest-axe';
import RoomInfoABACSection from './RoomInfoABACSection';
import { createFakeRoom } from '../../../../../../../tests/mocks/data';
type RoomWithABAC = IRoom & {
abacAttributes?: {
key: string;
values: string[];
}[];
};
describe('RoomInfoABACSection', () => {
const createRoomWithABAC = (attributes: { key: string; values: string[] }[]): RoomWithABAC => {
const room = createFakeRoom();
return {
...room,
abacAttributes: attributes,
} as RoomWithABAC;
};
const appRootWithABACEnabled = mockAppRoot().withSetting('ABAC_Enabled', true).withSetting('ABAC_ShowAttributesInRooms', true).build();
describe('Conditional rendering', () => {
it('should return null when ABAC_Enabled is false', () => {
const room = createRoomWithABAC([{ key: 'Test', values: ['Value1'] }]);
const appRoot = mockAppRoot().withSetting('ABAC_Enabled', false).withSetting('ABAC_ShowAttributesInRooms', true).build();
render(<RoomInfoABACSection room={room} />, { wrapper: appRoot });
expect(screen.queryByText('ABAC_Managed')).not.toBeInTheDocument();
});
it('should return null when ABAC_ShowAttributesInRooms is false', () => {
const room = createRoomWithABAC([{ key: 'Test', values: ['Value1'] }]);
const appRoot = mockAppRoot().withSetting('ABAC_Enabled', true).withSetting('ABAC_ShowAttributesInRooms', false).build();
render(<RoomInfoABACSection room={room} />, { wrapper: appRoot });
expect(screen.queryByText('ABAC_Managed')).not.toBeInTheDocument();
});
it('should return null when abacAttributes is empty', () => {
const room = createRoomWithABAC([]);
render(<RoomInfoABACSection room={room} />, { wrapper: appRootWithABACEnabled });
expect(screen.queryByText('ABAC_Managed')).not.toBeInTheDocument();
});
it('should render when all conditions are met', () => {
const room = createRoomWithABAC([{ key: 'Test', values: ['Value1'] }]);
render(<RoomInfoABACSection room={room} />, { wrapper: appRootWithABACEnabled });
expect(screen.getByText('ABAC_Managed')).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have no accessibility violations', async () => {
const room = createRoomWithABAC([
{ key: 'Chat-sensitivity', values: ['Classified', 'Top-Secret'] },
{ key: 'Country', values: ['US-only'] },
]);
const { container } = render(<RoomInfoABACSection room={room} />, { wrapper: appRootWithABACEnabled });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
describe('Snapshot', () => {
it('should match the snapshot', () => {
const room = createRoomWithABAC([
{ key: 'Chat-sensitivity', values: ['Classified', 'Top-Secret'] },
{ key: 'Country', values: ['US-only'] },
]);
const { baseElement } = render(<RoomInfoABACSection room={room} />, { wrapper: appRootWithABACEnabled });
expect(baseElement).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,72 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { Box, Divider, Tag } from '@rocket.chat/fuselage';
import { InfoPanelField, InfoPanelLabel } from '@rocket.chat/ui-client';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { RoomIcon } from '../../../../../../components/RoomIcon';
// TODO: Remove type union when ABAC is implemented
export type RoomInfoABACSectionProps = {
room: IRoom & {
abacAttributes?: {
key: string;
values: string[];
}[];
};
};
const RoomInfoABACSection = ({ room }: RoomInfoABACSectionProps) => {
const { t } = useTranslation();
const abacEnabled = useSetting('ABAC_Enabled');
const showAttributesInRoom = useSetting('ABAC_ShowAttributesInRooms');
if (!abacEnabled || !showAttributesInRoom || !room.abacAttributes?.length) {
return null;
}
return (
<>
<Divider marginBlock={32} width='full' />
<InfoPanelField>
<Box display='flex' marginBlockEnd={16}>
<Tag medium>
<Box display='flex' alignItems='center'>
<RoomIcon room={room} />
<Box marginInlineStart={2}>{t('ABAC_Managed')}</Box>
</Box>
</Tag>
</Box>
{t('ABAC_Managed_description')}
<InfoPanelLabel id='room-attributes-list-label'>{t('ABAC_Room_Attributes')}</InfoPanelLabel>
<Box is='ul' aria-labelledby='room-attributes-list-label'>
{room.abacAttributes.map((attribute) => (
<Box is='li' key={attribute.key} marginBlock={16}>
<Box is='span' id={`room-attribute-${attribute.key}-label`}>
{attribute.key}
</Box>
<Box
is='ul'
display='flex'
marginBlockStart={8}
alignItems='center'
aria-labelledby={`room-attribute-${attribute.key}-label`}
>
{attribute.values.map((value) => (
<Box is='li' marginInlineEnd={4} key={value}>
<Tag medium>{value}</Tag>
</Box>
))}
</Box>
</Box>
))}
</Box>
</InfoPanelField>
</>
);
};
export default RoomInfoABACSection;

View File

@@ -0,0 +1,123 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RoomInfoABACSection Snapshot should match the snapshot 1`] = `
<body>
<div>
<hr
class="rcx-box rcx-box--full rcx-divider rcx-css-1972bnv"
/>
<div
class="rcx-box rcx-box--full rcx-css-1jagyun"
>
<div
class="rcx-box rcx-box--full rcx-css-ukxwz6"
>
<span
class="rcx-box rcx-box--full rcx-tag rcx-tag--medium"
>
<span
class="rcx-tag__inner"
>
<div
class="rcx-box rcx-box--full rcx-css-127j9mz"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-hash-shield rcx-icon rcx-css-1g87xs3"
>
</i>
<div
class="rcx-box rcx-box--full rcx-css-1hq8wl6"
>
ABAC_Managed
</div>
</div>
</span>
</span>
</div>
ABAC_Managed_description
<div
class="rcx-box rcx-box--full rcx-css-1po16l8"
id="room-attributes-list-label"
>
ABAC_Room_Attributes
</div>
<ul
aria-labelledby="room-attributes-list-label"
class="rcx-box rcx-box--full"
>
<li
class="rcx-box rcx-box--full rcx-css-1jagyun"
>
<span
class="rcx-box rcx-box--full"
id="room-attribute-Chat-sensitivity-label"
>
Chat-sensitivity
</span>
<ul
aria-labelledby="room-attribute-Chat-sensitivity-label"
class="rcx-box rcx-box--full rcx-css-q18nro"
>
<li
class="rcx-box rcx-box--full rcx-css-sdt442"
>
<span
class="rcx-box rcx-box--full rcx-tag rcx-tag--medium"
>
<span
class="rcx-tag__inner"
>
Classified
</span>
</span>
</li>
<li
class="rcx-box rcx-box--full rcx-css-sdt442"
>
<span
class="rcx-box rcx-box--full rcx-tag rcx-tag--medium"
>
<span
class="rcx-tag__inner"
>
Top-Secret
</span>
</span>
</li>
</ul>
</li>
<li
class="rcx-box rcx-box--full rcx-css-1jagyun"
>
<span
class="rcx-box rcx-box--full"
id="room-attribute-Country-label"
>
Country
</span>
<ul
aria-labelledby="room-attribute-Country-label"
class="rcx-box rcx-box--full rcx-css-q18nro"
>
<li
class="rcx-box rcx-box--full rcx-css-sdt442"
>
<span
class="rcx-box rcx-box--full rcx-tag rcx-tag--medium"
>
<span
class="rcx-tag__inner"
>
US-only
</span>
</span>
</li>
</ul>
</li>
</ul>
</div>
</div>
</body>
`;

View File

@@ -0,0 +1,95 @@
import type { RoomType } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { Contextualbar } from '@rocket.chat/ui-client';
import type { Meta, StoryObj } from '@storybook/react';
import RoomInfo from './RoomInfo';
import FakeRoomProvider from '../../../../../../tests/mocks/client/FakeRoomProvider';
export default {
component: RoomInfo,
parameters: {
layout: 'fullscreen',
actions: { argTypesRegex: '^on[A-Z].*' },
},
decorators: [
(fn) => (
<FakeRoomProvider roomOverrides={roomArgs}>
<Contextualbar height='100vh'>{fn()}</Contextualbar>
</FakeRoomProvider>
),
],
args: {
icon: 'lock',
},
} satisfies Meta<typeof RoomInfo>;
const roomArgs = {
_id: 'myRoom',
t: 'c' as RoomType,
msgs: 5,
usersCount: 1,
u: {
_id: 'rocketchat.internal.admin.test',
username: 'rocketchat.internal.admin.test',
},
ts: new Date(),
autoTranslateLanguage: 'en',
_updatedAt: new Date(),
fname: 'rocketchat-frontend-team',
description:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam mollis nisi vel arcu bibendum vehicula. Integer vitae suscipit libero',
announcement:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam mollis nisi vel arcu bibendum vehicula. Integer vitae suscipit libero',
topic:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam mollis nisi vel arcu bibendum vehicula. Integer vitae suscipit libero',
};
export const Default = {
args: {
room: roomArgs,
},
};
export const Archived = {
args: {
...Default.args,
room: {
...roomArgs,
archived: true,
},
},
};
export const Broadcast = {
args: {
...Default.args,
room: {
...roomArgs,
broadcast: true,
},
},
};
export const ABAC: StoryObj<typeof RoomInfo> = {
decorators: [
mockAppRoot().withSetting('ABAC_Enabled', true).withSetting('ABAC_ShowAttributesInRooms', true).buildStoryDecorator(),
(fn) => (
<FakeRoomProvider roomOverrides={roomArgs}>
<Contextualbar height='100vh'>{fn()}</Contextualbar>
</FakeRoomProvider>
),
],
args: {
...Default.args,
room: {
...roomArgs,
abacAttributes: [
{ key: 'Chat-sensitivity', values: ['Classified', 'Top-Secret'] },
{ key: 'Country', values: ['US-only'] },
{ key: 'Project', values: ['Ruminator-2000'] },
],
},
},
};

View File

@@ -0,0 +1,138 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { Box, Callout, IconButton } from '@rocket.chat/fuselage';
import { RoomAvatar } from '@rocket.chat/ui-avatar';
import {
GenericMenu,
ContextualbarHeader,
ContextualbarScrollableContent,
ContextualbarBack,
ContextualbarIcon,
ContextualbarClose,
ContextualbarTitle,
ContextualbarDialog,
InfoPanel,
InfoPanelActionGroup,
InfoPanelAvatar,
InfoPanelField,
InfoPanelLabel,
InfoPanelSection,
InfoPanelText,
InfoPanelTitle,
} from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import RoomInfoActions from './RoomInfoActions';
import RetentionPolicyCallout from '../../../../../components/InfoPanel/RetentionPolicyCallout';
import MarkdownText from '../../../../../components/MarkdownText';
import { useRetentionPolicy } from '../../../hooks/useRetentionPolicy';
import { useRoomActions } from '../hooks/useRoomActions';
import { useSplitRoomActions } from '../hooks/useSplitRoomActions';
import RoomInfoABACSection from './ABAC/RoomInfoABACSection';
export type RoomInfoProps = {
room: IRoom;
icon: string;
onClickBack?: () => void;
onClickClose?: () => void;
onClickEnterRoom?: () => void;
onClickEdit?: () => void;
resetState?: () => void;
};
const RoomInfo = ({ room, icon, onClickBack, onClickClose, onClickEnterRoom, onClickEdit, resetState }: RoomInfoProps) => {
const { t } = useTranslation();
const { name, fname, description, topic, archived, broadcast, announcement } = room;
const roomTitle = fname || name;
const isDiscussion = 'prid' in room;
const retentionPolicy = useRetentionPolicy(room);
const actions = useRoomActions(room, { onClickEnterRoom, onClickEdit, resetState });
const { buttons, menu } = useSplitRoomActions(actions);
return (
<ContextualbarDialog>
<ContextualbarHeader>
{onClickBack ? <ContextualbarBack onClick={onClickBack} /> : <ContextualbarIcon name='info-circled' />}
<ContextualbarTitle>{isDiscussion ? t('Discussion_info') : t('Channel_info')}</ContextualbarTitle>
{onClickClose && <ContextualbarClose onClick={onClickClose} />}
</ContextualbarHeader>
<ContextualbarScrollableContent padding={24}>
<InfoPanel>
<InfoPanelSection maxWidth='x332' marginInline='auto'>
<InfoPanelAvatar>
<RoomAvatar size='x332' room={room} />
</InfoPanelAvatar>
<InfoPanelActionGroup>
<RoomInfoActions actions={buttons} />
{menu && (
<GenericMenu
title={t('More')}
placement='bottom-end'
button={<IconButton icon='kebab' secondary flexShrink={0} flexGrow={0} maxHeight='initial' />}
sections={menu}
/>
)}
</InfoPanelActionGroup>
</InfoPanelSection>
{archived && (
<InfoPanelSection>
<Box marginBlock={16}>
<Callout type='warning'>{t('Room_archived')}</Callout>
</Box>
</InfoPanelSection>
)}
{roomTitle && (
<InfoPanelSection>
<InfoPanelTitle title={roomTitle} icon={icon} />
</InfoPanelSection>
)}
<InfoPanelSection>
{broadcast && (
<InfoPanelField>
<InfoPanelLabel>
<b>{t('Broadcast_channel')}</b> {t('Broadcast_channel_Description')}
</InfoPanelLabel>
</InfoPanelField>
)}
{description && description !== '' && (
<InfoPanelField>
<InfoPanelLabel>{t('Description')}</InfoPanelLabel>
<InfoPanelText withTruncatedText={false}>
<MarkdownText variant='inline' content={description} />
</InfoPanelText>
</InfoPanelField>
)}
{announcement && announcement !== '' && (
<InfoPanelField>
<InfoPanelLabel>{t('Announcement')}</InfoPanelLabel>
<InfoPanelText withTruncatedText={false}>
<MarkdownText variant='inline' content={announcement} />
</InfoPanelText>
</InfoPanelField>
)}
{topic && topic !== '' && (
<InfoPanelField>
<InfoPanelLabel>{t('Topic')}</InfoPanelLabel>
<InfoPanelText withTruncatedText={false}>
<MarkdownText variant='inline' content={topic} />
</InfoPanelText>
</InfoPanelField>
)}
{retentionPolicy?.isActive && <RetentionPolicyCallout room={room} />}
<RoomInfoABACSection room={room} />
</InfoPanelSection>
</InfoPanel>
</ContextualbarScrollableContent>
</ContextualbarDialog>
);
};
export default RoomInfo;

View File

@@ -0,0 +1,27 @@
import type { Keys as IconKeys } from '@rocket.chat/icons';
import { InfoPanelAction } from '@rocket.chat/ui-client';
type Action = {
id: string;
content: string;
icon: IconKeys;
onClick: () => void;
variant?: string;
};
export type RoomInfoActionsProps = {
actions: { items: Action[] };
className?: string;
};
const RoomInfoActions = ({ actions, className }: RoomInfoActionsProps) => {
return (
<>
{actions.items.map(({ id, content, icon, onClick }) => (
<InfoPanelAction className={className} key={id} label={content} onClick={onClick} icon={icon} />
))}
</>
);
};
export default RoomInfoActions;

View File

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

View File

@@ -0,0 +1,45 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useRoomToolbox } from '@rocket.chat/ui-contexts';
import { useState } from 'react';
import EditRoomInfoWithData from './EditRoomInfo';
import RoomInfo from './RoomInfo';
import { useCanEditRoom } from './hooks/useCanEditRoom';
import { useRoom } from '../../contexts/RoomContext';
export type RoomInfoRouterProps = {
onClickBack?: () => void;
onEnterRoom?: (room: IRoom) => void;
resetState?: () => void;
};
const RoomInfoRouter = ({ onClickBack, onEnterRoom, resetState }: RoomInfoRouterProps) => {
const [isEditing, setIsEditing] = useState(false);
const { closeTab } = useRoomToolbox();
const room = useRoom();
const canEdit = useCanEditRoom(room);
const onClickEnterRoom = useStableCallback(() => onEnterRoom?.(room));
if (isEditing) {
return <EditRoomInfoWithData onClickBack={() => setIsEditing(false)} />;
}
return (
<RoomInfo
room={room}
icon={room.t === 'p' ? 'lock' : 'hashtag'}
onClickBack={onClickBack}
onClickEdit={canEdit ? () => setIsEditing(true) : undefined}
onClickClose={closeTab}
{...(Boolean(onEnterRoom) && {
onClickEnterRoom,
})}
resetState={resetState}
/>
);
};
export default RoomInfoRouter;

View File

@@ -0,0 +1,48 @@
import { isRoomFederated } from '@rocket.chat/core-typings';
import type { IRoom } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericModal } from '@rocket.chat/ui-client';
import { useSetModal, useToastMessageDispatch, useTranslation, useEndpoint, usePermission } from '@rocket.chat/ui-contexts';
import { useCanEditRoom } from '../useCanEditRoom';
export const useRoomConvertToTeam = (room: IRoom) => {
const t = useTranslation();
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const canEdit = useCanEditRoom(room);
const hasPermissionToConvertRoomToTeam = usePermission('create-team');
const canConvertRoomToTeam = isRoomFederated(room) ? false : hasPermissionToConvertRoomToTeam;
const allowConvertToTeam = !room.teamId && !room.prid && canConvertRoomToTeam && canEdit;
const convertRoomToTeam = useEndpoint('POST', room.t === 'c' ? '/v1/channels.convertToTeam' : '/v1/groups.convertToTeam');
const handleConvertToTeam = useStableCallback(async () => {
const onConfirm = async () => {
try {
await convertRoomToTeam(room.t === 'c' ? { channelId: room._id } : { roomId: room._id });
dispatchToastMessage({ type: 'success', message: t('Room_has_been_converted') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
} finally {
setModal(null);
}
};
setModal(
<GenericModal
title={t('Confirmation')}
variant='warning'
onClose={() => setModal(null)}
onCancel={() => setModal(null)}
onConfirm={onConfirm}
confirmText={t('Convert')}
>
{t('Converting_channel_to_a_team')}
</GenericModal>,
);
});
return allowConvertToTeam ? handleConvertToTeam : null;
};

View File

@@ -0,0 +1,39 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook } from '@testing-library/react';
import { useRoomLeave } from './useRoomLeave';
import { createFakeRoom, createFakeSubscription } from '../../../../../../../tests/mocks/data';
const mockRoom = createFakeRoom({ _id: 'room1', t: 'c', name: 'room1', fname: 'Room 1' });
const mockSubscription = createFakeSubscription({ name: 'room1', t: 'c', disableNotifications: false, rid: 'room1' });
jest.mock('../../../../../../../client/lib/LegacyRoomManager', () => ({
LegacyRoomManager: {
close: jest.fn(),
},
}));
jest.mock('../../../../../../../client/lib/rooms/roomCoordinator', () => ({
roomCoordinator: {
getRoomDirectives: () => ({
getUiText: () => 'leaveWarning',
}),
},
}));
it('should return leave function if user has subscription', () => {
const wrapper = mockAppRoot()
.withPermission('leave-c')
.withSubscription({ ...mockSubscription, rid: 'room1' })
.build();
const { result } = renderHook(() => useRoomLeave(mockRoom), { wrapper });
expect(typeof result.current).toBe('function');
});
it('should return null if user does not have subscription', () => {
const wrapper = mockAppRoot().withPermission('leave-c').build();
const { result } = renderHook(() => useRoomLeave(mockRoom), { wrapper });
expect(result.current).toBeNull();
});

View File

@@ -0,0 +1,59 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useRouter, useSetModal, useToastMessageDispatch, useEndpoint, usePermission, useUserSubscription } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { UiTextContext } from '../../../../../../../definition/IRoomTypeConfig';
import WarningModal from '../../../../../../components/WarningModal';
import { LegacyRoomManager } from '../../../../../../lib/LegacyRoomManager';
import { roomCoordinator } from '../../../../../../lib/rooms/roomCoordinator';
export const useRoomLeave = (room: IRoom) => {
const { t } = useTranslation();
const subscription = useUserSubscription(room._id);
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const leaveChannel = useEndpoint('POST', '/v1/channels.leave');
const leaveGroup = useEndpoint('POST', '/v1/groups.leave');
const leaveDirect = useEndpoint('POST', '/v1/im.leave');
const router = useRouter();
const canLeave = usePermission(room.t === 'c' ? 'leave-c' : 'leave-p') && room.cl !== false && Boolean(subscription);
const handleLeave = useStableCallback(() => {
const leaveAction = async () => {
try {
if (room.t === 'c') {
await leaveChannel({ roomId: room._id });
} else if (room.t === 'p') {
await leaveGroup({ roomId: room._id });
} else if (room.t === 'd') {
await leaveDirect({ roomId: room._id });
}
router.navigate('/home');
if (room.name) {
LegacyRoomManager.close(`${room.t}${room.name}`);
}
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
setModal(null);
};
const warnText = roomCoordinator.getRoomDirectives(room.t).getUiText(UiTextContext.LEAVE_WARNING);
setModal(
<WarningModal
text={t(warnText as TranslationKey, { roomName: room.fname || room.name })}
confirmText={t('Leave_room')}
close={() => setModal(null)}
cancelText={t('Cancel')}
confirm={leaveAction}
/>,
);
});
return canLeave ? handleLeave : null;
};

View File

@@ -0,0 +1,39 @@
import { isRoomFederated } from '@rocket.chat/core-typings';
import type { IRoom } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useSetModal, useToastMessageDispatch, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
import ChannelToTeamModal from '../../ChannelToTeamModal';
import { useCanEditRoom } from '../useCanEditRoom';
export const useRoomMoveToTeam = (room: IRoom) => {
const t = useTranslation();
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const canEdit = useCanEditRoom(room);
const canMoveToTeam = !isRoomFederated(room) && !room.teamId && !room.prid && canEdit;
const moveChannelToTeam = useEndpoint('POST', '/v1/teams.addRooms');
const handleMoveToTeam = useStableCallback(async () => {
const onConfirm = async (teamId: IRoom['teamId']) => {
if (!teamId) {
throw new Error('teamId not provided');
}
try {
await moveChannelToTeam({ rooms: [room._id], teamId });
dispatchToastMessage({ type: 'success', message: t('Rooms_added_successfully') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
} finally {
setModal(null);
}
};
setModal(<ChannelToTeamModal onCancel={() => setModal(null)} onConfirm={onConfirm} />);
});
return canMoveToTeam ? handleMoveToTeam : null;
};

View File

@@ -0,0 +1,18 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { isRoomFederated } from '@rocket.chat/core-typings';
import { usePermission, useUser } from '@rocket.chat/ui-contexts';
import * as Federation from '../../../../../lib/federation/Federation';
import { useRoomSubscription } from '../../../contexts/RoomContext';
export const useCanEditRoom = (room: IRoom) => {
const user = useUser();
const subscription = useRoomSubscription();
const hasPermissionToEdit = usePermission('edit-room', room._id);
const isFederated = isRoomFederated(room);
const canEdit =
user && isFederated ? Federation.isEditableByTheUser(user, room, subscription) && hasPermissionToEdit : hasPermissionToEdit;
return canEdit;
};

View File

@@ -0,0 +1,104 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useRoomConvertToTeam } from './actions/useRoomConvertToTeam';
import { useRoomLeave } from './actions/useRoomLeave';
import { useRoomMoveToTeam } from './actions/useRoomMoveToTeam';
import { useHideRoomAction } from '../../../../../hooks/useHideRoomAction';
import { useDeleteRoom } from '../../../../hooks/roomActions/useDeleteRoom';
type UseRoomActionsOptions = {
onClickEnterRoom?: () => void;
onClickEdit?: () => void;
resetState?: () => void;
};
export const useRoomActions = (room: IRoom, options: UseRoomActionsOptions) => {
const { onClickEnterRoom, onClickEdit, resetState } = options;
const { t } = useTranslation();
const handleLeave = useRoomLeave(room);
const { handleDelete, canDeleteRoom } = useDeleteRoom(room, { reload: resetState });
const handleMoveToTeam = useRoomMoveToTeam(room);
const handleConvertToTeam = useRoomConvertToTeam(room);
const handleHide = useHideRoomAction({ rid: room._id, type: room.t, name: room.name ?? '' });
return useMemo(() => {
const memoizedActions = {
items: [
{
id: 'hide',
content: t('Hide'),
icon: 'eye-off' as const,
onClick: handleHide,
},
...(onClickEnterRoom
? [
{
id: 'enter',
content: t('Enter'),
icon: 'login' as const,
onClick: onClickEnterRoom,
},
]
: []),
...(onClickEdit
? [
{
id: 'edit',
content: t('Edit'),
icon: 'edit' as const,
onClick: onClickEdit,
},
]
: []),
...(handleLeave
? [
{
id: 'leave',
content: t('Leave'),
icon: 'sign-out' as const,
onClick: handleLeave,
},
]
: []),
...(handleMoveToTeam
? [
{
id: 'move_channel_team',
content: t('Teams_move_channel_to_team'),
icon: 'team-arrow-right' as const,
onClick: handleMoveToTeam,
},
]
: []),
...(handleConvertToTeam
? [
{
id: 'convert_channel_team',
content: t('Teams_convert_channel_to_team'),
icon: 'team' as const,
onClick: handleConvertToTeam,
},
]
: []),
...(canDeleteRoom
? [
{
id: 'delete',
content: t('Delete'),
icon: 'trash' as const,
onClick: handleDelete,
variant: 'danger',
},
]
: []),
],
};
return memoizedActions;
}, [canDeleteRoom, handleConvertToTeam, handleDelete, handleHide, handleLeave, handleMoveToTeam, onClickEdit, onClickEnterRoom, t]);
};

View File

@@ -0,0 +1,28 @@
import type { RoomInfoActionsProps } from '../RoomInfo/RoomInfoActions';
type UseSplitRoomActionsOptions = {
size?: number;
};
/**
*
* @param room
* @param options
* @returns If more than two room actions are enabled `menu.regular` will be a non-empty array
*/
export const useSplitRoomActions = (actions: RoomInfoActionsProps['actions'], options?: UseSplitRoomActionsOptions) => {
const size = options?.size || 2;
if (actions.items.length <= size) {
return { buttons: actions };
}
const buttons = { items: actions.items.slice(0, size) };
const regular = actions.items.slice(size);
const firstDanger = regular.findIndex((item) => item.variant);
const danger = regular.splice(firstDanger);
const menu = [{ items: regular }, { items: danger }];
return { buttons, menu };
};

View File

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