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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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