Skip to content
3 changes: 2 additions & 1 deletion app/views/RoomView/List/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { type RefObject } from 'react';
import { type FlatListProps } from 'react-native';
import { type FlatList } from 'react-native-gesture-handler';

import { type TAnyMessageModel } from '../../../definitions';
import { type RoomType, type TAnyMessageModel } from '../../../definitions';

export type TListRef = RefObject<FlatList<TAnyMessageModel> | null>;

Expand All @@ -21,6 +21,7 @@ export interface IListContainerRef {
export interface IListContainerProps {
renderRow: Function;
rid: string;
t: RoomType;
tmid?: string;
listRef: TListRef;
hideSystemMessages: string[];
Expand Down
18 changes: 18 additions & 0 deletions app/views/RoomView/List/hooks/buildVisibleSystemTypesClause.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Q } from '@nozbe/watermelondb';

import { MESSAGE_TYPE_ANY_LOAD } from '../../../../lib/constants/messageTypeLoad';

/**
* When the user hides system message types, applying `take(N)` before filtering would return
* mostly hidden rows. This clause matches the JS filter `!m.t || !hideSystemMessages.includes(m.t)`
* so `take` applies to visible rows only.
*/
export function buildVisibleSystemTypesClause(hideSystemMessages: string[]): Q.Or | null {
if (!hideSystemMessages.length) {
return null;
}

const notHidden = Q.and(...hideSystemMessages.map(h => Q.where('t', Q.notEq(h))));

return Q.or(Q.where('t', null), Q.where('t', Q.oneOf([...MESSAGE_TYPE_ANY_LOAD])), notHidden);
}
189 changes: 189 additions & 0 deletions app/views/RoomView/List/hooks/useMessages.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import React from 'react';
import { act, renderHook, waitFor } from '@testing-library/react-native';
import { Provider } from 'react-redux';

import { ROOM } from '../../../../actions/actionsTypes';
import { type RoomType, type TAnyMessageModel } from '../../../../definitions';
import database from '../../../../lib/database';
import { getMessageById } from '../../../../lib/database/services/Message';
import { getThreadById } from '../../../../lib/database/services/Thread';
import { MessageTypeLoad } from '../../../../lib/constants/messageTypeLoad';
import { mockedStore } from '../../../../reducers/mockedStore';
import { useMessages } from './useMessages';

jest.mock('../../../../lib/database', () => ({
__esModule: true,
default: {
active: {
get: jest.fn()
}
}
}));

jest.mock('../../../../lib/database/services/Message', () => ({
getMessageById: jest.fn(() => Promise.resolve(null))
}));

jest.mock('../../../../lib/database/services/Thread', () => ({
getThreadById: jest.fn(() => Promise.resolve(null))
}));

jest.mock('../../../../lib/services/restApi', () => ({
readThreads: jest.fn(() => Promise.resolve())
}));

jest.mock('../../../../lib/methods/helpers', () => {
const actual = jest.requireActual('../../../../lib/methods/helpers');
return {
...actual,
useDebounce: (fn: (...args: unknown[]) => unknown) => fn
};
});

const mockDbGet = database.active.get as unknown as jest.Mock;
const mockGetThreadById = jest.mocked(getThreadById);
const mockGetMessageById = jest.mocked(getMessageById);

const baseArgs = {
rid: 'ROOM_ID',
showMessageInMainThread: true,
hideSystemMessages: [] as string[],
serverVersion: '3.0.0' as string | null,
t: 'c' as RoomType
};

const msg = (overrides: Partial<TAnyMessageModel> & { id: string }): TAnyMessageModel =>
({
ts: new Date('2024-01-01'),
...overrides
} as TAnyMessageModel);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe('useMessages', () => {
let emittedRows: TAnyMessageModel[];

const wrapper = ({ children }: { children: React.ReactNode }) => <Provider store={mockedStore}>{children}</Provider>;

beforeEach(() => {
emittedRows = [];
jest.clearAllMocks();
mockDbGet.mockImplementation(() => ({
query: jest.fn().mockReturnValue({
observe: () => ({
subscribe: (onNext: (rows: TAnyMessageModel[]) => void) => {
onNext(emittedRows);
return { unsubscribe: jest.fn() };
}
})
})
}));
Comment thread
OtavioStasiak marked this conversation as resolved.
});

const renderUseMessages = (overrides: Partial<Parameters<typeof useMessages>[0]> = {}) =>
renderHook(() => useMessages({ ...baseArgs, ...overrides }), { wrapper });

it('returns fetchMessages as a function', async () => {
emittedRows = [msg({ id: 'm1' })];
const { result } = renderUseMessages();
await waitFor(() => {
expect(typeof result.current[2]).toBe('function');
});
});

it('loads main room messages from the messages collection', async () => {
emittedRows = [msg({ id: 'a' }), msg({ id: 'b' })];
const { result } = renderUseMessages({ rid: 'R1' });
await waitFor(() => {
expect(result.current[0].map(m => m.id)).toEqual(['a', 'b']);
});
expect(mockDbGet).toHaveBeenCalledWith('messages');
});

it('does not query the database when rid is empty', async () => {
emittedRows = [msg({ id: 'x' })];
const { result } = renderUseMessages({ rid: '' });
await act(async () => {
await result.current[2]();
});
expect(mockDbGet).not.toHaveBeenCalled();
});

it('filters out system message types listed in hideSystemMessages', async () => {
emittedRows = [msg({ id: '1', t: 'uj' }), msg({ id: '2', t: undefined }), msg({ id: '3', t: 'room_changed_topic' })];
const { result } = renderUseMessages({ hideSystemMessages: ['uj'] });
await waitFor(() => {
expect(result.current[0].map(m => m.id)).toEqual(['2', '3']);
});
});

it('returns visibleMessagesIds aligned with visible messages', async () => {
emittedRows = [msg({ id: 'p' }), msg({ id: 'q' })];
const { result } = renderUseMessages();
await waitFor(() => {
expect(result.current[1]).toEqual(['p', 'q']);
});
});

it('dispatches room history request when server is 3.16+, user hides system messages, and a load row exists', async () => {
const dispatchSpy = jest.spyOn(mockedStore, 'dispatch');
emittedRows = [msg({ id: 'm1', t: undefined }), msg({ id: 'load-more-x', t: MessageTypeLoad.MORE })];
renderUseMessages({
serverVersion: '6.0.0',
hideSystemMessages: ['uj']
});
await waitFor(() => {
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: ROOM.HISTORY_REQUEST,
rid: 'ROOM_ID',
t: 'c',
loaderId: 'load-more-x'
})
);
});
dispatchSpy.mockRestore();
});

it('does not dispatch room history request when server is below 3.16', async () => {
const dispatchSpy = jest.spyOn(mockedStore, 'dispatch');
emittedRows = [msg({ id: 'load-more-x', t: MessageTypeLoad.MORE })];
const { result } = renderUseMessages({
serverVersion: '3.15.0',
hideSystemMessages: ['uj']
});
await waitFor(() => {
expect(result.current[0].length).toBeGreaterThan(0);
});
const historyDispatches = dispatchSpy.mock.calls.filter(([a]: any) => a?.type === ROOM.HISTORY_REQUEST);
expect(historyDispatches.length).toBe(0);
dispatchSpy.mockRestore();
});

it('queries thread_messages and appends thread parent when tmid is set', async () => {
const parent = {
...msg({ id: 'parent-thread', t: 'discussion-created' }),
collection: { table: 'threads' }
} as TAnyMessageModel;
mockGetThreadById.mockResolvedValueOnce(parent);
emittedRows = [msg({ id: 'tm1', tmid: 'THREAD_ID' })];
const { result } = renderUseMessages({ tmid: 'THREAD_ID' });
await waitFor(() => {
expect(mockDbGet).toHaveBeenCalledWith('thread_messages');
});
await waitFor(() => {
const ids = result.current[0].map(m => m.id);
expect(ids).toContain('parent-thread');
expect(ids).toContain('tm1');
});
});

it('falls back to getMessageById when thread record is missing', async () => {
const parent = msg({ id: 'fallback-parent', t: undefined });
mockGetThreadById.mockResolvedValueOnce(null);
mockGetMessageById.mockResolvedValueOnce(parent as TAnyMessageModel);
emittedRows = [msg({ id: 'only-child', tmid: 'TM' })];
const { result } = renderUseMessages({ tmid: 'TM' });
await waitFor(() => {
expect(result.current[0].map(m => m.id)).toContain('fallback-parent');
});
});
});
91 changes: 65 additions & 26 deletions app/views/RoomView/List/hooks/useMessages.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,40 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Q } from '@nozbe/watermelondb';
import { type Subscription } from 'rxjs';
import { useDispatch } from 'react-redux';

import { type TAnyMessageModel } from '../../../../definitions';
import { type RoomType, type TAnyMessageModel } from '../../../../definitions';
import database from '../../../../lib/database';
import { getMessageById } from '../../../../lib/database/services/Message';
import { getThreadById } from '../../../../lib/database/services/Thread';
import { compareServerVersion, useDebounce } from '../../../../lib/methods/helpers';
import { readThreads } from '../../../../lib/services/restApi';
import { MESSAGE_TYPE_ANY_LOAD, type MessageTypeLoad } from '../../../../lib/constants/messageTypeLoad';
import { QUERY_SIZE } from '../constants';
import { buildVisibleSystemTypesClause } from './buildVisibleSystemTypesClause';
import { roomHistoryRequest } from '../../../../actions/room';

export const useMessages = ({
rid,
tmid,
showMessageInMainThread,
hideSystemMessages,
serverVersion,
hideSystemMessages
t
}: {
rid: string;
tmid?: string;
showMessageInMainThread: boolean;
serverVersion: string | null;
hideSystemMessages: string[];
t: RoomType;
}) => {
const [messages, setMessages] = useState<TAnyMessageModel[]>([]);
// 1. Store RAW messages directly from the database
const [rawMessages, setRawMessages] = useState<TAnyMessageModel[]>([]);
const thread = useRef<TAnyMessageModel | null>(null);
const count = useRef(0);
const subscription = useRef<Subscription | null>(null);
const messagesIds = useRef<string[]>([]);
const dispatch = useDispatch();

const fetchMessages = useCallback(async () => {
unsubscribe();
Expand All @@ -38,10 +45,13 @@ export const useMessages = ({
}

const db = database.active;

// Apply the filter to the DB query. This guarantees Q.take() grabs
// exactly enough messages to keep pagination from breaking.
const visibleSystemClause = hideSystemMessages.length ? buildVisibleSystemTypesClause(hideSystemMessages) : null;

let observable;
if (tmid) {
// If the thread doesn't exist yet, we fetch it from messages, but trying to get it from threads when possible.
// As soon as we have it from threads table, we use it from cache only and never query again.
if (!thread.current || thread.current.collection.table !== 'threads') {
thread.current = await getThreadById(tmid);
if (!thread.current) {
Expand All @@ -50,13 +60,22 @@ export const useMessages = ({
}
observable = db
.get('thread_messages')
.query(Q.where('rid', tmid), Q.sortBy('ts', Q.desc), Q.skip(0), Q.take(count.current))
.query(
Q.where('rid', tmid),
...(visibleSystemClause ? [visibleSystemClause] : []),
Q.sortBy('ts', Q.desc),
Q.skip(0),
Q.take(count.current)
)
.observe();
} else {
const whereClause = [Q.where('rid', rid), Q.sortBy('ts', Q.desc), Q.skip(0), Q.take(count.current)] as (
| Q.WhereDescription
| Q.Or
)[];
const whereClause: Q.Clause[] = [
Q.where('rid', rid),
...(visibleSystemClause ? [visibleSystemClause] : []),
Q.sortBy('ts', Q.desc),
Q.skip(0),
Q.take(count.current)
];
if (!showMessageInMainThread) {
whereClause.push(Q.or(Q.where('tmid', null), Q.where('tshow', Q.eq(true))));
}
Expand All @@ -67,24 +86,18 @@ export const useMessages = ({
}

subscription.current = observable.subscribe(result => {
let newMessages: TAnyMessageModel[] = result;
const newMessages: TAnyMessageModel[] = [...result];

// Push the thread parent. If it happens to be a hidden system message,
// our useMemo down below will safely catch it and hide it from the UI.
if (tmid && thread.current) {
newMessages.push(thread.current);
}

/**
* Since 3.16.0 server version, the backend don't response with messages if
* hide system message is enabled
*/
if (compareServerVersion(serverVersion, 'lowerThan', '3.16.0') || hideSystemMessages.length) {
newMessages = newMessages.filter(m => !m.t || !hideSystemMessages?.includes(m.t));
}

readThread();
setMessages(newMessages);
messagesIds.current = newMessages.map(m => m.id);
setRawMessages(newMessages); // Set state with raw DB results
});
}, [rid, tmid, showMessageInMainThread, serverVersion, hideSystemMessages]);
}, [rid, tmid, showMessageInMainThread, hideSystemMessages]); // hideSystemMessages must be here so the DB re-queries for proper pagination

const readThread = useDebounce(async () => {
if (tmid) {
Expand All @@ -102,11 +115,37 @@ export const useMessages = ({
return () => {
unsubscribe();
};
}, [rid, tmid, showMessageInMainThread, serverVersion, hideSystemMessages, fetchMessages]);
}, [fetchMessages]);

const unsubscribe = () => {
subscription.current?.unsubscribe();
};

return [messages, messagesIds, fetchMessages] as const;
const visibleMessages = useMemo(() => {
if (!hideSystemMessages || hideSystemMessages.length === 0) {
return rawMessages;
}
return rawMessages.filter(m => !m.t || !hideSystemMessages.includes(m.t));
}, [rawMessages, hideSystemMessages]);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should test this just to make sure: rawMessages is an array of observables from WatermelonDB. It's as dep here. Is it really working or is visibleMessages not really using useMemo?


//
// * When the server version is greater than or equal to 3.16.0, we need to dispatch a roomHistoryRequest to load the next chunk of messages.
// * This is because the server will not return the next chunk of messages if the user has hidden system messages.
// * @see https://github.com/RocketChat/Rocket.Chat/pull/28406
// */
useEffect(() => {
if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '3.16.0')) {
const loaderId = visibleMessages.find(m => m.t && MESSAGE_TYPE_ANY_LOAD.includes(m.t as MessageTypeLoad))?.id;
if (hideSystemMessages && loaderId) {
dispatch(roomHistoryRequest({ rid, t, loaderId }));
}
}
}, [hideSystemMessages, visibleMessages]);
Comment thread
OtavioStasiak marked this conversation as resolved.
// 2. Reactively filter in-memory. This mimics the Web's Zustand behavior
// and updates the UI instantly before the DB query even finishes rebuilding.
Comment on lines +144 to +145
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This item seems to be empty


// 3. Reactively compute IDs based on the currently visible messages
const visibleMessagesIds = useMemo(() => visibleMessages.map(m => m.id), [visibleMessages]);

return [visibleMessages, visibleMessagesIds, fetchMessages] as const;
};
Loading
Loading