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,88 @@
import type { IRoom, Serialized } from '@rocket.chat/core-typings';
import { Box, CheckBox } from '@rocket.chat/fuselage';
import { GenericTable, GenericTableHeaderCell, GenericTableHeader, GenericTableBody, useSort } from '@rocket.chat/ui-client';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import ChannelDesertionTableRow from './ChannelDesertionTableRow';
export type ChannelDesertionTableProps = {
lastOwnerWarning?: string;
rooms?: (Serialized<IRoom> & { isLastOwner?: boolean })[];
eligibleRoomsLength: number | undefined;
onChangeRoomSelection: (room: Serialized<IRoom>) => void;
selectedRooms: { [key: string]: Serialized<IRoom> };
onToggleAllRooms: () => void;
};
const ChannelDesertionTable = ({
rooms,
eligibleRoomsLength,
onChangeRoomSelection,
selectedRooms,
onToggleAllRooms,
lastOwnerWarning,
}: ChannelDesertionTableProps) => {
const { sortBy, sortDirection, setSort } = useSort<'name' | 'ts'>('name');
const { t } = useTranslation();
const selectedRoomsLength = Object.values(selectedRooms).filter(Boolean).length;
const checked = eligibleRoomsLength === selectedRoomsLength;
const indeterminate = eligibleRoomsLength && eligibleRoomsLength > selectedRoomsLength ? selectedRoomsLength > 0 : false;
const results = useMemo(() => {
if (!rooms) {
return [];
}
const direction = sortDirection === 'asc' ? 1 : -1;
return rooms.toSorted((a, b) => {
const aValue = a[sortBy] ?? '';
const bValue = b[sortBy] ?? '';
if (!aValue && !bValue) {
return 0;
}
if (!aValue) {
return 1;
}
if (!bValue) {
return -1;
}
return aValue.localeCompare(bValue) * direction;
});
}, [rooms, sortBy, sortDirection]);
return (
<Box display='flex' flexDirection='column' height='x200' marginBlockStart={24}>
<GenericTable fixed={false}>
<GenericTableHeader>
<GenericTableHeaderCell key='name' sort='name' onClick={setSort} direction={sortDirection} active={sortBy === 'name'}>
<CheckBox indeterminate={indeterminate} checked={checked} onChange={onToggleAllRooms} />
<Box marginInline={8}>{t('Channel_name')}</Box>
</GenericTableHeaderCell>
<GenericTableHeaderCell key='ts' sort='ts' onClick={setSort} direction={sortDirection} active={sortBy === 'ts'}>
<Box width='100%' textAlign='end'>
{t('Joined_at')}
</Box>
</GenericTableHeaderCell>
</GenericTableHeader>
<GenericTableBody>
{results?.map((room, key) => (
<ChannelDesertionTableRow
key={key}
room={room}
onChange={onChangeRoomSelection}
selected={'_id' in room && room._id ? !!selectedRooms[room._id] : false}
lastOwnerWarning={lastOwnerWarning}
/>
))}
</GenericTableBody>
</GenericTable>
</Box>
);
};
export default ChannelDesertionTable;

View File

@@ -0,0 +1,41 @@
import type { IRoom, Serialized } from '@rocket.chat/core-typings';
import { Box, CheckBox, Icon, Margins } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericTableRow, GenericTableCell } from '@rocket.chat/ui-client';
import { useId } from 'react';
import { useFormatDateAndTime } from '../../../hooks/useFormatDateAndTime';
export type ChannelDesertionTableRowProps = {
onChange: (room: Serialized<IRoom> & { isLastOwner?: boolean }) => void;
selected: boolean;
room: Serialized<IRoom> & { isLastOwner?: boolean };
lastOwnerWarning?: string;
};
const ChannelDesertionTableRow = ({ room, onChange, selected, lastOwnerWarning }: ChannelDesertionTableRowProps) => {
const { name, fname, ts, isLastOwner } = room;
const formatDate = useFormatDateAndTime();
const checkboxId = useId();
const handleChange = useStableCallback(() => onChange(room));
return (
<GenericTableRow action>
<GenericTableCell maxWidth='x300' withTruncatedText>
<CheckBox id={checkboxId} checked={selected} onChange={handleChange} disabled={isLastOwner} />
<Box is='label' htmlFor={checkboxId} style={{ cursor: isLastOwner ? undefined : 'pointer' }}>
<Margins inline={8}>
<Icon name={room.t === 'p' ? 'hashtag-lock' : 'hashtag'} />
{fname ?? name}
{isLastOwner && <Icon size='x16' name='info-circled' color='status-font-on-danger' title={lastOwnerWarning} />}
</Margins>
</Box>
</GenericTableCell>
<GenericTableCell align='end' withTruncatedText>
{formatDate(ts)}
</GenericTableCell>
</GenericTableRow>
);
};
export default ChannelDesertionTableRow;

View File

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

View File

@@ -0,0 +1,50 @@
import { AutoComplete, Option, Box } from '@rocket.chat/fuselage';
import { RoomAvatar } from '@rocket.chat/ui-avatar';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { ComponentProps } from 'react';
import { memo, useMemo, useState } from 'react';
export type TeamAutocompleteProps = Omit<ComponentProps<typeof AutoComplete>, 'filter'>;
const TeamAutocomplete = ({ value, onChange, ...props }: TeamAutocompleteProps) => {
const [filter, setFilter] = useState('');
const teamsAutoCompleteEndpoint = useEndpoint('GET', '/v1/teams.autocomplete');
const { data, isSuccess } = useQuery({
queryKey: ['teamsAutoComplete', filter],
queryFn: async () => teamsAutoCompleteEndpoint({ name: filter }),
});
const options = useMemo(
() =>
isSuccess
? data?.teams.map(({ name, teamId, _id, avatarETag, t }) => ({
value: teamId as string,
label: { name, avatarETag, type: t, _id },
}))
: [],
[data, isSuccess],
);
return (
<AutoComplete
{...props}
value={value}
onChange={onChange}
filter={filter}
setFilter={setFilter}
renderSelected={({ selected: { value, label: room } }) => (
<Box value={value}>
<RoomAvatar size='x20' room={room} /> {room.name}
</Box>
)}
renderItem={({ value, label: room, ...props }) => (
<Option key={value} {...props} label={room.name} avatar={<RoomAvatar size='x20' room={room} />} />
)}
options={options}
/>
);
};
export default memo(TeamAutocomplete);

View File

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

View File

@@ -0,0 +1,95 @@
import type { IRoom } from '@rocket.chat/core-typings';
import {
Box,
Button,
Field,
FieldLabel,
Modal,
ModalClose,
ModalContent,
ModalFooter,
ModalFooterControllers,
ModalHeader,
ModalTitle,
} from '@rocket.chat/fuselage';
import { useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts';
import { memo, useCallback } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import RoomsAvailableForTeamsAutoComplete from './RoomsAvailableForTeamsAutoComplete';
type AddExistingModalFormData = {
rooms: IRoom['_id'][];
};
export type AddExistingModalProps = {
teamId: string;
onClose: () => void;
reload?: () => void;
};
// TODO: Use GenericModal instead of Modal
const AddExistingModal = ({ teamId, onClose, reload }: AddExistingModalProps) => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const addRoomEndpoint = useEndpoint('POST', '/v1/teams.addRooms');
const {
control,
formState: { isDirty },
handleSubmit,
} = useForm<AddExistingModalFormData>({ defaultValues: { rooms: [] } });
const handleAddChannels = useCallback(
async ({ rooms }: AddExistingModalFormData) => {
try {
await addRoomEndpoint({
rooms,
teamId,
});
dispatchToastMessage({ type: 'success', message: t('Channels_added') });
reload?.();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
} finally {
onClose();
}
},
[addRoomEndpoint, teamId, onClose, dispatchToastMessage, reload, t],
);
return (
<Modal
aria-label={t('Team_Add_existing_channels')}
wrapperFunction={(props) => <Box is='form' onSubmit={handleSubmit(handleAddChannels)} {...props} />}
>
<ModalHeader>
<ModalTitle>{t('Team_Add_existing_channels')}</ModalTitle>
<ModalClose onClick={onClose} />
</ModalHeader>
<ModalContent>
<Field marginBlockEnd={24}>
<FieldLabel>{t('Channels')}</FieldLabel>
<Controller
control={control}
name='rooms'
render={({ field: { value, onChange } }) => <RoomsAvailableForTeamsAutoComplete value={value} onChange={onChange} />}
/>
</Field>
</ModalContent>
<ModalFooter>
<ModalFooterControllers>
<Button onClick={onClose}>{t('Cancel')}</Button>
<Button disabled={!isDirty} type='submit' primary>
{t('Add')}
</Button>
</ModalFooterControllers>
</ModalFooter>
</Modal>
);
};
export default memo(AddExistingModal);

View File

@@ -0,0 +1,69 @@
import { AutoComplete, Box, Option, Chip } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { RoomAvatar } from '@rocket.chat/ui-avatar';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import type { ComponentProps } from 'react';
import { memo, useMemo, useState } from 'react';
import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator';
export type RoomsAvailableForTeamsAutoCompleteProps = Omit<ComponentProps<typeof AutoComplete>, 'filter'>;
const RoomsAvailableForTeamsAutoComplete = ({ value, onChange, ...props }: RoomsAvailableForTeamsAutoCompleteProps) => {
const [filter, setFilter] = useState('');
const debouncedFilter = useDebouncedValue(filter, 1000);
const roomsAvailableForTeamsAutoCompleteEndpoint = useEndpoint('GET', '/v1/rooms.autocomplete.availableForTeams');
const { data } = useQuery({
queryKey: ['roomsAvailableForTeamsAutoComplete', debouncedFilter],
queryFn: async () => roomsAvailableForTeamsAutoCompleteEndpoint({ name: debouncedFilter }),
placeholderData: keepPreviousData,
});
const options = useMemo(() => {
if (!data) {
return [];
}
return data.items.map((room) => ({
value: room._id,
label: {
name: roomCoordinator.getRoomName(room.t, { _id: room._id, name: room.name, fname: room.fname, prid: room.prid }),
type: room.t,
avatarETag: room.avatarETag,
},
}));
}, [data]);
return (
<AutoComplete
{...props}
multiple
value={value}
onChange={onChange}
filter={filter}
setFilter={setFilter}
renderSelected={({ selected: { value, label }, onRemove }) => (
<Chip key={value} height='x20' value={value} onClick={onRemove} marginBlock={2} marginInlineEnd={4}>
<RoomAvatar size='x20' room={{ ...label, type: label?.type || 'c', _id: value }} />
<Box is='span' margin='none' marginInlineStart={4}>
{label.name}
</Box>
</Chip>
)}
renderItem={({ value, label, ...props }) => (
<Option
key={value}
{...props}
label={label.name}
avatar={<RoomAvatar room={{ _id: value, type: label.type, avatarETag: label.avatarETag }} size='x20' />}
/>
)}
options={options}
/>
);
};
export default memo(RoomsAvailableForTeamsAutoComplete);

View File

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

View File

@@ -0,0 +1,82 @@
import type { IRoom } from '@rocket.chat/core-typings';
import {
Box,
Icon,
IconButton,
Option,
OptionAvatar,
OptionColumn,
OptionContent,
OptionMenu,
OptionSkeleton,
Tag,
} from '@rocket.chat/fuselage';
import { usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks';
import { RoomAvatar } from '@rocket.chat/ui-avatar';
import { usePermission } from '@rocket.chat/ui-contexts';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import TeamsChannelItemMenu from './TeamsChannelItemMenu';
import { usePreventPropagation } from '../../../../hooks/usePreventPropagation';
import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator';
export type TeamsChannelItemProps = {
room: IRoom;
mainRoom: IRoom;
onClickView: (room: IRoom) => void;
reload: () => void;
};
const TeamsChannelItem = ({ room, mainRoom, onClickView, reload }: TeamsChannelItemProps) => {
const { t } = useTranslation();
const rid = room._id;
const type = room.t;
const [showButton, setShowButton] = useState();
const canRemoveTeamChannel = usePermission('remove-team-channel', mainRoom._id);
const canEditTeamChannel = usePermission('edit-team-channel', mainRoom._id);
const canDeleteChannel = usePermission(`delete-${type}`, rid);
const canDeleteTeamChannel = usePermission(`delete-team-${type === 'c' ? 'channel' : 'group'}`, mainRoom._id);
const canDelete = canDeleteChannel && canDeleteTeamChannel;
const isReduceMotionEnabled = usePrefersReducedMotion();
const handleMenuEvent = {
[isReduceMotionEnabled ? 'onMouseEnter' : 'onTransitionEnd']: setShowButton,
};
const onClick = usePreventPropagation();
if (!room) {
return <OptionSkeleton />;
}
return (
<Option id={room._id} data-rid={room._id} {...handleMenuEvent} onClick={() => onClickView(room)}>
<OptionAvatar>
<RoomAvatar room={room} size='x28' />
</OptionAvatar>
<OptionColumn>{room.t === 'c' ? <Icon name='hash' size='x15' /> : <Icon name='hashtag-lock' size='x15' />}</OptionColumn>
<OptionContent>
<Box display='inline-flex' alignItems='center'>
{roomCoordinator.getRoomName(room.t, room)}{' '}
{room.teamDefault ? (
<Box marginInline={4}>
<Tag>{t('Team_Auto-join')}</Tag>
</Box>
) : (
''
)}
</Box>
</OptionContent>
{(canRemoveTeamChannel || canEditTeamChannel || canDelete) && (
<OptionMenu onClick={onClick}>
{showButton ? <TeamsChannelItemMenu room={room} mainRoom={mainRoom} reload={reload} /> : <IconButton tiny icon='kebab' />}
</OptionMenu>
)}
</Option>
);
};
export default TeamsChannelItem;

View File

@@ -0,0 +1,58 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { CheckBox } from '@rocket.chat/fuselage';
import { GenericMenu } from '@rocket.chat/ui-client';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import { useRemoveRoomFromTeam } from './hooks/useRemoveRoomFromTeam';
import { useToggleAutoJoin } from './hooks/useToggleAutoJoin';
import { useDeleteRoom } from '../../../hooks/roomActions/useDeleteRoom';
const TeamsChannelItemMenu = ({ room, mainRoom, reload }: { room: IRoom; mainRoom: IRoom; reload?: () => void }) => {
const { t } = useTranslation();
const { handleRemoveRoom, canRemoveTeamChannel } = useRemoveRoomFromTeam(room, { reload });
const { handleDelete, canDeleteRoom } = useDeleteRoom(room, { reload });
const { handleToggleAutoJoin, canEditTeamChannel } = useToggleAutoJoin(room, { reload, mainRoom });
const toggleAutoJoin = {
id: 'toggleAutoJoin',
icon: room.t === 'c' ? 'hash' : 'hashtag-lock',
content: t('Team_Auto-join'),
onClick: handleToggleAutoJoin,
addon: <CheckBox checked={room.teamDefault} />,
};
const removeRoom = {
id: 'removeRoom',
icon: 'cross',
content: t('Team_Remove_from_team'),
onClick: handleRemoveRoom,
variant: 'danger',
};
const deleteRoom = {
id: 'deleteRoom',
icon: 'trash',
content: t('Delete'),
onClick: handleDelete,
variant: 'danger',
};
return (
<GenericMenu
title={t('More')}
placement='bottom-end'
sections={[
{
title: '',
items: [canEditTeamChannel && toggleAutoJoin, canRemoveTeamChannel && removeRoom, canDeleteRoom && deleteRoom].filter(
Boolean,
) as GenericMenuItemProps[],
},
]}
/>
);
};
export default TeamsChannelItemMenu;

View File

@@ -0,0 +1,58 @@
/* eslint-disable testing-library/no-container */
/* eslint-disable testing-library/no-node-access */
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, waitFor } from '@testing-library/react';
import TeamsChannels from './TeamsChannels';
import { createFakeRoom } from '../../../../../tests/mocks/data';
jest.mock('../../../../lib/rooms/roomCoordinator', () => ({
roomCoordinator: {
getRouteLink: () => undefined,
},
}));
const mainRoom = createFakeRoom({ name: 'Main Room' });
const fakeRooms = Array.from({ length: 10 }, (_, index) => createFakeRoom({ t: 'c', name: `Fake Room ${index}` }));
beforeEach(() => {
Object.defineProperty(window, 'getComputedStyle', {
value: () => {
return {
getPropertyPriority: () => undefined,
getPropertyValue: () => undefined,
};
},
});
});
// TODO: Replace this with the storybook & snapshot
it('should render scrollbars', async () => {
const { container } = render(
<TeamsChannels
text=''
type='all'
reload={() => undefined}
loadMoreItems={() => undefined}
setText={() => undefined}
setType={() => undefined}
onClickClose={() => undefined}
onClickAddExisting={() => undefined}
onClickView={() => undefined}
onClickCreateNew={() => undefined}
total={fakeRooms.length}
loading={false}
mainRoom={mainRoom}
channels={fakeRooms}
/>,
{
wrapper: mockAppRoot().build(),
},
);
await waitFor(() => {
expect(container.querySelector('[data-overlayscrollbars]')).toBeInTheDocument();
});
expect(container.querySelector('[data-overlayscrollbars]')).toBeInTheDocument();
});

View File

@@ -0,0 +1,156 @@
import type { IRoom } from '@rocket.chat/core-typings';
import type { SelectOption } from '@rocket.chat/fuselage';
import { Box, Icon, TextInput, Select, Throbber, ButtonGroup, Button } from '@rocket.chat/fuselage';
import { useStableCallback, useAutoFocus, useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
import {
VirtualizedScrollbars,
ContextualbarHeader,
ContextualbarIcon,
ContextualbarTitle,
ContextualbarClose,
ContextualbarContent,
ContextualbarFooter,
ContextualbarEmptyContent,
ContextualbarSection,
ContextualbarDialog,
} from '@rocket.chat/ui-client';
import type { ChangeEvent, Dispatch, SetStateAction, SyntheticEvent } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Virtuoso } from 'react-virtuoso';
import TeamsChannelItem from './TeamsChannelItem';
import InfiniteListAnchor from '../../../../components/InfiniteListAnchor';
export type TeamsChannelsProps = {
loading: boolean;
channels: IRoom[];
mainRoom: IRoom;
text: string;
type: 'all' | 'autoJoin';
setText: (e: ChangeEvent<HTMLInputElement>) => void;
setType: Dispatch<SetStateAction<'all' | 'autoJoin'>>;
onClickClose: () => void;
onClickAddExisting: false | ((e: SyntheticEvent) => void);
onClickCreateNew: false | ((e: SyntheticEvent) => void);
total: number;
loadMoreItems: () => void;
onClickView: (room: IRoom) => void;
reload: () => void;
};
const TeamsChannels = ({
loading,
channels = [],
mainRoom,
text,
type,
setText,
setType,
onClickClose,
onClickAddExisting,
onClickCreateNew,
total,
loadMoreItems,
onClickView,
reload,
}: TeamsChannelsProps) => {
const { t } = useTranslation();
const inputRef = useAutoFocus<HTMLInputElement>(true);
const options: SelectOption[] = useMemo(
() => [
['all', t('All')],
['autoJoin', t('Team_Auto-join')],
],
[t],
);
const lm = useStableCallback(() => !loading && loadMoreItems());
const loadMoreChannels = useDebouncedCallback(
() => {
if (channels.length >= total) {
return;
}
lm();
},
300,
[lm, channels],
);
return (
<ContextualbarDialog>
<ContextualbarHeader>
<ContextualbarIcon name='hash' />
<ContextualbarTitle>{t('Team_Channels')}</ContextualbarTitle>
{onClickClose && <ContextualbarClose onClick={onClickClose} />}
</ContextualbarHeader>
<ContextualbarSection>
<TextInput
placeholder={t('Search')}
value={text}
ref={inputRef}
onChange={setText}
endAddon={<Icon name='magnifier' size='x20' />}
/>
<Box width='x144' marginInlineStart={8}>
<Select onChange={(val) => setType(val as 'all' | 'autoJoin')} value={type} options={options} />
</Box>
</ContextualbarSection>
<ContextualbarContent padding={12}>
{loading && (
<Box paddingInline={24} paddingBlock={12}>
<Throbber size='x12' />
</Box>
)}
{!loading && channels.length === 0 && <ContextualbarEmptyContent title={t('No_channels_in_team')} />}
{!loading && channels.length > 0 && (
<>
<Box paddingInline={18} paddingBlock={12}>
<Box is='span' color='hint' fontScale='p2'>
{t('Showing')}: {channels.length}
</Box>
<Box is='span' color='hint' fontScale='p2' marginInlineStart={8}>
{t('Total')}: {total}
</Box>
</Box>
<Box width='full' height='full' role='list' overflow='hidden' flexShrink={1}>
<VirtualizedScrollbars>
<Virtuoso
totalCount={total}
data={channels}
// eslint-disable-next-line react/no-multi-comp
components={{ Footer: () => <InfiniteListAnchor loadMore={loadMoreChannels} /> }}
itemContent={(index, data) => (
<TeamsChannelItem onClickView={onClickView} room={data} mainRoom={mainRoom} reload={reload} key={index} />
)}
/>
</VirtualizedScrollbars>
</Box>
</>
)}
</ContextualbarContent>
{(onClickAddExisting || onClickCreateNew) && (
<ContextualbarFooter>
<ButtonGroup stretch>
{onClickAddExisting && (
<Button onClick={onClickAddExisting} width='50%'>
{t('Team_Add_existing')}
</Button>
)}
{onClickCreateNew && (
<Button onClick={onClickCreateNew} width='50%'>
{t('Create_new')}
</Button>
)}
</ButtonGroup>
</ContextualbarFooter>
)}
</ContextualbarDialog>
);
};
export default TeamsChannels;

View File

@@ -0,0 +1,69 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useLocalStorage, useDebouncedValue, useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useSetModal, usePermission, useAtLeastOnePermission, useRoomToolbox } from '@rocket.chat/ui-contexts';
import type { ChangeEvent } from 'react';
import { useCallback, useState } from 'react';
import AddExistingModal from './AddExistingModal';
import TeamsChannels from './TeamsChannels';
import { useTeamsChannelList } from './hooks/useTeamsChannelList';
import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator';
import CreateChannelModal from '../../../../navbar/NavBarPagesGroup/actions/CreateChannelModal';
import { useRoom } from '../../../room/contexts/RoomContext';
const TeamsChannelsWithData = () => {
const room = useRoom();
const setModal = useSetModal();
const { closeTab } = useRoomToolbox();
const canAddExistingRoomToTeam = usePermission('move-room-to-team', room._id);
const canCreateRoomInTeam = useAtLeastOnePermission(['create-team-channel', 'create-team-group'], room._id);
const { teamId } = room;
if (!teamId) {
throw new Error('Invalid teamId');
}
const [type, setType] = useLocalStorage<'all' | 'autoJoin'>('channels-list-type', 'all');
const [text, setText] = useState('');
const debouncedText = useDebouncedValue(text, 800);
const { isPending, data, fetchNextPage, refetch } = useTeamsChannelList({ teamId, text: debouncedText, type });
const handleTextChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setText(event.currentTarget.value);
}, []);
const handleAddExisting = useStableCallback(() => {
setModal(<AddExistingModal teamId={teamId} onClose={() => setModal(null)} reload={refetch} />);
});
const handleCreateNew = useStableCallback(() => {
setModal(<CreateChannelModal teamId={teamId} mainRoom={room} onClose={() => setModal(null)} reload={refetch} />);
});
const goToRoom = useStableCallback((room: IRoom) => {
roomCoordinator.openRouteLink(room.t, room);
});
return (
<TeamsChannels
loading={isPending}
mainRoom={room}
type={type}
text={text}
setType={setType}
setText={handleTextChange}
channels={data?.channels ?? []}
total={data?.total ?? 0}
onClickClose={closeTab}
onClickAddExisting={canAddExistingRoomToTeam && handleAddExisting}
onClickCreateNew={canCreateRoomInTeam && handleCreateNew}
onClickView={goToRoom}
loadMoreItems={fetchNextPage}
reload={refetch}
/>
);
};
export default TeamsChannelsWithData;

View File

@@ -0,0 +1,43 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { GenericModal } from '@rocket.chat/ui-client';
import { useEndpoint, usePermission, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator';
export const useRemoveRoomFromTeam = (room: IRoom, { reload }: { reload?: () => void }) => {
const { t } = useTranslation();
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const canRemoveTeamChannel = usePermission('remove-team-channel', room._id);
const removeRoomEndpoint = useEndpoint('POST', '/v1/teams.removeRoom');
const handleRemoveRoom = () => {
const onConfirmAction = async () => {
if (!room.teamId) {
return;
}
try {
await removeRoomEndpoint({ teamId: room.teamId, roomId: room._id });
dispatchToastMessage({ type: 'error', message: t('Room_has_been_removed') });
reload?.();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
} finally {
setModal(null);
}
};
return setModal(
<GenericModal variant='danger' onCancel={() => setModal(null)} onConfirm={onConfirmAction} confirmText={t('Remove')}>
{t('Team_Remove_from_team_modal_content', {
teamName: roomCoordinator.getRoomName(room.t, room),
})}
</GenericModal>,
);
};
return { handleRemoveRoom, canRemoveTeamChannel };
};

View File

@@ -0,0 +1,57 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useInfiniteQuery } from '@tanstack/react-query';
import { teamsQueryKeys } from '../../../../../lib/queryKeys';
import { getConfig } from '../../../../../lib/utils/getConfig';
import { mapMessageFromApi } from '../../../../../lib/utils/mapMessageFromApi';
type TeamsChannelListOptions = {
teamId: string;
type: 'all' | 'autoJoin';
text: string;
};
export const useTeamsChannelList = ({ teamId, type, text }: TeamsChannelListOptions) => {
const listTeamRooms = useEndpoint('GET', '/v1/teams.listRooms');
const count = parseInt(`${getConfig('teamsChannelListSize', 10)}`, 10);
return useInfiniteQuery({
queryKey: teamsQueryKeys.listChannels(teamId, { type, text }),
queryFn: async ({ pageParam: offset }) => {
const { rooms, total } = await listTeamRooms({
teamId,
offset,
count,
filter: text,
type,
});
return {
items: rooms.map(({ _updatedAt, lastMessage, lm, ts, webRtcCallStartTime, usersWaitingForE2EKeys, ...room }): IRoom => ({
...(lm && { lm: new Date(lm) }),
...(ts && { ts: new Date(ts) }),
_updatedAt: new Date(_updatedAt),
...(lastMessage && { lastMessage: mapMessageFromApi(lastMessage) }),
...(webRtcCallStartTime && { webRtcCallStartTime: new Date(webRtcCallStartTime) }),
...(usersWaitingForE2EKeys && {
usersWaitingForE2EKeys: usersWaitingForE2EKeys?.map(({ userId, ts }) => ({ userId, ts: new Date(ts) })),
}),
...room,
})),
itemCount: total,
};
},
initialPageParam: 0,
getNextPageParam: (lastPage, _, lastOffset) => {
const nextOffset = lastOffset + count;
if (nextOffset >= lastPage.itemCount) return undefined;
return nextOffset;
},
select: ({ pages }) => ({
channels: pages.flatMap((page) => page.items),
total: pages.at(-1)?.itemCount,
}),
});
};

View File

@@ -0,0 +1,46 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useEndpoint, usePermission, useSetting, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { t } from 'i18next';
import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator';
export const useToggleAutoJoin = (room: IRoom, { reload, mainRoom }: { reload?: () => void; mainRoom: IRoom }) => {
const dispatchToastMessage = useToastMessageDispatch();
const updateRoomEndpoint = useEndpoint('POST', '/v1/teams.updateRoom');
const canEditTeamChannel = usePermission('edit-team-channel', room._id);
const maxNumberOfAutoJoinMembers = useSetting('API_User_Limit', 500);
const handleToggleAutoJoin = async () => {
// Sanity check, the setting has a default value, therefore it should always be defined
if (!maxNumberOfAutoJoinMembers) {
return;
}
try {
const { room: updatedRoom } = await updateRoomEndpoint({
roomId: room._id,
isDefault: !room.teamDefault,
});
if (updatedRoom.teamDefault) {
// If the number of members in the mainRoom (the team) is greater than the limit, show an info message
// informing that not all members will be auto-joined to the channel
const messageType = mainRoom.usersCount > maxNumberOfAutoJoinMembers ? 'info' : 'success';
const message = mainRoom.usersCount > maxNumberOfAutoJoinMembers ? 'Team_Auto-join_exceeded_user_limit' : 'Team_Auto-join_updated';
dispatchToastMessage({
type: messageType,
message: t(message, {
channelName: roomCoordinator.getRoomName(room.t, room),
numberOfMembers: updatedRoom.usersCount,
limit: maxNumberOfAutoJoinMembers,
}),
});
}
reload?.();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
};
return { handleToggleAutoJoin, canEditTeamChannel };
};

View File

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

View File

@@ -0,0 +1,79 @@
import type { IRoom, Serialized } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useState, useCallback } from 'react';
import FirstStep from './ModalSteps/FirstStep';
import SecondStep from './ModalSteps/SecondStep';
const STEPS = {
LIST_ROOMS: 'LIST_ROOMS',
CONFIRM_CONVERT: 'CONFIRM_CONVERT',
};
export type BaseConvertToChannelModalProps = {
onClose: () => void;
onCancel: () => void;
onConfirm: (deletedRooms: { [key: string]: Serialized<IRoom> }) => void;
currentStep?: string;
rooms?: (Serialized<IRoom> & { isLastOwner?: boolean })[];
};
const BaseConvertToChannelModal = ({
onClose,
onCancel,
onConfirm,
rooms,
currentStep = rooms?.length === 0 ? STEPS.CONFIRM_CONVERT : STEPS.LIST_ROOMS,
}: BaseConvertToChannelModalProps) => {
const [step, setStep] = useState(currentStep);
const [selectedRooms, setSelectedRooms] = useState<{ [key: string]: Serialized<IRoom> }>({});
const onContinue = useStableCallback(() => setStep(STEPS.CONFIRM_CONVERT));
const onReturn = useStableCallback(() => setStep(STEPS.LIST_ROOMS));
const eligibleRooms = rooms;
const onChangeRoomSelection = useCallback((room: Serialized<IRoom>) => {
setSelectedRooms((selectedRooms) => {
if (selectedRooms[room._id]) {
delete selectedRooms[room._id];
return { ...selectedRooms };
}
return { ...selectedRooms, [room._id]: room };
});
}, []);
const onToggleAllRooms = useStableCallback(() => {
if (Object.values(selectedRooms).filter(Boolean).length === 0 && eligibleRooms) {
return setSelectedRooms(Object.fromEntries(eligibleRooms.map((room) => [room._id, room])));
}
setSelectedRooms({});
});
if (step === STEPS.CONFIRM_CONVERT) {
return (
<SecondStep
onConfirm={onConfirm}
onClose={onClose}
onCancel={rooms && rooms.length > 0 ? onReturn : onCancel}
deletedRooms={selectedRooms}
rooms={rooms}
/>
);
}
return (
<FirstStep
onConfirm={onContinue}
onClose={onClose}
onCancel={onCancel}
rooms={rooms}
selectedRooms={selectedRooms}
onToggleAllRooms={onToggleAllRooms}
onChangeRoomSelection={onChangeRoomSelection}
eligibleRoomsLength={eligibleRooms?.length}
/>
);
};
export default BaseConvertToChannelModal;

View File

@@ -0,0 +1,31 @@
import type { IRoom, Serialized } from '@rocket.chat/core-typings';
import { GenericModalSkeleton } from '@rocket.chat/ui-client';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import BaseConvertToChannelModal from './BaseConvertToChannelModal';
import { teamsQueryKeys } from '../../../../../lib/queryKeys';
export type ConvertToChannelModalProps = {
onClose: () => void;
onCancel: () => void;
onConfirm: (deletedRooms: { [key: string]: Serialized<IRoom> }) => void;
teamId: string;
userId: string;
};
const ConvertToChannelModal = ({ onClose, onCancel, onConfirm, teamId, userId }: ConvertToChannelModalProps) => {
const listRoomsOfUser = useEndpoint('GET', '/v1/teams.listRoomsOfUser');
const { isPending, data } = useQuery({
queryKey: teamsQueryKeys.roomsOfUser(teamId, userId, { canUserDelete: true }),
queryFn: () => listRoomsOfUser({ teamId, userId, canUserDelete: 'true' }),
});
if (isPending) {
return <GenericModalSkeleton />;
}
return <BaseConvertToChannelModal onClose={onClose} onCancel={onCancel} onConfirm={onConfirm} rooms={data?.rooms} />;
};
export default ConvertToChannelModal;

View File

@@ -0,0 +1,64 @@
import type { IRoom, Serialized } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import ChannelDesertionTable from '../../../../ChannelDesertionTable';
export type FirstStepProps = {
onClose: () => void;
onCancel: () => void;
onConfirm: () => void;
onToggleAllRooms: () => void;
onChangeRoomSelection: (room: Serialized<IRoom>) => void;
rooms?: (Serialized<IRoom> & { isLastOwner?: boolean })[];
eligibleRoomsLength: number | undefined;
selectedRooms: { [key: string]: Serialized<IRoom> };
};
const FirstStep = ({
onClose,
onCancel,
onConfirm,
rooms,
onToggleAllRooms,
onChangeRoomSelection,
selectedRooms,
eligibleRoomsLength,
...props
}: FirstStepProps) => {
const { t } = useTranslation();
return (
<GenericModal
variant='warning'
icon='warning'
title={t('Converting_team_to_channel')}
cancelText={t('Cancel')}
confirmText={t('Continue')}
onClose={onClose}
onCancel={onCancel}
onConfirm={onConfirm}
{...props}
>
<Box marginBlockEnd={24} fontScale='p2'>
{t('Select_the_teams_channels_you_would_like_to_delete')}
</Box>
<Box marginBlockEnd={24} fontScale='p2'>
{t('Notice_that_public_channels_will_be_public_and_visible_to_everyone')}
</Box>
<ChannelDesertionTable
lastOwnerWarning={undefined}
onToggleAllRooms={onToggleAllRooms}
rooms={rooms}
onChangeRoomSelection={onChangeRoomSelection}
selectedRooms={selectedRooms}
eligibleRoomsLength={eligibleRoomsLength}
/>
</GenericModal>
);
};
export default FirstStep;

View File

@@ -0,0 +1,36 @@
import type { IRoom, Serialized } from '@rocket.chat/core-typings';
import { Icon } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
type SecondStepsProps = {
onClose: () => void;
onCancel: () => void;
onConfirm: (deletedRooms: { [key: string]: Serialized<IRoom> }) => void;
deletedRooms: {
[key: string]: Serialized<IRoom>;
};
rooms?: (Serialized<IRoom> & { isLastOwner?: boolean })[];
};
const SecondStep = ({ onClose, onCancel, onConfirm, deletedRooms = {}, rooms = [], ...props }: SecondStepsProps) => {
const { t } = useTranslation();
return (
<GenericModal
{...props}
variant='warning'
icon={<Icon name='modal-warning' size='x24' color='status-font-on-warning' />}
cancelText={rooms?.length > 0 ? t('Back') : t('Cancel')}
confirmText={t('Convert')}
title={t('Confirmation')}
onClose={onClose}
onCancel={onCancel}
onConfirm={(): void => onConfirm(deletedRooms)}
>
{t('You_are_converting_team_to_channel')}
</GenericModal>
);
};
export default SecondStep;

View File

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

View File

@@ -0,0 +1,76 @@
import type { IRoom, Serialized } from '@rocket.chat/core-typings';
import { Box, CheckBox } from '@rocket.chat/fuselage';
import { GenericTable, GenericTableHeaderCell, GenericTableBody, GenericTableHeader, useSort } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import ChannelDeletionTableRow from './ChannelDeletionTableRow';
export type ChannelDeletionTableProps = {
rooms: Serialized<IRoom>[];
onToggleAllRooms: () => void;
onChangeRoomSelection: (room: Serialized<IRoom>) => void;
selectedRooms: { [key: string]: Serialized<IRoom> };
};
const ChannelDeletionTable = ({ rooms, onChangeRoomSelection, selectedRooms, onToggleAllRooms }: ChannelDeletionTableProps) => {
const { t } = useTranslation();
const { sortBy, sortDirection, setSort } = useSort<'name' | 'usersCount'>('name');
const selectedRoomsLength = Object.values(selectedRooms).filter(Boolean).length;
const getSortedChannels = () => {
if (rooms) {
const sortedRooms = [...rooms];
if (sortBy === 'name') {
sortedRooms.sort((a, b) => (a.name && b.name ? a.name.localeCompare(b.name) : 0));
}
if (sortBy === 'usersCount') {
sortedRooms.sort((a, b) => a.usersCount - b.usersCount);
}
if (sortDirection === 'desc') {
return sortedRooms?.reverse();
}
return sortedRooms;
}
};
const sortedRooms = getSortedChannels();
const checked = rooms.length === selectedRoomsLength;
const indeterminate = rooms.length > selectedRoomsLength && selectedRoomsLength > 0;
const headers = (
<>
<GenericTableHeaderCell key='name' sort='name' onClick={setSort} direction={sortDirection} active={sortBy === 'name'}>
<CheckBox indeterminate={indeterminate} checked={checked} onChange={onToggleAllRooms} />
<Box marginInline={8}>{t('Channel_name')}</Box>
</GenericTableHeaderCell>
<GenericTableHeaderCell
key='usersCount'
sort='usersCount'
onClick={setSort}
direction={sortDirection}
active={sortBy === 'usersCount'}
>
<Box width='100%' textAlign='end'>
{t('Members')}
</Box>
</GenericTableHeaderCell>
</>
);
return (
<Box display='flex' flexDirection='column' height='x200' marginBlockStart={24}>
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
{sortedRooms?.map((room) => (
<ChannelDeletionTableRow room={room} key={room._id} onChange={onChangeRoomSelection} selected={!!selectedRooms[room._id]} />
))}
</GenericTableBody>
</GenericTable>
</Box>
);
};
export default ChannelDeletionTable;

View File

@@ -0,0 +1,34 @@
import type { IRoom, Serialized } from '@rocket.chat/core-typings';
import { CheckBox, Margins } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericTableRow, GenericTableCell } from '@rocket.chat/ui-client';
import { RoomIcon } from '../../../../../../components/RoomIcon';
export type ChannelDeletionTableRowProps = {
room: Serialized<IRoom>;
onChange: (room: Serialized<IRoom>) => void;
selected: boolean;
};
const ChannelDeletionTableRow = ({ room, onChange, selected }: ChannelDeletionTableRowProps) => {
const { name, fname, usersCount } = room;
const handleChange = useStableCallback(() => onChange(room));
return (
<GenericTableRow action>
<GenericTableCell maxWidth='x300' withTruncatedText>
<CheckBox checked={selected} onChange={handleChange} />
<Margins inline='x8'>
<RoomIcon room={room} />
{fname ?? name}
</Margins>
</GenericTableCell>
<GenericTableCell align='end' withTruncatedText>
{usersCount}
</GenericTableCell>
</GenericTableRow>
);
};
export default ChannelDeletionTableRow;

View File

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

View File

@@ -0,0 +1,55 @@
import type { IRoom, Serialized } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import ChannelDeletionTable from './ChannelDeletionTable';
export type DeleteTeamChannelsProps = {
rooms: Serialized<IRoom>[];
onCancel: () => void;
selectedRooms: { [key: string]: Serialized<IRoom> };
onToggleAllRooms: () => void;
onConfirm: () => void;
onChangeRoomSelection: (room: Serialized<IRoom>) => void;
};
const DeleteTeamChannels = ({
rooms,
onCancel,
selectedRooms,
onToggleAllRooms,
onConfirm,
onChangeRoomSelection,
}: DeleteTeamChannelsProps) => {
const { t } = useTranslation();
return (
<GenericModal
variant='warning'
title={t('Teams_about_the_channels')}
onConfirm={onConfirm}
onCancel={onCancel}
onClose={onCancel}
confirmText={t('Continue')}
>
<Box withRichContent marginBlockEnd={16}>
<Box is='span' color='status-font-on-danger' fontWeight='bold'>
{t('Team_Delete_Channel_modal_content_danger')}{' '}
</Box>
{t('Teams_delete_team_Warning')}
</Box>
<Box>
{t('Teams_delete_team_choose_channels')} {t('Teams_delete_team_public_notice')}
</Box>
<ChannelDeletionTable
rooms={rooms}
onToggleAllRooms={onToggleAllRooms}
onChangeRoomSelection={onChangeRoomSelection}
selectedRooms={selectedRooms}
/>
</GenericModal>
);
};
export default DeleteTeamChannels;

View File

@@ -0,0 +1,50 @@
import type { IRoom, Serialized } from '@rocket.chat/core-typings';
import { GenericModal } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import RoomLinkList from './RoomLinkList';
export type DeleteTeamConfirmationProps = {
deletedRooms: { [key: string]: Serialized<IRoom> };
keptRooms: { [key: string]: Serialized<IRoom> };
onConfirm: (roomsToDelete: IRoom['_id'][]) => void;
onReturn?: () => void;
onCancel: () => void;
};
const DeleteTeamConfirmation = ({ deletedRooms, keptRooms, onConfirm, onReturn, onCancel }: DeleteTeamConfirmationProps) => {
const { t } = useTranslation();
const roomIds = Object.values(deletedRooms).map(({ _id }) => _id);
return (
<GenericModal
variant='danger'
title={t('Delete_roomType', { roomType: 'team' })}
onConfirm={() => onConfirm(roomIds)}
onCancel={onReturn}
confirmText={t('Yes_delete_it')}
cancelText={t('Back')}
onClose={onCancel}
>
<p>{t('Delete_roomType_description', { roomType: 'team' })}</p>
{!!Object.values(deletedRooms).length && (
<>
<br />
<p>
{t('Teams_deleted_channels')} <RoomLinkList rooms={deletedRooms} />
</p>
</>
)}
{!!Object.values(keptRooms).length && (
<>
<br />
<p>
{t('Teams_kept_channels')} <RoomLinkList rooms={keptRooms} />
</p>
</>
)}
</GenericModal>
);
};
export default DeleteTeamConfirmation;

View File

@@ -0,0 +1,89 @@
import type { StoryObj, Meta } from '@storybook/react';
import { action } from 'storybook/actions';
import DeleteTeamModal from '.';
import DeleteTeamChannels from './DeleteTeamChannels';
import DeleteTeamConfirmation from './DeleteTeamConfirmation';
export default {
component: DeleteTeamModal,
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof DeleteTeamModal>;
export const Default: StoryObj<typeof DeleteTeamModal> = {
name: 'DeleteTeamModal',
args: {
teamId: '123',
onConfirm: action('onConfirm'),
onCancel: action('onCancel'),
},
};
export const ChannelsStep: StoryObj<typeof DeleteTeamChannels> = {
render: (args) => <DeleteTeamChannels {...args} />,
args: {
rooms: Array.from({ length: 15 }).map((_, i) => ({
_id: `${i}`,
fname: `Room #${i}`,
name: `room-${i}`,
usersCount: 10 * i,
type: 'p',
t: 'p',
msgs: 10,
u: {
_id: 'user',
},
autoTranslateLanguage: 'english',
_updatedAt: '2022-02-02 09:00',
})),
selectedRooms: {},
onConfirm: action('onConfirm'),
onCancel: action('onCancel'),
onChangeRoomSelection: action('onChangeRoomSelection'),
onToggleAllRooms: action('onToggleAllRooms'),
},
};
export const ConfirmationStep: StoryObj<typeof DeleteTeamConfirmation> = {
render: (args) => <DeleteTeamConfirmation {...args} />,
args: {
deletedRooms: {
test123: {
_id: `123`,
fname: `Room 123`,
name: `room-123`,
usersCount: 10,
t: 'p',
msgs: 10,
u: {
_id: 'user',
},
autoTranslateLanguage: 'english',
_updatedAt: '2022-02-02 09:00',
},
},
keptRooms: {
test123: {
_id: `123`,
fname: `Room 123`,
name: `room-123`,
usersCount: 10,
t: 'p',
msgs: 10,
u: {
_id: 'user',
},
autoTranslateLanguage: 'english',
_updatedAt: '2022-02-02 09:00',
},
},
onConfirm: async (_roomsToDelete) => action('onConfirm'),
onCancel: action('onCancel'),
onReturn: action('onReturn'),
},
};

View File

@@ -0,0 +1,71 @@
import type { IRoom, Serialized } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useState } from 'react';
import DeleteTeamChannels from './DeleteTeamChannels';
import DeleteTeamConfirmation from './DeleteTeamConfirmation';
const STEPS = { LIST_ROOMS: 'LIST_ROOMS', CONFIRM_DELETE: 'CONFIRM_DELETE' };
export type DeleteTeamModalProps = {
onCancel: () => void;
onConfirm: (roomsToDelete: IRoom['_id'][]) => void;
rooms: Serialized<IRoom>[];
};
const DeleteTeamModal = ({ onCancel, onConfirm, rooms }: DeleteTeamModalProps) => {
const hasRooms = rooms && rooms.length > 0;
const [step, setStep] = useState(hasRooms ? STEPS.LIST_ROOMS : STEPS.CONFIRM_DELETE);
const [deletedRooms, setDeletedRooms] = useState<{ [key: string]: Serialized<IRoom> }>({});
const [keptRooms, setKeptRooms] = useState<{ [key: string]: Serialized<IRoom> }>({});
const onChangeRoomSelection = useStableCallback((room: Serialized<IRoom>) => {
if (deletedRooms[room._id]) {
setDeletedRooms((deletedRooms) => {
delete deletedRooms[room._id];
return { ...deletedRooms };
});
return;
}
setDeletedRooms((deletedRooms) => ({ ...deletedRooms, [room._id]: room }));
});
const onToggleAllRooms = useStableCallback(() => {
if (Object.values(deletedRooms).filter(Boolean).length === 0) {
return setDeletedRooms(Object.fromEntries(rooms.map((room) => [room._id, room])));
}
setDeletedRooms({});
});
const onSelectRooms = useStableCallback(() => {
const keptRooms = Object.fromEntries(rooms.filter((room) => !deletedRooms[room._id]).map((room) => [room._id, room]));
setKeptRooms(keptRooms);
setStep(STEPS.CONFIRM_DELETE);
});
if (step === STEPS.CONFIRM_DELETE) {
return (
<DeleteTeamConfirmation
onConfirm={onConfirm}
onReturn={hasRooms ? () => setStep(STEPS.LIST_ROOMS) : undefined}
onCancel={onCancel}
deletedRooms={deletedRooms}
keptRooms={keptRooms}
/>
);
}
return (
<DeleteTeamChannels
rooms={rooms}
onCancel={onCancel}
selectedRooms={deletedRooms}
onToggleAllRooms={onToggleAllRooms}
onConfirm={onSelectRooms}
onChangeRoomSelection={onChangeRoomSelection}
/>
);
};
export default DeleteTeamModal;

View File

@@ -0,0 +1,29 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { GenericModalSkeleton } from '@rocket.chat/ui-client';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import DeleteTeamModal from './DeleteTeamModal';
export type DeleteTeamModalWithRoomsProps = {
teamId: string;
onConfirm: (roomsToDelete: IRoom['_id'][]) => void;
onCancel: () => void;
};
const DeleteTeamModalWithRooms = ({ teamId, onConfirm, onCancel }: DeleteTeamModalWithRoomsProps) => {
const query = useMemo(() => ({ teamId }), [teamId]);
const getTeamsListRooms = useEndpoint('GET', '/v1/teams.listRooms');
const { data, isLoading } = useQuery({
queryKey: ['getTeamsListRooms', query],
queryFn: async () => getTeamsListRooms(query),
});
if (isLoading) {
return <GenericModalSkeleton />;
}
return <DeleteTeamModal onCancel={onCancel} onConfirm={onConfirm} rooms={data?.rooms || []} />;
};
export default DeleteTeamModalWithRooms;

View File

@@ -0,0 +1,27 @@
import type { Serialized, IRoom } from '@rocket.chat/core-typings';
import { Fragment } from 'react';
import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator';
export type RoomLinkListProps = { rooms: { [key: string]: Serialized<IRoom> } };
const RoomLinkList = ({ rooms }: RoomLinkListProps) => {
const roomsArray = Object.values(rooms);
return (
<>
{roomsArray.map((room, i) => {
const roomLink = roomCoordinator.getRouteLink(room.t, room);
return (
<Fragment key={i}>
<a href={roomLink || undefined}>#{room.fname ?? room.name}</a>
{i === roomsArray.length - 1 ? '.' : ', '}
</Fragment>
);
})}
</>
);
};
export default RoomLinkList;

View File

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

View File

@@ -0,0 +1,52 @@
import type { StoryObj, Meta } from '@storybook/react';
import { action } from 'storybook/actions';
import LeaveTeamModal from './LeaveTeamModal';
import LeaveTeamModalChannels from './LeaveTeamModalChannels';
import LeaveTeamModalConfirmation from './LeaveTeamModalConfirmation';
export default {
component: LeaveTeamModal,
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof LeaveTeamModal>;
export const ChannelsStep: StoryObj<typeof LeaveTeamModalChannels> = {
render: (args) => <LeaveTeamModalChannels {...args} />,
name: 'LeaveTeamModalChannels',
args: {
rooms: Array.from({ length: 15 }).map((_, i) => ({
_id: `${i}`,
fname: `Room #${i}`,
name: `room-${i}`,
usersCount: 10 * i,
type: 'p',
t: 'p',
isLastOwner: false,
msgs: 10,
u: {
_id: 'user',
},
autoTranslateLanguage: 'english',
_updatedAt: '2022-02-02 09:00',
})),
selectedRooms: {},
onConfirm: action('onConfirm'),
onCancel: action('onCancel'),
onChangeRoomSelection: action('onChangeRoomSelection'),
onToggleAllRooms: action('onToggleAllRooms'),
},
};
export const ConfirmationStep: StoryObj<typeof LeaveTeamModalConfirmation> = {
render: (args) => <LeaveTeamModalConfirmation {...args} />,
name: 'StepTwo',
args: {
onConfirm: action('onConfirm'),
onCancel: action('onCancel'),
onClose: action('onClose'),
},
};

View File

@@ -0,0 +1,73 @@
import type { IRoom, Serialized } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useState, useCallback, useMemo } from 'react';
import LeaveTeamModalChannels from './LeaveTeamModalChannels';
import LeaveTeamModalConfirmation from './LeaveTeamModalConfirmation';
const LEAVE_TEAM_STEPS = {
LIST_ROOMS: 'LIST_ROOMS',
CONFIRM_LEAVE: 'CONFIRM_LEAVE',
} as const;
export type LeaveTeamModalProps = {
rooms: (Serialized<IRoom> & { isLastOwner?: boolean })[];
onCancel: () => void;
onConfirm: () => void;
};
const LeaveTeamModal = ({ rooms, onCancel, onConfirm }: LeaveTeamModalProps) => {
const memoizedRooms = useMemo(() => rooms, [rooms]);
const [step, setStep] = useState(memoizedRooms.length === 0 ? LEAVE_TEAM_STEPS.CONFIRM_LEAVE : LEAVE_TEAM_STEPS.LIST_ROOMS);
const [selectedRooms, setSelectedRooms] = useState<{ [key: string]: Serialized<IRoom> & { isLastOwner?: boolean } }>({});
const lastOwnerRooms = rooms.filter(({ isLastOwner }) => isLastOwner);
const handleContinue = useCallback(() => setStep(LEAVE_TEAM_STEPS.CONFIRM_LEAVE), []);
const handleReturn = useCallback(() => setStep(LEAVE_TEAM_STEPS.LIST_ROOMS), []);
const onChangeRoomSelection = useCallback((room: Serialized<IRoom>) => {
setSelectedRooms((selectedRooms) => {
if (selectedRooms[room._id]) {
delete selectedRooms[room._id];
return { ...selectedRooms };
}
return { ...selectedRooms, [room._id]: room };
});
}, []);
const onToggleAllRooms = useStableCallback(() => {
setSelectedRooms((selectedRooms) => {
if (Object.values(selectedRooms).filter(Boolean).length === 0) {
return Object.fromEntries(rooms.filter(({ isLastOwner }) => !isLastOwner).map((room) => [room._id, room]));
}
return {};
});
});
if (step === LEAVE_TEAM_STEPS.CONFIRM_LEAVE) {
return (
<LeaveTeamModalConfirmation
selectedRooms={selectedRooms}
onConfirm={onConfirm}
onClose={onCancel}
onCancel={rooms.length > 0 ? handleReturn : undefined}
/>
);
}
return (
<LeaveTeamModalChannels
rooms={rooms}
onCancel={onCancel}
eligibleRoomsLength={rooms.length - lastOwnerRooms.length}
selectedRooms={selectedRooms}
onToggleAllRooms={onToggleAllRooms}
onConfirm={handleContinue}
onChangeRoomSelection={onChangeRoomSelection}
/>
);
};
export default LeaveTeamModal;

View File

@@ -0,0 +1,52 @@
import type { IRoom, Serialized } from '@rocket.chat/core-typings';
import { GenericModal } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import ChannelDesertionTable from '../../../../ChannelDesertionTable';
export type LeaveTeamModalChannelsProps = {
rooms?: (Serialized<IRoom> & { isLastOwner?: boolean })[];
onToggleAllRooms: () => void;
onChangeRoomSelection: (room: Serialized<IRoom> & { isLastOwner?: boolean }) => void;
onConfirm: () => void;
onCancel: () => void;
eligibleRoomsLength: number;
selectedRooms: {
[key: string]: Serialized<IRoom> & { isLastOwner?: boolean };
};
};
const LeaveTeamModalChannels = ({
rooms,
onToggleAllRooms,
onChangeRoomSelection,
onConfirm,
onCancel,
eligibleRoomsLength,
selectedRooms,
}: LeaveTeamModalChannelsProps) => {
const { t } = useTranslation();
return (
<GenericModal
variant='warning'
title={t('Teams_leave')}
onConfirm={onConfirm}
onCancel={onCancel}
onClose={onCancel}
confirmText={t('Continue')}
>
{t('Teams_leave_channels')}
<ChannelDesertionTable
rooms={rooms}
lastOwnerWarning={t('Teams_channels_last_owner_leave_channel_warning')}
onToggleAllRooms={onToggleAllRooms}
eligibleRoomsLength={eligibleRoomsLength}
onChangeRoomSelection={onChangeRoomSelection}
selectedRooms={selectedRooms}
/>
</GenericModal>
);
};
export default LeaveTeamModalChannels;

View File

@@ -0,0 +1,32 @@
import type { IRoom, Serialized } from '@rocket.chat/core-typings';
import { GenericModal } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
export type LeaveTeamModalConfirmationProps = {
onConfirm: (selectedRooms?: { [key: string]: Serialized<IRoom> & { isLastOwner?: boolean } }) => void;
onClose: () => void;
onCancel?: () => void;
selectedRooms: {
[key: string]: Serialized<IRoom> & { isLastOwner?: boolean };
};
};
const LeaveTeamModalConfirmation = ({ selectedRooms, onConfirm, onCancel, onClose }: LeaveTeamModalConfirmationProps) => {
const { t } = useTranslation();
return (
<GenericModal
variant='danger'
title={t('Confirmation')}
onConfirm={(): void => onConfirm(selectedRooms)}
onCancel={onCancel || onClose}
onClose={onClose}
confirmText={t('Leave')}
cancelText={onCancel ? t('Back') : t('Cancel')}
>
{t('Teams_leaving_team')}
</GenericModal>
);
};
export default LeaveTeamModalConfirmation;

View File

@@ -0,0 +1,34 @@
import type { ITeam } from '@rocket.chat/core-typings';
import { GenericModalSkeleton } from '@rocket.chat/ui-client';
import { useUserId, useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import LeaveTeamModal from './LeaveTeamModal/LeaveTeamModal';
export type LeaveTeamWithDataProps = {
teamId: ITeam['_id'];
onCancel: () => void;
onConfirm: () => void;
};
const LeaveTeamWithData = ({ teamId, onCancel, onConfirm }: LeaveTeamWithDataProps) => {
const userId = useUserId();
if (!userId) {
throw Error('No user found');
}
const getRoomsOfUser = useEndpoint('GET', '/v1/teams.listRoomsOfUser');
const { data, isLoading } = useQuery({
queryKey: ['teams.listRoomsOfUser'],
queryFn: () => getRoomsOfUser({ teamId, userId }),
});
if (isLoading) {
return <GenericModalSkeleton />;
}
return <LeaveTeamModal onCancel={onCancel} onConfirm={onConfirm} rooms={data?.rooms || []} />;
};
export default LeaveTeamWithData;

View File

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

View File

@@ -0,0 +1,42 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { Contextualbar } from '@rocket.chat/ui-client';
import type { Meta } from '@storybook/react';
import TeamsInfo from './TeamsInfo';
const room = {
_id: 'awdawd',
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',
} as IRoom;
export default {
component: TeamsInfo,
parameters: {
layout: 'fullscreen',
actions: { argTypesRegex: '^on.*' },
},
decorators: [(fn) => <Contextualbar height='100vh'>{fn()}</Contextualbar>],
args: {
room,
},
} satisfies Meta<typeof TeamsInfo>;
export const Default = {};
export const Archived = {
args: {
room: { ...room, archived: true },
},
};
export const Broadcast = {
args: {
room: { ...room, broadcast: true },
},
};

View File

@@ -0,0 +1,144 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { Box, Button, Callout, IconButton } from '@rocket.chat/fuselage';
import { RoomAvatar } from '@rocket.chat/ui-avatar';
import {
GenericMenu,
ContextualbarHeader,
ContextualbarIcon,
ContextualbarTitle,
ContextualbarClose,
ContextualbarScrollableContent,
ContextualbarDialog,
InfoPanel,
InfoPanelAction,
InfoPanelActionGroup,
InfoPanelAvatar,
InfoPanelField,
InfoPanelLabel,
InfoPanelSection,
InfoPanelText,
InfoPanelTitle,
} from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import { useTeamActions } from './useTeamActions';
import RetentionPolicyCallout from '../../../../components/InfoPanel/RetentionPolicyCallout';
import MarkdownText from '../../../../components/MarkdownText';
import RoomInfoABACSection from '../../../room/contextualBar/Info/RoomInfo/ABAC/RoomInfoABACSection';
import { useSplitRoomActions } from '../../../room/contextualBar/Info/hooks/useSplitRoomActions';
import { useRetentionPolicy } from '../../../room/hooks/useRetentionPolicy';
export type TeamsInfoProps = {
room: IRoom;
onClickEdit?: () => void;
onClickClose?: () => void;
onClickViewChannels: () => void;
};
const TeamsInfo = ({ room, onClickClose, onClickEdit, onClickViewChannels }: TeamsInfoProps) => {
const { t } = useTranslation();
const retentionPolicy = useRetentionPolicy(room);
const memoizedActions = useTeamActions(room, { onClickEdit });
const { buttons: actions, menu } = useSplitRoomActions(memoizedActions);
return (
<ContextualbarDialog>
<ContextualbarHeader>
<ContextualbarIcon name='info-circled' />
<ContextualbarTitle>{t('Teams_Info')}</ContextualbarTitle>
{onClickClose && <ContextualbarClose onClick={onClickClose} />}
</ContextualbarHeader>
<ContextualbarScrollableContent padding={24}>
<InfoPanel>
<InfoPanelSection maxWidth='x332' marginInline='auto'>
<InfoPanelAvatar>
<RoomAvatar size='x332' room={room} />
</InfoPanelAvatar>
<InfoPanelActionGroup>
{actions.items.map(({ id, content, icon, onClick }) => (
<InfoPanelAction key={id} label={content} onClick={onClick} icon={icon} />
))}
{menu && (
<GenericMenu
title={t('More')}
placement='bottom-end'
detached
button={<IconButton icon='kebab' secondary flexShrink={0} flexGrow={0} maxHeight='initial' />}
sections={menu}
/>
)}
</InfoPanelActionGroup>
</InfoPanelSection>
<InfoPanelSection>
{room.archived && (
<Box marginBlock={16}>
<Callout type='warning'>{t('Room_archived')}</Callout>
</Box>
)}
</InfoPanelSection>
<InfoPanelSection>
<InfoPanelTitle title={room.fname || room.name || ''} icon='team' />
</InfoPanelSection>
<InfoPanelSection>
{room.broadcast && (
<InfoPanelField>
<InfoPanelLabel>
<b>{t('Broadcast_channel')}</b> {t('Broadcast_channel_Description')}
</InfoPanelLabel>
</InfoPanelField>
)}
{room.description && (
<InfoPanelField>
<InfoPanelLabel>{t('Description')}</InfoPanelLabel>
<InfoPanelText withTruncatedText={false}>
<MarkdownText variant='inline' content={room.description} />
</InfoPanelText>
</InfoPanelField>
)}
{room.announcement && (
<InfoPanelField>
<InfoPanelLabel>{t('Announcement')}</InfoPanelLabel>
<InfoPanelText withTruncatedText={false}>
<MarkdownText variant='inline' content={room.announcement} />
</InfoPanelText>
</InfoPanelField>
)}
{room.topic && (
<InfoPanelField>
<InfoPanelLabel>{t('Topic')}</InfoPanelLabel>
<InfoPanelText withTruncatedText={false}>
<MarkdownText variant='inline' content={room.topic} />
</InfoPanelText>
</InfoPanelField>
)}
{onClickViewChannels && (
<InfoPanelField>
<InfoPanelLabel>{t('Teams_channels')}</InfoPanelLabel>
<InfoPanelText>
<Button onClick={onClickViewChannels} small>
{t('View_channels')}
</Button>
</InfoPanelText>
</InfoPanelField>
)}
{retentionPolicy?.isActive && <RetentionPolicyCallout room={room} />}
<RoomInfoABACSection room={room} />
</InfoPanelSection>
</InfoPanel>
</ContextualbarScrollableContent>
</ContextualbarDialog>
);
};
export default TeamsInfo;

View File

@@ -0,0 +1,32 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { usePermission, useRoomToolbox } from '@rocket.chat/ui-contexts';
import { useCallback, useState } from 'react';
import TeamsInfo from './TeamsInfo';
import { useRoom } from '../../../room/contexts/RoomContext';
import EditChannelWithData from '../../../room/contextualBar/Info/EditRoomInfo';
const TeamsInfoWithData = () => {
const room = useRoom();
const [editing, setEditing] = useState(false);
const { openTab, closeTab } = useRoomToolbox();
const canEdit = usePermission('edit-team-channel', room._id);
const onClickBack = useStableCallback(() => setEditing(false));
const onClickViewChannels = useCallback(() => openTab('team-channels'), [openTab]);
if (editing) {
return <EditChannelWithData onClickBack={onClickBack} />;
}
return (
<TeamsInfo
room={room}
onClickEdit={canEdit ? () => setEditing(true) : undefined}
onClickClose={closeTab}
onClickViewChannels={onClickViewChannels}
/>
);
};
export default TeamsInfoWithData;

View File

@@ -0,0 +1,49 @@
import type { IRoom, Serialized } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { usePermission, useSetModal, useToastMessageDispatch, useUserId } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import ConvertToChannelModal from './ConvertToChannelModal';
import { useEndpointMutation } from '../../../../hooks/useEndpointMutation';
export const useConvertToChannel = ({ _id, teamId }: IRoom) => {
const { t } = useTranslation();
const setModal = useSetModal();
const userId = useUserId();
const canEdit = usePermission('edit-team-channel', _id);
const dispatchToastMessage = useToastMessageDispatch();
const { mutateAsync: convertTeamToChannel } = useEndpointMutation('POST', '/v1/teams.convertToChannel', {
onSuccess: () => {
dispatchToastMessage({ type: 'success', message: t('Team_converted_to_channel') });
},
onSettled: () => {
setModal(null);
},
});
const onClickConvertToChannel = useStableCallback(() => {
if (!userId || !teamId) {
throw new Error('Invalid teamId or userId');
}
const onConfirm = async (roomsToRemove: { [key: string]: Serialized<IRoom> }) => {
await convertTeamToChannel({
teamId,
roomsToRemove: Object.keys(roomsToRemove),
});
};
setModal(
<ConvertToChannelModal
onClose={() => setModal(null)}
onCancel={() => setModal(null)}
onConfirm={onConfirm}
teamId={teamId}
userId={userId}
/>,
);
});
return canEdit ? onClickConvertToChannel : undefined;
};

View File

@@ -0,0 +1,45 @@
import type { IRoom, Serialized } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useRouter, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import LeaveTeam from './LeaveTeam';
import { useEndpointMutation } from '../../../../hooks/useEndpointMutation';
export const useLeaveTeam = ({ teamId }: IRoom) => {
const { t } = useTranslation();
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const router = useRouter();
const { mutateAsync: leaveTeam } = useEndpointMutation('POST', '/v1/teams.leave', {
onSuccess: () => {
dispatchToastMessage({ type: 'success', message: t('Teams_left_team_successfully') });
router.navigate('/home');
},
onSettled: () => {
setModal(null);
},
});
// const canLeave = usePermission('leave-team'); /* && room.cl !== false && joined */
const handleLeaveTeam = useStableCallback(() => {
if (!teamId) {
throw new Error('Invalid teamId');
}
const onConfirm = async (selectedRooms: { [key: string]: Serialized<IRoom> & { isLastOwner?: boolean } } = {}) => {
const roomsLeft = Object.keys(selectedRooms);
const roomsToLeave = Array.isArray(roomsLeft) && roomsLeft.length > 0 ? roomsLeft : [];
await leaveTeam({
teamId,
...(roomsToLeave.length && { rooms: roomsToLeave }),
});
};
setModal(<LeaveTeam onConfirm={onConfirm} onCancel={() => setModal(null)} teamId={teamId} />);
});
return handleLeaveTeam;
};

View File

@@ -0,0 +1,75 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useConvertToChannel } from './useConvertToChannel';
import { useLeaveTeam } from './useLeaveTeam';
import { useHideRoomAction } from '../../../../hooks/useHideRoomAction';
import { useDeleteRoom } from '../../../hooks/roomActions/useDeleteRoom';
type GenProps = {
onClickEdit?: () => void;
};
export const useTeamActions = (room: IRoom, { onClickEdit }: GenProps) => {
const { t } = useTranslation();
const hideTeam = useHideRoomAction({ rid: room._id, type: room.t, name: room.name ?? '' });
const convertToChannel = useConvertToChannel(room);
const { handleDelete, canDeleteRoom } = useDeleteRoom(room);
const leaveTeam = useLeaveTeam(room);
return useMemo(
() => ({
items: [
{
id: 'hide',
content: t('Hide'),
icon: 'eye-off' as const,
onClick: hideTeam,
},
...(onClickEdit
? [
{
id: 'edit',
content: t('Edit'),
icon: 'edit' as const,
onClick: onClickEdit,
},
]
: []),
...(leaveTeam
? [
{
id: 'leave',
content: t('Leave'),
icon: 'sign-out' as const,
onClick: leaveTeam,
},
]
: []),
...(convertToChannel
? [
{
id: 'convert_team_to_channel',
content: t('Convert_to_channel'),
icon: 'hash' as const,
onClick: convertToChannel,
},
]
: []),
...(canDeleteRoom
? [
{
id: 'delete',
content: t('Delete'),
icon: 'trash' as const,
onClick: handleDelete,
variant: 'danger',
},
]
: []),
],
}),
[t, hideTeam, leaveTeam, onClickEdit, handleDelete, canDeleteRoom, convertToChannel],
);
};

View File

@@ -0,0 +1,86 @@
import type { IRoom, Serialized } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { usePermission } from '@rocket.chat/ui-contexts';
import { useState, useCallback } from 'react';
import RemoveUsersFirstStep from './RemoveUsersFirstStep';
import RemoveUsersSecondStep from './RemoveUsersSecondStep';
const STEPS = {
LIST_ROOMS: 'LIST_ROOMS',
CONFIRM_DELETE: 'CONFIRM_DELETE',
};
export type BaseRemoveUsersModalProps = {
onClose: () => void;
onCancel: () => void;
onConfirm: (deletedRooms: { [key: string]: Serialized<IRoom> }) => void;
currentStep?: string;
rooms?: (Serialized<IRoom> & { isLastOwner?: boolean })[];
username?: string;
};
const BaseRemoveUsersModal = ({
onClose,
onCancel,
onConfirm,
rooms,
currentStep = rooms?.length === 0 ? STEPS.CONFIRM_DELETE : STEPS.LIST_ROOMS,
username,
}: BaseRemoveUsersModalProps) => {
const [step, setStep] = useState(currentStep);
const [selectedRooms, setSelectedRooms] = useState<Record<string, Serialized<IRoom> & { isLastOwner?: boolean }>>({});
const onContinue = useStableCallback(() => setStep(STEPS.CONFIRM_DELETE));
const onReturn = useStableCallback(() => setStep(STEPS.LIST_ROOMS));
const canViewUserRooms = usePermission('view-all-team-channels');
const eligibleRooms = rooms?.filter(({ isLastOwner }) => !isLastOwner);
const onChangeRoomSelection = useCallback((room: Serialized<IRoom>) => {
setSelectedRooms((selectedRooms) => {
if (selectedRooms[room._id]) {
delete selectedRooms[room._id];
return { ...selectedRooms };
}
return { ...selectedRooms, [room._id]: room };
});
}, []);
const onToggleAllRooms = useStableCallback(() => {
if (Object.values(selectedRooms).filter(Boolean).length === 0) {
return setSelectedRooms(Object.fromEntries(eligibleRooms?.map((room) => [room._id, room]) ?? []));
}
setSelectedRooms({});
});
if (step === STEPS.CONFIRM_DELETE || !canViewUserRooms) {
return (
<RemoveUsersSecondStep
onConfirm={onConfirm}
onClose={onClose}
onCancel={(rooms?.length ?? 0) > 0 ? onReturn : onCancel}
deletedRooms={selectedRooms}
rooms={rooms}
username={username}
/>
);
}
return (
<RemoveUsersFirstStep
onConfirm={onContinue}
onClose={onClose}
onCancel={onCancel}
rooms={rooms}
selectedRooms={selectedRooms}
onToggleAllRooms={onToggleAllRooms}
onChangeRoomSelection={onChangeRoomSelection}
eligibleRoomsLength={eligibleRooms?.length}
/>
);
};
export default BaseRemoveUsersModal;

View File

@@ -0,0 +1,59 @@
import type { Serialized, IRoom } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import ChannelDesertionTable from '../../../ChannelDesertionTable';
export type RemoveUsersFirstStepProps = {
onClose: () => void;
onCancel: () => void;
onConfirm: () => void;
rooms?: (Serialized<IRoom> & { isLastOwner?: boolean })[];
onToggleAllRooms: () => void;
onChangeRoomSelection: (room: Serialized<IRoom> & { isLastOwner?: boolean }) => void;
selectedRooms: { [key: string]: Serialized<IRoom> };
eligibleRoomsLength: number | undefined;
};
const RemoveUsersFirstStep = ({
onClose,
onCancel,
onConfirm,
rooms,
onToggleAllRooms,
onChangeRoomSelection,
selectedRooms,
eligibleRoomsLength,
...props
}: RemoveUsersFirstStepProps) => {
const { t } = useTranslation();
return (
<GenericModal
variant='warning'
icon='warning'
title={t('Teams_removing_member')}
cancelText={t('Cancel')}
confirmText={t('Continue')}
onClose={onClose}
onCancel={onCancel}
onConfirm={onConfirm}
{...props}
>
<Box marginBlockEnd={24} fontScale='p2'>
{t('Select_the_channels_you_want_the_user_to_be_removed_from')}
</Box>
<ChannelDesertionTable
lastOwnerWarning={t('Teams_channels_last_owner_leave_channel_warning')}
onToggleAllRooms={onToggleAllRooms}
rooms={rooms}
onChangeRoomSelection={onChangeRoomSelection}
selectedRooms={selectedRooms}
eligibleRoomsLength={eligibleRoomsLength}
/>
</GenericModal>
);
};
export default RemoveUsersFirstStep;

View File

@@ -0,0 +1,45 @@
import type { Serialized, IRoom } from '@rocket.chat/core-typings';
import { GenericModalSkeleton } from '@rocket.chat/ui-client';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import BaseRemoveUsersModal from './BaseRemoveUsersModal';
import { teamsQueryKeys, usersQueryKeys } from '../../../../../lib/queryKeys';
export type RemoveUsersModalProps = {
onClose: () => void;
onCancel: () => void;
onConfirm: (deletedRooms: { [key: string]: Serialized<IRoom> }) => void;
teamId: string;
userId: string;
};
const RemoveUsersModal = ({ teamId, userId, onClose, onCancel, onConfirm }: RemoveUsersModalProps) => {
const listRoomsOfUser = useEndpoint('GET', '/v1/teams.listRoomsOfUser');
const { isPending, data } = useQuery({
queryKey: teamsQueryKeys.roomsOfUser(teamId, userId),
queryFn: () => listRoomsOfUser({ teamId, userId }),
});
const getUserInfo = useEndpoint('GET', '/v1/users.info');
const { data: userData } = useQuery({
queryKey: usersQueryKeys.userInfo({ uid: userId }),
queryFn: () => getUserInfo({ userId }),
});
if (isPending) {
return <GenericModalSkeleton />;
}
return (
<BaseRemoveUsersModal
onClose={onClose}
username={userData?.user.username ?? ''}
onCancel={onCancel}
onConfirm={onConfirm}
rooms={data?.rooms ?? []}
/>
);
};
export default RemoveUsersModal;

View File

@@ -0,0 +1,45 @@
import type { Serialized, IRoom } from '@rocket.chat/core-typings';
import { Icon } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
export type RemoveUsersSecondStepProps = {
onClose: () => void;
onCancel: () => void;
onConfirm: (deletedRooms: { [key: string]: Serialized<IRoom> }) => void;
deletedRooms: {
[key: string]: Serialized<IRoom>;
};
rooms?: { _id: string; t: string; name?: string; fname?: string; isLastOwner?: boolean }[];
username?: string;
};
const RemoveUsersSecondStep = ({
onClose,
onCancel,
onConfirm,
deletedRooms = {},
username,
rooms = [],
...props
}: RemoveUsersSecondStepProps) => {
const { t } = useTranslation();
return (
<GenericModal
variant='danger'
icon={<Icon name='modal-warning' size='x24' color='status-font-on-warning' />}
cancelText={rooms?.length > 0 ? t('Back') : t('Cancel')}
confirmText={t('Remove')}
title={t('Confirmation')}
onClose={onClose}
onCancel={onCancel}
onConfirm={() => onConfirm(deletedRooms)}
{...props}
>
{t('Teams_removing__username__from_team', { username })}
</GenericModal>
);
};
export default RemoveUsersSecondStep;

View File

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