Skip to content

Commit d427b80

Browse files
authored
fix: incorrect timestamp timezone conversion and relative time preview (RocketChat#40420)
1 parent 2d32e52 commit d427b80

8 files changed

Lines changed: 155 additions & 39 deletions

File tree

.changeset/ninety-lilies-kick.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@rocket.chat/gazzodown': patch
3+
'@rocket.chat/i18n': patch
4+
'@rocket.chat/meteor': patch
5+
---
6+
7+
Fixes Insert Timestamp relative time preview not updating on input changes and losing the user's locale after the first refresh tick.

apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/TimestampPickerModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,11 @@ export const TimestampPickerModal = ({ onClose, composer }: TimestampPickerProps
6464
confirmDisabled={!isValid}
6565
>
6666
<Box display='flex' flexDirection='column'>
67+
<Preview date={currentDate} format={currentFormat} timezone={currentTimezone} />
6768
<Controller name='date' control={control} render={({ field }) => <DatePicker {...field} />} />
6869
<Controller name='date' control={control} render={({ field }) => <TimePicker {...field} />} />
6970
<Controller name='format' control={control} render={({ field }) => <FormatSelector {...field} />} />
7071
<Controller name='timezone' control={control} render={({ field }) => <TimezoneSelector {...field} />} />
71-
<Preview date={currentDate} format={currentFormat} timezone={currentTimezone} />
7272
</Box>
7373
</GenericModal>
7474
);

apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/TimezoneSelector.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Box, Field, FieldLabel, FieldRow, Select } from '@rocket.chat/fuselage';
1+
import { Box, Field, FieldHint, FieldDescription, FieldLabel, FieldRow, Select } from '@rocket.chat/fuselage';
22
import type { ReactElement, Key } from 'react';
33
import { useTranslation } from 'react-i18next';
44

@@ -26,9 +26,11 @@ const TimezoneSelector = ({ value, onChange }: TimezoneSelectorProps): ReactElem
2626
<Box mb='x16'>
2727
<Field>
2828
<FieldLabel>{t('Timezone')}</FieldLabel>
29+
<FieldDescription>{t('Timezone_picker_description')}</FieldDescription>
2930
<FieldRow>
3031
<Select value={value} onChange={handleTimezoneChange} options={options} width='full' />
3132
</FieldRow>
33+
<FieldHint>{t('Timezone_picker_hint')}</FieldHint>
3234
</Field>
3335
</Box>
3436
);

apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/__snapshots__/TimestampPicker.spec.tsx.snap

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,28 @@ exports[`Story Tests renders Default without crashing 1`] = `
9898
<div
9999
class="rcx-box rcx-box--full rcx-css-1tbw8nv"
100100
>
101+
<div
102+
class="rcx-box rcx-box--full rcx-css-1bjg207"
103+
>
104+
<div
105+
class="rcx-box rcx-box--full rcx-field"
106+
>
107+
<label
108+
class="rcx-box rcx-box--full rcx-field__label rcx-label"
109+
>
110+
Preview
111+
</label>
112+
<span
113+
class="rcx-box rcx-box--full rcx-field__row"
114+
>
115+
<div
116+
data-testid="mock-gazzodown-text"
117+
>
118+
Mocked GazzodownText
119+
</div>
120+
</span>
121+
</div>
122+
</div>
101123
<div
102124
class="rcx-box rcx-box--full rcx-css-1bjg207"
103125
>
@@ -274,6 +296,11 @@ exports[`Story Tests renders Default without crashing 1`] = `
274296
>
275297
Timezone
276298
</label>
299+
<span
300+
class="rcx-box rcx-box--full rcx-field__description"
301+
>
302+
Timezone_picker_description
303+
</span>
277304
<span
278305
class="rcx-box rcx-box--full rcx-field__row"
279306
>
@@ -443,27 +470,10 @@ exports[`Story Tests renders Default without crashing 1`] = `
443470
</i>
444471
</button>
445472
</span>
446-
</div>
447-
</div>
448-
<div
449-
class="rcx-box rcx-box--full rcx-css-1bjg207"
450-
>
451-
<div
452-
class="rcx-box rcx-box--full rcx-field"
453-
>
454-
<label
455-
class="rcx-box rcx-box--full rcx-field__label rcx-label"
456-
>
457-
Preview
458-
</label>
459473
<span
460-
class="rcx-box rcx-box--full rcx-field__row"
474+
class="rcx-box rcx-box--full rcx-field__hint"
461475
>
462-
<div
463-
data-testid="mock-gazzodown-text"
464-
>
465-
Mocked GazzodownText
466-
</div>
476+
Timezone_picker_hint
467477
</span>
468478
</div>
469479
</div>

apps/meteor/client/lib/utils/timestamp/formats.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@ export const TIMESTAMP_FORMATS: Record<TimestampFormat, ITimestampFormatConfig>
2323
},
2424
f: {
2525
label: 'timestamps.fullDateTime',
26-
format: 'PPPppp',
26+
format: 'PPPP p',
2727
description: 'timestamps.fullDateTimeDescription',
2828
},
2929
F: {
3030
label: 'timestamps.fullDateTimeLong',
31-
format: 'PPPPpppp',
31+
format: 'PPPP pp',
3232
description: 'timestamps.fullDateTimeLongDescription',
3333
},
3434
R: {
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { render, screen, act } from '@testing-library/react';
2+
3+
import TimestampWrapper from './index';
4+
import { MarkupInteractionContext } from '../../MarkupInteractionContext';
5+
6+
beforeEach(() => {
7+
jest.useFakeTimers();
8+
});
9+
10+
afterEach(() => {
11+
jest.useRealTimers();
12+
});
13+
14+
const makeTimestampNode = (epochSeconds: number) =>
15+
({
16+
type: 'TIMESTAMP' as const,
17+
value: {
18+
timestamp: String(epochSeconds),
19+
format: 'R' as const,
20+
},
21+
}) satisfies Parameters<typeof TimestampWrapper>[0]['children'];
22+
23+
it('should update displayed text immediately when the value prop changes', () => {
24+
const now = new Date('2026-05-12T12:00:00.000Z');
25+
jest.setSystemTime(now);
26+
27+
const futureA = Math.floor(now.getTime() / 1000) + 7200;
28+
const futureB = Math.floor(now.getTime() / 1000) + 86400;
29+
30+
const { rerender } = render(<TimestampWrapper>{makeTimestampNode(futureA)}</TimestampWrapper>);
31+
32+
const tagA = screen.getByText(/2 hours/i);
33+
expect(tagA).toBeInTheDocument();
34+
35+
rerender(<TimestampWrapper>{makeTimestampNode(futureB)}</TimestampWrapper>);
36+
37+
const tagB = screen.getByText(/tomorrow/i);
38+
expect(tagB).toBeInTheDocument();
39+
});
40+
41+
it('should preserve the locale from context after interval refresh', () => {
42+
const now = new Date('2026-05-12T12:00:00.000Z');
43+
jest.setSystemTime(now);
44+
45+
const futureEpoch = Math.floor(now.getTime() / 1000) + 30;
46+
47+
render(
48+
<MarkupInteractionContext.Provider value={{ language: 'de' }}>
49+
<TimestampWrapper>{makeTimestampNode(futureEpoch)}</TimestampWrapper>
50+
</MarkupInteractionContext.Provider>,
51+
);
52+
53+
const initial = screen.getByRole('time');
54+
const initialText = initial.textContent;
55+
expect(initialText).toBeTruthy();
56+
57+
act(() => {
58+
jest.advanceTimersByTime(1500);
59+
});
60+
61+
const afterTick = screen.getByRole('time');
62+
const afterTickText = afterTick.textContent;
63+
expect(afterTickText).toBeTruthy();
64+
65+
expect(afterTickText).not.toMatch(/seconds/i);
66+
});
67+
68+
it('should update displayed text immediately when locale changes via context', () => {
69+
const now = new Date('2026-05-12T12:00:00.000Z');
70+
jest.setSystemTime(now);
71+
72+
const futureEpoch = Math.floor(now.getTime() / 1000) + 30;
73+
74+
const { rerender } = render(
75+
<MarkupInteractionContext.Provider value={{ language: 'en' }}>
76+
<TimestampWrapper>{makeTimestampNode(futureEpoch)}</TimestampWrapper>
77+
</MarkupInteractionContext.Provider>,
78+
);
79+
80+
expect(screen.getByRole('time').textContent).toMatch(/seconds/i);
81+
82+
rerender(
83+
<MarkupInteractionContext.Provider value={{ language: 'de' }}>
84+
<TimestampWrapper>{makeTimestampNode(futureEpoch)}</TimestampWrapper>
85+
</MarkupInteractionContext.Provider>,
86+
);
87+
88+
expect(screen.getByRole('time').textContent).not.toMatch(/seconds/i);
89+
});

packages/gazzodown/src/elements/Timestamp/index.tsx

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,10 @@ const ShortDate = ({ value }: { value: Date }) => <Time value={format(value, 'P'
5454
const LongDate = ({ value }: { value: Date }) => <Time value={format(value, 'Pp')} dateTime={value.toISOString()} />;
5555

5656
// eslint-disable-next-line react/no-multi-comp
57-
const FullDate = ({ value }: { value: Date }) => <Time value={format(value, 'PPPppp')} dateTime={value.toISOString()} />;
57+
const FullDate = ({ value }: { value: Date }) => <Time value={format(value, 'PPPP p')} dateTime={value.toISOString()} />;
5858

5959
// eslint-disable-next-line react/no-multi-comp
60-
const FullDateLong = ({ value }: { value: Date }) => <Time value={format(value, 'PPPPpppp')} dateTime={value.toISOString()} />;
60+
const FullDateLong = ({ value }: { value: Date }) => <Time value={format(value, 'PPPP pp')} dateTime={value.toISOString()} />;
6161

6262
// eslint-disable-next-line react/no-multi-comp
6363
const Time = ({ value, dateTime }: { value: string; dateTime: string }) => (
@@ -77,17 +77,23 @@ const RelativeTime = ({ value }: { value: Date }) => {
7777
const time = value.getTime();
7878

7979
const { language } = useContext(MarkupInteractionContext);
80-
const [text, setTime] = useState(() => intlFormatDistance(time, Date.now(), { locale: language ?? 'en' }));
81-
const [timeToRefresh, setTimeToRefresh] = useState(() => getTimeToRefresh(time));
80+
const locale = language ?? 'en';
81+
const [text, setText] = useState(() => intlFormatDistance(time, Date.now(), { locale }));
8282

8383
useEffect(() => {
84-
const interval = setInterval(() => {
85-
setTime(intlFormatDistance(value.getTime(), Date.now(), { locale: 'en' }));
86-
setTimeToRefresh(getTimeToRefresh(time));
87-
}, timeToRefresh);
84+
setText(intlFormatDistance(time, Date.now(), { locale }));
8885

89-
return () => clearInterval(interval);
90-
}, [time, timeToRefresh, value]);
86+
let timeoutId: ReturnType<typeof setTimeout>;
87+
88+
const refresh = () => {
89+
setText(intlFormatDistance(time, Date.now(), { locale }));
90+
timeoutId = setTimeout(refresh, getTimeToRefresh(time));
91+
};
92+
93+
timeoutId = setTimeout(refresh, getTimeToRefresh(time));
94+
95+
return () => clearTimeout(timeoutId);
96+
}, [time, locale]);
9197

9298
return <Time value={text} dateTime={value.toISOString()} />;
9399
};

packages/i18n/src/locales/en.i18n.json

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5380,7 +5380,9 @@
53805380
"Timeout_in_miliseconds_hint": "The time in milliseconds to wait for an external service to respond before canceling the request.",
53815381
"Timeouts": "Timeouts",
53825382
"Timestamp": "Timestamp",
5383-
"Timezone": "Timezone",
5383+
"Timezone": "Time zone",
5384+
"Timezone_picker_description": "Select the time zone for the entered time.",
5385+
"Timezone_picker_hint": "Others see timestamps converted to their own time zone.",
53845386
"Title": "Title",
53855387
"Title_bar_color": "Title bar color",
53865388
"Title_bar_color_offline": "Title bar color offline",
@@ -7209,15 +7211,15 @@
72097211
"timestamps.shortTime": "Short time",
72107212
"timestamps.longTime": "Long time",
72117213
"timestamps.shortDate": "Short date",
7212-
"timestamps.longDate": "Long date",
7213-
"timestamps.fullDateTime": "Full date and time",
7214-
"timestamps.fullDateTimeLong": "Full date and time (long)",
7215-
"timestamps.relativeTime": "Relative time",
7214+
"timestamps.longDate": "Short date and time",
7215+
"timestamps.fullDateTime": "Full date and short time",
7216+
"timestamps.fullDateTimeLong": "Full date and long time",
7217+
"timestamps.relativeTime": "Relative",
72167218
"timestamps.shortTimeDescription": "12:00 AM",
72177219
"timestamps.longTimeDescription": "12:00:00 AM",
72187220
"timestamps.shortDateDescription": "12/31/2020",
72197221
"timestamps.longDateDescription": "12/31/2020, 12:00 AM",
7220-
"timestamps.fullDateTimeDescription": "December 31, 2020 12:00 AM",
7222+
"timestamps.fullDateTimeDescription": "Thursday, December 31, 2020 12:00 AM",
72217223
"timestamps.fullDateTimeLongDescription": "Thursday, December 31, 2020 12:00:00 AM",
72227224
"__username__profile_picture": "{{username}}'s profile picture",
72237225
"User_card": "User card",

0 commit comments

Comments
 (0)