mirror of
https://github.com/RocketChat/Rocket.Chat.git
synced 2025-12-28 06:47:25 +00:00
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:
parent
713ce92954
commit
6f4362bd89
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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')} />
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
@ -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>
|
||||
`;
|
||||
@ -0,0 +1 @@
|
||||
export * from './TimestampPickerModal';
|
||||
30
apps/meteor/client/lib/utils/timestamp/conversion.ts
Normal file
30
apps/meteor/client/lib/utils/timestamp/conversion.ts
Normal 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}>`;
|
||||
};
|
||||
39
apps/meteor/client/lib/utils/timestamp/formats.ts
Normal file
39
apps/meteor/client/lib/utils/timestamp/formats.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
37
apps/meteor/client/lib/utils/timestamp/types.ts
Normal file
37
apps/meteor/client/lib/utils/timestamp/types.ts
Normal 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';
|
||||
@ -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')}
|
||||
/>
|
||||
</>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user