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,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] : []),
];
};

View File

@@ -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} />);
};
};

View File

@@ -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} />);
};
};

View File

@@ -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,
]);
};

View File

@@ -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)} />);
};

View File

@@ -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();
});
});
});

View File

@@ -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);
};