Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6ec8222
feat: status expiration UI and client
ricardogarim May 15, 2026
8b42279
feat: expand UserPresence with statusSource and statusExpiresAt
ricardogarim May 15, 2026
db09757
feat: redesign user menu and edit status modal
ricardogarim May 18, 2026
f147684
fix: prefer server statusText over stale localStorage in EditStatusModal
ricardogarim May 18, 2026
2b52ad0
fix: show expiration validation errors inline in EditStatusModal
ricardogarim May 18, 2026
76e13c5
fix: keep EditStatusModal open when saving fails
ricardogarim May 18, 2026
a1ad4eb
fix: use exact match in selectDuration e2e helper
ricardogarim May 18, 2026
cd15e10
fix: align e2e helpers with redesigned user menu
ricardogarim May 18, 2026
ac5c615
fix: derive date from pastTime to prevent flaky test near midnight
ricardogarim May 19, 2026
c6ebe7e
fix: status tooltip override in room members with reduced motion
ricardogarim May 19, 2026
3b3d9f1
fix: guard DM peer resolution against undefined userId
ricardogarim May 19, 2026
d48abcd
refactor: bring back quick statuses on user menu
ricardogarim May 21, 2026
06416c2
feat: prepend presence status to custom status tooltip
ricardogarim Jun 2, 2026
a7d77c3
feat: custom status expiration UI across user menu, tooltip, and profile
ricardogarim Jun 2, 2026
0241991
fix: custom status expiration being wiped by profile save
ricardogarim Jun 3, 2026
0c56e76
fix: custom status expiration in profile and status modal
ricardogarim Jun 3, 2026
a3afa34
fix: empty custom status rendering empty wrapper in user card
ricardogarim Jun 3, 2026
6a27461
test: wait for save responses before navigating in profile expiration…
ricardogarim Jun 3, 2026
5c84072
fix: gate status expiration validation by status-change permission
ricardogarim Jun 3, 2026
0fe8dc1
refactor: move profile form status fields to react-hook-form
ricardogarim Jun 3, 2026
9c87407
fix: handle disabled status-change setting consistently across menu a…
ricardogarim Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions apps/meteor/app/notifications/client/lib/Presence.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { PresenceSource } from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';

Expand All @@ -20,10 +21,17 @@ if (isSdkTransportEnabled()) {
streamerCentral.setupDdpConnection('user-presence', createDdpSdkStreamerAdapter(getDdpSdk()));
}

type args = [username: string, statusChanged?: UserStatus, statusText?: string];
type args = [username: string, statusChanged?: UserStatus, statusText?: string, statusSource?: PresenceSource, statusExpiresAt?: Date];

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

streamerCentral.on('stream-user-presence', (uid: string, [username, statusChanged, statusText]: args) => {
Presence.notify({ _id: uid, username, status: STATUS_MAP[statusChanged as any], statusText });
streamerCentral.on('stream-user-presence', (uid: string, [username, statusChanged, statusText, statusSource, statusExpiresAt]: args) => {
Presence.notify({
_id: uid,
username,
status: STATUS_MAP[statusChanged as any],
statusText,
statusSource,
statusExpiresAt,
});
});
2 changes: 1 addition & 1 deletion apps/meteor/client/components/UserCard/UserCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const UserCard = ({
)}
</Box>
{customStatus && (
<UserCardInfo mbe={16}>
<UserCardInfo mbe={4}>
{typeof customStatus === 'string' ? (
<MarkdownText withTruncatedText variant='inlineWithoutBreaks' content={customStatus} parseEmoji={true} />
) : (
Expand Down
5 changes: 4 additions & 1 deletion apps/meteor/client/components/UserInfo/UserInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useUserCustomFields } from '../../hooks/useUserCustomFields';
import MarkdownText from '../MarkdownText';
import UTCClock from '../UTCClock';
import { UserCardRoles } from '../UserCard';
import { UserStatusText } from '../UserStatusText';
import UserInfoABACAttributes from './UserInfoABACAttributes';
import UserInfoAvatar from './UserInfoAvatar';

Expand All @@ -38,6 +39,7 @@ type UserInfoDataProps = Serialized<
| 'phone'
| 'createdAt'
| 'statusText'
| 'statusExpiresAt'
| 'canViewAllInfo'
| 'customFields'
| 'freeSwitchExtension'
Expand Down Expand Up @@ -70,6 +72,7 @@ const UserInfo = ({
createdAt,
status,
statusText,
statusExpiresAt,
customFields,
canViewAllInfo,
actions,
Expand Down Expand Up @@ -102,7 +105,7 @@ const UserInfo = ({

{statusText && (
<InfoPanelText>
<MarkdownText content={statusText} parseEmoji={true} variant='inline' />
<UserStatusText statusText={statusText} statusExpiresAt={statusExpiresAt} showExpiration />
</InfoPanelText>
)}
</InfoPanelSection>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,15 +93,20 @@ exports[`renders Default without crashing 1`] = `
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-faoni4"
>
<div
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements"
class="rcx-box rcx-box--full"
data-tooltip=""
>
<img
alt="🛴"
class="emojione"
src="https://cdn.jsdelivr.net/emojione/assets/4.5/png/32/1f6f4.png"
title=":scooter:"
/>
currently working on User Card
<div
class="rcx-box rcx-box--full rcx-box--with-inline-elements rcx-css-1te28na"
>
<img
alt="🛴"
class="emojione"
src="https://cdn.jsdelivr.net/emojione/assets/4.5/png/32/1f6f4.png"
title=":scooter:"
/>
currently working on User Card
</div>
</div>
</div>
</div>
Expand Down Expand Up @@ -390,15 +395,20 @@ exports[`renders InvitedUser without crashing 1`] = `
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-faoni4"
>
<div
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements"
class="rcx-box rcx-box--full"
data-tooltip=""
>
<img
alt="🛴"
class="emojione"
src="https://cdn.jsdelivr.net/emojione/assets/4.5/png/32/1f6f4.png"
title=":scooter:"
/>
currently working on User Card
<div
class="rcx-box rcx-box--full rcx-box--with-inline-elements rcx-css-1te28na"
>
<img
alt="🛴"
class="emojione"
src="https://cdn.jsdelivr.net/emojione/assets/4.5/png/32/1f6f4.png"
title=":scooter:"
/>
currently working on User Card
</div>
</div>
</div>
</div>
Expand Down Expand Up @@ -701,15 +711,20 @@ exports[`renders WithABACAttributes without crashing 1`] = `
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-faoni4"
>
<div
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements"
class="rcx-box rcx-box--full"
data-tooltip=""
>
<img
alt="🛴"
class="emojione"
src="https://cdn.jsdelivr.net/emojione/assets/4.5/png/32/1f6f4.png"
title=":scooter:"
/>
currently working on User Card
<div
class="rcx-box rcx-box--full rcx-box--with-inline-elements rcx-css-1te28na"
>
<img
alt="🛴"
class="emojione"
src="https://cdn.jsdelivr.net/emojione/assets/4.5/png/32/1f6f4.png"
title=":scooter:"
/>
currently working on User Card
</div>
</div>
</div>
</div>
Expand Down Expand Up @@ -1058,15 +1073,20 @@ exports[`renders WithVoiceCallExtension without crashing 1`] = `
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-faoni4"
>
<div
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements"
class="rcx-box rcx-box--full"
data-tooltip=""
>
<img
alt="🛴"
class="emojione"
src="https://cdn.jsdelivr.net/emojione/assets/4.5/png/32/1f6f4.png"
title=":scooter:"
/>
currently working on User Card
<div
class="rcx-box rcx-box--full rcx-box--with-inline-elements rcx-css-1te28na"
>
<img
alt="🛴"
class="emojione"
src="https://cdn.jsdelivr.net/emojione/assets/4.5/png/32/1f6f4.png"
title=":scooter:"
/>
currently working on User Card
</div>
</div>
</div>
</div>
Expand Down
43 changes: 43 additions & 0 deletions apps/meteor/client/components/UserStatusText/UserStatusText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Box } from '@rocket.chat/fuselage';
import { useTooltipClose, useTooltipOpen } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import { useRef, useCallback } from 'react';

import { useExpirationText } from './useExpirationText';
import MarkdownText from '../MarkdownText';

type UserStatusTextProps = {
statusText?: string;
statusExpiresAt?: Date | string;
showExpiration?: boolean;
};

const UserStatusText = ({ statusText, statusExpiresAt, showExpiration: showExpirationProp }: UserStatusTextProps): ReactElement | null => {
const expirationText = useExpirationText(statusExpiresAt);
const hasValidExpiration = expirationText != null;
const showExpiration = showExpirationProp ?? hasValidExpiration;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const showExpiration = showExpirationProp ?? hasValidExpiration;
const showExpiration = showExpirationProp && hasValidExpiration;


const ref = useRef<HTMLDivElement>(null);
const openTooltip = useTooltipOpen();
const closeTooltip = useTooltipClose();

const handleMouseEnter = useCallback(() => {
if (!ref.current || !hasValidExpiration) {
return;
}
openTooltip(<Box fontScale='p2'>{expirationText}</Box>, ref.current);
}, [hasValidExpiration, expirationText, openTooltip]);

if (!statusText) {
return null;
}

return (
<Box ref={ref} data-tooltip='' onMouseEnter={handleMouseEnter} onMouseLeave={closeTooltip}>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<Box ref={ref} data-tooltip='' onMouseEnter={handleMouseEnter} onMouseLeave={closeTooltip}>
<Box ref={ref} data-tooltip='' onMouseEnter={handleMouseEnter} onMouseLeave={closeTooltip} withTruncatedText>

<MarkdownText content={statusText} parseEmoji={true} variant='inlineWithoutBreaks' withTruncatedText />
{showExpiration && hasValidExpiration && <Box color='secondary-info'>{expirationText}</Box>}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
{showExpiration && hasValidExpiration && <Box color='secondary-info'>{expirationText}</Box>}
{showExpiration && <Box color='secondary-info'>{expirationText}</Box>}

This way we don't need to check for hasValidExpiration twice, looks cleaner.

</Box>
);
};

export default UserStatusText;
4 changes: 4 additions & 0 deletions apps/meteor/client/components/UserStatusText/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { default as UserStatusText } from './UserStatusText';
export { useStatusTooltip } from './useStatusTooltip';

export { useExpirationText, parseExpiresAt } from './useExpirationText';
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useLanguage } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';

import { useFormatDate } from '../../hooks/useFormatDate';
import { useFormatTime } from '../../hooks/useFormatTime';

// Handles Date, ISO string, and EJSON { $date } (from DDP streamer which does raw JSON.parse without EJSON deserialization)
export function parseExpiresAt(value?: unknown): Date | undefined {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why exporting this helper? Doesn't seems to be used anywhere else.

if (!value) {
return undefined;
}

if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? undefined : value;
}

if (typeof value === 'object' && '$date' in (value as Record<string, unknown>)) {
const date = new Date((value as { $date: number }).$date);
return Number.isNaN(date.getTime()) ? undefined : date;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (typeof value === 'string') {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date;
}

return undefined;
}

function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}

export function useExpirationText(statusExpiresAt?: Date | string): string | undefined {
const { t } = useTranslation();
const language = useLanguage();
const formatTime = useFormatTime();
const formatDate = useFormatDate();

return useMemo(() => {
const expiresAt = parseExpiresAt(statusExpiresAt);
if (!expiresAt || expiresAt.getTime() <= Date.now()) {
return undefined;
}

const now = new Date();
if (isSameDay(expiresAt, now)) {
return `${t('Until')} ${formatTime(expiresAt)}`;
}

if (expiresAt.getFullYear() === now.getFullYear()) {
return `${t('Until')} ${new Intl.DateTimeFormat(language, { month: 'long', day: 'numeric' }).format(expiresAt)}`;
}

return `${t('Until')} ${formatDate(expiresAt)}`;
}, [statusExpiresAt, t, language, formatTime, formatDate]);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Box, Icon } from '@rocket.chat/fuselage';
import { useTooltipOpen, useTooltipClose } from '@rocket.chat/ui-contexts';
import type { MouseEvent } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';

import { useExpirationText } from './useExpirationText';

const STATUS_LABEL_KEYS: Record<string, string> = {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use the already existing UserStatus enum?

online: 'Online',
away: 'Away',
busy: 'Busy',
offline: 'Offline',
};

export function useStatusTooltip(statusText?: string, statusExpiresAt?: Date | string, status?: string) {
const { t } = useTranslation();
const expirationText = useExpirationText(statusExpiresAt);
const openTooltip = useTooltipOpen();
const closeTooltip = useTooltipClose();

const handleMouseEnter = useCallback(
(e: MouseEvent<HTMLElement>) => {
if (!statusText) {
return;
}
const statusLabelKey = status ? STATUS_LABEL_KEYS[status] : undefined;
const headline = statusLabelKey ? `${t(statusLabelKey)} - ${statusText}` : statusText;
openTooltip(
<Box>
<Box fontScale='p2'>{headline}</Box>
{expirationText && (
<Box color='secondary-info' display='flex' alignItems='center'>
<Icon name='clock' size='x16' mie={4} />
{expirationText}
</Box>
)}
</Box>,
e.currentTarget,
);
},
[statusText, status, expirationText, openTooltip, t],
);

return {
hasStatusText: !!statusText,
handleMouseEnter,
handleMouseLeave: closeTooltip,
};
}
50 changes: 50 additions & 0 deletions apps/meteor/client/lib/presence.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,54 @@ describe('Presence fallback status', () => {

expect(Presence.store.get('user1')?.status).toBe(UserStatus.OFFLINE);
});

it('should preserve statusSource and statusExpiresAt from REST response', async () => {
const expiresAt = new Date(Date.now() + 3600_000);
mockGet.mockResolvedValue({
users: [
{
_id: 'user1',
username: 'testuser',
status: UserStatus.BUSY,
statusText: 'focus time',
statusSource: 'manual',
statusExpiresAt: expiresAt.toISOString(),
},
],
});
Presence.setStatus('enabled');

Presence.listen('user1', jest.fn());
await jest.advanceTimersByTimeAsync(500);

const stored = Presence.store.get('user1');
expect(stored?.status).toBe(UserStatus.BUSY);
expect(stored?.statusText).toBe('focus time');
expect(stored?.statusSource).toBe('manual');
expect(stored?.statusExpiresAt).toEqual(expiresAt);
});

it('should merge statusSource and statusExpiresAt from notify into existing store entry', async () => {
mockGet.mockResolvedValue({ users: [] });
Presence.setStatus('enabled');

Presence.listen('user1', jest.fn());
await jest.advanceTimersByTimeAsync(500);

const expiresAt = new Date(Date.now() + 1800_000);
Presence.notify({
_id: 'user1',
username: 'testuser',
status: UserStatus.BUSY,
statusText: 'in a meeting',
statusSource: 'manual',
statusExpiresAt: expiresAt,
});

const stored = Presence.store.get('user1');
expect(stored?.status).toBe(UserStatus.BUSY);
expect(stored?.statusText).toBe('in a meeting');
expect(stored?.statusSource).toBe('manual');
expect(stored?.statusExpiresAt).toEqual(expiresAt);
});
});
Loading
Loading