diff --git a/apps/meteor/client/components/message/toolbar/MessageToolbarActionMenu.tsx b/apps/meteor/client/components/message/toolbar/MessageToolbarActionMenu.tsx index 47f7ac67c2c17..76c71994006d2 100644 --- a/apps/meteor/client/components/message/toolbar/MessageToolbarActionMenu.tsx +++ b/apps/meteor/client/components/message/toolbar/MessageToolbarActionMenu.tsx @@ -50,14 +50,18 @@ const MessageToolbarActionMenu = ({ message, context, room, subscription, onChan usePinMessageAction(message, { room, subscription }), useStarMessageAction(message, { room }), useUnstarMessageAction(message, { room }), - usePermalinkAction(message, { id: 'permalink-star', context: ['starred'], order: 10 }), - usePermalinkAction(message, { id: 'permalink-pinned', context: ['pinned'], order: 5 }), - usePermalinkAction(message, { - id: 'permalink', - context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'], - type: 'duplication', - order: 5, - }), + usePermalinkAction(message, { id: 'permalink-star', context: ['starred'], order: 10 }, { room }), + usePermalinkAction(message, { id: 'permalink-pinned', context: ['pinned'], order: 5 }, { room }), + usePermalinkAction( + message, + { + id: 'permalink', + context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'], + type: 'duplication', + order: 5, + }, + { room }, + ), useFollowMessageAction(message, { room, context }), useUnFollowMessageAction(message, { room, context }), useMarkAsUnreadMessageAction(message, { room, subscription }), diff --git a/apps/meteor/client/components/message/toolbar/items/DefaultItems.tsx b/apps/meteor/client/components/message/toolbar/items/DefaultItems.tsx index 28d536c6be9c2..bcf15c1fdfd28 100644 --- a/apps/meteor/client/components/message/toolbar/items/DefaultItems.tsx +++ b/apps/meteor/client/components/message/toolbar/items/DefaultItems.tsx @@ -17,7 +17,7 @@ const DefaultItems = ({ message, room, subscription }: DefaultItemsProps) => { - + ); }; diff --git a/apps/meteor/client/components/message/toolbar/items/MobileItems.tsx b/apps/meteor/client/components/message/toolbar/items/MobileItems.tsx index 462f356998ea5..c39e4c144ce2f 100644 --- a/apps/meteor/client/components/message/toolbar/items/MobileItems.tsx +++ b/apps/meteor/client/components/message/toolbar/items/MobileItems.tsx @@ -18,7 +18,7 @@ const MobileItems = ({ message, room, subscription }: MobileItemsProps) => { - + ); diff --git a/apps/meteor/client/components/message/toolbar/items/ThreadsItems.tsx b/apps/meteor/client/components/message/toolbar/items/ThreadsItems.tsx index 56f05cb13d681..dea0956505302 100644 --- a/apps/meteor/client/components/message/toolbar/items/ThreadsItems.tsx +++ b/apps/meteor/client/components/message/toolbar/items/ThreadsItems.tsx @@ -16,7 +16,7 @@ const ThreadsItems = ({ message, room, subscription }: ThreadsItemsProps) => { <> - + ); diff --git a/apps/meteor/client/components/message/toolbar/items/actions/ForwardMessageAction.spec.tsx b/apps/meteor/client/components/message/toolbar/items/actions/ForwardMessageAction.spec.tsx new file mode 100644 index 0000000000000..81873898db7ae --- /dev/null +++ b/apps/meteor/client/components/message/toolbar/items/actions/ForwardMessageAction.spec.tsx @@ -0,0 +1,154 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; + +import ForwardMessageAction from './ForwardMessageAction'; +import FakeRoomProvider from '../../../../../../tests/mocks/client/FakeRoomProvider'; +import { createFakeRoom } from '../../../../../../tests/mocks/data'; + +// Mock the getPermaLink function +jest.mock('../../../../../lib/getPermaLink', () => ({ + getPermaLink: jest.fn(() => Promise.resolve(null)), +})); + +jest.mock('../../../../../views/room/modals/ForwardMessageModal', () => ({ + getPermaLink: jest.fn(() => null), +})); + +const appRoot = mockAppRoot() + .withTranslations('en', 'core', { + Forward_message: 'Forward message', + Action_not_available_encrypted_content: 'Action not available for encrypted content', + }) + .build(); + +const createMockMessage = (overrides: any = {}) => ({ + _id: 'message-id', + rid: 'room-id', + msg: 'Test message', + ts: new Date(), + u: { _id: 'user-id', username: 'testuser' }, + ...overrides, +}); + +describe('ForwardMessageAction', () => { + it('should render the forward action for normal messages', () => { + const message = createMockMessage(); + const room = createFakeRoom(); + + render( + + + , + { wrapper: appRoot }, + ); + + expect(screen.getByRole('button', { name: 'Forward message' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Forward message' })).not.toBeDisabled(); + }); + + it('should be disabled for encrypted messages', () => { + const message = createMockMessage({ + t: 'e2e', + e2e: 'encrypted', + }); + const room = createFakeRoom(); + + render( + + + , + { wrapper: appRoot }, + ); + + const button = screen.getByRole('button', { name: 'Action not available for encrypted content' }); + expect(button).toBeDisabled(); + }); + + it('should be disabled for ABAC rooms', () => { + const message = createMockMessage(); + const room = createFakeRoom({ + // @ts-expect-error - abacAttributes is not yet implemented in IRoom type + abacAttributes: { someAttribute: 'value' }, + }); + + render( + + + , + { wrapper: appRoot }, + ); + + const button = screen.getByRole('button', { name: 'Not_available_for_ABAC_enabled_rooms' }); + expect(button).toBeDisabled(); + }); + + it('should be disabled for both encrypted messages and ABAC rooms', () => { + const message = createMockMessage({ + t: 'e2e', + e2e: 'encrypted', + }); + const room = createFakeRoom({ + // @ts-expect-error - abacAttributes is not yet implemented in IRoom type + abacAttributes: { someAttribute: 'value' }, + }); + + render( + + + , + { wrapper: appRoot }, + ); + + const button = screen.getByRole('button', { name: 'Action not available for encrypted content' }); + expect(button).toBeDisabled(); + }); + + it('should have no accessibility violations for normal messages', async () => { + const message = createMockMessage(); + const room = createFakeRoom(); + + const { container } = render( + + + , + { wrapper: appRoot }, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should have no accessibility violations for encrypted messages', async () => { + const message = createMockMessage({ + t: 'e2e', + e2e: 'encrypted', + }); + const room = createFakeRoom(); + + const { container } = render( + + + , + { wrapper: appRoot }, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should have no accessibility violations for ABAC rooms', async () => { + const message = createMockMessage(); + const room = createFakeRoom({ + // @ts-expect-error - abacAttributes is not yet implemented in IRoom type + abacAttributes: { someAttribute: 'value' }, + }); + + const { container } = render( + + + , + { wrapper: appRoot }, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/apps/meteor/client/components/message/toolbar/items/actions/ForwardMessageAction.tsx b/apps/meteor/client/components/message/toolbar/items/actions/ForwardMessageAction.tsx index 1e92189fb879a..161edabde7dc8 100644 --- a/apps/meteor/client/components/message/toolbar/items/actions/ForwardMessageAction.tsx +++ b/apps/meteor/client/components/message/toolbar/items/actions/ForwardMessageAction.tsx @@ -1,5 +1,7 @@ -import { type IMessage, isE2EEMessage } from '@rocket.chat/core-typings'; +import { isE2EEMessage } from '@rocket.chat/core-typings'; +import type { IRoom, IMessage } from '@rocket.chat/core-typings'; import { useSetModal } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { getPermaLink } from '../../../../../lib/getPermaLink'; @@ -8,21 +10,34 @@ import MessageToolbarItem from '../../MessageToolbarItem'; type ForwardMessageActionProps = { message: IMessage; + room: IRoom; }; -const ForwardMessageAction = ({ message }: ForwardMessageActionProps) => { +const ForwardMessageAction = ({ message, room }: ForwardMessageActionProps) => { const setModal = useSetModal(); const { t } = useTranslation(); const encrypted = isE2EEMessage(message); + // @ts-expect-error to be implemented + const isABACEnabled = !!room.abacAttributes; + + const getTitle = useMemo(() => { + if (encrypted) { + return t('Action_not_available_encrypted_content', { action: t('Forward_message') }); + } + if (isABACEnabled) { + return t('Not_available_for_ABAC_enabled_rooms'); + } + return t('Forward_message'); + }, [encrypted, isABACEnabled, t]); return ( { const permalink = await getPermaLink(message._id); setModal( diff --git a/apps/meteor/client/components/message/toolbar/usePermalinkAction.spec.ts b/apps/meteor/client/components/message/toolbar/usePermalinkAction.spec.ts new file mode 100644 index 0000000000000..ef5911c2c2395 --- /dev/null +++ b/apps/meteor/client/components/message/toolbar/usePermalinkAction.spec.ts @@ -0,0 +1,171 @@ +import type { MessageActionContext } from '@rocket.chat/apps-engine/definition/ui'; +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { renderHook } from '@testing-library/react'; + +import { usePermalinkAction } from './usePermalinkAction'; +import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction'; +import { createFakeUser } from '../../../../tests/mocks/data'; + +// Mock the getPermaLink function +jest.mock('../../../lib/getPermaLink', () => ({ + getPermaLink: jest.fn(() => Promise.resolve('https://example.com/permalink')), +})); + +const user = createFakeUser({ + _id: 'current-user-id', + username: 'currentuser', + active: true, + roles: ['admin'], + type: 'user', +}); + +const appRoot = mockAppRoot() + .withUser(user) + .withTranslations('en', 'core', { + Copy_link: 'Copy link', + Copied: 'Copied', + }) + .build(); + +const createMockMessage = (overrides: Partial = {}): IMessage => ({ + _id: 'message-id', + rid: 'room-id', + msg: 'Test message', + ts: new Date(), + u: { _id: 'user-id', username: 'testuser' }, + _updatedAt: new Date(), + channels: [], + file: { _id: 'file-id', name: 'file.txt', type: 'text/plain', size: 100, format: 'text/plain' }, + mentions: [], + reactions: {}, + starred: [], + ...overrides, +}); + +const createMockRoom = (overrides: Partial = {}): IRoom => ({ + _id: 'room-id', + t: 'c' as const, + name: 'test-room', + msgs: 0, + u: { _id: 'user-id', username: 'testuser' }, + usersCount: 1, + _updatedAt: new Date(), + ...overrides, +}); + +describe('usePermalinkAction', () => { + it('should be enabled for normal messages', () => { + const message = createMockMessage(); + const room = createMockRoom(); + const config = { + id: 'permalink', + context: ['message', 'message-mobile'], + type: 'communication', + order: 0, + } as { context: MessageActionContext[]; order: number } & Pick; + + const { result } = renderHook(() => usePermalinkAction(message, config, { room }), { wrapper: appRoot }); + + expect(result.current).toEqual({ + id: 'permalink', + icon: 'permalink', + label: 'Copy_link', + context: ['message', 'message-mobile'], + type: 'communication', + action: expect.any(Function), + order: 0, + group: 'menu', + disabled: false, + }); + }); + it('should be disabled for encrypted messages', () => { + const message = createMockMessage({ + t: 'e2e', + e2e: 'done', + }); + const room = createMockRoom(); + const config = { + id: 'permalink', + context: ['message', 'message-mobile'], + type: 'communication', + order: 0, + } as { context: MessageActionContext[]; order: number } & Pick; + + const { result } = renderHook(() => usePermalinkAction(message, config, { room }), { wrapper: appRoot }); + + expect(result.current).toEqual({ + id: 'permalink', + icon: 'permalink', + label: 'Copy_link', + context: ['message', 'message-mobile'], + type: 'communication', + action: expect.any(Function), + order: 0, + group: 'menu', + disabled: true, + tooltip: 'Action_not_available_encrypted_content', + }); + }); + + it('should be disabled for ABAC rooms', () => { + const message = createMockMessage(); + const room = createMockRoom({ + // @ts-expect-error to be implemented + abacAttributes: { someAttribute: 'value' }, + }); + const config = { + id: 'permalink', + context: ['message', 'message-mobile'], + type: 'communication', + order: 0, + } as { context: MessageActionContext[]; order: number } & Pick; + + const { result } = renderHook(() => usePermalinkAction(message, config, { room }), { wrapper: appRoot }); + + expect(result.current).toEqual({ + id: 'permalink', + icon: 'permalink', + label: 'Copy_link', + context: ['message', 'message-mobile'], + type: 'communication', + action: expect.any(Function), + order: 0, + group: 'menu', + disabled: true, + tooltip: 'Not_available_for_ABAC_enabled_rooms', + }); + }); + + it('should be disabled for both encrypted messages and ABAC rooms', () => { + const message = createMockMessage({ + t: 'e2e', + e2e: 'done', + }); + const room = createMockRoom({ + // @ts-expect-error to be implemented + abacAttributes: { someAttribute: 'value' }, + }); + const config = { + id: 'permalink', + context: ['message', 'message-mobile'], + type: 'communication', + order: 0, + } as { context: MessageActionContext[]; order: number } & Pick; + + const { result } = renderHook(() => usePermalinkAction(message, config, { room }), { wrapper: appRoot }); + + expect(result.current).toEqual({ + id: 'permalink', + icon: 'permalink', + label: 'Copy_link', + context: ['message', 'message-mobile'], + type: 'communication', + action: expect.any(Function), + order: 0, + group: 'menu', + disabled: true, + tooltip: 'Action_not_available_encrypted_content', + }); + }); +}); diff --git a/apps/meteor/client/components/message/toolbar/usePermalinkAction.ts b/apps/meteor/client/components/message/toolbar/usePermalinkAction.ts index 6ddc0a13139a2..f9b59abca1d8c 100644 --- a/apps/meteor/client/components/message/toolbar/usePermalinkAction.ts +++ b/apps/meteor/client/components/message/toolbar/usePermalinkAction.ts @@ -1,6 +1,7 @@ -import type { IMessage } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { isE2EEMessage } from '@rocket.chat/core-typings'; import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import type { MessageActionConfig, MessageActionContext } from '../../../../app/ui-utils/client/lib/MessageAction'; @@ -9,12 +10,24 @@ import { getPermaLink } from '../../../lib/getPermaLink'; export const usePermalinkAction = ( message: IMessage, { id, context, type, order }: { context: MessageActionContext[]; order: number } & Pick, + { room }: { room: IRoom }, ): MessageActionConfig | null => { const { t } = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); + // @ts-expect-error - to be implemented + const isABACEnabled = !!room.abacAttributes; const encrypted = isE2EEMessage(message); + const tooltip = useMemo(() => { + if (encrypted) { + return t('Action_not_available_encrypted_content', { action: t('Copy_link') }); + } + if (isABACEnabled) { + return t('Not_available_for_ABAC_enabled_rooms'); + } + return null; + }, [encrypted, isABACEnabled, t]); return { id, @@ -33,7 +46,7 @@ export const usePermalinkAction = ( }, order, group: 'menu', - disabled: encrypted, - ...(encrypted && { tooltip: t('Action_not_available_encrypted_content', { action: t('Copy_link') }) }), + disabled: encrypted || isABACEnabled, + ...(tooltip && { tooltip }), }; }; diff --git a/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts b/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts index 98532cc6a1ddd..33536db3deefc 100644 --- a/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts +++ b/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts @@ -16,6 +16,8 @@ export const useReplyInDMAction = ( const user = useUser(); const router = useRouter(); const encrypted = isE2EEMessage(message); + // @ts-expect-error - abacAttributes is not yet implemented in IRoom type + const isABACEnabled = !!room.abacAttributes; const canCreateDM = usePermission('create-d'); const isLayoutEmbedded = useEmbeddedLayout(); const { t } = useTranslation(); @@ -37,6 +39,16 @@ export const useReplyInDMAction = ( ); const dmSubs = Subscriptions.use(useShallow((state) => state.find(subsPredicate))); + const tooltip = useMemo(() => { + if (encrypted) { + return t('Action_not_available_encrypted_content', { action: t('Reply_in_direct_message') }); + } + if (isABACEnabled) { + return t('Not_available_for_ABAC_enabled_rooms'); + } + return null; + }, [encrypted, isABACEnabled, t]); + const canReplyInDM = useMemo(() => { if (!subscription || room.t === 'd' || room.t === 'l' || isLayoutEmbedded) { return false; @@ -71,7 +83,7 @@ export const useReplyInDMAction = ( }, order: 0, group: 'menu', - disabled: encrypted, - ...(encrypted && { tooltip: t('Action_not_available_encrypted_content', { action: t('Reply_in_direct_message') }) }), + disabled: encrypted || isABACEnabled, + ...(tooltip && { tooltip }), }; }; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index b6115227e137a..dcdbe01b31e34 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3739,6 +3739,7 @@ "Nonprofit": "Nonprofit", "Normal": "Normal", "Not_Available": "Not Available", + "Not_available_for_ABAC_enabled_rooms": "Not available in ABAC-managed rooms", "Not_Following": "Not Following", "Not_Imported_Messages_Title": "The following messages were not imported successfully", "Not_Visible_To_Workspace": "Not visible to workspace",