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/UserCard/UserCard.tsx b/apps/meteor/client/components/UserCard/UserCard.tsx index 8a042c0733e72..b616cb3ca76f5 100644 --- a/apps/meteor/client/components/UserCard/UserCard.tsx +++ b/apps/meteor/client/components/UserCard/UserCard.tsx @@ -66,7 +66,7 @@ const UserCard = ({ )} {customStatus && ( - + {typeof customStatus === 'string' ? ( ) : ( 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..f442c206b9682 --- /dev/null +++ b/apps/meteor/client/components/UserStatusText/UserStatusText.tsx @@ -0,0 +1,43 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useTooltipClose, useTooltipOpen } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import { useRef, useCallback } from 'react'; + +import { useExpirationText } from './useExpirationText'; +import MarkdownText from '../MarkdownText'; + +type UserStatusTextProps = { + statusText?: string; + statusExpiresAt?: Date | string; + showExpiration?: boolean; +}; + +const UserStatusText = ({ statusText, statusExpiresAt, showExpiration: showExpirationProp }: UserStatusTextProps): ReactElement | null => { + const expirationText = useExpirationText(statusExpiresAt); + const hasValidExpiration = expirationText != null; + const showExpiration = showExpirationProp ?? hasValidExpiration; + + 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..573f4189e460a --- /dev/null +++ b/apps/meteor/client/components/UserStatusText/useExpirationText.ts @@ -0,0 +1,58 @@ +import { useLanguage } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useFormatDate } from '../../hooks/useFormatDate'; +import { useFormatTime } from '../../hooks/useFormatTime'; + +// Handles Date, ISO string, and EJSON { $date } (from DDP streamer which does raw JSON.parse without EJSON deserialization) +export function parseExpiresAt(value?: unknown): Date | undefined { + 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 language = useLanguage(); + const formatTime = useFormatTime(); + const formatDate = useFormatDate(); + + return useMemo(() => { + const expiresAt = parseExpiresAt(statusExpiresAt); + if (!expiresAt || expiresAt.getTime() <= Date.now()) { + return undefined; + } + + const now = new Date(); + if (isSameDay(expiresAt, now)) { + return `${t('Until')} ${formatTime(expiresAt)}`; + } + + if (expiresAt.getFullYear() === now.getFullYear()) { + return `${t('Until')} ${new Intl.DateTimeFormat(language, { month: 'long', day: 'numeric' }).format(expiresAt)}`; + } + + return `${t('Until')} ${formatDate(expiresAt)}`; + }, [statusExpiresAt, t, language, formatTime, formatDate]); +} diff --git a/apps/meteor/client/components/UserStatusText/useStatusTooltip.tsx b/apps/meteor/client/components/UserStatusText/useStatusTooltip.tsx new file mode 100644 index 0000000000000..99a64e1b80d5c --- /dev/null +++ b/apps/meteor/client/components/UserStatusText/useStatusTooltip.tsx @@ -0,0 +1,50 @@ +import { Box, Icon } from '@rocket.chat/fuselage'; +import { useTooltipOpen, useTooltipClose } from '@rocket.chat/ui-contexts'; +import type { MouseEvent } from 'react'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useExpirationText } from './useExpirationText'; + +const STATUS_LABEL_KEYS: Record = { + online: 'Online', + away: 'Away', + busy: 'Busy', + offline: 'Offline', +}; + +export function useStatusTooltip(statusText?: string, statusExpiresAt?: Date | string, status?: string) { + const { t } = useTranslation(); + const expirationText = useExpirationText(statusExpiresAt); + const openTooltip = useTooltipOpen(); + const closeTooltip = useTooltipClose(); + + const handleMouseEnter = useCallback( + (e: MouseEvent) => { + if (!statusText) { + return; + } + const statusLabelKey = status ? STATUS_LABEL_KEYS[status] : undefined; + const headline = statusLabelKey ? `${t(statusLabelKey)} - ${statusText}` : statusText; + openTooltip( + + {headline} + {expirationText && ( + + + {expirationText} + + )} + , + e.currentTarget, + ); + }, + [statusText, status, expirationText, openTooltip, t], + ); + + 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/statusDurations.ts b/apps/meteor/client/lib/statusDurations.ts new file mode 100644 index 0000000000000..f4d6004a23942 --- /dev/null +++ b/apps/meteor/client/lib/statusDurations.ts @@ -0,0 +1,30 @@ +type DurationOption = { + value: string; + labelKey: 'Status_dont_clear' | 'Status_30_minutes' | 'Status_1_hour' | 'Status_choose_date_and_time'; + getExpiresAt?: (ctx: { now: Date; customDate?: string; customTime?: string }) => Date | undefined; +}; + +export const STATUS_DURATION_OPTIONS: DurationOption[] = [ + { value: '', labelKey: 'Status_dont_clear' }, + { + value: '30', + labelKey: 'Status_30_minutes', + getExpiresAt: ({ now }) => new Date(now.getTime() + 30 * 60_000), + }, + { + value: '60', + labelKey: 'Status_1_hour', + getExpiresAt: ({ now }) => new Date(now.getTime() + 60 * 60_000), + }, + { + value: 'custom', + labelKey: 'Status_choose_date_and_time', + getExpiresAt: ({ customDate, customTime }) => { + if (!customDate || !customTime) { + return undefined; + } + const date = new Date(`${customDate}T${customTime}`); + return Number.isNaN(date.getTime()) ? undefined : date; + }, + }, +]; 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..a6a6e2fe4fe37 100644 --- a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx +++ b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx @@ -1,122 +1,244 @@ -import type { IUser } from '@rocket.chat/core-typings'; +import { UserStatus as UserStatusType } from '@rocket.chat/core-typings'; +import { css } from '@rocket.chat/css-in-js'; +import type { SelectOption } from '@rocket.chat/fuselage'; import { Field, - TextInput, - FieldGroup, - Modal, - Button, - Box, FieldLabel, FieldRow, FieldError, FieldHint, + TextInput, + InputBox, + Select, + Margins, + Modal, + Button, + Box, ModalHeader, - ModalIcon, ModalTitle, ModalClose, ModalContent, ModalFooter, ModalFooterControllers, } from '@rocket.chat/fuselage'; -import { useEffectEvent, useLocalStorage } from '@rocket.chat/fuselage-hooks'; -import { useToastMessageDispatch, useSetting, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; -import type { ReactElement, ChangeEvent, ComponentProps, FormEvent } from 'react'; -import { useState, useCallback, useId } from 'react'; +import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; +import { useToastMessageDispatch, useSetting, useEndpoint, useUser } from '@rocket.chat/ui-contexts'; +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'; import { USER_STATUS_TEXT_MAX_LENGTH } from '../../../lib/constants'; +import { STATUS_DURATION_OPTIONS } from '../../../lib/statusDurations'; type EditStatusModalProps = { onClose: () => void; - userStatus: IUser['status']; - userStatusText: IUser['statusText']; }; -const EditStatusModal = ({ onClose, userStatus, userStatusText }: EditStatusModalProps): ReactElement => { +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'); const dispatchToastMessage = useToastMessageDispatch(); const [customStatus, setCustomStatus] = useLocalStorage('Local_Custom_Status', ''); - const initialStatusText = customStatus || userStatusText || ''; + const initialStatusText = user?.statusText ?? customStatus ?? ''; - const t = useTranslation(); + const { t } = useTranslation(); const modalId = useId(); - const [statusText, setStatusText] = useState(initialStatusText); - const [statusType, setStatusType] = useState(userStatus); - const [statusTextError, setStatusTextError] = useState(); - const setUserStatus = useEndpoint('POST', '/v1/users.setStatus'); + 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 handleStatusText = useEffectEvent((e: ChangeEvent): void => { - setStatusText(e.currentTarget.value); + const duration = useWatch({ control, name: 'duration' }); + const statusType = useWatch({ control, name: 'statusType' }); - if (statusText && statusText.length > USER_STATUS_TEXT_MAX_LENGTH) { - return setStatusTextError(t('Max_length_is', USER_STATUS_TEXT_MAX_LENGTH)); - } + const setUserStatus = useEndpoint('POST', '/v1/users.setStatus'); - return setStatusTextError(undefined); - }); + const defaultStatusLabel = `${t(statusType)} (${t('Default')})`; - const handleStatusType = (type: IUser['status']): void => setStatusType(type); + const durationOptions: SelectOption[] = useMemo(() => STATUS_DURATION_OPTIONS.map(({ value, labelKey }) => [value, t(labelKey)]), [t]); - const handleSaveStatus = useCallback(async () => { + 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; + } + } + clearErrors('duration'); try { - await setUserStatus({ message: statusText, status: statusType }); + await setUserStatus({ + message: statusText, + status: statusType, + ...(expiresAt && { expiresAt: expiresAt.toISOString() }), + }); setCustomStatus(statusText); dispatchToastMessage({ type: 'success', message: t('StatusMessage_Changed_Successfully') }); + onClose(); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } - - onClose(); - }, [onClose, setUserStatus, statusText, statusType, setCustomStatus, dispatchToastMessage, t]); + }; return ( ) => ( - { - e.preventDefault(); - handleSaveStatus(); - }} - {...props} - /> - )} + wrapperFunction={(props: ComponentProps) => } > - - {t('Edit_Status')} + {t('Status_set_your_status')} - + + + {t('Status')} + + ( + .rcx-input-box__addon { + order: -1; + margin-inline-end: 0.5rem; + } + `} + addon={ + ( + + )} + /> + } + /> + )} + /> + + {t(allowUserStatusMessageChange ? 'Status_you_can_use_emoji' : 'StatusMessage_Change_Disabled')} + {errors.statusText && {errors.statusText.message}} + - {t('StatusMessage')} + {t('Status_clear_after')} - } + ( + { + onChange(String(next)); + clearErrors('statusDuration'); + }} + /> + )} + /> + + {statusDuration === 'custom' && ( + + + ( + ) => { + onChange(e.currentTarget.value); + clearErrors('statusDuration'); + }} + min={new Date().toLocaleDateString('en-CA')} + /> + )} + /> + ( + ) => { + onChange(e.currentTarget.value); + clearErrors('statusDuration'); + }} + /> + )} + /> + + + )} + {errors.statusDuration && {errors.statusDuration.message}} + {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..cb9093d45648a 100644 --- a/apps/meteor/client/views/account/profile/getProfileInitialValues.ts +++ b/apps/meteor/client/views/account/profile/getProfileInitialValues.ts @@ -9,21 +9,31 @@ export type AccountProfileFormValues = { avatar: AvatarObject; url: string; statusText: string; - statusType: 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 ?? '', + }; +}; diff --git a/apps/meteor/client/views/room/Header/RoomTopic.tsx b/apps/meteor/client/views/room/Header/RoomTopic.tsx index abe34ab0f3252..6251335e7df18 100644 --- a/apps/meteor/client/views/room/Header/RoomTopic.tsx +++ b/apps/meteor/client/views/room/Header/RoomTopic.tsx @@ -1,9 +1,11 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { isDirectMessageRoom, isPrivateRoom, isPublicRoom, isTeamRoom } from '@rocket.chat/core-typings'; import { Box } from '@rocket.chat/fuselage'; -import { useUserId, useTranslation, useRouter, useUserPresence } from '@rocket.chat/ui-contexts'; +import { useUserId, useRouter, useUserPresence } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; import MarkdownText from '../../../components/MarkdownText'; +import { UserStatusText } from '../../../components/UserStatusText'; import { useCanEditRoom } from '../contextualBar/Info/hooks/useCanEditRoom'; type RoomTopicProps = { @@ -11,17 +13,18 @@ type RoomTopicProps = { }; const RoomTopic = ({ room }: RoomTopicProps) => { - const t = useTranslation(); + const { t } = useTranslation(); const canEdit = useCanEditRoom(room); const userId = useUserId(); - const directUserId = room.uids?.filter((uid) => uid !== userId).shift(); + const directUserId = room.uids?.filter((uid) => uid !== userId).shift() ?? (room.uids?.length === 1 ? userId : undefined); const directUserData = useUserPresence(directUserId); const router = useRouter(); const currentRoute = router.getLocationPathname(); const href = isTeamRoom(room) ? `${currentRoute}/team-info` : `${currentRoute}/channel-settings`; - const topic = isDirectMessageRoom(room) && (room.uids?.length ?? 0) < 3 ? directUserData?.statusText : room.topic; + const isDirect = isDirectMessageRoom(room) && (room.uids?.length ?? 0) < 3; + const topic = isDirect ? directUserData?.statusText : room.topic; const canEditTopic = canEdit && (isPublicRoom(room) || isPrivateRoom(room)); if (!topic && !canEditTopic) { @@ -36,6 +39,10 @@ const RoomTopic = ({ room }: RoomTopicProps) => { ); } + if (isDirect) { + return ; + } + return ; }; diff --git a/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx b/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx index d1b0041eeec95..6ce8829925567 100644 --- a/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx +++ b/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx @@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next'; import LocalTime from '../../../components/LocalTime'; import { UserCard, UserCardAction, UserCardRole, UserCardSkeleton } from '../../../components/UserCard'; import { ReactiveUserStatus } from '../../../components/UserStatus'; +import { UserStatusText } from '../../../components/UserStatusText'; import { useUserInfoQuery } from '../../../hooks/useUserInfoQuery'; import { useMemberExists } from '../../hooks/useMemberExists'; import { useUserInfoActions } from '../hooks/useUserInfoActions'; @@ -45,7 +46,8 @@ const UserCardWithData = ({ username, rid, onOpenUserInfo, onClose }: UserCardWi _id, name, roles = defaultValue, - statusText = defaultValue, + statusText, + statusExpiresAt, bio = defaultValue, utcOffset = defaultValue, nickname, @@ -62,7 +64,7 @@ const UserCardWithData = ({ username, rid, onOpenUserInfo, onClose }: UserCardWi etag: avatarETag, localTime: utcOffset && Number.isInteger(utcOffset) && , status: _id && , - customStatus: statusText, + customStatus: statusText && , nickname, freeSwitchExtension, }; diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersItem.tsx b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersItem.tsx index ef1448d50ff64..4a8b0f206ed77 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersItem.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersItem.tsx @@ -12,6 +12,7 @@ import { } from '@rocket.chat/fuselage'; import { usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; import { UserAvatar } from '@rocket.chat/ui-avatar'; +import { useUserPresence } from '@rocket.chat/ui-contexts'; import type { ReactElement, MouseEvent } from 'react'; import { useState } from 'react'; @@ -19,6 +20,7 @@ import UserActions from './RoomMembersActions'; import { getUserDisplayNames } from '../../../../../lib/getUserDisplayNames'; import InvitationBadge from '../../../../components/InvitationBadge'; import { ReactiveUserStatus } from '../../../../components/UserStatus'; +import { useStatusTooltip } from '../../../../components/UserStatusText'; import { usePreventPropagation } from '../../../../hooks/usePreventPropagation'; import type { RoomMember } from '../../../hooks/useMembersList'; @@ -41,24 +43,37 @@ const RoomMembersItem = ({ reload, useRealName, }: RoomMembersItemProps): ReactElement => { - const [showButton, setShowButton] = useState(); + const [showButton, setShowButton] = useState(false); const isReduceMotionEnabled = usePrefersReducedMotion(); const isInvited = subscription?.status === 'INVITED'; const invitationDate = isInvited ? subscription?.ts : undefined; - const handleMenuEvent = { - [isReduceMotionEnabled ? 'onMouseEnter' : 'onTransitionEnd']: setShowButton, - }; - const preventPropagation = usePreventPropagation(); - const [nameOrUsername, displayUsername] = getUserDisplayNames(name, username, useRealName); + const presenceData = useUserPresence(_id); + const { handleMouseEnter, handleMouseLeave } = useStatusTooltip( + presenceData?.statusText, + presenceData?.statusExpiresAt, + presenceData?.status, + ); + + const handleMenuEvent = isReduceMotionEnabled + ? { + onMouseEnter: (e: MouseEvent) => { + handleMouseEnter(e); + setShowButton(true); + }, + } + : { onTransitionEnd: () => setShowButton(true) }; + return (