Skip to content

Commit e83964f

Browse files
committed
feat: presence sync engine UI
1 parent d6a1147 commit e83964f

34 files changed

Lines changed: 1150 additions & 213 deletions

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} />
106109
</InfoPanelText>
107110
)}
108111
</InfoPanelSection>

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

Lines changed: 48 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,19 @@ 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"
9797
>
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
98+
<div
99+
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements"
100+
>
101+
<img
102+
alt="🛴"
103+
class="emojione"
104+
src="https://cdn.jsdelivr.net/emojione/assets/4.5/png/32/1f6f4.png"
105+
title=":scooter:"
106+
/>
107+
currently working on User Card
108+
</div>
105109
</div>
106110
</div>
107111
</div>
@@ -390,15 +394,19 @@ exports[`renders InvitedUser without crashing 1`] = `
390394
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-faoni4"
391395
>
392396
<div
393-
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements"
397+
class="rcx-box rcx-box--full"
394398
>
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
399+
<div
400+
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements"
401+
>
402+
<img
403+
alt="🛴"
404+
class="emojione"
405+
src="https://cdn.jsdelivr.net/emojione/assets/4.5/png/32/1f6f4.png"
406+
title=":scooter:"
407+
/>
408+
currently working on User Card
409+
</div>
402410
</div>
403411
</div>
404412
</div>
@@ -701,15 +709,19 @@ exports[`renders WithABACAttributes without crashing 1`] = `
701709
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-faoni4"
702710
>
703711
<div
704-
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements"
712+
class="rcx-box rcx-box--full"
705713
>
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
714+
<div
715+
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements"
716+
>
717+
<img
718+
alt="🛴"
719+
class="emojione"
720+
src="https://cdn.jsdelivr.net/emojione/assets/4.5/png/32/1f6f4.png"
721+
title=":scooter:"
722+
/>
723+
currently working on User Card
724+
</div>
713725
</div>
714726
</div>
715727
</div>
@@ -1058,15 +1070,19 @@ exports[`renders WithVoiceCallExtension without crashing 1`] = `
10581070
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-faoni4"
10591071
>
10601072
<div
1061-
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements"
1073+
class="rcx-box rcx-box--full"
10621074
>
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
1075+
<div
1076+
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements"
1077+
>
1078+
<img
1079+
alt="🛴"
1080+
class="emojione"
1081+
src="https://cdn.jsdelivr.net/emojione/assets/4.5/png/32/1f6f4.png"
1082+
title=":scooter:"
1083+
/>
1084+
currently working on User Card
1085+
</div>
10701086
</div>
10711087
</div>
10721088
</div>

apps/meteor/client/components/UserStatusMenu.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,16 +39,21 @@ const UserStatusMenu = ({
3939

4040
const statuses: Array<[value: UserStatusType, label: ReactElement]> = [
4141
[UserStatusType.ONLINE, renderOption(UserStatusType.ONLINE, t('Online'))],
42-
[UserStatusType.AWAY, renderOption(UserStatusType.AWAY, t('Away'))],
4342
[UserStatusType.BUSY, renderOption(UserStatusType.BUSY, t('Busy'))],
4443
];
4544

45+
// Away is no longer manually selectable, but surface it if the user is currently on it
46+
// (e.g., set in a previous version or auto-applied by the server) so they can switch off.
47+
if (status === UserStatusType.AWAY) {
48+
statuses.push([UserStatusType.AWAY, renderOption(UserStatusType.AWAY, t('Away'))]);
49+
}
50+
4651
if (allowInvisibleStatus) {
4752
statuses.push([UserStatusType.OFFLINE, renderOption(UserStatusType.OFFLINE, t('Offline'))]);
4853
}
4954

5055
return statuses;
51-
}, [t, allowInvisibleStatus]);
56+
}, [t, allowInvisibleStatus, status]);
5257

5358
const [cursor, handleKeyDown, handleKeyUp, reset, [visible, hide, show]] = useCursor(-1, options, ([selected], [, hide]) => {
5459
setStatus(selected);
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { UserStatus } from '@rocket.chat/core-typings';
2+
import { Box, Icon } from '@rocket.chat/fuselage';
3+
import type { ReactElement } from 'react';
4+
import { useTranslation } from 'react-i18next';
5+
6+
import { useExpirationText } from '../../hooks/useExpirationText';
7+
import MarkdownText from '../MarkdownText';
8+
9+
const STATUS_LABEL_KEYS: Record<UserStatus, string> = {
10+
[UserStatus.ONLINE]: 'Online',
11+
[UserStatus.AWAY]: 'Away',
12+
[UserStatus.BUSY]: 'Busy',
13+
[UserStatus.OFFLINE]: 'Offline',
14+
[UserStatus.DISABLED]: 'Disabled',
15+
};
16+
17+
type UserStatusTextProps = {
18+
status?: UserStatus;
19+
statusText?: string;
20+
statusExpiresAt?: Date | string;
21+
};
22+
23+
const UserStatusText = ({ status, statusText, statusExpiresAt }: UserStatusTextProps): ReactElement | null => {
24+
const { t } = useTranslation();
25+
const expirationText = useExpirationText(statusExpiresAt);
26+
27+
const statusLabel = status ? t(STATUS_LABEL_KEYS[status] ?? STATUS_LABEL_KEYS[UserStatus.OFFLINE]) : undefined;
28+
const headline = [statusLabel, statusText].filter(Boolean).join(' - ');
29+
30+
if (!headline && !expirationText) {
31+
return null;
32+
}
33+
34+
return (
35+
<Box>
36+
{headline && <MarkdownText content={headline} parseEmoji={true} variant='inline' />}
37+
{expirationText && (
38+
<Box color='secondary-info' display='flex' alignItems='center'>
39+
<Icon name='clock' size='x16' mie={4} />
40+
{expirationText}
41+
</Box>
42+
)}
43+
</Box>
44+
);
45+
};
46+
47+
export default UserStatusText;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as UserStatusText } from './UserStatusText';
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { useLanguage } from '@rocket.chat/ui-contexts';
2+
import { useMemo } from 'react';
3+
import { useTranslation } from 'react-i18next';
4+
5+
import { useFormatDate } from './useFormatDate';
6+
import { useFormatTime } from './useFormatTime';
7+
8+
// Handles Date, ISO string, and EJSON { $date } (from DDP streamer which does raw JSON.parse without EJSON deserialization)
9+
function parseExpiresAt(value?: unknown): Date | undefined {
10+
if (!value) {
11+
return undefined;
12+
}
13+
14+
if (value instanceof Date) {
15+
return Number.isNaN(value.getTime()) ? undefined : value;
16+
}
17+
18+
if (typeof value === 'object' && '$date' in (value as Record<string, unknown>)) {
19+
const date = new Date((value as { $date: number }).$date);
20+
return Number.isNaN(date.getTime()) ? undefined : date;
21+
}
22+
23+
if (typeof value === 'string') {
24+
const date = new Date(value);
25+
return Number.isNaN(date.getTime()) ? undefined : date;
26+
}
27+
28+
return undefined;
29+
}
30+
31+
function isSameDay(a: Date, b: Date): boolean {
32+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
33+
}
34+
35+
export function useExpirationText(statusExpiresAt?: Date | string): string | undefined {
36+
const { t } = useTranslation();
37+
const language = useLanguage();
38+
const formatTime = useFormatTime();
39+
const formatDate = useFormatDate();
40+
41+
return useMemo(() => {
42+
const expiresAt = parseExpiresAt(statusExpiresAt);
43+
if (!expiresAt || expiresAt.getTime() <= Date.now()) {
44+
return undefined;
45+
}
46+
47+
const now = new Date();
48+
if (isSameDay(expiresAt, now)) {
49+
return `${t('Until')} ${formatTime(expiresAt)}`;
50+
}
51+
52+
if (expiresAt.getFullYear() === now.getFullYear()) {
53+
return `${t('Until')} ${new Intl.DateTimeFormat(language, { month: 'long', day: 'numeric' }).format(expiresAt)}`;
54+
}
55+
56+
return `${t('Until')} ${formatDate(expiresAt)}`;
57+
}, [statusExpiresAt, t, language, formatTime, formatDate]);
58+
}

apps/meteor/client/hooks/useUserPresenceListener.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,15 @@ export const useUserPresenceListener = (): void => {
1111

1212
useEffect(
1313
() =>
14-
subscribe((uid, [[username, statusChanged, statusText]]) => {
15-
Presence.notify({ _id: uid, username, status: STATUS_MAP[statusChanged as any], statusText });
14+
subscribe((uid, [[username, statusChanged, statusText, statusSource, statusExpiresAt]]) => {
15+
Presence.notify({
16+
_id: uid,
17+
username,
18+
status: STATUS_MAP[statusChanged as any],
19+
statusText,
20+
statusSource,
21+
statusExpiresAt,
22+
});
1623
}),
1724
[subscribe],
1825
);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { useTooltipClose, useTooltipOpen, useUserPresence } from '@rocket.chat/ui-contexts';
2+
import type { MouseEvent } from 'react';
3+
import { useCallback, useMemo } from 'react';
4+
5+
import { UserStatusText } from '../components/UserStatusText';
6+
7+
type UserStatusTooltipHandlers = {
8+
onMouseEnter: (e: MouseEvent<HTMLElement>) => void;
9+
onMouseLeave: () => void;
10+
};
11+
12+
export function useUserStatusTooltip(uid: string | undefined): UserStatusTooltipHandlers {
13+
const presence = useUserPresence(uid);
14+
15+
const openTooltip = useTooltipOpen();
16+
const closeTooltip = useTooltipClose();
17+
18+
const tooltipNode = useMemo(() => {
19+
if (!uid) {
20+
return null;
21+
}
22+
return <UserStatusText status={presence?.status} statusText={presence?.statusText} statusExpiresAt={presence?.statusExpiresAt} />;
23+
}, [uid, presence?.status, presence?.statusText, presence?.statusExpiresAt]);
24+
25+
const onMouseEnter = useCallback(
26+
(e: MouseEvent<HTMLElement>) => {
27+
if (!tooltipNode) {
28+
return;
29+
}
30+
31+
// TODO: Workaround - need to find a proper way to display the tooltip
32+
// when it's more than simple string
33+
const target = e.currentTarget;
34+
setTimeout(() => openTooltip(tooltipNode, target), 0);
35+
},
36+
[tooltipNode, openTooltip],
37+
);
38+
39+
return { onMouseEnter, onMouseLeave: closeTooltip };
40+
}

apps/meteor/client/lib/presence.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,14 @@ const getPresence = ((): ((uid: UserPresence['_id']) => void) => {
9797

9898
const fallbackStatus = status === 'disabled' ? UserStatus.DISABLED : UserStatus.OFFLINE;
9999

100-
users.forEach((user) => {
101-
if (!store.has(user._id)) {
102-
notify(user);
100+
users.forEach(({ statusExpiresAt, ...rest }) => {
101+
if (!store.has(rest._id)) {
102+
notify({
103+
...rest,
104+
...(statusExpiresAt && { statusExpiresAt: new Date(statusExpiresAt) }),
105+
});
103106
}
104-
currentUids.delete(user._id);
107+
currentUids.delete(rest._id);
105108
});
106109

107110
currentUids.forEach((uid) => {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
type DurationOption = {
2+
value: string;
3+
labelKey: 'Status_dont_clear' | 'Status_30_minutes' | 'Status_1_hour' | 'Status_choose_date_and_time';
4+
getExpiresAt?: (ctx: { now: Date; customDate?: string; customTime?: string }) => Date | undefined;
5+
};
6+
7+
export const STATUS_DURATION_OPTIONS: DurationOption[] = [
8+
{ value: '', labelKey: 'Status_dont_clear' },
9+
{
10+
value: '30',
11+
labelKey: 'Status_30_minutes',
12+
getExpiresAt: ({ now }) => new Date(now.getTime() + 30 * 60_000),
13+
},
14+
{
15+
value: '60',
16+
labelKey: 'Status_1_hour',
17+
getExpiresAt: ({ now }) => new Date(now.getTime() + 60 * 60_000),
18+
},
19+
{
20+
value: 'custom',
21+
labelKey: 'Status_choose_date_and_time',
22+
getExpiresAt: ({ customDate, customTime }) => {
23+
if (!customDate || !customTime) {
24+
return undefined;
25+
}
26+
const date = new Date(`${customDate}T${customTime}`);
27+
return Number.isNaN(date.getTime()) ? undefined : date;
28+
},
29+
},
30+
];

0 commit comments

Comments
 (0)