@planningcenter/chat-react-native 3.35.0-rc.0 → 3.35.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/build/contexts/conversation_context.d.ts +6 -1
  2. package/build/contexts/conversation_context.d.ts.map +1 -1
  3. package/build/contexts/conversation_context.js +13 -3
  4. package/build/contexts/conversation_context.js.map +1 -1
  5. package/build/hooks/use_conversation_messages.d.ts +13 -6
  6. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  7. package/build/hooks/use_conversation_messages.js +56 -7
  8. package/build/hooks/use_conversation_messages.js.map +1 -1
  9. package/build/hooks/use_features.d.ts +1 -0
  10. package/build/hooks/use_features.d.ts.map +1 -1
  11. package/build/hooks/use_features.js +1 -0
  12. package/build/hooks/use_features.js.map +1 -1
  13. package/build/hooks/use_suspense_api.d.ts +1 -0
  14. package/build/hooks/use_suspense_api.d.ts.map +1 -1
  15. package/build/hooks/use_suspense_api.js +1 -1
  16. package/build/hooks/use_suspense_api.js.map +1 -1
  17. package/build/jest.d.ts.map +1 -1
  18. package/build/jest.js +5 -1
  19. package/build/jest.js.map +1 -1
  20. package/build/screens/conversation_screen.d.ts +1 -0
  21. package/build/screens/conversation_screen.d.ts.map +1 -1
  22. package/build/screens/conversation_screen.js +10 -4
  23. package/build/screens/conversation_screen.js.map +1 -1
  24. package/build/utils/conversation_messages.d.ts +10 -0
  25. package/build/utils/conversation_messages.d.ts.map +1 -0
  26. package/build/utils/conversation_messages.js +22 -0
  27. package/build/utils/conversation_messages.js.map +1 -0
  28. package/package.json +2 -2
  29. package/src/__tests__/hooks/use_conversation_messages.test.tsx +109 -0
  30. package/src/__tests__/jest.ts +5 -1
  31. package/src/contexts/conversation_context.tsx +28 -2
  32. package/src/hooks/use_conversation_messages.ts +105 -21
  33. package/src/hooks/use_features.ts +1 -0
  34. package/src/hooks/use_suspense_api.ts +1 -1
  35. package/src/jest.ts +5 -1
  36. package/src/screens/conversation_screen.tsx +13 -3
  37. package/src/utils/__tests__/conversation_messages.test.ts +105 -0
  38. package/src/utils/conversation_messages.ts +37 -0
  39. package/src/__tests__/hooks/use_conversation_messages.ts +0 -55
@@ -0,0 +1,22 @@
1
+ export const anchoredSeedPageParams = (anchor) => [
2
+ { where: { id_gte: anchor }, order: 'asc' },
3
+ { where: { id_lt: anchor }, order: 'desc' },
4
+ ];
5
+ export const olderPageParam = (page) => {
6
+ const idLt = page.meta?.next?.idLt;
7
+ if (!idLt)
8
+ return undefined;
9
+ return { where: { id_lt: idLt }, order: 'desc' };
10
+ };
11
+ export const newerPageParam = (page) => {
12
+ const idGt = page.meta?.next?.idGt;
13
+ if (!idGt)
14
+ return undefined;
15
+ return { where: { id_gt: idGt }, order: 'asc' };
16
+ };
17
+ export const sortAndFilterMessages = (pages) => pages
18
+ .flatMap(page => page.data)
19
+ .filter(message => (!message.deletedAt || message.replyRootId) &&
20
+ (message.attachments?.length || message.text?.length))
21
+ .sort((a, b) => -a.id.localeCompare(b.id));
22
+ //# sourceMappingURL=conversation_messages.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conversation_messages.js","sourceRoot":"","sources":["../../src/utils/conversation_messages.ts"],"names":[],"mappings":"AAOA,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,MAAc,EAAuB,EAAE,CAAC;IAC7E,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE;IAC3C,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE;CAC5C,CAAA;AAED,MAAM,CAAC,MAAM,cAAc,GAAG,CAC5B,IAAoC,EACL,EAAE;IACjC,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAA;IAClC,IAAI,CAAC,IAAI;QAAE,OAAO,SAAS,CAAA;IAC3B,OAAO,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,CAAA;AAClD,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,cAAc,GAAG,CAC5B,IAAoC,EACL,EAAE;IACjC,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAA;IAClC,IAAI,CAAC,IAAI;QAAE,OAAO,SAAS,CAAA;IAC3B,OAAO,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAA;AACjD,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,KAAuC,EAAqB,EAAE,CAClG,KAAK;KACF,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC;KAC1B,MAAM,CACL,OAAO,CAAC,EAAE,CACR,CAAC,CAAC,OAAO,CAAC,SAAS,IAAI,OAAO,CAAC,WAAW,CAAC;IAC3C,CAAC,OAAO,CAAC,WAAW,EAAE,MAAM,IAAI,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CACxD;KACA,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA","sourcesContent":["import { ApiCollection, MessageResource } from '../types'\nimport { RequestData } from './client'\n\nexport type MessagesPageParam = Partial<RequestData> & {\n order?: 'asc' | 'desc'\n}\n\nexport const anchoredSeedPageParams = (anchor: string): MessagesPageParam[] => [\n { where: { id_gte: anchor }, order: 'asc' },\n { where: { id_lt: anchor }, order: 'desc' },\n]\n\nexport const olderPageParam = (\n page: ApiCollection<MessageResource>\n): MessagesPageParam | undefined => {\n const idLt = page.meta?.next?.idLt\n if (!idLt) return undefined\n return { where: { id_lt: idLt }, order: 'desc' }\n}\n\nexport const newerPageParam = (\n page: ApiCollection<MessageResource>\n): MessagesPageParam | undefined => {\n const idGt = page.meta?.next?.idGt\n if (!idGt) return undefined\n return { where: { id_gt: idGt }, order: 'asc' }\n}\n\nexport const sortAndFilterMessages = (pages: ApiCollection<MessageResource>[]): MessageResource[] =>\n pages\n .flatMap(page => page.data)\n .filter(\n message =>\n (!message.deletedAt || message.replyRootId) &&\n (message.attachments?.length || message.text?.length)\n )\n .sort((a, b) => -a.id.localeCompare(b.id))\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planningcenter/chat-react-native",
3
- "version": "3.35.0-rc.0",
3
+ "version": "3.35.0-rc.2",
4
4
  "description": "",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -65,5 +65,5 @@
65
65
  "react-native-url-polyfill": "^2.0.0",
66
66
  "typescript": "~5.9.2"
67
67
  },
68
- "gitHead": "dd9252ef05d2542745a31cf10b45083a9a365834"
68
+ "gitHead": "eecec62a4683e528bb8e2f275d19fb9915544679"
69
69
  }
@@ -0,0 +1,109 @@
1
+ import { QueryClientProvider } from '@tanstack/react-query'
2
+ import { act, renderHook } from '@testing-library/react-hooks'
3
+ import React, { Suspense } from 'react'
4
+ import { buildTestQueryClient } from '../../__utils__/query_client'
5
+ import { ConversationContextProvider } from '../../contexts/conversation_context'
6
+ import * as useApiClientModule from '../../hooks/use_api_client'
7
+ import { useConversationMessages } from '../../hooks/use_conversation_messages'
8
+ import { ApiCollection, MessageResource } from '../../types'
9
+
10
+ const mockMessage = (id: string): MessageResource =>
11
+ ({
12
+ id,
13
+ type: 'Message',
14
+ text: `msg ${id}`,
15
+ attachments: [],
16
+ deletedAt: null,
17
+ replyRootId: null,
18
+ }) as MessageResource
19
+
20
+ const apiResponse = (data: MessageResource[]): ApiCollection<MessageResource> => ({
21
+ data,
22
+ links: {},
23
+ meta: { count: data.length, totalCount: data.length, next: {} },
24
+ })
25
+
26
+ const createWrapper = (initialMessageId: string | null) => {
27
+ const queryClient = buildTestQueryClient()
28
+
29
+ return ({ children }: { children: React.ReactNode }) => (
30
+ <QueryClientProvider client={queryClient}>
31
+ <Suspense fallback={null}>
32
+ <ConversationContextProvider
33
+ conversationId={123}
34
+ currentPageReplyRootId={null}
35
+ initialMessageId={initialMessageId}
36
+ initialMessageIdIsAnchor={!!initialMessageId}
37
+ >
38
+ {children}
39
+ </ConversationContextProvider>
40
+ </Suspense>
41
+ </QueryClientProvider>
42
+ )
43
+ }
44
+
45
+ const flushPromises = async () => {
46
+ await act(async () => {
47
+ await Promise.resolve()
48
+ await Promise.resolve()
49
+ await Promise.resolve()
50
+ await Promise.resolve()
51
+ })
52
+ }
53
+
54
+ const mockApiClient = (get: jest.Mock) => {
55
+ jest.spyOn(useApiClientModule, 'useApiClient').mockReturnValue({
56
+ chat: { get },
57
+ } as unknown as ReturnType<typeof useApiClientModule.useApiClient>)
58
+ }
59
+
60
+ describe('useConversationMessages', () => {
61
+ afterEach(() => {
62
+ jest.restoreAllMocks()
63
+ })
64
+
65
+ it('fires two parallel seed requests with id_gte/asc and id_lt/desc when anchored', async () => {
66
+ const get = jest.fn(({ data }: { data: { where?: Record<string, string> } }) => {
67
+ if (data.where?.id_gte === '01B') {
68
+ return Promise.resolve(apiResponse([mockMessage('01C'), mockMessage('01B')]))
69
+ }
70
+ if (data.where?.id_lt === '01B') {
71
+ return Promise.resolve(apiResponse([mockMessage('01A')]))
72
+ }
73
+ return Promise.resolve(apiResponse([]))
74
+ })
75
+ mockApiClient(get)
76
+
77
+ renderHook(() => useConversationMessages({ conversation_id: 123 }), {
78
+ wrapper: createWrapper('01B'),
79
+ })
80
+ await flushPromises()
81
+
82
+ expect(get).toHaveBeenCalledTimes(2)
83
+ const requested = get.mock.calls.map(
84
+ ([req]: [{ data: { where?: Record<string, string>; order?: string } }]) => ({
85
+ where: req.data.where,
86
+ order: req.data.order,
87
+ })
88
+ )
89
+ expect(requested).toEqual(
90
+ expect.arrayContaining([
91
+ { where: { id_gte: '01B' }, order: 'asc' },
92
+ { where: { id_lt: '01B' }, order: 'desc' },
93
+ ])
94
+ )
95
+ })
96
+
97
+ it('fires one fetch with no cursor when not anchored', async () => {
98
+ const get = jest.fn(() => Promise.resolve(apiResponse([mockMessage('01A')])))
99
+ mockApiClient(get)
100
+
101
+ renderHook(() => useConversationMessages({ conversation_id: 123 }), {
102
+ wrapper: createWrapper(null),
103
+ })
104
+ await flushPromises()
105
+
106
+ expect(get).toHaveBeenCalledTimes(1)
107
+ expect(get.mock.calls[0][0].data.where).toBeUndefined()
108
+ })
109
+ })
@@ -2,6 +2,10 @@ import { jestTransformPackages } from '../jest'
2
2
 
3
3
  describe('jestTransformPackages', () => {
4
4
  it('exports an array of package patterns', () => {
5
- expect(jestTransformPackages).toEqual(['@planningcenter/chat-react-native', '@fortawesome'])
5
+ expect(jestTransformPackages).toEqual([
6
+ '@planningcenter/chat-react-native',
7
+ '@fortawesome',
8
+ 'rn-emoji-keyboard',
9
+ ])
6
10
  })
7
11
  })
@@ -1,31 +1,57 @@
1
- import React, { createContext, PropsWithChildren, useContext, useMemo } from 'react'
1
+ import React, {
2
+ createContext,
3
+ PropsWithChildren,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ } from 'react'
2
9
 
3
10
  interface ConversationContextValue {
4
11
  conversationId: number
5
12
  currentPageReplyRootId: string | null
13
+ initialMessageId: string | null
14
+ setInitialMessageId: (id: string | null) => void
15
+ initialMessageIdIsAnchor: boolean
6
16
  }
7
17
 
8
18
  interface ConversationContextProviderProps extends PropsWithChildren {
9
19
  conversationId: number
10
20
  currentPageReplyRootId: string | null
21
+ initialMessageId?: string | null
22
+ initialMessageIdIsAnchor?: boolean
11
23
  }
12
24
 
13
25
  const ConversationContext = createContext<ConversationContextValue>({
14
26
  conversationId: 0,
15
27
  currentPageReplyRootId: null,
28
+ initialMessageId: null,
29
+ setInitialMessageId: () => {},
30
+ initialMessageIdIsAnchor: false,
16
31
  })
17
32
 
18
33
  export const ConversationContextProvider = ({
19
34
  children,
20
35
  conversationId,
21
36
  currentPageReplyRootId,
37
+ initialMessageId: initialMessageIdProp = null,
38
+ initialMessageIdIsAnchor = false,
22
39
  }: ConversationContextProviderProps) => {
40
+ const [initialMessageId, setInitialMessageId] = useState(initialMessageIdProp)
41
+
42
+ useEffect(() => {
43
+ setInitialMessageId(initialMessageIdProp)
44
+ }, [initialMessageIdProp])
45
+
23
46
  const value = useMemo(
24
47
  () => ({
25
48
  conversationId,
26
49
  currentPageReplyRootId,
50
+ initialMessageId,
51
+ setInitialMessageId,
52
+ initialMessageIdIsAnchor,
27
53
  }),
28
- [conversationId, currentPageReplyRootId]
54
+ [conversationId, currentPageReplyRootId, initialMessageId, initialMessageIdIsAnchor]
29
55
  )
30
56
 
31
57
  return <ConversationContext.Provider value={value}>{children}</ConversationContext.Provider>
@@ -1,28 +1,112 @@
1
+ import {
2
+ AnyUseSuspenseInfiniteQueryOptions,
3
+ InfiniteData,
4
+ useSuspenseInfiniteQuery,
5
+ useSuspenseQueries,
6
+ } from '@tanstack/react-query'
1
7
  import { useMemo } from 'react'
2
- import { MessageResource } from '../types'
8
+ import { useConversationContext } from '../contexts/conversation_context'
9
+ import { ApiCollection, MessageResource } from '../types'
10
+ import {
11
+ anchoredSeedPageParams,
12
+ MessagesPageParam,
13
+ newerPageParam,
14
+ olderPageParam,
15
+ sortAndFilterMessages,
16
+ } from '../utils/conversation_messages'
3
17
  import { getMessagesQueryKey, getMessagesRequestArgs } from '../utils/request/get_messages'
4
- import { SuspensePaginatorOptions, useSuspensePaginator } from './use_suspense_api'
18
+ import { useApiClient } from './use_api_client'
19
+ import { throwResponseError } from './use_suspense_api'
20
+
21
+ type Args = { conversation_id: number; reply_root_id?: string | null }
22
+
23
+ export type ConversationMessagesOptions = Omit<
24
+ AnyUseSuspenseInfiniteQueryOptions,
25
+ | 'getNextPageParam'
26
+ | 'getPreviousPageParam'
27
+ | 'initialData'
28
+ | 'initialPageParam'
29
+ | 'queryFn'
30
+ | 'queryKey'
31
+ >
5
32
 
6
33
  export const useConversationMessages = (
7
- { conversation_id, reply_root_id }: { conversation_id: number; reply_root_id?: string | null },
8
- opts?: SuspensePaginatorOptions
34
+ { conversation_id, reply_root_id }: Args,
35
+ opts?: ConversationMessagesOptions
9
36
  ) => {
10
- const { data, refetch, isRefetching, fetchNextPage } = useSuspensePaginator<MessageResource>(
11
- getMessagesRequestArgs({ conversation_id, reply_root_id }),
12
- opts
13
- )
37
+ const apiClient = useApiClient()
38
+ const { initialMessageId } = useConversationContext()
39
+ const anchored = !reply_root_id && !!initialMessageId
40
+
41
+ const requestArgs = getMessagesRequestArgs({ conversation_id, reply_root_id })
14
42
  const queryKey = getMessagesQueryKey({ conversation_id, reply_root_id })
15
- const messages = useMemo(
16
- () =>
17
- data
18
- .filter(
19
- message =>
20
- (!message.deletedAt || message.replyRootId) &&
21
- (message.attachments?.length || message.text?.length)
22
- )
23
- .sort((a, b) => -a.id.localeCompare(b.id)),
24
- [data]
25
- )
26
-
27
- return { messages, refetch, isRefetching, fetchNextPage, queryKey }
43
+
44
+ const fetchPage = (pageParam: MessagesPageParam) => {
45
+ const data = {
46
+ ...requestArgs.data,
47
+ ...(pageParam.where ? { where: pageParam.where } : {}),
48
+ ...(pageParam.order ? { order: pageParam.order } : {}),
49
+ }
50
+ return apiClient.chat
51
+ .get<ApiCollection<MessageResource>>({ url: requestArgs.url, data })
52
+ .catch(throwResponseError)
53
+ }
54
+
55
+ const seedPageParams = anchored ? anchoredSeedPageParams(initialMessageId) : []
56
+ const seedQueries = useSuspenseQueries({
57
+ queries: seedPageParams.map((pageParam, index) => ({
58
+ queryKey: [...queryKey, 'seed', index],
59
+ queryFn: () => fetchPage(pageParam),
60
+ staleTime: Infinity,
61
+ gcTime: 0,
62
+ })),
63
+ })
64
+
65
+ const initialData: InfiniteData<ApiCollection<MessageResource>, MessagesPageParam> | undefined =
66
+ anchored
67
+ ? {
68
+ pages: seedQueries.map(q => q.data),
69
+ pageParams: seedPageParams,
70
+ }
71
+ : undefined
72
+
73
+ const initialPageParam: MessagesPageParam = anchored ? seedPageParams[0] : {}
74
+
75
+ const {
76
+ data,
77
+ refetch,
78
+ isRefetching,
79
+ fetchNextPage,
80
+ hasNextPage,
81
+ fetchPreviousPage,
82
+ hasPreviousPage,
83
+ } = useSuspenseInfiniteQuery<
84
+ ApiCollection<MessageResource>,
85
+ Response,
86
+ InfiniteData<ApiCollection<MessageResource>, MessagesPageParam>,
87
+ typeof queryKey,
88
+ MessagesPageParam
89
+ >({
90
+ queryKey,
91
+ queryFn: ({ pageParam }) => fetchPage(pageParam),
92
+ initialPageParam,
93
+ initialData,
94
+ getNextPageParam: olderPageParam,
95
+ getPreviousPageParam: anchored ? newerPageParam : () => undefined,
96
+ ...(opts || {}),
97
+ ...(anchored ? { staleTime: Infinity, refetchOnMount: false } : {}),
98
+ })
99
+
100
+ const messages = useMemo(() => sortAndFilterMessages(data.pages), [data.pages])
101
+
102
+ return {
103
+ messages,
104
+ refetch,
105
+ isRefetching,
106
+ fetchOlderMessages: fetchNextPage,
107
+ hasMoreOlderMessages: hasNextPage,
108
+ fetchNewerMessages: fetchPreviousPage,
109
+ hasMoreNewerMessages: hasPreviousPage,
110
+ queryKey,
111
+ }
28
112
  }
@@ -38,6 +38,7 @@ export const availableFeatures = {
38
38
  message_reporting: 'ROLLOUT_MOBILE_message_reporting',
39
39
  granular_notifications_ui: 'ROLLOUT_granular_notification_preferences_ui',
40
40
  custom_conversation_avatars: 'ROLLOUT_custom_conversation_avatars',
41
+ jump_to_unread: 'ROLLOUT_jump_to_unread',
41
42
  }
42
43
 
43
44
  const stableEmptyFeatures: ApiCollection<FeatureResource> = {
@@ -90,7 +90,7 @@ export const useSuspensePaginator = <T extends ResourceObject>(
90
90
  return { ...query, data, totalCount }
91
91
  }
92
92
 
93
- const throwResponseError = (error: unknown) => {
93
+ export const throwResponseError = (error: unknown) => {
94
94
  if (error instanceof Response) {
95
95
  throw new ResponseError(error as FailedResponse)
96
96
  }
package/src/jest.ts CHANGED
@@ -14,4 +14,8 @@
14
14
  * transformIgnorePatterns: [`node_modules/(?!${ignorePatterns})`],
15
15
  * }
16
16
  */
17
- export const jestTransformPackages = ['@planningcenter/chat-react-native', '@fortawesome']
17
+ export const jestTransformPackages = [
18
+ '@planningcenter/chat-react-native',
19
+ '@fortawesome',
20
+ 'rn-emoji-keyboard',
21
+ ]
@@ -31,6 +31,7 @@ import { useConversation } from '../hooks/use_conversation'
31
31
  import { useConversationJoltEvents } from '../hooks/use_conversation_jolt_events'
32
32
  import { useConversationMessages } from '../hooks/use_conversation_messages'
33
33
  import { useConversationMessagesJoltEvents } from '../hooks/use_conversation_messages_jolt_events'
34
+ import { useFeatures } from '../hooks/use_features'
34
35
  import { useMarkLatestMessageRead } from '../hooks/use_mark_latest_message_read'
35
36
  import {
36
37
  normalizeAnalyticsMetadata,
@@ -50,6 +51,7 @@ export type ConversationRouteProps = {
50
51
  chat_group_graph_id?: string
51
52
  clear_input?: boolean
52
53
  editing_message_id?: number | null
54
+ message_id?: string
53
55
  title?: string
54
56
  subtitle?: string
55
57
  badge?: ConversationBadgeResource
@@ -60,19 +62,27 @@ export type ConversationRouteProps = {
60
62
  export type ConversationScreenProps = StaticScreenProps<ConversationRouteProps>
61
63
 
62
64
  export function ConversationScreen({ route }: ConversationScreenProps) {
63
- const { conversation_id, reply_root_id } = route.params
65
+ const { conversation_id, message_id, reply_root_id } = route.params
64
66
 
65
67
  const { data: conversation } = useConversation({ conversation_id })
68
+ const { featureEnabled } = useFeatures()
66
69
 
67
70
  usePublishProductAnalyticsEvent('chat.mobile.conversations.show.opened', {
68
71
  reply_root_id,
69
72
  ...normalizeAnalyticsMetadata(conversation),
70
73
  })
71
74
 
75
+ const lastReadMessageSortKey = conversation.conversationMembership?.lastReadMessageSortKey ?? null
76
+ const jumpToUnreadAnchor = featureEnabled('jump_to_unread') ? lastReadMessageSortKey : null
77
+ const initialMessageId = message_id ?? jumpToUnreadAnchor
78
+ const initialMessageIdIsAnchor = !!initialMessageId && !message_id
79
+
72
80
  return (
73
81
  <ConversationContextProvider
74
82
  conversationId={conversation_id}
75
83
  currentPageReplyRootId={reply_root_id ?? null}
84
+ initialMessageId={initialMessageId}
85
+ initialMessageIdIsAnchor={initialMessageIdIsAnchor}
76
86
  >
77
87
  <ConversationScreenContent route={route} />
78
88
  </ConversationContextProvider>
@@ -85,7 +95,7 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
85
95
  const { conversation_id, editing_message_id, reply_root_id, reply_root_author_name } =
86
96
  route.params
87
97
  const { data: conversation } = useConversation(route.params)
88
- const { messages, refetch, isRefetching, fetchNextPage } = useConversationMessages({
98
+ const { messages, refetch, isRefetching, fetchOlderMessages } = useConversationMessages({
89
99
  conversation_id,
90
100
  reply_root_id,
91
101
  })
@@ -204,7 +214,7 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
204
214
  />
205
215
  )
206
216
  }}
207
- onEndReached={() => fetchNextPage()}
217
+ onEndReached={() => fetchOlderMessages()}
208
218
  ListHeaderComponent={<View style={styles.listHeader} />}
209
219
  />
210
220
  )}
@@ -0,0 +1,105 @@
1
+ import { ApiCollection, MessageResource } from '../../types'
2
+ import {
3
+ anchoredSeedPageParams,
4
+ newerPageParam,
5
+ olderPageParam,
6
+ sortAndFilterMessages,
7
+ } from '../conversation_messages'
8
+
9
+ const message = (id: string, overrides: Partial<MessageResource> = {}): MessageResource =>
10
+ ({
11
+ id,
12
+ type: 'Message',
13
+ text: `msg ${id}`,
14
+ attachments: [],
15
+ deletedAt: null,
16
+ replyRootId: null,
17
+ ...overrides,
18
+ }) as MessageResource
19
+
20
+ const page = (
21
+ data: MessageResource[],
22
+ next?: { idLt?: string; idGt?: string }
23
+ ): ApiCollection<MessageResource> => ({
24
+ data,
25
+ links: {},
26
+ meta: { count: data.length, totalCount: data.length, next },
27
+ })
28
+
29
+ describe('anchoredSeedPageParams', () => {
30
+ it('returns one ascending after-page and one descending before-page', () => {
31
+ expect(anchoredSeedPageParams('01ABC')).toEqual([
32
+ { where: { id_gte: '01ABC' }, order: 'asc' },
33
+ { where: { id_lt: '01ABC' }, order: 'desc' },
34
+ ])
35
+ })
36
+ })
37
+
38
+ describe('olderPageParam', () => {
39
+ it('returns id_lt cursor when meta.next.idLt is present', () => {
40
+ expect(olderPageParam(page([], { idLt: '01XYZ' }))).toEqual({
41
+ where: { id_lt: '01XYZ' },
42
+ order: 'desc',
43
+ })
44
+ })
45
+
46
+ it('returns undefined when meta.next.idLt is missing', () => {
47
+ expect(olderPageParam(page([], {}))).toBeUndefined()
48
+ expect(olderPageParam(page([], { idGt: '01XYZ' }))).toBeUndefined()
49
+ })
50
+ })
51
+
52
+ describe('newerPageParam', () => {
53
+ it('returns id_gt cursor when meta.next.idGt is present', () => {
54
+ expect(newerPageParam(page([], { idGt: '01XYZ' }))).toEqual({
55
+ where: { id_gt: '01XYZ' },
56
+ order: 'asc',
57
+ })
58
+ })
59
+
60
+ it('returns undefined when meta.next.idGt is missing', () => {
61
+ expect(newerPageParam(page([], {}))).toBeUndefined()
62
+ expect(newerPageParam(page([], { idLt: '01XYZ' }))).toBeUndefined()
63
+ })
64
+ })
65
+
66
+ describe('sortAndFilterMessages', () => {
67
+ it('flattens pages and sorts descending by id (newest first)', () => {
68
+ const pages = [page([message('01B'), message('01A')]), page([message('01D'), message('01C')])]
69
+
70
+ expect(sortAndFilterMessages(pages).map(m => m.id)).toEqual(['01D', '01C', '01B', '01A'])
71
+ })
72
+
73
+ it('drops empty messages (no text, no attachments)', () => {
74
+ const pages = [page([message('01A'), message('01B', { text: '' })])]
75
+
76
+ expect(sortAndFilterMessages(pages).map(m => m.id)).toEqual(['01A'])
77
+ })
78
+
79
+ it('drops deleted messages outside reply threads', () => {
80
+ const pages = [page([message('01A'), message('01B', { deletedAt: '2026-01-01T00:00:00Z' })])]
81
+
82
+ expect(sortAndFilterMessages(pages).map(m => m.id)).toEqual(['01A'])
83
+ })
84
+
85
+ it('keeps deleted reply-thread messages so threads do not break', () => {
86
+ const pages = [
87
+ page([
88
+ message('01A'),
89
+ message('01B', { deletedAt: '2026-01-01T00:00:00Z', replyRootId: '01A' }),
90
+ ]),
91
+ ]
92
+
93
+ expect(sortAndFilterMessages(pages).map(m => m.id)).toEqual(['01B', '01A'])
94
+ })
95
+
96
+ it('keeps messages with attachments even when text is empty', () => {
97
+ const pages = [
98
+ page([
99
+ message('01A', { text: '', attachments: [{ id: '1' }] as MessageResource['attachments'] }),
100
+ ]),
101
+ ]
102
+
103
+ expect(sortAndFilterMessages(pages).map(m => m.id)).toEqual(['01A'])
104
+ })
105
+ })
@@ -0,0 +1,37 @@
1
+ import { ApiCollection, MessageResource } from '../types'
2
+ import { RequestData } from './client'
3
+
4
+ export type MessagesPageParam = Partial<RequestData> & {
5
+ order?: 'asc' | 'desc'
6
+ }
7
+
8
+ export const anchoredSeedPageParams = (anchor: string): MessagesPageParam[] => [
9
+ { where: { id_gte: anchor }, order: 'asc' },
10
+ { where: { id_lt: anchor }, order: 'desc' },
11
+ ]
12
+
13
+ export const olderPageParam = (
14
+ page: ApiCollection<MessageResource>
15
+ ): MessagesPageParam | undefined => {
16
+ const idLt = page.meta?.next?.idLt
17
+ if (!idLt) return undefined
18
+ return { where: { id_lt: idLt }, order: 'desc' }
19
+ }
20
+
21
+ export const newerPageParam = (
22
+ page: ApiCollection<MessageResource>
23
+ ): MessagesPageParam | undefined => {
24
+ const idGt = page.meta?.next?.idGt
25
+ if (!idGt) return undefined
26
+ return { where: { id_gt: idGt }, order: 'asc' }
27
+ }
28
+
29
+ export const sortAndFilterMessages = (pages: ApiCollection<MessageResource>[]): MessageResource[] =>
30
+ pages
31
+ .flatMap(page => page.data)
32
+ .filter(
33
+ message =>
34
+ (!message.deletedAt || message.replyRootId) &&
35
+ (message.attachments?.length || message.text?.length)
36
+ )
37
+ .sort((a, b) => -a.id.localeCompare(b.id))
@@ -1,55 +0,0 @@
1
- import { renderHook } from '@testing-library/react-hooks'
2
- import { useConversationMessages } from '../../hooks/use_conversation_messages'
3
- import * as useSuspenseApi from '../../hooks/use_suspense_api'
4
-
5
- const mockMessages = [
6
- {
7
- id: '1',
8
- text: 'Hello',
9
- deletedAt: null,
10
- attachments: [],
11
- },
12
- {
13
- id: '2',
14
- text: '',
15
- deletedAt: null,
16
- attachments: [{ id: 'a1' }],
17
- },
18
- {
19
- id: '3',
20
- text: '',
21
- deletedAt: '2024-01-01',
22
- attachments: [],
23
- },
24
- {
25
- id: '4',
26
- text: '',
27
- deletedAt: null,
28
- attachments: [],
29
- },
30
- ]
31
-
32
- describe('useConversationMessages', () => {
33
- beforeEach(() => {
34
- jest.spyOn(useSuspenseApi, 'useSuspensePaginator').mockReturnValue({
35
- data: mockMessages,
36
- refetch: jest.fn(),
37
- isRefetching: false,
38
- fetchNextPage: jest.fn(),
39
- } as any)
40
- })
41
-
42
- afterEach(() => {
43
- jest.restoreAllMocks()
44
- })
45
-
46
- it('filters out empty or deleted messages and sorts by id descending', () => {
47
- const { result } = renderHook(() => useConversationMessages({ conversation_id: 123 }))
48
- expect(result.current.messages).toEqual([
49
- mockMessages[1], // id: '2'
50
- mockMessages[0], // id: '1'
51
- ])
52
- // id: '3' is deleted and filtered out
53
- // id: '4' is filtered out because it has no text or attachments
54
- })
55
- })