diff --git a/.changeset/hungry-games-smoke.md b/.changeset/hungry-games-smoke.md new file mode 100644 index 0000000000000..350c33091aaaf --- /dev/null +++ b/.changeset/hungry-games-smoke.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Blocks edit message action for Off-The-Record (OTR) messages. diff --git a/apps/meteor/app/ui/client/lib/ChatMessages.ts b/apps/meteor/app/ui/client/lib/ChatMessages.ts index 3745864061f42..a675c413fe18f 100644 --- a/apps/meteor/app/ui/client/lib/ChatMessages.ts +++ b/apps/meteor/app/ui/client/lib/ChatMessages.ts @@ -64,7 +64,7 @@ export class ChatMessages implements ChatAPI { } if (!this.currentEditing) { - let lastMessage = await this.data.findLastOwnMessage(); + let lastMessage = await this.data.findPreviousOwnMessage(); // Videoconf messages should not be edited if (lastMessage && isVideoConfMessage(lastMessage)) { diff --git a/apps/meteor/client/components/message/toolbar/useEditMessageAction.ts b/apps/meteor/client/components/message/toolbar/useEditMessageAction.ts index 92df4f7cea35a..c19a2c7b8f5ec 100644 --- a/apps/meteor/client/components/message/toolbar/useEditMessageAction.ts +++ b/apps/meteor/client/components/message/toolbar/useEditMessageAction.ts @@ -1,4 +1,4 @@ -import { isRoomFederated } from '@rocket.chat/core-typings'; +import { isOTRAckMessage, isOTRMessage, isRoomFederated } from '@rocket.chat/core-typings'; import type { IRoom, IMessage, ISubscription } from '@rocket.chat/core-typings'; import { usePermission, useSetting, useUser } from '@rocket.chat/ui-contexts'; import moment from 'moment'; @@ -26,6 +26,10 @@ export const useEditMessageAction = ( return message.u._id === user?._id; } + if (isOTRMessage(message) || isOTRAckMessage(message)) { + return false; + } + const editOwn = message.u && message.u._id === user?._id; if (!canEditMessage && (!isEditAllowed || !editOwn)) { return false; diff --git a/apps/meteor/client/components/message/variants/RoomMessage.tsx b/apps/meteor/client/components/message/variants/RoomMessage.tsx index 3832aa216163d..a939487015af0 100644 --- a/apps/meteor/client/components/message/variants/RoomMessage.tsx +++ b/apps/meteor/client/components/message/variants/RoomMessage.tsx @@ -1,4 +1,4 @@ -import type { IMessage } from '@rocket.chat/core-typings'; +import { type IMessage, isOTRAckMessage, isOTRMessage } from '@rocket.chat/core-typings'; import { Message, MessageLeftContainer, MessageContainer, CheckBox } from '@rocket.chat/fuselage'; import { useToggle } from '@rocket.chat/fuselage-hooks'; import { MessageAvatar } from '@rocket.chat/ui-avatar'; @@ -55,13 +55,12 @@ const RoomMessage = ({ const { openUserCard, triggerProps } = useUserCard(); const selecting = useIsSelecting(); - const isOTRMessage = message.t === 'otr' || message.t === 'otr-ack'; + const isOTRMsg = isOTRMessage(message) || isOTRAckMessage(message); const toggleSelected = useToggleSelect(message._id); - const selected = useIsSelectedMessage(message._id, isOTRMessage); + const selected = useIsSelectedMessage(message._id, isOTRMsg); useCountSelected(); - const messageRef = useJumpToMessage(message._id); return ( @@ -69,10 +68,10 @@ const RoomMessage = ({ ref={messageRef} id={message._id} role='listitem' - aria-roledescription={t('message')} + aria-roledescription={isOTRMsg ? t('OTR_message') : t('message')} tabIndex={0} aria-labelledby={`${message._id}-displayName ${message._id}-time ${message._id}-content ${message._id}-read-status`} - onClick={selecting && !isOTRMessage ? toggleSelected : undefined} + onClick={selecting && !isOTRMsg ? toggleSelected : undefined} isSelected={selected} isEditing={editing} isPending={message.temp} @@ -101,7 +100,7 @@ const RoomMessage = ({ {...triggerProps} /> )} - {selecting && } + {selecting && } {sequential && } diff --git a/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx b/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx index 9b905fd085c6b..af0aadefbf308 100644 --- a/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx +++ b/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx @@ -1,4 +1,4 @@ -import type { IThreadMessage } from '@rocket.chat/core-typings'; +import { isOTRAckMessage, isOTRMessage, type IThreadMessage } from '@rocket.chat/core-typings'; import { Skeleton, ThreadMessage, @@ -45,10 +45,10 @@ const ThreadMessagePreview = ({ message, showUserAvatar, sequential, ...props }: const { t } = useTranslation(); const isSelecting = useIsSelecting(); - const isOTRMessage = message.t === 'otr' || message.t === 'otr-ack'; + const isOTRMsg = isOTRMessage(message) || isOTRAckMessage(message); const toggleSelected = useToggleSelect(message._id); - const isSelected = useIsSelectedMessage(message._id, isOTRMessage); + const isSelected = useIsSelectedMessage(message._id, isOTRMsg); useCountSelected(); const messageType = parentMessage.isSuccess ? MessageTypes.getType(parentMessage.data) : null; @@ -67,7 +67,7 @@ const ThreadMessagePreview = ({ message, showUserAvatar, sequential, ...props }: return goToThread({ rid: message.rid, tmid: message.tmid, msg: message._id }); } - if (isOTRMessage) { + if (isOTRMsg) { return; } @@ -77,7 +77,7 @@ const ThreadMessagePreview = ({ message, showUserAvatar, sequential, ...props }: return ( e.code === 'Enter' && handleThreadClick()} @@ -123,7 +123,7 @@ const ThreadMessagePreview = ({ message, showUserAvatar, sequential, ...props }: size='x18' /> )} - {isSelecting && } + {isSelecting && } diff --git a/apps/meteor/client/lib/chats/ChatAPI.ts b/apps/meteor/client/lib/chats/ChatAPI.ts index 058870cfbd286..f41e9366ceee9 100644 --- a/apps/meteor/client/lib/chats/ChatAPI.ts +++ b/apps/meteor/client/lib/chats/ChatAPI.ts @@ -68,9 +68,7 @@ export type DataAPI = { getMessageByID(mid: IMessage['_id']): Promise; findLastMessage(): Promise; getLastMessage(): Promise; - findLastOwnMessage(): Promise; - getLastOwnMessage(): Promise; - findPreviousOwnMessage(message: IMessage): Promise; + findPreviousOwnMessage(message?: IMessage): Promise; getPreviousOwnMessage(message: IMessage): Promise; findNextOwnMessage(message: IMessage): Promise; getNextOwnMessage(message: IMessage): Promise; diff --git a/apps/meteor/client/lib/chats/data.ts b/apps/meteor/client/lib/chats/data.ts index ffb5259983d7b..260db8da3f303 100644 --- a/apps/meteor/client/lib/chats/data.ts +++ b/apps/meteor/client/lib/chats/data.ts @@ -1,4 +1,11 @@ -import type { IEditedMessage, IMessage, IRoom, ISubscription } from '@rocket.chat/core-typings'; +import { + isOTRAckMessage, + isOTRMessage, + type IEditedMessage, + type IMessage, + type IRoom, + type ISubscription, +} from '@rocket.chat/core-typings'; import { Random } from '@rocket.chat/random'; import moment from 'moment'; @@ -57,34 +64,15 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage return message; }; - const findLastOwnMessage = async (): Promise => { - const uid = Meteor.userId(); - - if (!uid) { - return undefined; - } - - return Messages.findOne( - { rid, 'tmid': tmid ?? { $exists: false }, 'u._id': uid, '_hidden': { $ne: true } }, - { sort: { ts: -1 }, reactive: false }, - ); - }; - - const getLastOwnMessage = async (): Promise => { - const message = await findLastOwnMessage(); - - if (!message) { - throw new Error('Message not found'); - } - - return message; - }; - const canUpdateMessage = async (message: IMessage): Promise => { if (MessageTypes.isSystemMessage(message)) { return false; } + if (isOTRMessage(message) || isOTRAckMessage(message)) { + return false; + } + const canEditMessage = hasAtLeastOnePermission('edit-message', message.rid); const editAllowed = (settings.get('Message_AllowEditing') as boolean | undefined) ?? false; const editOwn = message?.u && message.u._id === Meteor.userId(); @@ -104,7 +92,7 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage return true; }; - const findPreviousOwnMessage = async (message: IMessage): Promise => { + const findPreviousOwnMessage = async (message?: IMessage): Promise => { const uid = Meteor.userId(); if (!uid) { @@ -112,7 +100,7 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage } const msg = Messages.findOne( - { rid, 'tmid': tmid ?? { $exists: false }, 'u._id': uid, '_hidden': { $ne: true }, 'ts': { $lt: message.ts } }, + { rid, 'tmid': tmid ?? { $exists: false }, 'u._id': uid, '_hidden': { $ne: true }, 'ts': { ...(message && { $lt: message.ts }) } }, { sort: { ts: -1 }, reactive: false }, ); @@ -308,8 +296,6 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage getMessageByID, findLastMessage, getLastMessage, - findLastOwnMessage, - getLastOwnMessage, findPreviousOwnMessage, getPreviousOwnMessage, findNextOwnMessage, diff --git a/apps/meteor/client/lib/chats/flows/sendMessage.ts b/apps/meteor/client/lib/chats/flows/sendMessage.ts index 3d372900fb299..0fb16296a8582 100644 --- a/apps/meteor/client/lib/chats/flows/sendMessage.ts +++ b/apps/meteor/client/lib/chats/flows/sendMessage.ts @@ -23,10 +23,11 @@ const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[], return; } - message = (await onClientBeforeSendMessage(message)) as IMessage; + message = (await onClientBeforeSendMessage({ ...message, isEditing: !!chat.currentEditing })) as IMessage & { isEditing?: boolean }; // e2e should be a client property only delete message.e2e; + delete (message as IMessage & { isEditing?: boolean }).isEditing; if (await processMessageEditing(chat, message, previewUrls)) { return; diff --git a/apps/meteor/client/lib/onClientBeforeSendMessage.ts b/apps/meteor/client/lib/onClientBeforeSendMessage.ts index e2e0404b9b301..e312b4f184f37 100644 --- a/apps/meteor/client/lib/onClientBeforeSendMessage.ts +++ b/apps/meteor/client/lib/onClientBeforeSendMessage.ts @@ -2,4 +2,4 @@ import type { AtLeast, IMessage } from '@rocket.chat/core-typings'; import { createAsyncTransformChain } from '../../lib/transforms'; -export const onClientBeforeSendMessage = createAsyncTransformChain>(); +export const onClientBeforeSendMessage = createAsyncTransformChain & { isEditing?: boolean }>(); diff --git a/apps/meteor/client/views/root/hooks/useOTRMessaging.ts b/apps/meteor/client/views/root/hooks/useOTRMessaging.ts index b3195326fe8c6..8a6e778d15a25 100644 --- a/apps/meteor/client/views/root/hooks/useOTRMessaging.ts +++ b/apps/meteor/client/views/root/hooks/useOTRMessaging.ts @@ -24,14 +24,18 @@ export const useOTRMessaging = (uid: string) => { }; const handleBeforeSendMessage = async ( - message: AtLeast, + message: AtLeast & { isEditing?: boolean }, ): Promise> => { if (!uid) { return message; } - const otrRoom = OTR.getInstanceByRoomId(uid, message.rid); + if (message.isEditing) { + return (({ isEditing: _isEditing, ...rest }) => rest)(message); + } + delete message.isEditing; + const otrRoom = OTR.getInstanceByRoomId(uid, message.rid); if (otrRoom && otrRoom.getState() === OtrRoomState.ESTABLISHED) { const msg = await otrRoom.encrypt(message); return { ...message, msg, t: 'otr' }; diff --git a/apps/meteor/tests/e2e/otr.spec.ts b/apps/meteor/tests/e2e/otr.spec.ts index b6ff4789e0c27..e909673a4fb83 100644 --- a/apps/meteor/tests/e2e/otr.spec.ts +++ b/apps/meteor/tests/e2e/otr.spec.ts @@ -38,9 +38,72 @@ test.describe.serial('OTR', () => { await poHomeChannel.content.sendMessage('hello OTR'); await poHomeChannel.tabs.kebab.click({ force: true }); await poHomeChannel.tabs.btnExportMessages.click(); - await poHomeChannel.content.getMessageByText('hello OTR').click(); + await poHomeChannel.content.getOTRMessageByText('hello OTR').click(); await expect(poHomeChannel.content.btnClearSelection).toBeDisabled(); await user1Page.close(); }); + + test('should not allow edit OTR messages', async ({ browser, page }) => { + const user1Page = await browser.newPage({ storageState: Users.user1.state }); + const user1Channel = new HomeChannel(user1Page); + + await test.step('log in user1', async () => { + await user1Page.goto(`/direct/${Users.admin.data.username}`); + await user1Channel.content.waitForChannel(); + }); + + await test.step('send normal messages', async () => { + await poHomeChannel.sidenav.openChat(Users.user1.data.username); + await poHomeChannel.content.sendMessage('Normal message'); + await user1Channel.content.sendMessage('Normal message'); + }); + + await test.step('invite OTR with user1', async () => { + await poHomeChannel.tabs.kebab.click({ force: true }); + await poHomeChannel.tabs.btnEnableOTR.click({ force: true }); + await poHomeChannel.tabs.otr.btnStartOTR.click(); + }); + + await test.step('accept handshake with user1', async () => { + await user1Channel.tabs.otr.btnAcceptOTR.click(); + }); + + await test.step('send OTR messages', async () => { + await poHomeChannel.content.sendMessage('hello user1'); + await expect(poHomeChannel.content.lastUserMessage).toHaveAttribute('aria-roledescription', 'OTR message'); + + await user1Channel.content.sendMessage('hello admin'); + await expect(user1Channel.content.lastUserMessage).toHaveAttribute('aria-roledescription', 'OTR message'); + }); + + await test.step('not show edit message action', async () => { + await poHomeChannel.content.openLastMessageMenu(); + await expect(poHomeChannel.content.btnOptionEditMessage).not.toBeVisible(); + + await user1Channel.content.openLastMessageMenu(); + await expect(user1Channel.content.btnOptionEditMessage).not.toBeVisible(); + + await page.keyboard.press('Escape'); + await user1Page.keyboard.press('Escape'); + }); + + await test.step('keyboard edit action when using up arrow key should show normal messages', async () => { + await poHomeChannel.composer.focus(); + await page.keyboard.press('ArrowUp'); + await expect(poHomeChannel.composer).toHaveValue('Normal message'); + await poHomeChannel.composer.fill('Normal message edited'); + await page.keyboard.press('Enter'); + await expect(poHomeChannel.content.nthMessage(0)).toContainText('Normal message edited'); + + await user1Channel.composer.focus(); + await user1Page.keyboard.press('ArrowUp'); + await expect(user1Channel.composer).toHaveValue('Normal message'); + await user1Channel.composer.fill('Normal message edited'); + await user1Page.keyboard.press('Enter'); + await expect(user1Channel.content.nthMessage(0)).toContainText('Normal message edited'); + }); + + await user1Page.close(); + }); }); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts index 63e03a71e38aa..5fcb7a044fe87 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -197,7 +197,7 @@ export class HomeContent { } get btnOptionEditMessage(): Locator { - return this.page.locator('[data-qa-id="edit-message"]'); + return this.page.locator('role=menu[name="More"] >> role=menuitem[name="Edit"]'); } get btnOptionDeleteMessage(): Locator { @@ -446,6 +446,10 @@ export class HomeContent { return this.page.locator('[role="listitem"][aria-roledescription="message"]', { hasText: text }); } + getOTRMessageByText(text: string): Locator { + return this.page.locator('[role="listitem"][aria-roledescription="OTR message"]', { hasText: text }); + } + getMessageById(id: string): Locator { return this.page.locator(`[data-qa-type="message"][id="${id}"]`); } diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 2173abe637a60..24b0fc2214c03 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3689,8 +3689,9 @@ "OTR_Description": "Off-the-record chats are secure, private and disappear once ended.", "OTR_Enable_Description": "Enable option to use off-the-record (OTR) messages in direct messages between 2 users. OTR messages are not recorded on the server and exchanged directly and encrypted between the 2 users.", "OTR_Session_ended_other_user_went_offline": "OTR Session has ended. User {{username}} went offline", + "OTR_thread_message_preview": "OTR thread message preview", "OTR_is_only_available_when_both_users_are_online": "OTR is only available when both users are online", - "OTR_message": "OTR Message", + "OTR_message": "OTR message", "OTR_messages_cannot_be_exported": "OTR messages cannot be exported", "OTR_not_available": "OTR not available", "OTR_not_available_e2ee": "This room has E2E encryption enabled, OTR cannot work with encrypted messages.", @@ -6650,6 +6651,7 @@ "this_app_is_included_with_subscription": "This app is included with {{bundleName}} plans", "thread": "thread", "thread_message": "thread message", + "thread_message_preview": "thread message preview", "threads_counter_one": "{{count}} unread threaded message", "threads_counter_other": "{{count}} unread threaded messages", "to_see_more_details_on_how_to_integrate": "to see more details on how to integrate.", @@ -6831,4 +6833,4 @@ "__usernames__joined": "{{usernames}} joined", "__usersCount__joined": "{{count}} joined", "__usersCount__people_will_be_invited": "{{usersCount}} people will be invited" -} \ No newline at end of file +}