Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions .maestro/tests/room/quote-thread-message.yaml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions app/containers/MessageComposer/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions app/containers/MessageComposer/components/Quotes/Quote.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
});
Expand Down Expand Up @@ -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';
Expand Down
4 changes: 2 additions & 2 deletions app/containers/MessageComposer/helpers/prepareQuoteMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ 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<string> => {
export const prepareQuoteMessage = async (textFromInput: string, selectedMessages: string[], tmid?: string): Promise<string> => {
let quoteText = '';
const { version: serverVersion } = store.getState().server;
const connectionString = compareServerVersion(serverVersion, 'lowerThan', '5.0.0') ? ' ' : '\n';

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);
Expand Down
4 changes: 2 additions & 2 deletions app/containers/MessageComposer/hooks/useMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IMessage>();
useEffect(() => {
const load = async () => {
const result = await getMessageById(messageId);
const result = await getMessageById(messageId, tmid);
if (result) {
setMessage(result);
}
Expand Down
102 changes: 102 additions & 0 deletions app/lib/database/services/Message.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof getThreadMessageById>;

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();
});
});
});
11 changes: 10 additions & 1 deletion app/lib/database/services/Message.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const db = database.active;
const messageCollection = getCollection(db);
try {
Expand Down
4 changes: 2 additions & 2 deletions app/lib/methods/getPermalinks.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -18,7 +18,7 @@ const roomTypes = {
d: 'direct'
};

export async function getPermalinkMessage(message: TMessageModel): Promise<string | null> {
export async function getPermalinkMessage(message: TAnyMessageModel): Promise<string | null> {
if (!message.subscription) return null;
let room: TSubscriptionModel;
try {
Expand Down
Loading