-
Notifications
You must be signed in to change notification settings - Fork 1.4k
fix: switch hide system message not working on mobile and loading on older server #7130
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
8ec8f99
2cdac4a
c6fc775
3a2b4fe
f955180
cc0b9a3
62d0f03
473cf4e
b083e1b
9f60dcb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| } |
| 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); | ||
|
|
||
| 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() }; | ||
| } | ||
| }) | ||
| }) | ||
| })); | ||
|
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'); | ||
| }); | ||
| }); | ||
| }); | ||
| 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(); | ||
|
|
@@ -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) { | ||
|
|
@@ -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)))); | ||
| } | ||
|
|
@@ -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) { | ||
|
|
@@ -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]); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should test this just to make sure: |
||
|
|
||
| // | ||
| // * 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]); | ||
|
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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| }; | ||
Uh oh!
There was an error while loading. Please reload this page.