feat: Add message timestamp picker components for date and time selection (#36595)

Co-authored-by: Martin Schoeler <20868078+MartinSchoeler@users.noreply.github.com>
Co-authored-by: Douglas Fabris <27704687+dougfabris@users.noreply.github.com>
This commit is contained in:
jhanyu2019 2025-10-28 17:44:25 -04:00 committed by GitHub
parent 713ce92954
commit 6f4362bd89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1123 additions and 6 deletions

View File

@ -0,0 +1,37 @@
import { Box, Field, FieldLabel, FieldRow, InputBox } from '@rocket.chat/fuselage';
import { format } from 'date-fns';
import type { ChangeEvent, ReactElement } from 'react';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
type DatePickerProps = {
value: Date;
onChange: (date: Date) => void;
};
const DatePicker = ({ value, onChange }: DatePickerProps): ReactElement => {
const { t } = useTranslation();
const fieldId = useId();
const handleDateChange = (e: ChangeEvent<HTMLInputElement>) => {
const [year, month, day] = e.target.value.split('-').map(Number);
const newDate = new Date(value);
newDate.setFullYear(year, month - 1, day);
onChange(newDate);
};
const dateValue = value && !isNaN(value.getTime()) ? format(value, 'yyyy-MM-dd') : '';
return (
<Box mb='x16'>
<Field>
<FieldLabel htmlFor={fieldId}>{t('Date')}</FieldLabel>
<FieldRow>
<InputBox id={fieldId} type='date' value={dateValue} onChange={handleDateChange} />
</FieldRow>
</Field>
</Box>
);
};
export default DatePicker;

View File

@ -0,0 +1,36 @@
import { Box, Field, FieldLabel, FieldRow, Select } from '@rocket.chat/fuselage';
import type { ReactElement, Key } from 'react';
import { useTranslation } from 'react-i18next';
import { TIMESTAMP_FORMATS } from '../../../../../../../lib/utils/timestamp/formats';
import type { TimestampFormat, ITimestampFormatConfig } from '../../../../../../../lib/utils/timestamp/types';
type FormatSelectorProps = {
value: TimestampFormat;
onChange: (format: TimestampFormat) => void;
};
const FormatSelector = ({ value, onChange }: FormatSelectorProps): ReactElement => {
const { t } = useTranslation();
const handleFormatChange = (key: Key): void => {
onChange(key as TimestampFormat);
};
const formatOptions = Object.entries(TIMESTAMP_FORMATS).map(
([format, config]: [string, ITimestampFormatConfig]) => [format, `${t(config.label)} (${t(config.description)})`] as const,
);
return (
<Box mb='x16'>
<Field>
<FieldLabel>{t('Format')}</FieldLabel>
<FieldRow>
<Select value={value} onChange={handleFormatChange} options={formatOptions} width='full' />
</FieldRow>
</Field>
</Box>
);
};
export default FormatSelector;

View File

@ -0,0 +1,37 @@
import { Box, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
import { Markup } from '@rocket.chat/gazzodown';
import { parse } from '@rocket.chat/message-parser';
import type { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import { dateToISOString, generateTimestampMarkup } from '../../../../../../../lib/utils/timestamp/conversion';
import type { TimestampFormat, TimezoneKey } from '../../../../../../../lib/utils/timestamp/types';
import GazzodownText from '../../../../../../GazzodownText';
type PreviewProps = {
date: Date;
format: TimestampFormat;
timezone: TimezoneKey;
};
const Preview = ({ date, format, timezone }: PreviewProps): ReactElement => {
const { t } = useTranslation();
const timestamp = dateToISOString(date, timezone);
const markup = generateTimestampMarkup(timestamp, format);
const tokens = parse(markup);
return (
<Box mb='x16'>
<Field>
<FieldLabel>{t('Preview')}</FieldLabel>
<FieldRow>
<GazzodownText>
<Markup tokens={tokens} />
</GazzodownText>
</FieldRow>
</Field>
</Box>
);
};
export default Preview;

View File

@ -0,0 +1,37 @@
import { Box, Field, FieldLabel, FieldRow, InputBox } from '@rocket.chat/fuselage';
import { format } from 'date-fns';
import type { ChangeEvent, ReactElement } from 'react';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
type TimePickerProps = {
value: Date;
onChange: (date: Date) => void;
};
const TimePicker = ({ value, onChange }: TimePickerProps): ReactElement => {
const { t } = useTranslation();
const fieldId = useId();
const handleTimeChange = (e: ChangeEvent<HTMLInputElement>) => {
const newDate = new Date(value);
const [hours, minutes] = e.target.value.split(':').map(Number);
newDate.setHours(hours, minutes, 0, 0);
onChange(newDate);
};
const timeValue = value && !isNaN(value.getTime()) ? format(value, 'HH:mm') : '';
return (
<Box mb='x16'>
<Field>
<FieldLabel htmlFor={fieldId}>{t('Time')}</FieldLabel>
<FieldRow>
<InputBox id={fieldId} type='time' value={timeValue} onChange={handleTimeChange} />
</FieldRow>
</Field>
</Box>
);
};
export default TimePicker;

View File

@ -0,0 +1,154 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import * as stories from './TimestampPicker.stories';
import { TimestampPickerModal } from './TimestampPickerModal';
import { dateToISOString, generateTimestampMarkup } from '../../../../../../../lib/utils/timestamp/conversion';
import { TIMESTAMP_FORMATS } from '../../../../../../../lib/utils/timestamp/formats';
jest.mock('../../../../../../GazzodownText', () => ({
__esModule: true,
default: () => <div data-testid='mock-gazzodown-text'>Mocked GazzodownText</div>,
}));
const wrapper = mockAppRoot().withUserPreference('useEmojis', true).withSetting('UI_Use_Real_Name', false).withJohnDoe();
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
describe('Story Tests', () => {
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2025-01-01T12:00:00.000Z'));
});
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const view = render(<Story />, { wrapper: wrapper.build() });
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)(
`%s should be accessible`,
async (_storyname, Story) => {
jest.useRealTimers();
const { container } = render(<Story />, { wrapper: wrapper.build() });
/**
** Disable 'nested-interactive' rule because our `Select` component is still not a11y compliant
**/
const results = await axe(container, {
rules: { 'nested-interactive': { enabled: false } },
});
expect(results).toHaveNoViolations();
jest.useFakeTimers();
jest.setSystemTime(new Date('2025-01-01T12:00:00.000Z'));
},
30000,
);
afterAll(() => {
jest.useRealTimers();
});
});
describe('TimestampPicker', () => {
const mockOnClose = jest.fn();
const mockComposer = { insertText: jest.fn() };
beforeEach(() => {
jest.clearAllMocks();
});
it('should complete timestamp creation workflow', async () => {
render(<TimestampPickerModal onClose={mockOnClose} composer={mockComposer as any} />, {
wrapper: wrapper.build(),
});
// 1. Select date
const dateInput = screen.getByDisplayValue(/\d{4}-\d{2}-\d{2}/);
await userEvent.type(dateInput, '{selectall}2025-07-25');
// 2. Select time
const timeInput = screen.getByDisplayValue(/\d{2}:\d{2}/);
await userEvent.type(timeInput, '{selectall}14:30');
// 3. Select format
const formatSelectButton = screen.getByRole('button', {
name: /timestamps\.fullDateTime/i,
});
await userEvent.click(formatSelectButton);
const formatOption = screen.getByRole('option', {
name: /timestamps\.fullDateTime \(timestamps\.fullDateTimeDescription\)/,
});
await userEvent.click(formatOption);
// 4. Select timezone
const timezoneSelects = screen.getAllByRole('button', { name: /Local_Time/ });
const timezoneSelect = timezoneSelects[0];
await userEvent.click(timezoneSelect);
const timezoneOption = screen.getByRole('option', { name: /UTC\+8/ });
await userEvent.click(timezoneOption);
// 5. Submit
const addButton = screen.getByRole('button', { name: /add/i });
await userEvent.click(addButton);
// Verify results
expect(mockComposer.insertText).toHaveBeenCalledTimes(1);
expect(mockOnClose).toHaveBeenCalledTimes(1);
const output = mockComposer.insertText.mock.calls[0][0];
expect(output).toMatch(/^<t::f>$/);
});
// Cancel Operation Test
it('should call onClose when cancel is clicked', async () => {
render(<TimestampPickerModal onClose={mockOnClose} />, { wrapper: wrapper.build() });
const cancelButton = screen.getByRole('button', { name: /cancel/i });
await userEvent.click(cancelButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
// Format Validation Test
it('should validate all timestamp formats', () => {
const testDate = new Date('2025-07-25T10:30:00.000Z');
const timestamp = dateToISOString(testDate, 'UTC+8');
Object.keys(TIMESTAMP_FORMATS).forEach((format) => {
const markup = generateTimestampMarkup(timestamp, format as any);
expect(markup).toBe(`<t:${timestamp}:${format}>`);
});
});
});
describe('Timestamp Conversion Logic', () => {
const testDate = new Date('2025-07-25T10:30:00.000Z');
it('should convert date to correct ISO string for different timezones', () => {
expect(dateToISOString(testDate, 'local')).toBe('2025-07-25T10:30:00.000+00:00');
expect(dateToISOString(testDate, 'UTC+8')).toContain('+08:00');
expect(dateToISOString(testDate, 'UTC-5')).toContain('-05:00');
});
it('should handle extreme timezone values', () => {
const extremeTimezones = ['UTC-12', 'UTC+12'];
extremeTimezones.forEach((timezone) => {
const result = dateToISOString(testDate, timezone as any);
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/);
if (timezone === 'UTC+12') {
expect(result).toContain('+12:00');
} else {
expect(result).toContain('-12:00');
}
});
});
});

View File

@ -0,0 +1,31 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { action } from '@storybook/addon-actions';
import type { Meta, StoryFn } from '@storybook/react';
import DatePicker from './DatePicker';
import FormatSelector from './FormatSelector';
import Preview from './Preview';
import TimePicker from './TimePicker';
import { TimestampPickerModal } from './TimestampPickerModal';
import TimezoneSelector from './TimezoneSelector';
export default {
component: TimestampPickerModal,
subcomponents: {
DatePicker,
TimePicker,
FormatSelector,
TimezoneSelector,
Preview,
},
decorators: [mockAppRoot().buildStoryDecorator()],
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof TimestampPickerModal>;
export const Default = () => <TimestampPickerModal onClose={action('onClose')} composer={{ insertText: action('insertText') } as any} />;
export const DatePickerDefault: StoryFn<typeof DatePicker> = () => (
<DatePicker value={new Date('2025-07-25')} onChange={action('date-change')} />
);

View File

@ -0,0 +1,75 @@
import { Box } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import { useForm, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import DatePicker from './DatePicker';
import FormatSelector from './FormatSelector';
import Preview from './Preview';
import TimePicker from './TimePicker';
import TimezoneSelector from './TimezoneSelector';
import type { ComposerAPI } from '../../../../../../../lib/chats/ChatAPI';
import { dateToISOString, generateTimestampMarkup } from '../../../../../../../lib/utils/timestamp/conversion';
import type { TimezoneKey, TimestampFormat } from '../../../../../../../lib/utils/timestamp/types';
type TimestampForm = {
date: Date;
format: TimestampFormat;
timezone: TimezoneKey;
};
type TimestampPickerProps = {
onClose: () => void;
composer?: ComposerAPI;
};
export const TimestampPickerModal = ({ onClose, composer }: TimestampPickerProps) => {
const { t } = useTranslation();
const {
control,
handleSubmit,
watch,
formState: { isValid },
} = useForm<TimestampForm>({
defaultValues: {
date: new Date(),
format: 'f',
timezone: 'local',
},
mode: 'onChange',
});
const currentDate = watch('date');
const currentFormat = watch('format');
const currentTimezone = watch('timezone');
const onSubmit = (data: TimestampForm) => {
const timestamp = dateToISOString(data.date, data.timezone);
const markup = generateTimestampMarkup(timestamp, data.format);
if (composer) {
composer.insertText(markup);
}
onClose();
};
return (
<GenericModal
variant='warning'
icon={null}
title={t('Insert_timestamp')}
onConfirm={handleSubmit(onSubmit)}
onCancel={onClose}
onClose={onClose}
confirmText={t('Add')}
confirmDisabled={!isValid}
>
<Box display='flex' flexDirection='column'>
<Controller name='date' control={control} render={({ field }) => <DatePicker {...field} />} />
<Controller name='date' control={control} render={({ field }) => <TimePicker {...field} />} />
<Controller name='format' control={control} render={({ field }) => <FormatSelector {...field} />} />
<Controller name='timezone' control={control} render={({ field }) => <TimezoneSelector {...field} />} />
<Preview date={currentDate} format={currentFormat} timezone={currentTimezone} />
</Box>
</GenericModal>
);
};

View File

@ -0,0 +1,37 @@
import { Box, Field, FieldLabel, FieldRow, Select } from '@rocket.chat/fuselage';
import type { ReactElement, Key } from 'react';
import { useTranslation } from 'react-i18next';
import { UTCOffsets } from '../../../../../../../lib/utils/timestamp/types';
import type { TimezoneKey } from '../../../../../../../lib/utils/timestamp/types';
type TimezoneSelectorProps = {
value: TimezoneKey;
onChange: (timezone: TimezoneKey) => void;
};
const TimezoneSelector = ({ value, onChange }: TimezoneSelectorProps): ReactElement => {
const { t } = useTranslation();
const handleTimezoneChange = (key: Key): void => {
onChange(key as TimezoneKey);
};
const options: [string, string][] = [
['local', t('Local_Time')],
...Object.entries(UTCOffsets).map(([label]) => [label, label] as [string, string]),
];
return (
<Box mb='x16'>
<Field>
<FieldLabel>{t('Timezone')}</FieldLabel>
<FieldRow>
<Select value={value} onChange={handleTimezoneChange} options={options} width='full' />
</FieldRow>
</Field>
</Box>
);
};
export default TimezoneSelector;

View File

@ -0,0 +1,506 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Story Tests renders DatePickerDefault without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-1bjg207"
>
<div
class="rcx-box rcx-box--full rcx-field"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for=":r0:"
>
Date
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<label
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper"
>
<input
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-date rcx-input-box"
id=":r0:"
size="1"
type="date"
value="2025-07-25"
/>
<span
class="rcx-box rcx-box--full rcx-input-box__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-calendar rcx-icon rcx-css-4pvxx3"
>
</i>
</span>
</label>
</span>
</div>
</div>
</div>
</body>
`;
exports[`Story Tests renders Default without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby=":r1:-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-1e2ego0"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id=":r1:-title"
>
Insert_timestamp
</h2>
</div>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
<div
class="rcx-box rcx-box--full rcx-css-1tbw8nv"
>
<div
class="rcx-box rcx-box--full rcx-css-1bjg207"
>
<div
class="rcx-box rcx-box--full rcx-field"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for=":r2:"
>
Date
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<label
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper"
>
<input
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-date rcx-input-box"
id=":r2:"
size="1"
type="date"
value="2025-01-01"
/>
<span
class="rcx-box rcx-box--full rcx-input-box__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-calendar rcx-icon rcx-css-4pvxx3"
>
</i>
</span>
</label>
</span>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1bjg207"
>
<div
class="rcx-box rcx-box--full rcx-field"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for=":r3:"
>
Time
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<label
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper"
>
<input
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-time rcx-input-box"
id=":r3:"
size="1"
type="time"
value="12:00"
/>
<span
class="rcx-box rcx-box--full rcx-input-box__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-clock rcx-icon rcx-css-4pvxx3"
>
</i>
</span>
</label>
</span>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1bjg207"
>
<div
class="rcx-box rcx-box--full rcx-field"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
>
Format
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-:rb:"
class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
type="button"
>
<div
aria-hidden="true"
data-a11y-ignore="aria-hidden-focus"
data-react-aria-prevent-focus="true"
data-testid="hidden-select-container"
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
>
<label>
<select
tabindex="-1"
>
<option />
<option
value="t"
>
t
</option>
<option
value="T"
>
T
</option>
<option
value="d"
>
d
</option>
<option
value="D"
>
D
</option>
<option
value="f"
>
f
</option>
<option
value="F"
>
F
</option>
<option
value="R"
>
R
</option>
</select>
</label>
</div>
<span
class="rcx-box rcx-box--full rcx-css-4w7o7u"
id="react-aria-:rb:"
>
timestamps.fullDateTime (timestamps.fullDateTimeDescription)
</span>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-6vi44e"
>
</i>
</button>
</span>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1bjg207"
>
<div
class="rcx-box rcx-box--full rcx-field"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
>
Timezone
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-:rj:"
class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
type="button"
>
<div
aria-hidden="true"
data-a11y-ignore="aria-hidden-focus"
data-react-aria-prevent-focus="true"
data-testid="hidden-select-container"
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
>
<label>
<select
tabindex="-1"
>
<option />
<option
value="local"
>
local
</option>
<option
value="UTC-12"
>
UTC-12
</option>
<option
value="UTC-11"
>
UTC-11
</option>
<option
value="UTC-10"
>
UTC-10
</option>
<option
value="UTC-9"
>
UTC-9
</option>
<option
value="UTC-8"
>
UTC-8
</option>
<option
value="UTC-7"
>
UTC-7
</option>
<option
value="UTC-6"
>
UTC-6
</option>
<option
value="UTC-5"
>
UTC-5
</option>
<option
value="UTC-4"
>
UTC-4
</option>
<option
value="UTC-3"
>
UTC-3
</option>
<option
value="UTC-2"
>
UTC-2
</option>
<option
value="UTC-1"
>
UTC-1
</option>
<option
value="UTC"
>
UTC
</option>
<option
value="UTC+1"
>
UTC+1
</option>
<option
value="UTC+2"
>
UTC+2
</option>
<option
value="UTC+3"
>
UTC+3
</option>
<option
value="UTC+4"
>
UTC+4
</option>
<option
value="UTC+5"
>
UTC+5
</option>
<option
value="UTC+6"
>
UTC+6
</option>
<option
value="UTC+7"
>
UTC+7
</option>
<option
value="UTC+8"
>
UTC+8
</option>
<option
value="UTC+9"
>
UTC+9
</option>
<option
value="UTC+10"
>
UTC+10
</option>
<option
value="UTC+11"
>
UTC+11
</option>
<option
value="UTC+12"
>
UTC+12
</option>
</select>
</label>
</div>
<span
class="rcx-box rcx-box--full rcx-css-4w7o7u"
id="react-aria-:rj:"
>
Local_Time
</span>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-6vi44e"
>
</i>
</button>
</span>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1bjg207"
>
<div
class="rcx-box rcx-box--full rcx-field"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
>
Preview
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<div
data-testid="mock-gazzodown-text"
>
Mocked GazzodownText
</div>
</span>
</div>
</div>
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary rcx-button rcx-button-group__item"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button rcx-button-group__item"
disabled=""
type="button"
>
<span
class="rcx-button--content"
>
Add
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;

View File

@ -0,0 +1 @@
export * from './TimestampPickerModal';

View File

@ -0,0 +1,30 @@
import { UTCOffsets, type TimestampFormat, type TimezoneKey } from './types';
export const dateToISOString = (date: Date, timezone?: TimezoneKey): string => {
if (!date || isNaN(date.getTime())) {
return '';
}
if (timezone && timezone !== 'local') {
const offset = UTCOffsets[timezone];
if (offset) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const ms = String(date.getMilliseconds()).padStart(3, '0');
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${ms}${offset}`;
}
}
return date.toISOString().replace('Z', '+00:00');
};
/**
* Generates a timestamp markup string in the format <t:timestamp:format>
*/
export const generateTimestampMarkup = (timestamp: string, format: TimestampFormat): string => {
return `<t:${timestamp}:${format}>`;
};

View File

@ -0,0 +1,39 @@
import type { TimestampFormat, ITimestampFormatConfig } from './types';
export const TIMESTAMP_FORMATS: Record<TimestampFormat, ITimestampFormatConfig> = {
t: {
label: 'timestamps.shortTime',
format: 'p',
description: 'timestamps.shortTimeDescription',
},
T: {
label: 'timestamps.longTime',
format: 'pp',
description: 'timestamps.longTimeDescription',
},
d: {
label: 'timestamps.shortDate',
format: 'P',
description: 'timestamps.shortDateDescription',
},
D: {
label: 'timestamps.longDate',
format: 'Pp',
description: 'timestamps.longDateDescription',
},
f: {
label: 'timestamps.fullDateTime',
format: 'PPPppp',
description: 'timestamps.fullDateTimeDescription',
},
F: {
label: 'timestamps.fullDateTimeLong',
format: 'PPPPpppp',
description: 'timestamps.fullDateTimeLongDescription',
},
R: {
label: 'timestamps.relativeTime',
format: 'relative',
description: 'timestamps.relativeTimeDescription',
},
};

View File

@ -0,0 +1,37 @@
export type TimestampFormat = 't' | 'T' | 'd' | 'D' | 'f' | 'F' | 'R';
export interface ITimestampFormatConfig {
label: string;
format: string;
description: string;
}
export enum UTCOffsets {
'UTC-12' = '-12:00',
'UTC-11' = '-11:00',
'UTC-10' = '-10:00',
'UTC-9' = '-09:00',
'UTC-8' = '-08:00',
'UTC-7' = '-07:00',
'UTC-6' = '-06:00',
'UTC-5' = '-05:00',
'UTC-4' = '-04:00',
'UTC-3' = '-03:00',
'UTC-2' = '-02:00',
'UTC-1' = '-01:00',
'UTC' = '+00:00',
'UTC+1' = '+01:00',
'UTC+2' = '+02:00',
'UTC+3' = '+03:00',
'UTC+4' = '+04:00',
'UTC+5' = '+05:00',
'UTC+6' = '+06:00',
'UTC+7' = '+07:00',
'UTC+8' = '+08:00',
'UTC+9' = '+09:00',
'UTC+10' = '+10:00',
'UTC+11' = '+11:00',
'UTC+12' = '+12:00',
}
export type TimezoneKey = keyof typeof UTCOffsets | 'local';

View File

@ -1,7 +1,6 @@
import type { IRoom, IMessage } from '@rocket.chat/core-typings';
import type { Icon } from '@rocket.chat/fuselage';
import { GenericMenu } from '@rocket.chat/ui-client';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { GenericMenu, type GenericMenuItemProps } from '@rocket.chat/ui-client';
import { MessageComposerAction, MessageComposerActionsDivider } from '@rocket.chat/ui-composer';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useTranslation, useLayoutHiddenActions } from '@rocket.chat/ui-contexts';
@ -12,6 +11,7 @@ import { useAudioMessageAction } from './hooks/useAudioMessageAction';
import { useCreateDiscussionAction } from './hooks/useCreateDiscussionAction';
import { useFileUploadAction } from './hooks/useFileUploadAction';
import { useShareLocationAction } from './hooks/useShareLocationAction';
import { useTimestampAction } from './hooks/useTimestampAction';
import { useVideoMessageAction } from './hooks/useVideoMessageAction';
import { useWebdavActions } from './hooks/useWebdavActions';
import { messageBox } from '../../../../../../app/ui-utils/client';
@ -61,6 +61,7 @@ const MessageBoxActionsToolbar = ({
const webdavActions = useWebdavActions();
const createDiscussionAction = useCreateDiscussionAction(room);
const shareLocationAction = useShareLocationAction(room, tmid);
const timestampAction = useTimestampAction(chatContext.composer);
const apps = useMessageboxAppsActionButtons();
const { composerToolbox: hiddenActions } = useLayoutHiddenActions();
@ -71,15 +72,21 @@ const MessageBoxActionsToolbar = ({
...(!isHidden(hiddenActions, fileUploadAction) && { fileUploadAction }),
...(!isHidden(hiddenActions, createDiscussionAction) && { createDiscussionAction }),
...(!isHidden(hiddenActions, shareLocationAction) && { shareLocationAction }),
...(!hiddenActions.includes('webdav-add') && { webdavActions }),
...(timestampAction && !isHidden(hiddenActions, timestampAction) && { timestampAction }),
...(!hiddenActions.includes('webdav-add') && webdavActions && { webdavActions }),
};
const featured = [];
const createNew = [];
const share = [];
const insert = [];
createNew.push(allActions.createDiscussionAction);
if (allActions.timestampAction) {
insert.push(allActions.timestampAction);
}
if (variant === 'small') {
featured.push(allActions.audioMessageAction, allActions.fileUploadAction);
createNew.push(allActions.videoMessageAction);
@ -100,7 +107,6 @@ const MessageBoxActionsToolbar = ({
}),
...messageBox.actions.get(),
};
const messageBoxActions = Object.entries(groups).map(([name, group]) => {
const items: GenericMenuItemProps[] = group
.filter((item) => !hiddenActions.includes(item.id))
@ -126,6 +132,7 @@ const MessageBoxActionsToolbar = ({
const createNewFiltered = createNew.filter(isTruthy);
const shareFiltered = share.filter(isTruthy);
const insertFiltered = insert.filter(isTruthy);
const renderAction = ({ id, icon, content, disabled, onClick }: GenericMenuItemProps) => {
if (!icon) {
@ -144,7 +151,12 @@ const MessageBoxActionsToolbar = ({
data-qa-id='menu-more-actions'
detached
icon='plus'
sections={[{ title: t('Create_new'), items: createNewFiltered }, { title: t('Share'), items: shareFiltered }, ...messageBoxActions]}
sections={[
{ title: t('Create_new'), items: createNewFiltered },
{ title: t('Share'), items: shareFiltered },
...(insertFiltered.length > 0 ? [{ title: t('Insert'), items: insertFiltered }] : []),
...messageBoxActions,
]}
title={t('More_actions')}
/>
</>

View File

@ -0,0 +1,32 @@
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useFeaturePreview } from '@rocket.chat/ui-client';
import { useSetModal } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { TimestampPickerModal } from '../../../../../../components/message/toolbar/items/actions/Timestamp/TimestampPicker/TimestampPickerModal';
import type { ComposerAPI } from '../../../../../../lib/chats/ChatAPI';
export const useTimestampAction = (composer: ComposerAPI | undefined): GenericMenuItemProps | undefined => {
const setModal = useSetModal();
const { t } = useTranslation();
const timestampFeatureEnabled = useFeaturePreview('enable-timestamp-message-parser');
if (!timestampFeatureEnabled) {
return;
}
const handleClick = () => {
if (!composer) {
return;
}
setModal(<TimestampPickerModal onClose={() => setModal(null)} composer={composer} />);
};
return {
id: 'timestamp',
icon: 'clock',
content: t('Timestamp'),
onClick: handleClick,
};
};

View File

@ -7069,6 +7069,8 @@
"__roomName__was_added_to_favorites": "{{roomName}} was added to favorites",
"__roomName__was_removed_from_favorites": "{{roomName}} was removed from favorites",
"__unreadTitle__from__roomTitle__": "{{unreadTitle}} from {{roomTitle}}",
"Insert_timestamp": "Insert timestamp",
"Insert": "Insert",
"__username__is_no_longer__role__defined_by__user_by_": "{{username}} is no longer {{role}} by {{user_by}}",
"__username__was_set__role__by__user_by_": "{{username}} was set {{role}} by {{user_by}}",
"__usernames__and__count__more_joined": "{{usernames}} and {{count}} more joined",
@ -7079,5 +7081,19 @@
"VERIFIED": "User is verified",
"UNVERIFIED": "User is unverified",
"UNABLE_TO_VERIFY": "Unable to verify user",
"Users_invited": "The users have been invited"
"Users_invited": "The users have been invited",
"timestamps.shortTime": "Short time",
"timestamps.longTime": "Long time",
"timestamps.shortDate": "Short date",
"timestamps.longDate": "Long date",
"timestamps.fullDateTime": "Full date and time",
"timestamps.fullDateTimeLong": "Full date and time (long)",
"timestamps.relativeTime": "Relative time",
"timestamps.shortTimeDescription": "12:00 AM",
"timestamps.longTimeDescription": "12:00:00 AM",
"timestamps.shortDateDescription": "12/31/2020",
"timestamps.longDateDescription": "12/31/2020, 12:00 AM",
"timestamps.fullDateTimeDescription": "December 31, 2020 12:00 AM",
"timestamps.fullDateTimeLongDescription": "Thursday, December 31, 2020 12:00:00 AM",
"timestamps.relativeTimeDescription": "1 year ago"
}