@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.
- package/build/contexts/conversation_context.d.ts +6 -1
- package/build/contexts/conversation_context.d.ts.map +1 -1
- package/build/contexts/conversation_context.js +13 -3
- package/build/contexts/conversation_context.js.map +1 -1
- package/build/hooks/use_conversation_messages.d.ts +13 -6
- package/build/hooks/use_conversation_messages.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages.js +56 -7
- package/build/hooks/use_conversation_messages.js.map +1 -1
- package/build/hooks/use_features.d.ts +1 -0
- package/build/hooks/use_features.d.ts.map +1 -1
- package/build/hooks/use_features.js +1 -0
- package/build/hooks/use_features.js.map +1 -1
- package/build/hooks/use_suspense_api.d.ts +1 -0
- package/build/hooks/use_suspense_api.d.ts.map +1 -1
- package/build/hooks/use_suspense_api.js +1 -1
- package/build/hooks/use_suspense_api.js.map +1 -1
- package/build/jest.d.ts.map +1 -1
- package/build/jest.js +5 -1
- package/build/jest.js.map +1 -1
- package/build/screens/conversation_screen.d.ts +1 -0
- package/build/screens/conversation_screen.d.ts.map +1 -1
- package/build/screens/conversation_screen.js +10 -4
- package/build/screens/conversation_screen.js.map +1 -1
- package/build/utils/conversation_messages.d.ts +10 -0
- package/build/utils/conversation_messages.d.ts.map +1 -0
- package/build/utils/conversation_messages.js +22 -0
- package/build/utils/conversation_messages.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/hooks/use_conversation_messages.test.tsx +109 -0
- package/src/__tests__/jest.ts +5 -1
- package/src/contexts/conversation_context.tsx +28 -2
- package/src/hooks/use_conversation_messages.ts +105 -21
- package/src/hooks/use_features.ts +1 -0
- package/src/hooks/use_suspense_api.ts +1 -1
- package/src/jest.ts +5 -1
- package/src/screens/conversation_screen.tsx +13 -3
- package/src/utils/__tests__/conversation_messages.test.ts +105 -0
- package/src/utils/conversation_messages.ts +37 -0
- 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.
|
|
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": "
|
|
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
|
+
})
|
package/src/__tests__/jest.ts
CHANGED
|
@@ -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([
|
|
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, {
|
|
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 {
|
|
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 {
|
|
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 }:
|
|
8
|
-
opts?:
|
|
34
|
+
{ conversation_id, reply_root_id }: Args,
|
|
35
|
+
opts?: ConversationMessagesOptions
|
|
9
36
|
) => {
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 = [
|
|
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,
|
|
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={() =>
|
|
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
|
-
})
|