Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
af9639d
feat: status expiration backend and API
ricardogarim May 15, 2026
265f543
fix: keep UserPresence type unchanged for backend PR
ricardogarim May 15, 2026
95f1d2b
remove unused i18n keys
ricardogarim Jun 9, 2026
6768bcb
update changeset and use test adminUsername helper
ricardogarim Jun 8, 2026
173a068
feat: status expiration backend and API
ricardogarim May 15, 2026
2691c3f
feat: presence sync engine UI
ricardogarim Jun 5, 2026
4b0b818
render DM partner status only in RoomMemberStatus
ricardogarim Jun 9, 2026
cd53953
fix: lighten profile form divider color to stroke-extra-light
ricardogarim Jun 10, 2026
b1c3261
fix: move status dot selector to left of status text on profile form
ricardogarim Jun 10, 2026
d8f941c
fix: correct status defaults in edit custom status modal
ricardogarim Jun 11, 2026
062c3d2
feat: show call-aware warning in edit custom status modal
ricardogarim Jun 11, 2026
8844c0a
chore: remove unused status i18n keys
ricardogarim Jun 11, 2026
cdb4981
change addon to startAddon on presence dot
ricardogarim Jun 16, 2026
f65a4f4
fix: only show away in edit custom status modal when it is the default
ricardogarim Jun 16, 2026
d18800b
use isTruthy instead of Boolean in UserStatusText filter
ricardogarim Jun 17, 2026
6e690dd
build UserStatusText tooltip lazily on hover
ricardogarim Jun 17, 2026
703913e
refactor: memoize useStatusItems menu for stable reference
ricardogarim Jun 17, 2026
ef082fa
refactor: return stable handlers object from useUserStatusTooltip
ricardogarim Jun 17, 2026
b84daa5
align custom menu item presence dot
ricardogarim Jun 17, 2026
8c8abcf
fix: XSS in sidebar message preview via unescaped sender name
ricardogarim Jun 17, 2026
48fd39a
rename user status menu action "Custom Status" to "Custom..."
ricardogarim Jun 18, 2026
1fa9f65
resolve userId once for sidebar RoomList instead of per-row
ricardogarim Jun 18, 2026
f903b2b
chore: replace `title` by `aria-label` in UserCard
dougfabris Jun 18, 2026
aa76ba8
chore: remove unnecessary `div` in UserStatusText
dougfabris Jun 18, 2026
f34a6b2
fix: UserStatusMenu size variant
dougfabris Jun 18, 2026
2cca3bd
fix: remove unnecessary timeout and improve useUserStatusTooltip to h…
dougfabris Jun 18, 2026
c98bfae
chore: remove onChange validation mode from EditStatusModal
ricardogarim Jun 18, 2026
f33ace5
chore: move status expiration validation to field-level rules
ricardogarim Jun 18, 2026
96d0936
chore: use fuselage-forms for EditStatusModal and AccountProfileForm …
ricardogarim Jun 18, 2026
ef5b848
chore: use date-fns isSameDay in useExpirationText
ricardogarim Jun 18, 2026
61e32ad
fix: pass missing title arg to useUserStatusTooltip in RoomMembersItem
ricardogarim Jun 18, 2026
0a3ce0b
fix: title shouldn't be required in useUserStatusTooltip
dougfabris Jun 19, 2026
2c82883
chore: revamp EditStatusModal
dougfabris Jun 19, 2026
cbd9f9d
chore: remove unnecessary return types
dougfabris Jun 19, 2026
7c3c466
chore: remove unnecessary style in Divider
dougfabris Jun 19, 2026
657684b
chore: use i18n interpolation for expiration text
dougfabris Jun 19, 2026
340d5ed
fix: show status label as fallback when custom status has no text
ricardogarim Jun 19, 2026
85fe433
fix: prevent setting expiration on online status without message
ricardogarim Jun 19, 2026
59828eb
fix: merge user initial status values
dougfabris Jun 19, 2026
23d15f6
test: adjust to new disabled rules
ricardogarim Jun 19, 2026
fb88b13
feat: presence sync engine integrations (#40557)
ricardogarim Jun 19, 2026
38f4456
chore: remove video conference presence integration
ricardogarim Jun 26, 2026
d998135
chore: presence sync engine findings (#41035)
ricardogarim Jun 23, 2026
4a5573f
chore: show Outlook prefix for calendar busy status (#41053)
ricardogarim Jun 23, 2026
da92feb
fix: show reactive status text on user card and user info (#41052)
ricardogarim Jun 23, 2026
5bea13b
fix: shared validateStatusExpiration for account profile and edit sta…
dougfabris Jun 24, 2026
a4a1071
test: use alert role for edit status modal error locators
ricardogarim Jun 26, 2026
dba082e
fix: show offline users' custom status text and expiration (#41087)
ricardogarim Jun 26, 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
22 changes: 9 additions & 13 deletions apps/meteor/app/api/server/v1/users.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MeteorError, Presence, Team, Calendar } from '@rocket.chat/core-services';
import { MeteorError, Presence, Team } from '@rocket.chat/core-services';
import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings';
import { Users, Subscriptions, Sessions, OAuthAccessTokens, OAuthRefreshTokens, OAuthAuthCodes } from '@rocket.chat/models';
import {
Expand Down Expand Up @@ -29,7 +29,7 @@ import {
validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import { getLoginExpirationInMs, wrapExceptions } from '@rocket.chat/tools';
import { getLoginExpirationInMs } from '@rocket.chat/tools';
import { Accounts } from 'meteor/accounts-base';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
Expand Down Expand Up @@ -1546,7 +1546,7 @@ API.v1.get(

if (ids) {
return API.v1.success({
users: await Users.findNotOfflineByIds(Array.isArray(ids) ? ids : ids.split(','), options).toArray(),
users: await Users.findPresenceUsersByIds(Array.isArray(ids) ? ids : ids.split(','), options).toArray(),
full: false,
});
}
Expand Down Expand Up @@ -1982,12 +1982,6 @@ API.v1
),
);

if (!settings.get('Accounts_AllowUserStatusMessageChange')) {
throw new Meteor.Error('error-not-allowed', 'Change status is not allowed', {
method: 'users.setStatus',
});
}

const user = await (async () => {
if (isUserFromParams(this.bodyParams, this.userId, this.user)) {
return Users.findOneById(this.userId);
Expand All @@ -2003,6 +1997,12 @@ API.v1

const { status, message, expiresAt } = this.bodyParams;

if (message && !settings.get('Accounts_AllowUserStatusMessageChange')) {
throw new Meteor.Error('error-not-allowed', 'Change status is not allowed', {
method: 'users.setStatus',
});
}
Comment thread
ricardogarim marked this conversation as resolved.

const statusExpiresAt = expiresAt ? new Date(expiresAt) : undefined;
if (statusExpiresAt && Number.isNaN(statusExpiresAt.getTime())) {
throw new Meteor.Error('error-invalid-date', 'Invalid expiresAt date string', {
Expand All @@ -2028,10 +2028,6 @@ API.v1

await Presence.setStatus(user._id, effectiveStatus, message, statusExpiresAt);

if (status) {
void wrapExceptions(() => Calendar.cancelUpcomingStatusChanges(user._id)).suppress();
}

return API.v1.success();
},
)
Expand Down
7 changes: 4 additions & 3 deletions apps/meteor/app/apps/server/bridges/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ export class AppUserBridge extends UserBridge {

protected async setActiveState(
userId: IUser['id'],
state: Pick<IUser, 'statusDefault' | 'statusSource' | 'statusText' | 'statusExpiresAt'>,
state: Pick<IUser, 'statusDefault' | 'statusSource' | 'statusText' | 'statusExpiresAt' | 'statusId'>,
appId: string,
): Promise<void> {
this.orch.debugLog(`The App ${appId} is setting active state for user ${userId}`);
Expand All @@ -191,13 +191,14 @@ export class AppUserBridge extends UserBridge {
statusText: state.statusText,
statusSource: state.statusSource as PresenceSource,
...(state.statusExpiresAt && { statusExpiresAt: state.statusExpiresAt }),
...(state.statusId && { statusId: state.statusId }),
});
}

protected async endActiveState(userId: IUser['id'], appId: string): Promise<void> {
protected async endActiveState(userId: IUser['id'], appId: string, statusId?: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is ending active state for user ${userId}`);

await Presence.endActiveState(userId);
await Presence.endActiveState(userId, statusId);
}

protected async getActiveUserCount(): Promise<number> {
Expand Down
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 @@ -49,7 +49,7 @@ const UserCard = ({
const isLayoutEmbedded = useEmbeddedLayout();

return (
<UserCardDialog title={t('User_card')} {...props}>
<UserCardDialog aria-label={t('User_card')} {...props}>
<div>
{username && <UserAvatar username={username} etag={etag} size='x124' />}
<Box flexGrow={0} display='flex' mbs={12} alignItems='center' justifyContent='center'>
Expand Down
5 changes: 3 additions & 2 deletions apps/meteor/client/components/UserInfo/UserInfo.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { ContextualbarDialog } from '@rocket.chat/ui-client';
import type { Meta, StoryFn } from '@storybook/react';

import * as Status from '../UserStatus';
import UserInfo from './UserInfo';
import { UserCardRole } from '../UserCard';
import * as Status from '../UserStatus';
import { UserStatusText } from '../UserStatusText';

export default {
component: UserInfo,
Expand All @@ -24,10 +25,10 @@ const defaultArgs = {
name: 'Guilherme Gazzo',
username: 'guilherme.gazzo',
nickname: 'gazzo',
statusText: '🛴 currently working on User Card',
bio: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla tempus, eros convallis vulputate cursus, nisi neque eleifend libero, eget lacinia justo purus nec est. In at sodales ipsum. Sed lacinia quis purus eget pulvinar. Aenean eu pretium nunc, at aliquam magna. Praesent dignissim, tortor sed volutpat mattis, mauris diam pulvinar leo, porta commodo risus est non purus.',
email: 'rocketchat@rocket.chat',
status: <Status.Offline />,
customStatus: <UserStatusText statusText='🛴 currently working on User Card' />,
roles: [<UserCardRole key='admin'>admin</UserCardRole>, <UserCardRole key='user'>user</UserCardRole>],
};

Expand Down
10 changes: 3 additions & 7 deletions apps/meteor/client/components/UserInfo/UserInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ type UserInfoDataProps = Serialized<
| 'utcOffset'
| 'phone'
| 'createdAt'
| 'statusText'
| 'canViewAllInfo'
| 'customFields'
| 'freeSwitchExtension'
Expand All @@ -47,6 +46,7 @@ type UserInfoDataProps = Serialized<

type UserInfoProps = UserInfoDataProps & {
status: ReactNode;
customStatus?: ReactNode;
email?: string;
verified?: boolean;
actions: ReactNode;
Expand All @@ -69,7 +69,7 @@ const UserInfo = ({
verified,
createdAt,
status,
statusText,
customStatus,
customFields,
canViewAllInfo,
actions,
Expand Down Expand Up @@ -100,11 +100,7 @@ const UserInfo = ({
<InfoPanelSection>
{userDisplayName && <InfoPanelTitle icon={status} title={userDisplayName} />}

{statusText && (
<InfoPanelText>
<MarkdownText content={statusText} parseEmoji={true} variant='inline' />
</InfoPanelText>
)}
{customStatus && <InfoPanelText>{customStatus}</InfoPanelText>}
Comment thread
ricardogarim marked this conversation as resolved.
</InfoPanelSection>

<InfoPanelSection>
Expand Down
16 changes: 9 additions & 7 deletions apps/meteor/client/components/UserStatusMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,17 @@ import { useTranslation } from 'react-i18next';
import { UserStatus } from './UserStatus';

type UserStatusMenuProps = {
margin: ComponentProps<typeof Box>['margin'];
onChange: (type: UserStatusType) => void;
initialStatus?: UserStatusType;
optionWidth?: ComponentProps<typeof Box>['width'];
placement?: ComponentProps<typeof PositionAnimated>['placement'];
};

const UserStatusMenu = ({
margin,
onChange,
initialStatus = UserStatusType.OFFLINE,
optionWidth = undefined,
placement = 'bottom-end',
placement = 'bottom-start',
}: UserStatusMenuProps) => {
const { t } = useTranslation();
const [status, setStatus] = useState(initialStatus);
Expand All @@ -39,16 +37,21 @@ const UserStatusMenu = ({

const statuses: Array<[value: UserStatusType, label: ReactNode]> = [
[UserStatusType.ONLINE, renderOption(UserStatusType.ONLINE, t('Online'))],
[UserStatusType.AWAY, renderOption(UserStatusType.AWAY, t('Away'))],
[UserStatusType.BUSY, renderOption(UserStatusType.BUSY, t('Busy'))],
];

// Away is no longer manually selectable, but surface it if the user is currently on it
// (e.g., set in a previous version or auto-applied by the server) so they can switch off.
if (status === UserStatusType.AWAY) {
statuses.push([UserStatusType.AWAY, renderOption(UserStatusType.AWAY, t('Away'))]);
}

if (allowInvisibleStatus) {
statuses.push([UserStatusType.OFFLINE, renderOption(UserStatusType.OFFLINE, t('Offline'))]);
}

return statuses;
}, [t, allowInvisibleStatus]);
}, [t, allowInvisibleStatus, status]);

const [cursor, handleKeyDown, handleKeyUp, reset, [visible, hide, show]] = useCursor(-1, options, ([selected], [, hide]) => {
setStatus(selected);
Expand Down Expand Up @@ -82,14 +85,13 @@ const UserStatusMenu = ({
<>
<Button
ref={ref}
small
mini
square
secondary
onClick={onClick}
onBlur={hide}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
margin={margin}
aria-label={t('User_status_menu')}
>
<UserStatus status={status} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { IUser } from '@rocket.chat/core-typings';
import { useUserPresence } from '@rocket.chat/ui-contexts';
import { memo } from 'react';

import UserStatusText from './UserStatusText';

type ReactiveUserStatusTextProps = {
uid: IUser['_id'];
};

const ReactiveUserStatusText = ({ uid }: ReactiveUserStatusTextProps) => {
const presence = useUserPresence(uid);
return <UserStatusText status={presence?.status} statusText={presence?.statusText} statusExpiresAt={presence?.statusExpiresAt} />;
};

export default memo(ReactiveUserStatusText);
47 changes: 47 additions & 0 deletions apps/meteor/client/components/UserStatusText/UserStatusText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { UserStatus } from '@rocket.chat/core-typings';
import { Box, Icon } from '@rocket.chat/fuselage';
import { isTruthy } from '@rocket.chat/tools';
import { useTranslation } from 'react-i18next';

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

const STATUS_LABEL_KEYS: Record<UserStatus, string> = {
[UserStatus.ONLINE]: 'Online',
[UserStatus.AWAY]: 'Away',
[UserStatus.BUSY]: 'Busy',
[UserStatus.OFFLINE]: 'Offline',
[UserStatus.DISABLED]: 'Disabled',
};

type UserStatusTextProps = {
status?: UserStatus;
statusText?: string;
statusExpiresAt?: Date | string;
};

const UserStatusText = ({ status, statusText, statusExpiresAt }: UserStatusTextProps) => {
const { t } = useTranslation();
const expirationText = useExpirationText(statusExpiresAt);

const statusLabel = status ? t(STATUS_LABEL_KEYS[status] ?? STATUS_LABEL_KEYS[UserStatus.OFFLINE]) : undefined;
const headline = [statusLabel, statusText].filter(isTruthy).join(' - ');

if (!headline && !expirationText) {
return null;
}

return (
<>
{headline && <MarkdownText content={headline} parseEmoji={true} variant='inline' />}
{expirationText && (
<Box fontScale='c1' display='flex' alignItems='center'>
<Icon name='clock' size='x16' mie={4} />
{expirationText}
</Box>
)}
</>
);
};

export default UserStatusText;
2 changes: 2 additions & 0 deletions apps/meteor/client/components/UserStatusText/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as UserStatusText } from './UserStatusText';
export { default as ReactiveUserStatusText } from './ReactiveUserStatusText';
55 changes: 55 additions & 0 deletions apps/meteor/client/hooks/useExpirationText.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useLanguage } from '@rocket.chat/ui-contexts';
import { isSameDay } from 'date-fns';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';

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

// Handles Date, ISO string, and EJSON { $date } (from DDP streamer which does raw JSON.parse without EJSON deserialization)
function parseExpiresAt(value?: unknown): Date | undefined {
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;
}

export function useExpirationText(statusExpiresAt?: Date | string) {
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_time', { time: formatTime(expiresAt) });
}

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

return t('Until_date', { date: formatDate(expiresAt) });
}, [statusExpiresAt, t, language, formatTime, formatDate]);
}
11 changes: 9 additions & 2 deletions apps/meteor/client/hooks/useUserPresenceListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,15 @@ export const useUserPresenceListener = (): void => {

useEffect(
() =>
subscribe((uid, [[username, statusChanged, statusText]]) => {
Presence.notify({ _id: uid, username, status: STATUS_MAP[statusChanged as any], statusText });
subscribe((uid, [[username, statusChanged, statusText, statusSource, statusExpiresAt]]) => {
Presence.notify({
_id: uid,
username,
status: STATUS_MAP[statusChanged as any],
statusText,
statusSource,
statusExpiresAt,
});
}),
[subscribe],
);
Expand Down
30 changes: 30 additions & 0 deletions apps/meteor/client/hooks/useUserStatusTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useTooltipClose, useTooltipOpen, useUserPresence } from '@rocket.chat/ui-contexts';
import type { MouseEvent } from 'react';
import { useCallback, useMemo } from 'react';

import { UserStatusText } from '../components/UserStatusText';

export function useUserStatusTooltip(uid: string | undefined, title?: string) {
const presence = useUserPresence(uid);

const openTooltip = useTooltipOpen();
const closeTooltip = useTooltipClose();

const onMouseEnter = useCallback(
(e: MouseEvent<HTMLElement>) => {
const target = e.currentTarget;

if (!uid) {
return openTooltip(title, target);
}

openTooltip(
<UserStatusText status={presence?.status} statusText={presence?.statusText} statusExpiresAt={presence?.statusExpiresAt} />,
target,
);
},
[uid, openTooltip, presence, title],
);
Comment thread
ricardogarim marked this conversation as resolved.

return useMemo(() => ({ 'data-tooltip': '', onMouseEnter, 'onMouseLeave': closeTooltip }), [onMouseEnter, closeTooltip]);
}
Loading
Loading