snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
import { Box } from '@rocket.chat/fuselage';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { StoryObj, Meta } from '@storybook/react';
import InviteUsers from './InviteUsers';
import InviteUsersEdit from './InviteUsersEdit';
import InviteUsersError from './InviteUsersError';
import InviteUsersLoading from './InviteUsersLoading';
import { links } from '../../../../../lib/links';
export default {
component: InviteUsers,
parameters: {
layout: 'fullscreen',
actions: { argTypesRegex: '^on.*' },
},
decorators: [
(fn) => <Box height='100vh'>{fn()}</Box>,
mockAppRoot()
.withTranslations('en', 'core', {
'Edit_Invite': 'Edit invite',
'Invite_Users': 'Invite users',
'Invite_Link': 'Invite link',
'Expiration_(Days)': 'Expiration (Days)',
'Max_number_of_uses': 'Max number of uses',
})
.buildStoryDecorator(),
],
} satisfies Meta<typeof InviteUsers>;
export const Default: StoryObj<typeof InviteUsers> = {
name: 'Invite Link',
args: {
linkText: links.go.invite,
captionText: 'Expire on February 4, 2020 4:45 PM.',
},
};
export const InviteEdit: StoryObj<typeof InviteUsersEdit> = {
render: (args) => <InviteUsersEdit {...args} daysAndMaxUses={{ days: '1', maxUses: '5' }} />,
};
export const InviteLoading: StoryObj<typeof InviteUsersLoading> = {
render: (args) => <InviteUsersLoading {...args} />,
};
export const InviteError: StoryObj<typeof InviteUsersError> = {
render: (args) => <InviteUsersError {...args} error={new Error('Error message')} />,
};

View File

@@ -0,0 +1,18 @@
import InviteLink from './InviteLink';
import InviteUsersWrapper from './InviteUsersWrapper';
export type InviteUsersProps = {
onClickBackMembers?: () => void;
onClose: () => void;
onClickEdit: () => void;
captionText: string;
linkText: string;
};
const InviteUsers = ({ onClickBackMembers, onClose, onClickEdit, captionText, linkText }: InviteUsersProps) => (
<InviteUsersWrapper onClickBack={onClickBackMembers} onClose={onClose}>
<InviteLink captionText={captionText} onClickEdit={onClickEdit} linkText={linkText} />
</InviteUsersWrapper>
);
export default InviteUsers;

View File

@@ -0,0 +1,19 @@
import EditInviteLink from './EditInviteLink';
import InviteUsersWrapper from './InviteUsersWrapper';
export type InviteUsersEditProps = {
onClickBackLink?: () => void;
onClickNewLink: (daysAndMaxUses: { days: string; maxUses: string }) => void;
onClose: () => void;
daysAndMaxUses: { days: string; maxUses: string };
};
const InviteUsersEdit = ({ onClickBackLink, onClickNewLink, onClose, daysAndMaxUses }: InviteUsersEditProps) => {
return (
<InviteUsersWrapper onClickBack={onClickBackLink} onClose={onClose}>
<EditInviteLink onClickNewLink={onClickNewLink} daysAndMaxUses={daysAndMaxUses} />
</InviteUsersWrapper>
);
};
export default InviteUsersEdit;

View File

@@ -0,0 +1,17 @@
import { Callout } from '@rocket.chat/fuselage';
import InviteUsersWrapper from './InviteUsersWrapper';
type InviteUsersProps = {
onClose: () => void;
error: Error;
onClickBack?: (() => void) | undefined;
};
const InviteUsersError = ({ onClose, error, onClickBack }: InviteUsersProps) => (
<InviteUsersWrapper onClose={onClose} onClickBack={onClickBack}>
<Callout type='danger'>{(error || '').toString()}</Callout>
</InviteUsersWrapper>
);
export default InviteUsersError;

View File

@@ -0,0 +1,16 @@
import { Skeleton } from '@rocket.chat/fuselage';
import InviteUsersWrapper from './InviteUsersWrapper';
type InviteUsersProps = {
onClose: () => void;
onClickBack: (() => void) | undefined;
};
const InviteUsersLoading = ({ onClose, onClickBack: onClickBackMembers }: InviteUsersProps) => (
<InviteUsersWrapper onClose={onClose} onClickBack={onClickBackMembers}>
<Skeleton width='full' />
</InviteUsersWrapper>
);
export default InviteUsersLoading;

View File

@@ -0,0 +1,127 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useEndpoint, useTranslation, useToastMessageDispatch, useRoomToolbox } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useState, useEffect } from 'react';
import InviteUsers from './InviteUsers';
import InviteUsersEdit from './InviteUsersEdit';
import InviteUsersError from './InviteUsersError';
import InviteUsersLoading from './InviteUsersLoading';
import { useFormatDateAndTime } from '../../../../../hooks/useFormatDateAndTime';
export type InviteUsersWithDataProps = {
rid: IRoom['_id'];
onClickBack: () => void;
};
const InviteUsersWithData = ({ rid, onClickBack }: InviteUsersWithDataProps) => {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const [
{
isEditing,
daysAndMaxUses: { days, maxUses },
},
setInviteState,
] = useState({
isEditing: false,
daysAndMaxUses: { days: '1', maxUses: '0' },
});
const { closeTab } = useRoomToolbox();
const format = useFormatDateAndTime();
const findOrCreateInvite = useEndpoint('POST', '/v1/findOrCreateInvite');
const handleEdit = useStableCallback(() => setInviteState((prevState) => ({ ...prevState, isEditing: true })));
const handleBackToLink = useStableCallback(() => setInviteState((prevState) => ({ ...prevState, isEditing: false })));
const linkExpirationText = useStableCallback(
(data?: {
days: number;
maxUses: number;
rid: string;
userId: string;
createdAt: string;
expires: string | null;
uses: number;
url: string;
_id: string;
_updatedAt: string;
}) => {
if (!data) {
return '';
}
if (data.expires) {
const expiration = new Date(data.expires);
if (data.maxUses) {
const usesLeft = data.maxUses - data.uses;
return t('Your_invite_link_will_expire_on__date__or_after__usesLeft__uses', {
date: format(expiration),
usesLeft,
});
}
return t('Your_invite_link_will_expire_on__date__', { date: format(expiration) });
}
if (data.maxUses) {
const usesLeft = data.maxUses - data.uses;
return t('Your_invite_link_will_expire_after__usesLeft__uses', { usesLeft });
}
return t('Your_invite_link_will_never_expire');
},
);
const { data, isSuccess, error, isError, isLoading } = useQuery({
queryKey: ['findOrCreateInvite', days, maxUses],
queryFn: async () => findOrCreateInvite({ rid, days: Number(days), maxUses: Number(maxUses) }),
});
useEffect(() => {
if (isSuccess) {
dispatchToastMessage({ type: 'success', message: t('Invite_link_generated') });
}
}, [dispatchToastMessage, isSuccess, t]);
const handleGenerateLink = useStableCallback((daysAndMaxUses: { days: string; maxUses: string }) => {
setInviteState((prevState) => ({ ...prevState, daysAndMaxUses, isEditing: false }));
});
if (isError) {
return <InviteUsersError error={error} onClose={closeTab} onClickBack={onClickBack} />;
}
if (isLoading) {
return <InviteUsersLoading onClose={closeTab} onClickBack={onClickBack} />;
}
if (isEditing) {
return (
<InviteUsersEdit
daysAndMaxUses={{ days, maxUses }}
onClickBackLink={handleBackToLink}
onClickNewLink={handleGenerateLink}
onClose={closeTab}
/>
);
}
if (isSuccess) {
return (
<InviteUsers
linkText={data.url}
captionText={linkExpirationText(data)}
onClose={closeTab}
onClickBackMembers={onClickBack}
onClickEdit={handleEdit}
/>
);
}
return <InviteUsersError error={new Error(t('Something_Went_Wrong'))} onClose={closeTab} onClickBack={onClickBack} />;
};
export default InviteUsersWithData;

View File

@@ -0,0 +1,33 @@
import {
ContextualbarHeader,
ContextualbarTitle,
ContextualbarBack,
ContextualbarClose,
ContextualbarScrollableContent,
ContextualbarDialog,
} from '@rocket.chat/ui-client';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
export type InviteUsersWrapperProps = {
children: ReactNode;
onClickBack: (() => void) | undefined;
onClose: () => void;
};
const InviteUsersWrapper = ({ children, onClickBack, onClose }: InviteUsersWrapperProps) => {
const { t } = useTranslation();
return (
<ContextualbarDialog>
<ContextualbarHeader>
<ContextualbarBack onClick={onClickBack} />
<ContextualbarTitle>{t('Invite_Users')}</ContextualbarTitle>
<ContextualbarClose onClick={onClose} />
</ContextualbarHeader>
<ContextualbarScrollableContent>{children}</ContextualbarScrollableContent>
</ContextualbarDialog>
);
};
export default InviteUsersWrapper;

View File

@@ -0,0 +1,847 @@
// 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-css-llf9s1"
>
<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"
>
<button
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ue04py"
title="Back"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-arrow-back rcx-icon rcx-css-4pvxx3"
>
⦉
</i>
</button>
<h5
class="rcx-box rcx-box--full rcx-css-1spko5i"
id="contextualbarTitle"
>
Invite users
</h5>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ue04py"
data-qa="ContextualbarActionClose"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1svuzur"
>
<div
class="rcx-box rcx-box--full rcx-css-1pd039f rcx-css-1cb6i7s"
data-overlayscrollbars="host"
>
<div
class="os-size-observer"
>
<div
class="os-size-observer-listener"
/>
</div>
<div
class=""
data-overlayscrollbars-viewport="scrollbarHidden overflowXHidden overflowYHidden"
style="margin-right: 0px; margin-bottom: 0px; margin-left: 0px; top: 0px; left: 0px; width: calc(100% + 0px); padding: 0px 0px 0px 0px;"
tabindex="-1"
>
<div
class="rcx-box rcx-box--full rcx-css-zg7o7h"
>
<div
class="rcx-box rcx-box--full rcx-field rcx-css-1fuhgt0"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label rcx-css-irxdij"
for="_r_1_"
>
Invite link
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<label
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper"
>
<input
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-url rcx-input-box"
id="_r_1_"
size="1"
type="url"
value="https://go.rocket.chat/invite?host=open.rocket.chat&path=invite%2F5sBs3a"
/>
<span
class="rcx-box rcx-box--full rcx-input-box__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-copy rcx-icon rcx-css-1g87xs3"
>
</i>
</span>
</label>
</span>
<div
class="rcx-box rcx-box--full rcx-css-14258hb"
>
Expire on February 4, 2020 4:45 PM.
</div>
</div>
</div>
</div>
<div
class="os-scrollbar os-scrollbar-horizontal os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
>
<div
class="os-scrollbar-track"
>
<div
class="os-scrollbar-handle"
/>
</div>
</div>
<div
class="os-scrollbar os-scrollbar-vertical os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
>
<div
class="os-scrollbar-track"
>
<div
class="os-scrollbar-handle"
/>
</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 InviteEdit without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-llf9s1"
>
<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"
>
<button
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ue04py"
title="Back"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-arrow-back rcx-icon rcx-css-4pvxx3"
>
⦉
</i>
</button>
<h5
class="rcx-box rcx-box--full rcx-css-1spko5i"
id="contextualbarTitle"
>
Invite users
</h5>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ue04py"
data-qa="ContextualbarActionClose"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1svuzur"
>
<div
class="rcx-box rcx-box--full rcx-css-1pd039f rcx-css-1cb6i7s"
data-overlayscrollbars="host"
>
<div
class="os-size-observer"
>
<div
class="os-size-observer-listener"
/>
</div>
<div
class=""
data-overlayscrollbars-viewport="scrollbarHidden overflowXHidden overflowYHidden"
style="margin-right: 0px; margin-bottom: 0px; margin-left: 0px; top: 0px; left: 0px; width: calc(100% + 0px); padding: 0px 0px 0px 0px;"
tabindex="-1"
>
<div
class="rcx-box rcx-box--full rcx-css-zg7o7h"
>
<div
class="rcx-box rcx-box--full rcx-field rcx-css-1fuhgt0"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label rcx-css-irxdij"
for="_r_3_"
>
Expiration (Days)
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<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
name="days"
tabindex="-1"
>
<option />
<option
value="1"
>
1
</option>
<option
value="7"
>
7
</option>
<option
value="15"
>
15
</option>
<option
value="30"
>
30
</option>
<option
value="0"
>
0
</option>
</select>
</label>
</div>
</div>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_c_"
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
id="_r_3_"
type="button"
>
<span
class="rcx-box rcx-box--full rcx-css-1ah8hdc"
id="react-aria-_r_c_"
>
1
</span>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-l5txo2"
>
</i>
</button>
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-field rcx-css-1fuhgt0"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label rcx-css-irxdij"
for="_r_4_"
>
Max number of uses
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<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
name="maxUses"
tabindex="-1"
>
<option />
<option
value="5"
>
5
</option>
<option
value="10"
>
10
</option>
<option
value="25"
>
25
</option>
<option
value="50"
>
50
</option>
<option
value="100"
>
100
</option>
<option
value="0"
>
0
</option>
</select>
</label>
</div>
</div>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_k_"
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
id="_r_4_"
type="button"
>
<span
class="rcx-box rcx-box--full rcx-css-1ah8hdc"
id="react-aria-_r_k_"
>
5
</span>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-l5txo2"
>
</i>
</button>
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-css-31obua"
>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
disabled=""
type="button"
>
<span
class="rcx-button--content"
>
Generate_New_Link
</span>
</button>
</div>
</div>
</div>
<div
class="os-scrollbar os-scrollbar-horizontal os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
>
<div
class="os-scrollbar-track"
>
<div
class="os-scrollbar-handle"
/>
</div>
</div>
<div
class="os-scrollbar os-scrollbar-vertical os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
>
<div
class="os-scrollbar-track"
>
<div
class="os-scrollbar-handle"
/>
</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 InviteError without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-llf9s1"
>
<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"
>
<button
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ue04py"
title="Back"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-arrow-back rcx-icon rcx-css-4pvxx3"
>
⦉
</i>
</button>
<h5
class="rcx-box rcx-box--full rcx-css-1spko5i"
id="contextualbarTitle"
>
Invite users
</h5>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ue04py"
data-qa="ContextualbarActionClose"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1svuzur"
>
<div
class="rcx-box rcx-box--full rcx-css-1pd039f rcx-css-1cb6i7s"
data-overlayscrollbars="host"
>
<div
class="os-size-observer"
>
<div
class="os-size-observer-listener"
/>
</div>
<div
class=""
data-overlayscrollbars-viewport="scrollbarHidden overflowXHidden overflowYHidden"
style="margin-right: 0px; margin-bottom: 0px; margin-left: 0px; top: 0px; left: 0px; width: calc(100% + 0px); padding: 0px 0px 0px 0px;"
tabindex="-1"
>
<div
class="rcx-box rcx-box--full rcx-css-zg7o7h"
>
<section
class="rcx-box rcx-box--full rcx-callout rcx-callout--danger rcx-css-1jggbrp rcx-css-1fuhgt0"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-ban rcx-icon rcx-callout__icon rcx-css-4pvxx3"
>
</i>
<div
class="rcx-box rcx-box--full rcx-callout__wrapper"
>
<div
class="rcx-box rcx-box--full rcx-callout__wrapper-content"
>
<div
class="rcx-box rcx-box--full rcx-callout__content"
>
Error: Error message
</div>
</div>
</div>
</section>
</div>
</div>
<div
class="os-scrollbar os-scrollbar-horizontal os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
>
<div
class="os-scrollbar-track"
>
<div
class="os-scrollbar-handle"
/>
</div>
</div>
<div
class="os-scrollbar os-scrollbar-vertical os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
>
<div
class="os-scrollbar-track"
>
<div
class="os-scrollbar-handle"
/>
</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 InviteLoading without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-llf9s1"
>
<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"
>
<button
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ue04py"
title="Back"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-arrow-back rcx-icon rcx-css-4pvxx3"
>
⦉
</i>
</button>
<h5
class="rcx-box rcx-box--full rcx-css-1spko5i"
id="contextualbarTitle"
>
Invite users
</h5>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ue04py"
data-qa="ContextualbarActionClose"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1svuzur"
>
<div
class="rcx-box rcx-box--full rcx-css-1pd039f rcx-css-1cb6i7s"
data-overlayscrollbars="host"
>
<div
class="os-size-observer"
>
<div
class="os-size-observer-listener"
/>
</div>
<div
class=""
data-overlayscrollbars-viewport="scrollbarHidden overflowXHidden overflowYHidden"
style="margin-right: 0px; margin-bottom: 0px; margin-left: 0px; top: 0px; left: 0px; width: calc(100% + 0px); padding: 0px 0px 0px 0px;"
tabindex="-1"
>
<div
class="rcx-box rcx-box--full rcx-css-zg7o7h"
>
<span
class="rcx-skeleton rcx-skeleton--text rcx-css-1jggbrp rcx-css-1qcz93u"
/>
</div>
</div>
<div
class="os-scrollbar os-scrollbar-horizontal os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
>
<div
class="os-scrollbar-track"
>
<div
class="os-scrollbar-handle"
/>
</div>
</div>
<div
class="os-scrollbar os-scrollbar-vertical os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
>
<div
class="os-scrollbar-track"
>
<div
class="os-scrollbar-handle"
/>
</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>
`;

View File

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

View File

@@ -0,0 +1,32 @@
import { Box } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
export type MembersListDividerProps = {
title: TranslationKey;
count: number;
};
export const MembersListDivider = ({ title, count }: MembersListDividerProps) => {
const { t } = useTranslation();
return (
<Box
key={title}
backgroundColor='room'
height={36}
fontScale='p2m'
color='defaut'
paddingBlock={8}
paddingInline={24}
display='flex'
flexDirection='row'
justifyContent='space-between'
borderBlockEndWidth={1}
borderBlockEndColor='extra-light'
>
<Box>{t(title)}</Box>
<Box>{count}</Box>
</Box>
);
};

View File

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

View File

@@ -0,0 +1,98 @@
import { UserStatus } from '@rocket.chat/core-typings';
import { Contextualbar } from '@rocket.chat/ui-client';
import type { Meta } from '@storybook/react';
import { action } from 'storybook/actions';
import RoomMembers from './RoomMembers';
export default {
component: RoomMembers,
parameters: {
layout: 'fullscreen',
actions: { argTypesRegex: '^on.*' },
},
decorators: [(fn) => <Contextualbar height='100vh'>{fn()}</Contextualbar>],
args: {
text: 'filter',
type: 'online',
setText: action('setText'),
setType: action('setType'),
loadMoreItems: action('loadMoreItems'),
reload: action('reload'),
rid: 'GENERAL',
isTeam: false,
isDirect: false,
},
} satisfies Meta<typeof RoomMembers>;
export const Default = {
args: {
isSuccess: true,
members: [
{
_id: 'rocket.cat',
username: 'rocket.cat',
status: UserStatus.ONLINE,
name: 'Rocket.Cat',
roles: ['user'],
subscription: {
_id: 'sub-rocket.cat',
ts: '2025-01-01T00:00:00Z',
},
},
],
},
};
export const Loading = {
args: {
isPending: true,
},
};
export const WithABACRoom = {
args: {
isSuccess: true,
members: [
{
_id: 'rocket.cat',
username: 'rocket.cat',
status: UserStatus.ONLINE,
name: 'Rocket.Cat',
roles: ['user'],
subscription: {
_id: 'sub-rocket.cat',
ts: '2025-01-01T00:00:00Z',
},
},
],
isABACRoom: true,
},
};
export const WithInvitedMember = {
args: {
isSuccess: true,
members: [
{
_id: 'rocket.cat',
username: 'rocket.cat',
roles: ['user'],
subscription: {
_id: 'sub-rocket.cat',
status: 'INVITED',
ts: '2025-01-01T00:00:00Z',
},
name: 'Rocket.Cat',
},
],
},
};
export const Empty = {
args: {
isSuccess: true,
members: [],
total: 0,
},
};

View File

@@ -0,0 +1,243 @@
import type { IRoom } from '@rocket.chat/core-typings';
import type { SelectOption } from '@rocket.chat/fuselage';
import { Box, Icon, TextInput, Select, Throbber, ButtonGroup, Button, Callout } from '@rocket.chat/fuselage';
import { useAutoFocus, useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
import {
VirtualizedScrollbars,
ContextualbarHeader,
ContextualbarIcon,
ContextualbarTitle,
ContextualbarClose,
ContextualbarContent,
ContextualbarFooter,
ContextualbarEmptyContent,
ContextualbarSection,
ContextualbarDialog,
} from '@rocket.chat/ui-client';
import { useSetting } from '@rocket.chat/ui-contexts';
import type { ChangeEventHandler, ComponentProps, MouseEvent, ElementType } from 'react';
import { useId, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { GroupedVirtuoso } from 'react-virtuoso';
import { MembersListDivider } from './MembersListDivider';
import RoomMembersListWrapper from './RoomMembersListWrapper';
import RoomMembersRow from './RoomMembersRow';
import InfiniteListAnchor from '../../../../components/InfiniteListAnchor';
import ResultsLiveRegion from '../../../../components/ResultsLiveRegion';
import type { RoomMember } from '../../../hooks/useMembersList';
export type RoomMembersProps = {
rid: IRoom['_id'];
isTeam?: boolean;
isDirect?: boolean;
isPending: boolean;
isSuccess: boolean;
text: string;
type: string;
setText: ChangeEventHandler<HTMLInputElement>;
setType: (type: 'online' | 'all') => void;
members: RoomMember[];
total: number;
error?: Error;
onClickClose: () => void;
onClickView: (e: MouseEvent<HTMLElement>) => void;
onClickAdd?: () => void;
onClickInvite?: () => void;
loadMoreItems: () => void;
renderRow?: ElementType<ComponentProps<typeof RoomMembersRow>>;
reload: () => void;
isABACRoom?: boolean;
};
const RoomMembers = ({
isPending,
isSuccess,
members = [],
text,
type = 'online',
setText,
setType,
onClickClose,
onClickView,
onClickAdd,
onClickInvite,
total,
error,
loadMoreItems,
renderRow: RowComponent = RoomMembersRow,
rid,
isTeam,
isDirect,
reload,
isABACRoom = false,
}: RoomMembersProps) => {
const { t } = useTranslation();
const membersListId = useId();
const inputRef = useAutoFocus<HTMLInputElement>(true);
const itemData = useMemo(() => ({ onClickView, rid }), [onClickView, rid]);
const options: SelectOption[] = useMemo(
() => [
['online', t('Online')],
['all', t('All')],
],
[t],
);
const loadMoreMembers = useDebouncedCallback(
() => {
loadMoreItems();
},
300,
[loadMoreItems, members],
);
const useRealName = useSetting('UI_Use_Real_Name', false);
const { counts, titles } = useMemo(() => {
const owners: RoomMember[] = [];
const leaders: RoomMember[] = [];
const moderators: RoomMember[] = [];
const normalMembers: RoomMember[] = [];
members.forEach((member) => {
if (member.roles?.includes('owner')) {
owners.push(member);
} else if (member.roles?.includes('leader')) {
leaders.push(member);
} else if (member.roles?.includes('moderator')) {
moderators.push(member);
} else {
normalMembers.push(member);
}
});
const counts = [];
const titles = [];
if (owners.length > 0) {
counts.push(owners.length);
titles.push(<MembersListDivider title='Owners' count={owners.length} />);
}
if (leaders.length > 0) {
counts.push(leaders.length);
titles.push(<MembersListDivider title='Leaders' count={leaders.length} />);
}
if (moderators.length > 0) {
counts.push(moderators.length);
titles.push(<MembersListDivider title='Moderators' count={moderators.length} />);
}
if (normalMembers.length > 0) {
counts.push(normalMembers.length);
titles.push(<MembersListDivider title='Members' count={normalMembers.length} />);
}
return { counts, titles };
}, [members]);
return (
<ContextualbarDialog>
<ContextualbarHeader>
<ContextualbarIcon name='members' />
<ContextualbarTitle>{isTeam ? t('Teams_members') : t('Members')}</ContextualbarTitle>
{onClickClose && <ContextualbarClose onClick={onClickClose} />}
</ContextualbarHeader>
<ContextualbarSection>
<TextInput
placeholder={t('Search_by_username')}
aria-label={t('Search_by_username')}
aria-controls={isSuccess ? membersListId : undefined}
value={text}
ref={inputRef}
onChange={setText}
endAddon={<Icon name='magnifier' size='x20' />}
/>
<Box width='x144' marginInlineStart={8}>
<Select
aria-controls={isSuccess ? membersListId : undefined}
options={options}
value={type}
onChange={(value): void => setType(value as 'online' | 'all')}
/>
</Box>
</ContextualbarSection>
<ContextualbarContent padding={0} paddingBlock={12}>
<ResultsLiveRegion shouldAnnounce={isSuccess} itemCount={members.length} />
{isPending && (
<Box paddingInline={24} paddingBlock={12}>
<Throbber size='x12' />
</Box>
)}
{error && (
<Box paddingInline={24} paddingBlock={12}>
<Callout type='danger'>{error.message}</Callout>
</Box>
)}
{isSuccess && (
<>
{members.length > 0 && (
<Box paddingInline={24} paddingBlock={12}>
<Box is='span' color='hint' fontScale='p2'>
{t('Showing_current_of_total', { current: members.length, total })}
</Box>
</Box>
)}
<Box id={membersListId} width='full' height='full' overflow='hidden' flexShrink={1}>
{members.length <= 0 && <ContextualbarEmptyContent title={t('No_members_found')} />}
{members.length > 0 && (
<VirtualizedScrollbars>
<GroupedVirtuoso
style={{
height: '100%',
width: '100%',
}}
overscan={50}
groupCounts={counts}
groupContent={(index) => titles[index]}
components={{
// eslint-disable-next-line react/no-multi-comp
Footer: () => <InfiniteListAnchor loadMore={loadMoreMembers} />,
List: RoomMembersListWrapper,
}}
itemContent={(index) => (
<RowComponent useRealName={useRealName} data={itemData} user={members[index]} index={index} reload={reload} />
)}
/>
</VirtualizedScrollbars>
)}
</Box>
</>
)}
</ContextualbarContent>
{!isDirect && (onClickInvite || onClickAdd) && (
<ContextualbarFooter>
<ButtonGroup stretch>
{onClickInvite && (
<Button
icon='link'
onClick={onClickInvite}
width='50%'
disabled={isABACRoom}
title={isABACRoom ? t('Not_available_for_ABAC_enabled_rooms') : undefined}
aria-label={t('Invite_Link')}
>
{t('Invite_Link')}
</Button>
)}
{onClickAdd && (
<Button icon='user-plus' onClick={onClickAdd} width='50%' primary>
{t('Add')}
</Button>
)}
</ButtonGroup>
</ContextualbarFooter>
)}
</ContextualbarDialog>
);
};
export default RoomMembers;

View File

@@ -0,0 +1,31 @@
import type { IUser, IRoom } from '@rocket.chat/core-typings';
import { GenericMenu } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import { useUserInfoActions } from '../../hooks/useUserInfoActions';
export type RoomMembersActionsProps = Pick<IUser, '_id' | 'name' | 'username' | 'freeSwitchExtension'> & {
rid: IRoom['_id'];
isInvited?: boolean;
reload: () => void;
};
const RoomMembersActions = ({ username, _id, name, rid, freeSwitchExtension, isInvited, reload }: RoomMembersActionsProps) => {
const { t } = useTranslation();
const { menuActions: menuOptions } = useUserInfoActions({
rid,
user: { _id, username, name, freeSwitchExtension },
reload,
size: 0,
isMember: !isInvited,
isInvited,
});
if (!menuOptions) {
return null;
}
return <GenericMenu detached title={t('More')} key='menu' sections={menuOptions} placement='bottom-end' />;
};
export default RoomMembersActions;

View File

@@ -0,0 +1,83 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';
import RoomMembersItem from './RoomMembersItem';
jest.mock('@rocket.chat/ui-avatar', () => ({
UserAvatar: () => null,
}));
jest.mock('../../../../components/UserStatus', () => ({
ReactiveUserStatus: () => null,
}));
jest.mock('./RoomMembersActions', () => ({
__esModule: true,
default: () => <button>Actions</button>,
}));
const defaultProps = {
_id: 'user1',
name: 'John Doe',
username: 'johndoe',
rid: 'GENERAL',
useRealName: false,
reload: jest.fn(),
onClickView: jest.fn(),
subscription: { _id: 'sub1', ts: '2025-01-01T00:00:00Z' },
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('keyboard navigation', () => {
it('should be focusable via keyboard', () => {
render(<RoomMembersItem {...defaultProps} />);
expect(screen.getByRole('listitem', { name: 'johndoe' })).toHaveAttribute('tabindex', '0');
});
it('should call onClickView when Enter is pressed while focused', async () => {
const user = userEvent.setup();
const onClickView = jest.fn();
render(<RoomMembersItem {...defaultProps} onClickView={onClickView} />);
await user.tab();
await user.keyboard('{Enter}');
expect(onClickView).toHaveBeenCalledTimes(1);
});
it('should call onClickView when Space is pressed while focused', async () => {
const user = userEvent.setup();
const onClickView = jest.fn();
render(<RoomMembersItem {...defaultProps} onClickView={onClickView} />);
await user.tab();
await user.keyboard(' ');
expect(onClickView).toHaveBeenCalledTimes(1);
});
it('should reveal the actions menu when focused', async () => {
const user = userEvent.setup();
render(<RoomMembersItem {...defaultProps} />);
expect(screen.queryByRole('button', { name: 'Actions' })).not.toBeInTheDocument();
await user.tab();
expect(screen.getByRole('button', { name: 'Actions' })).toBeInTheDocument();
});
it('should apply the focus style when focused via keyboard', async () => {
const user = userEvent.setup();
render(<RoomMembersItem {...defaultProps} />);
await user.tab();
expect(screen.getByRole('listitem', { name: /johndoe/ })).toHaveClass('rcx-option--focus');
expect(screen.getByRole('listitem', { name: /johndoe/ })).toHaveFocus();
});
});

View File

@@ -0,0 +1,125 @@
import { useFocusRing } from '@react-aria/focus';
import type { IRoom } from '@rocket.chat/core-typings';
import {
Option,
OptionAvatar,
OptionColumn,
OptionDescription,
OptionMenu,
OptionContent,
Icon,
IconButton,
OptionSkeleton,
} from '@rocket.chat/fuselage';
import { usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import type { KeyboardEvent, MouseEvent } from 'react';
import { useState } from 'react';
import UserActions from './RoomMembersActions';
import { getUserDisplayNames } from '../../../../../lib/getUserDisplayNames';
import InvitationBadge from '../../../../components/InvitationBadge';
import { ReactiveUserStatus } from '../../../../components/UserStatus';
import { usePreventPropagation } from '../../../../hooks/usePreventPropagation';
import { useUserStatusTooltip } from '../../../../hooks/useUserStatusTooltip';
import type { RoomMember } from '../../../hooks/useMembersList';
export type RoomMembersItemProps = Pick<RoomMember, 'federated' | 'username' | 'name' | '_id' | 'freeSwitchExtension' | 'subscription'> & {
rid: IRoom['_id'];
useRealName: boolean;
reload: () => void;
onClickView: (e: MouseEvent<HTMLElement>) => void;
};
const RoomMembersItem = ({
_id,
name,
username,
federated,
freeSwitchExtension,
onClickView,
rid,
subscription,
reload,
useRealName,
}: RoomMembersItemProps) => {
const [showButton, setShowButton] = useState(false);
const isReduceMotionEnabled = usePrefersReducedMotion();
const { focusProps, isFocusVisible } = useFocusRing();
const isInvited = subscription?.status === 'INVITED';
const invitationDate = isInvited ? subscription?.ts : undefined;
const preventPropagation = usePreventPropagation();
const [nameOrUsername, displayUsername] = getUserDisplayNames(name, username, useRealName);
const statusTooltipHandlers = useUserStatusTooltip(_id);
const handleMenuEvent = isReduceMotionEnabled
? {
...statusTooltipHandlers,
onMouseEnter: (e: MouseEvent<HTMLElement>) => {
setShowButton(true);
statusTooltipHandlers.onMouseEnter(e);
},
}
: {
...statusTooltipHandlers,
onTransitionEnd: () => setShowButton(true),
};
return (
<Option
{...focusProps}
aria-label={nameOrUsername}
onFocus={(e) => {
focusProps.onFocus?.(e);
setShowButton(true);
}}
focus={isFocusVisible}
data-username={username}
data-userid={_id}
data-invitationdate={invitationDate}
onClick={onClickView}
onKeyDown={(e: KeyboardEvent<HTMLElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.currentTarget.click();
}
}}
tabIndex={0}
style={{ paddingInline: 24 }}
{...handleMenuEvent}
>
<OptionAvatar>
<UserAvatar username={username || ''} size='x28' />
</OptionAvatar>
<OptionColumn>{federated ? <Icon name='globe' size='x16' /> : <ReactiveUserStatus uid={_id} />}</OptionColumn>
<OptionContent data-qa={`MemberItem-${username}`}>
{nameOrUsername} {displayUsername && <OptionDescription>@{displayUsername}</OptionDescription>}
</OptionContent>
{subscription?.status === 'INVITED' && (
<OptionColumn>
<InvitationBadge marginBlockStart={2} size='x20' invitationDate={subscription.ts} />
</OptionColumn>
)}
<OptionMenu onClick={preventPropagation}>
{showButton ? (
<UserActions
username={username}
name={name}
rid={rid}
_id={_id}
freeSwitchExtension={freeSwitchExtension}
isInvited={isInvited}
reload={reload}
/>
) : (
<IconButton tiny icon='kebab' aria-hidden tabIndex={-1} />
)}
</OptionMenu>
</Option>
);
};
export default Object.assign(RoomMembersItem, {
Skeleton: OptionSkeleton,
});

View File

@@ -0,0 +1,21 @@
import { useMergedRefs } from '@rocket.chat/fuselage-hooks';
import type { ForwardedRef, HTMLAttributes } from 'react';
import { forwardRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useMembersListNavigation } from './useMembersListNavigation';
export type RoomMembersListWrapperProps = HTMLAttributes<HTMLDivElement>;
const RoomMembersListWrapper = forwardRef(function RoomMembersListWrapper(
props: RoomMembersListWrapperProps,
ref: ForwardedRef<HTMLDivElement>,
) {
const { t } = useTranslation();
const { membersListRef } = useMembersListNavigation();
const mergedRefs = useMergedRefs(ref, membersListRef);
return <div role='list' aria-label={t('Members')} ref={mergedRefs} {...props} />;
});
export default RoomMembersListWrapper;

View File

@@ -0,0 +1,41 @@
import type { IRoom } from '@rocket.chat/core-typings';
import type { MouseEvent } from 'react';
import { memo } from 'react';
import RoomMembersItem from './RoomMembersItem';
import type { RoomMember } from '../../../hooks/useMembersList';
export type RoomMembersRowProps = {
user: Pick<RoomMember, 'federated' | 'username' | 'name' | '_id' | 'freeSwitchExtension' | 'subscription'>;
data: {
onClickView: (e: MouseEvent<HTMLElement>) => void;
rid: IRoom['_id'];
};
index: number;
reload: () => void;
useRealName: boolean;
};
const RoomMembersRow = ({ user, data: { onClickView, rid }, index, reload, useRealName }: RoomMembersRowProps) => {
if (!user?._id) {
return <RoomMembersItem.Skeleton />;
}
return (
<RoomMembersItem
key={index}
useRealName={useRealName}
username={user.username}
_id={user._id}
rid={rid}
name={user.name}
federated={user.federated}
freeSwitchExtension={user.freeSwitchExtension}
subscription={user.subscription}
onClickView={onClickView}
reload={reload}
/>
);
};
export default memo(RoomMembersRow);

View File

@@ -0,0 +1,142 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { isRoomFederated, isDirectMessageRoom, isTeamRoom, isRoomNativeFederated } from '@rocket.chat/core-typings';
import { useStableCallback, useDebouncedValue, useLocalStorage } from '@rocket.chat/fuselage-hooks';
import {
useUserRoom,
useAtLeastOnePermission,
useUser,
usePermission,
useUserSubscription,
useRoomToolbox,
} from '@rocket.chat/ui-contexts';
import type { ChangeEvent, MouseEvent } from 'react';
import { useCallback, useMemo, useState } from 'react';
import AddUsers from './AddUsers';
import InviteUsers from './InviteUsers';
import RoomMembers from './RoomMembers';
import * as Federation from '../../../../lib/federation/Federation';
import { useMembersList } from '../../../hooks/useMembersList';
import UserInfoWithData from '../UserInfo';
enum ROOM_MEMBERS_TABS {
INFO = 'user-info',
INVITE = 'invite-users',
ADD = 'add-users',
LIST = 'users-list',
}
type validRoomType = 'd' | 'p' | 'c';
export type RoomMembersWithDataProps = { rid: IRoom['_id'] };
const RoomMembersWithData = ({ rid }: RoomMembersWithDataProps) => {
const user = useUser();
const room = useUserRoom(rid);
const { closeTab } = useRoomToolbox();
const [type, setType] = useLocalStorage<'online' | 'all'>('members-list-type', 'online');
const [text, setText] = useState('');
const subscription = useUserSubscription(rid);
const isTeam = room && isTeamRoom(room);
const isDirect = room && isDirectMessageRoom(room);
const hasPermissionToCreateInviteLinks = usePermission('create-invite-links', rid);
const isFederated = room && isRoomFederated(room);
// we are dropping the non native federation for now
const isFederationBlocked = room && !isRoomNativeFederated(room);
const canCreateInviteLinks =
room && user && isFederated && !isFederationBlocked
? Federation.canCreateInviteLinks(user, room, subscription)
: hasPermissionToCreateInviteLinks;
const [state, setState] = useState<{ tab: ROOM_MEMBERS_TABS; user?: { id?: IUser['_id']; invitationDate?: string } }>({
tab: ROOM_MEMBERS_TABS.LIST,
user: undefined,
});
const debouncedText = useDebouncedValue(text, 800);
const { data, fetchNextPage, isPending, isSuccess, refetch, hasNextPage } = useMembersList(
useMemo(() => ({ rid, type, limit: 20, debouncedText, roomType: room?.t as validRoomType }), [rid, type, debouncedText, room?.t]),
);
const hasPermissionToAddUsers = useAtLeastOnePermission(
useMemo(() => [room?.t === 'p' ? 'add-user-to-any-p-room' : 'add-user-to-any-c-room', 'add-user-to-joined-room'], [room?.t]),
rid,
);
const canAddUsers =
room && user && isFederated && !isFederationBlocked
? Federation.isEditableByTheUser(user, room, subscription)
: hasPermissionToAddUsers;
const handleTextChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setText(event.currentTarget.value);
}, []);
const openUserInfo = useStableCallback((e: MouseEvent<HTMLElement>) => {
const { userid: userId, invitationdate: invitationDate } = e.currentTarget.dataset;
setState({
tab: ROOM_MEMBERS_TABS.INFO,
user: { id: userId, invitationDate },
});
});
const openInvite = useStableCallback(() => {
setState({ tab: ROOM_MEMBERS_TABS.INVITE });
});
const openAddUser = useStableCallback(() => {
setState({ tab: ROOM_MEMBERS_TABS.ADD });
});
const handleBack = useStableCallback(() => {
setState({ tab: ROOM_MEMBERS_TABS.LIST });
});
if (state.tab === ROOM_MEMBERS_TABS.INFO && state.user?.id) {
return (
<UserInfoWithData
rid={rid}
uid={state.user.id}
invitationDate={state.user.invitationDate}
onClose={closeTab}
onClickBack={handleBack}
/>
);
}
if (state.tab === ROOM_MEMBERS_TABS.INVITE) {
return <InviteUsers rid={rid} onClickBack={handleBack} />;
}
if (state.tab === ROOM_MEMBERS_TABS.ADD) {
return <AddUsers rid={rid} onClickBack={handleBack} reload={refetch} />;
}
return (
<RoomMembers
rid={rid}
isTeam={isTeam}
isDirect={isDirect}
isPending={isPending}
isSuccess={isSuccess}
type={type}
text={text}
setText={handleTextChange}
setType={setType}
members={data?.pages?.flatMap((page) => page.members) ?? []}
total={data?.pages[data.pages.length - 1].total ?? 0}
onClickClose={closeTab}
onClickView={openUserInfo}
loadMoreItems={hasNextPage ? fetchNextPage : () => undefined}
reload={refetch}
onClickInvite={canCreateInviteLinks && canAddUsers ? openInvite : undefined}
onClickAdd={canAddUsers ? openAddUser : undefined}
isABACRoom={Boolean(room?.abacAttributes)}
/>
);
};
export default RoomMembersWithData;

View File

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

View File

@@ -0,0 +1,200 @@
import { createFocusManager } from '@react-aria/focus';
import { renderHook, act, fireEvent, createEvent } from '@testing-library/react';
import { useMembersListNavigation } from './useMembersListNavigation';
jest.mock('@react-aria/focus', () => ({
createFocusManager: jest.fn(),
}));
const mockFocusManager = {
focusNext: jest.fn(),
focusPrevious: jest.fn(),
};
beforeAll(() => {
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0);
return 0;
});
});
afterAll(() => {
jest.restoreAllMocks();
});
beforeEach(() => {
(createFocusManager as jest.Mock).mockReturnValue(mockFocusManager);
mockFocusManager.focusNext.mockReset().mockReturnValue(document.createElement('li'));
mockFocusManager.focusPrevious.mockReset().mockReturnValue(document.createElement('li'));
});
const createList = (itemCount = 2) => {
const container = document.createElement('div');
const items = Array.from({ length: itemCount }, () => {
const li = document.createElement('li');
container.appendChild(li);
return li;
});
document.body.appendChild(container);
return { container, items, cleanup: () => document.body.removeChild(container) };
};
const setupHook = (container: HTMLElement) => {
const { result } = renderHook(() => useMembersListNavigation());
act(() => result.current.membersListRef(container));
return result;
};
it('creates a focus manager scoped to the list node', () => {
const { container, cleanup } = createList();
setupHook(container);
expect(createFocusManager).toHaveBeenCalledWith({ current: container });
cleanup();
});
it('calls focusNext with an LI-matching predicate on ArrowDown', () => {
const { container, items, cleanup } = createList();
setupHook(container);
fireEvent.keyDown(items[0], { key: 'ArrowDown' });
expect(mockFocusManager.focusNext).toHaveBeenCalledTimes(1);
expect(mockFocusManager.focusPrevious).not.toHaveBeenCalled();
const { accept } = mockFocusManager.focusNext.mock.calls[0][0];
expect(accept(document.createElement('li'))).toBe(true);
expect(accept(document.createElement('div'))).toBe(false);
expect(accept(document.createElement('button'))).toBe(false);
cleanup();
});
it('calls focusPrevious with an LI-matching predicate on ArrowUp', () => {
const { container, items, cleanup } = createList();
setupHook(container);
fireEvent.keyDown(items[1], { key: 'ArrowUp' });
expect(mockFocusManager.focusPrevious).toHaveBeenCalledTimes(1);
expect(mockFocusManager.focusNext).not.toHaveBeenCalled();
const { accept } = mockFocusManager.focusPrevious.mock.calls[0][0];
expect(accept(document.createElement('li'))).toBe(true);
expect(accept(document.createElement('div'))).toBe(false);
cleanup();
});
it('ignores events whose target is not an LI element', () => {
const { container, cleanup } = createList(0);
setupHook(container);
const div = document.createElement('div');
container.appendChild(div);
fireEvent.keyDown(div, { key: 'ArrowDown' });
expect(mockFocusManager.focusNext).not.toHaveBeenCalled();
cleanup();
});
it('ignores non-arrow keys', () => {
const { container, items, cleanup } = createList();
setupHook(container);
['Enter', 'Tab', ' ', 'Escape'].forEach((key) => fireEvent.keyDown(items[0], { key }));
expect(mockFocusManager.focusNext).not.toHaveBeenCalled();
expect(mockFocusManager.focusPrevious).not.toHaveBeenCalled();
cleanup();
});
it('prevents default and stops propagation on arrow keys', () => {
const { container, items, cleanup } = createList();
setupHook(container);
const event = createEvent.keyDown(items[0], { key: 'ArrowDown' });
jest.spyOn(event, 'stopPropagation');
fireEvent(items[0], event);
expect(event.defaultPrevented).toBe(true);
expect(event.stopPropagation).toHaveBeenCalled();
cleanup();
});
it('removes the event listener when called with null', () => {
const { container, items, cleanup } = createList();
const result = setupHook(container);
act(() => result.current.membersListRef(null));
const afterNull = createEvent.keyDown(items[0], { key: 'ArrowDown' });
fireEvent(items[0], afterNull);
expect(mockFocusManager.focusNext).not.toHaveBeenCalled();
cleanup();
});
it('replaces the listener without duplicating it when called twice with the same node', () => {
const { container, items, cleanup } = createList();
const result = setupHook(container);
act(() => result.current.membersListRef(container));
const secondCall = createEvent.keyDown(items[0], { key: 'ArrowDown' });
fireEvent(items[0], secondCall);
expect(mockFocusManager.focusNext).toHaveBeenCalledTimes(1);
cleanup();
});
describe('when the adjacent item is outside the render window', () => {
beforeEach(() => {
mockFocusManager.focusNext.mockReturnValue(null);
mockFocusManager.focusPrevious.mockReturnValue(null);
});
const createScrollableSetup = (initialScrollTop = 0) => {
const scroller = document.createElement('div');
scroller.scrollTop = initialScrollTop;
const listNode = document.createElement('div');
scroller.appendChild(listNode);
document.body.appendChild(scroller);
const li = document.createElement('li');
Object.defineProperty(li, 'offsetHeight', { configurable: true, get: () => 36 });
listNode.appendChild(li);
setupHook(listNode);
return { scroller, li, cleanup: () => document.body.removeChild(scroller) };
};
it('scrolls down by one item height and retries focusNext', () => {
const { scroller, li, cleanup } = createScrollableSetup(0);
fireEvent.keyDown(li, { key: 'ArrowDown' });
expect(scroller.scrollTop).toBe(36);
expect(mockFocusManager.focusNext).toHaveBeenCalledTimes(2);
cleanup();
});
it('scrolls up by one item height and retries focusPrevious', () => {
const { scroller, li, cleanup } = createScrollableSetup(100);
fireEvent.keyDown(li, { key: 'ArrowUp' });
expect(scroller.scrollTop).toBe(64);
expect(mockFocusManager.focusPrevious).toHaveBeenCalledTimes(2);
cleanup();
});
it('does not scroll or retry when the item has no height', () => {
const { container, items, cleanup } = createList();
setupHook(container);
// offsetHeight defaults to 0 in jsdom — the fallback is skipped
fireEvent.keyDown(items[0], { key: 'ArrowDown' });
expect(mockFocusManager.focusNext).toHaveBeenCalledTimes(1);
cleanup();
});
});

View File

@@ -0,0 +1,54 @@
import { createFocusManager } from '@react-aria/focus';
import type { RefCallback } from 'react';
import { useCallback, useRef } from 'react';
const isMemberItem = (node: Element) => node.tagName === 'LI';
export const useMembersListNavigation = (): { membersListRef: RefCallback<HTMLElement> } => {
const cleanupRef = useRef<(() => void) | null>(null);
const membersListRef: RefCallback<HTMLElement> = useCallback((node) => {
cleanupRef.current?.();
cleanupRef.current = null;
if (!node) return;
const focusManager = createFocusManager({ current: node });
const onKeyDown = (e: Event) => {
if (!(e.target instanceof Element) || !isMemberItem(e.target)) return;
if (!(e instanceof KeyboardEvent) || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return;
e.preventDefault();
e.stopPropagation();
const isUp = e.key === 'ArrowUp';
const moved = isUp ? focusManager.focusPrevious({ accept: isMemberItem }) : focusManager.focusNext({ accept: isMemberItem });
if (moved) return;
// Adjacent item is outside the render window — scroll by one row height and retry
let scroller: HTMLElement | null = node.parentElement;
while (scroller && getComputedStyle(scroller).overflowY === 'visible') {
scroller = scroller.parentElement;
}
const itemHeight = (e.target as HTMLElement).offsetHeight;
if (!scroller || itemHeight <= 0) return;
scroller.scrollTop = isUp ? Math.max(0, scroller.scrollTop - itemHeight) : scroller.scrollTop + itemHeight;
requestAnimationFrame(() => {
if (!node.isConnected) return;
if (isUp) {
focusManager.focusPrevious({ accept: isMemberItem });
} else {
focusManager.focusNext({ accept: isMemberItem });
}
});
};
node.addEventListener('keydown', onKeyDown);
cleanupRef.current = () => node.removeEventListener('keydown', onKeyDown);
}, []);
return { membersListRef };
};