diff --git a/.maestro/tests/room/quote-thread-message.yaml b/.maestro/tests/room/quote-thread-message.yaml new file mode 100644 index 00000000000..aad6cae1671 --- /dev/null +++ b/.maestro/tests/room/quote-thread-message.yaml @@ -0,0 +1,113 @@ +appId: ${APP_ID} +name: Quote a message inside a thread +onFlowStart: + - runFlow: '../../helpers/setup.yaml' +onFlowComplete: + - evalScript: ${output.utils.deleteCreatedUsers()} +tags: + - test-13 + +--- +- evalScript: ${output.user = output.utils.createUser()} +- evalScript: ${output.room = output.utils.createRandomRoom(output.user.username, output.user.password)} + +- runFlow: + file: '../../helpers/login-with-deeplink.yaml' + env: + USERNAME: ${output.user.username} + PASSWORD: ${output.user.password} +- runFlow: + file: '../../helpers/navigate-to-room.yaml' + env: + ROOM: ${output.room.name} + +# should create a thread with a reply +- runFlow: + file: '../../helpers/send-message.yaml' + env: + message: thread +- tapOn: + id: 'room-view-messages' +- longPressOn: + id: 'message-content-thread' +- extendedWaitUntil: + visible: + text: 'Reply in thread' + timeout: 60000 +- tapOn: + text: 'Reply in thread' +- extendedWaitUntil: + visible: + id: 'message-composer-input-thread' + timeout: 60000 +- tapOn: + id: 'message-composer-input-thread' +- inputText: quotable +- tapOn: + id: 'message-composer-send' +- extendedWaitUntil: + visible: + id: 'message-content-quotable' + timeout: 60000 + +# Reload with a clean local DB so the reply only exists in `thread_messages`, not `messages`. +# Quoting used a messages-only lookup, so it couldn't resolve the reply and silently did nothing. +- runFlow: + file: '../../helpers/login-with-deeplink.yaml' + env: + USERNAME: ${output.user.username} + PASSWORD: ${output.user.password} + CLEAR_STATE: true +- runFlow: + file: '../../helpers/navigate-to-room.yaml' + env: + ROOM: ${output.room.name} + +# should open the thread +- extendedWaitUntil: + visible: + id: 'message-thread-button-thread' + timeout: 60000 +- tapOn: + id: 'message-thread-button-thread' +- extendedWaitUntil: + visible: + id: 'message-content-quotable' + timeout: 60000 + +# should quote the thread reply +- longPressOn: + id: 'message-content-quotable' +- extendedWaitUntil: + visible: + id: 'action-sheet' + timeout: 60000 +- extendedWaitUntil: + visible: + id: 'message-actions-quote' + timeout: 60000 +- tapOn: + id: 'message-actions-quote' + +# should show the quote preview in the composer +- extendedWaitUntil: + visible: + id: 'composer-quote-.*' + timeout: 60000 +- assertVisible: + id: 'composer-quote-remove-.*' + +# should send the message with the quote attached +- tapOn: + id: 'message-composer-input-thread' +- inputText: quotedinthread +- tapOn: + id: 'message-composer-send' +- extendedWaitUntil: + visible: + id: 'message-content-.*quotedinthread' + timeout: 60000 +- extendedWaitUntil: + visible: + id: 'reply-.*-quotable' + timeout: 60000 diff --git a/app/containers/MessageComposer/MessageComposer.tsx b/app/containers/MessageComposer/MessageComposer.tsx index 40ee12556aa..647db26f0f8 100644 --- a/app/containers/MessageComposer/MessageComposer.tsx +++ b/app/containers/MessageComposer/MessageComposer.tsx @@ -125,7 +125,7 @@ export const MessageComposer = ({ let quotedMessage: string | undefined; if (action?.kind === 'quote') { - quotedMessage = await prepareQuoteMessage(textFromInput, action.messageIds); + quotedMessage = await prepareQuoteMessage(textFromInput, action.messageIds, tmid); } try { @@ -149,7 +149,7 @@ export const MessageComposer = ({ } if (action?.kind === 'quote') { - const quoteMessage = await prepareQuoteMessage(textFromInput, action.messageIds); + const quoteMessage = await prepareQuoteMessage(textFromInput, action.messageIds, tmid); onSendMessage?.(quoteMessage); return; } diff --git a/app/containers/MessageComposer/components/Quotes/Quote.tsx b/app/containers/MessageComposer/components/Quotes/Quote.tsx index 4c28dbc27c0..2f584c4db1a 100644 --- a/app/containers/MessageComposer/components/Quotes/Quote.tsx +++ b/app/containers/MessageComposer/components/Quotes/Quote.tsx @@ -13,9 +13,9 @@ export const Quote = ({ messageId }: { messageId: string }) => { 'use memo'; const [styles, colors] = useStyle(); - const message = useMessage(messageId); + const { tmid, onRemoveQuoteMessage } = useRoomContext(); + const message = useMessage(messageId, tmid); const useRealName = useAppSelector(({ settings }) => settings.UI_Use_Real_Name); - const { onRemoveQuoteMessage } = useRoomContext(); let username = ''; let msg = ''; diff --git a/app/containers/MessageComposer/helpers/prepareQuoteMessage.test.ts b/app/containers/MessageComposer/helpers/prepareQuoteMessage.test.ts index 96ee9343585..1b5cb00ef26 100644 --- a/app/containers/MessageComposer/helpers/prepareQuoteMessage.test.ts +++ b/app/containers/MessageComposer/helpers/prepareQuoteMessage.test.ts @@ -81,7 +81,7 @@ describe('prepareQuoteMessage', () => { const result = await prepareQuoteMessage(textFromInput, selectedMessages); - expect(mockGetMessageById).toHaveBeenCalledWith('message1'); + expect(mockGetMessageById).toHaveBeenCalledWith('message1', undefined); expect(mockGetPermalinkMessage).toHaveBeenCalledWith(mockMessage); expect(result).toBe(`[ ](${permalink}) \nMy reply`); }); @@ -162,6 +162,47 @@ describe('prepareQuoteMessage', () => { }); }); + describe('inside a thread', () => { + const mockThreadMessage = { + id: 'message1', + msg: 'Test thread message', + u: { username: 'testuser' }, + ts: new Date() + }; + + test('should forward tmid so the thread message can be resolved', async () => { + const permalink = 'https://example.com/message/message1'; + + mockGetMessageById.mockResolvedValue(mockThreadMessage as any); + mockGetPermalinkMessage.mockResolvedValue(permalink); + + const result = await prepareQuoteMessage('My reply', ['message1'], 'thread-id'); + + expect(mockGetMessageById).toHaveBeenCalledWith('message1', 'thread-id'); + expect(mockGetPermalinkMessage).toHaveBeenCalledWith(mockThreadMessage); + expect(result).toBe(`[ ](${permalink}) \nMy reply`); + }); + + test('should forward tmid for every selected message', async () => { + mockGetMessageById.mockResolvedValue(mockThreadMessage as any); + mockGetPermalinkMessage.mockResolvedValue('https://example.com/link'); + + await prepareQuoteMessage('My reply', ['message1', 'message2'], 'thread-id'); + + expect(mockGetMessageById).toHaveBeenNthCalledWith(1, 'message1', 'thread-id'); + expect(mockGetMessageById).toHaveBeenNthCalledWith(2, 'message2', 'thread-id'); + }); + + test('should pass no tmid when the composer is not in a thread', async () => { + mockGetMessageById.mockResolvedValue(mockThreadMessage as any); + mockGetPermalinkMessage.mockResolvedValue('https://example.com/link'); + + await prepareQuoteMessage('My reply', ['message1']); + + expect(mockGetMessageById).toHaveBeenCalledWith('message1', undefined); + }); + }); + describe('server version handling', () => { test('should use newline separator for modern servers', async () => { const textFromInput = 'Reply'; diff --git a/app/containers/MessageComposer/helpers/prepareQuoteMessage.ts b/app/containers/MessageComposer/helpers/prepareQuoteMessage.ts index dccbd0910f1..33a0f279546 100644 --- a/app/containers/MessageComposer/helpers/prepareQuoteMessage.ts +++ b/app/containers/MessageComposer/helpers/prepareQuoteMessage.ts @@ -3,7 +3,7 @@ import { getMessageById } from '../../../lib/database/services/Message'; import { store } from '../../../lib/store/auxStore'; import { compareServerVersion } from '../../../lib/methods/helpers'; -export const prepareQuoteMessage = async (textFromInput: string, selectedMessages: string[]): Promise => { +export const prepareQuoteMessage = async (textFromInput: string, selectedMessages: string[], tmid?: string): Promise => { let quoteText = ''; const { version: serverVersion } = store.getState().server; const connectionString = compareServerVersion(serverVersion, 'lowerThan', '5.0.0') ? ' ' : '\n'; @@ -11,7 +11,7 @@ export const prepareQuoteMessage = async (textFromInput: string, selectedMessage if (selectedMessages.length > 0) { for (let i = 0; i < selectedMessages.length; i += 1) { // eslint-disable-next-line no-await-in-loop - const message = await getMessageById(selectedMessages[i]); + const message = await getMessageById(selectedMessages[i], tmid); if (message) { // eslint-disable-next-line no-await-in-loop const permalink = await getPermalinkMessage(message); diff --git a/app/containers/MessageComposer/hooks/useMessage.ts b/app/containers/MessageComposer/hooks/useMessage.ts index e8bc4a80052..8b47ab1395c 100644 --- a/app/containers/MessageComposer/hooks/useMessage.ts +++ b/app/containers/MessageComposer/hooks/useMessage.ts @@ -4,13 +4,13 @@ import { type IMessage } from '../../../definitions'; import { getMessageById } from '../../../lib/database/services/Message'; // TODO: Not reactive. Should we work on an official version? -export const useMessage = (messageId: string): IMessage | undefined => { +export const useMessage = (messageId: string, tmid?: string): IMessage | undefined => { 'use memo'; const [message, setMessage] = useState(); useEffect(() => { const load = async () => { - const result = await getMessageById(messageId); + const result = await getMessageById(messageId, tmid); if (result) { setMessage(result); } diff --git a/app/lib/database/services/Message.test.ts b/app/lib/database/services/Message.test.ts new file mode 100644 index 00000000000..80d87300d73 --- /dev/null +++ b/app/lib/database/services/Message.test.ts @@ -0,0 +1,102 @@ +import database from '../index'; +import { MESSAGES_TABLE } from '../model/Message'; +import { type TMessageModel, type TThreadMessageModel } from '../../../definitions'; +import { getMessageById } from './Message'; +import { getThreadMessageById } from './ThreadMessage'; + +jest.mock('../index', () => ({ + __esModule: true, + default: { + active: { + get: jest.fn() + } + } +})); + +jest.mock('./ThreadMessage', () => ({ + getThreadMessageById: jest.fn() +})); + +const mockGet = database.active.get as jest.Mock; +const mockGetThreadMessageById = getThreadMessageById as jest.MockedFunction; + +describe('getMessageById', () => { + let mockFind: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockFind = jest.fn(); + mockGet.mockReturnValue({ find: mockFind }); + }); + + it('returns null when messageId is empty and does not query the database', async () => { + const result = await getMessageById(''); + + expect(result).toBeNull(); + expect(mockGet).not.toHaveBeenCalled(); + expect(mockGetThreadMessageById).not.toHaveBeenCalled(); + }); + + describe('without tmid', () => { + it('resolves the message from the messages table', async () => { + const message = { id: 'message1', msg: 'Test' } as unknown as TMessageModel; + mockFind.mockResolvedValue(message); + + const result = await getMessageById('message1'); + + expect(result).toBe(message); + expect(mockGet).toHaveBeenCalledWith(MESSAGES_TABLE); + expect(mockFind).toHaveBeenCalledWith('message1'); + }); + + it('does not look in the thread messages table', async () => { + mockFind.mockResolvedValue({ id: 'message1' }); + + await getMessageById('message1'); + + expect(mockGetThreadMessageById).not.toHaveBeenCalled(); + }); + + it('returns null when the message does not exist', async () => { + mockFind.mockRejectedValue(new Error('not found')); + + const result = await getMessageById('nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('with tmid', () => { + it('prefers the thread messages table', async () => { + const threadMessage = { id: 'message1', msg: 'Thread reply' } as unknown as TThreadMessageModel; + mockGetThreadMessageById.mockResolvedValue(threadMessage as any); + + const result = await getMessageById('message1', 'thread-id'); + + expect(result).toBe(threadMessage); + expect(mockGetThreadMessageById).toHaveBeenCalledWith('message1'); + expect(mockFind).not.toHaveBeenCalled(); + }); + + it('falls back to the messages table when not a thread message', async () => { + const parent = { id: 'parent', msg: 'Thread parent' } as unknown as TMessageModel; + mockGetThreadMessageById.mockResolvedValue(null); + mockFind.mockResolvedValue(parent); + + const result = await getMessageById('parent', 'thread-id'); + + expect(result).toBe(parent); + expect(mockGetThreadMessageById).toHaveBeenCalledWith('parent'); + expect(mockFind).toHaveBeenCalledWith('parent'); + }); + + it('returns null when the message is in neither table', async () => { + mockGetThreadMessageById.mockResolvedValue(null); + mockFind.mockRejectedValue(new Error('not found')); + + const result = await getMessageById('nonexistent', 'thread-id'); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/app/lib/database/services/Message.ts b/app/lib/database/services/Message.ts index 184ea29a0e9..b6328339224 100644 --- a/app/lib/database/services/Message.ts +++ b/app/lib/database/services/Message.ts @@ -1,13 +1,22 @@ import database from '..'; import { type TAppDatabase } from '../interfaces'; import { MESSAGES_TABLE } from '../model/Message'; +import { getThreadMessageById } from './ThreadMessage'; const getCollection = (db: TAppDatabase) => db.get(MESSAGES_TABLE); -export const getMessageById = async (messageId: string | null) => { +export const getMessageById = async (messageId: string | null, tmid?: string | null) => { if (!messageId) { return null; } + + if (tmid) { + const threadMessage = await getThreadMessageById(messageId); + if (threadMessage) { + return threadMessage; + } + } + const db = database.active; const messageCollection = getCollection(db); try { diff --git a/app/lib/methods/getPermalinks.ts b/app/lib/methods/getPermalinks.ts index 0aae8a1e2ac..a66b1345899 100644 --- a/app/lib/methods/getPermalinks.ts +++ b/app/lib/methods/getPermalinks.ts @@ -1,5 +1,5 @@ import log from './helpers/log'; -import { type TMessageModel, type TSubscriptionModel, type SubscriptionType } from '../../definitions'; +import { type TAnyMessageModel, type TSubscriptionModel, type SubscriptionType } from '../../definitions'; import { store } from '../store/auxStore'; import { isGroupChat } from './helpers'; import { getRoom } from './getRoom'; @@ -18,7 +18,7 @@ const roomTypes = { d: 'direct' }; -export async function getPermalinkMessage(message: TMessageModel): Promise { +export async function getPermalinkMessage(message: TAnyMessageModel): Promise { if (!message.subscription) return null; let room: TSubscriptionModel; try {