Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
48386f2
feat: status expiration backend and API
ricardogarim May 15, 2026
a78f260
fix: keep UserPresence type unchanged for backend PR
ricardogarim May 15, 2026
757f3a5
remove unused i18n keys
ricardogarim Jun 9, 2026
2939062
update changeset and use test adminUsername helper
ricardogarim Jun 8, 2026
1bb19e5
feat: status expiration backend and API
ricardogarim May 15, 2026
89a3b29
feat: presence sync engine UI
ricardogarim Jun 5, 2026
9ac5e32
render DM partner status only in RoomMemberStatus
ricardogarim Jun 9, 2026
1d2bbea
fix: lighten profile form divider color to stroke-extra-light
ricardogarim Jun 10, 2026
9dd6ad2
fix: move status dot selector to left of status text on profile form
ricardogarim Jun 10, 2026
6e513f8
fix: correct status defaults in edit custom status modal
ricardogarim Jun 11, 2026
1dd5d35
feat: show call-aware warning in edit custom status modal
ricardogarim Jun 11, 2026
4ab0a96
chore: remove unused status i18n keys
ricardogarim Jun 11, 2026
1b31d64
change addon to startAddon on presence dot
ricardogarim Jun 16, 2026
c9ba093
fix: only show away in edit custom status modal when it is the default
ricardogarim Jun 16, 2026
94d74ff
use isTruthy instead of Boolean in UserStatusText filter
ricardogarim Jun 17, 2026
6411ae8
build UserStatusText tooltip lazily on hover
ricardogarim Jun 17, 2026
f015452
refactor: memoize useStatusItems menu for stable reference
ricardogarim Jun 17, 2026
74414f8
refactor: return stable handlers object from useUserStatusTooltip
ricardogarim Jun 17, 2026
93ac35f
align custom menu item presence dot
ricardogarim Jun 17, 2026
9310228
fix: XSS in sidebar message preview via unescaped sender name
ricardogarim Jun 17, 2026
9908c27
rename user status menu action "Custom Status" to "Custom..."
ricardogarim Jun 18, 2026
d1935e6
resolve userId once for sidebar RoomList instead of per-row
ricardogarim Jun 18, 2026
c29b456
chore: replace `title` by `aria-label` in UserCard
dougfabris Jun 18, 2026
5512377
chore: remove unnecessary `div` in UserStatusText
dougfabris Jun 18, 2026
0899ea8
fix: UserStatusMenu size variant
dougfabris Jun 18, 2026
b19a934
fix: remove unnecessary timeout and improve useUserStatusTooltip to h…
dougfabris Jun 18, 2026
014779e
chore: remove onChange validation mode from EditStatusModal
ricardogarim Jun 18, 2026
8cbe1a2
chore: move status expiration validation to field-level rules
ricardogarim Jun 18, 2026
3e7cb33
chore: use fuselage-forms for EditStatusModal and AccountProfileForm …
ricardogarim Jun 18, 2026
f9e7758
chore: use date-fns isSameDay in useExpirationText
ricardogarim Jun 18, 2026
acfffeb
fix: pass missing title arg to useUserStatusTooltip in RoomMembersItem
ricardogarim Jun 18, 2026
ebe390d
fix: title shouldn't be required in useUserStatusTooltip
dougfabris Jun 19, 2026
6fcf67b
chore: revamp EditStatusModal
dougfabris Jun 19, 2026
55d0dd1
chore: remove unnecessary return types
dougfabris Jun 19, 2026
da9d130
chore: remove unnecessary style in Divider
dougfabris Jun 19, 2026
e8e2054
chore: use i18n interpolation for expiration text
dougfabris Jun 19, 2026
40c77de
fix: show status label as fallback when custom status has no text
ricardogarim Jun 19, 2026
c9690a3
fix: prevent setting expiration on online status without message
ricardogarim Jun 19, 2026
195092b
fix: merge user initial status values
dougfabris Jun 19, 2026
e7f3291
test: adjust to new disabled rules
ricardogarim Jun 19, 2026
bd244e1
feat: presence sync engine integrations (#40557)
ricardogarim Jun 19, 2026
421bb37
chore: presence sync engine findings (#41035)
ricardogarim Jun 23, 2026
ae788cf
refactor: relocate composer autocomplete to shared AutocompletePopup
ricardogarim Jun 23, 2026
3bfb86c
refactor: decouple autocomplete engine via EditableTextAdapter
ricardogarim Jun 23, 2026
d93460d
feat: emoji autocomplete in the edit custom status modal
ricardogarim Jun 23, 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
20 changes: 8 additions & 12 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 @@ -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 @@ -2010,6 +2004,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',
});
}

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 @@ -2029,10 +2029,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
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ function ComposerBoxPopup<
>({ title, items, focused, select, renderItem = ({ item }: { item: T }) => <>{JSON.stringify(item)}</> }: ComposerBoxPopupProps<T>) {
const { t } = useTranslation();
const id = useId();
const composerBoxPopupRef = useRef<HTMLElement>(null);
const popupSizes = useContentBoxSize(composerBoxPopupRef);
const popupRef = useRef<HTMLElement>(null);
const popupSizes = useContentBoxSize(popupRef);

const variant = popupSizes && popupSizes.inlineSize < 480 ? 'small' : 'large';

Expand All @@ -52,6 +52,8 @@ function ComposerBoxPopup<
if (item.disabled) {
return t('Unavailable_in_encrypted_channels');
}

return undefined;
};

const itemsFlat = useMemo(
Expand Down Expand Up @@ -80,7 +82,7 @@ function ComposerBoxPopup<

return (
<Box position='relative'>
<Tile ref={composerBoxPopupRef} padding={0} role='menu' mbe={8} overflow='hidden' aria-labelledby={id} name='ComposerBoxPopup'>
<Tile ref={popupRef} padding={0} role='menu' mbe={8} overflow='hidden' aria-labelledby={id} name='ComposerBoxPopup'>
{title && (
<Box bg='tint' pi={16} pb={8} id={id}>
{title}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { ReactNode } from 'react';

export type ComposerPopupOption<T extends { _id: string; sort?: number } = { _id: string; sort?: number }> = {
title?: string;
getItemsFromLocal?: (filter: any) => Promise<T[]>;
getItemsFromServer?: (filter: any) => Promise<T[]>;
blurOnSelectItem?: boolean;
closeOnEsc?: boolean;

trigger?: string;
triggerAnywhere?: boolean;
triggerLength?: number;

suffix?: string;
prefix?: string;

matchSelectorRegex?: RegExp;
preview?: boolean;
enablePreviewQuery?: (filter: unknown) => boolean;

getValue: (item: T) => string;

renderItem?: ({ item }: { item: T }) => ReactNode;
disabled?: boolean;
};

export const createMessageBoxPopupConfig = <T extends { _id: string; sort?: number }>(
partial: Omit<ComposerPopupOption<T>, 'getValue'> & Partial<Pick<ComposerPopupOption<T>, 'getValue'>>,
): ComposerPopupOption<T> => {
return {
blurOnSelectItem: true,
closeOnEsc: true,
triggerAnywhere: true,
suffix: ' ',
prefix: partial.prefix ?? partial.trigger ?? ' ',
getValue: (item) => item._id,
...partial,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export type EditableTextAdapter = {
textBeforeCaret(): string;
caret(): number;
replaceRange(text: string, start: number, end: number): void;
};

type InputLike = HTMLInputElement | HTMLTextAreaElement;

// the prototype's native setter is what makes React's onChange fire on a programmatic edit
const setNativeValue = (input: InputLike, value: string) => {
const proto = input instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
setter?.call(input, value);
};

export const fromInputElement = (getInput: () => InputLike | null): EditableTextAdapter => ({
textBeforeCaret: () => {
const input = getInput();
return input ? input.value.substring(0, input.selectionStart ?? input.value.length) : '';
},
caret: () => getInput()?.selectionStart ?? 0,
replaceRange: (text, start, end) => {
const input = getInput();
if (!input) {
return;
}
const nextValue = input.value.slice(0, start) + text + input.value.slice(end);
const caret = start + text.length;
input.focus();
setNativeValue(input, nextValue);
input.setSelectionRange(caret, caret);
input.dispatchEvent(new Event('input', { bubbles: true }));
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { act, fireEvent, renderHook, waitFor } from '@testing-library/react';

import { useComposerBoxPopup } from './useComposerBoxPopup';
import { createMessageBoxPopupConfig } from '../ComposerPopupOption';
import type { EditableTextAdapter } from '../editableTextAdapter';

const keys = { ENTER: 13, ESC: 27, ARROW_UP: 38, ARROW_DOWN: 40 };

type Item = { _id: string; username?: string };

const createComposer = (text: string, caret = text.length) => ({
substring: (start: number, end?: number) => text.substring(start, end),
selection: { start: caret, end: caret },
replaceText: jest.fn(),
});

const adapterFor = (composer: ReturnType<typeof createComposer>): EditableTextAdapter => ({
textBeforeCaret: () => composer.substring(0, composer.selection.start),
caret: () => composer.selection.start,
replaceRange: (text, start, end) => composer.replaceText(text, { start, end }),
});

const userOption = createMessageBoxPopupConfig<Item>({
trigger: '@',
getItemsFromLocal: async () => [
{ _id: 'u1', username: 'alice' },
{ _id: 'u2', username: 'bob' },
],
getItemsFromServer: async () => [],
getValue: (item) => item.username ?? '',
});

const emojiOption = createMessageBoxPopupConfig<Item>({
trigger: ':',
triggerLength: 2,
getItemsFromLocal: async (filter: string) => [{ _id: `:${filter}a:` }, { _id: `:${filter}b:` }],
getItemsFromServer: async () => [],
getValue: (item) => item._id.substring(1),
});

const commandOption = createMessageBoxPopupConfig<Item>({
trigger: '/',
triggerAnywhere: false,
getItemsFromLocal: async () => [{ _id: 'archive' }],
getItemsFromServer: async () => [],
getValue: (item) => item._id,
});

const options = [userOption, emojiOption, commandOption];

const setup = (text: string, caret = text.length) => {
const composer = createComposer(text, caret);
const node = document.createElement('textarea');

const { result } = renderHook(() => useComposerBoxPopup(options, adapterFor(composer)), {
wrapper: mockAppRoot().build(),
});
act(() => result.current.callbackRef(node));

const fire = (type: 'keyUp' | 'keyDown', which: number) => fireEvent[type](node, { which, keyCode: which });

return { result, composer, fire };
};

describe('useComposerBoxPopup (characterization)', () => {
it('opens no popup when the text has no trigger', () => {
const { result, fire } = setup('hello');
fire('keyUp', 0);
expect(result.current.option).toBeUndefined();
});

it('detects the `@` trigger and extracts the filter', () => {
const { result, fire } = setup('hi @al');
fire('keyUp', 0);
expect(result.current.option?.trigger).toBe('@');
expect(result.current.filter).toBe('al');
});

it('respects triggerLength: `:` alone does not open, `:ab` does', () => {
const closed = setup('x :');
closed.fire('keyUp', 0);
expect(closed.result.current.option).toBeUndefined();

const open = setup('x :ab');
open.fire('keyUp', 0);
expect(open.result.current.option?.trigger).toBe(':');
expect(open.result.current.filter).toBe('ab');
});

it('only triggers a start-only (`/`) option at the very start of the text', () => {
const mid = setup('hey /arch');
mid.fire('keyUp', 0);
expect(mid.result.current.option).toBeUndefined();

const start = setup('/arch');
start.fire('keyUp', 0);
expect(start.result.current.option?.trigger).toBe('/');
expect(start.result.current.filter).toBe('arch');
});

it('inserts prefix + value + suffix over the trigger range on select', async () => {
const { result, composer, fire } = setup('hi @al');
fire('keyUp', 0);
await waitFor(() => expect(result.current.focused).toBeDefined());

act(() => result.current.select?.({ _id: 'u1', username: 'alice' }));

expect(composer.replaceText).toHaveBeenCalledWith('@alice ', { start: 3, end: 6 });
});

it('moves focus with arrow keys and wraps around', async () => {
const { result, fire } = setup('hi @');
fire('keyUp', 0);
await waitFor(() => expect(result.current.focused?._id).toBe('u1'));

fire('keyDown', keys.ARROW_DOWN);
expect(result.current.focused?._id).toBe('u2');

fire('keyDown', keys.ARROW_DOWN);
expect(result.current.focused?._id).toBe('u1');

fire('keyDown', keys.ARROW_UP);
expect(result.current.focused?._id).toBe('u2');
});

it('selects the focused item on Enter', async () => {
const { result, composer, fire } = setup('hi @');
fire('keyUp', 0);
await waitFor(() => expect(result.current.focused?._id).toBe('u1'));

fire('keyDown', keys.ENTER);
expect(composer.replaceText).toHaveBeenCalledWith('@alice ', expect.objectContaining({ end: 4 }));
});

it('closes the popup on Escape', async () => {
const { result, fire } = setup('hi @al');
fire('keyUp', 0);
await waitFor(() => expect(result.current.option?.trigger).toBe('@'));

fire('keyUp', keys.ESC);
expect(result.current.option).toBeUndefined();
});
});
Loading
Loading