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
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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ChannelToTeamModal';
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './EditRoomInfoWithData';
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './RoomInfo';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './RoomInfoRouter';
|
||||
Reference in New Issue
Block a user