diff --git a/packages/common/src/store/pages/chat/sagas.ts b/packages/common/src/store/pages/chat/sagas.ts index 24ccff04019..9ffd7a56169 100644 --- a/packages/common/src/store/pages/chat/sagas.ts +++ b/packages/common/src/store/pages/chat/sagas.ts @@ -37,9 +37,11 @@ const { fetchUnreadMessagesCountFailed, goToChat, fetchChatIfNecessary, + fetchLatestChats, fetchMoreChats, fetchMoreChatsSucceeded, fetchMoreChatsFailed, + fetchLatestMessages, fetchMoreMessages, fetchMoreMessagesSucceeded, fetchMoreMessagesFailed, @@ -75,6 +77,9 @@ const { } = chatSelectors const { toast } = toastActions +const CHAT_PAGE_SIZE = 30 +const MESSAGES_PAGE_SIZE = 50 + /** * Helper to dispatch actions for fetching chat users */ @@ -111,6 +116,49 @@ function* doFetchUnreadMessagesCount() { } } +/** + * Gets all chats fresher than what we currently have + */ +function* doFetchLatestChats() { + try { + const audiusSdk = yield* getContext('audiusSdk') + const sdk = yield* call(audiusSdk) + const summary = yield* select(getChatsSummary) + let before: string | undefined + let hasMoreChats = true + let data: UserChat[] = [] + let firstResponse: TypedCommsResponse | undefined + while (hasMoreChats) { + const response = yield* call([sdk.chats, sdk.chats.getAll], { + before, + after: summary?.next_cursor, + limit: CHAT_PAGE_SIZE + }) + hasMoreChats = response.data.length > 0 + before = summary?.prev_cursor + data = data.concat(response.data) + if (!firstResponse) { + firstResponse = response + } + } + yield* fetchUsersForChats(data) + yield* put( + fetchMoreChatsSucceeded({ + ...firstResponse, + data + }) + ) + } catch (e) { + console.error('fetchLatestChatsFailed', e) + yield* put(fetchMoreChatsFailed()) + const reportToSentry = yield* getContext('reportToSentry') + reportToSentry({ + level: ErrorLevel.Error, + error: e as Error + }) + } +} + function* doFetchMoreChats() { try { const audiusSdk = yield* getContext('audiusSdk') @@ -119,7 +167,7 @@ function* doFetchMoreChats() { const before = summary?.prev_cursor const response = yield* call([sdk.chats, sdk.chats.getAll], { before, - limit: 30 + limit: CHAT_PAGE_SIZE }) yield* fetchUsersForChats(response.data) yield* put(fetchMoreChatsSucceeded(response)) @@ -134,6 +182,76 @@ function* doFetchMoreChats() { } } +/** + * Gets all messages newer than what we currently have + */ +function* doFetchLatestMessages( + action: ReturnType +) { + const { chatId } = action.payload + try { + const audiusSdk = yield* getContext('audiusSdk') + const sdk = yield* call(audiusSdk) + + // Update the chat too to keep everything in sync + yield* call(doFetchChat, { chatId }) + const chat = yield* select((state) => getChat(state, chatId)) + const after = chat?.messagesSummary?.next_cursor + + // On first fetch of messages, we won't have an after cursor. + // Do `fetchMoreMessages` instead for initial fetch of messages and get all up to the first unread + if (!after) { + yield* call(doFetchMoreMessages, action) + return + } + + let hasMoreUnread = true + let data: ChatMessage[] = [] + let before: string | undefined + let firstResponse: TypedCommsResponse | undefined + while (hasMoreUnread) { + // This will get all messages sent after what we currently got, starting at the most recent, + // and batching by MESSAGE_PAGE_SIZE. Sends one extra request to get the 0 response but oh well + const response = yield* call([sdk.chats, sdk.chats.getMessages], { + chatId, + before, + after, + limit: MESSAGES_PAGE_SIZE + }) + data = data.concat(response.data) + // If we have no more messages with our after filter, we're done + hasMoreUnread = response.data.length > 0 + before = response.summary?.prev_cursor + if (!firstResponse) { + firstResponse = response + } + } + yield* put( + fetchMoreMessagesSucceeded({ + chatId, + response: { + ...firstResponse, + data + } + }) + ) + } catch (e) { + console.error('fetchLatestChatMessagesFailed', e) + yield* put(fetchMoreMessagesFailed({ chatId })) + const reportToSentry = yield* getContext('reportToSentry') + reportToSentry({ + level: ErrorLevel.Error, + error: e as Error, + additionalInfo: { + chatId + } + }) + } +} + +/** + * Get older messages than what we currently have for a chat + */ function* doFetchMoreMessages(action: ReturnType) { const { chatId } = action.payload try { @@ -150,20 +268,20 @@ function* doFetchMoreMessages(action: ReturnType) { let hasMoreUnread = true let data: ChatMessage[] = [] while (hasMoreUnread) { - const limit = 10 - const response = yield* call([sdk.chats, sdk.chats!.getMessages], { + const response = yield* call([sdk.chats, sdk.chats.getMessages], { chatId, before, - limit + limit: MESSAGES_PAGE_SIZE }) // Only save the last response summary. Pagination is one-way lastResponse = response data = data.concat(response.data) // If the unread count is greater than the previous fetched messages (next_cursor) - // plus this batch (limit), we should keep fetching + // plus this batch, we should keep fetching hasMoreUnread = !!chat?.unread_message_count && - chat.unread_message_count > (response.summary?.next_count ?? 0) + limit + chat.unread_message_count > + (response.summary?.next_count ?? 0) + data.length before = response.summary?.prev_cursor } if (!lastResponse) { @@ -179,7 +297,7 @@ function* doFetchMoreMessages(action: ReturnType) { }) ) } catch (e) { - console.error('fetchNewChatMessagesFailed', e) + console.error('fetchMoreChatMessagesFailed', e) yield* put(fetchMoreMessagesFailed({ chatId })) const reportToSentry = yield* getContext('reportToSentry') reportToSentry({ @@ -631,11 +749,19 @@ function* watchSendMessage() { yield takeEvery(sendMessage, doSendMessage) } -function* watchFetchChats() { +function* watchFetchLatestChats() { + yield takeLatest(fetchLatestChats, doFetchLatestChats) +} + +function* watchFetchMoreChats() { yield takeLatest(fetchMoreChats, doFetchMoreChats) } -function* watchFetchChatMessages() { +function* watchFetchLatestMessages() { + yield takeEvery(fetchLatestMessages, doFetchLatestMessages) +} + +function* watchFetchMoreMessages() { yield takeEvery(fetchMoreMessages, doFetchMoreMessages) } @@ -683,8 +809,10 @@ export const sagas = () => { return [ watchFetchUnreadMessagesCount, watchFetchChatIfNecessary, - watchFetchChats, - watchFetchChatMessages, + watchFetchLatestChats, + watchFetchMoreChats, + watchFetchLatestMessages, + watchFetchMoreMessages, watchSetMessageReaction, watchCreateChat, watchMarkChatAsRead, diff --git a/packages/common/src/store/pages/chat/slice.ts b/packages/common/src/store/pages/chat/slice.ts index b4b4b0dd4f5..0f8fb9d2806 100644 --- a/packages/common/src/store/pages/chat/slice.ts +++ b/packages/common/src/store/pages/chat/slice.ts @@ -30,7 +30,7 @@ type ChatID = string type ChatState = { chats: EntityState & { - status: Status + status: Status | 'REFRESHING' summary?: TypedCommsResponse['summary'] } messages: Record< @@ -156,26 +156,61 @@ const slice = createSlice({ goToChat: (_state, _action: PayloadAction<{ chatId?: string }>) => { // triggers saga }, + fetchLatestChats: (state) => { + // triggers saga + state.chats.status = 'REFRESHING' + }, fetchMoreChats: (state) => { // triggers saga state.chats.status = Status.LOADING }, fetchMoreChatsSucceeded: ( state, - action: PayloadAction> + action: PayloadAction< + Pick, 'data' | 'summary'> + > ) => { + const { data, summary } = action.payload state.chats.status = Status.SUCCESS - state.chats.summary = action.payload.summary - for (const chat of action.payload.data) { + if (!state.chats.summary) { + state.chats.summary = summary + } else { + if ( + summary?.next_cursor && + dayjs(summary?.next_cursor).isAfter(state.chats.summary.next_cursor) + ) { + state.chats.summary.next_cursor = summary?.next_cursor + state.chats.summary.next_count = summary?.next_count + } + if ( + summary?.prev_cursor && + dayjs(summary?.prev_cursor).isBefore(state.chats.summary.prev_cursor) + ) { + state.chats.summary.prev_cursor = summary?.prev_cursor + state.chats.summary.prev_count = summary?.prev_count + } + } + for (const chat of data) { if (!(chat.chat_id in state.messages)) { state.messages[chat.chat_id] = chatMessagesAdapter.getInitialState() } } - chatsAdapter.addMany(state.chats, action.payload.data) + chatsAdapter.upsertMany(state.chats, data) }, fetchMoreChatsFailed: (state) => { state.chats.status = Status.ERROR }, + fetchLatestMessages: (state, action: PayloadAction<{ chatId: string }>) => { + // triggers saga + if (!state.messages[action.payload.chatId]) { + state.messages[action.payload.chatId] = { + ...chatMessagesAdapter.getInitialState(), + status: Status.LOADING + } + } else { + state.messages[action.payload.chatId].status = Status.LOADING + } + }, fetchMoreMessages: (state, action: PayloadAction<{ chatId: string }>) => { // triggers saga if (!state.messages[action.payload.chatId]) { @@ -190,7 +225,7 @@ const slice = createSlice({ fetchMoreMessagesSucceeded: ( state, action: PayloadAction<{ - response: TypedCommsResponse + response: Pick, 'summary' | 'data'> chatId: string }> ) => { @@ -203,15 +238,21 @@ const slice = createSlice({ return } - // Update the summary to include the min of next_cursor/next_count and - // prev_cursor/prev_count. + // Update the summary to include the max of next_cursor and + // min of prev_cursor. const existingSummary = state.chats.entities[chatId]?.messagesSummary const summaryToUse = { ...summary, ...existingSummary } - if (summary.next_count < (existingSummary?.next_count ?? Infinity)) { + if ( + !existingSummary || + dayjs(summary.next_cursor).isAfter(existingSummary.next_cursor) + ) { summaryToUse.next_count = summary.next_count summaryToUse.next_cursor = summary.next_cursor } - if (summary.prev_count < (existingSummary?.prev_count ?? Infinity)) { + if ( + !existingSummary || + dayjs(summary.prev_cursor).isBefore(existingSummary.prev_cursor) + ) { summaryToUse.prev_count = summary.prev_count summaryToUse.prev_cursor = summary.prev_cursor } @@ -227,10 +268,12 @@ const slice = createSlice({ return { ...item, hasTail: hasTail(item, data[index - 1]) } }) chatMessagesAdapter.upsertMany(state.messages[chatId], messagesWithTail) - recalculatePreviousMessageHasTail( - state.messages[chatId], - summary.next_count - 1 - ) + if (data.length > 0) { + recalculatePreviousMessageHasTail( + state.messages[chatId], + summary.next_count - 1 + ) + } }, fetchMoreMessagesFailed: ( state, diff --git a/packages/mobile/src/screens/chat-screen/ChatListScreen.tsx b/packages/mobile/src/screens/chat-screen/ChatListScreen.tsx index 3b0fae9f964..722029d94fa 100644 --- a/packages/mobile/src/screens/chat-screen/ChatListScreen.tsx +++ b/packages/mobile/src/screens/chat-screen/ChatListScreen.tsx @@ -19,7 +19,7 @@ import { ChatListItemSkeleton } from './ChatListItemSkeleton' import { HeaderShadow } from './HeaderShadow' const { getChats, getChatsStatus, getHasMoreChats } = chatSelectors -const { fetchMoreMessages, fetchMoreChats } = chatActions +const { fetchMoreMessages, fetchLatestChats, fetchMoreChats } = chatActions const CHATS_MESSAGES_PREFETCH_LIMIT = 10 @@ -120,6 +120,15 @@ export const ChatListScreen = () => { ) + const handleLoadMore = useCallback(() => { + if (chatsStatus === Status.LOADING || !hasMore) return + dispatch(fetchMoreChats()) + }, [hasMore, chatsStatus, dispatch]) + + const refresh = useCallback(() => { + dispatch(fetchLatestChats()) + }, [dispatch]) + // Prefetch messages for initial loaded chats useEffect(() => { if ( @@ -134,10 +143,9 @@ export const ChatListScreen = () => { } }, [chats, dispatch]) - const handleLoadMore = useCallback(() => { - if (chatsStatus === Status.LOADING || !hasMore) return - dispatch(fetchMoreChats()) - }, [hasMore, chatsStatus, dispatch]) + useEffect(() => { + refresh() + }, [refresh]) return ( { )) ) : ( } diff --git a/packages/mobile/src/screens/chat-screen/ChatScreen.tsx b/packages/mobile/src/screens/chat-screen/ChatScreen.tsx index 8bb280c6af4..7f5ad16cbbb 100644 --- a/packages/mobile/src/screens/chat-screen/ChatScreen.tsx +++ b/packages/mobile/src/screens/chat-screen/ChatScreen.tsx @@ -78,6 +78,7 @@ const { getReactionsPopupMessageId } = chatSelectors const { + fetchLatestMessages, fetchMoreMessages, markChatAsRead, setReactionsPopupMessageId, @@ -254,16 +255,12 @@ export const ChatScreen = () => { // The chat/chatId selectors will trigger the rerenders necessary. const chatFrozenRef = useRef(chat) - // Initial fetch, but only if messages weren't fetched on app load + // Refresh messages on first render useEffect(() => { - if ( - chatId && - (chat?.messagesStatus ?? Status.IDLE) === Status.IDLE && - chatMessages.length === 0 - ) { - dispatch(fetchMoreMessages({ chatId })) + if (chatId) { + dispatch(fetchLatestMessages({ chatId })) } - }, [dispatch, chatId, chat, chatMessages.length]) + }, [dispatch, chatId]) useEffect(() => { // Update chatFrozenRef when entering a new chat screen.