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,69 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import NavBarItemAdministrationMenu from './NavBarItemAdministrationMenu';
|
||||
|
||||
const handleMenuClick = async () => {
|
||||
const menuButton = await screen.findByRole('button', { name: 'Manage' });
|
||||
await userEvent.click(menuButton);
|
||||
};
|
||||
|
||||
it('should not display the menu if no permission is set', async () => {
|
||||
render(<NavBarItemAdministrationMenu />, { wrapper: mockAppRoot().build() });
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Manage' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display the workspace menu item if at least one admin permission is set', async () => {
|
||||
render(<NavBarItemAdministrationMenu />, { wrapper: mockAppRoot().withPermission('access-permissions').build() });
|
||||
|
||||
await handleMenuClick();
|
||||
expect(await screen.findByRole('menuitem', { name: 'Workspace' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display the omnichannel menu item if view-livechat-manager permission is set', async () => {
|
||||
render(<NavBarItemAdministrationMenu />, {
|
||||
wrapper: mockAppRoot().withPermission('view-livechat-manager').withPermission('access-permissions').build(),
|
||||
});
|
||||
|
||||
await handleMenuClick();
|
||||
expect(await screen.findByRole('menuitem', { name: 'Omnichannel' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display any audit items if has at least one admin permission, some audit permission and the auditing module is not enabled', async () => {
|
||||
render(<NavBarItemAdministrationMenu />, {
|
||||
wrapper: mockAppRoot().withPermission('access-permissions').withPermission('can-audit').build(),
|
||||
});
|
||||
|
||||
await handleMenuClick();
|
||||
expect(screen.queryByRole('menuitem', { name: 'Messages' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display audit items if has at least one admin permission, both audit permission and the auditing module is enabled', async () => {
|
||||
render(<NavBarItemAdministrationMenu />, {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint('GET', '/v1/licenses.info', () => ({
|
||||
license: {
|
||||
license: {
|
||||
// @ts-expect-error: just for testing
|
||||
grantedModules: [{ module: 'auditing' }],
|
||||
},
|
||||
// @ts-expect-error: just for testing
|
||||
activeModules: ['auditing'],
|
||||
},
|
||||
}))
|
||||
.withJohnDoe()
|
||||
.withPermission('can-audit')
|
||||
.withPermission('can-audit-log')
|
||||
.build(),
|
||||
});
|
||||
|
||||
await handleMenuClick();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Messages')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(await screen.findByRole('menuitem', { name: 'Messages' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('menuitem', { name: 'Logs' })).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NavBarItem } from '@rocket.chat/fuselage';
|
||||
import { GenericMenu } from '@rocket.chat/ui-client';
|
||||
import { useCurrentRoutePath } from '@rocket.chat/ui-contexts';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAdministrationMenu } from './hooks/useAdministrationMenu';
|
||||
import { useAuditMenu } from './hooks/useAuditMenu';
|
||||
|
||||
export type NavBarItemAdministrationMenuProps = Omit<HTMLAttributes<HTMLElement>, 'is'>;
|
||||
|
||||
const NavBarItemAdministrationMenu = (props: NavBarItemAdministrationMenuProps) => {
|
||||
const { t } = useTranslation();
|
||||
const currentRoute = useCurrentRoutePath();
|
||||
|
||||
const adminSection = useAdministrationMenu();
|
||||
const auditSection = useAuditMenu();
|
||||
|
||||
const adminRoutesRegex = new RegExp(['/omnichannel/', '/admin', '/audit'].join('|'));
|
||||
const pressed = adminRoutesRegex.test(currentRoute || '');
|
||||
|
||||
const sections = [adminSection, auditSection].filter((section) => section.items.length > 0);
|
||||
|
||||
if (sections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericMenu sections={sections} title={t('Manage')} is={NavBarItem} icon='cog' pressed={pressed} placement='bottom-end' {...props} />
|
||||
);
|
||||
};
|
||||
|
||||
export default NavBarItemAdministrationMenu;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Button } from '@rocket.chat/fuselage';
|
||||
import { useSessionDispatch } from '@rocket.chat/ui-contexts';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type NavBarItemLoginPageProps = Omit<HTMLAttributes<HTMLElement>, 'is'>;
|
||||
|
||||
const NavBarItemLoginPage = (props: NavBarItemLoginPageProps) => {
|
||||
const setForceLogin = useSessionDispatch('forceLogin');
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Button primary small icon='login' onClick={(): void => setForceLogin(true)} {...props}>
|
||||
{t('Login')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavBarItemLoginPage;
|
||||
@@ -0,0 +1,193 @@
|
||||
import { UserStatus as UserStatusType } from '@rocket.chat/core-typings';
|
||||
import type { SelectOption } from '@rocket.chat/fuselage';
|
||||
import { InputBox, Margins, Box } from '@rocket.chat/fuselage';
|
||||
import { Field, FieldGroup, FieldLabel, FieldRow, FieldError, FieldHint, TextInput, Select } from '@rocket.chat/fuselage-forms';
|
||||
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
|
||||
import { GenericModal } from '@rocket.chat/ui-client';
|
||||
import { useToastMessageDispatch, useSetting, useEndpoint, useUser, useTranslation } from '@rocket.chat/ui-contexts';
|
||||
import type { ChangeEvent, ComponentProps } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import UserStatusMenu from '../../../components/UserStatusMenu';
|
||||
import { USER_STATUS_TEXT_MAX_LENGTH } from '../../../lib/constants';
|
||||
import { getUserStatusInitialValues } from '../../../lib/getUserInitialStatus';
|
||||
import { STATUS_DURATION_OPTIONS, validateStatusExpiration } from '../../../lib/statusDurations';
|
||||
|
||||
export type EditStatusModalProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type StatusFormValues = {
|
||||
statusText: string;
|
||||
statusType: UserStatusType;
|
||||
statusDuration: string;
|
||||
statusCustomDate: string;
|
||||
statusCustomTime: string;
|
||||
};
|
||||
|
||||
const EditStatusModal = ({ onClose }: EditStatusModalProps) => {
|
||||
const t = useTranslation();
|
||||
const user = useUser();
|
||||
const allowUserStatusMessageChange = useSetting('Accounts_AllowUserStatusMessageChange');
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const [customStatus, setCustomStatus] = useLocalStorage<string>('Local_Custom_Status', '');
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<StatusFormValues>({
|
||||
defaultValues: getUserStatusInitialValues(user, customStatus),
|
||||
});
|
||||
|
||||
const { statusDuration, statusType, statusText } = watch();
|
||||
const isExpirationDisabled = statusType === UserStatusType.ONLINE && !statusText.trim();
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpirationDisabled) {
|
||||
setValue('statusDuration', '', { shouldValidate: true });
|
||||
}
|
||||
}, [isExpirationDisabled, setValue]);
|
||||
|
||||
const isStatusFromCall = user?.statusSource === 'internal' || user?.statusSource === 'external';
|
||||
|
||||
const setUserStatus = useEndpoint('POST', '/v1/users.setStatus');
|
||||
|
||||
const durationOptions: SelectOption[] = useMemo(() => STATUS_DURATION_OPTIONS.map(({ value, labelKey }) => [value, t(labelKey)]), [t]);
|
||||
|
||||
const handleSaveStatus = async ({
|
||||
statusText,
|
||||
statusType,
|
||||
statusDuration,
|
||||
statusCustomDate,
|
||||
statusCustomTime,
|
||||
}: StatusFormValues): Promise<void> => {
|
||||
const expiresAt = STATUS_DURATION_OPTIONS.find((o) => o.value === statusDuration)?.getExpiresAt?.({
|
||||
now: new Date(),
|
||||
customDate: statusCustomDate,
|
||||
customTime: statusCustomTime,
|
||||
});
|
||||
try {
|
||||
await setUserStatus({
|
||||
message: statusText,
|
||||
status: statusType,
|
||||
...(expiresAt && { expiresAt: expiresAt.toISOString() }),
|
||||
});
|
||||
setCustomStatus(statusText);
|
||||
dispatchToastMessage({ type: 'success', message: t('StatusMessage_Changed_Successfully') });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
icon={null}
|
||||
variant='warning'
|
||||
title={t('Status_set_your_status')}
|
||||
onCancel={onClose}
|
||||
confirmText={t('Save')}
|
||||
confirmDisabled={!isDirty}
|
||||
wrapperFunction={(props: ComponentProps<typeof Box>) => <Box is='form' onSubmit={handleSubmit(handleSaveStatus)} {...props} />}
|
||||
>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel>{t('Status')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
control={control}
|
||||
name='statusText'
|
||||
rules={{
|
||||
maxLength: {
|
||||
value: USER_STATUS_TEXT_MAX_LENGTH,
|
||||
message: t('Max_length_is', { limit: USER_STATUS_TEXT_MAX_LENGTH }),
|
||||
},
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
error={errors.statusText?.message}
|
||||
disabled={!allowUserStatusMessageChange}
|
||||
flexGrow={1}
|
||||
placeholder={t(statusType)}
|
||||
startAddon={
|
||||
<Controller
|
||||
control={control}
|
||||
name='statusType'
|
||||
render={({ field: { value, onChange } }) => <UserStatusMenu initialStatus={value} onChange={onChange} />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldHint>{t(allowUserStatusMessageChange ? 'Status_you_can_use_emoji' : 'StatusMessage_Change_Disabled')}</FieldHint>
|
||||
{errors.statusText && <FieldError>{errors.statusText.message}</FieldError>}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{t('Status_clear_after')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
control={control}
|
||||
name='statusDuration'
|
||||
rules={{
|
||||
deps: ['statusCustomDate', 'statusCustomTime'],
|
||||
validate: (value, { statusCustomDate, statusCustomTime }) =>
|
||||
validateStatusExpiration(value, { statusCustomDate, statusCustomTime }, t),
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Select
|
||||
value={value}
|
||||
options={durationOptions}
|
||||
onChange={(next) => onChange(String(next))}
|
||||
disabled={isExpirationDisabled}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{statusDuration === 'custom' && (
|
||||
<Box display='flex' marginInline='neg-x4' marginBlockStart={8}>
|
||||
<Margins inline={4}>
|
||||
<Controller
|
||||
control={control}
|
||||
name='statusCustomDate'
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<InputBox
|
||||
aria-label={t('Status_expiration_date')}
|
||||
type='date'
|
||||
flexGrow={1}
|
||||
value={value}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.currentTarget.value)}
|
||||
min={new Date().toLocaleDateString('en-CA')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='statusCustomTime'
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<InputBox
|
||||
aria-label={t('Status_expiration_time')}
|
||||
type='time'
|
||||
flexGrow={1}
|
||||
value={value}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Margins>
|
||||
</Box>
|
||||
)}
|
||||
{errors.statusDuration && <FieldError>{errors.statusDuration.message}</FieldError>}
|
||||
<FieldHint>{t(isStatusFromCall ? 'Status_new_status_warning_after_call' : 'Status_new_status_warning')}</FieldHint>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</GenericModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditStatusModal;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { Field, FieldGroup, FieldHint, FieldLabel, FieldRow } from '@rocket.chat/fuselage-forms';
|
||||
import { GenericModal } from '@rocket.chat/ui-client';
|
||||
import { useEndpoint, useToastMessageDispatch, useUser } from '@rocket.chat/ui-contexts';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import UserAutoCompleteMultiple from '../../../components/UserAutoCompleteMultiple';
|
||||
|
||||
export type EditStatusVisibilityModalProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type StatusVisibilityFormValues = {
|
||||
statusVisibilityDenied: string[];
|
||||
};
|
||||
|
||||
export const EditStatusVisibilityModal = ({ onClose }: EditStatusVisibilityModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const user = useUser();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const setPreferences = useEndpoint('POST', '/v1/users.setPreferences');
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isDirty, isSubmitting },
|
||||
} = useForm<StatusVisibilityFormValues>({
|
||||
defaultValues: {
|
||||
statusVisibilityDenied: user?.settings?.preferences?.statusVisibilityDenied ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = async ({ statusVisibilityDenied }: StatusVisibilityFormValues) => {
|
||||
try {
|
||||
await setPreferences({ data: { statusVisibilityDenied } });
|
||||
dispatchToastMessage({ type: 'success', message: t('Accounts_StatusVisibility_Saved') });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
icon={null}
|
||||
variant='warning'
|
||||
title={t('Accounts_StatusVisibility_HideStatus')}
|
||||
onCancel={onClose}
|
||||
confirmText={t('Save')}
|
||||
confirmDisabled={!isDirty || isSubmitting}
|
||||
wrapperFunction={(props: ComponentProps<typeof Box>) => <Box is='form' onSubmit={handleSubmit(handleSave)} {...props} />}
|
||||
>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel>{t('Accounts_StatusVisibility_HideFromUsers')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
control={control}
|
||||
name='statusVisibilityDenied'
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<UserAutoCompleteMultiple
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
exceptions={user?.username ? [user.username] : undefined}
|
||||
placeholder={t('Select_users')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldHint>{t('Accounts_StatusVisibility_HideFromUsers_Description')}</FieldHint>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</GenericModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditStatusVisibilityModal;
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Box, Divider } from '@rocket.chat/fuselage';
|
||||
import { GenericModal } from '@rocket.chat/ui-client';
|
||||
import { Fragment, memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type KeyCombo = {
|
||||
mac: readonly string[];
|
||||
other: readonly string[];
|
||||
};
|
||||
|
||||
type ShortcutDefinition = {
|
||||
id: string;
|
||||
descriptionKey: string;
|
||||
combos: readonly KeyCombo[];
|
||||
};
|
||||
|
||||
const SHORTCUTS: readonly ShortcutDefinition[] = [
|
||||
{
|
||||
id: 'openKeyboardShortcuts',
|
||||
descriptionKey: 'Keyboard_Shortcuts_Show_Keyboard_Shortcuts',
|
||||
combos: [{ mac: ['Shift', '?'], other: ['Shift', '?'] }],
|
||||
},
|
||||
{
|
||||
id: 'openSearch',
|
||||
descriptionKey: 'Keyboard_Shortcuts_Open_Channel_Slash_User_Search',
|
||||
combos: [
|
||||
{ mac: ['Command', 'P'], other: ['Control', 'P'] },
|
||||
{ mac: ['Command', 'K'], other: ['Control', 'K'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'markAllAsRead',
|
||||
descriptionKey: 'Keyboard_Shortcuts_Mark_all_as_read',
|
||||
combos: [{ mac: ['Shift', 'Escape'], other: ['Control', 'Escape'] }],
|
||||
},
|
||||
{
|
||||
id: 'editPreviousMessage',
|
||||
descriptionKey: 'Keyboard_Shortcuts_Edit_Previous_Message',
|
||||
combos: [{ mac: ['ArrowUp'], other: ['ArrowUp'] }],
|
||||
},
|
||||
{
|
||||
id: 'moveToBeginningHorizontal',
|
||||
descriptionKey: 'Keyboard_Shortcuts_Move_To_Beginning_Of_Message',
|
||||
combos: [{ mac: ['Command', 'ArrowLeft'], other: ['Alt', 'ArrowLeft'] }],
|
||||
},
|
||||
{
|
||||
id: 'moveToBeginningVertical',
|
||||
descriptionKey: 'Keyboard_Shortcuts_Move_To_Beginning_Of_Message',
|
||||
combos: [{ mac: ['Command', 'ArrowUp'], other: ['Alt', 'ArrowUp'] }],
|
||||
},
|
||||
{
|
||||
id: 'moveToEndHorizontal',
|
||||
descriptionKey: 'Keyboard_Shortcuts_Move_To_End_Of_Message',
|
||||
combos: [{ mac: ['Command', 'ArrowRight'], other: ['Alt', 'ArrowRight'] }],
|
||||
},
|
||||
{
|
||||
id: 'moveToEndVertical',
|
||||
descriptionKey: 'Keyboard_Shortcuts_Move_To_End_Of_Message',
|
||||
combos: [{ mac: ['Command', 'ArrowDown'], other: ['Alt', 'ArrowDown'] }],
|
||||
},
|
||||
{
|
||||
id: 'newLine',
|
||||
descriptionKey: 'Keyboard_Shortcuts_New_Line_In_Message',
|
||||
combos: [{ mac: ['Shift', 'Enter'], other: ['Shift', 'Enter'] }],
|
||||
},
|
||||
];
|
||||
|
||||
const KEY_LABEL_TRANSLATIONS: Record<string, string> = {
|
||||
Command: 'Keyboard_Shortcut_Key_Command',
|
||||
Control: 'Keyboard_Shortcut_Key_Control',
|
||||
Option: 'Keyboard_Shortcut_Key_Option',
|
||||
Alt: 'Keyboard_Shortcut_Key_Alt',
|
||||
Shift: 'Keyboard_Shortcut_Key_Shift',
|
||||
Enter: 'Keyboard_Shortcut_Key_Enter',
|
||||
Escape: 'Keyboard_Shortcut_Key_Escape',
|
||||
ArrowUp: 'Keyboard_Shortcut_Key_ArrowUp',
|
||||
ArrowDown: 'Keyboard_Shortcut_Key_ArrowDown',
|
||||
ArrowLeft: 'Keyboard_Shortcut_Key_ArrowLeft',
|
||||
ArrowRight: 'Keyboard_Shortcut_Key_ArrowRight',
|
||||
};
|
||||
|
||||
const isMacPlatform = (): boolean =>
|
||||
typeof navigator !== 'undefined' && typeof navigator.platform === 'string' && navigator.platform.toLowerCase().includes('mac');
|
||||
|
||||
export type KeyboardShortcutsModalProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const KeyboardShortcutsModal = ({ onClose }: KeyboardShortcutsModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isMac = isMacPlatform();
|
||||
|
||||
return (
|
||||
<GenericModal icon='keyboard' variant='info' title={t('Keyboard_Shortcuts_Title')} cancelText={t('Close')} onCancel={onClose}>
|
||||
<Box is='dl' aria-label={t('Keyboard_Shortcuts_Title')} margin={0}>
|
||||
{SHORTCUTS.map(({ id, descriptionKey, combos }) => (
|
||||
<Box key={id} marginBlockEnd={12}>
|
||||
<Box is='dt' fontScale='p2m' fontWeight='700' marginBlockEnd={4}>
|
||||
{t(descriptionKey)}
|
||||
</Box>
|
||||
<Box is='dd' fontScale='p2' margin={0} marginBlockEnd={8}>
|
||||
{combos.map((combo, comboIndex) => {
|
||||
const keys = isMac ? combo.mac : combo.other;
|
||||
return (
|
||||
<Fragment key={comboIndex}>
|
||||
{comboIndex > 0 && (
|
||||
<Box is='span' marginInline={8} color='hint'>
|
||||
{t('or')}
|
||||
</Box>
|
||||
)}
|
||||
<Box is='kbd'>
|
||||
{keys.map((token, tokenIndex) => (
|
||||
<Fragment key={tokenIndex}>
|
||||
{tokenIndex > 0 && (
|
||||
<Box is='span' marginInline={4} aria-hidden='true'>
|
||||
+
|
||||
</Box>
|
||||
)}
|
||||
<Box is='kbd'>{KEY_LABEL_TRANSLATIONS[token] ? t(KEY_LABEL_TRANSLATIONS[token]) : token}</Box>
|
||||
</Fragment>
|
||||
))}
|
||||
</Box>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Divider aria-hidden='true' margin={0} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</GenericModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(KeyboardShortcutsModal);
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { GenericMenu, useHandleMenuAction } from '@rocket.chat/ui-client';
|
||||
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import UserMenuButton from './UserMenuButton';
|
||||
import { useUserMenu } from './hooks/useUserMenu';
|
||||
|
||||
export type UserMenuProps = { user: IUser } & Omit<ComponentProps<typeof GenericMenu>, 'sections' | 'items' | 'title'>;
|
||||
|
||||
const UserMenu = function UserMenu({ user, ...props }: UserMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const sections = useUserMenu(user);
|
||||
const items = sections.reduce((acc, { items }) => [...acc, ...items], [] as GenericMenuItemProps[]);
|
||||
|
||||
const handleAction = useHandleMenuAction(items, () => setIsOpen(false));
|
||||
|
||||
return (
|
||||
<GenericMenu
|
||||
{...props}
|
||||
is={UserMenuButton}
|
||||
placement='bottom-end'
|
||||
selectionMode='multiple'
|
||||
sections={sections}
|
||||
title={t('User_menu')}
|
||||
onAction={handleAction}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
aria-label={t('User_menu')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(UserMenu);
|
||||
@@ -0,0 +1,59 @@
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Box, IconButton } from '@rocket.chat/fuselage';
|
||||
import { UserAvatar } from '@rocket.chat/ui-avatar';
|
||||
import { useSetting, useUser } from '@rocket.chat/ui-contexts';
|
||||
import type { ComponentPropsWithoutRef, ForwardedRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { UserStatus } from '../../../components/UserStatus';
|
||||
|
||||
const anon = {
|
||||
_id: '',
|
||||
username: 'Anonymous',
|
||||
status: 'online',
|
||||
avatarETag: undefined,
|
||||
} as const;
|
||||
|
||||
export type UserMenuButtonProps = ComponentPropsWithoutRef<typeof IconButton>;
|
||||
|
||||
const UserMenuButton = forwardRef(function UserMenuButton(props: UserMenuButtonProps, ref: ForwardedRef<HTMLElement>) {
|
||||
const user = useUser();
|
||||
|
||||
const { status = !user ? 'online' : 'offline', username, avatarETag } = user || anon;
|
||||
const presenceDisabled = useSetting('Presence_broadcast_disabled', false);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
{...props}
|
||||
ref={ref}
|
||||
position='relative'
|
||||
display='flex'
|
||||
flexDirection='column'
|
||||
overflow='visible'
|
||||
icon={username ? <UserAvatar size='x28' username={username} etag={avatarETag} /> : 'user'}
|
||||
>
|
||||
<Box
|
||||
position='absolute'
|
||||
role='status'
|
||||
className={css`
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
transform: translate(30%, 30%);
|
||||
`}
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
overflow='hidden'
|
||||
size='x12'
|
||||
borderWidth='default'
|
||||
backgroundColor='surface-tint'
|
||||
borderColor='extra-light'
|
||||
borderRadius='full'
|
||||
>
|
||||
<UserStatus small status={presenceDisabled ? 'disabled' : status} />
|
||||
</Box>
|
||||
</IconButton>
|
||||
);
|
||||
});
|
||||
|
||||
export default UserMenuButton;
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { UserAvatar } from '@rocket.chat/ui-avatar';
|
||||
import { useUserDisplayName } from '@rocket.chat/ui-client';
|
||||
|
||||
export type UserMenuHeaderProps = { user: IUser };
|
||||
|
||||
const UserMenuHeader = ({ user }: UserMenuHeaderProps) => {
|
||||
const displayName = useUserDisplayName(user);
|
||||
|
||||
return (
|
||||
<Box display='flex' flexDirection='row' alignItems='center' minWidth='x208' marginBlockEnd='neg-x4' marginBlockStart='neg-x8'>
|
||||
<Box marginInlineEnd={4}>
|
||||
<UserAvatar size='x36' username={user?.username || ''} etag={user?.avatarETag} />
|
||||
</Box>
|
||||
<Box marginInlineStart={4} display='flex' overflow='hidden' flexDirection='column' fontScale='p2' flexGrow={1} flexShrink={1}>
|
||||
<Box withTruncatedText width='full' is='span' fontWeight='700'>
|
||||
{displayName}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserMenuHeader;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Badge } from '@rocket.chat/fuselage';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
|
||||
import { defaultFeaturesPreview, usePreferenceFeaturePreviewList } from '@rocket.chat/ui-client';
|
||||
import { useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const useAccountItems = (): GenericMenuItemProps[] => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const { unseenFeatures, featurePreviewEnabled } = usePreferenceFeaturePreviewList();
|
||||
|
||||
const handleMyAccount = useStableCallback(() => {
|
||||
router.navigate('/account');
|
||||
});
|
||||
const handlePreferences = useStableCallback(() => {
|
||||
router.navigate('/account/preferences');
|
||||
});
|
||||
const handleFeaturePreview = useStableCallback(() => {
|
||||
router.navigate('/account/feature-preview');
|
||||
});
|
||||
const handleAccessibility = useStableCallback(() => {
|
||||
router.navigate('/account/accessibility-and-appearance');
|
||||
});
|
||||
|
||||
const featurePreviewItem = {
|
||||
id: 'feature-preview',
|
||||
icon: 'flask' as const,
|
||||
content: t('Feature_preview'),
|
||||
onClick: handleFeaturePreview,
|
||||
...(unseenFeatures > 0 && {
|
||||
addon: (
|
||||
<Badge variant='primary' aria-label={t('Unseen_features')}>
|
||||
{unseenFeatures}
|
||||
</Badge>
|
||||
),
|
||||
}),
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'profile',
|
||||
icon: 'user',
|
||||
content: t('Profile'),
|
||||
onClick: handleMyAccount,
|
||||
},
|
||||
{
|
||||
id: 'preferences',
|
||||
icon: 'customize',
|
||||
content: t('Preferences'),
|
||||
onClick: handlePreferences,
|
||||
},
|
||||
{
|
||||
id: 'accessibility',
|
||||
icon: 'person-arms-spread',
|
||||
content: t('Accessibility_and_Appearance'),
|
||||
onClick: handleAccessibility,
|
||||
},
|
||||
...(featurePreviewEnabled && defaultFeaturesPreview.length > 0 ? [featurePreviewItem] : []),
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useSetModal } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import EditStatusModal from '../EditStatusModal';
|
||||
|
||||
export const useCustomStatusModalHandler = () => {
|
||||
const setModal = useSetModal();
|
||||
|
||||
return () => {
|
||||
const handleModalClose = () => setModal(null);
|
||||
setModal(<EditStatusModal onClose={handleModalClose} />);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useSetModal } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import KeyboardShortcutsModal from '../KeyboardShortcutsModal';
|
||||
|
||||
export const useKeyboardShortcutsModalHandler = () => {
|
||||
const setModal = useSetModal();
|
||||
|
||||
return () => {
|
||||
const handleModalClose = () => setModal(null);
|
||||
setModal(<KeyboardShortcutsModal onClose={handleModalClose} />);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { ICustomUserStatus, IUser, UserStatus as UserStatusEnum } from '@rocket.chat/core-typings';
|
||||
import { Box, Icon, RadioButton } from '@rocket.chat/fuselage';
|
||||
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
|
||||
import { clientCallbacks } from '@rocket.chat/ui-client';
|
||||
import { useEndpoint, useSetting, useStream } from '@rocket.chat/ui-contexts';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useCustomStatusModalHandler } from './useCustomStatusModalHandler';
|
||||
import { useStatusVisibilityModalHandler } from './useStatusVisibilityModalHandler';
|
||||
import MarkdownText from '../../../../components/MarkdownText';
|
||||
import { UserStatus } from '../../../../components/UserStatus';
|
||||
import { useExpirationText } from '../../../../hooks/useExpirationText';
|
||||
import { useFireGlobalEvent } from '../../../../hooks/useFireGlobalEvent';
|
||||
import { userStatuses } from '../../../../lib/userStatuses';
|
||||
import type { UserStatusDescriptor } from '../../../../lib/userStatuses';
|
||||
import { mapCustomUserStatusFromApi } from '../../../../lib/utils/mapCustomUserStatusFromApi';
|
||||
import { useStatusDisabledModal } from '../../../../views/admin/customUserStatus/hooks/useStatusDisabledModal';
|
||||
|
||||
export const useStatusItems = (user?: IUser): GenericMenuItemProps[] => {
|
||||
// We should lift this up to somewhere else if we want to use it in other places
|
||||
|
||||
userStatuses.invisibleAllowed = useSetting('Accounts_AllowInvisibleStatusOption', true);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const stream = useStream('notify-logged');
|
||||
const listCustomUserStatusEndpoint = useEndpoint('GET', '/v1/custom-user-status.list');
|
||||
const listCustomUserStatus = useCallback(async (): Promise<ICustomUserStatus[]> => {
|
||||
const all: ICustomUserStatus[] = [];
|
||||
const count = 100;
|
||||
let offset = 0;
|
||||
// REST endpoint is paginated; loop until total reached.
|
||||
while (true) {
|
||||
const { statuses, total } = await listCustomUserStatusEndpoint({ count, offset });
|
||||
all.push(...statuses.map(mapCustomUserStatusFromApi));
|
||||
if (all.length >= total || statuses.length === 0) break;
|
||||
offset += statuses.length;
|
||||
}
|
||||
return all;
|
||||
}, [listCustomUserStatusEndpoint]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
userStatuses.watch(stream, () => {
|
||||
queryClient.setQueryData(['user-statuses'], Array.from(userStatuses));
|
||||
}),
|
||||
[queryClient, stream],
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const presenceDisabled = useSetting('Presence_broadcast_disabled', false);
|
||||
const allowUserStatusMessageChange = useSetting('Accounts_AllowUserStatusMessageChange', true);
|
||||
|
||||
const fireGlobalStatusEvent = useFireGlobalEvent('user-status-manually-set');
|
||||
const setStatus = useEndpoint('POST', '/v1/users.setStatus');
|
||||
const setStatusMutation = useMutation({
|
||||
mutationFn: async (status: UserStatusDescriptor) => {
|
||||
void setStatus({
|
||||
status: status.statusType,
|
||||
...(allowUserStatusMessageChange && { message: userStatuses.isValidType(status.id) ? '' : status.name }),
|
||||
});
|
||||
void clientCallbacks.run('userStatusManuallySet', status);
|
||||
await fireGlobalStatusEvent.mutateAsync(status);
|
||||
},
|
||||
});
|
||||
|
||||
const { data: statuses } = useQuery({
|
||||
queryKey: ['user-statuses'],
|
||||
queryFn: async () => {
|
||||
await userStatuses.sync(listCustomUserStatus);
|
||||
return Array.from(userStatuses);
|
||||
},
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const handleStatusDisabledModal = useStatusDisabledModal();
|
||||
const handleCustomStatus = useCustomStatusModalHandler();
|
||||
const handleStatusVisibility = useStatusVisibilityModalHandler();
|
||||
const statusVisibilityEnabled = useSetting('Accounts_StatusVisibility_Enabled', false);
|
||||
const customStatusExpiration = useExpirationText(user?.statusExpiresAt);
|
||||
|
||||
return useMemo<GenericMenuItemProps[]>(() => {
|
||||
if (presenceDisabled) {
|
||||
return [
|
||||
{
|
||||
id: 'presence-disabled',
|
||||
content: (
|
||||
<Box fontScale='p2'>
|
||||
<Box marginBlockEnd={4} wordBreak='break-word' style={{ whiteSpace: 'normal' }}>
|
||||
{t('User_status_disabled')}
|
||||
</Box>
|
||||
<Box is='a' color='info' onClick={handleStatusDisabledModal}>
|
||||
{t('Learn_more')}
|
||||
</Box>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const items: GenericMenuItemProps[] = [];
|
||||
|
||||
// Top: user's currently-active custom status (display only — clicking does nothing, already selected).
|
||||
if (user?.statusText || customStatusExpiration) {
|
||||
const contentValue = user?.statusText || (user?.status ? t(user.status) : undefined);
|
||||
items.push({
|
||||
id: 'current-custom-status',
|
||||
status: <UserStatus status={user?.status} />,
|
||||
content: (
|
||||
<>
|
||||
{contentValue && <MarkdownText withTruncatedText content={contentValue} parseEmoji variant='inline' />}
|
||||
{customStatusExpiration && (
|
||||
<Box color='secondary-info' display='flex' alignItems='center'>
|
||||
<Icon name='clock' size='x16' marginInlineEnd={4} />
|
||||
{customStatusExpiration}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
addon: <RadioButton checked readOnly />,
|
||||
});
|
||||
}
|
||||
|
||||
// "Custom..." action opens the edit modal
|
||||
if (allowUserStatusMessageChange) {
|
||||
items.push({
|
||||
id: 'custom-status-edit',
|
||||
icon: 'edit',
|
||||
content: t('Custom_Status'),
|
||||
onClick: handleCustomStatus,
|
||||
});
|
||||
}
|
||||
|
||||
const isPresetSelected = (statusType: UserStatusEnum): boolean =>
|
||||
!user?.statusText && !customStatusExpiration && user?.status === statusType;
|
||||
const presetItems = (statuses ?? [])
|
||||
.filter((s) => userStatuses.isValidType(s.id))
|
||||
.map((status): GenericMenuItemProps => ({
|
||||
id: status.id,
|
||||
status: <UserStatus status={status.statusType} />,
|
||||
content: <MarkdownText content={status.localizeName ? t(status.name) : status.name} parseEmoji variant='inline' />,
|
||||
addon: <RadioButton checked={isPresetSelected(status.statusType)} readOnly />,
|
||||
onClick: () => setStatusMutation.mutate(status),
|
||||
}));
|
||||
|
||||
// Admin-defined custom statuses
|
||||
const customItems = allowUserStatusMessageChange
|
||||
? (statuses ?? [])
|
||||
.filter((s) => !userStatuses.isValidType(s.id))
|
||||
.map((status): GenericMenuItemProps => ({
|
||||
id: status.id,
|
||||
status: <UserStatus status={status.statusType} />,
|
||||
content: (
|
||||
<MarkdownText withTruncatedText content={status.localizeName ? t(status.name) : status.name} parseEmoji variant='inline' />
|
||||
),
|
||||
addon: <RadioButton checked={user?.statusText === status.name} readOnly />,
|
||||
onClick: () => setStatusMutation.mutate(status),
|
||||
}))
|
||||
: [];
|
||||
|
||||
const actionItems: GenericMenuItemProps[] = [];
|
||||
|
||||
if (statusVisibilityEnabled) {
|
||||
actionItems.push({
|
||||
id: 'status-visibility-edit',
|
||||
icon: 'eye-off',
|
||||
content: t('Accounts_StatusVisibility_HideFrom'),
|
||||
onClick: handleStatusVisibility,
|
||||
});
|
||||
}
|
||||
|
||||
return [...items, ...presetItems, ...customItems, ...actionItems];
|
||||
}, [
|
||||
presenceDisabled,
|
||||
allowUserStatusMessageChange,
|
||||
t,
|
||||
handleStatusDisabledModal,
|
||||
user?.status,
|
||||
user?.statusText,
|
||||
customStatusExpiration,
|
||||
statuses,
|
||||
handleCustomStatus,
|
||||
handleStatusVisibility,
|
||||
statusVisibilityEnabled,
|
||||
setStatusMutation,
|
||||
]);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useSetModal } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import { EditStatusVisibilityModal } from '../EditStatusVisibilityModal';
|
||||
|
||||
export const useStatusVisibilityModalHandler = () => {
|
||||
const setModal = useSetModal();
|
||||
|
||||
return () => setModal(<EditStatusVisibilityModal onClose={() => setModal(null)} />);
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import { UIActionButtonContext } from '@rocket.chat/apps-engine/definition/ui';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useUserMenu } from './useUserMenu';
|
||||
|
||||
// Mock userStatuses to avoid sdk.stream to call Meteor.connection.subscribe
|
||||
jest.mock('../../../../lib/userStatuses', () => ({
|
||||
userStatuses: {
|
||||
invisibleAllowed: true,
|
||||
watch: jest.fn(() => () => undefined),
|
||||
sync: jest.fn(() => Promise.resolve()),
|
||||
},
|
||||
}));
|
||||
|
||||
const fakeUser = {
|
||||
_id: 'john.doe',
|
||||
username: 'john.doe',
|
||||
name: 'John Doe',
|
||||
createdAt: new Date(),
|
||||
active: true,
|
||||
_updatedAt: new Date(),
|
||||
roles: ['admin'],
|
||||
type: 'user' as const,
|
||||
};
|
||||
|
||||
it('should return one action from the server with no conditions', async () => {
|
||||
const { result } = renderHook(() => useUserMenu(fakeUser), {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint('GET', '/apps/actionButtons', () => [
|
||||
{
|
||||
appId: 'APP_ID',
|
||||
actionId: 'ACTION_ID',
|
||||
labelI18n: 'LABEL_I18N',
|
||||
context: UIActionButtonContext.USER_DROPDOWN_ACTION,
|
||||
},
|
||||
])
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const appsGroup = result.current.find((group) => group.title === 'Apps');
|
||||
expect(appsGroup).toBeDefined();
|
||||
expect(appsGroup?.items).toContainEqual(expect.objectContaining({ id: 'APP_ID_ACTION_ID' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('user menu with role conditions', () => {
|
||||
it('should return the action if the user has admin role', async () => {
|
||||
const { result } = renderHook(() => useUserMenu(fakeUser), {
|
||||
wrapper: mockAppRoot()
|
||||
.withJohnDoe()
|
||||
.withEndpoint('GET', '/apps/actionButtons', () => [
|
||||
{
|
||||
appId: 'APP_ID',
|
||||
actionId: 'ACTION_ID',
|
||||
labelI18n: 'LABEL_I18N',
|
||||
context: UIActionButtonContext.USER_DROPDOWN_ACTION,
|
||||
when: {
|
||||
hasOneRole: ['admin'],
|
||||
},
|
||||
},
|
||||
])
|
||||
.withRole('admin')
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const appsGroup = result.current.find((group) => group.title === 'Apps');
|
||||
expect(appsGroup).toBeDefined();
|
||||
expect(appsGroup?.items).toContainEqual(expect.objectContaining({ id: 'APP_ID_ACTION_ID' }));
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out the action if the user does not have admin role', async () => {
|
||||
const { result } = renderHook(() => useUserMenu(fakeUser), {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint('GET', '/apps/actionButtons', () => [
|
||||
{
|
||||
appId: 'APP_ID',
|
||||
actionId: 'ACTION_ID',
|
||||
labelI18n: 'LABEL_I18N',
|
||||
context: UIActionButtonContext.USER_DROPDOWN_ACTION,
|
||||
when: {
|
||||
hasOneRole: ['admin'],
|
||||
},
|
||||
},
|
||||
])
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const appsGroup = result.current.find((group) => group.title === 'Apps');
|
||||
expect(appsGroup).not.toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('user menu with permission conditions', () => {
|
||||
it('should return the action if the user has manage-apps permission', async () => {
|
||||
const { result } = renderHook(() => useUserMenu(fakeUser), {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint('GET', '/apps/actionButtons', () => [
|
||||
{
|
||||
appId: 'APP_ID',
|
||||
actionId: 'ACTION_ID',
|
||||
labelI18n: 'LABEL_I18N',
|
||||
context: UIActionButtonContext.USER_DROPDOWN_ACTION,
|
||||
when: {
|
||||
hasOnePermission: ['manage-apps'],
|
||||
},
|
||||
},
|
||||
])
|
||||
.withPermission('manage-apps')
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const appsGroup = result.current.find((group) => group.title === 'Apps');
|
||||
expect(appsGroup).toBeDefined();
|
||||
expect(appsGroup?.items).toContainEqual(expect.objectContaining({ id: 'APP_ID_ACTION_ID' }));
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out the action if the user does not have any permission', async () => {
|
||||
const { result } = renderHook(() => useUserMenu(fakeUser), {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint('GET', '/apps/actionButtons', () => [
|
||||
{
|
||||
appId: 'APP_ID',
|
||||
actionId: 'ACTION_ID',
|
||||
labelI18n: 'LABEL_I18N',
|
||||
context: UIActionButtonContext.USER_DROPDOWN_ACTION,
|
||||
when: {
|
||||
hasOnePermission: ['any'],
|
||||
},
|
||||
},
|
||||
])
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const appsGroup = result.current.find((group) => group.title === 'Apps');
|
||||
expect(appsGroup).not.toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
|
||||
import { useLogout } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import UserMenuHeader from '../UserMenuHeader';
|
||||
import { useAccountItems } from './useAccountItems';
|
||||
import { useKeyboardShortcutsModalHandler } from './useKeyboardShortcutsModalHandler';
|
||||
import { useStatusItems } from './useStatusItems';
|
||||
import { useUserDropdownAppsActionButtons } from '../../../../hooks/useUserDropdownAppsActionButtons';
|
||||
|
||||
export const useUserMenu = (user: IUser) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const statusItems = useStatusItems(user);
|
||||
const accountItems = useAccountItems();
|
||||
const appBoxItems = useUserDropdownAppsActionButtons();
|
||||
const handleKeyboardShortcuts = useKeyboardShortcutsModalHandler();
|
||||
|
||||
const logout = useLogout();
|
||||
const handleLogout = useStableCallback(() => {
|
||||
logout();
|
||||
});
|
||||
|
||||
const keyboardShortcutsItem: GenericMenuItemProps = {
|
||||
id: 'keyboardShortcuts',
|
||||
icon: 'keyboard',
|
||||
content: t('Keyboard_Shortcuts_Title'),
|
||||
onClick: handleKeyboardShortcuts,
|
||||
};
|
||||
|
||||
const logoutItem: GenericMenuItemProps = {
|
||||
id: 'logout',
|
||||
icon: 'sign-out',
|
||||
content: t('Logout'),
|
||||
onClick: handleLogout,
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
title: <UserMenuHeader user={user} />,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
title: t('Status'),
|
||||
items: statusItems,
|
||||
},
|
||||
{
|
||||
title: t('Account'),
|
||||
items: accountItems,
|
||||
},
|
||||
{
|
||||
items: [keyboardShortcutsItem],
|
||||
},
|
||||
...(appBoxItems.isSuccess && appBoxItems.data?.length ? [{ title: t('Apps'), items: appBoxItems.data }] : []),
|
||||
{
|
||||
items: [logoutItem],
|
||||
},
|
||||
].filter((section) => section !== undefined);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './UserMenu';
|
||||
@@ -0,0 +1,54 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useAdministrationMenu } from './useAdministrationMenu';
|
||||
|
||||
it('should return omnichannel item if has `view-livechat-manager` permission', async () => {
|
||||
const { result } = renderHook(() => useAdministrationMenu(), {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint('GET', '/v1/licenses.info', () => ({
|
||||
// @ts-expect-error this is a mock
|
||||
license: {},
|
||||
}))
|
||||
.withEndpoint('GET', '/v1/cloud.registrationStatus', () => ({
|
||||
registrationStatus: {
|
||||
workspaceRegistered: false,
|
||||
} as any,
|
||||
}))
|
||||
.withPermission('view-livechat-manager')
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'omnichannel',
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show administration item if has at least one admin permission', async () => {
|
||||
const { result } = renderHook(() => useAdministrationMenu(), {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint('GET', '/v1/licenses.info', () => ({
|
||||
// @ts-expect-error this is a mock
|
||||
license: {},
|
||||
}))
|
||||
.withEndpoint('GET', '/v1/cloud.registrationStatus', () => ({
|
||||
registrationStatus: {
|
||||
workspaceRegistered: false,
|
||||
} as any,
|
||||
}))
|
||||
.withPermission('access-permissions')
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'workspace',
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
|
||||
import { useAtLeastOnePermission, usePermission, useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ADMIN_PERMISSIONS = [
|
||||
'view-statistics',
|
||||
'run-import',
|
||||
'view-user-administration',
|
||||
'view-room-administration',
|
||||
'create-invite-links',
|
||||
'manage-cloud',
|
||||
'view-logs',
|
||||
'manage-sounds',
|
||||
'view-federation-data',
|
||||
'manage-email-inbox',
|
||||
'manage-emoji',
|
||||
'manage-outgoing-integrations',
|
||||
'manage-own-outgoing-integrations',
|
||||
'manage-incoming-integrations',
|
||||
'manage-own-incoming-integrations',
|
||||
'manage-oauth-apps',
|
||||
'access-mailer',
|
||||
'manage-user-status',
|
||||
'access-permissions',
|
||||
'access-setting-permissions',
|
||||
'view-privileged-setting',
|
||||
'edit-privileged-setting',
|
||||
'manage-selected-settings',
|
||||
'view-engagement-dashboard',
|
||||
'view-moderation-console',
|
||||
];
|
||||
|
||||
export const useAdministrationMenu = () => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isAdmin = useAtLeastOnePermission(ADMIN_PERMISSIONS);
|
||||
const isOmnichannel = usePermission('view-livechat-manager');
|
||||
|
||||
const workspace: GenericMenuItemProps = {
|
||||
id: 'workspace',
|
||||
content: t('Workspace'),
|
||||
onClick: () => router.navigate('/admin'),
|
||||
};
|
||||
const omnichannel: GenericMenuItemProps = {
|
||||
id: 'omnichannel',
|
||||
content: t('Omnichannel'),
|
||||
onClick: () => router.navigate('/omnichannel'),
|
||||
};
|
||||
|
||||
return {
|
||||
title: t('Manage'),
|
||||
items: [isAdmin && workspace, isOmnichannel && omnichannel].filter(Boolean) as GenericMenuItemProps[],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useAuditMenu } from './useAuditMenu';
|
||||
|
||||
it('should return an empty array of items if doesn`t have license', async () => {
|
||||
const { result } = renderHook(() => useAuditMenu(), {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint('GET', '/v1/licenses.info', () => ({
|
||||
// @ts-expect-error: just for testing
|
||||
license: {
|
||||
activeModules: [],
|
||||
},
|
||||
}))
|
||||
.withJohnDoe()
|
||||
.withPermission('can-audit')
|
||||
.withPermission('can-audit-log')
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.items).toEqual([]));
|
||||
});
|
||||
|
||||
it('should return an empty array of items if have license and not have permissions', async () => {
|
||||
const { result } = renderHook(() => useAuditMenu(), {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint('GET', '/v1/licenses.info', () => ({
|
||||
license: {
|
||||
license: {
|
||||
// @ts-expect-error: just for testing
|
||||
grantedModules: [{ module: 'auditing' }],
|
||||
},
|
||||
// @ts-expect-error: just for testing
|
||||
activeModules: ['auditing'],
|
||||
},
|
||||
}))
|
||||
.withMethod('license:getModules', () => ['auditing'])
|
||||
.withJohnDoe()
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.items).toEqual([]));
|
||||
});
|
||||
|
||||
it('should return auditItems if have license and permissions', async () => {
|
||||
const { result } = renderHook(() => useAuditMenu(), {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint('GET', '/v1/licenses.info', () => ({
|
||||
license: {
|
||||
license: {
|
||||
// @ts-expect-error: just for testing
|
||||
grantedModules: [{ module: 'auditing' }],
|
||||
},
|
||||
// @ts-expect-error: just for testing
|
||||
activeModules: ['auditing'],
|
||||
},
|
||||
}))
|
||||
.withJohnDoe()
|
||||
.withPermission('can-audit')
|
||||
.withPermission('can-audit-log')
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'messages',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.items[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'auditLog',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return auditMessages item if have license and can-audit permission', async () => {
|
||||
const { result } = renderHook(() => useAuditMenu(), {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint('GET', '/v1/licenses.info', () => ({
|
||||
license: {
|
||||
license: {
|
||||
// @ts-expect-error: just for testing
|
||||
grantedModules: [{ module: 'auditing' }],
|
||||
},
|
||||
// @ts-expect-error: just for testing
|
||||
activeModules: ['auditing'],
|
||||
},
|
||||
}))
|
||||
.withJohnDoe()
|
||||
.withPermission('can-audit')
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'messages',
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return audiLogs item if have license and can-audit-log permission', async () => {
|
||||
const { result } = renderHook(() => useAuditMenu(), {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint('GET', '/v1/licenses.info', () => ({
|
||||
license: {
|
||||
license: {
|
||||
// @ts-expect-error: just for testing
|
||||
grantedModules: [{ module: 'auditing' }],
|
||||
},
|
||||
// @ts-expect-error: just for testing
|
||||
activeModules: ['auditing'],
|
||||
},
|
||||
}))
|
||||
.withJohnDoe()
|
||||
.withPermission('can-audit-log')
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'auditLog',
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return auditSecurityLog item if have license and can-audit-log permission', async () => {
|
||||
const { result } = renderHook(() => useAuditMenu(), {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint('GET', '/v1/licenses.info', () => ({
|
||||
license: {
|
||||
license: {
|
||||
// @ts-expect-error: just for testing
|
||||
grantedModules: [{ module: 'auditing' }],
|
||||
},
|
||||
// @ts-expect-error: just for testing
|
||||
activeModules: ['auditing'],
|
||||
},
|
||||
}))
|
||||
.withJohnDoe()
|
||||
.withPermission('can-audit')
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.items[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'auditSecurityLog',
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
|
||||
import { usePermission, useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
|
||||
|
||||
export const useAuditMenu = () => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: hasAuditLicense = false } = useHasLicenseModule('auditing');
|
||||
|
||||
const hasAuditPermission = usePermission('can-audit') && hasAuditLicense;
|
||||
const hasAuditLogPermission = usePermission('can-audit-log') && hasAuditLicense;
|
||||
|
||||
const auditMessageItem: GenericMenuItemProps = {
|
||||
id: 'messages',
|
||||
content: t('Messages'),
|
||||
onClick: () => router.navigate('/audit'),
|
||||
};
|
||||
|
||||
const auditLogItem: GenericMenuItemProps = {
|
||||
id: 'auditLog',
|
||||
content: t('Logs'),
|
||||
onClick: () => router.navigate('/audit-log'),
|
||||
};
|
||||
|
||||
const auditSecurityLogsItem: GenericMenuItemProps = {
|
||||
id: 'auditSecurityLog',
|
||||
content: t('Security_logs'),
|
||||
onClick: () => router.navigate('/security-logs'),
|
||||
};
|
||||
|
||||
return {
|
||||
title: t('Audit'),
|
||||
items: [
|
||||
hasAuditPermission && auditMessageItem,
|
||||
hasAuditLogPermission && auditLogItem,
|
||||
hasAuditPermission && auditSecurityLogsItem,
|
||||
].filter(Boolean) as GenericMenuItemProps[],
|
||||
};
|
||||
};
|
||||
3
apps/meteor/client/navbar/NavBarSettingsToolbar/index.ts
Normal file
3
apps/meteor/client/navbar/NavBarSettingsToolbar/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as NavBarItemAdministrationMenu } from './NavBarItemAdministrationMenu';
|
||||
export { default as NavBarItemLoginPage } from './NavBarItemLoginPage';
|
||||
export { default as UserMenu } from './UserMenu';
|
||||
Reference in New Issue
Block a user