snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,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'],
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AutoTranslateWithData';
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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'),
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
export { default } from './BannedUsersWithData';
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)} />;
|
||||
@@ -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));
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './DiscussionsListContextBar';
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ExportMessages';
|
||||
@@ -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') });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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') });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { GenericModal } from '@rocket.chat/ui-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type ChannelToTeamConfirmationProps = {
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
const ChannelToTeamConfirmation = ({ onCancel, onConfirm }: ChannelToTeamConfirmationProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
variant='warning'
|
||||
icon='warning'
|
||||
title={t('Confirmation')}
|
||||
confirmText={t('Yes')}
|
||||
onClose={onCancel}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
>
|
||||
<Box>{t('Teams_move_channel_to_team_confirm_description')}</Box>
|
||||
</GenericModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelToTeamConfirmation;
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { useState } from 'react';
|
||||
|
||||
import ChannelToTeamConfirmation from './ChannelToTeamConfirmation';
|
||||
import ChannelToTeamSelection from './ChannelToTeamSelection';
|
||||
|
||||
export type ChannelToTeamModalProps = {
|
||||
onCancel: () => void;
|
||||
onConfirm: (teamId: IRoom['_id']) => void;
|
||||
};
|
||||
|
||||
const CHANNEL_TO_TEAM_STEPS = {
|
||||
SELECTION: 'selection',
|
||||
CONFIRMATION: 'confirmation',
|
||||
};
|
||||
|
||||
const ChannelToTeamModal = ({ onCancel, onConfirm }: ChannelToTeamModalProps) => {
|
||||
const [step, setStep] = useState(CHANNEL_TO_TEAM_STEPS.SELECTION);
|
||||
const [teamId, setTeamId] = useState<string>();
|
||||
|
||||
if (step === CHANNEL_TO_TEAM_STEPS.CONFIRMATION && teamId) {
|
||||
return <ChannelToTeamConfirmation onCancel={onCancel} onConfirm={() => onConfirm(teamId)} />;
|
||||
}
|
||||
|
||||
const handleChange = (value: string | string[]) => {
|
||||
if (typeof value === 'string') {
|
||||
setTeamId(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ChannelToTeamSelection
|
||||
onCancel={onCancel}
|
||||
onConfirm={() => setStep(CHANNEL_TO_TEAM_STEPS.CONFIRMATION)}
|
||||
onChange={handleChange}
|
||||
teamId={teamId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelToTeamModal;
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { Box, Margins } from '@rocket.chat/fuselage';
|
||||
import { GenericModal } from '@rocket.chat/ui-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import TeamAutocomplete from '../../../../teams/contextualBar/TeamAutocomplete';
|
||||
|
||||
export type ChannelToTeamSelectionProps = {
|
||||
teamId: IRoom['teamId'];
|
||||
onChange: (value: string | string[]) => void;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
const ChannelToTeamSelection = ({ teamId, onCancel, onChange, onConfirm }: ChannelToTeamSelectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
variant='warning'
|
||||
cancelText={t('Cancel')}
|
||||
confirmText={t('Continue')}
|
||||
title={t('Teams_Select_a_team')}
|
||||
onClose={onCancel}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
confirmDisabled={!teamId}
|
||||
>
|
||||
<Margins blockEnd={20}>
|
||||
<Box>{t('Teams_move_channel_to_team_description_first')}</Box>
|
||||
<Box>{t('Teams_move_channel_to_team_description_second')}</Box>
|
||||
<Box>{t('Teams_move_channel_to_team_description_third')}</Box>
|
||||
<Box>{t('Teams_move_channel_to_team_description_fourth')}</Box>
|
||||
</Margins>
|
||||
|
||||
<Box display='flex' width='100%'>
|
||||
<TeamAutocomplete onChange={onChange} value={teamId} />
|
||||
</Box>
|
||||
</GenericModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelToTeamSelection;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ChannelToTeamModal';
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { IRoomWithRetentionPolicy } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import EditRoomInfo from './EditRoomInfo';
|
||||
import { createFakeRoom } from '../../../../../../tests/mocks/data';
|
||||
|
||||
jest.mock('../../../../../lib/rooms/roomCoordinator', () => ({
|
||||
roomCoordinator: {
|
||||
getRoomDirectives: () => ({
|
||||
allowRoomSettingChange: () => true,
|
||||
}),
|
||||
getRoomName: (_t: string, room: { name?: string }) => room.name ?? 'test-room',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../../../../components/avatar/RoomAvatarEditor', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
const noop = () => undefined;
|
||||
|
||||
const createRoom = (sysMes?: string[]) =>
|
||||
createFakeRoom<IRoomWithRetentionPolicy>({
|
||||
t: 'c',
|
||||
name: 'test-room',
|
||||
...(sysMes && { sysMes: sysMes as IRoomWithRetentionPolicy['sysMes'] }),
|
||||
});
|
||||
|
||||
const buildAppRoot = (saveFn: jest.Mock, room: IRoomWithRetentionPolicy) =>
|
||||
mockAppRoot()
|
||||
.withJohnDoe()
|
||||
.withPermission('edit-room')
|
||||
.withEndpoint('POST', '/v1/rooms.saveRoomSettings', saveFn as never)
|
||||
.withEndpoint('POST', '/v1/rooms.changeArchivationState', () => ({ success: true }) as never)
|
||||
.withEndpoint('GET', '/v1/rooms.nameExists', () => ({ exists: false }) as never)
|
||||
.withSetting('UTF8_Channel_Names_Validation', '[0-9a-zA-Z-_.]+')
|
||||
.withSetting('UI_Allow_room_names_with_special_chars', true)
|
||||
.withSetting('RetentionPolicy_Enabled', false)
|
||||
.withTranslations('en', 'core', {
|
||||
Save: 'Save',
|
||||
Hide_System_Messages: 'Hide system messages',
|
||||
Advanced_settings: 'Advanced settings',
|
||||
Select_messages_to_hide: 'Select messages to hide',
|
||||
})
|
||||
.withRoom(room)
|
||||
.build();
|
||||
|
||||
const openAdvancedSettings = async () => {
|
||||
await userEvent.click(await screen.findByRole('button', { name: /Advanced settings/i }));
|
||||
};
|
||||
|
||||
const findHideSysMesToggle = () => screen.findByLabelText(/Hide system messages/i);
|
||||
|
||||
const clickSave = async () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
};
|
||||
|
||||
const removeSystemMessageChip = async (translationKey: string) => {
|
||||
const chip = screen.getByRole('button', { name: new RegExp(translationKey, 'i') });
|
||||
await userEvent.click(chip);
|
||||
};
|
||||
|
||||
const selectSystemMessageOption = async (translationKey: string) => {
|
||||
const trigger = screen.getByRole('combobox', { name: /Select messages to hide/i });
|
||||
await userEvent.click(trigger);
|
||||
|
||||
const listboxes = await screen.findAllByRole('listbox', { hidden: true });
|
||||
const listbox = listboxes.find((el) => within(el).queryAllByRole('option', { hidden: true }).length > 0);
|
||||
|
||||
if (!listbox) {
|
||||
throw new Error('Could not find a listbox containing options');
|
||||
}
|
||||
|
||||
const optionEl = within(listbox).getByText(translationKey).closest('li[role="option"]');
|
||||
|
||||
if (!optionEl) {
|
||||
throw new Error(`Could not find option with text "${translationKey}"`);
|
||||
}
|
||||
|
||||
await userEvent.click(optionEl);
|
||||
};
|
||||
|
||||
it('should send systemMessages: [] when hideSysMes is toggled OFF', async () => {
|
||||
const saveFn = jest.fn((_data: Record<string, unknown>) => ({ rid: 'room-id', success: true }));
|
||||
const room = createRoom(['au', 'ru']);
|
||||
|
||||
render(<EditRoomInfo room={room} onClickClose={noop} onClickBack={noop} />, { wrapper: buildAppRoot(saveFn, room) });
|
||||
await openAdvancedSettings();
|
||||
|
||||
const toggle = await findHideSysMesToggle();
|
||||
expect(toggle).toBeChecked();
|
||||
|
||||
await userEvent.click(toggle);
|
||||
expect(toggle).not.toBeChecked();
|
||||
|
||||
await clickSave();
|
||||
await waitFor(() => expect(saveFn).toHaveBeenCalled());
|
||||
|
||||
expect(saveFn.mock.calls[0][0]).toEqual(expect.objectContaining({ systemMessages: [] }));
|
||||
});
|
||||
|
||||
it('should send selected systemMessages when user selects message types to hide', async () => {
|
||||
const saveFn = jest.fn((_data: Record<string, unknown>) => ({ rid: 'room-id', success: true }));
|
||||
const room = createRoom();
|
||||
|
||||
render(<EditRoomInfo room={room} onClickClose={noop} onClickBack={noop} />, { wrapper: buildAppRoot(saveFn, room) });
|
||||
await openAdvancedSettings();
|
||||
|
||||
const toggle = await findHideSysMesToggle();
|
||||
await userEvent.click(toggle);
|
||||
expect(toggle).toBeChecked();
|
||||
|
||||
await selectSystemMessageOption('Message_HideType_au');
|
||||
|
||||
await clickSave();
|
||||
await waitFor(() => expect(saveFn).toHaveBeenCalled());
|
||||
|
||||
expect(saveFn.mock.calls[0][0]).toEqual(expect.objectContaining({ systemMessages: expect.arrayContaining(['au']) }));
|
||||
});
|
||||
|
||||
it('should send existing systemMessages when toggle is turned ON without changing selection', async () => {
|
||||
const saveFn = jest.fn((_data: Record<string, unknown>) => ({ rid: 'room-id', success: true }));
|
||||
const room = createRoom();
|
||||
|
||||
render(<EditRoomInfo room={room} onClickClose={noop} onClickBack={noop} />, { wrapper: buildAppRoot(saveFn, room) });
|
||||
await openAdvancedSettings();
|
||||
|
||||
const toggle = await findHideSysMesToggle();
|
||||
expect(toggle).not.toBeChecked();
|
||||
|
||||
await userEvent.click(toggle);
|
||||
expect(toggle).toBeChecked();
|
||||
|
||||
await clickSave();
|
||||
await waitFor(() => expect(saveFn).toHaveBeenCalled());
|
||||
|
||||
expect(saveFn.mock.calls[0][0]).toEqual(expect.objectContaining({ systemMessages: [] }));
|
||||
});
|
||||
|
||||
it('should send updated systemMessages when only the multi-select is changed', async () => {
|
||||
const saveFn = jest.fn((_data: Record<string, unknown>) => ({ rid: 'room-id', success: true }));
|
||||
const room = createRoom(['ru']);
|
||||
|
||||
render(<EditRoomInfo room={room} onClickClose={noop} onClickBack={noop} />, { wrapper: buildAppRoot(saveFn, room) });
|
||||
await openAdvancedSettings();
|
||||
|
||||
const toggle = await findHideSysMesToggle();
|
||||
expect(toggle).toBeChecked();
|
||||
|
||||
await removeSystemMessageChip('Message_HideType_ru');
|
||||
await selectSystemMessageOption('Message_HideType_au');
|
||||
|
||||
await clickSave();
|
||||
await waitFor(() => expect(saveFn).toHaveBeenCalled());
|
||||
|
||||
expect(saveFn.mock.calls[0][0]).toEqual(expect.objectContaining({ systemMessages: expect.arrayContaining(['au']) }));
|
||||
});
|
||||
|
||||
it('should NOT send systemMessages when something else changed', async () => {
|
||||
const saveFn = jest.fn((_data: Record<string, unknown>) => ({ rid: 'room-id', success: true }));
|
||||
const room = createRoom(['au']);
|
||||
|
||||
render(<EditRoomInfo room={room} onClickClose={noop} onClickBack={noop} />, { wrapper: buildAppRoot(saveFn, room) });
|
||||
|
||||
await userEvent.type(await screen.findByRole('textbox', { name: 'Topic' }), 'new topic');
|
||||
|
||||
await clickSave();
|
||||
await waitFor(() => expect(saveFn).toHaveBeenCalled());
|
||||
|
||||
expect(saveFn.mock.calls[0][0]).not.toHaveProperty('systemMessages');
|
||||
});
|
||||
@@ -0,0 +1,621 @@
|
||||
/* eslint-disable complexity */
|
||||
import type { IRoomWithRetentionPolicy } from '@rocket.chat/core-typings';
|
||||
import { isRoomFederated } from '@rocket.chat/core-typings';
|
||||
import type { SelectOption } from '@rocket.chat/fuselage';
|
||||
import {
|
||||
FieldError,
|
||||
Field,
|
||||
FieldRow,
|
||||
FieldLabel,
|
||||
FieldHint,
|
||||
TextInput,
|
||||
PasswordInput,
|
||||
ToggleSwitch,
|
||||
MultiSelect,
|
||||
Accordion,
|
||||
Callout,
|
||||
NumberInput,
|
||||
FieldGroup,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Box,
|
||||
TextAreaInput,
|
||||
AccordionItem,
|
||||
} from '@rocket.chat/fuselage';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import {
|
||||
ContextualbarHeader,
|
||||
ContextualbarBack,
|
||||
ContextualbarTitle,
|
||||
ContextualbarClose,
|
||||
ContextualbarScrollableContent,
|
||||
ContextualbarFooter,
|
||||
ContextualbarDialog,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import type { TranslationKey } from '@rocket.chat/ui-contexts';
|
||||
import { useSetting, useTranslation, useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useId, useMemo } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
|
||||
import type { EditRoomInfoFormData } from './useEditRoomInitialValues';
|
||||
import { useEditRoomInitialValues } from './useEditRoomInitialValues';
|
||||
import { useEditRoomPermissions } from './useEditRoomPermissions';
|
||||
import { MessageTypesValues } from '../../../../../../app/lib/lib/MessageTypes';
|
||||
import RawText from '../../../../../components/RawText';
|
||||
import RoomAvatarEditor from '../../../../../components/avatar/RoomAvatarEditor';
|
||||
import { msToTimeUnit, TIMEUNIT } from '../../../../../lib/convertTimeUnit';
|
||||
import { getDirtyFields } from '../../../../../lib/getDirtyFields';
|
||||
import { links } from '../../../../../lib/links';
|
||||
import { roomsQueryKeys } from '../../../../../lib/queryKeys';
|
||||
import { useIsABACManagedRoom } from '../../../../admin/ABAC/hooks/useIsABACManagedRoom';
|
||||
import { useArchiveRoom } from '../../../../hooks/roomActions/useArchiveRoom';
|
||||
import { useRetentionPolicy } from '../../../hooks/useRetentionPolicy';
|
||||
|
||||
export type EditRoomInfoProps = {
|
||||
room: IRoomWithRetentionPolicy;
|
||||
onClickClose: () => void;
|
||||
onClickBack: () => void;
|
||||
};
|
||||
|
||||
const title = {
|
||||
team: 'Edit_team',
|
||||
channel: 'Edit_channel',
|
||||
discussion: 'Edit_discussion',
|
||||
} as const;
|
||||
|
||||
const getRetentionSetting = (roomType: IRoomWithRetentionPolicy['t']): string => {
|
||||
switch (roomType) {
|
||||
case 'd':
|
||||
return 'RetentionPolicy_TTL_DMs';
|
||||
case 'p':
|
||||
return 'RetentionPolicy_TTL_Groups';
|
||||
case 'c':
|
||||
default:
|
||||
return 'RetentionPolicy_TTL_Channels';
|
||||
}
|
||||
};
|
||||
|
||||
const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => {
|
||||
const query = useQueryClient();
|
||||
const t = useTranslation();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const isFederated = isRoomFederated(room);
|
||||
const isAbacManaged = useIsABACManagedRoom(room);
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
const roomType = 'prid' in room ? 'discussion' : room.teamMain ? 'team' : 'channel';
|
||||
|
||||
const retentionPolicy = useRetentionPolicy(room);
|
||||
const retentionMaxAgeDefault = msToTimeUnit(TIMEUNIT.days, useSetting(getRetentionSetting(room.t), 2592000000)) ?? 30;
|
||||
const defaultValues = useEditRoomInitialValues(room);
|
||||
const namesValidation = useSetting('UTF8_Channel_Names_Validation');
|
||||
const allowSpecialNames = useSetting('UI_Allow_room_names_with_special_chars');
|
||||
const checkTeamNameExists = useEndpoint('GET', '/v1/rooms.nameExists');
|
||||
|
||||
const teamNameRegex = useMemo(() => {
|
||||
if (allowSpecialNames) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new RegExp(`^${namesValidation}$`);
|
||||
}, [allowSpecialNames, namesValidation]);
|
||||
|
||||
const {
|
||||
watch,
|
||||
reset,
|
||||
control,
|
||||
handleSubmit,
|
||||
getFieldState,
|
||||
formState: { isDirty, dirtyFields, errors, isSubmitting },
|
||||
} = useForm<EditRoomInfoFormData>({ defaultValues });
|
||||
|
||||
const sysMesOptions: SelectOption[] = useMemo(
|
||||
() => MessageTypesValues.map(({ key, i18nLabel }) => [key, t(i18nLabel as TranslationKey)]),
|
||||
[t],
|
||||
);
|
||||
|
||||
const { isDirty: isRoomNameDirty } = getFieldState('roomName');
|
||||
|
||||
const {
|
||||
readOnly,
|
||||
archived,
|
||||
joinCodeRequired,
|
||||
hideSysMes,
|
||||
retentionEnabled,
|
||||
retentionOverrideGlobal,
|
||||
roomType: roomTypeP,
|
||||
reactWhenReadOnly,
|
||||
} = watch();
|
||||
|
||||
const {
|
||||
canChangeType,
|
||||
canSetReadOnly,
|
||||
canSetReactWhenReadOnly,
|
||||
canEditRoomRetentionPolicy,
|
||||
canArchiveOrUnarchive,
|
||||
canViewName,
|
||||
canViewTopic,
|
||||
canViewAnnouncement,
|
||||
canViewArchived,
|
||||
canViewDescription,
|
||||
canViewType,
|
||||
canViewReadOnly,
|
||||
canViewHideSysMes,
|
||||
canViewJoinCode,
|
||||
} = useEditRoomPermissions(room);
|
||||
|
||||
const changeArchiving = archived !== !!room.archived;
|
||||
|
||||
const saveAction = useEndpoint('POST', '/v1/rooms.saveRoomSettings');
|
||||
|
||||
const handleArchive = useArchiveRoom(room);
|
||||
|
||||
// TODO: add payload validation
|
||||
const handleUpdateRoomData = useStableCallback(
|
||||
async ({
|
||||
hideSysMes,
|
||||
joinCodeRequired,
|
||||
retentionEnabled,
|
||||
retentionOverrideGlobal,
|
||||
retentionMaxAge,
|
||||
retentionExcludePinned,
|
||||
retentionFilesOnly,
|
||||
retentionIgnoreThreads,
|
||||
...formData
|
||||
}: EditRoomInfoFormData) => {
|
||||
const data = getDirtyFields<Partial<typeof defaultValues>>(formData, dirtyFields);
|
||||
delete data.archived;
|
||||
|
||||
try {
|
||||
await saveAction({
|
||||
rid: room._id,
|
||||
...data,
|
||||
...((data.joinCode || 'joinCodeRequired' in data) && { joinCode: joinCodeRequired ? data.joinCode : '' }),
|
||||
...((dirtyFields.hideSysMes || dirtyFields.systemMessages) && {
|
||||
systemMessages: hideSysMes ? (data.systemMessages ?? defaultValues.systemMessages) : [],
|
||||
}),
|
||||
retentionEnabled,
|
||||
retentionOverrideGlobal,
|
||||
...(retentionEnabled &&
|
||||
retentionOverrideGlobal && {
|
||||
retentionMaxAge,
|
||||
retentionExcludePinned,
|
||||
retentionFilesOnly,
|
||||
retentionIgnoreThreads,
|
||||
}),
|
||||
});
|
||||
|
||||
await query.invalidateQueries({ queryKey: roomsQueryKeys.info(room._id) });
|
||||
dispatchToastMessage({ type: 'success', message: t('Room_updated_successfully') });
|
||||
onClickClose();
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const handleSave = useStableCallback((data: EditRoomInfoFormData) =>
|
||||
Promise.all([isDirty && handleUpdateRoomData(data), changeArchiving && handleArchive()].filter(Boolean)),
|
||||
);
|
||||
|
||||
const validateName = async (name: string): Promise<string | undefined> => {
|
||||
if (!name || !isRoomNameDirty) return;
|
||||
if (roomType === 'discussion') return;
|
||||
|
||||
if (teamNameRegex && !teamNameRegex?.test(name)) {
|
||||
return t('Name_cannot_have_special_characters');
|
||||
}
|
||||
|
||||
const { exists } = await checkTeamNameExists({ roomName: name });
|
||||
if (exists) {
|
||||
return t('Teams_Errors_Already_exists', { name });
|
||||
}
|
||||
};
|
||||
|
||||
const formId = useId();
|
||||
const roomNameField = useId();
|
||||
const roomDescriptionField = useId();
|
||||
const roomAnnouncementField = useId();
|
||||
const roomTopicField = useId();
|
||||
const roomTypeField = useId();
|
||||
const readOnlyField = useId();
|
||||
const reactWhenReadOnlyField = useId();
|
||||
const archivedField = useId();
|
||||
const joinCodeRequiredField = useId();
|
||||
const hideSysMesField = useId();
|
||||
const retentionEnabledField = useId();
|
||||
const retentionOverrideGlobalField = useId();
|
||||
const retentionMaxAgeField = useId();
|
||||
const retentionExcludePinnedField = useId();
|
||||
const retentionFilesOnlyField = useId();
|
||||
const retentionIgnoreThreads = useId();
|
||||
|
||||
const showAdvancedSettings = canViewReadOnly || readOnly || canViewArchived || canViewJoinCode || canViewHideSysMes;
|
||||
const showRetentionPolicy = canEditRoomRetentionPolicy && retentionPolicy?.enabled;
|
||||
|
||||
const showAccordion = showAdvancedSettings || showRetentionPolicy;
|
||||
|
||||
return (
|
||||
<ContextualbarDialog>
|
||||
<ContextualbarHeader>
|
||||
{onClickBack && <ContextualbarBack onClick={onClickBack} />}
|
||||
<ContextualbarTitle>{t(`${title[roomType]}`)}</ContextualbarTitle>
|
||||
{onClickClose && <ContextualbarClose onClick={onClickClose} />}
|
||||
</ContextualbarHeader>
|
||||
<ContextualbarScrollableContent padding={24}>
|
||||
<form id={formId} onSubmit={handleSubmit(handleSave)}>
|
||||
<Box display='flex' justifyContent='center'>
|
||||
<Controller
|
||||
control={control}
|
||||
name='roomAvatar'
|
||||
render={({ field: { onChange, value } }) => <RoomAvatarEditor room={room} roomAvatar={value} onChangeAvatar={onChange} />}
|
||||
/>
|
||||
</Box>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={roomNameField} required>
|
||||
{t('Name')}
|
||||
</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='roomName'
|
||||
control={control}
|
||||
rules={{
|
||||
required: t('Required_field', { field: t('Name') }),
|
||||
validate: (value) => validateName(value),
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
id={roomNameField}
|
||||
{...field}
|
||||
disabled={!canViewName}
|
||||
aria-invalid={errors.roomName ? 'true' : 'false'}
|
||||
aria-describedby={`${roomNameField}-error`}
|
||||
aria-required='true'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{errors.roomName && <FieldError id={`${roomNameField}-error`}>{errors.roomName.message}</FieldError>}
|
||||
</Field>
|
||||
{canViewTopic && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={roomTopicField}>{t('Topic')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='roomTopic'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextInput id={roomTopicField} aria-describedby={`${roomTopicField}-hint`} {...field} disabled={isAbacManaged} />
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<FieldHint id={`${roomTopicField}-hint`}>{t('Displayed_next_to_name')}</FieldHint>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
)}
|
||||
{canViewAnnouncement && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={roomAnnouncementField}>{t('Announcement')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='roomAnnouncement'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
id={roomAnnouncementField}
|
||||
aria-describedby={`${roomAnnouncementField}-hint`}
|
||||
{...field}
|
||||
disabled={isFederated || isAbacManaged}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<FieldHint id={`${roomAnnouncementField}-hint`}>{t('Information_to_keep_top_of_mind')}</FieldHint>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
)}
|
||||
{canViewDescription && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={roomDescriptionField}>{t('Description')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='roomDescription'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextAreaInput id={roomDescriptionField} {...field} disabled={isFederated || isAbacManaged} rows={4} />
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
)}
|
||||
{canViewType && (
|
||||
<Field>
|
||||
<FieldRow>
|
||||
<FieldLabel htmlFor={roomTypeField}>{t('Private')}</FieldLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name='roomType'
|
||||
render={({ field: { name, onBlur, onChange, value, ref } }) => (
|
||||
<ToggleSwitch
|
||||
id={roomTypeField}
|
||||
ref={ref}
|
||||
name={name}
|
||||
onBlur={onBlur}
|
||||
disabled={!canChangeType || isFederated}
|
||||
checked={value === 'p'}
|
||||
onChange={() => onChange(value === 'p' ? 'c' : 'p')}
|
||||
aria-describedby={`${roomTypeField}-hint`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<FieldHint id={`${roomTypeField}-hint`}>
|
||||
{roomTypeP === 'p' ? t('Only_invited_people') : t('Anyone_can_access')}
|
||||
</FieldHint>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
)}
|
||||
</FieldGroup>
|
||||
{showAccordion && (
|
||||
<Accordion>
|
||||
{showAdvancedSettings && (
|
||||
<AccordionItem title={t('Advanced_settings')}>
|
||||
<FieldGroup>
|
||||
<Box is='h5' fontScale='h5' color='titles-labels'>
|
||||
{t('Security_and_permissions')}
|
||||
</Box>
|
||||
{canViewReadOnly && (
|
||||
<Field>
|
||||
<FieldRow>
|
||||
<FieldLabel htmlFor={readOnlyField}>{t('Read_only')}</FieldLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name='readOnly'
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<ToggleSwitch
|
||||
id={readOnlyField}
|
||||
{...field}
|
||||
checked={value}
|
||||
disabled={!canSetReadOnly || isFederated}
|
||||
aria-describedby={`${readOnlyField}-hint`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldHint id={`${readOnlyField}-hint`}>
|
||||
{readOnly ? t('Read_only_field_hint_enabled', { roomType }) : t('Read_only_field_hint_disabled')}
|
||||
</FieldHint>
|
||||
</Field>
|
||||
)}
|
||||
{readOnly && (
|
||||
<Field>
|
||||
<FieldRow>
|
||||
<FieldLabel htmlFor={reactWhenReadOnlyField}>{t('React_when_read_only')}</FieldLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name='reactWhenReadOnly'
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<ToggleSwitch
|
||||
id={reactWhenReadOnlyField}
|
||||
{...field}
|
||||
disabled={!canSetReactWhenReadOnly}
|
||||
checked={value}
|
||||
aria-describedby={`${reactWhenReadOnlyField}-hint`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<FieldHint id={`${reactWhenReadOnlyField}-hint`}>
|
||||
{reactWhenReadOnly ? t('Anyone_can_react_to_messages') : t('Only_authorized_users_can_react_to_messages')}
|
||||
</FieldHint>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
)}
|
||||
{canViewArchived && (
|
||||
<Field>
|
||||
<FieldRow>
|
||||
<FieldLabel htmlFor={archivedField}>{t('Room_archivation_state_true')}</FieldLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name='archived'
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<ToggleSwitch
|
||||
id={archivedField}
|
||||
aria-describedby={`${archivedField}-hint`}
|
||||
{...field}
|
||||
disabled={!canArchiveOrUnarchive}
|
||||
checked={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{archived && (
|
||||
<FieldRow>
|
||||
<FieldHint id={`${archivedField}-hint`}>{t('New_messages_cannot_be_sent')}</FieldHint>
|
||||
</FieldRow>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
{canViewJoinCode && (
|
||||
<Field>
|
||||
<FieldRow>
|
||||
<FieldLabel htmlFor={joinCodeRequiredField}>{t('Password_to_access')}</FieldLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name='joinCodeRequired'
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<ToggleSwitch id={joinCodeRequiredField} {...field} disabled={isFederated} checked={value} />
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{joinCodeRequired && (
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='joinCode'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<PasswordInput {...field} placeholder={t('Reset_password')} disabled={!joinCodeRequired} />
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
{canViewHideSysMes && (
|
||||
<Field>
|
||||
<FieldRow>
|
||||
<FieldLabel htmlFor={hideSysMesField}>{t('Hide_System_Messages')}</FieldLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name='hideSysMes'
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<ToggleSwitch
|
||||
id={hideSysMesField}
|
||||
{...field}
|
||||
checked={value}
|
||||
disabled={isFederated}
|
||||
aria-describedby={`${hideSysMesField}-hint`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
control={control}
|
||||
name='systemMessages'
|
||||
render={({ field }) => (
|
||||
<MultiSelect
|
||||
{...field}
|
||||
options={sysMesOptions}
|
||||
disabled={!hideSysMes || isFederated}
|
||||
placeholder={t('Select_messages_to_hide')}
|
||||
aria-label={t('Select_messages_to_hide')}
|
||||
aria-describedby={`${hideSysMesField}-hint`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<FieldHint id={`${hideSysMesField}-hint`}>{t('Hide_System_Messages_Hint')}</FieldHint>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
)}
|
||||
</FieldGroup>
|
||||
</AccordionItem>
|
||||
)}
|
||||
{showRetentionPolicy && (
|
||||
<AccordionItem title={t('Prune')}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldRow>
|
||||
<FieldLabel htmlFor={retentionEnabledField}>{t('RetentionPolicyRoom_Enabled')}</FieldLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name='retentionEnabled'
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<ToggleSwitch id={retentionEnabledField} {...field} checked={value} />
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldRow>
|
||||
<FieldLabel htmlFor={retentionOverrideGlobalField}>{t('RetentionPolicyRoom_OverrideGlobal')}</FieldLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name='retentionOverrideGlobal'
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<ToggleSwitch id={retentionOverrideGlobalField} {...field} disabled={!retentionEnabled} checked={value} />
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
{retentionOverrideGlobal && (
|
||||
<>
|
||||
<Callout type='danger'>
|
||||
<RawText>{t('RetentionPolicyRoom_ReadTheDocs', { retentionPolicyUrl: links.retentionPolicy })}</RawText>
|
||||
</Callout>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={retentionMaxAgeField}>
|
||||
{t('RetentionPolicyRoom_MaxAge', { max: retentionMaxAgeDefault })}
|
||||
</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
control={control}
|
||||
name='retentionMaxAge'
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<NumberInput
|
||||
id={retentionMaxAgeField}
|
||||
{...field}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(Number(e.currentTarget.value))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldRow>
|
||||
<FieldLabel htmlFor={retentionExcludePinnedField}>{t('RetentionPolicyRoom_ExcludePinned')}</FieldLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name='retentionExcludePinned'
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<ToggleSwitch id={retentionExcludePinnedField} {...field} checked={value} />
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldRow>
|
||||
<FieldLabel htmlFor={retentionFilesOnlyField}>{t('RetentionPolicyRoom_FilesOnly')}</FieldLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name='retentionFilesOnly'
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<ToggleSwitch id={retentionFilesOnlyField} {...field} checked={value} />
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldRow>
|
||||
<FieldLabel htmlFor={retentionIgnoreThreads}>{t('RetentionPolicy_DoNotPruneThreads')}</FieldLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name='retentionIgnoreThreads'
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<ToggleSwitch id={retentionIgnoreThreads} {...field} checked={value} />
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</FieldGroup>
|
||||
</AccordionItem>
|
||||
)}
|
||||
</Accordion>
|
||||
)}
|
||||
</form>
|
||||
</ContextualbarScrollableContent>
|
||||
<ContextualbarFooter>
|
||||
<ButtonGroup stretch>
|
||||
<Button type='reset' disabled={!isDirty || isSubmitting} onClick={() => reset(defaultValues)}>
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
<Button form={formId} type='submit' primary loading={isSubmitting} disabled={!isDirty}>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</ContextualbarFooter>
|
||||
</ContextualbarDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditRoomInfo;
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { IRoomWithRetentionPolicy } from '@rocket.chat/core-typings';
|
||||
import { useRoomToolbox } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import EditRoomInfo from './EditRoomInfo';
|
||||
import { useRoom } from '../../../contexts/RoomContext';
|
||||
|
||||
const EditRoomInfoWithData = ({ onClickBack }: { onClickBack: () => void }) => {
|
||||
const room = useRoom() as IRoomWithRetentionPolicy;
|
||||
const { closeTab } = useRoomToolbox();
|
||||
|
||||
return <EditRoomInfo onClickClose={closeTab} onClickBack={onClickBack} room={room} />;
|
||||
};
|
||||
|
||||
export default EditRoomInfoWithData;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './EditRoomInfoWithData';
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { IRoomWithRetentionPolicy, RoomType, MessageTypesValues } from '@rocket.chat/core-typings';
|
||||
import { usePermission } from '@rocket.chat/ui-contexts';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { msToTimeUnit, TIMEUNIT } from '../../../../../lib/convertTimeUnit';
|
||||
import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator';
|
||||
import { useRetentionPolicy } from '../../../hooks/useRetentionPolicy';
|
||||
|
||||
export type EditRoomInfoFormData = {
|
||||
roomName: string;
|
||||
roomTopic: string;
|
||||
roomAnnouncement: string;
|
||||
roomDescription: string;
|
||||
roomType: RoomType;
|
||||
roomAvatar?: string;
|
||||
readOnly: boolean;
|
||||
reactWhenReadOnly: boolean;
|
||||
archived: boolean;
|
||||
joinCodeRequired: boolean;
|
||||
hideSysMes: boolean;
|
||||
encrypted: boolean;
|
||||
retentionEnabled: boolean;
|
||||
retentionOverrideGlobal: boolean;
|
||||
retentionMaxAge: number;
|
||||
retentionExcludePinned: boolean;
|
||||
retentionFilesOnly: boolean;
|
||||
retentionIgnoreThreads: boolean;
|
||||
showChannels: boolean;
|
||||
showDiscussions: boolean;
|
||||
joinCode: string;
|
||||
systemMessages: MessageTypesValues[];
|
||||
};
|
||||
|
||||
export const useEditRoomInitialValues = (room: IRoomWithRetentionPolicy): Partial<EditRoomInfoFormData> => {
|
||||
const retentionPolicy = useRetentionPolicy(room);
|
||||
const canEditRoomRetentionPolicy = usePermission('edit-room-retention-policy', room._id);
|
||||
|
||||
const { t, ro, archived, topic, description, announcement, joinCodeRequired, sysMes, encrypted, retention, reactWhenReadOnly } = room;
|
||||
|
||||
return useMemo(
|
||||
(): Partial<EditRoomInfoFormData> => ({
|
||||
roomName: t === 'd' && room.usernames ? room.usernames.join(' x ') : roomCoordinator.getRoomName(t, room),
|
||||
roomType: t,
|
||||
readOnly: !!ro,
|
||||
reactWhenReadOnly,
|
||||
archived: !!archived,
|
||||
roomTopic: topic ?? '',
|
||||
roomDescription: description ?? '',
|
||||
roomAnnouncement: announcement ?? '',
|
||||
roomAvatar: undefined,
|
||||
joinCode: '',
|
||||
joinCodeRequired: !!joinCodeRequired,
|
||||
systemMessages: Array.isArray(sysMes) ? sysMes : [],
|
||||
hideSysMes: Array.isArray(sysMes) ? !!sysMes?.length : !!sysMes,
|
||||
encrypted,
|
||||
...(canEditRoomRetentionPolicy &&
|
||||
retentionPolicy?.enabled && {
|
||||
retentionEnabled: retention?.enabled ?? retentionPolicy.isActive,
|
||||
retentionOverrideGlobal: !!retention?.overrideGlobal,
|
||||
retentionMaxAge: retention?.maxAge ?? msToTimeUnit(TIMEUNIT.days, retentionPolicy.maxAge),
|
||||
retentionExcludePinned: retention?.excludePinned ?? retentionPolicy.excludePinned,
|
||||
retentionFilesOnly: retention?.filesOnly ?? retentionPolicy.filesOnly,
|
||||
retentionIgnoreThreads: retention?.ignoreThreads ?? retentionPolicy.ignoreThreads,
|
||||
}),
|
||||
}),
|
||||
[
|
||||
announcement,
|
||||
archived,
|
||||
description,
|
||||
joinCodeRequired,
|
||||
retention,
|
||||
retentionPolicy,
|
||||
ro,
|
||||
room,
|
||||
sysMes,
|
||||
t,
|
||||
topic,
|
||||
encrypted,
|
||||
reactWhenReadOnly,
|
||||
canEditRoomRetentionPolicy,
|
||||
],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useEditRoomPermissions } from './useEditRoomPermissions';
|
||||
import { createFakeRoom } from '../../../../../../tests/mocks/data';
|
||||
import { useTeamInfoQuery } from '../../../../../hooks/useTeamInfoQuery';
|
||||
|
||||
jest.mock('../../../../../lib/rooms/roomCoordinator', () => ({
|
||||
roomCoordinator: {
|
||||
getRoomDirectives: () => ({
|
||||
allowRoomSettingChange: () => true,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../../../../hooks/useTeamInfoQuery');
|
||||
|
||||
const mockUseTeamInfoQuery = useTeamInfoQuery as jest.MockedFunction<typeof useTeamInfoQuery>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseTeamInfoQuery.mockReturnValue({ data: undefined } as ReturnType<typeof useTeamInfoQuery>);
|
||||
});
|
||||
|
||||
describe('useEditRoomPermissions', () => {
|
||||
describe('canChangeType - non-team room', () => {
|
||||
it('should allow changing type from private to channel when user has create-c permission', () => {
|
||||
const room = createFakeRoom({ t: 'p', default: false });
|
||||
const wrapper = mockAppRoot().withPermission('create-c').build();
|
||||
|
||||
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
|
||||
|
||||
expect(result.current.canChangeType).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow changing type from channel to private when user has create-p permission', () => {
|
||||
const room = createFakeRoom({ t: 'c', default: false });
|
||||
const wrapper = mockAppRoot().withPermission('create-p').build();
|
||||
|
||||
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
|
||||
|
||||
expect(result.current.canChangeType).toBe(true);
|
||||
});
|
||||
|
||||
it('should not allow changing type when user lacks the required permission', () => {
|
||||
const room = createFakeRoom({ t: 'p', default: false });
|
||||
const wrapper = mockAppRoot().build();
|
||||
|
||||
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
|
||||
|
||||
expect(result.current.canChangeType).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canChangeType - team main room', () => {
|
||||
const teamRoomId = 'team-room-id';
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseTeamInfoQuery.mockReturnValue({ data: { roomId: teamRoomId } } as ReturnType<typeof useTeamInfoQuery>);
|
||||
});
|
||||
|
||||
it('should allow changing type from private to channel when user has create-c', () => {
|
||||
const room = createFakeRoom({ t: 'p', default: false, teamId: 'team1', teamMain: true });
|
||||
const wrapper = mockAppRoot().withPermission('create-c').build();
|
||||
|
||||
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
|
||||
|
||||
expect(result.current.canChangeType).toBe(true);
|
||||
});
|
||||
|
||||
it('should not allow changing type from private to channel when user lacks create-c', () => {
|
||||
const room = createFakeRoom({ t: 'p', default: false, teamId: 'team1', teamMain: true });
|
||||
const wrapper = mockAppRoot().build();
|
||||
|
||||
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
|
||||
|
||||
expect(result.current.canChangeType).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canChangeType - team channel room', () => {
|
||||
const teamRoomId = 'team-room-id';
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseTeamInfoQuery.mockReturnValue({ data: { roomId: teamRoomId } } as ReturnType<typeof useTeamInfoQuery>);
|
||||
});
|
||||
|
||||
it('should allow changing type from private to channel when user has create-team-channel', () => {
|
||||
const room = createFakeRoom({ t: 'p', default: false, teamId: 'team1' });
|
||||
const wrapper = mockAppRoot().withPermission('create-team-channel').build();
|
||||
|
||||
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
|
||||
|
||||
expect(result.current.canChangeType).toBe(true);
|
||||
});
|
||||
|
||||
it('should not allow changing type from private to channel when user lacks create-team-channel', () => {
|
||||
const room = createFakeRoom({ t: 'p', default: false, teamId: 'team1' });
|
||||
const wrapper = mockAppRoot().build();
|
||||
|
||||
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
|
||||
|
||||
expect(result.current.canChangeType).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow changing type from channel to private when user has create-team-group', () => {
|
||||
const room = createFakeRoom({ t: 'c', default: false, teamId: 'team1' });
|
||||
const wrapper = mockAppRoot().withPermission('create-team-group').build();
|
||||
|
||||
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
|
||||
|
||||
expect(result.current.canChangeType).toBe(true);
|
||||
});
|
||||
|
||||
it('should not allow changing type from channel to private when user lacks create-team-group', () => {
|
||||
const room = createFakeRoom({ t: 'c', default: false, teamId: 'team1' });
|
||||
const wrapper = mockAppRoot().build();
|
||||
|
||||
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
|
||||
|
||||
expect(result.current.canChangeType).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('other permissions', () => {
|
||||
it('should reflect canSetReadOnly based on set-readonly permission', () => {
|
||||
const room = createFakeRoom({ t: 'c', default: false });
|
||||
const wrapperWith = mockAppRoot().withPermission('set-readonly').build();
|
||||
const wrapperWithout = mockAppRoot().build();
|
||||
|
||||
const { result: withPerm } = renderHook(() => useEditRoomPermissions(room), { wrapper: wrapperWith });
|
||||
const { result: withoutPerm } = renderHook(() => useEditRoomPermissions(room), { wrapper: wrapperWithout });
|
||||
|
||||
expect(withPerm.current.canSetReadOnly).toBe(true);
|
||||
expect(withoutPerm.current.canSetReadOnly).toBe(false);
|
||||
});
|
||||
|
||||
it('should reflect canSetReactWhenReadOnly based on set-react-when-readonly permission', () => {
|
||||
const room = createFakeRoom({ t: 'c', default: false });
|
||||
const wrapperWith = mockAppRoot().withPermission('set-react-when-readonly').build();
|
||||
const wrapperWithout = mockAppRoot().build();
|
||||
|
||||
const { result: withPerm } = renderHook(() => useEditRoomPermissions(room), { wrapper: wrapperWith });
|
||||
const { result: withoutPerm } = renderHook(() => useEditRoomPermissions(room), { wrapper: wrapperWithout });
|
||||
|
||||
expect(withPerm.current.canSetReactWhenReadOnly).toBe(true);
|
||||
expect(withoutPerm.current.canSetReactWhenReadOnly).toBe(false);
|
||||
});
|
||||
|
||||
it('should reflect canEditRoomRetentionPolicy based on edit-room-retention-policy permission', () => {
|
||||
const room = createFakeRoom({ t: 'c', default: false });
|
||||
const wrapperWith = mockAppRoot().withPermission('edit-room-retention-policy').build();
|
||||
const wrapperWithout = mockAppRoot().build();
|
||||
|
||||
const { result: withPerm } = renderHook(() => useEditRoomPermissions(room), { wrapper: wrapperWith });
|
||||
const { result: withoutPerm } = renderHook(() => useEditRoomPermissions(room), { wrapper: wrapperWithout });
|
||||
|
||||
expect(withPerm.current.canEditRoomRetentionPolicy).toBe(true);
|
||||
expect(withoutPerm.current.canEditRoomRetentionPolicy).toBe(false);
|
||||
});
|
||||
|
||||
it('should reflect canArchiveOrUnarchive when user has archive-room permission', () => {
|
||||
const room = createFakeRoom({ t: 'c', default: false });
|
||||
const wrapper = mockAppRoot().withPermission('archive-room').build();
|
||||
|
||||
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
|
||||
|
||||
expect(result.current.canArchiveOrUnarchive).toBe(true);
|
||||
});
|
||||
|
||||
it('should reflect canArchiveOrUnarchive when user has unarchive-room permission', () => {
|
||||
const room = createFakeRoom({ t: 'c', default: false });
|
||||
const wrapper = mockAppRoot().withPermission('unarchive-room').build();
|
||||
|
||||
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
|
||||
|
||||
expect(result.current.canArchiveOrUnarchive).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for canArchiveOrUnarchive when user has neither permission', () => {
|
||||
const room = createFakeRoom({ t: 'c', default: false });
|
||||
const wrapper = mockAppRoot().build();
|
||||
|
||||
const { result } = renderHook(() => useEditRoomPermissions(room), { wrapper });
|
||||
|
||||
expect(result.current.canArchiveOrUnarchive).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { IRoom, IRoomWithRetentionPolicy } from '@rocket.chat/core-typings';
|
||||
import { usePermission, useAtLeastOnePermission, useRole } from '@rocket.chat/ui-contexts';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { RoomSettingsEnum } from '../../../../../../definition/IRoomTypeConfig';
|
||||
import { useTeamInfoQuery } from '../../../../../hooks/useTeamInfoQuery';
|
||||
import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator';
|
||||
|
||||
const getCanChangeType = (room: IRoom | IRoomWithRetentionPolicy, canCreateChannel: boolean, canCreateGroup: boolean, isAdmin: boolean) =>
|
||||
(!room.default || isAdmin) && ((room.t === 'p' && canCreateChannel) || (room.t === 'c' && canCreateGroup));
|
||||
|
||||
export const useEditRoomPermissions = (room: IRoom | IRoomWithRetentionPolicy) => {
|
||||
const isAdmin = useRole('admin');
|
||||
const canCreateChannel = usePermission('create-c');
|
||||
const canCreateGroup = usePermission('create-p');
|
||||
|
||||
const teamId = room.teamId || '';
|
||||
const teamMain = room.teamMain || '';
|
||||
const { data: teamInfo } = useTeamInfoQuery(teamId);
|
||||
|
||||
const canCreateTeamChannel = usePermission('create-team-channel', teamInfo?.roomId);
|
||||
const canCreateTeamGroup = usePermission('create-team-group', teamInfo?.roomId);
|
||||
|
||||
const canChangeType = getCanChangeType(
|
||||
room,
|
||||
teamId && !teamMain ? canCreateTeamChannel : canCreateChannel,
|
||||
teamId && !teamMain ? canCreateTeamGroup : canCreateGroup,
|
||||
isAdmin,
|
||||
);
|
||||
const canSetReadOnly = usePermission('set-readonly', room._id);
|
||||
const canSetReactWhenReadOnly = usePermission('set-react-when-readonly', room._id);
|
||||
const canEditRoomRetentionPolicy = usePermission('edit-room-retention-policy', room._id);
|
||||
const canArchiveOrUnarchive = useAtLeastOnePermission(
|
||||
useMemo(() => ['archive-room', 'unarchive-room'], []),
|
||||
room._id,
|
||||
);
|
||||
|
||||
const [
|
||||
canViewName,
|
||||
canViewTopic,
|
||||
canViewAnnouncement,
|
||||
canViewArchived,
|
||||
canViewDescription,
|
||||
canViewType,
|
||||
canViewReadOnly,
|
||||
canViewHideSysMes,
|
||||
canViewJoinCode,
|
||||
canViewReactWhenReadOnly,
|
||||
] = useMemo(() => {
|
||||
const isAllowed =
|
||||
roomCoordinator.getRoomDirectives(room.t)?.allowRoomSettingChange ||
|
||||
(() => {
|
||||
undefined;
|
||||
});
|
||||
return [
|
||||
isAllowed(room, RoomSettingsEnum.NAME),
|
||||
isAllowed(room, RoomSettingsEnum.TOPIC),
|
||||
isAllowed(room, RoomSettingsEnum.ANNOUNCEMENT),
|
||||
isAllowed(room, RoomSettingsEnum.ARCHIVE_OR_UNARCHIVE),
|
||||
isAllowed(room, RoomSettingsEnum.DESCRIPTION),
|
||||
isAllowed(room, RoomSettingsEnum.TYPE),
|
||||
isAllowed(room, RoomSettingsEnum.READ_ONLY),
|
||||
isAllowed(room, RoomSettingsEnum.SYSTEM_MESSAGES),
|
||||
isAllowed(room, RoomSettingsEnum.JOIN_CODE),
|
||||
isAllowed(room, RoomSettingsEnum.REACT_WHEN_READ_ONLY),
|
||||
];
|
||||
}, [room]);
|
||||
|
||||
return {
|
||||
canChangeType,
|
||||
canSetReadOnly,
|
||||
canSetReactWhenReadOnly,
|
||||
canEditRoomRetentionPolicy,
|
||||
canArchiveOrUnarchive,
|
||||
canViewName,
|
||||
canViewTopic,
|
||||
canViewAnnouncement,
|
||||
canViewArchived,
|
||||
canViewDescription,
|
||||
canViewType,
|
||||
canViewReadOnly,
|
||||
canViewHideSysMes,
|
||||
canViewJoinCode,
|
||||
canViewReactWhenReadOnly,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { axe } from 'jest-axe';
|
||||
|
||||
import RoomInfoABACSection from './RoomInfoABACSection';
|
||||
import { createFakeRoom } from '../../../../../../../tests/mocks/data';
|
||||
|
||||
type RoomWithABAC = IRoom & {
|
||||
abacAttributes?: {
|
||||
key: string;
|
||||
values: string[];
|
||||
}[];
|
||||
};
|
||||
|
||||
describe('RoomInfoABACSection', () => {
|
||||
const createRoomWithABAC = (attributes: { key: string; values: string[] }[]): RoomWithABAC => {
|
||||
const room = createFakeRoom();
|
||||
return {
|
||||
...room,
|
||||
abacAttributes: attributes,
|
||||
} as RoomWithABAC;
|
||||
};
|
||||
|
||||
const appRootWithABACEnabled = mockAppRoot().withSetting('ABAC_Enabled', true).withSetting('ABAC_ShowAttributesInRooms', true).build();
|
||||
|
||||
describe('Conditional rendering', () => {
|
||||
it('should return null when ABAC_Enabled is false', () => {
|
||||
const room = createRoomWithABAC([{ key: 'Test', values: ['Value1'] }]);
|
||||
const appRoot = mockAppRoot().withSetting('ABAC_Enabled', false).withSetting('ABAC_ShowAttributesInRooms', true).build();
|
||||
|
||||
render(<RoomInfoABACSection room={room} />, { wrapper: appRoot });
|
||||
expect(screen.queryByText('ABAC_Managed')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should return null when ABAC_ShowAttributesInRooms is false', () => {
|
||||
const room = createRoomWithABAC([{ key: 'Test', values: ['Value1'] }]);
|
||||
const appRoot = mockAppRoot().withSetting('ABAC_Enabled', true).withSetting('ABAC_ShowAttributesInRooms', false).build();
|
||||
|
||||
render(<RoomInfoABACSection room={room} />, { wrapper: appRoot });
|
||||
expect(screen.queryByText('ABAC_Managed')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should return null when abacAttributes is empty', () => {
|
||||
const room = createRoomWithABAC([]);
|
||||
render(<RoomInfoABACSection room={room} />, { wrapper: appRootWithABACEnabled });
|
||||
expect(screen.queryByText('ABAC_Managed')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render when all conditions are met', () => {
|
||||
const room = createRoomWithABAC([{ key: 'Test', values: ['Value1'] }]);
|
||||
render(<RoomInfoABACSection room={room} />, { wrapper: appRootWithABACEnabled });
|
||||
expect(screen.getByText('ABAC_Managed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have no accessibility violations', async () => {
|
||||
const room = createRoomWithABAC([
|
||||
{ key: 'Chat-sensitivity', values: ['Classified', 'Top-Secret'] },
|
||||
{ key: 'Country', values: ['US-only'] },
|
||||
]);
|
||||
const { container } = render(<RoomInfoABACSection room={room} />, { wrapper: appRootWithABACEnabled });
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
describe('Snapshot', () => {
|
||||
it('should match the snapshot', () => {
|
||||
const room = createRoomWithABAC([
|
||||
{ key: 'Chat-sensitivity', values: ['Classified', 'Top-Secret'] },
|
||||
{ key: 'Country', values: ['US-only'] },
|
||||
]);
|
||||
const { baseElement } = render(<RoomInfoABACSection room={room} />, { wrapper: appRootWithABACEnabled });
|
||||
expect(baseElement).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { Box, Divider, Tag } from '@rocket.chat/fuselage';
|
||||
import { InfoPanelField, InfoPanelLabel } from '@rocket.chat/ui-client';
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { RoomIcon } from '../../../../../../components/RoomIcon';
|
||||
|
||||
// TODO: Remove type union when ABAC is implemented
|
||||
export type RoomInfoABACSectionProps = {
|
||||
room: IRoom & {
|
||||
abacAttributes?: {
|
||||
key: string;
|
||||
values: string[];
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
const RoomInfoABACSection = ({ room }: RoomInfoABACSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const abacEnabled = useSetting('ABAC_Enabled');
|
||||
const showAttributesInRoom = useSetting('ABAC_ShowAttributesInRooms');
|
||||
|
||||
if (!abacEnabled || !showAttributesInRoom || !room.abacAttributes?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider marginBlock={32} width='full' />
|
||||
<InfoPanelField>
|
||||
<Box display='flex' marginBlockEnd={16}>
|
||||
<Tag medium>
|
||||
<Box display='flex' alignItems='center'>
|
||||
<RoomIcon room={room} />
|
||||
<Box marginInlineStart={2}>{t('ABAC_Managed')}</Box>
|
||||
</Box>
|
||||
</Tag>
|
||||
</Box>
|
||||
|
||||
{t('ABAC_Managed_description')}
|
||||
|
||||
<InfoPanelLabel id='room-attributes-list-label'>{t('ABAC_Room_Attributes')}</InfoPanelLabel>
|
||||
<Box is='ul' aria-labelledby='room-attributes-list-label'>
|
||||
{room.abacAttributes.map((attribute) => (
|
||||
<Box is='li' key={attribute.key} marginBlock={16}>
|
||||
<Box is='span' id={`room-attribute-${attribute.key}-label`}>
|
||||
{attribute.key}
|
||||
</Box>
|
||||
<Box
|
||||
is='ul'
|
||||
display='flex'
|
||||
marginBlockStart={8}
|
||||
alignItems='center'
|
||||
aria-labelledby={`room-attribute-${attribute.key}-label`}
|
||||
>
|
||||
{attribute.values.map((value) => (
|
||||
<Box is='li' marginInlineEnd={4} key={value}>
|
||||
<Tag medium>{value}</Tag>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</InfoPanelField>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomInfoABACSection;
|
||||
@@ -0,0 +1,123 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`RoomInfoABACSection Snapshot should match the snapshot 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<hr
|
||||
class="rcx-box rcx-box--full rcx-divider rcx-css-1972bnv"
|
||||
/>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1jagyun"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-ukxwz6"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-tag rcx-tag--medium"
|
||||
>
|
||||
<span
|
||||
class="rcx-tag__inner"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-127j9mz"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-hash-shield rcx-icon rcx-css-1g87xs3"
|
||||
>
|
||||
|
||||
</i>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1hq8wl6"
|
||||
>
|
||||
ABAC_Managed
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
ABAC_Managed_description
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1po16l8"
|
||||
id="room-attributes-list-label"
|
||||
>
|
||||
ABAC_Room_Attributes
|
||||
</div>
|
||||
<ul
|
||||
aria-labelledby="room-attributes-list-label"
|
||||
class="rcx-box rcx-box--full"
|
||||
>
|
||||
<li
|
||||
class="rcx-box rcx-box--full rcx-css-1jagyun"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-box--full"
|
||||
id="room-attribute-Chat-sensitivity-label"
|
||||
>
|
||||
Chat-sensitivity
|
||||
</span>
|
||||
<ul
|
||||
aria-labelledby="room-attribute-Chat-sensitivity-label"
|
||||
class="rcx-box rcx-box--full rcx-css-q18nro"
|
||||
>
|
||||
<li
|
||||
class="rcx-box rcx-box--full rcx-css-sdt442"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-tag rcx-tag--medium"
|
||||
>
|
||||
<span
|
||||
class="rcx-tag__inner"
|
||||
>
|
||||
Classified
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
class="rcx-box rcx-box--full rcx-css-sdt442"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-tag rcx-tag--medium"
|
||||
>
|
||||
<span
|
||||
class="rcx-tag__inner"
|
||||
>
|
||||
Top-Secret
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li
|
||||
class="rcx-box rcx-box--full rcx-css-1jagyun"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-box--full"
|
||||
id="room-attribute-Country-label"
|
||||
>
|
||||
Country
|
||||
</span>
|
||||
<ul
|
||||
aria-labelledby="room-attribute-Country-label"
|
||||
class="rcx-box rcx-box--full rcx-css-q18nro"
|
||||
>
|
||||
<li
|
||||
class="rcx-box rcx-box--full rcx-css-sdt442"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-tag rcx-tag--medium"
|
||||
>
|
||||
<span
|
||||
class="rcx-tag__inner"
|
||||
>
|
||||
US-only
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { RoomType } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { Contextualbar } from '@rocket.chat/ui-client';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import RoomInfo from './RoomInfo';
|
||||
import FakeRoomProvider from '../../../../../../tests/mocks/client/FakeRoomProvider';
|
||||
|
||||
export default {
|
||||
component: RoomInfo,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
},
|
||||
decorators: [
|
||||
(fn) => (
|
||||
<FakeRoomProvider roomOverrides={roomArgs}>
|
||||
<Contextualbar height='100vh'>{fn()}</Contextualbar>
|
||||
</FakeRoomProvider>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
icon: 'lock',
|
||||
},
|
||||
} satisfies Meta<typeof RoomInfo>;
|
||||
|
||||
const roomArgs = {
|
||||
_id: 'myRoom',
|
||||
t: 'c' as RoomType,
|
||||
msgs: 5,
|
||||
usersCount: 1,
|
||||
u: {
|
||||
_id: 'rocketchat.internal.admin.test',
|
||||
username: 'rocketchat.internal.admin.test',
|
||||
},
|
||||
ts: new Date(),
|
||||
autoTranslateLanguage: 'en',
|
||||
_updatedAt: new Date(),
|
||||
fname: 'rocketchat-frontend-team',
|
||||
description:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam mollis nisi vel arcu bibendum vehicula. Integer vitae suscipit libero',
|
||||
announcement:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam mollis nisi vel arcu bibendum vehicula. Integer vitae suscipit libero',
|
||||
topic:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam mollis nisi vel arcu bibendum vehicula. Integer vitae suscipit libero',
|
||||
};
|
||||
|
||||
export const Default = {
|
||||
args: {
|
||||
room: roomArgs,
|
||||
},
|
||||
};
|
||||
|
||||
export const Archived = {
|
||||
args: {
|
||||
...Default.args,
|
||||
room: {
|
||||
...roomArgs,
|
||||
archived: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Broadcast = {
|
||||
args: {
|
||||
...Default.args,
|
||||
room: {
|
||||
...roomArgs,
|
||||
broadcast: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ABAC: StoryObj<typeof RoomInfo> = {
|
||||
decorators: [
|
||||
mockAppRoot().withSetting('ABAC_Enabled', true).withSetting('ABAC_ShowAttributesInRooms', true).buildStoryDecorator(),
|
||||
(fn) => (
|
||||
<FakeRoomProvider roomOverrides={roomArgs}>
|
||||
<Contextualbar height='100vh'>{fn()}</Contextualbar>
|
||||
</FakeRoomProvider>
|
||||
),
|
||||
],
|
||||
|
||||
args: {
|
||||
...Default.args,
|
||||
room: {
|
||||
...roomArgs,
|
||||
abacAttributes: [
|
||||
{ key: 'Chat-sensitivity', values: ['Classified', 'Top-Secret'] },
|
||||
{ key: 'Country', values: ['US-only'] },
|
||||
{ key: 'Project', values: ['Ruminator-2000'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { Box, Callout, IconButton } from '@rocket.chat/fuselage';
|
||||
import { RoomAvatar } from '@rocket.chat/ui-avatar';
|
||||
import {
|
||||
GenericMenu,
|
||||
ContextualbarHeader,
|
||||
ContextualbarScrollableContent,
|
||||
ContextualbarBack,
|
||||
ContextualbarIcon,
|
||||
ContextualbarClose,
|
||||
ContextualbarTitle,
|
||||
ContextualbarDialog,
|
||||
InfoPanel,
|
||||
InfoPanelActionGroup,
|
||||
InfoPanelAvatar,
|
||||
InfoPanelField,
|
||||
InfoPanelLabel,
|
||||
InfoPanelSection,
|
||||
InfoPanelText,
|
||||
InfoPanelTitle,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import RoomInfoActions from './RoomInfoActions';
|
||||
import RetentionPolicyCallout from '../../../../../components/InfoPanel/RetentionPolicyCallout';
|
||||
import MarkdownText from '../../../../../components/MarkdownText';
|
||||
import { useRetentionPolicy } from '../../../hooks/useRetentionPolicy';
|
||||
import { useRoomActions } from '../hooks/useRoomActions';
|
||||
import { useSplitRoomActions } from '../hooks/useSplitRoomActions';
|
||||
import RoomInfoABACSection from './ABAC/RoomInfoABACSection';
|
||||
|
||||
export type RoomInfoProps = {
|
||||
room: IRoom;
|
||||
icon: string;
|
||||
onClickBack?: () => void;
|
||||
onClickClose?: () => void;
|
||||
onClickEnterRoom?: () => void;
|
||||
onClickEdit?: () => void;
|
||||
resetState?: () => void;
|
||||
};
|
||||
|
||||
const RoomInfo = ({ room, icon, onClickBack, onClickClose, onClickEnterRoom, onClickEdit, resetState }: RoomInfoProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { name, fname, description, topic, archived, broadcast, announcement } = room;
|
||||
const roomTitle = fname || name;
|
||||
const isDiscussion = 'prid' in room;
|
||||
|
||||
const retentionPolicy = useRetentionPolicy(room);
|
||||
const actions = useRoomActions(room, { onClickEnterRoom, onClickEdit, resetState });
|
||||
const { buttons, menu } = useSplitRoomActions(actions);
|
||||
|
||||
return (
|
||||
<ContextualbarDialog>
|
||||
<ContextualbarHeader>
|
||||
{onClickBack ? <ContextualbarBack onClick={onClickBack} /> : <ContextualbarIcon name='info-circled' />}
|
||||
<ContextualbarTitle>{isDiscussion ? t('Discussion_info') : t('Channel_info')}</ContextualbarTitle>
|
||||
{onClickClose && <ContextualbarClose onClick={onClickClose} />}
|
||||
</ContextualbarHeader>
|
||||
|
||||
<ContextualbarScrollableContent padding={24}>
|
||||
<InfoPanel>
|
||||
<InfoPanelSection maxWidth='x332' marginInline='auto'>
|
||||
<InfoPanelAvatar>
|
||||
<RoomAvatar size='x332' room={room} />
|
||||
</InfoPanelAvatar>
|
||||
<InfoPanelActionGroup>
|
||||
<RoomInfoActions actions={buttons} />
|
||||
{menu && (
|
||||
<GenericMenu
|
||||
title={t('More')}
|
||||
placement='bottom-end'
|
||||
button={<IconButton icon='kebab' secondary flexShrink={0} flexGrow={0} maxHeight='initial' />}
|
||||
sections={menu}
|
||||
/>
|
||||
)}
|
||||
</InfoPanelActionGroup>
|
||||
</InfoPanelSection>
|
||||
|
||||
{archived && (
|
||||
<InfoPanelSection>
|
||||
<Box marginBlock={16}>
|
||||
<Callout type='warning'>{t('Room_archived')}</Callout>
|
||||
</Box>
|
||||
</InfoPanelSection>
|
||||
)}
|
||||
|
||||
{roomTitle && (
|
||||
<InfoPanelSection>
|
||||
<InfoPanelTitle title={roomTitle} icon={icon} />
|
||||
</InfoPanelSection>
|
||||
)}
|
||||
|
||||
<InfoPanelSection>
|
||||
{broadcast && (
|
||||
<InfoPanelField>
|
||||
<InfoPanelLabel>
|
||||
<b>{t('Broadcast_channel')}</b> {t('Broadcast_channel_Description')}
|
||||
</InfoPanelLabel>
|
||||
</InfoPanelField>
|
||||
)}
|
||||
|
||||
{description && description !== '' && (
|
||||
<InfoPanelField>
|
||||
<InfoPanelLabel>{t('Description')}</InfoPanelLabel>
|
||||
<InfoPanelText withTruncatedText={false}>
|
||||
<MarkdownText variant='inline' content={description} />
|
||||
</InfoPanelText>
|
||||
</InfoPanelField>
|
||||
)}
|
||||
|
||||
{announcement && announcement !== '' && (
|
||||
<InfoPanelField>
|
||||
<InfoPanelLabel>{t('Announcement')}</InfoPanelLabel>
|
||||
<InfoPanelText withTruncatedText={false}>
|
||||
<MarkdownText variant='inline' content={announcement} />
|
||||
</InfoPanelText>
|
||||
</InfoPanelField>
|
||||
)}
|
||||
|
||||
{topic && topic !== '' && (
|
||||
<InfoPanelField>
|
||||
<InfoPanelLabel>{t('Topic')}</InfoPanelLabel>
|
||||
<InfoPanelText withTruncatedText={false}>
|
||||
<MarkdownText variant='inline' content={topic} />
|
||||
</InfoPanelText>
|
||||
</InfoPanelField>
|
||||
)}
|
||||
|
||||
{retentionPolicy?.isActive && <RetentionPolicyCallout room={room} />}
|
||||
<RoomInfoABACSection room={room} />
|
||||
</InfoPanelSection>
|
||||
</InfoPanel>
|
||||
</ContextualbarScrollableContent>
|
||||
</ContextualbarDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomInfo;
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Keys as IconKeys } from '@rocket.chat/icons';
|
||||
import { InfoPanelAction } from '@rocket.chat/ui-client';
|
||||
|
||||
type Action = {
|
||||
id: string;
|
||||
content: string;
|
||||
icon: IconKeys;
|
||||
onClick: () => void;
|
||||
variant?: string;
|
||||
};
|
||||
|
||||
export type RoomInfoActionsProps = {
|
||||
actions: { items: Action[] };
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const RoomInfoActions = ({ actions, className }: RoomInfoActionsProps) => {
|
||||
return (
|
||||
<>
|
||||
{actions.items.map(({ id, content, icon, onClick }) => (
|
||||
<InfoPanelAction className={className} key={id} label={content} onClick={onClick} icon={icon} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomInfoActions;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './RoomInfo';
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useRoomToolbox } from '@rocket.chat/ui-contexts';
|
||||
import { useState } from 'react';
|
||||
|
||||
import EditRoomInfoWithData from './EditRoomInfo';
|
||||
import RoomInfo from './RoomInfo';
|
||||
import { useCanEditRoom } from './hooks/useCanEditRoom';
|
||||
import { useRoom } from '../../contexts/RoomContext';
|
||||
|
||||
export type RoomInfoRouterProps = {
|
||||
onClickBack?: () => void;
|
||||
onEnterRoom?: (room: IRoom) => void;
|
||||
resetState?: () => void;
|
||||
};
|
||||
|
||||
const RoomInfoRouter = ({ onClickBack, onEnterRoom, resetState }: RoomInfoRouterProps) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const { closeTab } = useRoomToolbox();
|
||||
const room = useRoom();
|
||||
|
||||
const canEdit = useCanEditRoom(room);
|
||||
const onClickEnterRoom = useStableCallback(() => onEnterRoom?.(room));
|
||||
|
||||
if (isEditing) {
|
||||
return <EditRoomInfoWithData onClickBack={() => setIsEditing(false)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<RoomInfo
|
||||
room={room}
|
||||
icon={room.t === 'p' ? 'lock' : 'hashtag'}
|
||||
onClickBack={onClickBack}
|
||||
onClickEdit={canEdit ? () => setIsEditing(true) : undefined}
|
||||
onClickClose={closeTab}
|
||||
{...(Boolean(onEnterRoom) && {
|
||||
onClickEnterRoom,
|
||||
})}
|
||||
resetState={resetState}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomInfoRouter;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { isRoomFederated } from '@rocket.chat/core-typings';
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { GenericModal } from '@rocket.chat/ui-client';
|
||||
import { useSetModal, useToastMessageDispatch, useTranslation, useEndpoint, usePermission } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import { useCanEditRoom } from '../useCanEditRoom';
|
||||
|
||||
export const useRoomConvertToTeam = (room: IRoom) => {
|
||||
const t = useTranslation();
|
||||
const setModal = useSetModal();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
|
||||
const canEdit = useCanEditRoom(room);
|
||||
const hasPermissionToConvertRoomToTeam = usePermission('create-team');
|
||||
const canConvertRoomToTeam = isRoomFederated(room) ? false : hasPermissionToConvertRoomToTeam;
|
||||
const allowConvertToTeam = !room.teamId && !room.prid && canConvertRoomToTeam && canEdit;
|
||||
|
||||
const convertRoomToTeam = useEndpoint('POST', room.t === 'c' ? '/v1/channels.convertToTeam' : '/v1/groups.convertToTeam');
|
||||
|
||||
const handleConvertToTeam = useStableCallback(async () => {
|
||||
const onConfirm = async () => {
|
||||
try {
|
||||
await convertRoomToTeam(room.t === 'c' ? { channelId: room._id } : { roomId: room._id });
|
||||
dispatchToastMessage({ type: 'success', message: t('Room_has_been_converted') });
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
} finally {
|
||||
setModal(null);
|
||||
}
|
||||
};
|
||||
|
||||
setModal(
|
||||
<GenericModal
|
||||
title={t('Confirmation')}
|
||||
variant='warning'
|
||||
onClose={() => setModal(null)}
|
||||
onCancel={() => setModal(null)}
|
||||
onConfirm={onConfirm}
|
||||
confirmText={t('Convert')}
|
||||
>
|
||||
{t('Converting_channel_to_a_team')}
|
||||
</GenericModal>,
|
||||
);
|
||||
});
|
||||
|
||||
return allowConvertToTeam ? handleConvertToTeam : null;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useRoomLeave } from './useRoomLeave';
|
||||
import { createFakeRoom, createFakeSubscription } from '../../../../../../../tests/mocks/data';
|
||||
|
||||
const mockRoom = createFakeRoom({ _id: 'room1', t: 'c', name: 'room1', fname: 'Room 1' });
|
||||
const mockSubscription = createFakeSubscription({ name: 'room1', t: 'c', disableNotifications: false, rid: 'room1' });
|
||||
|
||||
jest.mock('../../../../../../../client/lib/LegacyRoomManager', () => ({
|
||||
LegacyRoomManager: {
|
||||
close: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../../../../../../client/lib/rooms/roomCoordinator', () => ({
|
||||
roomCoordinator: {
|
||||
getRoomDirectives: () => ({
|
||||
getUiText: () => 'leaveWarning',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
it('should return leave function if user has subscription', () => {
|
||||
const wrapper = mockAppRoot()
|
||||
.withPermission('leave-c')
|
||||
.withSubscription({ ...mockSubscription, rid: 'room1' })
|
||||
.build();
|
||||
|
||||
const { result } = renderHook(() => useRoomLeave(mockRoom), { wrapper });
|
||||
expect(typeof result.current).toBe('function');
|
||||
});
|
||||
|
||||
it('should return null if user does not have subscription', () => {
|
||||
const wrapper = mockAppRoot().withPermission('leave-c').build();
|
||||
|
||||
const { result } = renderHook(() => useRoomLeave(mockRoom), { wrapper });
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import type { TranslationKey } from '@rocket.chat/ui-contexts';
|
||||
import { useRouter, useSetModal, useToastMessageDispatch, useEndpoint, usePermission, useUserSubscription } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { UiTextContext } from '../../../../../../../definition/IRoomTypeConfig';
|
||||
import WarningModal from '../../../../../../components/WarningModal';
|
||||
import { LegacyRoomManager } from '../../../../../../lib/LegacyRoomManager';
|
||||
import { roomCoordinator } from '../../../../../../lib/rooms/roomCoordinator';
|
||||
|
||||
export const useRoomLeave = (room: IRoom) => {
|
||||
const { t } = useTranslation();
|
||||
const subscription = useUserSubscription(room._id);
|
||||
const setModal = useSetModal();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const leaveChannel = useEndpoint('POST', '/v1/channels.leave');
|
||||
const leaveGroup = useEndpoint('POST', '/v1/groups.leave');
|
||||
const leaveDirect = useEndpoint('POST', '/v1/im.leave');
|
||||
const router = useRouter();
|
||||
|
||||
const canLeave = usePermission(room.t === 'c' ? 'leave-c' : 'leave-p') && room.cl !== false && Boolean(subscription);
|
||||
|
||||
const handleLeave = useStableCallback(() => {
|
||||
const leaveAction = async () => {
|
||||
try {
|
||||
if (room.t === 'c') {
|
||||
await leaveChannel({ roomId: room._id });
|
||||
} else if (room.t === 'p') {
|
||||
await leaveGroup({ roomId: room._id });
|
||||
} else if (room.t === 'd') {
|
||||
await leaveDirect({ roomId: room._id });
|
||||
}
|
||||
router.navigate('/home');
|
||||
|
||||
if (room.name) {
|
||||
LegacyRoomManager.close(`${room.t}${room.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
setModal(null);
|
||||
};
|
||||
|
||||
const warnText = roomCoordinator.getRoomDirectives(room.t).getUiText(UiTextContext.LEAVE_WARNING);
|
||||
|
||||
setModal(
|
||||
<WarningModal
|
||||
text={t(warnText as TranslationKey, { roomName: room.fname || room.name })}
|
||||
confirmText={t('Leave_room')}
|
||||
close={() => setModal(null)}
|
||||
cancelText={t('Cancel')}
|
||||
confirm={leaveAction}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
return canLeave ? handleLeave : null;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { isRoomFederated } from '@rocket.chat/core-typings';
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useSetModal, useToastMessageDispatch, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import ChannelToTeamModal from '../../ChannelToTeamModal';
|
||||
import { useCanEditRoom } from '../useCanEditRoom';
|
||||
|
||||
export const useRoomMoveToTeam = (room: IRoom) => {
|
||||
const t = useTranslation();
|
||||
const setModal = useSetModal();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
|
||||
const canEdit = useCanEditRoom(room);
|
||||
const canMoveToTeam = !isRoomFederated(room) && !room.teamId && !room.prid && canEdit;
|
||||
|
||||
const moveChannelToTeam = useEndpoint('POST', '/v1/teams.addRooms');
|
||||
|
||||
const handleMoveToTeam = useStableCallback(async () => {
|
||||
const onConfirm = async (teamId: IRoom['teamId']) => {
|
||||
if (!teamId) {
|
||||
throw new Error('teamId not provided');
|
||||
}
|
||||
|
||||
try {
|
||||
await moveChannelToTeam({ rooms: [room._id], teamId });
|
||||
dispatchToastMessage({ type: 'success', message: t('Rooms_added_successfully') });
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
} finally {
|
||||
setModal(null);
|
||||
}
|
||||
};
|
||||
|
||||
setModal(<ChannelToTeamModal onCancel={() => setModal(null)} onConfirm={onConfirm} />);
|
||||
});
|
||||
|
||||
return canMoveToTeam ? handleMoveToTeam : null;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { isRoomFederated } from '@rocket.chat/core-typings';
|
||||
import { usePermission, useUser } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import * as Federation from '../../../../../lib/federation/Federation';
|
||||
import { useRoomSubscription } from '../../../contexts/RoomContext';
|
||||
|
||||
export const useCanEditRoom = (room: IRoom) => {
|
||||
const user = useUser();
|
||||
const subscription = useRoomSubscription();
|
||||
|
||||
const hasPermissionToEdit = usePermission('edit-room', room._id);
|
||||
const isFederated = isRoomFederated(room);
|
||||
const canEdit =
|
||||
user && isFederated ? Federation.isEditableByTheUser(user, room, subscription) && hasPermissionToEdit : hasPermissionToEdit;
|
||||
|
||||
return canEdit;
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useRoomConvertToTeam } from './actions/useRoomConvertToTeam';
|
||||
import { useRoomLeave } from './actions/useRoomLeave';
|
||||
import { useRoomMoveToTeam } from './actions/useRoomMoveToTeam';
|
||||
import { useHideRoomAction } from '../../../../../hooks/useHideRoomAction';
|
||||
import { useDeleteRoom } from '../../../../hooks/roomActions/useDeleteRoom';
|
||||
|
||||
type UseRoomActionsOptions = {
|
||||
onClickEnterRoom?: () => void;
|
||||
onClickEdit?: () => void;
|
||||
resetState?: () => void;
|
||||
};
|
||||
|
||||
export const useRoomActions = (room: IRoom, options: UseRoomActionsOptions) => {
|
||||
const { onClickEnterRoom, onClickEdit, resetState } = options;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleLeave = useRoomLeave(room);
|
||||
const { handleDelete, canDeleteRoom } = useDeleteRoom(room, { reload: resetState });
|
||||
const handleMoveToTeam = useRoomMoveToTeam(room);
|
||||
const handleConvertToTeam = useRoomConvertToTeam(room);
|
||||
const handleHide = useHideRoomAction({ rid: room._id, type: room.t, name: room.name ?? '' });
|
||||
|
||||
return useMemo(() => {
|
||||
const memoizedActions = {
|
||||
items: [
|
||||
{
|
||||
id: 'hide',
|
||||
content: t('Hide'),
|
||||
icon: 'eye-off' as const,
|
||||
onClick: handleHide,
|
||||
},
|
||||
|
||||
...(onClickEnterRoom
|
||||
? [
|
||||
{
|
||||
id: 'enter',
|
||||
content: t('Enter'),
|
||||
icon: 'login' as const,
|
||||
onClick: onClickEnterRoom,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(onClickEdit
|
||||
? [
|
||||
{
|
||||
id: 'edit',
|
||||
content: t('Edit'),
|
||||
icon: 'edit' as const,
|
||||
onClick: onClickEdit,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(handleLeave
|
||||
? [
|
||||
{
|
||||
id: 'leave',
|
||||
content: t('Leave'),
|
||||
icon: 'sign-out' as const,
|
||||
onClick: handleLeave,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(handleMoveToTeam
|
||||
? [
|
||||
{
|
||||
id: 'move_channel_team',
|
||||
content: t('Teams_move_channel_to_team'),
|
||||
icon: 'team-arrow-right' as const,
|
||||
onClick: handleMoveToTeam,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(handleConvertToTeam
|
||||
? [
|
||||
{
|
||||
id: 'convert_channel_team',
|
||||
content: t('Teams_convert_channel_to_team'),
|
||||
icon: 'team' as const,
|
||||
onClick: handleConvertToTeam,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canDeleteRoom
|
||||
? [
|
||||
{
|
||||
id: 'delete',
|
||||
content: t('Delete'),
|
||||
icon: 'trash' as const,
|
||||
onClick: handleDelete,
|
||||
variant: 'danger',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
||||
return memoizedActions;
|
||||
}, [canDeleteRoom, handleConvertToTeam, handleDelete, handleHide, handleLeave, handleMoveToTeam, onClickEdit, onClickEnterRoom, t]);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { RoomInfoActionsProps } from '../RoomInfo/RoomInfoActions';
|
||||
|
||||
type UseSplitRoomActionsOptions = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param room
|
||||
* @param options
|
||||
* @returns If more than two room actions are enabled `menu.regular` will be a non-empty array
|
||||
*/
|
||||
export const useSplitRoomActions = (actions: RoomInfoActionsProps['actions'], options?: UseSplitRoomActionsOptions) => {
|
||||
const size = options?.size || 2;
|
||||
|
||||
if (actions.items.length <= size) {
|
||||
return { buttons: actions };
|
||||
}
|
||||
|
||||
const buttons = { items: actions.items.slice(0, size) };
|
||||
const regular = actions.items.slice(size);
|
||||
const firstDanger = regular.findIndex((item) => item.variant);
|
||||
const danger = regular.splice(firstDanger);
|
||||
|
||||
const menu = [{ items: regular }, { items: danger }];
|
||||
|
||||
return { buttons, menu };
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './RoomInfoRouter';
|
||||
46
apps/meteor/client/views/room/contextualBar/MentionsTab.tsx
Normal file
46
apps/meteor/client/views/room/contextualBar/MentionsTab.tsx
Normal 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;
|
||||
119
apps/meteor/client/views/room/contextualBar/MessageListTab.tsx
Normal file
119
apps/meteor/client/views/room/contextualBar/MessageListTab.tsx
Normal 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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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'),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './MessageSearchTab';
|
||||
@@ -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'],
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './NotificationPreferencesWithData';
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './PruneMessagesWithData';
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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(),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './RoomFilesWithData';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AddUsers';
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user