Skip to content

Commit 5dd9075

Browse files
committed
feat: add status expiration UI with duration picker and tooltip display
Add Edit Status modal with duration picker (preset and custom date/time), status expiration text and tooltip across sidebar, user card, user info, room header, and room members. Includes client-side presence processing for statusExpiresAt and e2e tests for the new UI.
1 parent 10e8f52 commit 5dd9075

23 files changed

Lines changed: 604 additions & 88 deletions

File tree

apps/meteor/app/notifications/client/lib/Presence.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { PresenceSource } from '@rocket.chat/core-typings';
12
import { UserStatus } from '@rocket.chat/core-typings';
23
import { Meteor } from 'meteor/meteor';
34

@@ -9,10 +10,17 @@ import { streamerCentral } from '../../../../client/lib/streamer';
910

1011
streamerCentral.getStreamer('user-presence', { ddpConnection: Meteor.connection });
1112

12-
type args = [username: string, statusChanged?: UserStatus, statusText?: string];
13+
type args = [username: string, statusChanged?: UserStatus, statusText?: string, statusSource?: PresenceSource, statusExpiresAt?: Date];
1314

1415
export const STATUS_MAP = [UserStatus.OFFLINE, UserStatus.ONLINE, UserStatus.AWAY, UserStatus.BUSY, UserStatus.DISABLED];
1516

16-
streamerCentral.on('stream-user-presence', (uid: string, [username, statusChanged, statusText]: args) => {
17-
Presence.notify({ _id: uid, username, status: STATUS_MAP[statusChanged as any], statusText });
17+
streamerCentral.on('stream-user-presence', (uid: string, [username, statusChanged, statusText, statusSource, statusExpiresAt]: args) => {
18+
Presence.notify({
19+
_id: uid,
20+
username,
21+
status: STATUS_MAP[statusChanged as any],
22+
statusText,
23+
statusSource,
24+
statusExpiresAt,
25+
});
1826
});

apps/meteor/client/components/UserInfo/UserInfo.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { useUserCustomFields } from '../../hooks/useUserCustomFields';
2222
import MarkdownText from '../MarkdownText';
2323
import UTCClock from '../UTCClock';
2424
import { UserCardRoles } from '../UserCard';
25+
import { UserStatusText } from '../UserStatusText';
2526
import UserInfoABACAttributes from './UserInfoABACAttributes';
2627
import UserInfoAvatar from './UserInfoAvatar';
2728

@@ -38,6 +39,7 @@ type UserInfoDataProps = Serialized<
3839
| 'phone'
3940
| 'createdAt'
4041
| 'statusText'
42+
| 'statusExpiresAt'
4143
| 'canViewAllInfo'
4244
| 'customFields'
4345
| 'freeSwitchExtension'
@@ -70,6 +72,7 @@ const UserInfo = ({
7072
createdAt,
7173
status,
7274
statusText,
75+
statusExpiresAt,
7376
customFields,
7477
canViewAllInfo,
7578
actions,
@@ -102,7 +105,7 @@ const UserInfo = ({
102105

103106
{statusText && (
104107
<InfoPanelText>
105-
<MarkdownText content={statusText} parseEmoji={true} variant='inline' />
108+
<UserStatusText statusText={statusText} statusExpiresAt={statusExpiresAt} showExpiration />
106109
</InfoPanelText>
107110
)}
108111
</InfoPanelSection>

apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap

Lines changed: 52 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,20 @@ exports[`renders Default without crashing 1`] = `
9393
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-faoni4"
9494
>
9595
<div
96-
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements"
96+
class="rcx-box rcx-box--full"
97+
data-tooltip=""
9798
>
98-
<img
99-
alt="🛴"
100-
class="emojione"
101-
src="https://cdn.jsdelivr.net/emojione/assets/4.5/png/32/1f6f4.png"
102-
title=":scooter:"
103-
/>
104-
currently working on User Card
99+
<div
100+
class="rcx-box rcx-box--full rcx-box--with-inline-elements rcx-css-1te28na"
101+
>
102+
<img
103+
alt="🛴"
104+
class="emojione"
105+
src="https://cdn.jsdelivr.net/emojione/assets/4.5/png/32/1f6f4.png"
106+
title=":scooter:"
107+
/>
108+
currently working on User Card
109+
</div>
105110
</div>
106111
</div>
107112
</div>
@@ -390,15 +395,20 @@ exports[`renders InvitedUser without crashing 1`] = `
390395
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-faoni4"
391396
>
392397
<div
393-
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements"
398+
class="rcx-box rcx-box--full"
399+
data-tooltip=""
394400
>
395-
<img
396-
alt="🛴"
397-
class="emojione"
398-
src="https://cdn.jsdelivr.net/emojione/assets/4.5/png/32/1f6f4.png"
399-
title=":scooter:"
400-
/>
401-
currently working on User Card
401+
<div
402+
class="rcx-box rcx-box--full rcx-box--with-inline-elements rcx-css-1te28na"
403+
>
404+
<img
405+
alt="🛴"
406+
class="emojione"
407+
src="https://cdn.jsdelivr.net/emojione/assets/4.5/png/32/1f6f4.png"
408+
title=":scooter:"
409+
/>
410+
currently working on User Card
411+
</div>
402412
</div>
403413
</div>
404414
</div>
@@ -701,15 +711,20 @@ exports[`renders WithABACAttributes without crashing 1`] = `
701711
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-faoni4"
702712
>
703713
<div
704-
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements"
714+
class="rcx-box rcx-box--full"
715+
data-tooltip=""
705716
>
706-
<img
707-
alt="🛴"
708-
class="emojione"
709-
src="https://cdn.jsdelivr.net/emojione/assets/4.5/png/32/1f6f4.png"
710-
title=":scooter:"
711-
/>
712-
currently working on User Card
717+
<div
718+
class="rcx-box rcx-box--full rcx-box--with-inline-elements rcx-css-1te28na"
719+
>
720+
<img
721+
alt="🛴"
722+
class="emojione"
723+
src="https://cdn.jsdelivr.net/emojione/assets/4.5/png/32/1f6f4.png"
724+
title=":scooter:"
725+
/>
726+
currently working on User Card
727+
</div>
713728
</div>
714729
</div>
715730
</div>
@@ -1058,15 +1073,20 @@ exports[`renders WithVoiceCallExtension without crashing 1`] = `
10581073
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-faoni4"
10591074
>
10601075
<div
1061-
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements"
1076+
class="rcx-box rcx-box--full"
1077+
data-tooltip=""
10621078
>
1063-
<img
1064-
alt="🛴"
1065-
class="emojione"
1066-
src="https://cdn.jsdelivr.net/emojione/assets/4.5/png/32/1f6f4.png"
1067-
title=":scooter:"
1068-
/>
1069-
currently working on User Card
1079+
<div
1080+
class="rcx-box rcx-box--full rcx-box--with-inline-elements rcx-css-1te28na"
1081+
>
1082+
<img
1083+
alt="🛴"
1084+
class="emojione"
1085+
src="https://cdn.jsdelivr.net/emojione/assets/4.5/png/32/1f6f4.png"
1086+
title=":scooter:"
1087+
/>
1088+
currently working on User Card
1089+
</div>
10701090
</div>
10711091
</div>
10721092
</div>
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Box } from '@rocket.chat/fuselage';
2+
import { useTooltipClose, useTooltipOpen } from '@rocket.chat/ui-contexts';
3+
import type { ReactElement } from 'react';
4+
import { useRef, useCallback } from 'react';
5+
6+
import { useExpirationText } from './useExpirationText';
7+
import MarkdownText from '../MarkdownText';
8+
9+
type UserStatusTextProps = {
10+
statusText?: string;
11+
statusExpiresAt?: Date | string;
12+
showExpiration?: boolean;
13+
};
14+
15+
const UserStatusText = ({ statusText, statusExpiresAt, showExpiration: showExpirationProp }: UserStatusTextProps): ReactElement | null => {
16+
const expirationText = useExpirationText(statusExpiresAt);
17+
const hasValidExpiration = expirationText != null;
18+
const showExpiration = showExpirationProp ?? hasValidExpiration;
19+
20+
const ref = useRef<HTMLDivElement>(null);
21+
const openTooltip = useTooltipOpen();
22+
const closeTooltip = useTooltipClose();
23+
24+
const handleMouseEnter = useCallback(() => {
25+
if (!ref.current || !hasValidExpiration) {
26+
return;
27+
}
28+
openTooltip(<Box fontScale='p2'>{expirationText}</Box>, ref.current);
29+
}, [hasValidExpiration, expirationText, openTooltip]);
30+
31+
if (!statusText) {
32+
return null;
33+
}
34+
35+
return (
36+
<Box ref={ref} data-tooltip='' onMouseEnter={handleMouseEnter} onMouseLeave={closeTooltip}>
37+
<MarkdownText content={statusText} parseEmoji={true} variant='inlineWithoutBreaks' withTruncatedText />
38+
{showExpiration && hasValidExpiration && (
39+
<Box color='hint' fontScale='c1'>
40+
{expirationText}
41+
</Box>
42+
)}
43+
</Box>
44+
);
45+
};
46+
47+
export default UserStatusText;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { default as UserStatusText } from './UserStatusText';
2+
export { useStatusTooltip } from './useStatusTooltip';
3+
4+
export { useExpirationText, parseExpiresAt } from './useExpirationText';
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { useMemo } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
4+
import { useFormatDate } from '../../hooks/useFormatDate';
5+
import { useFormatTime } from '../../hooks/useFormatTime';
6+
7+
// Handles Date, ISO string, and EJSON { $date } (from DDP streamer which does raw JSON.parse without EJSON deserialization)
8+
export function parseExpiresAt(value?: unknown): Date | undefined {
9+
if (!value) {
10+
return undefined;
11+
}
12+
13+
if (value instanceof Date) {
14+
return Number.isNaN(value.getTime()) ? undefined : value;
15+
}
16+
17+
if (typeof value === 'object' && '$date' in (value as Record<string, unknown>)) {
18+
return new Date((value as { $date: number }).$date);
19+
}
20+
21+
if (typeof value === 'string') {
22+
const date = new Date(value);
23+
return Number.isNaN(date.getTime()) ? undefined : date;
24+
}
25+
26+
return undefined;
27+
}
28+
29+
function isSameDay(a: Date, b: Date): boolean {
30+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
31+
}
32+
33+
export function useExpirationText(statusExpiresAt?: Date | string): string | undefined {
34+
const { t } = useTranslation();
35+
const formatTime = useFormatTime();
36+
const formatDate = useFormatDate();
37+
38+
return useMemo(() => {
39+
const expiresAt = parseExpiresAt(statusExpiresAt);
40+
if (!expiresAt || expiresAt.getTime() <= Date.now()) {
41+
return undefined;
42+
}
43+
44+
const isToday = isSameDay(expiresAt, new Date());
45+
return `${t('Until')} ${isToday ? formatTime(expiresAt) : formatDate(expiresAt)}`;
46+
}, [statusExpiresAt, t, formatTime, formatDate]);
47+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Box } from '@rocket.chat/fuselage';
2+
import { useTooltipOpen, useTooltipClose } from '@rocket.chat/ui-contexts';
3+
import type { MouseEvent } from 'react';
4+
import { useCallback } from 'react';
5+
6+
import { useExpirationText } from './useExpirationText';
7+
8+
export function useStatusTooltip(statusText?: string, statusExpiresAt?: Date | string) {
9+
const expirationText = useExpirationText(statusExpiresAt);
10+
const openTooltip = useTooltipOpen();
11+
const closeTooltip = useTooltipClose();
12+
13+
const handleMouseEnter = useCallback(
14+
(e: MouseEvent<HTMLElement>) => {
15+
if (!statusText) {
16+
return;
17+
}
18+
openTooltip(
19+
<Box>
20+
<Box fontScale='p2'>{statusText}</Box>
21+
{expirationText && (
22+
<Box fontScale='c1' color='hint'>
23+
{expirationText}
24+
</Box>
25+
)}
26+
</Box>,
27+
e.currentTarget,
28+
);
29+
},
30+
[statusText, expirationText, openTooltip],
31+
);
32+
33+
return {
34+
hasStatusText: !!statusText,
35+
handleMouseEnter,
36+
handleMouseLeave: closeTooltip,
37+
};
38+
}

apps/meteor/client/lib/presence.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,54 @@ describe('Presence fallback status', () => {
4848

4949
expect(Presence.store.get('user1')?.status).toBe(UserStatus.OFFLINE);
5050
});
51+
52+
it('should preserve statusSource and statusExpiresAt from REST response', async () => {
53+
const expiresAt = new Date(Date.now() + 3600_000);
54+
mockGet.mockResolvedValue({
55+
users: [
56+
{
57+
_id: 'user1',
58+
username: 'testuser',
59+
status: UserStatus.BUSY,
60+
statusText: 'focus time',
61+
statusSource: 'manual',
62+
statusExpiresAt: expiresAt.toISOString(),
63+
},
64+
],
65+
});
66+
Presence.setStatus('enabled');
67+
68+
Presence.listen('user1', jest.fn());
69+
await jest.advanceTimersByTimeAsync(500);
70+
71+
const stored = Presence.store.get('user1');
72+
expect(stored?.status).toBe(UserStatus.BUSY);
73+
expect(stored?.statusText).toBe('focus time');
74+
expect(stored?.statusSource).toBe('manual');
75+
expect(stored?.statusExpiresAt).toEqual(expiresAt);
76+
});
77+
78+
it('should merge statusSource and statusExpiresAt from notify into existing store entry', async () => {
79+
mockGet.mockResolvedValue({ users: [] });
80+
Presence.setStatus('enabled');
81+
82+
Presence.listen('user1', jest.fn());
83+
await jest.advanceTimersByTimeAsync(500);
84+
85+
const expiresAt = new Date(Date.now() + 1800_000);
86+
Presence.notify({
87+
_id: 'user1',
88+
username: 'testuser',
89+
status: UserStatus.BUSY,
90+
statusText: 'in a meeting',
91+
statusSource: 'manual',
92+
statusExpiresAt: expiresAt,
93+
});
94+
95+
const stored = Presence.store.get('user1');
96+
expect(stored?.status).toBe(UserStatus.BUSY);
97+
expect(stored?.statusText).toBe('in a meeting');
98+
expect(stored?.statusSource).toBe('manual');
99+
expect(stored?.statusExpiresAt).toEqual(expiresAt);
100+
});
51101
});

apps/meteor/client/lib/presence.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,12 @@ const getPresence = ((): ((uid: UserPresence['_id']) => void) => {
7575

7676
const fallbackStatus = status === 'disabled' ? UserStatus.DISABLED : UserStatus.OFFLINE;
7777

78-
users.forEach((user) => {
78+
users.forEach(({ statusExpiresAt, ...user }) => {
7979
if (!store.has(user._id)) {
80-
notify(user);
80+
notify({
81+
...user,
82+
...(statusExpiresAt && { statusExpiresAt: new Date(statusExpiresAt) }),
83+
});
8184
}
8285
currentUids.delete(user._id);
8386
});

0 commit comments

Comments
 (0)