@planningcenter/chat-react-native 4.2.0 → 4.2.1-qa-staging.1
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/components/conversation/anchor_not_found_boundary.d.ts +18 -0
- package/build/components/conversation/anchor_not_found_boundary.d.ts.map +1 -0
- package/build/components/conversation/anchor_not_found_boundary.js +20 -0
- package/build/components/conversation/anchor_not_found_boundary.js.map +1 -0
- package/build/components/conversation/message_form.d.ts +2 -1
- package/build/components/conversation/message_form.d.ts.map +1 -1
- package/build/components/conversation/message_form.js +54 -51
- package/build/components/conversation/message_form.js.map +1 -1
- package/build/components/conversation/message_list.d.ts +3 -2
- package/build/components/conversation/message_list.d.ts.map +1 -1
- package/build/components/conversation/message_list.js +152 -66
- package/build/components/conversation/message_list.js.map +1 -1
- package/build/hooks/index.d.ts +1 -0
- package/build/hooks/index.d.ts.map +1 -1
- package/build/hooks/index.js +1 -0
- package/build/hooks/index.js.map +1 -1
- package/build/hooks/paginator_meta.d.ts +3 -1
- package/build/hooks/paginator_meta.d.ts.map +1 -1
- package/build/hooks/paginator_meta.js +25 -8
- package/build/hooks/paginator_meta.js.map +1 -1
- package/build/hooks/use_api.d.ts.map +1 -1
- package/build/hooks/use_api.js +5 -8
- package/build/hooks/use_api.js.map +1 -1
- package/build/hooks/use_conversation_messages.d.ts +8 -2
- package/build/hooks/use_conversation_messages.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages.js +77 -45
- package/build/hooks/use_conversation_messages.js.map +1 -1
- package/build/hooks/use_conversation_messages_jolt_events.d.ts +9 -1
- package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages_jolt_events.js +88 -73
- package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
- package/build/hooks/use_direct_message_eligibility.d.ts +14 -0
- package/build/hooks/use_direct_message_eligibility.d.ts.map +1 -0
- package/build/hooks/use_direct_message_eligibility.js +87 -0
- package/build/hooks/use_direct_message_eligibility.js.map +1 -0
- package/build/hooks/use_fetch_newer_messages.d.ts +8 -0
- package/build/hooks/use_fetch_newer_messages.d.ts.map +1 -0
- package/build/hooks/use_fetch_newer_messages.js +56 -0
- package/build/hooks/use_fetch_newer_messages.js.map +1 -0
- package/build/hooks/use_fetch_newer_on_foreground.d.ts +8 -0
- package/build/hooks/use_fetch_newer_on_foreground.d.ts.map +1 -0
- package/build/hooks/use_fetch_newer_on_foreground.js +41 -0
- package/build/hooks/use_fetch_newer_on_foreground.js.map +1 -0
- package/build/hooks/use_mark_latest_message_read.d.ts +2 -1
- package/build/hooks/use_mark_latest_message_read.d.ts.map +1 -1
- package/build/hooks/use_mark_latest_message_read.js +4 -3
- package/build/hooks/use_mark_latest_message_read.js.map +1 -1
- package/build/hooks/use_suspense_api.d.ts.map +1 -1
- package/build/hooks/use_suspense_api.js +5 -8
- package/build/hooks/use_suspense_api.js.map +1 -1
- package/build/screens/conversation_new/components/services_form.js +9 -8
- package/build/screens/conversation_new/components/services_form.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 +241 -355
- package/build/screens/conversation_screen.js.map +1 -1
- package/build/types/api_primitives.d.ts +2 -0
- package/build/types/api_primitives.d.ts.map +1 -1
- package/build/types/api_primitives.js.map +1 -1
- package/build/types/resources/member.d.ts +1 -0
- package/build/types/resources/member.d.ts.map +1 -1
- package/build/types/resources/member.js.map +1 -1
- package/build/utils/cache/prepend_newer_page.d.ts +7 -0
- package/build/utils/cache/prepend_newer_page.d.ts.map +1 -0
- package/build/utils/cache/prepend_newer_page.js +14 -0
- package/build/utils/cache/prepend_newer_page.js.map +1 -0
- package/build/utils/client/types.d.ts +1 -0
- package/build/utils/client/types.d.ts.map +1 -1
- package/build/utils/client/types.js.map +1 -1
- package/build/utils/gated_message_cache.d.ts +7 -0
- package/build/utils/gated_message_cache.d.ts.map +1 -0
- package/build/utils/gated_message_cache.js +13 -0
- package/build/utils/gated_message_cache.js.map +1 -0
- package/build/utils/generate_placeholder_ulid.d.ts +0 -6
- package/build/utils/generate_placeholder_ulid.d.ts.map +1 -1
- package/build/utils/generate_placeholder_ulid.js +2 -2
- package/build/utils/generate_placeholder_ulid.js.map +1 -1
- package/build/utils/group_messages.js +4 -1
- package/build/utils/group_messages.js.map +1 -1
- package/build/utils/request/get_messages.d.ts +18 -6
- package/build/utils/request/get_messages.d.ts.map +1 -1
- package/build/utils/request/get_messages.js +10 -12
- package/build/utils/request/get_messages.js.map +1 -1
- package/build/utils/ulid.d.ts +3 -0
- package/build/utils/ulid.d.ts.map +1 -0
- package/build/utils/ulid.js +7 -0
- package/build/utils/ulid.js.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/hooks/paginator_meta.test.ts +73 -2
- package/src/__tests__/hooks/use_conversation_messages_jolt_events.test.ts +36 -0
- package/src/__tests__/hooks/use_direct_message_eligibility.test.tsx +83 -0
- package/src/__tests__/screens/conversation_new/components/services_form.test.tsx +91 -0
- package/src/__tests__/utils/gated_message_cache.test.ts +26 -0
- package/src/__tests__/utils/request/get_messages.test.ts +24 -0
- package/src/__tests__/utils/ulid.test.ts +15 -0
- package/src/components/conversation/__tests__/anchor_not_found_boundary.test.tsx +51 -0
- package/src/components/conversation/__tests__/message_list.test.tsx +42 -0
- package/src/components/conversation/anchor_not_found_boundary.tsx +21 -0
- package/src/components/conversation/message_form.tsx +4 -0
- package/src/components/conversation/message_list.tsx +58 -4
- package/src/hooks/index.ts +1 -0
- package/src/hooks/paginator_meta.ts +29 -5
- package/src/hooks/use_api.ts +4 -5
- package/src/hooks/use_conversation_messages.ts +43 -11
- package/src/hooks/use_conversation_messages_jolt_events.ts +57 -40
- package/src/hooks/use_direct_message_eligibility.ts +40 -0
- package/src/hooks/use_fetch_newer_messages.ts +37 -0
- package/src/hooks/use_fetch_newer_on_foreground.ts +25 -0
- package/src/hooks/use_mark_latest_message_read.ts +4 -3
- package/src/hooks/use_suspense_api.ts +4 -5
- package/src/screens/conversation_new/components/services_form.tsx +5 -5
- package/src/screens/conversation_screen.tsx +120 -17
- package/src/types/api_primitives.ts +2 -0
- package/src/types/resources/member.ts +1 -0
- package/src/utils/__tests__/group_messages.test.ts +29 -0
- package/src/utils/cache/__tests__/prepend_newer_page.test.ts +35 -0
- package/src/utils/cache/prepend_newer_page.ts +22 -0
- package/src/utils/client/types.ts +1 -0
- package/src/utils/gated_message_cache.ts +19 -0
- package/src/utils/generate_placeholder_ulid.ts +2 -2
- package/src/utils/group_messages.ts +4 -1
- package/src/utils/request/get_messages.ts +12 -14
- package/src/utils/ulid.ts +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@planningcenter/chat-react-native",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.1-qa-staging.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
|
35
35
|
"@fortawesome/react-native-fontawesome": "^0.3.2",
|
|
36
|
-
"@planningcenter/emoji-keyboard": "4.
|
|
36
|
+
"@planningcenter/emoji-keyboard": "4.2.1-qa-staging.1",
|
|
37
37
|
"lodash-inflection": "^1.5.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
@@ -84,5 +84,5 @@
|
|
|
84
84
|
"sf-symbols-typescript": "^2.2.0",
|
|
85
85
|
"typescript": "~6.0.3"
|
|
86
86
|
},
|
|
87
|
-
"gitHead": "
|
|
87
|
+
"gitHead": "09c36025b92c9616f97afc67a83b723e0662d3e8"
|
|
88
88
|
}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
getNewerPageParam,
|
|
3
|
+
getNextPageParamFromMeta,
|
|
4
|
+
mergePageWhere,
|
|
5
|
+
} from '../../hooks/paginator_meta'
|
|
6
|
+
import { NextPageCursor } from '../../types/api_primitives'
|
|
7
|
+
import { RequestData } from '../../utils/client'
|
|
2
8
|
|
|
3
9
|
describe('getNextPageParamFromMeta', () => {
|
|
4
|
-
it.each([
|
|
10
|
+
it.each<[NextPageCursor | undefined, Partial<RequestData> | undefined]>([
|
|
5
11
|
[undefined, undefined],
|
|
6
12
|
[{}, undefined],
|
|
7
13
|
[{ idLt: '01ABC' }, { where: { id_lt: '01ABC' } }],
|
|
@@ -9,7 +15,72 @@ describe('getNextPageParamFromMeta', () => {
|
|
|
9
15
|
[{ idGt: '01ABC' }, { where: { id_gt: '01ABC' } }],
|
|
10
16
|
[{ idGte: '01ABC' }, { where: { id_gte: '01ABC' } }],
|
|
11
17
|
[{ offset: '40' }, { offset: 40 }],
|
|
18
|
+
[
|
|
19
|
+
{ idLt: '01ABC', order: 'desc' },
|
|
20
|
+
{ where: { id_lt: '01ABC' }, order: 'desc' },
|
|
21
|
+
],
|
|
22
|
+
[
|
|
23
|
+
{ idGt: '01ABC', order: 'asc' },
|
|
24
|
+
{ where: { id_gt: '01ABC' }, order: 'asc' },
|
|
25
|
+
],
|
|
12
26
|
])('maps %p to %p', (input, expected) => {
|
|
13
27
|
expect(getNextPageParamFromMeta(input)).toEqual(expected)
|
|
14
28
|
})
|
|
15
29
|
})
|
|
30
|
+
|
|
31
|
+
describe('getNewerPageParam', () => {
|
|
32
|
+
const page = (meta: { next?: NextPageCursor; prev?: NextPageCursor }) => ({
|
|
33
|
+
data: [],
|
|
34
|
+
links: {},
|
|
35
|
+
meta: { count: 0, totalCount: 0, ...meta },
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('follows meta.prev on the anchored page', () => {
|
|
39
|
+
const anchored = page({
|
|
40
|
+
next: { idLt: '01A', order: 'desc' },
|
|
41
|
+
prev: { idGt: '01E', order: 'asc' },
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
expect(getNewerPageParam(anchored, {})).toEqual({ where: { id_gt: '01E' }, order: 'asc' })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('stops when the anchored page has no newer side', () => {
|
|
48
|
+
expect(getNewerPageParam(page({ next: { idLt: '01A', order: 'desc' } }), {})).toBeUndefined()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('follows meta.next on an ascending page', () => {
|
|
52
|
+
const newer = page({ next: { idGt: '01H', order: 'asc' } })
|
|
53
|
+
|
|
54
|
+
expect(getNewerPageParam(newer, { order: 'asc' })).toEqual({
|
|
55
|
+
where: { id_gt: '01H' },
|
|
56
|
+
order: 'asc',
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('stops once an ascending page has no meta.next', () => {
|
|
61
|
+
expect(getNewerPageParam(page({}), { order: 'asc' })).toBeUndefined()
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('mergePageWhere', () => {
|
|
66
|
+
it('lets a page cursor replace the request bound instead of stacking bounds', () => {
|
|
67
|
+
expect(mergePageWhere({ id_around: '01ANCHOR' }, { id_gt: '01NEWER' })).toEqual({
|
|
68
|
+
id_gt: '01NEWER',
|
|
69
|
+
})
|
|
70
|
+
expect(mergePageWhere({ id_around: '01ANCHOR' }, { id_lt: '01OLDER' })).toEqual({
|
|
71
|
+
id_lt: '01OLDER',
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('keeps non-cursor filters alongside the cursor', () => {
|
|
76
|
+
expect(mergePageWhere({ source_type: 'Group' }, { id_lt: '01OLDER' })).toEqual({
|
|
77
|
+
source_type: 'Group',
|
|
78
|
+
id_lt: '01OLDER',
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('leaves the request where alone when the page has no cursor', () => {
|
|
83
|
+
expect(mergePageWhere({ id_around: '01ANCHOR' }, {})).toEqual({ id_around: '01ANCHOR' })
|
|
84
|
+
expect(mergePageWhere(undefined, undefined)).toEqual({})
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {
|
|
2
|
+
queryKeysForEvent,
|
|
3
|
+
resolveMessageCaches,
|
|
4
|
+
} from '../../hooks/use_conversation_messages_jolt_events'
|
|
5
|
+
|
|
6
|
+
const main = ['main']
|
|
7
|
+
const threaded = ['threaded']
|
|
8
|
+
|
|
9
|
+
describe('resolveMessageCaches', () => {
|
|
10
|
+
it('lets an override replace the default entry for the same key', () => {
|
|
11
|
+
const gate = { queryKey: ['main'], canUpdate: () => false }
|
|
12
|
+
|
|
13
|
+
expect(resolveMessageCaches([main, threaded], [gate])).toEqual([gate, { queryKey: threaded }])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('appends caches that are not among the defaults', () => {
|
|
17
|
+
const extra = { queryKey: ['anchored'] }
|
|
18
|
+
|
|
19
|
+
expect(resolveMessageCaches([main], [extra])).toEqual([{ queryKey: main }, extra])
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('queryKeysForEvent', () => {
|
|
24
|
+
const noInserts = { queryKey: ['anchored'], canUpdate: (e: string) => e !== 'message.created' }
|
|
25
|
+
|
|
26
|
+
it('returns every cache that accepts the event', () => {
|
|
27
|
+
expect(queryKeysForEvent('message.updated', [main], [noInserts])).toEqual([main, ['anchored']])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('skips caches whose predicate rejects the event, including a gated default', () => {
|
|
31
|
+
const gatedMain = { queryKey: ['main'], canUpdate: (e: string) => e !== 'message.created' }
|
|
32
|
+
|
|
33
|
+
expect(queryKeysForEvent('message.created', [main], [noInserts])).toEqual([main])
|
|
34
|
+
expect(queryKeysForEvent('message.created', [main, threaded], [gatedMain])).toEqual([threaded])
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
+
import { renderHook, waitFor } from '@testing-library/react-native'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { buildTestQueryClient } from '../../__utils__/query_client'
|
|
5
|
+
import { useApiClient } from '../../hooks/use_api_client'
|
|
6
|
+
import { useDirectMessageEligibility } from '../../hooks/use_direct_message_eligibility'
|
|
7
|
+
|
|
8
|
+
jest.mock('../../hooks/use_api_client')
|
|
9
|
+
|
|
10
|
+
const mockedUseApiClient = useApiClient as jest.MockedFunction<typeof useApiClient>
|
|
11
|
+
const mockGet = jest.fn()
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
mockGet.mockReset()
|
|
15
|
+
mockedUseApiClient.mockReturnValue({ chat: { get: mockGet } } as any)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const createWrapper = () => {
|
|
19
|
+
const queryClient = buildTestQueryClient()
|
|
20
|
+
return ({ children }: { children: React.ReactNode }) => (
|
|
21
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const args = { groupId: 'Groups-Group-42', personId: 75179873 }
|
|
26
|
+
|
|
27
|
+
test("reads the member's eligibility from the chat group", async () => {
|
|
28
|
+
mockGet.mockResolvedValue({
|
|
29
|
+
data: {
|
|
30
|
+
id: '75179873',
|
|
31
|
+
type: 'Member',
|
|
32
|
+
canDirectMessage: false,
|
|
33
|
+
directMessagesDisabled: true,
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const { result } = renderHook(() => useDirectMessageEligibility(args), {
|
|
38
|
+
wrapper: createWrapper(),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
await waitFor(() => expect(result.current.eligibility).toBeDefined())
|
|
42
|
+
|
|
43
|
+
expect(mockGet).toHaveBeenCalledWith({
|
|
44
|
+
url: '/me/groups/Groups-Group-42/members/75179873',
|
|
45
|
+
data: { fields: { Member: ['can_direct_message', 'direct_messages_disabled'] } },
|
|
46
|
+
})
|
|
47
|
+
expect(result.current.eligibility).toEqual({
|
|
48
|
+
canDirectMessage: false,
|
|
49
|
+
directMessagesDisabled: true,
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('has no answer until the member loads', () => {
|
|
54
|
+
mockGet.mockReturnValue(new Promise(() => {}))
|
|
55
|
+
|
|
56
|
+
const { result } = renderHook(() => useDirectMessageEligibility(args), {
|
|
57
|
+
wrapper: createWrapper(),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
expect(result.current.eligibility).toBeUndefined()
|
|
61
|
+
expect(result.current.isLoading).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('reports a failed lookup without an answer', async () => {
|
|
65
|
+
mockGet.mockRejectedValue(new Error('404'))
|
|
66
|
+
|
|
67
|
+
const { result } = renderHook(() => useDirectMessageEligibility(args), {
|
|
68
|
+
wrapper: createWrapper(),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
await waitFor(() => expect(result.current.isError).toBe(true))
|
|
72
|
+
|
|
73
|
+
expect(result.current.eligibility).toBeUndefined()
|
|
74
|
+
expect(result.current.isLoading).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('does not ask when disabled', () => {
|
|
78
|
+
renderHook(() => useDirectMessageEligibility({ ...args, enabled: false }), {
|
|
79
|
+
wrapper: createWrapper(),
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
expect(mockGet).not.toHaveBeenCalled()
|
|
83
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { DefaultTheme, ThemeProvider } from '@react-navigation/native'
|
|
2
|
+
import { render } from '@testing-library/react-native'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { SafeAreaProvider } from 'react-native-safe-area-context'
|
|
5
|
+
import { useFindOrCreateServicesConversation } from '../../../../hooks/services/use_find_or_create_services_conversation'
|
|
6
|
+
import { useTeamMembersForNewConversation } from '../../../../hooks/services/use_team_members_for_new_conversation'
|
|
7
|
+
import { ServicesForm } from '../../../../screens/conversation_new/components/services_form'
|
|
8
|
+
|
|
9
|
+
jest.mock('react-native-reanimated', () => ({
|
|
10
|
+
...jest.requireActual('react-native-reanimated/mock'),
|
|
11
|
+
LayoutAnimationConfig: ({ children }: { children: React.ReactNode }) => children,
|
|
12
|
+
}))
|
|
13
|
+
jest.mock('@react-navigation/native', () => ({
|
|
14
|
+
...jest.requireActual('@react-navigation/native'),
|
|
15
|
+
useNavigation: () => ({ navigate: jest.fn(), dispatch: jest.fn(), getParent: () => undefined }),
|
|
16
|
+
}))
|
|
17
|
+
jest.mock('../../../../hooks/use_api_client', () => ({ useApiClient: () => ({}) }))
|
|
18
|
+
jest.mock('../../../../hooks/use_upload_client', () => ({ useUploadClient: () => ({}) }))
|
|
19
|
+
jest.mock('../../../../hooks/use_features', () => ({
|
|
20
|
+
...jest.requireActual('../../../../hooks/use_features'),
|
|
21
|
+
useFeatures: () => ({ features: [], featureEnabled: () => false }),
|
|
22
|
+
}))
|
|
23
|
+
jest.mock('../../../../hooks/use_conversation_validate', () => ({
|
|
24
|
+
useConversationValidate: () => ({ warnings: [], validationPending: false }),
|
|
25
|
+
}))
|
|
26
|
+
jest.mock('../../../../hooks/services/use_services_chat_conversation_payload', () => ({
|
|
27
|
+
useServicesChatConversationPayload: () => ({ payload: undefined, isLoading: false }),
|
|
28
|
+
}))
|
|
29
|
+
jest.mock('../../../../hooks/services/use_services_team', () => ({
|
|
30
|
+
useServicesTeams: () => [],
|
|
31
|
+
}))
|
|
32
|
+
jest.mock('../../../../hooks/services/use_team_plans', () => ({
|
|
33
|
+
useTeamPlans: () => ({
|
|
34
|
+
planOptions: [],
|
|
35
|
+
isFetching: false,
|
|
36
|
+
isError: false,
|
|
37
|
+
findPlan: () => undefined,
|
|
38
|
+
}),
|
|
39
|
+
}))
|
|
40
|
+
jest.mock('../../../../hooks/services/use_team_members_for_new_conversation', () => ({
|
|
41
|
+
useTeamMembersForNewConversation: jest.fn(() => ({ members: [], isError: false })),
|
|
42
|
+
}))
|
|
43
|
+
jest.mock('../../../../hooks/services/use_find_or_create_services_conversation', () => ({
|
|
44
|
+
useFindOrCreateServicesConversation: jest.fn(() => ({
|
|
45
|
+
mutate: jest.fn(),
|
|
46
|
+
isPending: false,
|
|
47
|
+
selectionHasConversation: false,
|
|
48
|
+
isLoadingConversationCheck: false,
|
|
49
|
+
})),
|
|
50
|
+
}))
|
|
51
|
+
|
|
52
|
+
const mockedFindOrCreate = jest.mocked(useFindOrCreateServicesConversation)
|
|
53
|
+
const mockedTeamMembers = jest.mocked(useTeamMembersForNewConversation)
|
|
54
|
+
|
|
55
|
+
const latestArgs = <T extends jest.Mock>(mock: T) => mock.mock.calls.at(-1)?.[0]
|
|
56
|
+
|
|
57
|
+
const safeAreaMetrics = {
|
|
58
|
+
frame: { x: 0, y: 0, width: 390, height: 844 },
|
|
59
|
+
insets: { top: 0, right: 0, bottom: 0, left: 0 },
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const renderForm = (props: { initialPlanId?: number } = {}) =>
|
|
63
|
+
render(
|
|
64
|
+
<SafeAreaProvider initialMetrics={safeAreaMetrics}>
|
|
65
|
+
<ThemeProvider value={DefaultTheme}>
|
|
66
|
+
<ServicesForm initialTeamIds={[1, 2]} {...props} />
|
|
67
|
+
</ThemeProvider>
|
|
68
|
+
</SafeAreaProvider>
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
describe('ServicesForm', () => {
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
jest.clearAllMocks()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('filters by plan when opened from a plan', () => {
|
|
77
|
+
const { getByRole } = renderForm({ initialPlanId: 42 })
|
|
78
|
+
|
|
79
|
+
expect(getByRole('switch').props.value).toBe(true)
|
|
80
|
+
expect(latestArgs(mockedFindOrCreate)).toMatchObject({ teamIds: [1, 2], planId: 42 })
|
|
81
|
+
expect(latestArgs(mockedTeamMembers)).toMatchObject({ teamIds: [1, 2], planId: 42 })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('does not filter by plan when opened without a plan', () => {
|
|
85
|
+
const { getByRole } = renderForm()
|
|
86
|
+
|
|
87
|
+
expect(getByRole('switch').props.value).toBe(false)
|
|
88
|
+
expect(latestArgs(mockedFindOrCreate)).toMatchObject({ teamIds: [1, 2], planId: undefined })
|
|
89
|
+
expect(latestArgs(mockedTeamMembers)).toMatchObject({ teamIds: [1, 2], planId: undefined })
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { gatedMessageCache } from '../../utils/gated_message_cache'
|
|
2
|
+
|
|
3
|
+
describe('gatedMessageCache', () => {
|
|
4
|
+
const queryKey = ['messages']
|
|
5
|
+
|
|
6
|
+
it('keeps new messages out while they are not accepted and reports the skip', () => {
|
|
7
|
+
const onSkippedNewMessage = jest.fn()
|
|
8
|
+
const cache = gatedMessageCache({ queryKey, acceptNewMessages: false, onSkippedNewMessage })
|
|
9
|
+
|
|
10
|
+
expect(cache.queryKey).toBe(queryKey)
|
|
11
|
+
expect(cache.canUpdate?.('message.created')).toBe(false)
|
|
12
|
+
expect(onSkippedNewMessage).toHaveBeenCalledTimes(1)
|
|
13
|
+
expect(cache.canUpdate?.('message.updated')).toBe(true)
|
|
14
|
+
expect(cache.canUpdate?.('message.destroyed')).toBe(true)
|
|
15
|
+
expect(cache.canUpdate?.('reaction')).toBe(true)
|
|
16
|
+
expect(onSkippedNewMessage).toHaveBeenCalledTimes(1)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('lets new messages in once they are accepted', () => {
|
|
20
|
+
const onSkippedNewMessage = jest.fn()
|
|
21
|
+
const cache = gatedMessageCache({ queryKey, acceptNewMessages: true, onSkippedNewMessage })
|
|
22
|
+
|
|
23
|
+
expect(cache.canUpdate?.('message.created')).toBe(true)
|
|
24
|
+
expect(onSkippedNewMessage).not.toHaveBeenCalled()
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getMessagesQueryKey, getMessagesRequestArgs } from '../../../utils/request/get_messages'
|
|
2
|
+
|
|
3
|
+
describe('getMessagesRequestArgs', () => {
|
|
4
|
+
it('requests the newest page by default', () => {
|
|
5
|
+
const { data } = getMessagesRequestArgs({ conversation_id: 1 })
|
|
6
|
+
|
|
7
|
+
expect(data).not.toHaveProperty('where')
|
|
8
|
+
expect(data).not.toHaveProperty('order')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('opens around the anchor when one is given', () => {
|
|
12
|
+
const { data } = getMessagesRequestArgs({ conversation_id: 1, anchor: '01ANCHOR' })
|
|
13
|
+
|
|
14
|
+
expect(data).toMatchObject({ where: { id_around: '01ANCHOR' } })
|
|
15
|
+
expect(data).not.toHaveProperty('order')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('keeps anchored and live queries in separate cache entries', () => {
|
|
19
|
+
const live = getMessagesQueryKey({ conversation_id: 1 })
|
|
20
|
+
const anchored = getMessagesQueryKey({ conversation_id: 1, anchor: '01ANCHOR' })
|
|
21
|
+
|
|
22
|
+
expect(anchored).not.toEqual(live)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { generatePlaceholderUlid } from '../../utils/generate_placeholder_ulid'
|
|
2
|
+
import { decodeUlidTime } from '../../utils/ulid'
|
|
3
|
+
|
|
4
|
+
describe('decodeUlidTime', () => {
|
|
5
|
+
it('decodes the spec example', () => {
|
|
6
|
+
expect(decodeUlidTime('01ARYZ6S41TSV4RRFFQ69G5FAV')).toBe(1469918176385)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('round-trips a placeholder ulid', () => {
|
|
10
|
+
const now = Date.now()
|
|
11
|
+
jest.spyOn(Date, 'now').mockReturnValue(now)
|
|
12
|
+
|
|
13
|
+
expect(decodeUlidTime(generatePlaceholderUlid())).toBe(now)
|
|
14
|
+
})
|
|
15
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react-native'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { Text } from 'react-native'
|
|
4
|
+
import { FailedResponse } from '../../../types/api_primitives'
|
|
5
|
+
import { ResponseError } from '../../../utils/response_error'
|
|
6
|
+
import { AnchorNotFoundBoundary } from '../anchor_not_found_boundary'
|
|
7
|
+
|
|
8
|
+
const responseError = (status: number) =>
|
|
9
|
+
new ResponseError({ status, statusText: 'nope' } as unknown as FailedResponse)
|
|
10
|
+
|
|
11
|
+
function Thrower({ error }: { error: Error | null }) {
|
|
12
|
+
if (error) throw error
|
|
13
|
+
return <Text>content</Text>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('AnchorNotFoundBoundary', () => {
|
|
17
|
+
beforeEach(() => jest.spyOn(console, 'error').mockImplementation(() => {}))
|
|
18
|
+
|
|
19
|
+
it('reports a missing anchor and renders nothing while the parent drops it', () => {
|
|
20
|
+
const onAnchorNotFound = jest.fn()
|
|
21
|
+
|
|
22
|
+
render(
|
|
23
|
+
<AnchorNotFoundBoundary onAnchorNotFound={onAnchorNotFound}>
|
|
24
|
+
<Thrower error={responseError(404)} />
|
|
25
|
+
</AnchorNotFoundBoundary>
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
expect(onAnchorNotFound).toHaveBeenCalledTimes(1)
|
|
29
|
+
expect(screen.queryByText('content')).toBeNull()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('lets every other error through untouched', () => {
|
|
33
|
+
const onAnchorNotFound = jest.fn()
|
|
34
|
+
|
|
35
|
+
expect(() =>
|
|
36
|
+
render(
|
|
37
|
+
<AnchorNotFoundBoundary onAnchorNotFound={onAnchorNotFound}>
|
|
38
|
+
<Thrower error={responseError(403)} />
|
|
39
|
+
</AnchorNotFoundBoundary>
|
|
40
|
+
)
|
|
41
|
+
).toThrow()
|
|
42
|
+
expect(() =>
|
|
43
|
+
render(
|
|
44
|
+
<AnchorNotFoundBoundary onAnchorNotFound={onAnchorNotFound}>
|
|
45
|
+
<Thrower error={new Error('render blew up')} />
|
|
46
|
+
</AnchorNotFoundBoundary>
|
|
47
|
+
)
|
|
48
|
+
).toThrow('render blew up')
|
|
49
|
+
expect(onAnchorNotFound).not.toHaveBeenCalled()
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -104,6 +104,7 @@ let queryClient: QueryClient
|
|
|
104
104
|
|
|
105
105
|
// A top-level rerender would rebuild the wrapper context and redraw every row.
|
|
106
106
|
let setHarnessConversation: (next: ConversationResource) => void = () => {}
|
|
107
|
+
let harnessAnchorMessageId: string | null = null
|
|
107
108
|
|
|
108
109
|
function Harness() {
|
|
109
110
|
const [current, setCurrent] = React.useState(conversation)
|
|
@@ -117,6 +118,7 @@ function Harness() {
|
|
|
117
118
|
conversation={current}
|
|
118
119
|
conversationId={CONVERSATION_ID}
|
|
119
120
|
messages={loaded}
|
|
121
|
+
anchorMessageId={harnessAnchorMessageId}
|
|
120
122
|
hasNextPage
|
|
121
123
|
onEndReached={() => {}}
|
|
122
124
|
/>
|
|
@@ -176,9 +178,49 @@ beforeEach(() => {
|
|
|
176
178
|
afterEach(() => {
|
|
177
179
|
queryClient.clear()
|
|
178
180
|
jest.clearAllMocks()
|
|
181
|
+
harnessAnchorMessageId = null
|
|
179
182
|
})
|
|
180
183
|
|
|
181
184
|
describe('MessageList', () => {
|
|
185
|
+
it('pins the anchor row to the bottom edge as content lays out, until the user drags', () => {
|
|
186
|
+
const scrollToIndex = jest.spyOn(FlatList.prototype, 'scrollToIndex').mockImplementation()
|
|
187
|
+
harnessAnchorMessageId = messages[3].id
|
|
188
|
+
renderList()
|
|
189
|
+
const list = screen.UNSAFE_getByType(FlatList)
|
|
190
|
+
const anchorIndex = list.props.data.findIndex(
|
|
191
|
+
(item: { id: string }) => item.id === messages[3].id
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
act(() => list.props.onLayout({ nativeEvent: { layout: { height: 600 } } }))
|
|
195
|
+
act(() => list.props.onContentSizeChange(0, 400))
|
|
196
|
+
expect(scrollToIndex).not.toHaveBeenCalled()
|
|
197
|
+
|
|
198
|
+
act(() => list.props.onContentSizeChange(0, 1000))
|
|
199
|
+
act(() => list.props.onContentSizeChange(0, 2000))
|
|
200
|
+
expect(anchorIndex).toBeGreaterThan(0)
|
|
201
|
+
expect(scrollToIndex).toHaveBeenCalledTimes(2)
|
|
202
|
+
expect(scrollToIndex).toHaveBeenLastCalledWith({
|
|
203
|
+
index: anchorIndex,
|
|
204
|
+
viewPosition: 0,
|
|
205
|
+
animated: false,
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
act(() => list.props.onScrollBeginDrag({ nativeEvent: {} }))
|
|
209
|
+
act(() => list.props.onContentSizeChange(0, 3000))
|
|
210
|
+
expect(scrollToIndex).toHaveBeenCalledTimes(2)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('leaves the offset alone when there is no anchor', () => {
|
|
214
|
+
const scrollToIndex = jest.spyOn(FlatList.prototype, 'scrollToIndex').mockImplementation()
|
|
215
|
+
renderList()
|
|
216
|
+
const list = screen.UNSAFE_getByType(FlatList)
|
|
217
|
+
|
|
218
|
+
act(() => list.props.onLayout({ nativeEvent: { layout: { height: 600 } } }))
|
|
219
|
+
act(() => list.props.onContentSizeChange(0, 1000))
|
|
220
|
+
|
|
221
|
+
expect(scrollToIndex).not.toHaveBeenCalled()
|
|
222
|
+
})
|
|
223
|
+
|
|
182
224
|
it('redraws only the reacted row when a reaction arrives', () => {
|
|
183
225
|
renderList()
|
|
184
226
|
const target = messages[3]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React, { PropsWithChildren } from 'react'
|
|
2
|
+
import { ResponseError } from '../../utils/response_error'
|
|
3
|
+
|
|
4
|
+
type Props = PropsWithChildren<{ onAnchorNotFound: () => void }>
|
|
5
|
+
|
|
6
|
+
export class AnchorNotFoundBoundary extends React.Component<Props, { recovering: boolean }> {
|
|
7
|
+
state = { recovering: false }
|
|
8
|
+
|
|
9
|
+
static getDerivedStateFromError(error: Error) {
|
|
10
|
+
if (error instanceof ResponseError && error.status === 404) return { recovering: true }
|
|
11
|
+
throw error
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
componentDidCatch() {
|
|
15
|
+
this.props.onAnchorNotFound()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
render() {
|
|
19
|
+
return this.state.recovering ? null : this.props.children
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -71,6 +71,7 @@ interface MessagesFormRootProps extends ViewProps {
|
|
|
71
71
|
replyRootId?: string | null
|
|
72
72
|
replyRootAuthorFirstName?: string | null
|
|
73
73
|
onFlaggedInMessageForm?: (id: string) => void
|
|
74
|
+
onBeforeSubmit?: () => void
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
const MessageFormContext = React.createContext<{
|
|
@@ -113,6 +114,7 @@ function MessageFormRoot({
|
|
|
113
114
|
replyRootId,
|
|
114
115
|
replyRootAuthorFirstName,
|
|
115
116
|
onFlaggedInMessageForm,
|
|
117
|
+
onBeforeSubmit,
|
|
116
118
|
}: MessagesFormRootProps) {
|
|
117
119
|
const { giphyApiKey } = useContext(ChatContext)
|
|
118
120
|
const canGiphy = !!giphyApiKey && !currentlyEditingMessage
|
|
@@ -227,6 +229,8 @@ function MessageFormRoot({
|
|
|
227
229
|
const handleSubmit = () => {
|
|
228
230
|
if (!canSubmit) return
|
|
229
231
|
|
|
232
|
+
onBeforeSubmit?.()
|
|
233
|
+
|
|
230
234
|
if (canGiphy && usingGiphy) {
|
|
231
235
|
TextInput.State.blurTextInput(TextInput.State.currentlyFocusedInput())
|
|
232
236
|
Haptic.impactLight()
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { date as formatDate } from '@planningcenter/datetime-fmt'
|
|
2
|
-
import
|
|
2
|
+
import { noop } from 'lodash'
|
|
3
|
+
import React, { RefObject, useRef } from 'react'
|
|
3
4
|
import { FlatList, FlatListProps, StyleSheet, View } from 'react-native'
|
|
4
5
|
import { useTheme } from '../../hooks'
|
|
5
6
|
import { ConversationResource } from '../../types/resources/conversation'
|
|
@@ -16,7 +17,15 @@ import { SystemMessage } from './system_message'
|
|
|
16
17
|
|
|
17
18
|
type ScrollHandlers = Pick<
|
|
18
19
|
FlatListProps<EnrichedMessage>,
|
|
19
|
-
|
|
20
|
+
| 'onScroll'
|
|
21
|
+
| 'onLayout'
|
|
22
|
+
| 'onContentSizeChange'
|
|
23
|
+
| 'onScrollEndDrag'
|
|
24
|
+
| 'onMomentumScrollEnd'
|
|
25
|
+
| 'onScrollBeginDrag'
|
|
26
|
+
| 'onStartReached'
|
|
27
|
+
| 'onStartReachedThreshold'
|
|
28
|
+
| 'maintainVisibleContentPosition'
|
|
20
29
|
>
|
|
21
30
|
|
|
22
31
|
interface MessageListProps extends ScrollHandlers {
|
|
@@ -25,6 +34,7 @@ interface MessageListProps extends ScrollHandlers {
|
|
|
25
34
|
conversationId: number
|
|
26
35
|
messages: MessageResource[]
|
|
27
36
|
replyRootId?: string | null
|
|
37
|
+
anchorMessageId?: string | null
|
|
28
38
|
hasNextPage: boolean
|
|
29
39
|
onEndReached: () => void
|
|
30
40
|
}
|
|
@@ -38,6 +48,7 @@ export function MessageList({
|
|
|
38
48
|
conversationId,
|
|
39
49
|
messages,
|
|
40
50
|
replyRootId,
|
|
51
|
+
anchorMessageId,
|
|
41
52
|
hasNextPage,
|
|
42
53
|
onEndReached,
|
|
43
54
|
onScroll,
|
|
@@ -45,6 +56,10 @@ export function MessageList({
|
|
|
45
56
|
onContentSizeChange,
|
|
46
57
|
onScrollEndDrag,
|
|
47
58
|
onMomentumScrollEnd,
|
|
59
|
+
onScrollBeginDrag,
|
|
60
|
+
onStartReached,
|
|
61
|
+
onStartReachedThreshold,
|
|
62
|
+
maintainVisibleContentPosition,
|
|
48
63
|
}: MessageListProps) {
|
|
49
64
|
const styles = useStyles()
|
|
50
65
|
const inReplyScreen = !!replyRootId
|
|
@@ -52,6 +67,40 @@ export function MessageList({
|
|
|
52
67
|
conversation.memberAbility?.canDeleteNonAuthoredMessages ?? false
|
|
53
68
|
const latestRead = conversation.latestReadMessageSortKey
|
|
54
69
|
const messagesWithSeparators = groupMessages({ ms: messages, inReplyScreen })
|
|
70
|
+
const anchorIndex = anchorMessageId
|
|
71
|
+
? messagesWithSeparators.findIndex(item => item.id === anchorMessageId)
|
|
72
|
+
: -1
|
|
73
|
+
const layoutHeight = useRef(0)
|
|
74
|
+
const contentHeight = useRef(0)
|
|
75
|
+
const userHasScrolled = useRef(false)
|
|
76
|
+
|
|
77
|
+
const pinAnchorToBottomEdge = () => {
|
|
78
|
+
if (anchorIndex < 0 || userHasScrolled.current) return
|
|
79
|
+
const scrollable = layoutHeight.current > 0 && contentHeight.current > layoutHeight.current
|
|
80
|
+
if (!scrollable) return
|
|
81
|
+
|
|
82
|
+
listRef.current?.scrollToIndex({ index: anchorIndex, viewPosition: 0, animated: false })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const handleLayout: FlatListProps<EnrichedMessage>['onLayout'] = event => {
|
|
86
|
+
onLayout?.(event)
|
|
87
|
+
layoutHeight.current = event.nativeEvent.layout.height
|
|
88
|
+
pinAnchorToBottomEdge()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const handleContentSizeChange: FlatListProps<EnrichedMessage>['onContentSizeChange'] = (
|
|
92
|
+
width,
|
|
93
|
+
height
|
|
94
|
+
) => {
|
|
95
|
+
onContentSizeChange?.(width, height)
|
|
96
|
+
contentHeight.current = height
|
|
97
|
+
pinAnchorToBottomEdge()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const handleScrollBeginDrag: FlatListProps<EnrichedMessage>['onScrollBeginDrag'] = event => {
|
|
101
|
+
userHasScrolled.current = true
|
|
102
|
+
onScrollBeginDrag?.(event)
|
|
103
|
+
}
|
|
55
104
|
|
|
56
105
|
return (
|
|
57
106
|
<FlatList
|
|
@@ -61,8 +110,9 @@ export function MessageList({
|
|
|
61
110
|
data={messagesWithSeparators}
|
|
62
111
|
keyExtractor={item => item.id}
|
|
63
112
|
onScroll={onScroll}
|
|
64
|
-
onLayout={
|
|
65
|
-
onContentSizeChange={
|
|
113
|
+
onLayout={handleLayout}
|
|
114
|
+
onContentSizeChange={handleContentSizeChange}
|
|
115
|
+
onScrollToIndexFailed={noop}
|
|
66
116
|
onScrollEndDrag={onScrollEndDrag}
|
|
67
117
|
onMomentumScrollEnd={onMomentumScrollEnd}
|
|
68
118
|
scrollEventThrottle={10}
|
|
@@ -102,6 +152,10 @@ export function MessageList({
|
|
|
102
152
|
)
|
|
103
153
|
}}
|
|
104
154
|
onEndReached={onEndReached}
|
|
155
|
+
onScrollBeginDrag={handleScrollBeginDrag}
|
|
156
|
+
onStartReached={onStartReached}
|
|
157
|
+
onStartReachedThreshold={onStartReachedThreshold}
|
|
158
|
+
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
|
105
159
|
ListHeaderComponent={<View style={styles.listHeader} />}
|
|
106
160
|
/>
|
|
107
161
|
)
|
package/src/hooks/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from './use_chat_permissions'
|
|
|
8
8
|
export * from './use_create_android_ripple_color'
|
|
9
9
|
export * from './use_current_person'
|
|
10
10
|
export * from './use_deleting_ids'
|
|
11
|
+
export * from './use_direct_message_eligibility'
|
|
11
12
|
export * from './use_direct_messages_eligible'
|
|
12
13
|
export * from './use_font_scale'
|
|
13
14
|
export * from './use_has_direct_messages'
|