diff --git a/core/screens/chat/ChatRoomView.tsx b/core/screens/chat/ChatRoomView.tsx index d75f75284686e465e6cb15900523782d9729b1d1..5221973c5ba30745e1a30254e1a331b6963b6973 100644 --- a/core/screens/chat/ChatRoomView.tsx +++ b/core/screens/chat/ChatRoomView.tsx @@ -38,10 +38,10 @@ import HoliLoader from '@holi/ui/components/molecules/HoliLoader' import { HoliToastType, useToast } from '@holi/ui/components/molecules/HoliToastProvider' import HoliKeyboardSafeAreaView from '@holi/ui/components/organisms/HoliKeyboardSafeAreaView' import { dimensions } from '@holi/ui/styles/globalVars' -import { createStyleSheet } from 'holi-bricks/utils' import { useStyles } from 'holi-bricks/hooks' import { TrackingEvent } from '@holi/core/tracking' import useTracking from '@holi/core/tracking/hooks/useTracking' +import { createStyleSheet } from 'holi-bricks/utils' const WAIT_FOR_CHATROOM_DELAY = 5000 @@ -51,6 +51,8 @@ export type ChatRoomPageParams = { const { useParam } = createParamHooks<ChatRoomPageParams>() +const NUMBER_OF_TEXT_MESSAGES_NEEDED_TO_FILL_A_SCREEN_AND_CAUSE_SCROLLBARS = 40 + const ChatRoomView = () => { const { t } = useTranslation() const { navigateBack, replaceRoute } = useRouting() @@ -63,7 +65,7 @@ const ChatRoomView = () => { const { user: loggedInUser } = useLoggedInUser() const chatPartner = useAtomValue(useMemo(() => chatPartnerAtomCreator(roomId, loggedInUser), [loggedInUser, roomId])) - const requestMessages = useRequestRoomMessages() + const requestMessages = useRequestRoomMessages(NUMBER_OF_TEXT_MESSAGES_NEEDED_TO_FILL_A_SCREEN_AND_CAUSE_SCROLLBARS) const setScreenOptions = useSetScreenOptions() const roomMemberIds = room?.members.map((m) => m.id) ?? [] diff --git a/packages/chat/src/components/RoomMessageList/index.tsx b/packages/chat/src/components/RoomMessageList/index.tsx index 1936fd02aa4edc2092ccfe5aa99b07eeb274973b..082409091d4d5179e125e64b1215ed66ae7496a3 100644 --- a/packages/chat/src/components/RoomMessageList/index.tsx +++ b/packages/chat/src/components/RoomMessageList/index.tsx @@ -1,18 +1,18 @@ -import React from 'react' -import { useTranslation } from 'react-i18next' -import { View } from 'react-native' -import Animated, { LayoutAnimationConfig, ZoomInEasyDown, LinearTransition } from 'react-native-reanimated' import type { ChatRoomMessage } from '@holi/chat/src/client/types' import RoomMessageSection from '@holi/chat/src/components/RoomMessageList/RoomMessageSection' import { type MessageItem, isMessageSection, isTextMessageItem } from '@holi/chat/src/components/RoomMessageList/types' import { getSectionDisplayDate } from '@holi/chat/src/utils/date' import { getLocale } from '@holi/core/i18n/helpers/dateHelper' import { dimensions } from '@holi/ui/styles/globalVars' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { View } from 'react-native' +import Animated, { LayoutAnimationConfig, ZoomInEasyDown, LinearTransition } from 'react-native-reanimated' +import { useStyles } from 'holi-bricks/hooks' +import { createStyleSheet } from 'holi-bricks/utils' import RoomMessage from './RoomMessage' import type { RoomMessageListProps } from './types' -import { createStyleSheet } from 'holi-bricks/utils' -import { useStyles } from 'holi-bricks/hooks' const groupByDate = (messages: ChatRoomMessage[], locale: string): MessageItem[] => { const groupedMessages: Record<string, MessageItem[]> = {} diff --git a/packages/chat/src/components/__tests__/ChatHomeContent.test.tsx b/packages/chat/src/components/__tests__/ChatHomeContent.test.tsx index 8434b12fe5849b1309e8a08a783654daa4b5ec00..f7df3ad8902df4c86aa614190bef4bff4eef18ea 100644 --- a/packages/chat/src/components/__tests__/ChatHomeContent.test.tsx +++ b/packages/chat/src/components/__tests__/ChatHomeContent.test.tsx @@ -131,7 +131,7 @@ describe('ChatHomeContent Component', () => { }) it('should render login info screen if the user is not logged-in', async () => { - mxClient.createMessagesRequest.mockResolvedValue({ start: 'start', end: 'end', chunk: [] }) + mxClient.createMessagesRequest.mockResolvedValue({ start: 'start', end: undefined, chunk: [] }) useLoginStateMock.mockReturnValue(loggedOut) render( @@ -160,7 +160,7 @@ describe('ChatHomeContent Component', () => { mxClient.getRooms.mockReturnValue([roomMock]) mxClient.roomState.mockResolvedValue([]) - mxClient.createMessagesRequest.mockResolvedValue({ start: 'start', end: 'end', chunk: [] }) + mxClient.createMessagesRequest.mockResolvedValue({ start: 'start', end: undefined, chunk: [] }) render( <MockedProvider @@ -204,7 +204,7 @@ describe('ChatHomeContent Component', () => { mxClient.getRooms.mockReturnValue([roomMock]) mxClient.roomState.mockResolvedValue([]) mxClient.getUserId.mockReturnValue(MEMBER1_FIXTURE.id) - mxClient.createMessagesRequest.mockResolvedValue({ start: 'start', end: 'end', chunk: [] }) + mxClient.createMessagesRequest.mockResolvedValue({ start: 'start', end: undefined, chunk: [] }) render( <MockedProvider @@ -227,7 +227,7 @@ describe('ChatHomeContent Component', () => { }) it('should render incoming rooms', async () => { - mxClient.createMessagesRequest.mockResolvedValue({ start: 'start', end: 'end', chunk: [] }) + mxClient.createMessagesRequest.mockResolvedValue({ start: 'start', end: undefined, chunk: [] }) useLoginStateMock.mockReturnValue(loggedIn(HOLI_MEMBER1_ID_FIXTURE)) diff --git a/packages/chat/src/store/__tests__/store.messages.test.tsx b/packages/chat/src/store/__tests__/store.messages.test.tsx index 09761a631c62dec0343daff7206426a367f40dac..9f27c4753b56dfa598d9b8ba12f5f9ef38e145f4 100644 --- a/packages/chat/src/store/__tests__/store.messages.test.tsx +++ b/packages/chat/src/store/__tests__/store.messages.test.tsx @@ -1,7 +1,6 @@ import { act, renderHook } from '@testing-library/react-hooks' -import { waitFor } from '@testing-library/react-native' import { Provider, createStore, useAtom } from 'jotai' -import { EventType, type IRoomEvent, type MatrixClient, type MatrixEvent, type Room, RoomEvent } from 'matrix-js-sdk' +import { EventType, type MatrixClient, type MatrixEvent, type Room, RoomEvent } from 'matrix-js-sdk' import React from 'react' import type { PropsWithChildren } from 'react' @@ -18,19 +17,20 @@ import { createChatClient, createMatrixClientMock, createMatrixEventMock, + fakeRoomMessageEvent, + fakeUnrenderedEvent, getListenerForMatrixEvent, } from '@holi/chat/src/test-utils' import { - expectedMsgForEvent, + fakeChatRoomMessageFrom, initChatRoomFixture, - msgEvent, sendMessageEvent, } from '@holi/chat/src/test-utils/matrix-fixtures' jest.mock('matrix-js-sdk') jest.mock('@holi/chat/src/client/pusher') -const useStoreMessagesAtomsTest = (roomId: string) => { +const useTestRoom = (roomId: string) => { const response = useRoomById(roomId) useAtom(_roomAtomsRecordAtomSubscriber) useAtom(_newMessageAtomSubscriber) @@ -38,7 +38,7 @@ const useStoreMessagesAtomsTest = (roomId: string) => { } describe('Chat Store - messages', () => { - const wrapper = ({ children }: PropsWithChildren) => <Provider store={store}>{children}</Provider> + const JotaiProvider = ({ children }: PropsWithChildren) => <Provider store={store}>{children}</Provider> let store: ReturnType<typeof createStore> let mxClient: jest.MockedObjectDeep<MatrixClient> let chatClient: ChatClient @@ -57,7 +57,7 @@ describe('Chat Store - messages', () => { it('shoud receive messages in an existing room', () => { const { roomMock, room, creator, member } = initChatRoomFixture(mxClient) - const { result } = renderHook(() => useStoreMessagesAtomsTest(room.id), { wrapper }) + const { result } = renderHook(() => useTestRoom(room.id), { wrapper: JotaiProvider }) const listener = getListenerForMatrixEvent(mxClient, RoomEvent.Timeline, 'messagesListener') as ( event: MatrixEvent, room: Room | undefined @@ -81,7 +81,7 @@ describe('Chat Store - messages', () => { it('A new message with the same event id should replace existing message content', () => { const { roomMock, room, creator, member } = initChatRoomFixture(mxClient) - const { result } = renderHook(() => useStoreMessagesAtomsTest(room.id), { wrapper }) + const { result } = renderHook(() => useTestRoom(room.id), { wrapper: JotaiProvider }) const listener = getListenerForMatrixEvent(mxClient, RoomEvent.Timeline, 'messagesListener') as ( event: MatrixEvent, room: Room | undefined @@ -107,12 +107,12 @@ describe('Chat Store - messages', () => { [message1, message2, message3], ]) - const event2Duplicate = msgEvent(room.id, member.id, 4) + const event2Duplicate = fakeRoomMessageEvent(room.id, member.id, 4) event2Duplicate.event_id = event2.event_id event2Duplicate.origin_server_ts = event2.origin_server_ts const eventMock2Duplicate = createMatrixEventMock(EventType.RoomMessage, room.id, {}, event2Duplicate) act(() => listener(eventMock2Duplicate, roomMock)) - const message2Duplicate = expectedMsgForEvent(event2Duplicate) + const message2Duplicate = fakeChatRoomMessageFrom(event2Duplicate) // the last message and timestamp is not updated, because an existing message with an older timestamp is replaced expect(result.current).toStrictEqual([ @@ -121,23 +121,56 @@ describe('Chat Store - messages', () => { ]) }) - it('Requesting room messages should update the store in bulk, but not the room', async () => { - const event1 = msgEvent(room.id, creator.id, 1) - const event2 = msgEvent(room.id, creator.id, 2) - const event3 = msgEvent(room.id, creator.id, 3) - const roomEvents = [event1, event2, event3] as IRoomEvent[] - mxClient.createMessagesRequest.mockResolvedValue({ chunk: roomEvents, start: 'start', end: 'end' }) - const { result } = renderHook(() => useRequestRoomMessages(), { wrapper }) - result.current(room.id, false) + /* react ------------------> matrix sdk ------------> jotai store */ + /* useRequestRoomMessages createMessagesRequest useRoomById */ - const { result: result2 } = renderHook(() => useStoreMessagesAtomsTest(room.id), { wrapper }) + it('Requesting messages for a room should update the store', async () => { + const event1 = fakeRoomMessageEvent(room.id, creator.id, 1) + const event2 = fakeRoomMessageEvent(room.id, creator.id, 2) + const message1 = fakeChatRoomMessageFrom(event1) + const message2 = fakeChatRoomMessageFrom(event2) + const roomEvents = [event1, event2] + mxClient.createMessagesRequest.mockResolvedValue({ chunk: roomEvents, start: 'start', end: undefined }) - const message1 = expectedMsgForEvent(event1) - const message2 = expectedMsgForEvent(event2) - const message3 = expectedMsgForEvent(event3) + const { result } = renderHook(() => useRequestRoomMessages(), { wrapper: JotaiProvider }) + await result.current(room.id, false) + const { result: result2 } = renderHook(() => useTestRoom(room.id), { wrapper: JotaiProvider }) - await waitFor(() => { - expect(result2.current).toStrictEqual([room, [message3, message2, message1]]) - }) + expect(result2.current).toStrictEqual([room, [message2, message1]]) + }) + + it('Requesting 2 messages from a room should repeatedly fetch messages until 2 messages are found', async () => { + const me1 = fakeRoomMessageEvent(room.id, creator.id, 1) + const ue2 = fakeUnrenderedEvent(room.id, creator.id, 2) + const ue3 = fakeUnrenderedEvent(room.id, creator.id, 3) + const me4 = fakeRoomMessageEvent(room.id, creator.id, 4) + const me5 = fakeRoomMessageEvent(room.id, creator.id, 5) + + mxClient.createMessagesRequest + .mockResolvedValueOnce({ chunk: [me1, ue2], start: 'start', end: 'someEnd' }) + .mockResolvedValueOnce({ chunk: [ue3, me4], start: 'start', end: 'someOtherEnd' }) + .mockResolvedValueOnce({ chunk: [me4, me5], start: 'start', end: undefined }) + + const { result } = renderHook(() => useRequestRoomMessages(2), { wrapper: JotaiProvider }) + await result.current(room.id, false) + const { result: result2 } = renderHook(() => useTestRoom(room.id), { wrapper: JotaiProvider }) + + expect(result2.current).toStrictEqual([room, [fakeChatRoomMessageFrom(me4), fakeChatRoomMessageFrom(me1)]]) + }) + + it('Requesting more messages than available, will fetch until end is reached', async () => { + const me1 = fakeRoomMessageEvent(room.id, creator.id, 1) + const ue2 = fakeUnrenderedEvent(room.id, creator.id, 2) + const ue3 = fakeUnrenderedEvent(room.id, creator.id, 3) + + mxClient.createMessagesRequest + .mockResolvedValueOnce({ chunk: [me1, ue2], start: 'start', end: 'someEnd' }) + .mockResolvedValueOnce({ chunk: [ue3], start: 'start', end: undefined }) + + const { result } = renderHook(() => useRequestRoomMessages(2), { wrapper: JotaiProvider }) + await result.current(room.id, false) + const { result: result2 } = renderHook(() => useTestRoom(room.id), { wrapper: JotaiProvider }) + + expect(result2.current).toStrictEqual([room, [fakeChatRoomMessageFrom(me1)]]) }) }) diff --git a/packages/chat/src/store/store.ts b/packages/chat/src/store/store.ts index 2772b2e21f7af0ae673e2788beaea9b8079c278c..a805f1dd1d5659f6b0a30ceb1d2ebd32ef5b7d66 100644 --- a/packages/chat/src/store/store.ts +++ b/packages/chat/src/store/store.ts @@ -1,21 +1,21 @@ import { produce } from 'immer' import { type Atom, atom, useAtom, useAtomValue, useSetAtom } from 'jotai' -import { IRoomEvent, MatrixEvent, Room, RoomMember, RoomState } from 'matrix-js-sdk' +import type { IRoomEvent, MatrixEvent, Room, RoomMember, RoomState } from 'matrix-js-sdk' import { useCallback, useEffect, useMemo, useRef } from 'react' -import ChatClient from '@holi/chat/src/client/ChatClient' +import type ChatClient from '@holi/chat/src/client/ChatClient' import { setChatInitialized } from '@holi/chat/src/client/chatState' import { - ChatRoom, + type ChatRoom, type ChatRoomMember, - ChatRoomMessage, + type ChatRoomMessage, type MatrixId, MembershipStatus, type NotificationCount, type RoomId, - RoomUpdatesContent, + type RoomUpdatesContent, RoomUpdatesType, - SpaceChatRoom, + type SpaceChatRoom, } from '@holi/chat/src/client/types' import { createChatMessage } from '@holi/chat/src/client/utils' import { @@ -38,7 +38,7 @@ import type { RoomAtomByRoomId, SpaceRoomAtom, } from '@holi/chat/src/store/types' -import { MessageAtomsByRoomId } from '@holi/chat/src/store/types' +import type { MessageAtomsByRoomId } from '@holi/chat/src/store/types' import { getAtomFromRecordId, isPrivateChatRoom, @@ -47,7 +47,7 @@ import { toMatrixIdentity, updateSpaceRoomsNotifications, } from '@holi/chat/src/store/utils' -import { User as HoliUser } from '@holi/core/domain/shared/types' +import type { User as HoliUser } from '@holi/core/domain/shared/types' import { HoliError } from '@holi/core/errors/classes/HoliError' import { logError } from '@holi/core/errors/helpers' import { getLogger } from '@holi/core/helpers/logging' @@ -118,8 +118,8 @@ export const privateRoomAtomsAtom = atom<ChatRoomAtom[]>((get) => export const roomAtomsSortedByDateAtom = atom((get) => { const roomAtoms = Object.values(get(_roomAtomsRecordAtom)) return roomAtoms.sort((rA, rB) => { - const tsA = get(rA).timestamp ?? Infinity - const tsB = get(rB).timestamp ?? Infinity + const tsA = get(rA).timestamp ?? Number.POSITIVE_INFINITY + const tsB = get(rB).timestamp ?? Number.POSITIVE_INFINITY return tsA < tsB ? 1 : -1 }) }) @@ -584,11 +584,7 @@ export const updateMessages = produce((thread: ChatRoomMessage[], newMessage: Ch Object.assign(dupMessage, newMessage) }) -/** - * @name useRequestRoomMessages (Hook-Helper) - * @description - */ -export const useRequestRoomMessages = () => { +export const useRequestRoomMessages = (numberOfMessagesToFetch = 40) => { const setMessages = useSetAtom(_messageBunchAtomSetter) const client = chatClient const tokenRef = useRef<{ start?: string; end?: string }>({ start: undefined, end: undefined }) @@ -596,28 +592,42 @@ export const useRequestRoomMessages = () => { return useCallback( async (roomId: string, resync: boolean, limit?: number) => { - if (!roomId || !client) return - if (isLoading.current || (tokenRef.current.start && !tokenRef.current.end)) return - - isLoading.current = true - let merge = !!tokenRef.current.end - if (resync) { - tokenRef.current = { start: undefined, end: undefined } - merge = false + if (!roomId || !client) return {} + // skip when triggered by multiple scroll events + if (isLoading.current) { + return + } + // skip when all messages were fetched + if (tokenRef.current.start && !tokenRef.current.end) { + return } - await client - .createMessagesRequest(roomId, tokenRef.current.end, limit) - .then(async (resp) => { - const { chunk: roomEvents, start, end } = resp - setMessages(roomId, roomEvents, merge) + let totalMessages = 0 + + const fetchMessages = async () => { + isLoading.current = true + let merge = !!tokenRef.current.end + if (resync) { + tokenRef.current = { start: undefined, end: undefined } + merge = false + } + + await client + .createMessagesRequest(roomId, tokenRef.current.end, limit) + .then(async (resp) => { + const { chunk: roomMessageEvents, start, end } = resp + setMessages(roomId, roomMessageEvents, merge) + tokenRef.current = { start, end } + totalMessages += roomMessageEvents.length + }) + .finally(() => (isLoading.current = false)) + } - // unpdate start and end tokens only after merging messages - tokenRef.current = { start, end } - }) - .finally(() => (isLoading.current = false)) + do { + await fetchMessages() + } while (totalMessages < numberOfMessagesToFetch && tokenRef.current.end) }, - [client, setMessages] + [client, setMessages, numberOfMessagesToFetch] ) } diff --git a/packages/chat/src/test-utils/matrix-fixtures.tsx b/packages/chat/src/test-utils/matrix-fixtures.tsx index e8a5428ebdd225b623efccee3363a493358b6ff5..12dd4fc93462492d1de5810a62adc90eb187bfe5 100644 --- a/packages/chat/src/test-utils/matrix-fixtures.tsx +++ b/packages/chat/src/test-utils/matrix-fixtures.tsx @@ -98,8 +98,7 @@ export const SPACE_CHILD_EVENT_CONTENT = { parentSpaceRoomId: SPACE_ROOM_ID_FIXTURE, } -// Message helpers -export const msgEvent = (roomId: string, senderId: string, eventNr: number): IEvent => { +export const fakeRoomMessageEvent = (roomId: string, senderId: string, eventNr: number): IEvent => { return { state_key: 'dummy', event_id: 'id_' + eventNr, @@ -114,7 +113,20 @@ export const msgEvent = (roomId: string, senderId: string, eventNr: number): IEv } } -export const expectedMsgForEvent = (e: IEvent): ChatRoomMessage => { +export const fakeUnrenderedEvent = (roomId: string, senderId: string, eventNr: number): IEvent => { + return { + state_key: 'someStateKey', + event_id: 'id_' + eventNr, + type: EventType.Dummy, + sender: senderId, + origin_server_ts: eventNr, + unsigned: {}, + room_id: roomId, + content: {}, + } +} + +export const fakeChatRoomMessageFrom = (e: IEvent): ChatRoomMessage => { return { id: e.event_id, timestamp: e.origin_server_ts, @@ -134,12 +146,12 @@ export const sendMessageEvent = ( senderId: string, eventNr: number ) => { - const event = msgEvent(roomMock.roomId, senderId, eventNr) + const event = fakeRoomMessageEvent(roomMock.roomId, senderId, eventNr) const eventMock = createMatrixEventMock(EventType.RoomMessage, roomMock.roomId, {}, event) act(() => listener(eventMock, roomMock)) - const message = expectedMsgForEvent(event) + const message = fakeChatRoomMessageFrom(event) return { message, event, eventMock } }