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,25 @@
import { Contextualbar } from '@rocket.chat/ui-client';
import type { StoryObj, Meta } from '@storybook/react';
import AutoTranslate from './AutoTranslate';
export default {
component: AutoTranslate,
parameters: {
layout: 'fullscreen',
},
decorators: [(fn) => <Contextualbar height='100vh'>{fn()}</Contextualbar>],
} satisfies Meta<typeof AutoTranslate>;
export const Default: StoryObj<typeof AutoTranslate> = {
name: 'AutoTranslate',
args: {
language: 'en',
languages: [
['en', 'English'],
['jp', 'Japanese'],
['pt', 'Portuguese'],
],
},
};

View File

@@ -0,0 +1,72 @@
import { Callout, FieldGroup, Field, FieldLabel, FieldRow, ToggleSwitch, Select } from '@rocket.chat/fuselage';
import type { SelectOption } from '@rocket.chat/fuselage';
import {
ContextualbarClose,
ContextualbarTitle,
ContextualbarHeader,
ContextualbarIcon,
ContextualbarContent,
ContextualbarDialog,
} from '@rocket.chat/ui-client';
import type { ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useRoom } from '../../contexts/RoomContext';
export type AutoTranslateProps = {
language: string;
languages: SelectOption[];
handleSwitch: (e: ChangeEvent<HTMLInputElement>) => void;
translateEnable: boolean | undefined;
handleChangeLanguage: (value: string) => void;
handleClose?: () => void;
};
const AutoTranslate = ({ language, languages, handleSwitch, translateEnable, handleChangeLanguage, handleClose }: AutoTranslateProps) => {
const { t } = useTranslation();
const room = useRoom();
return (
<ContextualbarDialog>
<ContextualbarHeader>
<ContextualbarIcon name='language' />
<ContextualbarTitle>{t('Auto_Translate')}</ContextualbarTitle>
{handleClose && <ContextualbarClose onClick={handleClose} />}
</ContextualbarHeader>
<ContextualbarContent paddingBlockStart={24}>
<FieldGroup>
{room.encrypted && (
<Callout title={t('Automatic_translation_not_available')} type='warning'>
{t('Automatic_translation_not_available_info')}
</Callout>
)}
<Field>
<FieldRow>
<FieldLabel htmlFor='automatic-translation'>{t('Automatic_Translation')}</FieldLabel>
<ToggleSwitch
id='automatic-translation'
onChange={handleSwitch}
defaultChecked={translateEnable}
disabled={room.encrypted && !translateEnable}
/>
</FieldRow>
</Field>
<Field>
<FieldLabel htmlFor='translate-to'>{t('Translate_to')}</FieldLabel>
<FieldRow verticalAlign='middle'>
<Select
id='language'
value={language}
disabled={!translateEnable}
onChange={(value) => handleChangeLanguage(String(value))}
options={languages}
/>
</FieldRow>
</Field>
</FieldGroup>
</ContextualbarContent>
</ContextualbarDialog>
);
};
export default AutoTranslate;

View File

@@ -0,0 +1,90 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useEndpoint, useLanguage, useToastMessageDispatch, useRoomToolbox } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { ChangeEvent } from 'react';
import { useEffect, useState, memo } from 'react';
import { useTranslation } from 'react-i18next';
import AutoTranslate from './AutoTranslate';
import { useEndpointMutation } from '../../../../hooks/useEndpointMutation';
import { miscQueryKeys } from '../../../../lib/queryKeys';
import { useRoom, useRoomSubscription } from '../../contexts/RoomContext';
const AutoTranslateWithData = () => {
const room = useRoom();
const subscription = useRoomSubscription();
const { closeTab } = useRoomToolbox();
const userLanguage = useLanguage();
const [currentLanguage, setCurrentLanguage] = useState(subscription?.autoTranslateLanguage ?? '');
const dispatchToastMessage = useToastMessageDispatch();
const { mutateAsync: saveSettings } = useEndpointMutation('POST', '/v1/autotranslate.saveSettings');
const { t } = useTranslation();
const getSupportedLanguages = useEndpoint('GET', '/v1/autotranslate.getSupportedLanguages');
const { data: supportedLanguages } = useQuery({
queryKey: miscQueryKeys.autotranslateSupportedLanguages(userLanguage),
queryFn: async () => {
const { languages } = await getSupportedLanguages({ targetLanguage: userLanguage });
return languages;
},
});
const languagesDict = supportedLanguages ? Object.fromEntries(supportedLanguages.map((lang) => [lang.language, lang.name])) : {};
const handleChangeLanguage = useStableCallback(async (value: string) => {
setCurrentLanguage(value);
await saveSettings({
roomId: room._id,
field: 'autoTranslateLanguage',
value,
});
dispatchToastMessage({
type: 'success',
message: t('AutoTranslate_language_set_to', { language: languagesDict[value] }),
});
});
const handleSwitch = useStableCallback(async (event: ChangeEvent<HTMLInputElement>) => {
await saveSettings({
roomId: room._id,
field: 'autoTranslate',
value: event.target.checked,
});
dispatchToastMessage({
type: 'success',
message: event.target.checked
? t('AutoTranslate_Enabled_for_room', { roomName: room.name })
: t('AutoTranslate_Disabled_for_room', { roomName: room.name }),
});
if (event.target.checked && currentLanguage) {
dispatchToastMessage({
type: 'success',
message: t('AutoTranslate_language_set_to', { language: languagesDict[currentLanguage] }),
});
}
});
useEffect(() => {
if (!subscription?.autoTranslate) {
return;
}
if (!subscription?.autoTranslateLanguage) {
handleChangeLanguage(userLanguage);
}
}, [subscription?.autoTranslate, subscription?.autoTranslateLanguage, handleChangeLanguage, userLanguage]);
return (
<AutoTranslate
language={currentLanguage}
languages={supportedLanguages ? supportedLanguages.map((language) => [language.language, language.name]) : []}
handleSwitch={handleSwitch}
handleChangeLanguage={handleChangeLanguage}
translateEnable={!!subscription?.autoTranslate}
handleClose={closeTab}
/>
);
};
export default memo(AutoTranslateWithData);

View File

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

View File

@@ -0,0 +1,19 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { VirtuosoMockContext } from 'react-virtuoso';
import * as stories from './BannedUsers.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
const appRoot = mockAppRoot()
.wrap((children) => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>{children}</VirtuosoMockContext.Provider>
))
.build();
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const { baseElement } = render(<Story />, { wrapper: appRoot });
expect(baseElement).toMatchSnapshot();
});

View File

@@ -0,0 +1,86 @@
import { Contextualbar } from '@rocket.chat/ui-client';
import type { Meta, StoryObj } from '@storybook/react';
import { action } from 'storybook/actions';
import BannedUsers from './BannedUsers';
const meta = {
component: BannedUsers,
parameters: {
layout: 'fullscreen',
actions: { argTypesRegex: '^on.*' },
},
args: {
useRealName: false,
},
decorators: [(fn) => <Contextualbar height='100vh'>{fn()}</Contextualbar>],
} satisfies Meta<typeof BannedUsers>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
loading: false,
bannedUsers: [
{
_id: 'user1',
username: 'john.doe',
name: 'John Doe',
},
{
_id: 'user2',
username: '@jane.smith:matrix.org',
name: 'Jane Smith',
},
{
_id: 'user3',
username: 'rocket.cat',
name: 'Rocket.Cat',
},
],
useRealName: false,
onClickClose: action('onClickClose'),
onClickUnban: action('onClickUnban'),
onLoadMore: action('onLoadMore'),
},
};
export const Loading: Story = {
args: {
loading: true,
bannedUsers: [],
onClickClose: action('onClickClose'),
onClickUnban: action('onClickUnban'),
onLoadMore: action('onLoadMore'),
},
};
export const WithRealNames: Story = {
args: {
...Default.args,
useRealName: true,
},
};
export const Empty: Story = {
args: {
loading: false,
bannedUsers: [],
onClickClose: action('onClickClose'),
onClickUnban: action('onClickUnban'),
onLoadMore: action('onLoadMore'),
},
};
export const WithError: Story = {
args: {
loading: false,
error: new Error('Failed to load banned users'),
bannedUsers: [],
onClickClose: action('onClickClose'),
onClickUnban: action('onClickUnban'),
onLoadMore: action('onLoadMore'),
},
};

View File

@@ -0,0 +1,72 @@
import { Box, Throbber } from '@rocket.chat/fuselage';
import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
import {
ContextualbarClose,
ContextualbarContent,
ContextualbarDialog,
ContextualbarEmptyContent,
ContextualbarHeader,
ContextualbarIcon,
ContextualbarTitle,
VirtualizedScrollbars,
} from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import { Virtuoso } from 'react-virtuoso';
import BannedUsersItem from './BannedUsersItem';
import type { BannedUser } from '../../../hooks/useRoomBannedUsers';
export type BannedUsersProps = {
loading: boolean;
error?: Error;
useRealName?: boolean;
bannedUsers: BannedUser[];
onClickClose: () => void;
onClickUnban: (username: string) => void;
onLoadMore: () => void;
};
const BannedUsers = ({ loading, error, bannedUsers, useRealName = false, onClickClose, onClickUnban, onLoadMore }: BannedUsersProps) => {
const { t } = useTranslation();
const loadMoreBannedUsers = useDebouncedCallback(() => onLoadMore(), 300, [onLoadMore, bannedUsers]);
return (
<ContextualbarDialog>
<ContextualbarHeader>
<ContextualbarIcon name='ban' />
<ContextualbarTitle>{t('Banned_Users')}</ContextualbarTitle>
<ContextualbarClose onClick={onClickClose} />
</ContextualbarHeader>
<ContextualbarContent padding={0} paddingBlock={12}>
{loading && (
<Box paddingInline={24} paddingBlock={12}>
<Throbber size='x12' />
</Box>
)}
{error && <ContextualbarEmptyContent icon='warning' title={t('Banned_users_error')} subtitle={t('Please_try_again')} />}
{!loading && !error && bannedUsers.length === 0 && (
<ContextualbarEmptyContent icon='ban' title={t('No_banned_users')} subtitle={t('No_banned_users_description')} />
)}
{!loading && !error && bannedUsers.length > 0 && (
<Box width='full' height='full' overflow='hidden' flexShrink={1}>
<VirtualizedScrollbars>
<Virtuoso
style={{ height: '100%', width: '100%' }}
data={bannedUsers}
overscan={50}
endReached={loadMoreBannedUsers}
itemContent={(_index, user) => <BannedUsersItem user={user} useRealName={useRealName} onClickUnban={onClickUnban} />}
/>
</VirtualizedScrollbars>
</Box>
)}
</ContextualbarContent>
</ContextualbarDialog>
);
};
export default BannedUsers;

View File

@@ -0,0 +1,53 @@
import { Box, Icon, Option, OptionAvatar, OptionColumn, OptionContent, OptionDescription, OptionMenu } from '@rocket.chat/fuselage';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { GenericMenu } from '@rocket.chat/ui-client';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { getUserDisplayNames } from '../../../../../lib/getUserDisplayNames';
import { normalizeUsername } from '../../../../../lib/utils/normalizeUsername';
import { ReactiveUserStatus } from '../../../../components/UserStatus';
import type { BannedUser } from '../../../hooks/useRoomBannedUsers';
export type BannedUsersItemProps = {
user: BannedUser;
useRealName: boolean;
onClickUnban: (username: string) => void;
};
const BannedUsersItem = ({ user, useRealName, onClickUnban }: BannedUsersItemProps) => {
const { t } = useTranslation();
const [nameOrUsername, displayUsername] = getUserDisplayNames(user.name, user.username, useRealName);
const federated = user.username.startsWith('@') && user.username.includes(':');
const options = useMemo(
() => [
{
id: 'unban-user',
content: <Box color='status-font-on-danger'>{t('Unban_user_from_room')}</Box>,
icon: 'ban' as const,
iconColor: 'status-font-on-danger',
onClick: () => onClickUnban(user.username),
},
],
[onClickUnban, t, user.username],
);
return (
<Option style={{ paddingInline: 24, cursor: 'default' }}>
<OptionAvatar>
<UserAvatar username={normalizeUsername(user.username)} size='x28' />
</OptionAvatar>
<OptionColumn>{federated ? <Icon name='globe' size='x16' /> : <ReactiveUserStatus uid={user._id} />}</OptionColumn>
<OptionContent>
{nameOrUsername} {displayUsername && <OptionDescription>@{displayUsername}</OptionDescription>}
</OptionContent>
<OptionMenu>
<GenericMenu detached title={t('More')} items={options} placement='bottom-end' />
</OptionMenu>
</Option>
);
};
export default BannedUsersItem;

View File

@@ -0,0 +1,30 @@
import { useRoomToolbox, useSetting } from '@rocket.chat/ui-contexts';
import BannedUsers from './BannedUsers';
import { useRoomBannedUsers } from '../../../hooks/useRoomBannedUsers';
import { useRoom } from '../../contexts/RoomContext';
import { useUnbanUser } from '../../hooks/useUnbanUser';
const BannedUsersWithData = () => {
const room = useRoom();
const { closeTab } = useRoomToolbox();
const useRealName = useSetting('UI_Use_Real_Name', false);
const { data, error, isPending, hasNextPage, fetchNextPage } = useRoomBannedUsers({ rid: room._id });
const handleUnban = useUnbanUser({ roomId: room._id });
return (
<BannedUsers
loading={isPending}
error={error ?? undefined}
useRealName={useRealName}
bannedUsers={data?.bannedUsers ?? []}
onClickClose={closeTab}
onClickUnban={handleUnban}
onLoadMore={hasNextPage ? () => void fetchNextPage() : () => undefined}
/>
);
};
export default BannedUsersWithData;

View File

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

View File

@@ -0,0 +1,123 @@
import { composeStories } from '@storybook/react';
import { render, screen, within } from '@testing-library/react';
import { axe } from 'jest-axe';
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
import * as React from 'react';
import { Children, forwardRef, isValidElement } from 'react';
import * as stories from './DiscussionsList.stories';
jest.mock('../../../../lib/rooms/roomCoordinator', () => ({
roomCoordinator: {
getRoomDirectives: jest.fn(() => ({})),
},
}));
const mockVirtualizerHandle = {
scrollToIndex: jest.fn(),
scrollTo: jest.fn(),
findItemIndex: jest.fn((offset: number) => offset),
scrollOffset: 0,
scrollSize: 1000,
viewportSize: 300,
};
type MockVListProps = {
children: ReactNode;
onScroll?: (offset: number) => void;
as?: React.ElementType;
item?: React.ElementType;
shift?: boolean;
style?: CSSProperties;
className?: string;
};
jest.mock('virtua', () => {
return {
Virtualizer: React.forwardRef(
(
{ children, onScroll, as: asRoot = 'div', item: asItem = 'div', shift: _shift, style, className }: MockVListProps,
ref: React.Ref<unknown>,
) => {
React.useImperativeHandle(ref, () => mockVirtualizerHandle);
const Root = asRoot;
const Item = asItem;
const wrapped = Children.map(children, (child, index) => {
const key = isValidElement(child) && child.key != null ? String(child.key) : `row-${index}`;
return <Item key={key}>{child}</Item>;
});
return (
<Root
className={className}
data-testid='discussions-virtual-list'
style={style ?? { height: '100%' }}
onScroll={() => onScroll?.(mockVirtualizerHandle.scrollOffset)}
>
{wrapped}
</Root>
);
},
),
};
});
jest.mock('@rocket.chat/ui-client', () => ({
...jest.requireActual('@rocket.chat/ui-client'),
CustomVirtuaScrollbars: forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function CustomVirtuaScrollbars(
{ children, ...props },
ref,
) {
// eslint-disable-next-line testing-library/no-node-access
const content = isValidElement<{ children?: ReactNode }>(children) && children.type === 'div' ? children.props.children : children;
return (
<div ref={ref} {...props}>
{content}
</div>
);
}),
}));
const composed = composeStories(stories);
const testCases = Object.values(composed).map((Story) => [Story.storyName || 'Story', Story] as const);
describe('DiscussionsList', () => {
it('renders Default with virtual list and discussion rows', () => {
const { Default } = composed;
render(<Default />);
const list = screen.getByTestId('discussions-virtual-list');
expect(list).toBeInTheDocument();
expect(list.tagName.toLowerCase()).toBe('ul');
expect(within(list).getAllByRole('listitem')).toHaveLength(10);
expect(within(list).getAllByText('user.name')).toHaveLength(10);
});
it('renders Empty without virtual list', () => {
const { Empty } = composed;
render(<Empty />);
expect(screen.queryByTestId('discussions-virtual-list')).not.toBeInTheDocument();
expect(screen.getByText('No_Discussions_found')).toBeInTheDocument();
});
it('renders Loading without virtual list', () => {
const { Loading } = composed;
render(<Loading />);
expect(screen.queryByTestId('discussions-virtual-list')).not.toBeInTheDocument();
});
});
test.each(testCases)('renders %s with stable structure', (_storyname, Story) => {
const { baseElement } = render(<Story />);
expect(baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

View File

@@ -0,0 +1,63 @@
import { Contextualbar } from '@rocket.chat/ui-client';
import type { Meta } from '@storybook/react';
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
import { action } from 'storybook/actions';
import DiscussionsList from './DiscussionsList';
const loadMoreItemsAction = action('loadMoreItems');
const loadMoreItems = (async (options) => {
loadMoreItemsAction(options);
return {} as Awaited<ReturnType<UseInfiniteQueryResult['fetchNextPage']>>;
}) satisfies UseInfiniteQueryResult['fetchNextPage'];
export default {
component: DiscussionsList,
parameters: {
layout: 'fullscreen',
actions: { argTypesRegex: '^on.*' },
},
decorators: [(fn) => <Contextualbar height='100vh'>{fn()}</Contextualbar>],
args: {
text: '',
loadMoreItems,
},
} satisfies Meta<typeof DiscussionsList>;
const fakeDiscussions = Array.from({ length: 10 }, (_, i) => ({
_id: String(i),
msg: `Discussion ${i}`,
ts: new Date('2024-01-01T00:00:00Z'),
username: 'user.name',
dcount: 5,
dlm: new Date('2024-01-01T00:00:00Z'),
drid: `drid-${i}`,
rid: 'roomId',
_updatedAt: new Date('2024-01-01T00:00:00Z'),
u: {
_id: 'user-id',
username: 'user.name',
},
}));
export const Default = {
args: {
isSuccess: true,
discussions: fakeDiscussions,
itemCount: fakeDiscussions.length,
},
};
export const Loading = {
args: {
isPending: true,
},
};
export const Empty = {
args: {
isSuccess: true,
discussions: [],
itemCount: 0,
},
};

View File

@@ -0,0 +1,117 @@
import type { IDiscussionMessage } from '@rocket.chat/core-typings';
import { Box, Icon, TextInput, Callout, Throbber } from '@rocket.chat/fuselage';
import { useAutoFocus } from '@rocket.chat/fuselage-hooks';
import {
ContextualbarHeader,
ContextualbarIcon,
ContextualbarContent,
ContextualbarClose,
ContextualbarEmptyContent,
ContextualbarTitle,
ContextualbarSection,
ContextualbarDialog,
} from '@rocket.chat/ui-client';
import { useSetting } from '@rocket.chat/ui-contexts';
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
import type { ChangeEvent, MouseEvent, RefObject } from 'react';
import { useCallback, useId } from 'react';
import { useTranslation } from 'react-i18next';
import DiscussionsListRow from './DiscussionsListRow';
import { PaginatedVirtualList } from '../../../../components/PaginatedVirtualList';
import ResultsLiveRegion from '../../../../components/ResultsLiveRegion';
import { useGoToRoom } from '../../hooks/useGoToRoom';
export type DiscussionsListProps = {
itemCount: number;
discussions: Array<IDiscussionMessage>;
loadMoreItems: UseInfiniteQueryResult['fetchNextPage'];
isPending: boolean;
isSuccess: boolean;
onClose: () => void;
error: unknown;
text: string;
onChangeFilter: (e: ChangeEvent<HTMLInputElement>) => void;
};
function DiscussionsList({
itemCount,
discussions = [],
loadMoreItems,
isPending,
isSuccess,
onClose,
error,
text,
onChangeFilter,
}: DiscussionsListProps) {
const { t } = useTranslation();
const discussionListId = useId();
const showRealNames = useSetting('UI_Use_Real_Name', false);
const inputRef = useAutoFocus(true);
const goToRoom = useGoToRoom();
const onClick = useCallback(
(e: MouseEvent<HTMLElement>) => {
const { drid } = e.currentTarget.dataset;
if (drid) void goToRoom(drid);
},
[goToRoom],
);
return (
<ContextualbarDialog>
<ContextualbarHeader>
<ContextualbarIcon name='discussion' />
<ContextualbarTitle>{t('Discussions')}</ContextualbarTitle>
<ContextualbarClose onClick={onClose} />
</ContextualbarHeader>
<ContextualbarSection>
<TextInput
placeholder={t('Search_Messages')}
aria-label={t('Search_Messages')}
aria-controls={isSuccess ? discussionListId : undefined}
value={text}
onChange={onChangeFilter}
ref={inputRef as RefObject<HTMLInputElement>}
endAddon={<Icon name='magnifier' size='x20' />}
/>
</ContextualbarSection>
<ContextualbarContent paddingInline={0}>
<ResultsLiveRegion shouldAnnounce={isSuccess} itemCount={itemCount} />
{isPending && (
<Box paddingInline={24} paddingBlock={12}>
<Throbber size='x12' />
</Box>
)}
{error instanceof Error && (
<Callout marginInline={24} type='danger'>
{error.toString()}
</Callout>
)}
{isSuccess && (
<Box id={discussionListId} width='full' height='full' overflow='hidden' flexShrink={1}>
{discussions.length === 0 && <ContextualbarEmptyContent title={t('No_Discussions_found')} />}
{discussions.length > 0 && (
<Box height='full' width='full' style={{ minHeight: 0 }}>
<PaginatedVirtualList
items={discussions}
totalCount={itemCount}
overscan={25}
onEndReached={isPending ? undefined : loadMoreItems}
renderItem={(discussion) => (
<DiscussionsListRow discussion={discussion} showRealNames={showRealNames} onClick={onClick} />
)}
/>
</Box>
)}
</Box>
)}
</ContextualbarContent>
</ContextualbarDialog>
);
}
export default DiscussionsList;

View File

@@ -0,0 +1,54 @@
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { useUserId, useRoomToolbox } from '@rocket.chat/ui-contexts';
import type { ChangeEvent } from 'react';
import { useCallback, useMemo, useState } from 'react';
import DiscussionsList from './DiscussionsList';
import { useDiscussionsList } from './useDiscussionsList';
import { useRoom } from '../../contexts/RoomContext';
const DiscussionListContextBar = () => {
const userId = useUserId();
const room = useRoom();
const { closeTab } = useRoomToolbox();
const [text, setText] = useState('');
const debouncedText = useDebouncedValue(text, 400);
const options = useMemo(
() => ({
rid: room._id,
text: debouncedText,
}),
[room._id, debouncedText],
);
const { isPending, isSuccess, error, data, fetchNextPage } = useDiscussionsList(options);
const discussions = data?.items || [];
const itemCount = data?.itemCount ?? 0;
const handleTextChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setText(e.currentTarget.value);
}, []);
if (!userId) {
return null;
}
return (
<DiscussionsList
onClose={closeTab}
error={error}
discussions={discussions}
itemCount={itemCount}
isPending={isPending}
isSuccess={isSuccess}
loadMoreItems={fetchNextPage}
text={text}
onChangeFilter={handleTextChange}
/>
);
};
export default DiscussionListContextBar;

View File

@@ -0,0 +1,41 @@
import type { IDiscussionMessage } from '@rocket.chat/core-typings';
import type { MouseEvent } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import DiscussionsListItem from './components/DiscussionsListItem';
import { useTimeAgo } from '../../../../hooks/useTimeAgo';
import { normalizeThreadMessage } from '../../../../lib/normalizeThreadMessage';
export type DiscussionListRowProps = {
discussion: IDiscussionMessage;
showRealNames: boolean;
onClick: (e: MouseEvent<HTMLElement>) => void;
};
function DiscussionListRow({ discussion, showRealNames, onClick }: DiscussionListRowProps) {
const { t } = useTranslation();
const formatDate = useTimeAgo();
const msg = normalizeThreadMessage(discussion, t);
const { name = discussion.u.username } = discussion.u;
return (
<DiscussionsListItem
_id={discussion._id}
emoji={discussion.emoji}
dcount={discussion.dcount}
dlm={discussion.dlm}
name={showRealNames ? name : discussion.u.username}
username={discussion.u.username}
data-drid={discussion.drid}
ts={discussion.ts}
msg={msg}
formatDate={formatDate}
onClick={onClick}
/>
);
}
export default memo(DiscussionListRow);

View File

@@ -0,0 +1,30 @@
import DiscussionsListItem from './DiscussionsListItem';
const message = {
msg: 'hello world',
ts: new Date(0),
username: 'guilherme.gazzo',
dcount: 5,
dlm: new Date(0),
formatDate: (date: Date) => date.toISOString(),
};
const largeText = {
...message,
msg: 'Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text',
};
const noReplies = {
...message,
dcount: 0,
};
export default {
component: DiscussionsListItem,
};
export const Basic = () => <DiscussionsListItem {...(message as any)} />;
export const LargeText = () => <DiscussionsListItem {...(largeText as any)} />;
export const NoReplies = () => <DiscussionsListItem {...(noReplies as any)} />;

View File

@@ -0,0 +1,88 @@
import type { IDiscussionMessage } from '@rocket.chat/core-typings';
import {
Box,
Message,
MessageLeftContainer,
MessageContainer,
MessageHeader,
MessageName,
MessageTimestamp,
MessageBody,
MessageBlock,
MessageMetrics,
MessageMetricsItem,
MessageMetricsItemLabel,
MessageMetricsItemIcon,
} from '@rocket.chat/fuselage';
import { MessageAvatar } from '@rocket.chat/ui-avatar';
import type { ComponentProps, ReactNode } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import Emoji from '../../../../../components/Emoji';
import { clickableItem } from '../../../../../lib/clickableItem';
export type DiscussionListItemProps = {
_id: IDiscussionMessage['_id'];
msg: ReactNode;
dcount: number;
dlm: Date | undefined;
formatDate: (date: Date) => string;
username: IDiscussionMessage['u']['username'];
name?: IDiscussionMessage['u']['name'];
ts: IDiscussionMessage['ts'];
emoji: IDiscussionMessage['emoji'];
} & Omit<ComponentProps<typeof Box>, 'is'>;
const DiscussionListItem = ({
_id,
msg,
username,
name = username,
ts,
dcount,
formatDate,
dlm,
className = [],
emoji,
...props
}: DiscussionListItemProps) => {
const { t } = useTranslation();
return (
<Box is={Message} {...props} className={className} paddingBlockStart={16} paddingBlockEnd={8}>
<MessageLeftContainer>
<MessageAvatar emoji={emoji ? <Emoji emojiHandle={emoji} fillContainer /> : undefined} username={username} size='x36' />
</MessageLeftContainer>
<MessageContainer>
<MessageHeader>
<MessageName title={username}>{name}</MessageName>
<MessageTimestamp>{formatDate(ts)}</MessageTimestamp>
</MessageHeader>
<MessageBody clamp={2}>{msg}</MessageBody>
<MessageBlock>
<MessageMetrics>
{!dcount && (
<MessageMetricsItem>
<MessageMetricsItemLabel>{t('No_messages_yet')}</MessageMetricsItemLabel>
</MessageMetricsItem>
)}
{!!dcount && (
<MessageMetricsItem>
<MessageMetricsItemIcon name='discussion' />
<MessageMetricsItemLabel>{dcount}</MessageMetricsItemLabel>
</MessageMetricsItem>
)}
{!!dcount && (
<MessageMetricsItem>
<MessageMetricsItemIcon name='clock' />
<MessageMetricsItemLabel>{dlm ? formatDate(dlm) : undefined}</MessageMetricsItemLabel>
</MessageMetricsItem>
)}
</MessageMetrics>
</MessageBlock>
</MessageContainer>
</Box>
);
};
export default memo(clickableItem(DiscussionListItem));

View File

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

View File

@@ -0,0 +1,63 @@
import type { IDiscussionMessage, IMessage } from '@rocket.chat/core-typings';
import { escapeRegExp } from '@rocket.chat/tools';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInfiniteMessageQueryUpdates } from '../../../../hooks/useInfiniteMessageQueryUpdates';
import { roomsQueryKeys } from '../../../../lib/queryKeys';
import { getConfig } from '../../../../lib/utils/getConfig';
import { mapMessageFromApi } from '../../../../lib/utils/mapMessageFromApi';
export const useDiscussionsList = ({ rid, text }: { rid: IMessage['rid']; text?: string }) => {
const getDiscussions = useEndpoint('GET', '/v1/chat.getDiscussions');
const count = parseInt(`${getConfig('discussionListSize', 10)}`, 10);
useInfiniteMessageQueryUpdates({
queryKey: roomsQueryKeys.discussions(rid, { text }),
roomId: rid,
// Replicates the filtering done server-side
filter: (message): message is IDiscussionMessage => {
if (!('drid' in message)) {
return false;
}
if (text) {
const regex = new RegExp(escapeRegExp(text), 'i');
if (!regex.test(message.msg)) {
return false;
}
}
return true;
},
});
return useInfiniteQuery({
queryKey: roomsQueryKeys.discussions(rid, { text }),
queryFn: async ({ pageParam: offset }) => {
const { messages, total } = await getDiscussions({
roomId: rid,
text,
offset,
count,
});
return {
items: messages.map(mapMessageFromApi) as IDiscussionMessage[],
itemCount: total,
};
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
// FIXME: This is an estimation, as discussions can be created or removed while paginating
// Ideally, the server should return the next offset to use or the pagination should be done using "createdAt" or "updatedAt"
const loadedItemsCount = allPages.reduce((acc, page) => acc + page.items.length, 0);
return loadedItemsCount < lastPage.itemCount ? loadedItemsCount : undefined;
},
select: ({ pages }) => ({
items: pages.flatMap((page) => page.items),
itemCount: pages.at(-1)?.itemCount ?? 0,
}),
});
};

View File

@@ -0,0 +1,17 @@
import { Contextualbar } from '@rocket.chat/ui-client';
import type { StoryObj, Meta } from '@storybook/react';
import ExportMessages from './index';
export default {
component: ExportMessages,
parameters: {
layout: 'fullscreen',
},
decorators: [(fn) => <Contextualbar height='100vh'>{fn()}</Contextualbar>],
} satisfies Meta<typeof ExportMessages>;
export const Default: StoryObj<typeof ExportMessages> = {
render: () => <ExportMessages />,
name: 'ExportMessages',
};

View File

@@ -0,0 +1,397 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { ButtonGroup, Button, Icon, InputBox, Callout } from '@rocket.chat/fuselage';
import { FieldError, Field, FieldLabel, FieldRow, TextAreaInput, FieldGroup, Select, TextInput } from '@rocket.chat/fuselage-forms';
import { useAutoFocus } from '@rocket.chat/fuselage-hooks';
import { validateEmail } from '@rocket.chat/tools';
import {
ContextualbarHeader,
ContextualbarScrollableContent,
ContextualbarIcon,
ContextualbarTitle,
ContextualbarClose,
ContextualbarFooter,
ContextualbarDialog,
} from '@rocket.chat/ui-client';
import { usePermission, useRoomToolbox } from '@rocket.chat/ui-contexts';
import { useContext, useEffect, useId, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useDownloadExportMutation } from './useDownloadExportMutation';
import { useExportMessagesAsPDFMutation } from './useExportMessagesAsPDFMutation';
import { useRoomExportMutation } from './useRoomExportMutation';
import UserAutoCompleteMultiple from '../../../../components/UserAutoCompleteMultiple';
import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator';
import { SelectedMessageContext, useCountSelected } from '../../MessageList/contexts/SelectedMessagesContext';
import { useRoom } from '../../contexts/RoomContext';
export type ExportMessagesFormValues = {
type: 'email' | 'file' | 'download';
dateFrom: string;
dateTo: string;
format: 'html' | 'json' | 'pdf';
toUsers: string[];
additionalEmails: string;
messagesCount: number;
subject: string;
};
const ExportMessages = () => {
const { t } = useTranslation();
const { closeTab } = useRoomToolbox();
const pfdExportPermission = usePermission('export-messages-as-pdf');
const formFocus = useAutoFocus<HTMLFormElement>();
const room = useRoom();
const isE2ERoom = room.encrypted;
const roomName = room?.t && roomCoordinator.getRoomName(room.t, room);
const {
control,
formState: { errors, isSubmitting, isDirty, isSubmitted },
watch,
register,
setValue,
handleSubmit,
clearErrors,
reset,
} = useForm<ExportMessagesFormValues>({
defaultValues: {
type: isE2ERoom ? 'download' : 'email',
dateFrom: '',
dateTo: '',
toUsers: [],
additionalEmails: '',
messagesCount: 0,
subject: t('Mail_Messages_Subject', { roomName }),
format: isE2ERoom ? 'json' : 'html',
},
});
const exportOptions = useMemo<SelectOption[]>(
() => [
['email', t('Send_email')],
['file', t('Send_file_via_email')],
['download', t('Download_file')],
],
[t],
);
const outputOptions = useMemo<SelectOption[]>(() => {
const options: SelectOption[] = [
['html', t('HTML')],
['json', t('JSON')],
];
if (pfdExportPermission) {
options.push(['pdf', t('PDF')]);
}
return options;
}, [t, pfdExportPermission]);
// Remove HTML from download options
const downloadOutputOptions = useMemo<SelectOption[]>(() => {
return outputOptions.filter((option) => option[0] !== 'html');
}, [outputOptions]);
// Remove PDF from file options
const fileOutputOptions = useMemo<SelectOption[]>(() => {
return outputOptions.filter((option) => option[0] !== 'pdf');
}, [outputOptions]);
const { mutateAsync: exportRoom } = useRoomExportMutation();
const { mutateAsync: exportAndDownload } = useDownloadExportMutation();
const { selectedMessageStore } = useContext(SelectedMessageContext);
const messageCount = useCountSelected();
const { type, toUsers } = watch();
useEffect(() => {
if (type === 'email') {
setValue('format', 'html');
}
if (type === 'download') {
setValue('format', 'json');
}
}, [type, setValue]);
useEffect(() => {
if (type !== 'file') {
selectedMessageStore.setIsSelecting(true);
}
return (): void => {
selectedMessageStore.reset();
};
}, [type, selectedMessageStore]);
useEffect(() => {
setValue('messagesCount', messageCount, { shouldDirty: true, shouldValidate: isSubmitted });
}, [messageCount, setValue, isSubmitted]);
const { mutateAsync: exportAsPDF } = useExportMessagesAsPDFMutation();
const handleExport = async ({
type,
toUsers,
dateFrom,
dateTo,
format,
subject,
additionalEmails,
}: ExportMessagesFormValues): Promise<void> => {
const messages = selectedMessageStore.getSelectedMessages();
if (type === 'download') {
if (format === 'pdf') {
await exportAsPDF(messages);
return;
}
if (format === 'json') {
await exportAndDownload({
mids: messages,
});
return;
}
}
if (type === 'file') {
await exportRoom({
rid: room._id,
type: 'file',
...(dateFrom && { dateFrom }),
...(dateTo && { dateTo }),
format: format as 'html' | 'json',
});
return;
}
await exportRoom({
rid: room._id,
type: 'email',
toUsers,
toEmails: additionalEmails?.split(','),
subject,
messages,
});
};
const formId = useId();
const dateFromField = useId();
const dateToField = useId();
return (
<ContextualbarDialog aria-labelledby={`${formId}-title`}>
<ContextualbarHeader>
<ContextualbarIcon name='mail' />
<ContextualbarTitle id={`${formId}-title`}>{t('Export_Messages')}</ContextualbarTitle>
<ContextualbarClose onClick={closeTab} />
</ContextualbarHeader>
<ContextualbarScrollableContent>
<form ref={formFocus} tabIndex={-1} aria-labelledby={`${formId}-title`} id={formId} onSubmit={handleSubmit(handleExport)}>
<FieldGroup>
<Field>
<FieldLabel>{t('Method')}</FieldLabel>
<FieldRow>
<Controller
name='type'
control={control}
render={({ field }) => (
<Select
data-testid='export-messages-method'
{...field}
placeholder={t('Type')}
disabled={isE2ERoom}
options={exportOptions}
/>
)}
/>
</FieldRow>
</Field>
<Field>
<FieldLabel>{t('Output_format')}</FieldLabel>
<FieldRow>
<Controller
name='format'
control={control}
render={({ field }) => {
let options: SelectOption[];
if (type === 'download') {
options = downloadOutputOptions;
} else if (type === 'file') {
options = fileOutputOptions;
} else {
options = outputOptions;
}
return (
<Select
{...field}
data-testid='export-messages-output-format'
disabled={type === 'email'}
placeholder={t('Format')}
options={options}
/>
);
}}
/>
</FieldRow>
</Field>
{type === 'file' && (
<>
<Field>
<FieldLabel htmlFor={dateFromField}>{t('Date_From')}</FieldLabel>
<FieldRow>
<Controller
name='dateFrom'
control={control}
render={({ field }) => <InputBox id={dateFromField} type='date' {...field} />}
/>
</FieldRow>
</Field>
<Field>
<FieldLabel htmlFor={dateToField}>{t('Date_to')}</FieldLabel>
<FieldRow>
<Controller
name='dateTo'
control={control}
render={({ field }) => <InputBox id={dateToField} {...field} type='date' />}
/>
</FieldRow>
</Field>
</>
)}
{type === 'email' && (
<>
<Field>
<FieldLabel>{t('To_users')}</FieldLabel>
<FieldRow>
<Controller
name='toUsers'
control={control}
rules={{
validate: {
validateRecipient: (toUsers) => {
const additionalEmails = watch('additionalEmails');
if (toUsers?.length > 0 || additionalEmails !== '') {
return undefined;
}
return t('Mail_Message_Missing_to');
},
},
}}
render={({ field: { value, onChange, onBlur, name, ...field } }) => (
<UserAutoCompleteMultiple
{...field}
value={value}
onChange={(value) => {
onChange(value);
clearErrors('additionalEmails');
}}
onBlur={onBlur}
name={name}
error={errors?.toUsers?.message}
/>
)}
/>
</FieldRow>
{errors?.toUsers && <FieldError>{errors.toUsers.message}</FieldError>}
</Field>
<Field>
<FieldLabel>{t('To_additional_emails')}</FieldLabel>
<FieldRow>
<Controller
name='additionalEmails'
control={control}
rules={{
validate: {
validateEmail: (additionalEmails) => {
if (additionalEmails === '') {
return undefined;
}
const emails = additionalEmails?.split(',').map((email) => email.trim());
if (Array.isArray(emails) && emails.every((email) => validateEmail(email.trim()))) {
return undefined;
}
return t('Mail_Message_Invalid_emails', { emails: additionalEmails });
},
validateRecipient: (additionalEmails) => {
if (additionalEmails !== '' || toUsers?.length > 0) {
return undefined;
}
return t('Mail_Message_Missing_to');
},
},
}}
render={({ field: { onChange, onBlur, ...field } }) => (
<TextInput
{...field}
onChange={(e) => {
onChange(e);
clearErrors('toUsers');
}}
onBlur={onBlur}
placeholder={t('Email_Placeholder_any')}
endAddon={<Icon name='mail' size='x20' />}
error={errors?.additionalEmails?.message}
/>
)}
/>
</FieldRow>
{errors?.additionalEmails && <FieldError>{errors.additionalEmails.message}</FieldError>}
</Field>
<Field>
<FieldLabel>{t('Subject')}</FieldLabel>
<FieldRow>
<Controller
name='subject'
control={control}
render={({ field }) => <TextAreaInput rows={3} {...field} endAddon={<Icon name='edit' size='x20' />} />}
/>
</FieldRow>
</Field>
</>
)}
{type !== 'file' && (
<>
<input
type='hidden'
{...register('messagesCount', {
validate: (messagesCount) => (messagesCount > 0 ? undefined : t('Mail_Message_No_messages_selected_select_all')),
})}
/>
{errors.messagesCount && (
<Field>
<Callout role='alert' type='danger'>
{errors.messagesCount.message}
</Callout>
</Field>
)}
</>
)}
</FieldGroup>
</form>
</ContextualbarScrollableContent>
<ContextualbarFooter>
<ButtonGroup stretch>
<Button type='reset' disabled={!isDirty || isSubmitting} onClick={() => reset()}>
{t('Reset')}
</Button>
<Button loading={isSubmitting} form={formId} primary type='submit'>
{type === 'download' ? t('Download') : t('Send')}
</Button>
</ButtonGroup>
</ContextualbarFooter>
</ContextualbarDialog>
);
};
export default ExportMessages;

View File

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

View File

@@ -0,0 +1,69 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { useToastMessageDispatch, useUser } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { downloadJsonAs } from '../../../../lib/download';
import { Messages } from '../../../../stores';
import { useRoom } from '../../contexts/RoomContext';
export const useDownloadExportMutation = () => {
const { t } = useTranslation();
const room = useRoom();
const user = useUser();
const dispatchToastMessage = useToastMessageDispatch();
const filterMessages = Messages.use((state) => state.filter);
return useMutation({
mutationFn: async ({ mids }: { mids: IMessage['_id'][] }) => {
const messages = filterMessages((record) => mids.includes(record._id) || (!!record.tmid && mids.includes(record.tmid))).map(
({ _id, ts, u, msg, _updatedAt, tlm, replies, tmid, attachments }) => ({
_id,
ts,
u,
msg,
_updatedAt,
tlm,
replies,
tmid,
attachments:
attachments?.map((attachment) => ({
ts: attachment.ts,
title: attachment.title,
title_link: attachment.title_link,
title_link_download: attachment.title_link_download,
...('image_dimensions' in attachment && { image_dimensions: attachment.image_dimensions }),
...('image_preview' in attachment && { image_preview: attachment.image_preview }),
...('image_url' in attachment && { image_url: attachment.image_url }),
...('image_type' in attachment && { image_type: attachment.image_type }),
...('image_size' in attachment && { image_size: attachment.image_size }),
...('type' in attachment && { type: attachment.type }),
description: attachment.description,
})) ?? [],
}),
);
const fileData = {
roomId: room._id,
roomName: room.fname || room.name,
userExport: {
id: user?._id,
username: user?.username,
name: user?.name,
roles: user?.roles,
},
exportDate: new Date().toISOString(),
messages,
};
return downloadJsonAs(fileData, `exportedMessages-${new Date().toISOString()}`);
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
onSuccess: () => {
dispatchToastMessage({ type: 'success', message: t('Messages_exported_successfully') });
},
});
};

View File

@@ -0,0 +1,196 @@
import { Document, Font, Image, Page, pdf, StyleSheet, Text, View } from '@react-pdf/renderer';
import type { IMessage, MessageAttachmentDefault } from '@rocket.chat/core-typings';
import { MessageTypes } from '@rocket.chat/message-types';
import { escapeHTML } from '@rocket.chat/tools';
import { useSetting, useToastMessageDispatch, useAbsoluteUrl } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime';
import { Messages } from '../../../../stores';
const leftTab = {
marginLeft: 20,
};
const NOTO_SANS_FONTS: { name: string; fontSrc: string }[] = [
{ name: 'Noto Sans Hebrew', fontSrc: '/fonts/NotoSansHebrew-Regular.ttf' },
{ name: 'Noto Sans', fontSrc: '/fonts/NotoSans-Regular.ttf' },
{ name: 'Noto Sans Arabic', fontSrc: '/fonts/NotoSansArabic-Regular.ttf' },
{ name: 'Noto Sans Devanagari', fontSrc: '/fonts/NotoSansDevanagari-Regular.ttf' },
{ name: 'Noto Sans Bengali', fontSrc: '/fonts/NotoSansBengali-Regular.ttf' },
{ name: 'Noto Sans Tamil', fontSrc: '/fonts/NotoSansTamil-Regular.ttf' },
{ name: 'Noto Sans Sinhala', fontSrc: '/fonts/NotoSansSinhala-Regular.ttf' },
{ name: 'Noto Sans Thai', fontSrc: '/fonts/NotoSansThai-Regular.ttf' },
{ name: 'Noto Sans Lao', fontSrc: '/fonts/NotoSansLao-Regular.ttf' },
{ name: 'Noto Sans Georgian', fontSrc: '/fonts/NotoSansGeorgian-Regular.ttf' },
{ name: 'Noto Sans JP', fontSrc: '/fonts/NotoSansJP-Regular.ttf' },
{ name: 'Noto Sans KR', fontSrc: '/fonts/NotoSansKR-Regular.ttf' },
{ name: 'Noto Sans SC', fontSrc: '/fonts/NotoSansSC-Regular.ttf' },
{ name: 'Noto Sans TC', fontSrc: '/fonts/NotoSansTC-Regular.ttf' },
{ name: 'Noto Sans HK', fontSrc: '/fonts/NotoSansHK-Regular.ttf' },
];
const EMOJI_RENDER_SIZE = 72;
const renderEmojiToDataURL = (codePoints: string): string => {
const points = codePoints.split('-').map((codePoint) => parseInt(codePoint, 16));
if (points.some((point) => !Number.isInteger(point) || point < 0 || point > 0x10ffff)) {
return '';
}
const emoji = String.fromCodePoint(...points);
const canvas = document.createElement('canvas');
canvas.width = EMOJI_RENDER_SIZE;
canvas.height = EMOJI_RENDER_SIZE;
const ctx = canvas.getContext('2d');
if (!ctx) {
return '';
}
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
ctx.font = `${Math.floor(EMOJI_RENDER_SIZE * 0.8)}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Noto Emoji", sans-serif`;
ctx.fillText(emoji, EMOJI_RENDER_SIZE / 2, EMOJI_RENDER_SIZE / 2);
return canvas.toDataURL('image/png');
};
const pdfStyles = StyleSheet.create({
page: {
fontFamily: NOTO_SANS_FONTS.map((font) => font.name),
},
messageHeader: {
display: 'flex',
flexDirection: 'row',
alignItems: 'flex-end',
gap: 10,
},
username: {
color: '#000',
fontSize: 14,
},
dateTime: {
color: '#aaa',
fontSize: 12,
},
threadMessagesCount: {
color: '#000',
fontSize: 14,
},
threadMessage: {
color: '#555',
fontSize: 12,
...leftTab,
},
message: {
color: '#555',
fontSize: 14,
},
});
export const useExportMessagesAsPDFMutation = () => {
const { t } = useTranslation();
const chatopsUsername = useSetting('Chatops_Username');
const formatDateAndTime = useFormatDateAndTime();
const dispatchToastMessage = useToastMessageDispatch();
const absoluteUrl = useAbsoluteUrl();
useEffect(() => {
for (const font of NOTO_SANS_FONTS) {
Font.register({
family: font.name,
fonts: [{ src: absoluteUrl(font.fontSrc) }],
});
}
// Manually build the emoji data to avoid relying on a cdn
// https://react-pdf.org/fonts#registeremojisource
Font.registerEmojiSource({
withVariationSelectors: true,
builder: renderEmojiToDataURL,
});
}, []);
return useMutation({
mutationFn: async (messageIds: IMessage['_id'][]) => {
const parseMessage = (msg: IMessage) => {
const messageType = MessageTypes.getType(msg);
if (messageType) {
return messageType.text(t, msg);
}
if (msg.u && msg.u.username === chatopsUsername) {
msg.html = msg.msg;
return msg.html;
}
msg.html = msg.msg;
if (msg.html.trim() !== '') {
msg.html = escapeHTML(msg.html);
}
return msg.html;
};
const messages = Messages.state.filter((record) => messageIds.includes(record._id)).sort((a, b) => a.ts.getTime() - b.ts.getTime());
const jsx = (
<Document>
<Page size='A4'>
<View style={{ ...pdfStyles.page, margin: 10 }}>
{messages.map((message) => {
const dateTime = formatDateAndTime(message.ts);
return (
<View key={message._id} style={{ marginBottom: 10 }}>
<View style={pdfStyles.messageHeader}>
<Text style={{ ...(message.tmid ? leftTab : {}), ...pdfStyles.username }}>{message.u.username}</Text>
<Text style={pdfStyles.dateTime}>{dateTime}</Text>
{message.tcount && <Text style={pdfStyles.threadMessagesCount}>{`${message.tcount} ${t('thread_messages')}`}</Text>}
</View>
<Text style={message.tmid ? pdfStyles.threadMessage : pdfStyles.message}>{parseMessage(message)}</Text>
{message.attachments?.map((attachment: MessageAttachmentDefault, index) => (
<View key={index}>
{attachment.description && <Text style={pdfStyles.message}>{attachment.description}</Text>}
{attachment.image_url && <Image src={attachment.title_link} style={attachment.image_dimensions} />}
<Text style={pdfStyles.message}>{attachment.title}</Text>
</View>
))}
</View>
);
})}
</View>
</Page>
</Document>
);
const instance = pdf();
await new Promise<void>((resolve, reject) => {
const callback = async () => {
const link = document.createElement('a');
link.href = URL.createObjectURL(await instance.toBlob());
link.download = `exportedMessages-${new Date().toISOString()}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
resolve();
};
try {
instance.on('change', callback);
instance.updateContainer(jsx);
} catch (error) {
reject(error);
} finally {
instance.removeListener('change', callback);
}
});
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
onSuccess: () => {
dispatchToastMessage({ type: 'success', message: t('Messages_exported_successfully') });
},
});
};

View File

@@ -0,0 +1,25 @@
import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
export const useRoomExportMutation = () => {
const { t } = useTranslation();
const roomsExport = useEndpoint('POST', '/v1/rooms.export');
const dispatchToastMessage = useToastMessageDispatch();
return useMutation({
mutationFn: roomsExport,
onSuccess: () => {
dispatchToastMessage({
type: 'success',
message: t('Your_email_has_been_queued_for_sending'),
});
},
onError: (error) => {
dispatchToastMessage({
type: 'error',
message: error,
});
},
});
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import MessageListTab from './MessageListTab';
import { mapMessageFromApi } from '../../../lib/utils/mapMessageFromApi';
import { useRoom } from '../contexts/RoomContext';
const MentionsTab = () => {
const getMentionedMessages = useEndpoint('GET', '/v1/chat.getMentionedMessages');
const room = useRoom();
const mentionedMessagesQueryResult = useQuery({
queryKey: ['rooms', room._id, 'mentioned-messages'] as const,
queryFn: async () => {
const messages: IMessage[] = [];
for (
let offset = 0, result = await getMentionedMessages({ roomId: room._id, offset: 0 });
result.count > 0;
offset += result.count, result = await getMentionedMessages({ roomId: room._id, offset })
) {
messages.push(...result.messages.map(mapMessageFromApi));
}
return messages;
},
});
const { t } = useTranslation();
return (
<MessageListTab
iconName='at'
title={t('Mentions')}
emptyResultMessage={t('No_mentions_found')}
context='mentions'
queryResult={mentionedMessagesQueryResult}
/>
);
};
export default MentionsTab;

View File

@@ -0,0 +1,119 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { Box, MessageDivider, Throbber } from '@rocket.chat/fuselage';
import type { Keys as IconName } from '@rocket.chat/icons';
import { MessageTypes } from '@rocket.chat/message-types';
import {
VirtualizedScrollbars,
ContextualbarContent,
ContextualbarHeader,
ContextualbarIcon,
ContextualbarTitle,
ContextualbarClose,
ContextualbarEmptyContent,
ContextualbarDialog,
} from '@rocket.chat/ui-client';
import { useUserPreference, useRoomToolbox } from '@rocket.chat/ui-contexts';
import type { UseQueryResult } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useCallback } from 'react';
import { Virtuoso } from 'react-virtuoso';
import RoomMessage from '../../../components/message/variants/RoomMessage';
import SystemMessage from '../../../components/message/variants/SystemMessage';
import { useFormatDate } from '../../../hooks/useFormatDate';
import type { MessageActionContext } from '../../../lib/MessageAction';
import MessageListErrorBoundary from '../MessageList/MessageListErrorBoundary';
import { isMessageNewDay } from '../MessageList/lib/isMessageNewDay';
import MessageListProvider from '../MessageList/providers/MessageListProvider';
import { useRoomSubscription } from '../contexts/RoomContext';
export type MessageListTabProps = {
iconName: IconName;
title: ReactNode;
emptyResultMessage: string;
context: MessageActionContext;
queryResult: UseQueryResult<IMessage[]>;
};
const MessageListTab = ({ iconName, title, emptyResultMessage, context, queryResult }: MessageListTabProps) => {
const formatDate = useFormatDate();
const showUserAvatar = !!useUserPreference<boolean>('displayAvatars');
const { closeTab } = useRoomToolbox();
const handleTabBarCloseButtonClick = useCallback(() => {
closeTab();
}, [closeTab]);
const subscription = useRoomSubscription();
return (
<ContextualbarDialog>
<ContextualbarHeader>
<ContextualbarIcon name={iconName} />
<ContextualbarTitle>{title}</ContextualbarTitle>
<ContextualbarClose onClick={handleTabBarCloseButtonClick} />
</ContextualbarHeader>
<ContextualbarContent flexShrink={1} flexGrow={1} paddingInline={0}>
{queryResult.isLoading && (
<Box paddingInline={24} paddingBlock={12}>
<Throbber size='x12' />
</Box>
)}
{queryResult.isSuccess && (
<>
{queryResult.data.length === 0 && <ContextualbarEmptyContent title={emptyResultMessage} />}
{queryResult.data.length > 0 && (
<MessageListErrorBoundary>
<MessageListProvider>
<Box is='section' display='flex' flexDirection='column' flexGrow={1} flexShrink={1} flexBasis='auto' height='full'>
<VirtualizedScrollbars>
<Virtuoso
totalCount={queryResult.data.length}
overscan={25}
data={queryResult.data}
itemContent={(index, message) => {
const previous = queryResult.data[index - 1];
const newDay = isMessageNewDay(message, previous);
const system = MessageTypes.isSystemMessage(message);
const unread = subscription?.tunread?.includes(message._id) ?? false;
const mention = subscription?.tunreadUser?.includes(message._id) ?? false;
const all = subscription?.tunreadGroup?.includes(message._id) ?? false;
return (
<>
{newDay && <MessageDivider>{formatDate(message.ts)}</MessageDivider>}
{system ? (
<SystemMessage message={message} showUserAvatar={showUserAvatar} />
) : (
<RoomMessage
message={message}
sequential={false}
unread={unread}
mention={mention}
all={all}
context={context}
showUserAvatar={showUserAvatar}
/>
)}
</>
);
}}
/>
</VirtualizedScrollbars>
</Box>
</MessageListProvider>
</MessageListErrorBoundary>
)}
</>
)}
</ContextualbarContent>
</ContextualbarDialog>
);
};
export default MessageListTab;

View File

@@ -0,0 +1,133 @@
import { Callout, Box, MessageDivider, Throbber } from '@rocket.chat/fuselage';
import { MessageTypes } from '@rocket.chat/message-types';
import {
ContextualbarClose,
ContextualbarContent,
ContextualbarHeader,
ContextualbarTitle,
ContextualbarIcon,
ContextualbarSection,
ContextualbarDialog,
VirtualizedScrollbars,
ContextualbarEmptyContent,
} from '@rocket.chat/ui-client';
import { useRoomToolbox, useUserPreference, useSetting } from '@rocket.chat/ui-contexts';
import { useState, memo, Fragment, useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Virtuoso } from 'react-virtuoso';
import MessageSearchForm from './components/MessageSearchForm';
import { useMessageSearchProviderQuery } from './hooks/useMessageSearchProviderQuery';
import { useMessageSearchQuery } from './hooks/useMessageSearchQuery';
import ResultsLiveRegion from '../../../../components/ResultsLiveRegion';
import RoomMessage from '../../../../components/message/variants/RoomMessage';
import SystemMessage from '../../../../components/message/variants/SystemMessage';
import { useFormatDate } from '../../../../hooks/useFormatDate';
import MessageListErrorBoundary from '../../MessageList/MessageListErrorBoundary';
import { isMessageNewDay } from '../../MessageList/lib/isMessageNewDay';
import MessageListProvider from '../../MessageList/providers/MessageListProvider';
import { useRoomSubscription } from '../../contexts/RoomContext';
// TODO: Refactor this component to isolate the data from the visual
const MessageSearchTab = () => {
const { t } = useTranslation();
const searchListId = useId();
const formatDate = useFormatDate();
const { closeTab } = useRoomToolbox();
const pageSize = useSetting('PageSize', 10);
const [limit, setLimit] = useState(pageSize);
const subscription = useRoomSubscription();
const showUserAvatar = !!useUserPreference<boolean>('displayAvatars');
const providerQuery = useMessageSearchProviderQuery();
const [{ searchText, globalSearch }, handleSearch] = useState({ searchText: '', globalSearch: false });
const { isSuccess, data: messageSearchData, isPending } = useMessageSearchQuery({ searchText, limit, globalSearch });
const itemCount = messageSearchData?.length ?? 0;
return (
<ContextualbarDialog>
<ContextualbarHeader>
<ContextualbarIcon name='magnifier' />
<ContextualbarTitle>{t('Search_Messages')}</ContextualbarTitle>
<ContextualbarClose onClick={closeTab} />
</ContextualbarHeader>
{providerQuery.data && (
<ContextualbarSection>
<MessageSearchForm provider={providerQuery.data} onSearch={handleSearch} searchListId={searchListId} isSuccess={isSuccess} />
</ContextualbarSection>
)}
<ContextualbarContent flexShrink={1} flexGrow={1} paddingInline={0}>
<ResultsLiveRegion shouldAnnounce={isSuccess} itemCount={itemCount} />
{providerQuery.isSuccess && (
<>
{searchText && isPending && <Throbber />}
{isSuccess && (
<Box id={searchListId} display='flex' flexDirection='column' flexGrow={1} flexShrink={1} flexBasis={0}>
{messageSearchData.length === 0 && <ContextualbarEmptyContent title={t('No_results_found')} />}
{messageSearchData.length > 0 && (
<MessageListErrorBoundary>
<MessageListProvider>
<Box is='section' display='flex' flexDirection='column' flexGrow={1} flexShrink={1} flexBasis='auto' height='full'>
<VirtualizedScrollbars>
<Virtuoso
totalCount={messageSearchData.length}
overscan={25}
data={messageSearchData}
itemContent={(index, message) => {
const previous = messageSearchData[index - 1];
const newDay = isMessageNewDay(message, previous);
const system = MessageTypes.isSystemMessage(message);
const unread = subscription?.tunread?.includes(message._id) ?? false;
const mention = subscription?.tunreadUser?.includes(message._id) ?? false;
const all = subscription?.tunreadGroup?.includes(message._id) ?? false;
return (
<Fragment key={message._id}>
{newDay && <MessageDivider>{formatDate(message.ts)}</MessageDivider>}
{system ? (
<SystemMessage message={message} showUserAvatar={showUserAvatar} />
) : (
<RoomMessage
message={message}
sequential={false}
unread={unread}
mention={mention}
all={all}
context='search'
searchText={searchText}
showUserAvatar={showUserAvatar}
/>
)}
</Fragment>
);
}}
endReached={() => {
setLimit((limit) => limit + pageSize);
}}
/>
</VirtualizedScrollbars>
</Box>
</MessageListProvider>
</MessageListErrorBoundary>
)}
</Box>
)}
</>
)}
{providerQuery.isError && (
<Callout margin={24} type='danger'>
{t('Search_current_provider_not_active')}
</Callout>
)}
</ContextualbarContent>
</ContextualbarDialog>
);
};
export default memo(MessageSearchTab);

View File

@@ -0,0 +1,84 @@
import type { IMessageSearchProvider } from '@rocket.chat/core-typings';
import { Box, Field, FieldLabel, FieldHint, Icon, TextInput, ToggleSwitch, Callout } from '@rocket.chat/fuselage';
import { useDebouncedCallback, useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import DOMPurify from 'dompurify';
import { useEffect, useId } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { getRoomTypeTranslation } from '../../../../../lib/getRoomTypeTranslation';
import { useRoom } from '../../../contexts/RoomContext';
export type MessageSearchFormProps = {
provider: IMessageSearchProvider;
onSearch: (params: { searchText: string; globalSearch: boolean }) => void;
searchListId: string;
isSuccess: boolean;
};
const MessageSearchForm = ({ provider, onSearch, searchListId, isSuccess }: MessageSearchFormProps) => {
const { handleSubmit, register, setFocus, control } = useForm({
defaultValues: {
searchText: '',
globalSearch: false,
},
});
const room = useRoom();
useEffect(() => {
setFocus('searchText');
}, [setFocus]);
const debouncedOnSearch = useDebouncedCallback(useStableCallback(onSearch), 300);
const submitHandler = handleSubmit(({ searchText, globalSearch }) => {
debouncedOnSearch.cancel();
onSearch({ searchText, globalSearch });
});
const searchText = useWatch({ control, name: 'searchText' });
const globalSearch = useWatch({ control, name: 'globalSearch' });
useEffect(() => {
debouncedOnSearch({ searchText, globalSearch });
}, [debouncedOnSearch, searchText, globalSearch]);
const globalSearchEnabled = provider.settings.GlobalSearchEnabled;
const globalSearchToggleId = useId();
const { t } = useTranslation();
return (
<Box is='form' onSubmit={submitHandler} width='full'>
<Field>
<TextInput
endAddon={<Icon name='magnifier' size='x20' />}
placeholder={t('Search_Messages')}
aria-label={t('Search_Messages')}
aria-controls={isSuccess ? searchListId : undefined}
autoComplete='off'
{...register('searchText')}
/>
{provider.description && (
<FieldHint dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(t(provider.description as TranslationKey)) }} />
)}
</Field>
{globalSearchEnabled && (
<Field>
<FieldLabel htmlFor={globalSearchToggleId}>{t('Global_Search')}</FieldLabel>
<ToggleSwitch id={globalSearchToggleId} {...register('globalSearch')} />
</Field>
)}
{room.encrypted && (
<Callout type='warning' marginBlockStart={12} icon='circle-exclamation'>
<Box fontScale='p2b'>{t('Encrypted_RoomType', { roomType: getRoomTypeTranslation(room).toLowerCase() })}</Box>
{t('Encrypted_content_cannot_be_searched')}
</Callout>
)}
</Box>
);
};
export default MessageSearchForm;

View File

@@ -0,0 +1,18 @@
import { useMethod } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
export const useMessageSearchProviderQuery = () => {
const getSearchProvider = useMethod('rocketchatSearch.getProvider');
return useQuery({
queryKey: ['search', 'provider'] as const,
queryFn: async () => {
const provider = await getSearchProvider();
if (provider === undefined) {
throw new Error('Search provider not found');
}
return provider;
},
});
};

View File

@@ -0,0 +1,33 @@
import { useMethod, useTranslation, useUserId } from '@rocket.chat/ui-contexts';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useRoom } from '../../../contexts/RoomContext';
export const useMessageSearchQuery = ({
searchText,
limit,
globalSearch,
}: {
searchText: string;
limit: number;
globalSearch: boolean;
}) => {
const uid = useUserId();
const room = useRoom();
const t = useTranslation();
const searchMessages = useMethod('rocketchatSearch.search');
return useQuery({
queryKey: ['rooms', room._id, 'message-search', { uid, rid: room._id, searchText, limit, globalSearch }] as const,
queryFn: async () => {
const result = await searchMessages(searchText, { uid, rid: room._id }, { limit, searchAll: globalSearch });
return result.message?.docs ?? [];
},
placeholderData: keepPreviousData,
meta: {
errorToastMessage: t('Search_message_search_failed'),
},
});
};

View File

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

View File

@@ -0,0 +1,31 @@
import { Contextualbar } from '@rocket.chat/ui-client';
import type { StoryObj, Meta } from '@storybook/react';
import { action } from 'storybook/actions';
import NotificationsPreferences from './NotificationPreferences';
export default {
component: NotificationsPreferences,
parameters: {
layout: 'fullscreen',
},
decorators: [(fn) => <Contextualbar height='100vh'>{fn()}</Contextualbar>],
} satisfies Meta<typeof NotificationsPreferences>;
export const Default: StoryObj<typeof NotificationsPreferences> = {
name: 'NotificationsPreferences',
args: {
handleClose: action('handleClose'),
handleSave: action('handleSaveButton'),
handlePlaySound: action('handlePlaySound'),
notificationOptions: {
alerts: [
['default', 'Default'],
['all', 'All_messages'],
['mentions', 'Mentions'],
['nothing', 'Nothing'],
],
},
},
};

View File

@@ -0,0 +1,57 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { Button, ButtonGroup } from '@rocket.chat/fuselage';
import {
ContextualbarHeader,
ContextualbarIcon,
ContextualbarTitle,
ContextualbarClose,
ContextualbarScrollableContent,
ContextualbarFooter,
ContextualbarDialog,
} from '@rocket.chat/ui-client';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import NotificationPreferencesForm from './NotificationPreferencesForm';
export type NotificationPreferencesProps = {
handleClose: () => void;
handleSave: () => void;
notificationOptions: {
[key: string]: SelectOption[];
};
handlePlaySound: () => void;
};
const NotificationPreferences = ({ handleClose, handleSave, notificationOptions, handlePlaySound }: NotificationPreferencesProps) => {
const { t } = useTranslation();
const {
formState: { isDirty, isSubmitting },
reset,
} = useFormContext();
return (
<ContextualbarDialog>
<ContextualbarHeader>
<ContextualbarIcon name='bell' />
<ContextualbarTitle>{t('Notifications_Preferences')}</ContextualbarTitle>
{handleClose && <ContextualbarClose onClick={handleClose} />}
</ContextualbarHeader>
<ContextualbarScrollableContent>
<NotificationPreferencesForm notificationOptions={notificationOptions} handlePlaySound={handlePlaySound} />
</ContextualbarScrollableContent>
<ContextualbarFooter>
<ButtonGroup stretch>
<Button type='reset' disabled={!isDirty || isSubmitting} onClick={() => reset()}>
{t('Reset')}
</Button>
<Button primary disabled={!isDirty} loading={isSubmitting} onClick={handleSave}>
{t('Save')}
</Button>
</ButtonGroup>
</ContextualbarFooter>
</ContextualbarDialog>
);
};
export default NotificationPreferences;

View File

@@ -0,0 +1,128 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { FieldGroup, IconButton, Margins } from '@rocket.chat/fuselage';
import { useFormContext, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import NotificationByDevice from './components/NotificationByDevice';
import NotificationPreference from './components/NotificationPreference';
import NotificationToggle from './components/NotificationToggle';
export type NotificationPreferencesFormProps = {
notificationOptions: {
[key: string]: SelectOption[];
};
handlePlaySound: () => void;
};
const NotificationPreferencesForm = ({ notificationOptions, handlePlaySound }: NotificationPreferencesFormProps) => {
const { t } = useTranslation();
const { watch, control } = useFormContext();
const { showCounter } = watch();
return (
<>
<Controller
control={control}
name='turnOn'
render={({ field: { value, onChange } }) => (
<NotificationToggle label={t('Turn_ON')} description={t('Receive_alerts')} onChange={onChange} checked={value} />
)}
/>
<Controller
control={control}
name='muteGroupMentions'
render={({ field: { value, onChange } }) => (
<NotificationToggle label={t('Mute_Group_Mentions')} onChange={onChange} checked={value} />
)}
/>
<Controller
control={control}
name='showCounter'
render={({ field: { value, onChange } }) => (
<NotificationToggle label={t('Show_counter')} description={t('Display_unread_counter')} onChange={onChange} checked={value} />
)}
/>
{!showCounter && (
<Controller
control={control}
name='showMentions'
render={({ field: { value, onChange } }) => (
<NotificationToggle
label={t('Show_mentions')}
description={t('Display_mentions_counter')}
onChange={onChange}
checked={value}
/>
)}
/>
)}
<FieldGroup>
<NotificationByDevice device={t('Desktop')} icon='desktop'>
<Controller
control={control}
name='desktopAlert'
render={({ field: { value, onChange } }) => (
<NotificationPreference
id='DesktopAlert'
name={t('Alerts')}
options={notificationOptions.alerts}
optionValue={value}
onChange={onChange}
/>
)}
/>
<Margins blockStart={16}>
<Controller
control={control}
name='desktopSound'
render={({ field: { value, onChange } }) => (
<NotificationPreference
id='DesktopSound'
name={t('Sound')}
options={notificationOptions.sounds}
optionValue={value}
onChange={onChange}
>
<IconButton icon='play' marginInlineStart={4} onClick={handlePlaySound} aria-label={t('Play')} />
</NotificationPreference>
)}
/>
</Margins>
</NotificationByDevice>
<NotificationByDevice device={t('Mobile')} icon='mobile'>
<Controller
control={control}
name='mobileAlert'
render={({ field: { value, onChange } }) => (
<NotificationPreference
id='MobileAlert'
name={t('Alerts')}
options={notificationOptions.alerts}
optionValue={value}
onChange={onChange}
/>
)}
/>
</NotificationByDevice>
<NotificationByDevice device={t('Email')} icon='mail'>
<Controller
control={control}
name='emailAlert'
render={({ field: { value, onChange } }) => (
<NotificationPreference
id='EmailAlert'
name={t('Alerts')}
options={notificationOptions.alerts}
optionValue={value}
onChange={onChange}
/>
)}
/>
</NotificationByDevice>
</FieldGroup>
</>
);
};
export default NotificationPreferencesForm;

View File

@@ -0,0 +1,83 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import NotificationPreferencesWithData from './NotificationPreferencesWithData';
const mockPlay = jest.fn();
const mockCloseTab = jest.fn();
const mockUseRoomSubscription = jest.fn();
jest.mock('@rocket.chat/ui-contexts', () => ({
...jest.requireActual('@rocket.chat/ui-contexts'),
useCustomSound: () => ({ play: mockPlay, list: [] }),
useRoomToolbox: () => ({ closeTab: mockCloseTab }),
useToastMessageDispatch: () => jest.fn(),
}));
jest.mock('../../contexts/RoomContext', () => ({
useRoom: () => ({ _id: 'GENERAL' }),
useRoomSubscription: () => mockUseRoomSubscription(),
}));
jest.mock('../../../../hooks/useEndpointMutation', () => ({
useEndpointMutation: () => ({ mutateAsync: jest.fn() }),
}));
const appRoot = (userPreferences?: Record<string, unknown>) =>
mockAppRoot()
.withUserPreference('newMessageNotification', userPreferences?.newMessageNotification ?? 'chime')
.build();
beforeEach(() => {
mockPlay.mockClear();
mockUseRoomSubscription.mockReturnValue({
disableNotifications: false,
muteGroupMentions: false,
hideUnreadStatus: false,
hideMentionStatus: false,
audioNotificationValue: undefined, // desktopSound defaults to 'default'
});
});
describe('NotificationPreferencesWithData - handlePlaySound', () => {
it('plays the user newMessageNotification preference when desktopSound is "default"', async () => {
render(<NotificationPreferencesWithData />, {
wrapper: appRoot({ newMessageNotification: 'chime' }),
});
await userEvent.click(screen.getByRole('button', { name: 'Play' }));
expect(mockPlay).toHaveBeenCalledWith('chime');
expect(mockPlay).not.toHaveBeenCalledWith('default');
});
it('plays the user preference sound even when it is not the default chime', async () => {
render(<NotificationPreferencesWithData />, {
wrapper: appRoot({ newMessageNotification: 'ringtone' }),
});
await userEvent.click(screen.getByRole('button', { name: 'Play' }));
expect(mockPlay).toHaveBeenCalledWith('ringtone');
});
it('plays the specific sound directly when desktopSound is not "default"', async () => {
mockUseRoomSubscription.mockReturnValue({
disableNotifications: false,
muteGroupMentions: false,
hideUnreadStatus: false,
hideMentionStatus: false,
audioNotificationValue: 'door',
});
render(<NotificationPreferencesWithData />, {
wrapper: appRoot({ newMessageNotification: 'chime' }),
});
await userEvent.click(screen.getByRole('button', { name: 'Play' }));
expect(mockPlay).toHaveBeenCalledWith('door');
expect(mockPlay).not.toHaveBeenCalledWith('chime');
});
});

View File

@@ -0,0 +1,96 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { useCustomSound, useToastMessageDispatch, useRoomToolbox, useUserPreference } from '@rocket.chat/ui-contexts';
import { memo } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import NotificationPreferences from './NotificationPreferences';
import { useEndpointMutation } from '../../../../hooks/useEndpointMutation';
import { useRoom, useRoomSubscription } from '../../contexts/RoomContext';
const NotificationPreferencesWithData = () => {
const { t } = useTranslation();
const room = useRoom();
const subscription = useRoomSubscription();
const { closeTab } = useRoomToolbox();
const customSound = useCustomSound();
const dispatchToastMessage = useToastMessageDispatch();
const newMessageNotificationPreference = useUserPreference<string>('newMessageNotification', 'chime') as string;
const { mutateAsync: saveSettings } = useEndpointMutation('POST', '/v1/rooms.saveNotification', {
onSuccess: () => {
dispatchToastMessage({ type: 'success', message: t('Room_updated_successfully') });
},
});
const customSoundAsset: SelectOption[] | undefined = customSound.list?.map((value) => [value._id, value.name]);
const defaultOption: SelectOption[] = [
['default', t('Default')],
['all', t('All_messages')],
['mentions', t('Mentions')],
['nothing', t('Nothing')],
];
const defaultSoundOption: SelectOption[] = [
['none', t('None')],
['default', t('Default')],
];
const notificationOptions = {
alerts: defaultOption,
sounds: customSoundAsset ? [...defaultSoundOption, ...customSoundAsset] : defaultSoundOption,
};
const methods = useForm({
defaultValues: {
turnOn: !subscription?.disableNotifications,
muteGroupMentions: !!subscription?.muteGroupMentions,
showCounter: !subscription?.hideUnreadStatus,
showMentions: !subscription?.hideMentionStatus,
desktopAlert: (subscription?.desktopPrefOrigin === 'subscription' && subscription.desktopNotifications) || 'default',
desktopSound: subscription?.audioNotificationValue || 'default',
mobileAlert: (subscription?.mobilePrefOrigin === 'subscription' && subscription.mobilePushNotifications) || 'default',
emailAlert: (subscription?.emailPrefOrigin === 'subscription' && subscription.emailNotifications) || 'default',
},
});
const { desktopSound } = methods.watch();
const handlePlaySound = (): void => {
customSound.play(desktopSound === 'default' ? newMessageNotificationPreference : desktopSound);
};
const handleSave = methods.handleSubmit(
async ({ turnOn, muteGroupMentions, showCounter, showMentions, desktopAlert, desktopSound, mobileAlert, emailAlert }) => {
const notifications = {
disableNotifications: turnOn ? '0' : '1',
muteGroupMentions: muteGroupMentions ? '1' : '0',
hideUnreadStatus: showCounter ? '0' : '1',
hideMentionStatus: showMentions ? '0' : '1',
desktopNotifications: desktopAlert,
audioNotificationValue: desktopSound,
mobilePushNotifications: mobileAlert,
emailNotifications: emailAlert,
};
await saveSettings({
roomId: room._id,
notifications,
});
},
);
return (
<FormProvider {...methods}>
<NotificationPreferences
handleClose={closeTab}
handleSave={handleSave}
handlePlaySound={handlePlaySound}
notificationOptions={notificationOptions}
/>
</FormProvider>
);
};
export default memo(NotificationPreferencesWithData);

View File

@@ -0,0 +1,27 @@
import { Box, AccordionItem, Icon, FieldGroup } from '@rocket.chat/fuselage';
import type { Keys as IconName } from '@rocket.chat/icons';
import type { ReactNode } from 'react';
import { memo } from 'react';
export type NotificationByDeviceProps = {
device: string;
icon: IconName;
children: ReactNode;
};
const NotificationByDevice = ({ device, icon, children }: NotificationByDeviceProps) => (
<AccordionItem
title={
<Box display='flex' alignItems='center'>
<Icon name={icon} size='x18' />
<Box fontScale='p2m' marginInline={16}>
{device}
</Box>
</Box>
}
>
<FieldGroup>{children}</FieldGroup>
</AccordionItem>
);
export default memo(NotificationByDevice);

View File

@@ -0,0 +1,24 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { Field, FieldLabel, FieldRow, Select } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
export type NotificationPreferenceProps = {
id: string;
name: string;
options: SelectOption[];
onChange: (e: unknown) => void;
optionValue: string;
children?: ReactNode;
};
const NotificationPreference = ({ name, options, onChange, optionValue, children, ...props }: NotificationPreferenceProps) => (
<Field {...props}>
<FieldLabel>{name}</FieldLabel>
<FieldRow>
<Select onChange={onChange} options={options} value={optionValue} />
{children}
</FieldRow>
</Field>
);
export default NotificationPreference;

View File

@@ -0,0 +1,27 @@
import { Field, FieldLabel, FieldDescription, FieldGroup, ToggleSwitch, FieldRow } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { memo, useId } from 'react';
export type NotificationToggleProps = {
label: string;
description?: string;
onChange: (e: unknown) => void;
} & ComponentProps<typeof ToggleSwitch>;
const NotificationToggle = ({ label, description, onChange, ...props }: NotificationToggleProps) => {
const fieldId = useId();
return (
<FieldGroup>
<Field>
<FieldRow>
<FieldLabel htmlFor={fieldId}>{label}</FieldLabel>
<ToggleSwitch id={fieldId} aria-describedby={`${fieldId}-hint`} onChange={onChange} {...props} />
</FieldRow>
{description && <FieldDescription id={`${fieldId}-hint`}>{description}</FieldDescription>}
</Field>
</FieldGroup>
);
};
export default memo(NotificationToggle);

View File

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

View File

@@ -0,0 +1,47 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import MessageListTab from './MessageListTab';
import { onClientMessageReceived } from '../../../lib/onClientMessageReceived';
import { mapMessageFromApi } from '../../../lib/utils/mapMessageFromApi';
import { useRoom } from '../contexts/RoomContext';
const PinnedMessagesTab = () => {
const getPinnedMessages = useEndpoint('GET', '/v1/chat.getPinnedMessages');
const room = useRoom();
const pinnedMessagesQueryResult = useQuery({
queryKey: ['rooms', room._id, 'pinned-messages'] as const,
queryFn: async () => {
const messages: IMessage[] = [];
for (
let offset = 0, result = await getPinnedMessages({ roomId: room._id, offset: 0 });
result.count > 0;
offset += result.count, result = await getPinnedMessages({ roomId: room._id, offset })
) {
messages.push(...result.messages.map(mapMessageFromApi));
}
return Promise.all(messages.map(onClientMessageReceived));
},
});
const { t } = useTranslation();
return (
<MessageListTab
iconName='pin'
title={t('Pinned_Messages')}
emptyResultMessage={t('No_pinned_messages')}
context='pinned'
queryResult={pinnedMessagesQueryResult}
/>
);
};
export default PinnedMessagesTab;

View File

@@ -0,0 +1,36 @@
import { Contextualbar } from '@rocket.chat/ui-client';
import type { Meta } from '@storybook/react';
import { FormProvider, useForm } from 'react-hook-form';
import PruneMessages from './PruneMessages';
export default {
component: PruneMessages,
parameters: {
layout: 'fullscreen',
actions: { argTypesRegex: '^on.*' },
},
decorators: [
(fn) => {
const methods = useForm({
defaultValues: {
pinned: true,
},
});
return (
<FormProvider {...methods}>
<Contextualbar height='100vh'>{fn()}</Contextualbar>
</FormProvider>
);
},
],
} satisfies Meta<typeof PruneMessages>;
export const Default = {};
export const WithCallout = {
args: {
callOutText: 'This is a callout',
},
};

View File

@@ -0,0 +1,120 @@
import { Field, FieldLabel, FieldRow, ButtonGroup, Button, CheckBox, Callout } from '@rocket.chat/fuselage';
import {
ContextualbarHeader,
ContextualbarIcon,
ContextualbarTitle,
ContextualbarScrollableContent,
ContextualbarFooter,
ContextualbarClose,
ContextualbarDialog,
} from '@rocket.chat/ui-client';
import { useId } from 'react';
import { useFormContext, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import PruneMessagesDateTimeRow from './PruneMessagesDateTimeRow';
import UserAutoCompleteMultiple from '../../../../components/UserAutoCompleteMultiple';
export type PruneMessagesProps = {
callOutText?: string;
validateText?: string;
users: string[];
onClickClose: () => void;
onClickPrune: () => void;
};
const PruneMessages = ({ callOutText, validateText, onClickClose, onClickPrune }: PruneMessagesProps) => {
const { t } = useTranslation();
const { control } = useFormContext();
const inclusiveCheckboxId = useId();
const pinnedCheckboxId = useId();
const discussionCheckboxId = useId();
const threadsCheckboxId = useId();
const attachedCheckboxId = useId();
return (
<ContextualbarDialog>
<ContextualbarHeader>
<ContextualbarIcon name='eraser' />
<ContextualbarTitle>{t('Prune_Messages')}</ContextualbarTitle>
{onClickClose && <ContextualbarClose onClick={onClickClose} />}
</ContextualbarHeader>
<ContextualbarScrollableContent>
<PruneMessagesDateTimeRow label={t('Newer_than')} field='newer' />
<PruneMessagesDateTimeRow label={t('Older_than')} field='older' />
<Field>
<FieldLabel flexGrow={0}>{t('Only_from_users')}</FieldLabel>
<Controller
control={control}
name='users'
render={({ field: { onChange, value } }) => (
<UserAutoCompleteMultiple value={value} onChange={onChange} placeholder={t('Please_enter_usernames')} />
)}
/>
</Field>
<Field>
<FieldRow>
<FieldLabel htmlFor={inclusiveCheckboxId}>{t('Inclusive')}</FieldLabel>
<Controller
control={control}
name='inclusive'
render={({ field: { value, ...field } }) => <CheckBox id={inclusiveCheckboxId} {...field} checked={value} />}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel htmlFor={pinnedCheckboxId}>{t('RetentionPolicy_DoNotPrunePinned')}</FieldLabel>
<Controller
control={control}
name='pinned'
render={({ field: { value, ...field } }) => <CheckBox id={pinnedCheckboxId} {...field} checked={value} />}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel htmlFor={discussionCheckboxId}>{t('RetentionPolicy_DoNotPruneDiscussion')}</FieldLabel>
<Controller
control={control}
name='discussion'
render={({ field: { value, ...field } }) => <CheckBox id={discussionCheckboxId} {...field} checked={value} />}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel htmlFor={threadsCheckboxId}>{t('RetentionPolicy_DoNotPruneThreads')}</FieldLabel>
<Controller
control={control}
name='threads'
render={({ field: { value, ...field } }) => <CheckBox id={threadsCheckboxId} {...field} checked={value} />}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel htmlFor={attachedCheckboxId}>{t('Files_only')}</FieldLabel>
<Controller
control={control}
name='attached'
render={({ field: { value, ...field } }) => <CheckBox id={attachedCheckboxId} {...field} checked={value} />}
/>
</FieldRow>
</Field>
{callOutText && !validateText && <Callout type='warning'>{callOutText}</Callout>}
{validateText && <Callout type='warning'>{validateText}</Callout>}
</ContextualbarScrollableContent>
<ContextualbarFooter>
<ButtonGroup stretch>
<Button danger disabled={Boolean(validateText)} onClick={onClickPrune}>
{t('Prune')}
</Button>
</ButtonGroup>
</ContextualbarFooter>
</ContextualbarDialog>
);
};
export default PruneMessages;

View File

@@ -0,0 +1,26 @@
import { Box, Margins, Field, FieldLabel, InputBox } from '@rocket.chat/fuselage';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
export type PruneMessagesDateTimeRowProps = {
label: string;
field: 'newer' | 'older';
};
const PruneMessagesDateTimeRow = ({ label, field }: PruneMessagesDateTimeRowProps) => {
const { register } = useFormContext();
const { t } = useTranslation();
return (
<Field>
<FieldLabel flexGrow={0}>{label}</FieldLabel>
<Box display='flex' marginInline='neg-x4'>
<Margins inline={4}>
<InputBox aria-label={`${label} ${t('Date')}`} type='date' flexGrow={1} height='x20' {...register(`${field}.date`)} />
<InputBox aria-label={`${label} ${t('Time')}`} type='time' flexGrow={1} height='x20' {...register(`${field}.time`)} />
</Margins>
</Box>
</Field>
);
};
export default PruneMessagesDateTimeRow;

View File

@@ -0,0 +1,176 @@
import { isDirectMessageRoom } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericModal } from '@rocket.chat/ui-client';
import { useSetModal, useToastMessageDispatch, useEndpoint, useRoomToolbox } from '@rocket.chat/ui-contexts';
import { useCallback, useMemo, useState } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import PruneMessages from './PruneMessages';
import { formatDate } from '../../../../lib/utils/dateFormat';
import { useRoom } from '../../contexts/RoomContext';
const getTimeZoneOffset = (): string => {
const offset = new Date().getTimezoneOffset();
const absOffset = Math.abs(offset);
return `${offset < 0 ? '+' : '-'}${`00${Math.floor(absOffset / 60)}`.slice(-2)}:${`00${absOffset % 60}`.slice(-2)}`;
};
export const initialValues = {
newer: {
date: '',
time: '',
},
older: {
date: '',
time: '',
},
users: [],
inclusive: false,
pinned: false,
discussion: false,
threads: false,
attached: false,
};
const DEFAULT_PRUNE_LIMIT = 2000;
const PruneMessagesWithData = () => {
const { t } = useTranslation();
const room = useRoom();
const setModal = useSetModal();
const { closeTab: close } = useRoomToolbox();
const closeModal = useCallback(() => setModal(null), [setModal]);
const dispatchToastMessage = useToastMessageDispatch();
const pruneMessagesAction = useEndpoint('POST', '/v1/rooms.cleanHistory');
const [counter, setCounter] = useState(0);
const methods = useForm({ defaultValues: initialValues });
const {
newer: { date: newerDate, time: newerTime },
older: { date: olderDate, time: olderTime },
users,
inclusive,
pinned,
discussion,
threads,
attached,
} = methods.watch();
const fromDate = useMemo(() => {
return new Date(`${newerDate || '0001-01-01'}T${newerTime || '00:00'}:00${getTimeZoneOffset()}`);
}, [newerDate, newerTime]);
const toDate = useMemo(() => {
return new Date(`${olderDate || '9999-12-31'}T${olderTime || '23:59'}:59${getTimeZoneOffset()}`);
}, [olderDate, olderTime]);
const handlePrune = useStableCallback((): void => {
const handlePruneAction = async () => {
const limit = DEFAULT_PRUNE_LIMIT;
try {
if (counter === limit) {
return;
}
const { count } = await pruneMessagesAction({
roomId: room._id,
latest: toDate.toISOString(),
oldest: fromDate.toISOString(),
inclusive,
limit,
excludePinned: pinned,
filesOnly: attached,
ignoreDiscussion: discussion,
ignoreThreads: threads,
users,
});
setCounter(count);
if (count < 1) {
throw new Error(attached ? t('No_files_found_to_prune') : t('No_messages_found_to_prune'));
}
dispatchToastMessage({
type: 'success',
message: attached ? t('__count__file_pruned', { count }) : t('__count__message_pruned', { count }),
});
methods.reset();
} catch (error: unknown) {
dispatchToastMessage({ type: 'error', message: error });
} finally {
closeModal();
}
};
return setModal(
<GenericModal
variant='danger'
onClose={closeModal}
onCancel={closeModal}
onConfirm={handlePruneAction}
confirmText={t('Yes_prune_them')}
>
{t('Prune_Modal')}
</GenericModal>,
);
});
const callOutText = useMemo(() => {
const name = room && ((isDirectMessageRoom(room) && room.usernames?.join(' x ')) || room.fname || room.name);
const exceptPinned = pinned ? ` ${t('except_pinned')}` : '';
const ifFrom = users.length ? ` ${t('if_they_are_from', { users: users.map((element) => element).join(', ') })}` : '';
const filesOrMessages = attached ? t('files') : t('messages');
if (newerDate && olderDate) {
return (
t('Prune_Warning_between', {
items: filesOrMessages,
roomName: name,
fromDate: formatDate(fromDate, 'L LT'),
toDate: formatDate(toDate, 'L LT'),
}) +
exceptPinned +
ifFrom
);
}
if (newerDate) {
return (
t('Prune_Warning_after', { items: filesOrMessages, roomName: name, fromDate: formatDate(fromDate, 'L LT') }) + exceptPinned + ifFrom
);
}
if (olderDate) {
return (
t('Prune_Warning_before', { items: filesOrMessages, roomName: name, toDate: formatDate(toDate, 'L LT') }) + exceptPinned + ifFrom
);
}
return t('Prune_Warning_all', { items: filesOrMessages, roomName: name }) + exceptPinned + ifFrom;
}, [attached, fromDate, newerDate, olderDate, pinned, room, t, toDate, users]);
const validateText = useMemo(() => {
if (fromDate > toDate) {
return t('Newer_than_may_not_exceed_Older_than');
}
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
return t('error-invalid-date');
}
return undefined;
}, [fromDate, t, toDate]);
return (
<FormProvider {...methods}>
<PruneMessages callOutText={callOutText} validateText={validateText} users={users} onClickClose={close} onClickPrune={handlePrune} />
</FormProvider>
);
};
export default PruneMessagesWithData;

View File

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

View File

@@ -0,0 +1,35 @@
import type { IUploadWithUser } from '@rocket.chat/core-typings';
import { css } from '@rocket.chat/css-in-js';
import { Box, Palette } from '@rocket.chat/fuselage';
import type { ComponentProps, Ref } from 'react';
import { forwardRef } from 'react';
const customClass = css`
&:hover {
cursor: pointer;
background: ${Palette.surface['surface-hover']};
}
`;
export type RoomFileItemWrapperProps = ComponentProps<typeof Box> & { item: IUploadWithUser };
const RoomFileItemWrapper = forwardRef(function RoomFileItemWrapper(
{ item, ...props }: RoomFileItemWrapperProps,
ref: Ref<HTMLDivElement>,
) {
return (
<Box
ref={ref}
role='listitem'
aria-label={item.name}
display='flex'
paddingBlock={12}
paddingInline={24}
borderRadius={4}
className={customClass}
{...props}
/>
);
});
export default RoomFileItemWrapper;

View File

@@ -0,0 +1,23 @@
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import * as stories from './RoomFiles.stories';
jest.mock('../../../../../client/lib/getURL', () => ({
getURL: jest.fn((url: string) => url),
}));
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const { baseElement } = render(<Story />);
expect(baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />);
// Disable 'nested-interactive' rule because our `Select` component is still not a11y compliant
const results = await axe(container, { rules: { 'nested-interactive': { enabled: false } } });
expect(results).toHaveNoViolations();
});

View File

@@ -0,0 +1,55 @@
import { Contextualbar } from '@rocket.chat/ui-client';
import type { Meta } from '@storybook/react';
import { action } from 'storybook/actions';
import RoomFiles from './RoomFiles';
export default {
component: RoomFiles,
parameters: {
layout: 'fullscreen',
actions: { argTypesRegex: '^on.*' },
},
decorators: [(fn) => <Contextualbar height='100vh'>{fn()}</Contextualbar>],
args: {
type: 'all',
text: '',
loadMoreItems: action('loadMoreItems'),
setText: action('setText'),
setType: action('setType'),
},
} satisfies Meta<typeof RoomFiles>;
const fakeItems = Array.from({ length: 10 }, (_, i) => ({
_id: String(i),
name: `File ${i}`,
url: '#',
uploadedAt: new Date(),
user: {
_id: 'rocket.cat',
username: 'rocket.cat',
},
_updatedAt: new Date(),
}));
export const Default = {
args: {
filesItems: fakeItems,
total: fakeItems.length,
isSuccess: true,
},
};
export const Loading = {
args: {
isPending: true,
},
};
export const Empty = {
args: {
isSuccess: true,
filesItems: [],
total: 0,
},
};

View File

@@ -0,0 +1,126 @@
import type { IRoom, IUpload, IUploadWithUser } from '@rocket.chat/core-typings';
import type { SelectOption } from '@rocket.chat/fuselage';
import { Box, Icon, TextInput, Select, Throbber } from '@rocket.chat/fuselage';
import {
VirtualizedScrollbars,
ContextualbarHeader,
ContextualbarIcon,
ContextualbarTitle,
ContextualbarClose,
ContextualbarContent,
ContextualbarEmptyContent,
ContextualbarSection,
ContextualbarDialog,
} from '@rocket.chat/ui-client';
import type { ChangeEvent } from 'react';
import { useId, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Virtuoso } from 'react-virtuoso';
import RoomFileItemWrapper from './RoomFileItemWrapper';
import RoomFilesListWrapper from './RoomFilesListWrapper';
import FileItem from './components/FileItem';
import ResultsLiveRegion from '../../../../components/ResultsLiveRegion';
export type RoomFilesProps = {
rid: IRoom['_id'];
isPending: boolean;
isSuccess: boolean;
type: string;
text: string;
filesItems: IUploadWithUser[];
loadMoreItems: () => void;
setType: (value: any) => void;
setText: (e: ChangeEvent<HTMLInputElement>) => void;
total: number;
onClickClose: () => void;
onClickDelete: (id: IUpload['_id']) => void;
};
const RoomFiles = ({
rid,
isPending,
isSuccess,
type,
text,
filesItems = [],
loadMoreItems,
setType,
setText,
total,
onClickClose,
onClickDelete,
}: RoomFilesProps) => {
const { t } = useTranslation();
const filesListId = useId();
const options: SelectOption[] = useMemo(
() => [
['all', t('All')],
['image', t('Images')],
['video', t('Videos')],
['audio', t('Audios')],
['text', t('Texts')],
['application', t('Files')],
],
[t],
);
return (
<ContextualbarDialog>
<ContextualbarHeader>
<ContextualbarIcon name='attachment' />
<ContextualbarTitle>{t('Files')}</ContextualbarTitle>
{onClickClose && <ContextualbarClose onClick={onClickClose} />}
</ContextualbarHeader>
<ContextualbarSection>
<TextInput
data-qa-files-search
placeholder={t('Search_Files')}
aria-label={t('Search_Files')}
aria-controls={isSuccess ? filesListId : undefined}
value={text}
onChange={setText}
endAddon={<Icon name='magnifier' size='x20' />}
/>
<Box width='x144' marginInlineStart={8}>
<Select aria-controls={isSuccess ? filesListId : undefined} onChange={setType} value={type} options={options} />
</Box>
</ContextualbarSection>
<ContextualbarContent paddingInline={0}>
<ResultsLiveRegion shouldAnnounce={isSuccess} itemCount={total} />
{isPending && (
<Box padding={24}>
<Throbber size='x12' />
</Box>
)}
{isSuccess && (
<Box width='full' height='full' id={filesListId} flexShrink={1} overflow='hidden'>
{filesItems.length === 0 && <ContextualbarEmptyContent title={t('No_files_found')} />}
{filesItems.length > 0 && (
<VirtualizedScrollbars>
<Virtuoso
style={{
height: '100%',
width: '100%',
}}
totalCount={total}
endReached={loadMoreItems}
overscan={100}
data={filesItems}
itemContent={(_, data) => <FileItem rid={rid} fileData={data} onClickDelete={onClickDelete} />}
components={{
List: RoomFilesListWrapper,
Item: RoomFileItemWrapper,
}}
/>
</VirtualizedScrollbars>
)}
</Box>
)}
</ContextualbarContent>
</ContextualbarDialog>
);
};
export default RoomFiles;

View File

@@ -0,0 +1,10 @@
import type { HTMLAttributes, Ref } from 'react';
import { forwardRef } from 'react';
import { useTranslation } from 'react-i18next';
const RoomFilesListWrapper = forwardRef(function RoomFilesListWrapper(props: HTMLAttributes<HTMLDivElement>, ref: Ref<HTMLDivElement>) {
const { t } = useTranslation();
return <div role='list' aria-label={t('Files_list')} ref={ref} {...props} />;
});
export default RoomFilesListWrapper;

View File

@@ -0,0 +1,49 @@
import { useLocalStorage, useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { useRoomToolbox } from '@rocket.chat/ui-contexts';
import type { ChangeEvent } from 'react';
import { useState, useCallback } from 'react';
import RoomFiles from './RoomFiles';
import { useDeleteFile } from './hooks/useDeleteFile';
import { useFilesList } from './hooks/useFilesList';
import { useRoom } from '../../contexts/RoomContext';
const RoomFilesWithData = () => {
const room = useRoom();
const { closeTab } = useRoomToolbox();
const [text, setText] = useState('');
const [type, setType] = useLocalStorage('file-list-type', 'all');
const debouncedText = useDebouncedValue(text, 400);
const { isPending, isSuccess, data, fetchNextPage, refetch } = useFilesList({
rid: room._id,
type,
text: debouncedText,
});
const handleTextChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setText(event.currentTarget.value);
}, []);
const handleDeleteFile = useDeleteFile(refetch);
return (
<RoomFiles
rid={room._id}
isPending={isPending}
isSuccess={isSuccess}
type={type}
text={text}
filesItems={data?.filesItems ?? []}
loadMoreItems={fetchNextPage}
setType={setType}
setText={handleTextChange}
total={data?.total ?? 0}
onClickClose={closeTab}
onClickDelete={handleDeleteFile}
/>
);
};
export default RoomFilesWithData;

View File

@@ -0,0 +1,639 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders Default without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-glyll0"
>
<span
data-focus-scope-start="true"
hidden=""
/>
<div
style="position: relative; user-select: auto; width: 380px; height: 100%; max-width: 50%; min-width: 380px; min-height: 100%; box-sizing: border-box; flex-shrink: 0;"
>
<div
aria-labelledby="contextualbarTitle"
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-nlfrt3"
role="dialog"
tabindex="-1"
>
<div
class="rcx-box rcx-box--full rcx-css-lm07yg"
>
<div
class="rcx-box rcx-box--full rcx-css-1bdyjik rcx-css-1l4fxfb"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-attachment rcx-icon rcx-css-1yal9it"
>
</i>
<h5
class="rcx-box rcx-box--full rcx-css-1spko5i"
id="contextualbarTitle"
>
Files
</h5>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-vertical-bar__section rcx-css-16arwmq"
>
<label
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper"
>
<input
aria-controls="_r_0_"
aria-label="Search_Files"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-text rcx-input-box"
data-qa-files-search="true"
placeholder="Search_Files"
size="1"
type="text"
value=""
/>
<span
class="rcx-box rcx-box--full rcx-input-box__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-magnifier rcx-icon rcx-css-4pvxx3"
>
</i>
</span>
</label>
<div
class="rcx-box rcx-box--full rcx-css-l1qvi5"
>
<div
class="rcx-box rcx-box--full rcx-css-1mbgoc8"
>
<div
aria-hidden="true"
data-a11y-ignore="aria-hidden-focus"
data-react-aria-prevent-focus="true"
data-testid="hidden-select-container"
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
>
<label>
<select
tabindex="-1"
>
<option />
<option
value="all"
>
all
</option>
<option
value="image"
>
image
</option>
<option
value="video"
>
video
</option>
<option
value="audio"
>
audio
</option>
<option
value="text"
>
text
</option>
<option
value="application"
>
application
</option>
</select>
</label>
</div>
</div>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_9_"
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
type="button"
>
<span
class="rcx-box rcx-box--full rcx-css-1ah8hdc"
id="react-aria-_r_9_"
>
All
</span>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-l5txo2"
>
</i>
</button>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-vertical-bar__content rcx-css-10ikbhr"
>
<div
role="status"
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
>
__count__result_found
</div>
<div
class="rcx-box rcx-box--full rcx-css-kotrsu"
id="_r_0_"
>
<div
class="rcx-box rcx-box--full rcx-css-1pd039f rcx-css-1cb6i7s"
>
<div
data-testid="virtuoso-scroller"
data-virtuoso-scroller="true"
style="height: 100%; outline: none; overflow-y: auto; position: relative; width: 100%;"
tabindex="-1"
>
<div
data-viewport-type="element"
style="height: 100%; position: absolute; top: 0px; width: 100%;"
>
<div
aria-label="Files_list"
data-testid="virtuoso-item-list"
role="list"
style="box-sizing: border-box; margin-top: 0px; padding-bottom: 0px; padding-top: 0px;"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div>
<div
style="position: absolute; user-select: none; width: 100%; height: 10px; top: -5px; left: 0px; cursor: row-resize;"
/>
<div
style="position: absolute; user-select: none; width: 10px; height: 100%; top: 0px; cursor: col-resize; right: -5px;"
/>
<div
style="position: absolute; user-select: none; width: 100%; height: 10px; left: 0px; cursor: row-resize; bottom: -5px;"
/>
<div
style="position: absolute; user-select: none; width: 3px; height: 100%; top: 0px; left: 0px; cursor: col-resize; z-index: 99;"
>
<div
class="rcx-box rcx-box--full rcx-css-124vum9"
/>
</div>
<div
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; right: -10px; top: -10px; cursor: ne-resize;"
/>
<div
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; right: -10px; bottom: -10px; cursor: se-resize;"
/>
<div
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; left: -10px; bottom: -10px; cursor: sw-resize;"
/>
<div
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; left: -10px; top: -10px; cursor: nw-resize;"
/>
</div>
</div>
<span
data-focus-scope-end="true"
hidden=""
/>
</div>
</div>
</body>
`;
exports[`renders Empty without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-glyll0"
>
<span
data-focus-scope-start="true"
hidden=""
/>
<div
style="position: relative; user-select: auto; width: 380px; height: 100%; max-width: 50%; min-width: 380px; min-height: 100%; box-sizing: border-box; flex-shrink: 0;"
>
<div
aria-labelledby="contextualbarTitle"
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-nlfrt3"
role="dialog"
tabindex="-1"
>
<div
class="rcx-box rcx-box--full rcx-css-lm07yg"
>
<div
class="rcx-box rcx-box--full rcx-css-1bdyjik rcx-css-1l4fxfb"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-attachment rcx-icon rcx-css-1yal9it"
>
</i>
<h5
class="rcx-box rcx-box--full rcx-css-1spko5i"
id="contextualbarTitle"
>
Files
</h5>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-vertical-bar__section rcx-css-16arwmq"
>
<label
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper"
>
<input
aria-controls="_r_a_"
aria-label="Search_Files"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-text rcx-input-box"
data-qa-files-search="true"
placeholder="Search_Files"
size="1"
type="text"
value=""
/>
<span
class="rcx-box rcx-box--full rcx-input-box__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-magnifier rcx-icon rcx-css-4pvxx3"
>
</i>
</span>
</label>
<div
class="rcx-box rcx-box--full rcx-css-l1qvi5"
>
<div
class="rcx-box rcx-box--full rcx-css-1mbgoc8"
>
<div
aria-hidden="true"
data-a11y-ignore="aria-hidden-focus"
data-react-aria-prevent-focus="true"
data-testid="hidden-select-container"
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
>
<label>
<select
tabindex="-1"
>
<option />
<option
value="all"
>
all
</option>
<option
value="image"
>
image
</option>
<option
value="video"
>
video
</option>
<option
value="audio"
>
audio
</option>
<option
value="text"
>
text
</option>
<option
value="application"
>
application
</option>
</select>
</label>
</div>
</div>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_j_"
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
type="button"
>
<span
class="rcx-box rcx-box--full rcx-css-1ah8hdc"
id="react-aria-_r_j_"
>
All
</span>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-l5txo2"
>
</i>
</button>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-vertical-bar__content rcx-css-10ikbhr"
>
<div
role="status"
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
>
No_results_found
</div>
<div
class="rcx-box rcx-box--full rcx-css-kotrsu"
id="_r_a_"
>
<div
class="rcx-box rcx-box--full rcx-vertical-bar__content rcx-css-r8c38r"
>
<div
class="rcx-states"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-states__icon rcx-icon--name-magnifier rcx-icon rcx-css-d884y5"
>
</i>
<h3
class="rcx-states__title"
>
No_files_found
</h3>
</div>
</div>
</div>
</div>
</div>
<div>
<div
style="position: absolute; user-select: none; width: 100%; height: 10px; top: -5px; left: 0px; cursor: row-resize;"
/>
<div
style="position: absolute; user-select: none; width: 10px; height: 100%; top: 0px; cursor: col-resize; right: -5px;"
/>
<div
style="position: absolute; user-select: none; width: 100%; height: 10px; left: 0px; cursor: row-resize; bottom: -5px;"
/>
<div
style="position: absolute; user-select: none; width: 3px; height: 100%; top: 0px; left: 0px; cursor: col-resize; z-index: 99;"
>
<div
class="rcx-box rcx-box--full rcx-css-124vum9"
/>
</div>
<div
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; right: -10px; top: -10px; cursor: ne-resize;"
/>
<div
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; right: -10px; bottom: -10px; cursor: se-resize;"
/>
<div
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; left: -10px; bottom: -10px; cursor: sw-resize;"
/>
<div
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; left: -10px; top: -10px; cursor: nw-resize;"
/>
</div>
</div>
<span
data-focus-scope-end="true"
hidden=""
/>
</div>
</div>
</body>
`;
exports[`renders Loading without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-glyll0"
>
<span
data-focus-scope-start="true"
hidden=""
/>
<div
style="position: relative; user-select: auto; width: 380px; height: 100%; max-width: 50%; min-width: 380px; min-height: 100%; box-sizing: border-box; flex-shrink: 0;"
>
<div
aria-labelledby="contextualbarTitle"
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-nlfrt3"
role="dialog"
tabindex="-1"
>
<div
class="rcx-box rcx-box--full rcx-css-lm07yg"
>
<div
class="rcx-box rcx-box--full rcx-css-1bdyjik rcx-css-1l4fxfb"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-attachment rcx-icon rcx-css-1yal9it"
>
</i>
<h5
class="rcx-box rcx-box--full rcx-css-1spko5i"
id="contextualbarTitle"
>
Files
</h5>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-vertical-bar__section rcx-css-16arwmq"
>
<label
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper"
>
<input
aria-label="Search_Files"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-text rcx-input-box"
data-qa-files-search="true"
placeholder="Search_Files"
size="1"
type="text"
value=""
/>
<span
class="rcx-box rcx-box--full rcx-input-box__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-magnifier rcx-icon rcx-css-4pvxx3"
>
</i>
</span>
</label>
<div
class="rcx-box rcx-box--full rcx-css-l1qvi5"
>
<div
class="rcx-box rcx-box--full rcx-css-1mbgoc8"
>
<div
aria-hidden="true"
data-a11y-ignore="aria-hidden-focus"
data-react-aria-prevent-focus="true"
data-testid="hidden-select-container"
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
>
<label>
<select
tabindex="-1"
>
<option />
<option
value="all"
>
all
</option>
<option
value="image"
>
image
</option>
<option
value="video"
>
video
</option>
<option
value="audio"
>
audio
</option>
<option
value="text"
>
text
</option>
<option
value="application"
>
application
</option>
</select>
</label>
</div>
</div>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_t_"
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
type="button"
>
<span
class="rcx-box rcx-box--full rcx-css-1ah8hdc"
id="react-aria-_r_t_"
>
All
</span>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-l5txo2"
>
</i>
</button>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-vertical-bar__content rcx-css-10ikbhr"
>
<div
role="status"
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
/>
<div
class="rcx-box rcx-box--full rcx-css-m843eh"
>
<div
class="rcx-box rcx-box--full rcx-throbber"
>
<span
class="rcx-box rcx-box--full rcx-throbber__circle rcx-css-1xtde58 rcx-css-10wc9to"
/>
<span
class="rcx-box rcx-box--full rcx-throbber__circle rcx-css-1mkyloh rcx-css-10wc9to"
/>
<span
class="rcx-box rcx-box--full rcx-throbber__circle rcx-css-1r9rmv6 rcx-css-10wc9to"
/>
</div>
</div>
</div>
</div>
<div>
<div
style="position: absolute; user-select: none; width: 100%; height: 10px; top: -5px; left: 0px; cursor: row-resize;"
/>
<div
style="position: absolute; user-select: none; width: 10px; height: 100%; top: 0px; cursor: col-resize; right: -5px;"
/>
<div
style="position: absolute; user-select: none; width: 100%; height: 10px; left: 0px; cursor: row-resize; bottom: -5px;"
/>
<div
style="position: absolute; user-select: none; width: 3px; height: 100%; top: 0px; left: 0px; cursor: col-resize; z-index: 99;"
>
<div
class="rcx-box rcx-box--full rcx-css-124vum9"
/>
</div>
<div
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; right: -10px; top: -10px; cursor: ne-resize;"
/>
<div
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; right: -10px; bottom: -10px; cursor: se-resize;"
/>
<div
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; left: -10px; bottom: -10px; cursor: sw-resize;"
/>
<div
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; left: -10px; top: -10px; cursor: nw-resize;"
/>
</div>
</div>
<span
data-focus-scope-end="true"
hidden=""
/>
</div>
</div>
</body>
`;

View File

@@ -0,0 +1,30 @@
import { Contextualbar } from '@rocket.chat/ui-client';
import type { StoryObj, Meta } from '@storybook/react';
import FileItem from './FileItem';
export default {
component: FileItem,
parameters: {
layout: 'fullscreen',
},
decorators: [(fn) => <Contextualbar height='100vh'>{fn()}</Contextualbar>],
} satisfies Meta<typeof FileItem>;
export const Default: StoryObj<typeof FileItem> = {
name: 'FileItem',
args: {
fileData: {
_id: '1',
name: 'Lorem Ipsum Indolor Dolor',
url: '#',
uploadedAt: new Date(),
user: {
_id: 'rocket.cat',
username: 'rocket.cat',
},
_updatedAt: new Date(),
},
},
};

View File

@@ -0,0 +1,70 @@
import type { IRoom, IUpload, IUploadWithUser } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import { FilePreviewIcon } from '@rocket.chat/ui-client';
import FileItemMenu from './FileItemMenu';
import ImageItem from './ImageItem';
import { getFileExtension } from '../../../../../../lib/utils/getFileExtension';
import { normalizeUsername } from '../../../../../../lib/utils/normalizeUsername';
import { useDownloadFromServiceWorker } from '../../../../../hooks/useDownloadFromServiceWorker';
import { useFormatDateAndTime } from '../../../../../hooks/useFormatDateAndTime';
import { isPreviewableImage } from '../../../../../lib/utils/isPreviewableImage';
export type FileItemProps = {
rid: IRoom['_id'];
fileData: IUploadWithUser;
onClickDelete: (id: IUpload['_id']) => void;
};
const FileItem = ({ rid, fileData, onClickDelete }: FileItemProps) => {
const format = useFormatDateAndTime();
const { _id, path, name, uploadedAt, type, typeGroup, user, description } = fileData;
const encryptedAnchorProps = useDownloadFromServiceWorker(path || '', name);
const normalizedUsername = user?.username ? normalizeUsername(user.username) : undefined;
const shouldDisplayPreview = typeGroup === 'image' && !!type && isPreviewableImage(type);
return (
<>
{shouldDisplayPreview ? (
<ImageItem id={_id} url={path} name={name} username={normalizedUsername} timestamp={format(uploadedAt)} alt={description} />
) : (
<Box
is='a'
minWidth={0}
aria-label={name}
download
rel='noopener noreferrer'
target='_blank'
title={name}
display='flex'
alignItems='center'
flexGrow={1}
flexShrink={1}
href={path}
tabIndex={-1}
textDecorationLine='none'
{...(path?.includes('/file-decrypt/') ? encryptedAnchorProps : {})}
>
<FilePreviewIcon format={getFileExtension(name)} />
<Box marginInlineStart={8} flexShrink={1} overflow='hidden'>
<Box withTruncatedText color='default' fontScale='p2m'>
{name}
</Box>
{user?.username && (
<Box withTruncatedText color='hint' fontScale='p2'>
@{normalizedUsername}
</Box>
)}
<Box color='hint' fontScale='micro'>
{format(uploadedAt)}
</Box>
</Box>
</Box>
)}
<FileItemMenu rid={rid} fileData={fileData} onClickDelete={onClickDelete} />
</>
);
};
export default FileItem;

View File

@@ -0,0 +1,93 @@
import type { IRoom, IUpload } from '@rocket.chat/core-typings';
import { Emitter } from '@rocket.chat/emitter';
import { Box } from '@rocket.chat/fuselage';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { GenericMenu } from '@rocket.chat/ui-client';
import { useTranslation, useUserId } from '@rocket.chat/ui-contexts';
import { memo, useEffect, useId } from 'react';
import { download, downloadAs } from '../../../../../lib/download';
import { getURL } from '../../../../../lib/getURL';
import { useMessageDeletionIsAllowed } from '../hooks/useMessageDeletionIsAllowed';
export type FileItemMenuProps = {
rid: IRoom['_id'];
fileData: IUpload;
onClickDelete: (id: IUpload['_id']) => void;
};
const ee = new Emitter<Record<string, { result: ArrayBuffer; id: string }>>();
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data.type === 'attachment-download-result') {
const { result } = event.data as { result: ArrayBuffer; id: string };
ee.emit(event.data.id, { result, id: event.data.id });
}
});
}
const FileItemMenu = ({ rid, fileData, onClickDelete }: FileItemMenuProps) => {
const t = useTranslation();
const userId = useUserId();
const isDeletionAllowed = useMessageDeletionIsAllowed(rid, fileData, userId);
const canDownloadFile = !fileData.encryption || 'serviceWorker' in navigator;
const { controller } = navigator?.serviceWorker || {};
const uid = useId();
useEffect(
() =>
ee.once(uid, ({ result }) => {
downloadAs({ data: [new Blob([result])] }, fileData.name ?? t('Download'));
}),
[fileData, t, uid],
);
const menuOptions = [
{
id: 'download',
content: t('Download'),
icon: 'download',
onClick: () => {
if (fileData.path?.includes('/file-decrypt/')) {
if (!controller) {
return;
}
controller?.postMessage({
type: 'attachment-download',
url: fileData.path,
id: uid,
});
return;
}
if (fileData.url && fileData.name) {
const URL = window.webkitURL ?? window.URL;
const href = getURL(fileData.url);
download(href, fileData.name);
URL.revokeObjectURL(fileData.url);
}
},
disabled: !canDownloadFile,
},
...(isDeletionAllowed && onClickDelete
? [
{
id: 'delete',
content: <Box color='status-font-on-danger'>{t('Delete')}</Box>,
onClick: () => onClickDelete(fileData._id),
icon: 'trash',
iconColor: 'status-font-on-danger',
},
]
: []),
] as GenericMenuItemProps[];
return <GenericMenu title={t('More')} aria-label={t('More')} items={menuOptions} placement='bottom-end' />;
};
export default memo(FileItemMenu);

View File

@@ -0,0 +1,48 @@
import { Avatar, Box } from '@rocket.chat/fuselage';
export type ImageItemProps = {
id: string;
url: string | undefined;
name: string | undefined;
timestamp: string;
username?: string;
alt?: string;
};
const ImageItem = ({ id, url, name, timestamp, username, alt = '' }: ImageItemProps) => {
return (
<Box
minWidth={0}
data-id={id}
className='gallery-item-container'
title={name}
display='flex'
alignItems='center'
flexGrow={1}
flexShrink={1}
>
{url && (
<Box minWidth='x48'>
<Avatar objectFit='cover' size='x48' url={url} className='gallery-item' alt={alt} />
</Box>
)}
<Box marginInlineStart={8} flexShrink={1} overflow='hidden' className='gallery-item' cursor='default'>
{name && (
<Box withTruncatedText color='default' fontScale='p2m' className='gallery-item'>
{name}
</Box>
)}
{username && (
<Box withTruncatedText color='hint' fontScale='p2' className='gallery-item'>
@{username}
</Box>
)}
<Box color='hint' fontScale='micro' className='gallery-item'>
{timestamp}
</Box>
</Box>
</Box>
);
};
export default ImageItem;

View File

@@ -0,0 +1,34 @@
import type { IUpload } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericModal } from '@rocket.chat/ui-client';
import { useSetModal, useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
export const useDeleteFile = (reload: () => void) => {
const { t } = useTranslation();
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const deleteMessage = useEndpoint('POST', '/v1/chat.delete');
const handleDelete = useStableCallback((_id: IUpload['_id']) => {
const onConfirm = async () => {
try {
await deleteMessage({ fileId: _id });
dispatchToastMessage({ type: 'success', message: t('Deleted') });
reload();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
} finally {
setModal(null);
}
};
setModal(
<GenericModal variant='danger' onConfirm={onConfirm} onCancel={() => setModal(null)} confirmText={t('Delete')}>
{t('Delete_File_Warning')}
</GenericModal>,
);
});
return handleDelete;
};

View File

@@ -0,0 +1,82 @@
import { Base64 } from '@rocket.chat/base64';
import type { IUpload } from '@rocket.chat/core-typings';
import { useUserRoom, useEndpoint } from '@rocket.chat/ui-contexts';
import { useInfiniteQuery } from '@tanstack/react-query';
import { e2e } from '../../../../../lib/e2ee/rocketchat.e2e';
import { roomsQueryKeys } from '../../../../../lib/queryKeys';
import { getConfig } from '../../../../../lib/utils/getConfig';
export const useFilesList = ({ rid, type, text }: { rid: Required<IUpload>['rid']; type: string; text: string }) => {
const room = useUserRoom(rid);
const roomTypes = {
c: '/v1/channels.files',
l: '/v1/channels.files',
v: '/v1/channels.files',
d: '/v1/im.files',
p: '/v1/groups.files',
} as const;
const getFiles = useEndpoint('GET', roomTypes[room?.t ?? 'c']);
const count = parseInt(`${getConfig('discussionListSize', 10)}`, 10);
return useInfiniteQuery({
queryKey: roomsQueryKeys.files(rid, { type, text }),
queryFn: async ({ pageParam: offset }) => {
const { files, total } = await getFiles({
roomId: rid,
offset,
count,
sort: JSON.stringify({ uploadedAt: -1 }),
...(text ? { name: text } : {}),
...(type !== 'all' && {
typeGroup: type,
}),
onlyConfirmed: true,
});
const items = files.map((file) => ({
...file,
uploadedAt: file.uploadedAt ? new Date(file.uploadedAt) : undefined,
modifiedAt: file.modifiedAt ? new Date(file.modifiedAt) : undefined,
expiresAt: file.expiresAt ? new Date(file.expiresAt) : undefined,
_updatedAt: new Date(file._updatedAt),
}));
for await (const file of items) {
if (file.rid && file.content) {
const e2eRoom = await e2e.getInstanceByRoomId(file.rid);
if (e2eRoom?.shouldConvertReceivedMessages()) {
const decrypted = await e2e.decryptFileContent(file);
const key = Base64.encode(
JSON.stringify({
...decrypted.encryption,
name: String.fromCharCode(...new TextEncoder().encode(decrypted.name)),
type: decrypted.type,
}),
);
decrypted.path = `/file-decrypt${decrypted.path}?key=${encodeURIComponent(key)}`;
Object.assign(file, decrypted);
}
}
}
return {
items,
itemCount: total,
};
},
initialPageParam: 0,
getNextPageParam: (lastPage, _, lastOffset) => {
const nextOffset = lastOffset + count;
if (nextOffset >= lastPage.itemCount) return undefined;
return nextOffset;
},
select: ({ pages }) => ({
filesItems: pages.flatMap((page) => page.items),
total: pages.at(-1)?.itemCount,
}),
});
};

View File

@@ -0,0 +1,60 @@
import type { IRoom, IUser, IUpload } from '@rocket.chat/core-typings';
import { useSetting, usePermission } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { getDifference, MINUTES } from '../lib/getDifference';
export const useMessageDeletionIsAllowed = (rid: IRoom['_id'], file: IUpload, uid: IUser['_id'] | undefined) => {
const canForceDelete = usePermission('force-delete-message', rid);
const deletionIsEnabled = useSetting('Message_AllowDeleting');
const userHasPermissionToDeleteAny = usePermission('delete-message', rid);
const userHasPermissionToDeleteOwn = usePermission('delete-own-message');
const bypassBlockTimeLimit = usePermission('bypass-time-limit-edit-and-delete', rid);
const blockDeleteInMinutes = useSetting('Message_AllowDeleting_BlockDeleteInMinutes', 0);
const isDeletionAllowed = useMemo(() => {
if (canForceDelete) {
return true;
}
if (!deletionIsEnabled) {
return false;
}
if (!userHasPermissionToDeleteAny && !userHasPermissionToDeleteOwn) {
return false;
}
const checkTimeframe = (file: IUpload) => {
if (!bypassBlockTimeLimit && blockDeleteInMinutes !== 0) {
if (!file.uploadedAt || !blockDeleteInMinutes) {
return false;
}
const currentTsDiff = getDifference(new Date(), new Date(file.uploadedAt), MINUTES);
return currentTsDiff < blockDeleteInMinutes;
}
return true;
};
const isUserOwnFile = (file: IUpload) => file.userId === uid;
if (userHasPermissionToDeleteAny || isUserOwnFile(file)) {
return checkTimeframe(file);
}
return false;
}, [
canForceDelete,
deletionIsEnabled,
userHasPermissionToDeleteAny,
userHasPermissionToDeleteOwn,
blockDeleteInMinutes,
bypassBlockTimeLimit,
file,
uid,
]);
return isDeletionAllowed;
};

View File

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

View File

@@ -0,0 +1,6 @@
export const MINUTES = 1000 * 60;
export const getDifference = (now: Date, ts: Date, scale = MINUTES) => {
const diff = now.getTime() - ts.getTime() / scale;
return diff;
};

View File

@@ -0,0 +1,141 @@
import {
Modal,
Button,
Box,
Icon,
Label,
CheckBox,
ModalHeader,
ModalHeaderText,
ModalTitle,
ModalClose,
ModalContent,
ModalFooter,
ModalFooterAnnotation,
ModalFooterControllers,
} from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useId, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
export type AddMatrixUsersModalProps = {
matrixIdVerifiedStatus: Map<string, string>;
completeUserList: string[];
onClose: () => void;
onSave: (args_0: { users: string[]; unbanConfirmed?: boolean }) => Promise<void>;
};
type FormValues = {
usersToInvite: string[];
};
const verificationStatusAsIcon = (verificationStatus: string): ComponentProps<typeof Icon>['name'] => {
if (verificationStatus === 'VERIFIED') {
return 'circle-check';
}
if (verificationStatus === 'UNVERIFIED') {
return 'circle-cross';
}
return 'circle-exclamation';
};
const AddMatrixUsersModal = ({ onClose, matrixIdVerifiedStatus, onSave, completeUserList }: AddMatrixUsersModalProps) => {
const { t } = useTranslation();
const checkboxId = useId();
const [bannedError, setBannedError] = useState(false);
const [unbanConfirmed, setUnbanConfirmed] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const usersToInvite = completeUserList.filter(
(user) => !(matrixIdVerifiedStatus.has(user) && matrixIdVerifiedStatus.get(user) === 'UNVERIFIED'),
);
const rocketChatUsers = usersToInvite.filter((user) => !matrixIdVerifiedStatus.has(user));
const { handleSubmit } = useForm<FormValues>({
defaultValues: {
usersToInvite,
},
});
const onSubmit = async (data: FormValues) => {
setLoading(true);
setError(undefined);
try {
await onSave({ users: data.usersToInvite, unbanConfirmed });
onClose();
} catch (err: any) {
if (err?.error === 'error-user-is-banned') {
setBannedError(true);
setUnbanConfirmed(false);
} else {
setError(err?.message || t('Something_went_wrong'));
}
} finally {
setLoading(false);
}
};
return (
<Modal>
<ModalHeader>
<ModalHeaderText>
<ModalTitle>{t('Continue_Adding')}</ModalTitle>
</ModalHeaderText>
<ModalClose title={t('Close')} onClick={onClose} />
</ModalHeader>
<ModalContent>
<Box is='ul'>
{[...matrixIdVerifiedStatus.entries()].map(([_matrixId, _verificationStatus]) => (
<Box is='li' display='flex' key={_matrixId}>
{_matrixId}{' '}
<Icon marginInlineStart={4} name={verificationStatusAsIcon(_verificationStatus)} title={t(_verificationStatus)} size='x20' />
</Box>
))}
{rocketChatUsers.map((_user) => (
<Box is='li' key={`rocket-chat-${_user}`}>
{_user}
</Box>
))}
</Box>
{bannedError && (
<Box color='danger' fontScale='c1' marginBlockStart={8}>
{t('User_is_banned_from_room_confirm_unban')}
</Box>
)}
{error && (
<Box color='danger' fontScale='c1' marginBlockStart={8}>
{error}
</Box>
)}
</ModalContent>
<ModalFooter justifyContent={bannedError ? 'space-between' : 'flex-end'}>
{bannedError && (
<ModalFooterAnnotation>
<Box display='flex' alignItems='center'>
<CheckBox checked={unbanConfirmed} onChange={() => setUnbanConfirmed((prev) => !prev)} id={checkboxId} />
<Label htmlFor={checkboxId} marginInlineStart={8}>
{t('Yes_unban_user')}
</Label>
</Box>
</ModalFooterAnnotation>
)}
<ModalFooterControllers>
<Button onClick={onClose}>{t('Cancel')}</Button>
<Button
primary
onClick={handleSubmit(onSubmit)}
disabled={!(usersToInvite.length > 0) || (bannedError && !unbanConfirmed)}
loading={loading}
>
{t('Yes_continue')}
</Button>
</ModalFooterControllers>
</ModalFooter>
</Modal>
);
};
export default AddMatrixUsersModal;

View File

@@ -0,0 +1,42 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useSetModal, useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import AddMatrixUsersModal from './AddMatrixUsersModal';
export type useAddMatrixUsersProps = {
handleSave: (args_0: { users: string[]; unbanConfirmed?: boolean }) => Promise<void>;
users: string[];
};
export const useAddMatrixUsers = () => {
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const handleClose = useStableCallback(() => setModal(null));
const dispatchVerifyEndpoint = useEndpoint('GET', '/v1/federation/matrixIds.verify');
return useMutation({
mutationFn: async ({ users, handleSave }: useAddMatrixUsersProps) => {
try {
let matrixIdVerificationMap = new Map();
const matrixIds = users.filter((user) => user.startsWith('@'));
if (matrixIds.length > 0) {
const matrixIdsVerificationResponse = await dispatchVerifyEndpoint({ matrixIds });
const { results: matrixIdsVerificationResults } = matrixIdsVerificationResponse;
matrixIdVerificationMap = new Map(Object.entries(matrixIdsVerificationResults));
}
setModal(
<AddMatrixUsersModal
completeUserList={users}
onClose={handleClose}
onSave={handleSave}
matrixIdVerifiedStatus={matrixIdVerificationMap as Map<string, string>}
/>,
);
} catch (error) {
dispatchToastMessage({ type: 'error', message: error as Error });
}
},
});
};

View File

@@ -0,0 +1,17 @@
import { Contextualbar } from '@rocket.chat/ui-client';
import type { StoryObj, Meta } from '@storybook/react';
import AddUsers from './AddUsers';
export default {
component: AddUsers,
parameters: {
layout: 'fullscreen',
actions: { argTypesRegex: '^on.*' },
},
decorators: [(fn) => <Contextualbar height='100vh'>{fn()}</Contextualbar>],
} satisfies Meta<typeof AddUsers>;
export const Default: StoryObj<typeof AddUsers> = {
name: 'AddUsers',
};

View File

@@ -0,0 +1,175 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { isRoomFederated, isRoomNativeFederated } from '@rocket.chat/core-typings';
import { Field, FieldError, FieldLabel, Button, ButtonGroup, FieldGroup } 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 { useToastMessageDispatch, useSetModal, useEndpoint, useRoomToolbox } from '@rocket.chat/ui-contexts';
import { useId } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useAddMatrixUsers } from './AddMatrixUsers/useAddMatrixUsers';
import BannedUsersUnbanModal from './BannedUsersUnbanModal';
import UserAutoCompleteMultiple from '../../../../../components/UserAutoCompleteMultiple';
import { useRoom } from '../../../contexts/RoomContext';
const hasExternalUsers = (users: string[]): boolean => users.some((user) => user.startsWith('@'));
export type AddUsersProps = {
rid: IRoom['_id'];
onClickBack: () => void;
reload: () => void;
};
const AddUsers = ({ rid, onClickBack, reload }: AddUsersProps) => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const room = useRoom();
const usersFieldId = useId();
const roomIsFederated = isRoomFederated(room);
// we are dropping the non native federation for now
const isFederationBlocked = room && !isRoomNativeFederated(room);
const isFederated = roomIsFederated && !isFederationBlocked;
const setModal = useSetModal();
const { closeTab } = useRoomToolbox();
const inviteToChannel = useEndpoint('POST', '/v1/channels.invite');
const inviteToGroup = useEndpoint('POST', '/v1/groups.invite');
const getBannedUsers = useEndpoint('GET', '/v1/rooms.bannedUsers');
const unbanUser = useEndpoint('POST', '/v1/rooms.unbanUser');
const {
handleSubmit,
control,
getValues,
formState: { isDirty, isSubmitting, errors },
} = useForm({ defaultValues: { users: [] } });
const handleSave = useStableCallback(async ({ users, unbanConfirmed }: { users: string[]; unbanConfirmed?: boolean }) => {
if (unbanConfirmed) {
const { bannedUsers } = await getBannedUsers({ roomId: rid });
const bannedSet = new Set(bannedUsers.map((u) => u.username));
const usersToUnban = users.filter((username) => bannedSet.has(username));
if (usersToUnban.length) {
await Promise.all(usersToUnban.map((username) => unbanUser({ roomId: rid, username })));
}
}
await Promise.all(
users.map((username) => (room.t === 'c' ? inviteToChannel({ roomId: rid, username }) : inviteToGroup({ roomId: rid, username }))),
);
dispatchToastMessage({ type: 'success', message: t(roomIsFederated && !isFederationBlocked ? 'Users_invited' : 'Users_added') });
onClickBack();
reload();
});
const handleSaveWithBannedCheck = useStableCallback(async ({ users }: { users: string[] }) => {
try {
await handleSave({ users });
} catch (error: any) {
if (error.error === 'error-user-is-banned') {
const { bannedUsers } = await getBannedUsers({ roomId: rid });
const bannedSet = new Set(bannedUsers.map((u) => u.username));
const usersToUnban = users.filter((username) => bannedSet.has(username));
if (usersToUnban.length) {
setModal(
<BannedUsersUnbanModal
onClose={() => setModal(null)}
onConfirm={async () => {
await Promise.all(usersToUnban.map((username) => unbanUser({ roomId: rid, username })));
await Promise.all(
users.map((username) =>
room.t === 'c' ? inviteToChannel({ roomId: rid, username }) : inviteToGroup({ roomId: rid, username }),
),
);
setModal(null);
dispatchToastMessage({
type: 'success',
message: t(roomIsFederated && !isFederationBlocked ? 'Users_invited' : 'Users_added'),
});
onClickBack();
reload();
}}
/>,
);
return;
}
}
dispatchToastMessage({ type: 'error', message: error as Error });
}
});
const addClickHandler = useAddMatrixUsers();
return (
<ContextualbarDialog>
<ContextualbarHeader>
{onClickBack && <ContextualbarBack onClick={onClickBack} />}
<ContextualbarTitle>{t('Add_users')}</ContextualbarTitle>
{closeTab && <ContextualbarClose onClick={closeTab} />}
</ContextualbarHeader>
<ContextualbarScrollableContent>
<FieldGroup>
<Field>
<FieldLabel flexGrow={0}>{t('Choose_users')}</FieldLabel>
<Controller
name='users'
control={control}
rules={{
validate: (users) => !isFederated && (!hasExternalUsers(users) || t('You_cannot_add_external_users_to_non_federated_room')),
}}
render={({ field }) => (
<UserAutoCompleteMultiple
federated={isFederated}
placeholder={t('Choose_users')}
aria-describedby={`${usersFieldId}-error`}
{...field}
/>
)}
/>
{errors.users && (
<FieldError role='alert' id={`${usersFieldId}-error`}>
{errors.users.message}
</FieldError>
)}
</Field>
</FieldGroup>
</ContextualbarScrollableContent>
<ContextualbarFooter>
<ButtonGroup stretch>
{roomIsFederated ? (
!isFederationBlocked && (
<Button
primary
disabled={addClickHandler.isPending || !isDirty}
onClick={() =>
addClickHandler.mutate({
users: getValues('users'),
handleSave,
})
}
>
{t('Add_users')}
</Button>
)
) : (
<Button primary loading={isSubmitting} disabled={!isDirty} onClick={handleSubmit(handleSaveWithBannedCheck)}>
{t('Add_users')}
</Button>
)}
</ButtonGroup>
</ContextualbarFooter>
</ContextualbarDialog>
);
};
export default AddUsers;

View File

@@ -0,0 +1,79 @@
import {
Modal,
Box,
Button,
Label,
CheckBox,
ModalHeader,
ModalHeaderText,
ModalTitle,
ModalClose,
ModalContent,
ModalFooter,
ModalFooterAnnotation,
ModalFooterControllers,
} from '@rocket.chat/fuselage';
import { useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
export type BannedUsersUnbanModalProps = {
onClose: () => void;
onConfirm: () => Promise<void>;
};
const BannedUsersUnbanModal = ({ onClose, onConfirm }: BannedUsersUnbanModalProps) => {
const { t } = useTranslation();
const checkboxId = useId();
const [unbanConfirmed, setUnbanConfirmed] = useState(false);
const [error, setError] = useState<string>();
const [loading, setLoading] = useState(false);
const handleConfirm = async () => {
setLoading(true);
setError(undefined);
try {
await onConfirm();
} catch (err: any) {
setError(err?.message || t('Something_went_wrong'));
} finally {
setLoading(false);
}
};
return (
<Modal>
<ModalHeader>
<ModalHeaderText>
<ModalTitle>{t('User_is_banned')}</ModalTitle>
</ModalHeaderText>
<ModalClose title={t('Close')} onClick={onClose} />
</ModalHeader>
<ModalContent>
<Box>{t('User_is_banned_from_room_confirm_unban')}</Box>
{error && (
<Box color='danger' fontScale='c1' marginBlockStart={8}>
{error}
</Box>
)}
</ModalContent>
<ModalFooter justifyContent='space-between'>
<ModalFooterAnnotation>
<Box display='flex' alignItems='center'>
<CheckBox checked={unbanConfirmed} onChange={() => setUnbanConfirmed((prev) => !prev)} id={checkboxId} />
<Label htmlFor={checkboxId} marginInlineStart={8}>
{t('Yes_unban_user')}
</Label>
</Box>
</ModalFooterAnnotation>
<ModalFooterControllers>
<Button onClick={onClose}>{t('Cancel')}</Button>
<Button primary onClick={handleConfirm} disabled={!unbanConfirmed} loading={loading}>
{t('Add_users')}
</Button>
</ModalFooterControllers>
</ModalFooter>
</Modal>
);
};
export default BannedUsersUnbanModal;

View File

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

View File

@@ -0,0 +1,84 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { Box, Field, FieldLabel, FieldRow, Select, Button } from '@rocket.chat/fuselage';
import { useId, useMemo } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
export type EditInviteLinkProps = {
daysAndMaxUses: { days: string; maxUses: string };
onClickNewLink: (daysAndMaxUses: { days: string; maxUses: string }) => void;
};
const EditInviteLink = ({ daysAndMaxUses, onClickNewLink }: EditInviteLinkProps) => {
const { t } = useTranslation();
const {
handleSubmit,
formState: { isDirty, isSubmitting },
control,
} = useForm({ defaultValues: { days: daysAndMaxUses.days, maxUses: daysAndMaxUses.maxUses } });
const expirationId = useId();
const maxUsesId = useId();
const daysOptions: SelectOption[] = useMemo(
() => [
['1', '1'],
['7', '7'],
['15', '15'],
['30', '30'],
['0', t('Never')],
],
[t],
);
const maxUsesOptions: SelectOption[] = useMemo(
() => [
['5', '5'],
['10', '10'],
['25', '25'],
['50', '50'],
['100', '100'],
['0', t('No_Limit')],
],
[t],
);
return (
<>
<Field>
<FieldLabel htmlFor={expirationId} flexGrow={0}>
{t('Expiration_(Days)')}
</FieldLabel>
<FieldRow>
<Controller
name='days'
control={control}
render={({ field: { onChange, value, name } }) => (
<Select id={expirationId} name={name} value={value} onChange={onChange} options={daysOptions} />
)}
/>
</FieldRow>
</Field>
<Field>
<FieldLabel htmlFor={maxUsesId} flexGrow={0}>
{t('Max_number_of_uses')}
</FieldLabel>
<FieldRow>
<Controller
name='maxUses'
control={control}
render={({ field: { onChange, value, name } }) => (
<Select id={maxUsesId} name={name} value={value} onChange={onChange} options={maxUsesOptions} />
)}
/>
</FieldRow>
</Field>
<Box marginBlockStart={8}>
<Button loading={isSubmitting} disabled={!isDirty} primary onClick={handleSubmit(onClickNewLink)}>
{t('Generate_New_Link')}
</Button>
</Box>
</>
);
};
export default EditInviteLink;

View File

@@ -0,0 +1,45 @@
import { Box, Field, FieldLabel, FieldRow, UrlInput, Icon, Button, InputBoxSkeleton } from '@rocket.chat/fuselage';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import useClipboardWithToast from '../../../../../hooks/useClipboardWithToast';
export type InviteLinkProps = {
linkText: string;
captionText: string;
onClickEdit: () => void;
};
const InviteLink = ({ linkText, captionText, onClickEdit }: InviteLinkProps) => {
const { t } = useTranslation();
const { copy } = useClipboardWithToast(linkText);
const inviteLinkId = useId();
return (
<>
<Field>
<FieldLabel htmlFor={inviteLinkId} flexGrow={0}>
{t('Invite_Link')}
</FieldLabel>
<FieldRow>
{!linkText && <InputBoxSkeleton />}
{linkText && (
<UrlInput id={inviteLinkId} value={linkText} endAddon={<Icon onClick={(): Promise<void> => copy()} name='copy' size='x16' />} />
)}
</FieldRow>
{captionText && (
<Box paddingBlock={8} color='annotation' fontScale='c2'>
{captionText}
</Box>
)}
</Field>
{onClickEdit && (
<Box marginBlockStart={8}>
<Button onClick={onClickEdit}>{t('Edit_Invite')}</Button>
</Box>
)}
</>
);
};
export default InviteLink;

Some files were not shown because too many files have changed in this diff Show More