From 6ec8222ec7e6edf97664af1899ce285114ceaf80 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Fri, 15 May 2026 12:55:18 -0300 Subject: [PATCH 01/21] feat: status expiration UI and client --- .../app/notifications/client/lib/Presence.ts | 14 +- .../client/components/UserInfo/UserInfo.tsx | 5 +- .../__snapshots__/UserInfo.spec.tsx.snap | 84 ++++--- .../UserStatusText/UserStatusText.tsx | 47 ++++ .../client/components/UserStatusText/index.ts | 4 + .../UserStatusText/useExpirationText.ts | 48 ++++ .../UserStatusText/useStatusTooltip.tsx | 38 ++++ apps/meteor/client/lib/presence.spec.ts | 50 +++++ apps/meteor/client/lib/presence.ts | 7 +- apps/meteor/client/lib/userData.ts | 5 + apps/meteor/client/lib/userStatuses.ts | 2 +- .../UserMenu/EditStatusModal.tsx | 212 +++++++++++++++--- .../UserMenu/UserMenuHeader.tsx | 11 +- .../client/sidebar/RoomList/RoomList.tsx | 6 +- .../client/sidebar/RoomList/RoomListRow.tsx | 4 +- .../RoomList/SidebarItemTemplateWithData.tsx | 12 +- .../client/views/room/Header/RoomTopic.tsx | 15 +- .../views/room/UserCard/UserCardWithData.tsx | 6 +- .../RoomMembers/RoomMembersItem.tsx | 8 +- .../UserInfo/UserInfoWithData.tsx | 2 + .../fragments/modals/edit-status-modal.ts | 58 ++++- .../e2e/page-objects/fragments/navbar.ts | 20 ++ apps/meteor/tests/e2e/presence.spec.ts | 45 ++++ 23 files changed, 615 insertions(+), 88 deletions(-) create mode 100644 apps/meteor/client/components/UserStatusText/UserStatusText.tsx create mode 100644 apps/meteor/client/components/UserStatusText/index.ts create mode 100644 apps/meteor/client/components/UserStatusText/useExpirationText.ts create mode 100644 apps/meteor/client/components/UserStatusText/useStatusTooltip.tsx diff --git a/apps/meteor/app/notifications/client/lib/Presence.ts b/apps/meteor/app/notifications/client/lib/Presence.ts index 7323459a45b73..fa6cc9866b445 100644 --- a/apps/meteor/app/notifications/client/lib/Presence.ts +++ b/apps/meteor/app/notifications/client/lib/Presence.ts @@ -1,3 +1,4 @@ +import type { PresenceSource } from '@rocket.chat/core-typings'; import { UserStatus } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; @@ -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, + }); }); diff --git a/apps/meteor/client/components/UserInfo/UserInfo.tsx b/apps/meteor/client/components/UserInfo/UserInfo.tsx index 1f32888e901cf..7439b793c7036 100644 --- a/apps/meteor/client/components/UserInfo/UserInfo.tsx +++ b/apps/meteor/client/components/UserInfo/UserInfo.tsx @@ -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'; @@ -38,6 +39,7 @@ type UserInfoDataProps = Serialized< | 'phone' | 'createdAt' | 'statusText' + | 'statusExpiresAt' | 'canViewAllInfo' | 'customFields' | 'freeSwitchExtension' @@ -70,6 +72,7 @@ const UserInfo = ({ createdAt, status, statusText, + statusExpiresAt, customFields, canViewAllInfo, actions, @@ -102,7 +105,7 @@ const UserInfo = ({ {statusText && ( - + )} diff --git a/apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap b/apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap index 09d900532be03..d023795436af5 100644 --- a/apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap +++ b/apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap @@ -93,15 +93,20 @@ exports[`renders Default without crashing 1`] = ` class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-faoni4" >
- 🛴 - currently working on User Card +
+ 🛴 + currently working on User Card +
@@ -390,15 +395,20 @@ exports[`renders InvitedUser without crashing 1`] = ` class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-faoni4" >
- 🛴 - currently working on User Card +
+ 🛴 + currently working on User Card +
@@ -701,15 +711,20 @@ exports[`renders WithABACAttributes without crashing 1`] = ` class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-faoni4" >
- 🛴 - currently working on User Card +
+ 🛴 + currently working on User Card +
@@ -1058,15 +1073,20 @@ exports[`renders WithVoiceCallExtension without crashing 1`] = ` class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-faoni4" >
- 🛴 - currently working on User Card +
+ 🛴 + currently working on User Card +
diff --git a/apps/meteor/client/components/UserStatusText/UserStatusText.tsx b/apps/meteor/client/components/UserStatusText/UserStatusText.tsx new file mode 100644 index 0000000000000..99092546c4658 --- /dev/null +++ b/apps/meteor/client/components/UserStatusText/UserStatusText.tsx @@ -0,0 +1,47 @@ +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; + + const ref = useRef(null); + const openTooltip = useTooltipOpen(); + const closeTooltip = useTooltipClose(); + + const handleMouseEnter = useCallback(() => { + if (!ref.current || !hasValidExpiration) { + return; + } + openTooltip({expirationText}, ref.current); + }, [hasValidExpiration, expirationText, openTooltip]); + + if (!statusText) { + return null; + } + + return ( + + + {showExpiration && hasValidExpiration && ( + + {expirationText} + + )} + + ); +}; + +export default UserStatusText; diff --git a/apps/meteor/client/components/UserStatusText/index.ts b/apps/meteor/client/components/UserStatusText/index.ts new file mode 100644 index 0000000000000..5d279ddfa462a --- /dev/null +++ b/apps/meteor/client/components/UserStatusText/index.ts @@ -0,0 +1,4 @@ +export { default as UserStatusText } from './UserStatusText'; +export { useStatusTooltip } from './useStatusTooltip'; + +export { useExpirationText, parseExpiresAt } from './useExpirationText'; diff --git a/apps/meteor/client/components/UserStatusText/useExpirationText.ts b/apps/meteor/client/components/UserStatusText/useExpirationText.ts new file mode 100644 index 0000000000000..13261e4422b9c --- /dev/null +++ b/apps/meteor/client/components/UserStatusText/useExpirationText.ts @@ -0,0 +1,48 @@ +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 { + if (!value) { + return undefined; + } + + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? undefined : value; + } + + if (typeof value === 'object' && '$date' in (value as Record)) { + const date = new Date((value as { $date: number }).$date); + return Number.isNaN(date.getTime()) ? undefined : date; + } + + 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 formatTime = useFormatTime(); + const formatDate = useFormatDate(); + + return useMemo(() => { + const expiresAt = parseExpiresAt(statusExpiresAt); + if (!expiresAt || expiresAt.getTime() <= Date.now()) { + return undefined; + } + + const isToday = isSameDay(expiresAt, new Date()); + return `${t('Until')} ${isToday ? formatTime(expiresAt) : formatDate(expiresAt)}`; + }, [statusExpiresAt, t, formatTime, formatDate]); +} diff --git a/apps/meteor/client/components/UserStatusText/useStatusTooltip.tsx b/apps/meteor/client/components/UserStatusText/useStatusTooltip.tsx new file mode 100644 index 0000000000000..90f59b83350ae --- /dev/null +++ b/apps/meteor/client/components/UserStatusText/useStatusTooltip.tsx @@ -0,0 +1,38 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useTooltipOpen, useTooltipClose } from '@rocket.chat/ui-contexts'; +import type { MouseEvent } from 'react'; +import { useCallback } from 'react'; + +import { useExpirationText } from './useExpirationText'; + +export function useStatusTooltip(statusText?: string, statusExpiresAt?: Date | string) { + const expirationText = useExpirationText(statusExpiresAt); + const openTooltip = useTooltipOpen(); + const closeTooltip = useTooltipClose(); + + const handleMouseEnter = useCallback( + (e: MouseEvent) => { + if (!statusText) { + return; + } + openTooltip( + + {statusText} + {expirationText && ( + + {expirationText} + + )} + , + e.currentTarget, + ); + }, + [statusText, expirationText, openTooltip], + ); + + return { + hasStatusText: !!statusText, + handleMouseEnter, + handleMouseLeave: closeTooltip, + }; +} diff --git a/apps/meteor/client/lib/presence.spec.ts b/apps/meteor/client/lib/presence.spec.ts index 5e630bd4f03da..720a2a2f8fd59 100644 --- a/apps/meteor/client/lib/presence.spec.ts +++ b/apps/meteor/client/lib/presence.spec.ts @@ -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); + }); }); diff --git a/apps/meteor/client/lib/presence.ts b/apps/meteor/client/lib/presence.ts index 6b748c85d3541..071ad5249fca7 100644 --- a/apps/meteor/client/lib/presence.ts +++ b/apps/meteor/client/lib/presence.ts @@ -97,9 +97,12 @@ const getPresence = ((): ((uid: UserPresence['_id']) => void) => { const fallbackStatus = status === 'disabled' ? UserStatus.DISABLED : UserStatus.OFFLINE; - users.forEach((user) => { + users.forEach(({ statusExpiresAt, ...user }) => { if (!store.has(user._id)) { - notify(user); + notify({ + ...user, + ...(statusExpiresAt && { statusExpiresAt: new Date(statusExpiresAt) }), + }); } currentUids.delete(user._id); }); diff --git a/apps/meteor/client/lib/userData.ts b/apps/meteor/client/lib/userData.ts index 9ae32b29dfca5..c5abf38296380 100644 --- a/apps/meteor/client/lib/userData.ts +++ b/apps/meteor/client/lib/userData.ts @@ -18,6 +18,8 @@ type RawUserData = Serialized< | 'status' | 'statusDefault' | 'statusText' + | 'statusSource' + | 'statusExpiresAt' | 'statusConnection' | 'avatarOrigin' | 'utcOffset' @@ -158,6 +160,9 @@ export const synchronizeUserData = async (uid: IUser['_id']): Promise { public invisibleAllowed = true; private store: Map = new Map( - [UserStatus.ONLINE, UserStatus.AWAY, UserStatus.BUSY, UserStatus.OFFLINE].map((status) => [ + [UserStatus.ONLINE, UserStatus.BUSY, UserStatus.OFFLINE].map((status) => [ status, { id: status, diff --git a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx index 7e017b88750b9..ef643f730a52c 100644 --- a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx +++ b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx @@ -1,15 +1,21 @@ import type { IUser } from '@rocket.chat/core-typings'; +import { UserStatus as UserStatusType } from '@rocket.chat/core-typings'; +import type { SelectOption } from '@rocket.chat/fuselage'; import { Field, - TextInput, - FieldGroup, - Modal, - Button, - Box, FieldLabel, FieldRow, FieldError, FieldHint, + TextInput, + InputBox, + Select, + SelectLegacy, + Option, + Margins, + Modal, + Button, + Box, ModalHeader, ModalIcon, ModalTitle, @@ -19,11 +25,14 @@ import { ModalFooterControllers, } from '@rocket.chat/fuselage'; import { useEffectEvent, useLocalStorage } from '@rocket.chat/fuselage-hooks'; -import { useToastMessageDispatch, useSetting, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useToastMessageDispatch, useSetting, useEndpoint } from '@rocket.chat/ui-contexts'; +import type { TFunction } from 'i18next'; import type { ReactElement, ChangeEvent, ComponentProps, FormEvent } from 'react'; -import { useState, useCallback, useId } from 'react'; +import { useState, useCallback, useId, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; -import UserStatusMenu from '../../../components/UserStatusMenu'; +import { UserStatus } from '../../../components/UserStatus'; +import { useFormatTime } from '../../../hooks/useFormatTime'; import { USER_STATUS_TEXT_MAX_LENGTH } from '../../../lib/constants'; type EditStatusModalProps = { @@ -32,35 +41,122 @@ type EditStatusModalProps = { userStatusText: IUser['statusText']; }; +type DurationOption = { + value: string; + getLabel: (t: TFunction, formatTime: (d: Date) => string, now: Date) => string; + getExpiresAt?: (now: Date) => Date; +}; + +const DURATION_OPTIONS: DurationOption[] = [ + { value: '', getLabel: (t) => t('Status_dont_clear') }, + { + value: '30', + getLabel: (t, formatTime, now) => `${t('Status_30_minutes')} (${formatTime(new Date(now.getTime() + 30 * 60_000))})`, + getExpiresAt: (now) => new Date(now.getTime() + 30 * 60_000), + }, + { + value: '60', + getLabel: (t, formatTime, now) => `${t('Status_1_hour')} (${formatTime(new Date(now.getTime() + 60 * 60_000))})`, + getExpiresAt: (now) => new Date(now.getTime() + 60 * 60_000), + }, + { + value: '240', + getLabel: (t, formatTime, now) => `${t('Status_4_hours')} (${formatTime(new Date(now.getTime() + 240 * 60_000))})`, + getExpiresAt: (now) => new Date(now.getTime() + 240 * 60_000), + }, + { + value: 'today', + getLabel: (t, formatTime, now) => + `${t('Today')} (${formatTime(new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999))})`, + getExpiresAt: (now) => new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999), + }, + { value: 'custom', getLabel: (t) => t('Status_choose_date_and_time') }, +]; + +const StatusOption = ({ status, label }: { status: UserStatusType; label: string }) => ( + + + + + {label} + +); + +// eslint-disable-next-line react/no-multi-comp const EditStatusModal = ({ onClose, userStatus, userStatusText }: EditStatusModalProps): ReactElement => { const allowUserStatusMessageChange = useSetting('Accounts_AllowUserStatusMessageChange'); + const allowInvisibleStatus = useSetting('Accounts_AllowInvisibleStatusOption', true); const dispatchToastMessage = useToastMessageDispatch(); const [customStatus, setCustomStatus] = useLocalStorage('Local_Custom_Status', ''); const initialStatusText = customStatus || userStatusText || ''; - const t = useTranslation(); + const { t } = useTranslation(); const modalId = useId(); const [statusText, setStatusText] = useState(initialStatusText); const [statusType, setStatusType] = useState(userStatus); const [statusTextError, setStatusTextError] = useState(); + const [duration, setDuration] = useState(''); + const [customDate, setCustomDate] = useState(() => new Date().toLocaleDateString('en-CA')); + const [customTime, setCustomTime] = useState(() => new Date().toTimeString().slice(0, 5)); + const minCustomDate = useMemo(() => new Date().toLocaleDateString('en-CA'), []); const setUserStatus = useEndpoint('POST', '/v1/users.setStatus'); + const formatTime = useFormatTime(); - const handleStatusText = useEffectEvent((e: ChangeEvent): void => { - setStatusText(e.currentTarget.value); + const statusOptions: SelectOption[] = useMemo(() => { + const options: SelectOption[] = [ + [UserStatusType.ONLINE, t('Online')], + [UserStatusType.AWAY, t('Away')], + [UserStatusType.BUSY, t('Busy')], + ]; - if (statusText && statusText.length > USER_STATUS_TEXT_MAX_LENGTH) { - return setStatusTextError(t('Max_length_is', USER_STATUS_TEXT_MAX_LENGTH)); + if (allowInvisibleStatus) { + options.push([UserStatusType.OFFLINE, t('Offline')]); } - return setStatusTextError(undefined); - }); + return options; + }, [t, allowInvisibleStatus]); - const handleStatusType = (type: IUser['status']): void => setStatusType(type); + const durationOptions: SelectOption[] = useMemo( + () => DURATION_OPTIONS.map(({ value, getLabel }) => [value, getLabel(t, formatTime, new Date())]), + [t, formatTime], + ); + + const computeExpiresAt = useCallback((): Date | undefined => { + if (duration === 'custom') { + if (!customDate || !customTime) { + return undefined; + } + const [year, month, day] = customDate.split('-').map(Number); + const [hours, mins] = customTime.split(':').map(Number); + const parsedDate = new Date(year, month - 1, day, hours, mins, 0, 0); + return Number.isNaN(parsedDate.getTime()) ? undefined : parsedDate; + } + + const option = DURATION_OPTIONS.find((o) => o.value === duration); + return option?.getExpiresAt?.(new Date()); + }, [duration, customDate, customTime]); + + const handleStatusText = useEffectEvent((e: ChangeEvent): void => { + const { value } = e.currentTarget; + setStatusText(value); + setStatusTextError( + value.length > USER_STATUS_TEXT_MAX_LENGTH ? t('Max_length_is', { length: USER_STATUS_TEXT_MAX_LENGTH }) : undefined, + ); + }); const handleSaveStatus = useCallback(async () => { try { - await setUserStatus({ message: statusText, status: statusType }); + const expiresAt = computeExpiresAt(); + if (duration === 'custom' && !expiresAt) { + dispatchToastMessage({ type: 'error', message: t('Status_choose_date_and_time') }); + return; + } + await setUserStatus({ + message: statusText, + status: statusType as UserStatusType, + ...(expiresAt && { expiresAt: expiresAt.toISOString() }), + }); setCustomStatus(statusText); dispatchToastMessage({ type: 'success', message: t('StatusMessage_Changed_Successfully') }); } catch (error) { @@ -68,7 +164,7 @@ const EditStatusModal = ({ onClose, userStatus, userStatusText }: EditStatusModa } onClose(); - }, [onClose, setUserStatus, statusText, statusType, setCustomStatus, dispatchToastMessage, t]); + }, [onClose, setUserStatus, statusText, statusType, computeExpiresAt, setCustomStatus, dispatchToastMessage, t, duration]); return ( - {t('Edit_Status')} + {t('Status_set_your_status')} - + + + {t('Status')} + + setStatusType(value as UserStatusType)} + renderSelected={({ value, label }) => ( + + + + )} + renderItem={({ value, label, ...props }) => ( + + {t('StatusMessage')} @@ -103,23 +219,59 @@ const EditStatusModal = ({ onClose, userStatus, userStatusText }: EditStatusModa value={statusText} onChange={handleStatusText} placeholder={t('StatusMessage_Placeholder')} - addon={} /> {!allowUserStatusMessageChange && {t('StatusMessage_Change_Disabled')}} {statusTextError} - + + {t('Status_clear_after')} + + { + setStatusDuration(String(next)); + setStatusDurationError(undefined); + }} + /> + + {statusDuration === 'custom' && ( + + + ) => { + setStatusCustomDate(e.currentTarget.value); + setStatusDurationError(undefined); + }} + min={new Date().toLocaleDateString('en-CA')} + /> + ) => { + setStatusCustomTime(e.currentTarget.value); + setStatusDurationError(undefined); + }} + /> + + + )} + {statusDurationError && {statusDurationError}} + {t('Status_new_status_warning')} + {t('Nickname')} diff --git a/apps/meteor/client/views/account/profile/getProfileInitialValues.ts b/apps/meteor/client/views/account/profile/getProfileInitialValues.ts index a15e8477d48db..dd91e52b44fb6 100644 --- a/apps/meteor/client/views/account/profile/getProfileInitialValues.ts +++ b/apps/meteor/client/views/account/profile/getProfileInitialValues.ts @@ -9,7 +9,7 @@ export type AccountProfileFormValues = { avatar: AvatarObject; url: string; statusText: string; - statusType: string; + statusType: IUser['status']; bio: string; customFields: Record; nickname: string; @@ -22,7 +22,7 @@ export const getProfileInitialValues = (user: IUser | null): AccountProfileFormV avatar: '' as AvatarObject, url: '', statusText: user?.statusText ?? '', - statusType: user?.status ?? '', + statusType: user?.status, bio: user?.bio ?? '', customFields: user?.customFields ?? {}, nickname: user?.nickname ?? '', diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersItem.tsx b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersItem.tsx index 592c502eab6a3..4a8b0f206ed77 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersItem.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersItem.tsx @@ -51,7 +51,11 @@ const RoomMembersItem = ({ const [nameOrUsername, displayUsername] = getUserDisplayNames(name, username, useRealName); const presenceData = useUserPresence(_id); - const { handleMouseEnter, handleMouseLeave } = useStatusTooltip(presenceData?.statusText, presenceData?.statusExpiresAt, presenceData?.status); + const { handleMouseEnter, handleMouseLeave } = useStatusTooltip( + presenceData?.statusText, + presenceData?.statusExpiresAt, + presenceData?.status, + ); const handleMenuEvent = isReduceMotionEnabled ? { diff --git a/apps/meteor/tests/e2e/page-objects/account-profile.ts b/apps/meteor/tests/e2e/page-objects/account-profile.ts index 0c1b9924ba45e..6d88821e48140 100644 --- a/apps/meteor/tests/e2e/page-objects/account-profile.ts +++ b/apps/meteor/tests/e2e/page-objects/account-profile.ts @@ -44,6 +44,19 @@ export class AccountProfile extends Account { return this.page.locator('//label[contains(text(), "Email")]/..//input'); } + get inputStatusText(): Locator { + return this.page.getByRole('textbox', { name: 'Status' }); + } + + get selectClearStatusAfter(): Locator { + return this.page.locator('//label[contains(text(), "Clear status after")]/..//button'); + } + + async chooseClearStatusAfter(option: string): Promise { + await this.selectClearStatusAfter.click(); + await this.page.getByRole('option', { name: new RegExp(option) }).click(); + } + get preferencesSoundAccordionOption(): Locator { return this.page.locator('h2:has-text("Sound")'); } diff --git a/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts b/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts index 770b7eee5ca7d..ef7b0f6db86bd 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts @@ -239,8 +239,7 @@ export class Navbar { } async changeUserCustomStatus(text?: string): Promise { - await this.btnUserMenu.click(); - await this.getUserProfileMenuOption('Custom Status').click(); + await this.openEditStatusModal(); await this.modals.editStatus.changeStatusMessage(text); } @@ -250,7 +249,7 @@ export class Navbar { async openEditStatusModal(): Promise { await this.btnUserMenu.click(); - await this.getUserProfileMenuOption('Custom Status').click(); + await this.userMenu.getByRole('menuitemcheckbox').first().click(); } async changeUserCustomStatusWithExpiration(options: { diff --git a/apps/meteor/tests/e2e/presence.spec.ts b/apps/meteor/tests/e2e/presence.spec.ts index e2fa95e637656..4270a40efd663 100644 --- a/apps/meteor/tests/e2e/presence.spec.ts +++ b/apps/meteor/tests/e2e/presence.spec.ts @@ -2,16 +2,18 @@ import { faker } from '@faker-js/faker'; import { DEFAULT_USER_CREDENTIALS } from './config/constants'; import { Users } from './fixtures/userStates'; -import { HomeChannel, Login } from './page-objects'; +import { AccountProfile, HomeChannel, Login } from './page-objects'; import { test, expect } from './utils/test'; test.describe.serial('Presence', () => { let poLogin: Login; let poHomeChannel: HomeChannel; + let poAccountProfile: AccountProfile; test.beforeEach(async ({ page }) => { poLogin = new Login(page); poHomeChannel = new HomeChannel(page); + poAccountProfile = new AccountProfile(page); await page.goto('/home'); }); @@ -73,6 +75,28 @@ test.describe.serial('Presence', () => { expect(await page.evaluate(() => localStorage.getItem('fuselage-localStorage-Local_Custom_Status'))).not.toBe('undefined'); }); }); + + test('should replace Custom Status button with active status row when a custom status is set', async ({ page }) => { + const text = faker.string.alpha(8); + + await test.step('Custom Status entry visible while no status is set', async () => { + await poHomeChannel.navbar.btnUserMenu.click(); + await expect(poHomeChannel.navbar.userMenu.getByRole('menuitemcheckbox', { name: 'Custom Status' })).toBeVisible(); + await page.keyboard.press('Escape'); + }); + + await test.step('after setting a status, Custom Status entry is replaced by the active row', async () => { + await poHomeChannel.navbar.changeUserCustomStatus(text); + await poHomeChannel.navbar.btnUserMenu.click(); + await expect(poHomeChannel.navbar.userMenu.getByRole('menuitemcheckbox', { name: 'Custom Status' })).not.toBeVisible(); + await expect(poHomeChannel.navbar.userMenu).toContainText(text); + await page.keyboard.press('Escape'); + }); + + await test.step('cleanup', async () => { + await poHomeChannel.navbar.changeUserCustomStatus(''); + }); + }); }); test.describe('Status expiration', () => { @@ -179,4 +203,31 @@ test.describe.serial('Presence', () => { }); }); }); + + test.describe('Status set from account profile', () => { + test.use({ storageState: Users.admin.state }); + + test('should save status with expiration from the profile form and surface it in the user menu', async ({ page }) => { + const text = faker.string.alpha(10); + + await test.step('fill Status field + Clear-after on /account/profile and save', async () => { + await page.goto('/account/profile'); + await poAccountProfile.inputStatusText.fill(text); + await poAccountProfile.chooseClearStatusAfter('30 minutes'); + await poAccountProfile.btnSaveChanges.click(); + }); + + await test.step('status with expiration appears in user menu', async () => { + await page.goto('/home'); + await poHomeChannel.navbar.btnUserMenu.click(); + await expect(poHomeChannel.navbar.userMenu).toContainText(text); + await expect(poHomeChannel.navbar.userMenu).toContainText('Until'); + await page.keyboard.press('Escape'); + }); + + await test.step('cleanup', async () => { + await poHomeChannel.navbar.changeUserCustomStatus(''); + }); + }); + }); }); diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index ff6d2f742e5fc..accb3a73ab07d 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -5154,11 +5154,13 @@ "Status_1_hour": "1 hour", "Status_4_hours": "4 hours", "Status_choose_date_and_time": "Choose date and time", + "Status_expiration_date": "Expiration date", + "Status_expiration_time": "Expiration time", "Status_expiration_must_be_future": "Expiration must be in the future", "Status_calendar_events_wont_override": "Calendar events won't override this", "Status_current": "Current status", "Status_you_can_use_emoji": "You can use emoji", - "Status_new_status_warning": "Setting a manual status will override any current external status. Automated integrations may temporarily change your status and will restore it once they end.", + "Status_new_status_warning": "New status can be changed by: calendar integrations, voice calls or videoconferencing.", "Step": "Step", "Stop_Recording": "Stop Recording", "Stop_call": "Stop call", @@ -5588,6 +5590,7 @@ "Update_EnableChecker": "Enable the Update Checker", "Update_EnableChecker_Description": "Checks automatically for new updates / important messages from the Rocket.Chat developers and receives notifications when available. The notification appears once per new version as a clickable banner and as a message from the Rocket.Cat bot, both visible only for administrators.", "Update_LatestAvailableVersion": "Update Latest Available Version", + "Until": "Until", "Update_anyway": "Update anyway", "Update_every": "Update every", "Update_to_access_marketplace": "Update to access marketplace", From 0241991b02304cd677043d23d19d2d34b1662934 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 2 Jun 2026 21:25:19 -0300 Subject: [PATCH 15/21] fix: custom status expiration being wiped by profile save --- .../account/profile/AccountProfileForm.tsx | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx index 52e15d54aef91..2745dfc33bc7a 100644 --- a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx +++ b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx @@ -58,9 +58,10 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle const { email, avatar, username, name: userFullName } = watch(); - const [statusDuration, setStatusDuration] = useState(''); - const [statusCustomDate, setStatusCustomDate] = useState(() => new Date().toLocaleDateString('en-CA')); - const [statusCustomTime, setStatusCustomTime] = useState(() => new Date().toTimeString().slice(0, 5)); + const initialExpiration = user?.statusExpiresAt && new Date(user.statusExpiresAt) > new Date() ? new Date(user.statusExpiresAt) : null; + const [statusDuration, setStatusDuration] = useState(initialExpiration ? 'custom' : ''); + const [statusCustomDate, setStatusCustomDate] = useState(() => (initialExpiration ?? new Date()).toLocaleDateString('en-CA')); + const [statusCustomTime, setStatusCustomTime] = useState(() => (initialExpiration ?? new Date()).toTimeString().slice(0, 5)); const [statusDurationError, setStatusDurationError] = useState(); const statusDurationOptions: SelectOption[] = useMemo( () => STATUS_DURATION_OPTIONS.map(({ value, labelKey }) => [value, t(labelKey)]), @@ -110,24 +111,25 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle const updateAvatar = useUpdateAvatar(avatar, user?._id || ''); const handleSave = async ({ email, name, username, statusType, statusText, nickname, bio, customFields }: AccountProfileFormValues) => { + const expiresAt = STATUS_DURATION_OPTIONS.find((o) => o.value === statusDuration)?.getExpiresAt?.({ + now: new Date(), + customDate: statusCustomDate, + customTime: statusCustomTime, + }); + if (statusDuration === 'custom') { + if (!expiresAt) { + setStatusDurationError(t('Status_choose_date_and_time')); + return; + } + if (expiresAt <= new Date()) { + setStatusDurationError(t('Status_expiration_must_be_future')); + return; + } + } + setStatusDurationError(undefined); + try { - if (statusDuration) { - const expiresAt = STATUS_DURATION_OPTIONS.find((o) => o.value === statusDuration)?.getExpiresAt?.({ - now: new Date(), - customDate: statusCustomDate, - customTime: statusCustomTime, - }); - if (statusDuration === 'custom') { - if (!expiresAt) { - setStatusDurationError(t('Status_choose_date_and_time')); - return; - } - if (expiresAt <= new Date()) { - setStatusDurationError(t('Status_expiration_must_be_future')); - return; - } - } - setStatusDurationError(undefined); + if (allowUserStatusMessageChange) { await setUserStatus({ message: statusText, status: statusType, @@ -140,8 +142,6 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle name, ...(user ? getUserEmailAddress(user) !== email && { email } : {}), username, - statusText, - statusType, nickname, bio, }, From 0c56e7652363152e77c24b1a14e54cdd00dd8973 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 2 Jun 2026 21:56:06 -0300 Subject: [PATCH 16/21] fix: custom status expiration in profile and status modal --- .../UserMenu/EditStatusModal.tsx | 231 +++++++++++------- 1 file changed, 139 insertions(+), 92 deletions(-) diff --git a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx index 0d1b2602b0fe2..a6a6e2fe4fe37 100644 --- a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx +++ b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx @@ -21,10 +21,11 @@ import { ModalFooter, ModalFooterControllers, } from '@rocket.chat/fuselage'; -import { useEffectEvent, useLocalStorage } from '@rocket.chat/fuselage-hooks'; +import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useSetting, useEndpoint, useUser } from '@rocket.chat/ui-contexts'; -import type { ReactElement, ChangeEvent, ComponentProps, FormEvent } from 'react'; -import { useState, useCallback, useId, useMemo } from 'react'; +import type { ReactElement, ChangeEvent, ComponentProps } from 'react'; +import { useId, useMemo } from 'react'; +import { Controller, useForm, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import UserStatusMenu from '../../../components/UserStatusMenu'; @@ -35,6 +36,14 @@ type EditStatusModalProps = { onClose: () => void; }; +type StatusFormValues = { + statusText: string; + statusType: UserStatusType; + duration: string; + customDate: string; + customTime: string; +}; + const EditStatusModal = ({ onClose }: EditStatusModalProps): ReactElement => { const user = useUser(); const allowUserStatusMessageChange = useSetting('Accounts_AllowUserStatusMessageChange'); @@ -44,13 +53,29 @@ const EditStatusModal = ({ onClose }: EditStatusModalProps): ReactElement => { const { t } = useTranslation(); const modalId = useId(); - const [statusText, setStatusText] = useState(initialStatusText); - const [statusType, setStatusType] = useState(user?.status ?? UserStatusType.ONLINE); - const [statusTextError, setStatusTextError] = useState(); - const [durationError, setDurationError] = useState(); - const [duration, setDuration] = useState(''); - const [customDate, setCustomDate] = useState(() => new Date().toLocaleDateString('en-CA')); - const [customTime, setCustomTime] = useState(() => new Date().toTimeString().slice(0, 5)); + + const initialExpiration = user?.statusExpiresAt && new Date(user.statusExpiresAt) > new Date() ? new Date(user.statusExpiresAt) : null; + const initialDate = initialExpiration ?? new Date(); + + const { + control, + handleSubmit, + setError, + clearErrors, + formState: { errors }, + } = useForm({ + mode: 'onChange', + defaultValues: { + statusText: initialStatusText, + statusType: user?.status ?? UserStatusType.ONLINE, + duration: initialExpiration ? 'custom' : '', + customDate: initialDate.toLocaleDateString('en-CA'), + customTime: initialDate.toTimeString().slice(0, 5), + }, + }); + + const duration = useWatch({ control, name: 'duration' }); + const statusType = useWatch({ control, name: 'statusType' }); const setUserStatus = useEndpoint('POST', '/v1/users.setStatus'); @@ -58,32 +83,24 @@ const EditStatusModal = ({ onClose }: EditStatusModalProps): ReactElement => { const durationOptions: SelectOption[] = useMemo(() => STATUS_DURATION_OPTIONS.map(({ value, labelKey }) => [value, t(labelKey)]), [t]); - const handleStatusText = useEffectEvent((e: ChangeEvent): void => { - const { value } = e.currentTarget; - setStatusText(value); - setStatusTextError( - value.length > USER_STATUS_TEXT_MAX_LENGTH ? t('Max_length_is', { length: USER_STATUS_TEXT_MAX_LENGTH }) : undefined, - ); - }); - - const handleSaveStatus = useCallback(async () => { - try { - const expiresAt = STATUS_DURATION_OPTIONS.find((o) => o.value === duration)?.getExpiresAt?.({ - now: new Date(), - customDate, - customTime, - }); - if (duration === 'custom') { - if (!expiresAt) { - setDurationError(t('Status_choose_date_and_time')); - return; - } - if (expiresAt <= new Date()) { - setDurationError(t('Status_expiration_must_be_future')); - return; - } + const handleSaveStatus = async ({ statusText, statusType, duration, customDate, customTime }: StatusFormValues): Promise => { + const expiresAt = STATUS_DURATION_OPTIONS.find((o) => o.value === duration)?.getExpiresAt?.({ + now: new Date(), + customDate, + customTime, + }); + if (duration === 'custom') { + if (!expiresAt) { + setError('duration', { message: t('Status_choose_date_and_time') }); + return; + } + if (expiresAt <= new Date()) { + setError('duration', { message: t('Status_expiration_must_be_future') }); + return; } - setDurationError(undefined); + } + clearErrors('duration'); + try { await setUserStatus({ message: statusText, status: statusType, @@ -95,21 +112,12 @@ const EditStatusModal = ({ onClose }: EditStatusModalProps): ReactElement => { } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } - }, [onClose, setUserStatus, statusText, statusType, duration, customDate, customTime, setCustomStatus, dispatchToastMessage, t]); + }; return ( ) => ( - { - e.preventDefault(); - handleSaveStatus(); - }} - {...props} - /> - )} + wrapperFunction={(props: ComponentProps) => } > {t('Status_set_your_status')} @@ -120,68 +128,107 @@ const EditStatusModal = ({ onClose }: EditStatusModalProps): ReactElement => { {t('Status')} - ( + .rcx-input-box__addon { - order: -1; - margin-inline-end: 0.5rem; - } - `} - addon={} + & > .rcx-input-box__addon { + order: -1; + margin-inline-end: 0.5rem; + } + `} + addon={ + ( + + )} + /> + } + /> + )} /> - {!allowUserStatusMessageChange && {t('StatusMessage_Change_Disabled')}} - {allowUserStatusMessageChange && {t('Status_you_can_use_emoji')}} - {statusTextError} + {t(allowUserStatusMessageChange ? 'Status_you_can_use_emoji' : 'StatusMessage_Change_Disabled')} + {errors.statusText && {errors.statusText.message}} {t('Status_clear_after')} - { + onChange(String(next)); + clearErrors('duration'); + }} + /> + )} /> {duration === 'custom' && ( - ) => { - setCustomDate(e.currentTarget.value); - setDurationError(undefined); - }} - min={new Date().toLocaleDateString('en-CA')} + ( + ) => { + onChange(e.currentTarget.value); + clearErrors('duration'); + }} + min={new Date().toLocaleDateString('en-CA')} + /> + )} /> - ) => { - setCustomTime(e.currentTarget.value); - setDurationError(undefined); - }} + ( + ) => { + onChange(e.currentTarget.value); + clearErrors('duration'); + }} + /> + )} /> )} - {durationError && {durationError}} + {errors.duration && {errors.duration.message}} {t('Status_new_status_warning')} @@ -191,7 +238,7 @@ const EditStatusModal = ({ onClose }: EditStatusModalProps): ReactElement => { - From a3afa3430759f9aae5697f1c86000856f4435c37 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 2 Jun 2026 21:58:57 -0300 Subject: [PATCH 17/21] fix: empty custom status rendering empty wrapper in user card --- apps/meteor/client/views/room/UserCard/UserCardWithData.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx b/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx index 0d4fcf8638d7d..6ce8829925567 100644 --- a/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx +++ b/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx @@ -64,7 +64,7 @@ const UserCardWithData = ({ username, rid, onOpenUserInfo, onClose }: UserCardWi etag: avatarETag, localTime: utcOffset && Number.isInteger(utcOffset) && , status: _id && , - customStatus: , + customStatus: statusText && , nickname, freeSwitchExtension, }; From 6a27461a07700ccf2037c064c3c3f6bd44c5a554 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 2 Jun 2026 22:16:08 -0300 Subject: [PATCH 18/21] test: wait for save responses before navigating in profile expiration e2e --- apps/meteor/tests/e2e/presence.spec.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/meteor/tests/e2e/presence.spec.ts b/apps/meteor/tests/e2e/presence.spec.ts index 4270a40efd663..ab8447d3c83c5 100644 --- a/apps/meteor/tests/e2e/presence.spec.ts +++ b/apps/meteor/tests/e2e/presence.spec.ts @@ -214,7 +214,12 @@ test.describe.serial('Presence', () => { await page.goto('/account/profile'); await poAccountProfile.inputStatusText.fill(text); await poAccountProfile.chooseClearStatusAfter('30 minutes'); - await poAccountProfile.btnSaveChanges.click(); + await Promise.all([ + page.waitForResponse((r) => r.url().endsWith('/v1/users.setStatus') && r.ok()), + page.waitForResponse((r) => r.url().endsWith('/v1/users.updateOwnBasicInfo') && r.ok()), + poAccountProfile.btnSaveChanges.click(), + ]); + await expect(page.getByText('Profile saved successfully')).toBeVisible(); }); await test.step('status with expiration appears in user menu', async () => { From 5c84072d077d21d1cb8519afce94e577d3004d98 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 2 Jun 2026 22:20:46 -0300 Subject: [PATCH 19/21] fix: gate status expiration validation by status-change permission --- .../navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx | 2 +- apps/meteor/client/views/account/profile/AccountProfileForm.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx index a6a6e2fe4fe37..45d7c4eb7762e 100644 --- a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx +++ b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx @@ -89,7 +89,7 @@ const EditStatusModal = ({ onClose }: EditStatusModalProps): ReactElement => { customDate, customTime, }); - if (duration === 'custom') { + if (allowUserStatusMessageChange && duration === 'custom') { if (!expiresAt) { setError('duration', { message: t('Status_choose_date_and_time') }); return; diff --git a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx index 2745dfc33bc7a..902046f824526 100644 --- a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx +++ b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx @@ -116,7 +116,7 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle customDate: statusCustomDate, customTime: statusCustomTime, }); - if (statusDuration === 'custom') { + if (allowUserStatusMessageChange && statusDuration === 'custom') { if (!expiresAt) { setStatusDurationError(t('Status_choose_date_and_time')); return; From 0fe8dc1653c42187e4fc00b6cf3886c1bc07a1bf Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 2 Jun 2026 22:41:16 -0300 Subject: [PATCH 20/21] refactor: move profile form status fields to react-hook-form --- .../account/profile/AccountProfileForm.tsx | 119 ++++++++++++------ .../profile/getProfileInitialValues.ts | 34 +++-- 2 files changed, 100 insertions(+), 53 deletions(-) diff --git a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx index 902046f824526..59b8a9ccdfd40 100644 --- a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx +++ b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx @@ -15,7 +15,7 @@ import { } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; import type { AllHTMLAttributes, ChangeEvent, ReactElement } from 'react'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import type { AccountProfileFormValues } from './getProfileInitialValues'; @@ -53,16 +53,14 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle watch, handleSubmit, reset, - formState: { errors }, + setError, + clearErrors, + formState: { errors, dirtyFields }, } = useFormContext(); const { email, avatar, username, name: userFullName } = watch(); + const statusDuration = watch('statusDuration'); - const initialExpiration = user?.statusExpiresAt && new Date(user.statusExpiresAt) > new Date() ? new Date(user.statusExpiresAt) : null; - const [statusDuration, setStatusDuration] = useState(initialExpiration ? 'custom' : ''); - const [statusCustomDate, setStatusCustomDate] = useState(() => (initialExpiration ?? new Date()).toLocaleDateString('en-CA')); - const [statusCustomTime, setStatusCustomTime] = useState(() => (initialExpiration ?? new Date()).toTimeString().slice(0, 5)); - const [statusDurationError, setStatusDurationError] = useState(); const statusDurationOptions: SelectOption[] = useMemo( () => STATUS_DURATION_OPTIONS.map(({ value, labelKey }) => [value, t(labelKey)]), [t], @@ -110,7 +108,21 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle const updateAvatar = useUpdateAvatar(avatar, user?._id || ''); - const handleSave = async ({ email, name, username, statusType, statusText, nickname, bio, customFields }: AccountProfileFormValues) => { + const handleSave = async (values: AccountProfileFormValues) => { + const { + email, + name, + username, + statusType, + statusText, + statusDuration, + statusCustomDate, + statusCustomTime, + nickname, + bio, + customFields, + } = values; + const expiresAt = STATUS_DURATION_OPTIONS.find((o) => o.value === statusDuration)?.getExpiresAt?.({ now: new Date(), customDate: statusCustomDate, @@ -118,18 +130,25 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle }); if (allowUserStatusMessageChange && statusDuration === 'custom') { if (!expiresAt) { - setStatusDurationError(t('Status_choose_date_and_time')); + setError('statusDuration', { message: t('Status_choose_date_and_time') }); return; } if (expiresAt <= new Date()) { - setStatusDurationError(t('Status_expiration_must_be_future')); + setError('statusDuration', { message: t('Status_expiration_must_be_future') }); return; } } - setStatusDurationError(undefined); + clearErrors('statusDuration'); + + const statusDirty = + dirtyFields.statusText || + dirtyFields.statusType || + dirtyFields.statusDuration || + dirtyFields.statusCustomDate || + dirtyFields.statusCustomTime; try { - if (allowUserStatusMessageChange) { + if (allowUserStatusMessageChange && statusDirty) { await setUserStatus({ message: statusText, status: statusType, @@ -153,7 +172,7 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } finally { - reset({ email, name, username, statusType, statusText, nickname, bio, customFields }); + reset(values); } }; @@ -260,44 +279,62 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle {t('Status_clear_after')} - { + onChange(String(next)); + clearErrors('statusDuration'); + }} + /> + )} /> {statusDuration === 'custom' && ( - ) => { - setStatusCustomDate(e.currentTarget.value); - setStatusDurationError(undefined); - }} - min={new Date().toLocaleDateString('en-CA')} + ( + ) => { + onChange(e.currentTarget.value); + clearErrors('statusDuration'); + }} + min={new Date().toLocaleDateString('en-CA')} + /> + )} /> - ) => { - setStatusCustomTime(e.currentTarget.value); - setStatusDurationError(undefined); - }} + ( + ) => { + onChange(e.currentTarget.value); + clearErrors('statusDuration'); + }} + /> + )} /> )} - {statusDurationError && {statusDurationError}} + {errors.statusDuration && {errors.statusDuration.message}} {t('Status_new_status_warning')} diff --git a/apps/meteor/client/views/account/profile/getProfileInitialValues.ts b/apps/meteor/client/views/account/profile/getProfileInitialValues.ts index dd91e52b44fb6..cb9093d45648a 100644 --- a/apps/meteor/client/views/account/profile/getProfileInitialValues.ts +++ b/apps/meteor/client/views/account/profile/getProfileInitialValues.ts @@ -10,20 +10,30 @@ export type AccountProfileFormValues = { url: string; statusText: string; statusType: IUser['status']; + statusDuration: string; + statusCustomDate: string; + statusCustomTime: string; bio: string; customFields: Record; nickname: string; }; -export const getProfileInitialValues = (user: IUser | null): AccountProfileFormValues => ({ - email: user ? getUserEmailAddress(user) || '' : '', - name: user?.name ?? '', - username: user?.username ?? '', - avatar: '' as AvatarObject, - url: '', - statusText: user?.statusText ?? '', - statusType: user?.status, - bio: user?.bio ?? '', - customFields: user?.customFields ?? {}, - nickname: user?.nickname ?? '', -}); +export const getProfileInitialValues = (user: IUser | null): AccountProfileFormValues => { + const expiration = user?.statusExpiresAt && new Date(user.statusExpiresAt) > new Date() ? new Date(user.statusExpiresAt) : null; + const date = expiration ?? new Date(); + return { + email: user ? getUserEmailAddress(user) || '' : '', + name: user?.name ?? '', + username: user?.username ?? '', + avatar: '' as AvatarObject, + url: '', + statusText: user?.statusText ?? '', + statusType: user?.status, + statusDuration: expiration ? 'custom' : '', + statusCustomDate: date.toLocaleDateString('en-CA'), + statusCustomTime: date.toTimeString().slice(0, 5), + bio: user?.bio ?? '', + customFields: user?.customFields ?? {}, + nickname: user?.nickname ?? '', + }; +}; From 9c87407b37b342cc8c6518606f1ae992fff1f04c Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 2 Jun 2026 22:59:48 -0300 Subject: [PATCH 21/21] fix: handle disabled status-change setting consistently across menu and modal --- .../navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx | 2 +- .../NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx | 3 ++- .../meteor/client/views/account/profile/AccountProfileForm.tsx | 3 +-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx index 45d7c4eb7762e..a6a6e2fe4fe37 100644 --- a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx +++ b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx @@ -89,7 +89,7 @@ const EditStatusModal = ({ onClose }: EditStatusModalProps): ReactElement => { customDate, customTime, }); - if (allowUserStatusMessageChange && duration === 'custom') { + if (duration === 'custom') { if (!expiresAt) { setError('duration', { message: t('Status_choose_date_and_time') }); return; diff --git a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx index f022a0b589496..a875e52cb5b4d 100644 --- a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx +++ b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx @@ -44,6 +44,7 @@ export const useStatusItems = (user?: IUser): GenericMenuItemProps[] => { }); const presenceDisabled = useSetting('Presence_broadcast_disabled', false); + const allowUserStatusMessageChange = useSetting('Accounts_AllowUserStatusMessageChange', true); const { data: statuses } = useQuery({ queryKey: ['user-statuses'], @@ -58,7 +59,7 @@ export const useStatusItems = (user?: IUser): GenericMenuItemProps[] => { const handleCustomStatus = useCustomStatusModalHandler(); const customStatusExpiration = useExpirationText(user?.statusExpiresAt); - if (presenceDisabled) { + if (presenceDisabled || !allowUserStatusMessageChange) { return [ { id: 'presence-disabled', diff --git a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx index 59b8a9ccdfd40..3b9cf75876fc5 100644 --- a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx +++ b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx @@ -169,10 +169,9 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle await updateAvatar(); dispatchToastMessage({ type: 'success', message: t('Profile_saved_successfully') }); + reset(values); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); - } finally { - reset(values); } };