Skip to content

Commit 613c332

Browse files
committed
x
1 parent 2632182 commit 613c332

18 files changed

Lines changed: 882 additions & 324 deletions

File tree

apps/meteor/app/api/server/helpers/getUserFromParams.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,16 @@ export async function getUserFromParams<T extends boolean = false>(
1010
user?: string;
1111
},
1212
full?: T,
13-
): Promise<T extends true ? IUser : Pick<IUser, '_id' | 'username' | 'name' | 'status' | 'statusText' | 'roles'>> {
13+
): Promise<
14+
T extends true
15+
? IUser
16+
: Pick<IUser, '_id' | 'username' | 'name' | 'status' | 'statusText' | 'statusSource' | 'statusEmoji' | 'statusExpiresAt' | 'roles'>
17+
> {
1418
let user;
1519

16-
const projection = full ? {} : { username: 1, name: 1, status: 1, statusText: 1, roles: 1 };
20+
const projection = full
21+
? {}
22+
: { username: 1, name: 1, status: 1, statusText: 1, statusSource: 1, statusEmoji: 1, statusExpiresAt: 1, roles: 1 };
1723
if (params.userId?.trim()) {
1824
user = await Users.findOneById(params.userId, { projection });
1925
} else if (params.username?.trim()) {

apps/meteor/app/api/server/v1/users.ts

Lines changed: 58 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { MeteorError, Team, api, Calendar } from '@rocket.chat/core-services';
2-
import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings';
1+
import { MeteorError, Presence, Team } from '@rocket.chat/core-services';
2+
import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser } from '@rocket.chat/core-typings';
3+
import { UserStatus } from '@rocket.chat/core-typings';
34
import { Users, Subscriptions, Sessions } from '@rocket.chat/models';
45
import {
56
isUserCreateParamsPOST,
@@ -29,7 +30,7 @@ import {
2930
validateForbiddenErrorResponse,
3031
} from '@rocket.chat/rest-typings';
3132
import { escapeRegExp } from '@rocket.chat/string-helpers';
32-
import { getLoginExpirationInMs, wrapExceptions } from '@rocket.chat/tools';
33+
import { getLoginExpirationInMs } from '@rocket.chat/tools';
3334
import { Accounts } from 'meteor/accounts-base';
3435
import { Match, check } from 'meteor/check';
3536
import { Meteor } from 'meteor/meteor';
@@ -66,7 +67,6 @@ import { saveCustomFieldsWithoutValidation } from '../../../lib/server/functions
6667
import { saveUser } from '../../../lib/server/functions/saveUser';
6768
import { sendWelcomeEmail } from '../../../lib/server/functions/saveUser/sendUserEmail';
6869
import { canEditExtension } from '../../../lib/server/functions/saveUser/validateUserEditing';
69-
import { setStatusText } from '../../../lib/server/functions/setStatusText';
7070
import { setUserAvatar } from '../../../lib/server/functions/setUserAvatar';
7171
import { setUsernameWithValidation } from '../../../lib/server/functions/setUsername';
7272
import { validateCustomFields } from '../../../lib/server/functions/validateCustomFields';
@@ -649,7 +649,7 @@ API.v1.addRoute(
649649
if (!canViewFullOtherUserInfo) {
650650
return API.v1.forbidden();
651651
}
652-
const escapedEmail = escapeRegExp(this.queryParams.email as string);
652+
const escapedEmail = escapeRegExp(this.queryParams.email);
653653
nonEmptyQuery['emails.address'] = {
654654
$regex: `^${escapedEmail}$`,
655655
$options: 'i',
@@ -1870,6 +1870,28 @@ API.v1
18701870

18711871
const statusType = { type: 'string', enum: ['online', 'offline', 'away', 'busy'] } as const;
18721872

1873+
const getStatusResponseSchema = ajv.compile<{
1874+
_id: string;
1875+
status: string;
1876+
connectionStatus?: string;
1877+
statusSource?: string;
1878+
statusEmoji?: string;
1879+
statusExpiresAt?: string;
1880+
}>({
1881+
type: 'object',
1882+
properties: {
1883+
_id: { type: 'string' },
1884+
status: statusType,
1885+
connectionStatus: { type: 'string', nullable: true },
1886+
statusSource: { type: 'string', nullable: true },
1887+
statusEmoji: { type: 'string', nullable: true },
1888+
statusExpiresAt: { type: 'string', nullable: true },
1889+
success: { type: 'boolean', enum: [true] },
1890+
},
1891+
required: ['_id', 'status', 'success'],
1892+
additionalProperties: false,
1893+
});
1894+
18731895
API.v1
18741896
.get(
18751897
'users.getPresence',
@@ -1920,6 +1942,7 @@ API.v1
19201942
body: ajv.compile<{
19211943
status?: UserStatus;
19221944
message?: string;
1945+
expiresAt?: string;
19231946
userId?: string;
19241947
username?: string;
19251948
user?: string;
@@ -1928,6 +1951,7 @@ API.v1
19281951
properties: {
19291952
status: { type: 'string', enum: ['online', 'away', 'offline', 'busy'] },
19301953
message: { type: 'string', nullable: true },
1954+
expiresAt: { type: 'string', format: 'date-time', nullable: true },
19311955
userId: { type: 'string' },
19321956
username: { type: 'string' },
19331957
user: { type: 'string' },
@@ -1975,48 +1999,29 @@ API.v1
19751999
return API.v1.forbidden();
19762000
}
19772001

1978-
const { _id, username, roles, name } = user;
1979-
let { statusText, status } = user;
1980-
1981-
if (this.bodyParams.message || this.bodyParams.message === '') {
1982-
await setStatusText(user, this.bodyParams.message, { emit: false });
1983-
statusText = this.bodyParams.message;
2002+
if (this.bodyParams.status === UserStatus.OFFLINE && !settings.get('Accounts_AllowInvisibleStatusOption')) {
2003+
throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', {
2004+
method: 'users.setStatus',
2005+
});
19842006
}
19852007

1986-
if (this.bodyParams.status) {
1987-
const validStatus = ['online', 'away', 'offline', 'busy'];
1988-
if (validStatus.includes(this.bodyParams.status)) {
1989-
status = this.bodyParams.status;
1990-
1991-
if (status === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) {
1992-
throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', {
1993-
method: 'users.setStatus',
1994-
});
1995-
}
1996-
1997-
await Users.updateOne(
1998-
{ _id: user._id },
1999-
{
2000-
$set: {
2001-
status,
2002-
statusDefault: status,
2003-
},
2004-
},
2005-
);
2006-
2007-
void wrapExceptions(() => Calendar.cancelUpcomingStatusChanges(user._id)).suppress();
2008-
} else {
2009-
throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', {
2010-
method: 'users.setStatus',
2011-
});
2012-
}
2008+
const finalStatus = this.bodyParams.status ?? user.status ?? UserStatus.ONLINE;
2009+
const finalText = this.bodyParams.message ?? user.statusText ?? '';
2010+
const expiresAt = this.bodyParams.expiresAt ? new Date(this.bodyParams.expiresAt) : undefined;
2011+
2012+
// "Online" with no text is a status reset — removes the active claim
2013+
// and lets the system manage presence automatically (auto-away, connection-based)
2014+
if (finalStatus === UserStatus.ONLINE && !finalText) {
2015+
await Presence.clearActiveState(user._id);
2016+
} else {
2017+
await Presence.setActiveState(user._id, {
2018+
statusDefault: finalStatus,
2019+
statusText: finalText,
2020+
statusSource: 'manual',
2021+
...(expiresAt && { statusExpiresAt: expiresAt }),
2022+
});
20132023
}
20142024

2015-
void api.broadcast('presence.status', {
2016-
user: { status, _id, username, statusText, roles, name },
2017-
previousStatus: user.status,
2018-
});
2019-
20202025
return API.v1.success();
20212026
},
20222027
)
@@ -2026,17 +2031,7 @@ API.v1
20262031
authRequired: true,
20272032
query: isUsersGetStatusParamsGET,
20282033
response: {
2029-
200: ajv.compile<{ _id: string; status: string; connectionStatus?: string }>({
2030-
type: 'object',
2031-
properties: {
2032-
_id: { type: 'string' },
2033-
status: statusType,
2034-
connectionStatus: { type: 'string', nullable: true },
2035-
success: { type: 'boolean', enum: [true] },
2036-
},
2037-
required: ['_id', 'status', 'success'],
2038-
additionalProperties: false,
2039-
}),
2034+
200: getStatusResponseSchema,
20402035
400: validateBadRequestErrorResponse,
20412036
401: validateUnauthorizedErrorResponse,
20422037
},
@@ -2045,18 +2040,22 @@ API.v1
20452040
if (isUserFromParams(this.queryParams, this.userId, this.user)) {
20462041
return API.v1.success({
20472042
_id: this.userId,
2048-
// message: user.statusText,
2049-
connectionStatus: (this.user.statusConnection || 'offline') as 'online' | 'offline' | 'away' | 'busy',
2050-
status: (this.user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy',
2043+
connectionStatus: this.user.statusConnection || 'offline',
2044+
status: this.user.status || 'offline',
2045+
statusSource: this.user.statusSource,
2046+
statusEmoji: this.user.statusEmoji,
2047+
statusExpiresAt: this.user.statusExpiresAt?.toISOString(),
20512048
});
20522049
}
20532050

20542051
const user = await getUserFromParams(this.queryParams);
20552052

20562053
return API.v1.success({
20572054
_id: user._id,
2058-
// message: user.statusText,
2059-
status: (user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy',
2055+
status: user.status || 'offline',
2056+
statusSource: user.statusSource,
2057+
statusEmoji: user.statusEmoji,
2058+
statusExpiresAt: user.statusExpiresAt?.toISOString(),
20602059
});
20612060
},
20622061
);

apps/meteor/app/lib/server/functions/getFullUserData.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ export const defaultFields = {
1818
bio: 1,
1919
reason: 1,
2020
statusText: 1,
21+
statusEmoji: 1,
22+
statusSource: 1,
23+
statusExpiresAt: 1,
2124
avatarETag: 1,
2225
federated: 1,
2326
statusLivechat: 1,

apps/meteor/app/user-status/server/methods/setUserStatus.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { Presence } from '@rocket.chat/core-services';
22
import type { IUser } from '@rocket.chat/core-typings';
3+
import { UserStatus } from '@rocket.chat/core-typings';
34
import type { ServerMethods } from '@rocket.chat/ddp-client';
45
import { check } from 'meteor/check';
56
import { Meteor } from 'meteor/meteor';
67

78
import { RateLimiter } from '../../../lib/server';
8-
import { setStatusText } from '../../../lib/server/functions/setStatusText';
99
import { settings } from '../../../settings/server';
1010

1111
declare module '@rocket.chat/ddp-client' {
@@ -20,26 +20,37 @@ export const setUserStatusMethod = async (
2020
statusType: IUser['status'],
2121
statusText: IUser['statusText'],
2222
): Promise<void> => {
23-
if (statusType) {
24-
if (statusType === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) {
25-
throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', {
26-
method: 'setUserStatus',
27-
});
28-
}
29-
await Presence.setStatus(user._id, statusType);
23+
if (statusType === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) {
24+
throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', {
25+
method: 'setUserStatus',
26+
});
3027
}
3128

32-
if (statusText || statusText === '') {
29+
if (statusText != null) {
3330
check(statusText, String);
3431

3532
if (!settings.get('Accounts_AllowUserStatusMessageChange')) {
3633
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
3734
method: 'setUserStatus',
3835
});
3936
}
37+
}
38+
39+
const finalStatus = statusType ?? user.status ?? UserStatus.ONLINE;
40+
const finalText = statusText ?? user.statusText ?? '';
4041

41-
await setStatusText(user, statusText);
42+
// "Online" with no text is a status reset — removes the active claim
43+
// and lets the system manage presence automatically (auto-away, connection-based)
44+
if (finalStatus === UserStatus.ONLINE && !finalText) {
45+
await Presence.clearActiveState(user._id);
46+
return;
4247
}
48+
49+
await Presence.setActiveState(user._id, {
50+
statusDefault: finalStatus,
51+
statusText: finalText,
52+
statusSource: 'manual',
53+
});
4354
};
4455

4556
Meteor.methods<ServerMethods>({

apps/meteor/server/services/calendar/service.ts

Lines changed: 3 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import type { ICalendarService } from '@rocket.chat/core-services';
2-
import { ServiceClassInternal, api } from '@rocket.chat/core-services';
2+
import { Presence, ServiceClassInternal, api } from '@rocket.chat/core-services';
33
import type { IUser, ICalendarEvent } from '@rocket.chat/core-typings';
4-
import { UserStatus } from '@rocket.chat/core-typings';
54
import { cronJobs } from '@rocket.chat/cron';
65
import { Logger } from '@rocket.chat/logger';
76
import type { InsertionModel } from '@rocket.chat/model-typings';
8-
import { CalendarEvent, Users } from '@rocket.chat/models';
7+
import { CalendarEvent } from '@rocket.chat/models';
98
import type { UpdateResult, DeleteResult } from 'mongodb';
109

1110
import { applyStatusChange } from './statusEvents/applyStatusChange';
@@ -285,26 +284,10 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
285284
return;
286285
}
287286

288-
const user = await Users.findOneById(event.uid, { projection: { statusDefault: 1 } });
289-
if (!user || user.statusDefault === UserStatus.OFFLINE) {
290-
return;
291-
}
292-
293-
const overlappingEvents = await CalendarEvent.findOverlappingEvents(event._id, event.uid, event.startTime, event.endTime)
294-
.sort({ startTime: -1 })
295-
.toArray();
296-
const previousStatus = overlappingEvents.at(0)?.previousStatus ?? user.statusDefault;
297-
298-
if (previousStatus) {
299-
await CalendarEvent.updateEvent(event._id, { previousStatus });
300-
}
301-
302287
await applyStatusChange({
303288
eventId: event._id,
304289
uid: event.uid,
305-
startTime: event.startTime,
306290
endTime: event.endTime,
307-
status: UserStatus.BUSY,
308291
});
309292
}
310293

@@ -313,31 +296,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
313296
return;
314297
}
315298

316-
const user = await Users.findOneById(event.uid, { projection: { statusDefault: 1 } });
317-
if (!user) {
318-
return;
319-
}
320-
321-
// Only restore status if:
322-
// 1. The current statusDefault is BUSY (meaning it was set by our system, not manually changed by user)
323-
// 2. We have a previousStatus stored from before the event started
324-
325-
if (user.statusDefault === UserStatus.BUSY && event.previousStatus && event.previousStatus !== user.statusDefault) {
326-
await applyStatusChange({
327-
eventId: event._id,
328-
uid: event.uid,
329-
startTime: event.startTime,
330-
endTime: event.endTime,
331-
status: event.previousStatus,
332-
});
333-
} else {
334-
logger.debug({
335-
msg: 'Not restoring status for user',
336-
userId: event.uid,
337-
currentStatusDefault: user.statusDefault,
338-
previousStatus: event.previousStatus,
339-
});
340-
}
299+
await Presence.endActiveState(event.uid);
341300
}
342301

343302
private async sendCurrentNotifications(date: Date): Promise<void> {

0 commit comments

Comments
 (0)