@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.
Files changed (123) hide show
  1. package/build/components/conversation/anchor_not_found_boundary.d.ts +18 -0
  2. package/build/components/conversation/anchor_not_found_boundary.d.ts.map +1 -0
  3. package/build/components/conversation/anchor_not_found_boundary.js +20 -0
  4. package/build/components/conversation/anchor_not_found_boundary.js.map +1 -0
  5. package/build/components/conversation/message_form.d.ts +2 -1
  6. package/build/components/conversation/message_form.d.ts.map +1 -1
  7. package/build/components/conversation/message_form.js +54 -51
  8. package/build/components/conversation/message_form.js.map +1 -1
  9. package/build/components/conversation/message_list.d.ts +3 -2
  10. package/build/components/conversation/message_list.d.ts.map +1 -1
  11. package/build/components/conversation/message_list.js +152 -66
  12. package/build/components/conversation/message_list.js.map +1 -1
  13. package/build/hooks/index.d.ts +1 -0
  14. package/build/hooks/index.d.ts.map +1 -1
  15. package/build/hooks/index.js +1 -0
  16. package/build/hooks/index.js.map +1 -1
  17. package/build/hooks/paginator_meta.d.ts +3 -1
  18. package/build/hooks/paginator_meta.d.ts.map +1 -1
  19. package/build/hooks/paginator_meta.js +25 -8
  20. package/build/hooks/paginator_meta.js.map +1 -1
  21. package/build/hooks/use_api.d.ts.map +1 -1
  22. package/build/hooks/use_api.js +5 -8
  23. package/build/hooks/use_api.js.map +1 -1
  24. package/build/hooks/use_conversation_messages.d.ts +8 -2
  25. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  26. package/build/hooks/use_conversation_messages.js +77 -45
  27. package/build/hooks/use_conversation_messages.js.map +1 -1
  28. package/build/hooks/use_conversation_messages_jolt_events.d.ts +9 -1
  29. package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
  30. package/build/hooks/use_conversation_messages_jolt_events.js +88 -73
  31. package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
  32. package/build/hooks/use_direct_message_eligibility.d.ts +14 -0
  33. package/build/hooks/use_direct_message_eligibility.d.ts.map +1 -0
  34. package/build/hooks/use_direct_message_eligibility.js +87 -0
  35. package/build/hooks/use_direct_message_eligibility.js.map +1 -0
  36. package/build/hooks/use_fetch_newer_messages.d.ts +8 -0
  37. package/build/hooks/use_fetch_newer_messages.d.ts.map +1 -0
  38. package/build/hooks/use_fetch_newer_messages.js +56 -0
  39. package/build/hooks/use_fetch_newer_messages.js.map +1 -0
  40. package/build/hooks/use_fetch_newer_on_foreground.d.ts +8 -0
  41. package/build/hooks/use_fetch_newer_on_foreground.d.ts.map +1 -0
  42. package/build/hooks/use_fetch_newer_on_foreground.js +41 -0
  43. package/build/hooks/use_fetch_newer_on_foreground.js.map +1 -0
  44. package/build/hooks/use_mark_latest_message_read.d.ts +2 -1
  45. package/build/hooks/use_mark_latest_message_read.d.ts.map +1 -1
  46. package/build/hooks/use_mark_latest_message_read.js +4 -3
  47. package/build/hooks/use_mark_latest_message_read.js.map +1 -1
  48. package/build/hooks/use_suspense_api.d.ts.map +1 -1
  49. package/build/hooks/use_suspense_api.js +5 -8
  50. package/build/hooks/use_suspense_api.js.map +1 -1
  51. package/build/screens/conversation_new/components/services_form.js +9 -8
  52. package/build/screens/conversation_new/components/services_form.js.map +1 -1
  53. package/build/screens/conversation_screen.d.ts +1 -0
  54. package/build/screens/conversation_screen.d.ts.map +1 -1
  55. package/build/screens/conversation_screen.js +241 -355
  56. package/build/screens/conversation_screen.js.map +1 -1
  57. package/build/types/api_primitives.d.ts +2 -0
  58. package/build/types/api_primitives.d.ts.map +1 -1
  59. package/build/types/api_primitives.js.map +1 -1
  60. package/build/types/resources/member.d.ts +1 -0
  61. package/build/types/resources/member.d.ts.map +1 -1
  62. package/build/types/resources/member.js.map +1 -1
  63. package/build/utils/cache/prepend_newer_page.d.ts +7 -0
  64. package/build/utils/cache/prepend_newer_page.d.ts.map +1 -0
  65. package/build/utils/cache/prepend_newer_page.js +14 -0
  66. package/build/utils/cache/prepend_newer_page.js.map +1 -0
  67. package/build/utils/client/types.d.ts +1 -0
  68. package/build/utils/client/types.d.ts.map +1 -1
  69. package/build/utils/client/types.js.map +1 -1
  70. package/build/utils/gated_message_cache.d.ts +7 -0
  71. package/build/utils/gated_message_cache.d.ts.map +1 -0
  72. package/build/utils/gated_message_cache.js +13 -0
  73. package/build/utils/gated_message_cache.js.map +1 -0
  74. package/build/utils/generate_placeholder_ulid.d.ts +0 -6
  75. package/build/utils/generate_placeholder_ulid.d.ts.map +1 -1
  76. package/build/utils/generate_placeholder_ulid.js +2 -2
  77. package/build/utils/generate_placeholder_ulid.js.map +1 -1
  78. package/build/utils/group_messages.js +4 -1
  79. package/build/utils/group_messages.js.map +1 -1
  80. package/build/utils/request/get_messages.d.ts +18 -6
  81. package/build/utils/request/get_messages.d.ts.map +1 -1
  82. package/build/utils/request/get_messages.js +10 -12
  83. package/build/utils/request/get_messages.js.map +1 -1
  84. package/build/utils/ulid.d.ts +3 -0
  85. package/build/utils/ulid.d.ts.map +1 -0
  86. package/build/utils/ulid.js +7 -0
  87. package/build/utils/ulid.js.map +1 -0
  88. package/package.json +3 -3
  89. package/src/__tests__/hooks/paginator_meta.test.ts +73 -2
  90. package/src/__tests__/hooks/use_conversation_messages_jolt_events.test.ts +36 -0
  91. package/src/__tests__/hooks/use_direct_message_eligibility.test.tsx +83 -0
  92. package/src/__tests__/screens/conversation_new/components/services_form.test.tsx +91 -0
  93. package/src/__tests__/utils/gated_message_cache.test.ts +26 -0
  94. package/src/__tests__/utils/request/get_messages.test.ts +24 -0
  95. package/src/__tests__/utils/ulid.test.ts +15 -0
  96. package/src/components/conversation/__tests__/anchor_not_found_boundary.test.tsx +51 -0
  97. package/src/components/conversation/__tests__/message_list.test.tsx +42 -0
  98. package/src/components/conversation/anchor_not_found_boundary.tsx +21 -0
  99. package/src/components/conversation/message_form.tsx +4 -0
  100. package/src/components/conversation/message_list.tsx +58 -4
  101. package/src/hooks/index.ts +1 -0
  102. package/src/hooks/paginator_meta.ts +29 -5
  103. package/src/hooks/use_api.ts +4 -5
  104. package/src/hooks/use_conversation_messages.ts +43 -11
  105. package/src/hooks/use_conversation_messages_jolt_events.ts +57 -40
  106. package/src/hooks/use_direct_message_eligibility.ts +40 -0
  107. package/src/hooks/use_fetch_newer_messages.ts +37 -0
  108. package/src/hooks/use_fetch_newer_on_foreground.ts +25 -0
  109. package/src/hooks/use_mark_latest_message_read.ts +4 -3
  110. package/src/hooks/use_suspense_api.ts +4 -5
  111. package/src/screens/conversation_new/components/services_form.tsx +5 -5
  112. package/src/screens/conversation_screen.tsx +120 -17
  113. package/src/types/api_primitives.ts +2 -0
  114. package/src/types/resources/member.ts +1 -0
  115. package/src/utils/__tests__/group_messages.test.ts +29 -0
  116. package/src/utils/cache/__tests__/prepend_newer_page.test.ts +35 -0
  117. package/src/utils/cache/prepend_newer_page.ts +22 -0
  118. package/src/utils/client/types.ts +1 -0
  119. package/src/utils/gated_message_cache.ts +19 -0
  120. package/src/utils/generate_placeholder_ulid.ts +2 -2
  121. package/src/utils/group_messages.ts +4 -1
  122. package/src/utils/request/get_messages.ts +12 -14
  123. 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.0",
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.0.0",
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": "2e954ae112b6ebef2c978d34c82bc92363d3de0c"
87
+ "gitHead": "09c36025b92c9616f97afc67a83b723e0662d3e8"
88
88
  }
@@ -1,7 +1,13 @@
1
- import { getNextPageParamFromMeta } from '../../hooks/paginator_meta'
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 React, { RefObject } from 'react'
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
- 'onScroll' | 'onLayout' | 'onContentSizeChange' | 'onScrollEndDrag' | 'onMomentumScrollEnd'
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={onLayout}
65
- onContentSizeChange={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
  )
@@ -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'