diff --git a/.changeset/odd-steaks-pull.md b/.changeset/odd-steaks-pull.md new file mode 100644 index 0000000000000..2f0944e6fd7bf --- /dev/null +++ b/.changeset/odd-steaks-pull.md @@ -0,0 +1,11 @@ +--- +'@rocket.chat/core-services': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/models': minor +'@rocket.chat/rest-typings': minor +--- + +Adds status visibility, letting users hide their presence and status message from specific people they choose. Blocked people see that user as offline, indistinguishable from genuinely offline, and the block can be lifted at any time — changes apply live, without a reload. diff --git a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx index 2535e940235e2..8a65bcb2d4e58 100644 --- a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx +++ b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx @@ -14,6 +14,7 @@ export type UserAutoCompleteMultipleProps = { value: Array | undefined; placeholder?: string; federated?: boolean; + exceptions?: string[]; error?: string; } & Omit, 'is' | 'onChange' | 'value'>; @@ -30,7 +31,7 @@ type UserAutoCompleteOptions = { const matrixRegex = new RegExp('@(.*:.*)'); const UserAutoCompleteMultiple = forwardRef( - ({ onChange, value, placeholder, federated, ...props }, ref) => { + ({ onChange, value, placeholder, federated, exceptions, ...props }, ref) => { const [filter, setFilter] = useState(''); const [selectedCache, setSelectedCache] = useState({}); @@ -38,10 +39,10 @@ const UserAutoCompleteMultiple = forwardRef { - const users = await getUsers({ selector: JSON.stringify({ term: debouncedFilter }) }); + const users = await getUsers({ selector: JSON.stringify({ term: debouncedFilter, ...(exceptions?.length && { exceptions }) }) }); const options = users.items.map((item): [string, UserAutoCompleteOptionType] => [item.username, item]); // Add extra option if filter text matches `username:server` diff --git a/apps/meteor/client/lib/queryKeys.ts b/apps/meteor/client/lib/queryKeys.ts index c703743e567d6..da9979eef2da2 100644 --- a/apps/meteor/client/lib/queryKeys.ts +++ b/apps/meteor/client/lib/queryKeys.ts @@ -122,7 +122,8 @@ export const usersQueryKeys = { all: ['users'] as const, userInfo: ({ uid, username }: { uid?: IUser['_id']; username?: IUser['username'] }) => [...usersQueryKeys.all, 'info', { uid, username }] as const, - userAutoComplete: (filter: string, federated: boolean) => [...usersQueryKeys.all, 'autocomplete', filter, federated] as const, + userAutoComplete: (filter: string, federated: boolean, exceptions: string[] = []) => + [...usersQueryKeys.all, 'autocomplete', filter, federated, exceptions] as const, }; export const teamsQueryKeys = { diff --git a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusVisibilityModal.tsx b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusVisibilityModal.tsx new file mode 100644 index 0000000000000..350da348f60e6 --- /dev/null +++ b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusVisibilityModal.tsx @@ -0,0 +1,79 @@ +import { Box } from '@rocket.chat/fuselage'; +import { Field, FieldGroup, FieldHint, FieldLabel, FieldRow } from '@rocket.chat/fuselage-forms'; +import { GenericModal } from '@rocket.chat/ui-client'; +import { useEndpoint, useToastMessageDispatch, useUser } from '@rocket.chat/ui-contexts'; +import type { ComponentProps } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import UserAutoCompleteMultiple from '../../../components/UserAutoCompleteMultiple'; + +export type EditStatusVisibilityModalProps = { + onClose: () => void; +}; + +type StatusVisibilityFormValues = { + statusVisibilityDenied: string[]; +}; + +export const EditStatusVisibilityModal = ({ onClose }: EditStatusVisibilityModalProps) => { + const { t } = useTranslation(); + const user = useUser(); + const dispatchToastMessage = useToastMessageDispatch(); + const setPreferences = useEndpoint('POST', '/v1/users.setPreferences'); + + const { + control, + handleSubmit, + formState: { isDirty, isSubmitting }, + } = useForm({ + defaultValues: { + statusVisibilityDenied: user?.settings?.preferences?.statusVisibilityDenied ?? [], + }, + }); + + const handleSave = async ({ statusVisibilityDenied }: StatusVisibilityFormValues) => { + try { + await setPreferences({ data: { statusVisibilityDenied } }); + dispatchToastMessage({ type: 'success', message: t('Accounts_StatusVisibility_Saved') }); + onClose(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }; + + return ( + ) => } + > + + + {t('Accounts_StatusVisibility_HideFromUsers')} + + ( + + )} + /> + + {t('Accounts_StatusVisibility_HideFromUsers_Description')} + + + + ); +}; + +export default EditStatusVisibilityModal; diff --git a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx index 618dde497dcef..0e7519fba569b 100644 --- a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx +++ b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx @@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useCustomStatusModalHandler } from './useCustomStatusModalHandler'; +import { useStatusVisibilityModalHandler } from './useStatusVisibilityModalHandler'; import MarkdownText from '../../../../components/MarkdownText'; import { UserStatus } from '../../../../components/UserStatus'; import { useExpirationText } from '../../../../hooks/useExpirationText'; @@ -76,6 +77,8 @@ export const useStatusItems = (user?: IUser): GenericMenuItemProps[] => { const handleStatusDisabledModal = useStatusDisabledModal(); const handleCustomStatus = useCustomStatusModalHandler(); + const handleStatusVisibility = useStatusVisibilityModalHandler(); + const statusVisibilityEnabled = useSetting('Accounts_StatusVisibility_Enabled', false); const customStatusExpiration = useExpirationText(user?.statusExpiresAt); return useMemo(() => { @@ -159,7 +162,18 @@ export const useStatusItems = (user?: IUser): GenericMenuItemProps[] => { ) : []; - return [...items, ...presetItems, ...customItems]; + const actionItems: GenericMenuItemProps[] = []; + + if (statusVisibilityEnabled) { + actionItems.push({ + id: 'status-visibility-edit', + icon: 'eye-off', + content: t('Accounts_StatusVisibility_HideFrom'), + onClick: handleStatusVisibility, + }); + } + + return [...items, ...presetItems, ...customItems, ...actionItems]; }, [ presenceDisabled, allowUserStatusMessageChange, @@ -170,6 +184,8 @@ export const useStatusItems = (user?: IUser): GenericMenuItemProps[] => { customStatusExpiration, statuses, handleCustomStatus, + handleStatusVisibility, + statusVisibilityEnabled, setStatusMutation, ]); }; diff --git a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusVisibilityModalHandler.tsx b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusVisibilityModalHandler.tsx new file mode 100644 index 0000000000000..893d4f6f70ed4 --- /dev/null +++ b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusVisibilityModalHandler.tsx @@ -0,0 +1,9 @@ +import { useSetModal } from '@rocket.chat/ui-contexts'; + +import { EditStatusVisibilityModal } from '../EditStatusVisibilityModal'; + +export const useStatusVisibilityModalHandler = () => { + const setModal = useSetModal(); + + return () => setModal( setModal(null)} />); +}; diff --git a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx index 3537bc3d55e39..c7a1a5c5f6a47 100644 --- a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx +++ b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx @@ -23,6 +23,7 @@ import { useEndpoint, useUser, useLayout, + useSetting, } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; import type { AllHTMLAttributes, ChangeEvent } from 'react'; @@ -32,6 +33,7 @@ import { Controller, useFormContext } from 'react-hook-form'; import type { AccountProfileFormValues } from './getProfileInitialValues'; import { useAccountProfileSettings } from './useAccountProfileSettings'; import { getUserEmailAddress } from '../../../../lib/getUserEmailAddress'; +import UserAutoCompleteMultiple from '../../../components/UserAutoCompleteMultiple'; import UserStatusMenu from '../../../components/UserStatusMenu'; import UserAvatarEditor from '../../../components/avatar/UserAvatarEditor'; import { useUpdateAvatar } from '../../../hooks/useUpdateAvatar'; @@ -44,6 +46,8 @@ const AccountProfileForm = (props: AllHTMLAttributes) => { const dispatchToastMessage = useToastMessageDispatch(); const { isMobile } = useLayout(); + const setPreferences = useEndpoint('POST', '/v1/users.setPreferences'); + const statusVisibilityEnabled = useSetting('Accounts_StatusVisibility_Enabled', false); const checkUsernameAvailability = useEndpoint('GET', '/v1/users.checkUsernameAvailability'); const sendConfirmationEmail = useEndpoint('POST', '/v1/users.sendConfirmationEmail'); @@ -138,6 +142,7 @@ const AccountProfileForm = (props: AllHTMLAttributes) => { nickname, bio, customFields, + statusVisibilityDenied, } = values; const expiresAt = STATUS_DURATION_OPTIONS.find((o) => o.value === statusDuration)?.getExpiresAt?.({ @@ -165,6 +170,10 @@ const AccountProfileForm = (props: AllHTMLAttributes) => { customFields, }); + if (dirtyFields.statusVisibilityDenied) { + await setPreferences({ data: { statusVisibilityDenied } }); + } + if (statusDirty) { await setUserStatus({ status: statusType, @@ -338,6 +347,26 @@ const AccountProfileForm = (props: AllHTMLAttributes) => { {errors.statusDuration && {errors.statusDuration.message}} {t('Status_new_status_warning')} + {statusVisibilityEnabled && ( + + {t('Accounts_StatusVisibility_HideStatusFromUsers')} + + ( + + )} + /> + + {t('Accounts_StatusVisibility_HideFromUsers_Description')} + + )} {t('Nickname')} diff --git a/apps/meteor/client/views/account/profile/getProfileInitialValues.ts b/apps/meteor/client/views/account/profile/getProfileInitialValues.ts index a1132a7b0d618..cf8b22db9df55 100644 --- a/apps/meteor/client/views/account/profile/getProfileInitialValues.ts +++ b/apps/meteor/client/views/account/profile/getProfileInitialValues.ts @@ -13,6 +13,7 @@ export type AccountProfileFormValues = { bio: string; customFields: Record; nickname: string; + statusVisibilityDenied: string[]; } & UserStatusInitialValues; export const getProfileInitialValues = (user: IUser | null): AccountProfileFormValues => { @@ -25,6 +26,7 @@ export const getProfileInitialValues = (user: IUser | null): AccountProfileFormV bio: user?.bio ?? '', customFields: user?.customFields ?? {}, nickname: user?.nickname ?? '', + statusVisibilityDenied: user?.settings?.preferences?.statusVisibilityDenied ?? [], ...getUserStatusInitialValues(user), }; }; diff --git a/apps/meteor/jest.config.ts b/apps/meteor/jest.config.ts index 6eca9ca6c4a26..3210637a256a5 100644 --- a/apps/meteor/jest.config.ts +++ b/apps/meteor/jest.config.ts @@ -52,6 +52,8 @@ export default { '/server/api/v1/middlewares/*.spec.ts', '/server/lib/cloud/version-check/**/*.spec.ts', '/server/lib/auth-providers/apple/**.spec.ts', + '/server/lib/statusVisibility/*.spec.ts', + '/server/services/statusVisibility/*.spec.ts', ], coveragePathIgnorePatterns: ['/node_modules/'], }, diff --git a/apps/meteor/server/api/lib/getUserInfo.ts b/apps/meteor/server/api/lib/getUserInfo.ts index adfbedefe4bef..685555a8c81de 100644 --- a/apps/meteor/server/api/lib/getUserInfo.ts +++ b/apps/meteor/server/api/lib/getUserInfo.ts @@ -2,6 +2,7 @@ import { isOAuthUser, type IMeApiUser, type IUser, type IUserEmail, type IUserCa import semver from 'semver'; import { Info } from '../../../app/utils/rocketchat.info'; +import { resolveUsersByIds } from '../../lib/statusVisibility/resolveUsers'; import { getURL } from '../../lib/utils/getURL'; import { getUserPreference } from '../../lib/utils/lib/getUserPreference'; import { settings } from '../../settings'; @@ -14,7 +15,7 @@ const isVerifiedEmail = (me: IUser): false | IUserEmail | undefined => { return me.emails.find((email) => email.verified); }; -const getUserPreferences = async (me: IUser): Promise> => { +const getPreferencesWithDefaults = async (me: IUser): Promise> => { const defaultUserSettingPrefix = 'Accounts_Default_User_Preferences_'; const allDefaultUserSettings = settings.getByRegexp(new RegExp(`^${defaultUserSettingPrefix}.*$`)); @@ -87,7 +88,18 @@ const getUserCalendar = (email: false | IUserEmail | undefined): IUserCalendar = export async function getUserInfo(me: IUser, pullPreferences = true): Promise { const verifiedEmail = isVerifiedEmail(me); - const userPreferences = me.settings?.preferences ?? {}; + const { statusVisibilityDenied, ...savedPreferences } = me.settings?.preferences ?? {}; + + const preferences = pullPreferences + ? { + ...(await getPreferencesWithDefaults(me)), + ...savedPreferences, + ...(settings.get('Accounts_StatusVisibility_Enabled') && + statusVisibilityDenied?.length && { + statusVisibilityDenied: (await resolveUsersByIds(statusVisibilityDenied)).usernames, + }), + } + : undefined; return { ...me, @@ -95,7 +107,7 @@ export async function getUserInfo(me: IUser, pullPreferences = true): Promise { + it('should return false for a query without status fields', () => { + expect(queryFiltersStatus({ username: { $regex: 'ana' } })).to.be.equal(false); + }); + + it('should return true for a top level status filter', () => { + expect(queryFiltersStatus({ status: 'online' })).to.be.equal(true); + }); + + it('should return true for a status filter nested in $or', () => { + expect(queryFiltersStatus({ $or: [{ username: { $regex: '' } }, { status: 'online' }] })).to.be.equal(true); + }); + + it('should return true for a status filter nested in $and inside $or', () => { + expect(queryFiltersStatus({ $or: [{ $and: [{ statusText: { $regex: 'lunch' } }] }] })).to.be.equal(true); + }); + + it('should return true for any redacted status field', () => { + for (const field of ['statusText', 'statusSource', 'statusExpiresAt', 'statusDefault', 'statusConnection']) { + expect(queryFiltersStatus({ [field]: { $exists: true } })).to.be.equal(true); + } + }); + + it('should return false for null and primitive values', () => { + expect(queryFiltersStatus(null)).to.be.equal(false); + expect(queryFiltersStatus('status')).to.be.equal(false); + expect(queryFiltersStatus(undefined)).to.be.equal(false); + }); +}); diff --git a/apps/meteor/server/api/lib/queryFiltersStatus.ts b/apps/meteor/server/api/lib/queryFiltersStatus.ts new file mode 100644 index 0000000000000..d7c68da02dba5 --- /dev/null +++ b/apps/meteor/server/api/lib/queryFiltersStatus.ts @@ -0,0 +1,11 @@ +export function queryFiltersStatus(query: unknown): boolean { + if (Array.isArray(query)) { + return query.some(queryFiltersStatus); + } + + if (query === null || typeof query !== 'object') { + return false; + } + + return Object.entries(query).some(([key, value]) => key.startsWith('status') || queryFiltersStatus(value)); +} diff --git a/apps/meteor/server/api/v1/im.ts b/apps/meteor/server/api/v1/im.ts index 79b79d3db95ab..8f91f3b8aac64 100644 --- a/apps/meteor/server/api/v1/im.ts +++ b/apps/meteor/server/api/v1/im.ts @@ -26,6 +26,8 @@ import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; import { eraseRoom } from '../../lib/eraseRoom'; import { openRoom } from '../../lib/openRoom'; import { getRoomByNameOrIdWithOptionToJoin } from '../../lib/rooms/getRoomByNameOrIdWithOptionToJoin'; +import { getUsersHiddenFrom } from '../../lib/statusVisibility/hiddenUsers'; +import { redactStatus } from '../../lib/statusVisibility/redactStatus'; import { blockUserMethod } from '../../lib/users/blockUser'; import { unblockUserMethod } from '../../lib/users/unblockUser'; import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser'; @@ -549,8 +551,10 @@ const dmMembersAction = (_path: Path): TypedAction