@planningcenter/chat-react-native 4.2.0 → 4.2.1-qa-staging.0

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 (83) hide show
  1. package/build/components/conversation/message_form.d.ts +2 -1
  2. package/build/components/conversation/message_form.d.ts.map +1 -1
  3. package/build/components/conversation/message_form.js +54 -51
  4. package/build/components/conversation/message_form.js.map +1 -1
  5. package/build/components/conversation/message_list.d.ts +2 -2
  6. package/build/components/conversation/message_list.d.ts.map +1 -1
  7. package/build/components/conversation/message_list.js +19 -16
  8. package/build/components/conversation/message_list.js.map +1 -1
  9. package/build/hooks/index.d.ts +1 -0
  10. package/build/hooks/index.d.ts.map +1 -1
  11. package/build/hooks/index.js +1 -0
  12. package/build/hooks/index.js.map +1 -1
  13. package/build/hooks/paginator_meta.d.ts +3 -1
  14. package/build/hooks/paginator_meta.d.ts.map +1 -1
  15. package/build/hooks/paginator_meta.js +25 -8
  16. package/build/hooks/paginator_meta.js.map +1 -1
  17. package/build/hooks/use_api.d.ts.map +1 -1
  18. package/build/hooks/use_api.js +5 -8
  19. package/build/hooks/use_api.js.map +1 -1
  20. package/build/hooks/use_conversation_messages_jolt_events.d.ts +9 -1
  21. package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
  22. package/build/hooks/use_conversation_messages_jolt_events.js +88 -73
  23. package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
  24. package/build/hooks/use_direct_message_eligibility.d.ts +14 -0
  25. package/build/hooks/use_direct_message_eligibility.d.ts.map +1 -0
  26. package/build/hooks/use_direct_message_eligibility.js +87 -0
  27. package/build/hooks/use_direct_message_eligibility.js.map +1 -0
  28. package/build/hooks/use_fetch_newer_messages.d.ts +8 -0
  29. package/build/hooks/use_fetch_newer_messages.d.ts.map +1 -0
  30. package/build/hooks/use_fetch_newer_messages.js +56 -0
  31. package/build/hooks/use_fetch_newer_messages.js.map +1 -0
  32. package/build/hooks/use_suspense_api.d.ts.map +1 -1
  33. package/build/hooks/use_suspense_api.js +5 -8
  34. package/build/hooks/use_suspense_api.js.map +1 -1
  35. package/build/screens/conversation_new/components/services_form.js +9 -8
  36. package/build/screens/conversation_new/components/services_form.js.map +1 -1
  37. package/build/screens/conversation_screen.d.ts.map +1 -1
  38. package/build/screens/conversation_screen.js +137 -322
  39. package/build/screens/conversation_screen.js.map +1 -1
  40. package/build/types/api_primitives.d.ts +2 -0
  41. package/build/types/api_primitives.d.ts.map +1 -1
  42. package/build/types/api_primitives.js.map +1 -1
  43. package/build/types/resources/member.d.ts +1 -0
  44. package/build/types/resources/member.d.ts.map +1 -1
  45. package/build/types/resources/member.js.map +1 -1
  46. package/build/utils/cache/prepend_newer_page.d.ts +7 -0
  47. package/build/utils/cache/prepend_newer_page.d.ts.map +1 -0
  48. package/build/utils/cache/prepend_newer_page.js +14 -0
  49. package/build/utils/cache/prepend_newer_page.js.map +1 -0
  50. package/build/utils/client/types.d.ts +1 -0
  51. package/build/utils/client/types.d.ts.map +1 -1
  52. package/build/utils/client/types.js.map +1 -1
  53. package/build/utils/gated_message_cache.d.ts +7 -0
  54. package/build/utils/gated_message_cache.d.ts.map +1 -0
  55. package/build/utils/gated_message_cache.js +13 -0
  56. package/build/utils/gated_message_cache.js.map +1 -0
  57. package/build/utils/group_messages.js +4 -1
  58. package/build/utils/group_messages.js.map +1 -1
  59. package/package.json +3 -3
  60. package/src/__tests__/hooks/paginator_meta.test.ts +73 -2
  61. package/src/__tests__/hooks/use_conversation_messages_jolt_events.test.ts +36 -0
  62. package/src/__tests__/hooks/use_direct_message_eligibility.test.tsx +83 -0
  63. package/src/__tests__/screens/conversation_new/components/services_form.test.tsx +91 -0
  64. package/src/__tests__/utils/gated_message_cache.test.ts +26 -0
  65. package/src/components/conversation/message_form.tsx +4 -0
  66. package/src/components/conversation/message_list.tsx +8 -1
  67. package/src/hooks/index.ts +1 -0
  68. package/src/hooks/paginator_meta.ts +29 -5
  69. package/src/hooks/use_api.ts +4 -5
  70. package/src/hooks/use_conversation_messages_jolt_events.ts +57 -40
  71. package/src/hooks/use_direct_message_eligibility.ts +40 -0
  72. package/src/hooks/use_fetch_newer_messages.ts +37 -0
  73. package/src/hooks/use_suspense_api.ts +4 -5
  74. package/src/screens/conversation_new/components/services_form.tsx +5 -5
  75. package/src/screens/conversation_screen.tsx +36 -4
  76. package/src/types/api_primitives.ts +2 -0
  77. package/src/types/resources/member.ts +1 -0
  78. package/src/utils/__tests__/group_messages.test.ts +29 -0
  79. package/src/utils/cache/__tests__/prepend_newer_page.test.ts +35 -0
  80. package/src/utils/cache/prepend_newer_page.ts +22 -0
  81. package/src/utils/client/types.ts +1 -0
  82. package/src/utils/gated_message_cache.ts +19 -0
  83. package/src/utils/group_messages.ts +4 -1
@@ -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
+ })
@@ -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()
@@ -16,7 +16,12 @@ import { SystemMessage } from './system_message'
16
16
 
17
17
  type ScrollHandlers = Pick<
18
18
  FlatListProps<EnrichedMessage>,
19
- 'onScroll' | 'onLayout' | 'onContentSizeChange' | 'onScrollEndDrag' | 'onMomentumScrollEnd'
19
+ | 'onScroll'
20
+ | 'onLayout'
21
+ | 'onContentSizeChange'
22
+ | 'onScrollEndDrag'
23
+ | 'onMomentumScrollEnd'
24
+ | 'maintainVisibleContentPosition'
20
25
  >
21
26
 
22
27
  interface MessageListProps extends ScrollHandlers {
@@ -45,6 +50,7 @@ export function MessageList({
45
50
  onContentSizeChange,
46
51
  onScrollEndDrag,
47
52
  onMomentumScrollEnd,
53
+ maintainVisibleContentPosition,
48
54
  }: MessageListProps) {
49
55
  const styles = useStyles()
50
56
  const inReplyScreen = !!replyRootId
@@ -102,6 +108,7 @@ export function MessageList({
102
108
  )
103
109
  }}
104
110
  onEndReached={onEndReached}
111
+ maintainVisibleContentPosition={maintainVisibleContentPosition}
105
112
  ListHeaderComponent={<View style={styles.listHeader} />}
106
113
  />
107
114
  )
@@ -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'
@@ -1,13 +1,37 @@
1
- import { NextPageCursor } from '../types/api_primitives'
1
+ import { ApiCollection, NextPageCursor, ResourceObject } from '../types/api_primitives'
2
2
  import { RequestData } from '../utils/client'
3
3
 
4
4
  export const getNextPageParamFromMeta = (
5
5
  next: NextPageCursor = {}
6
6
  ): Partial<RequestData> | undefined => {
7
- if (next.idLt) return { where: { id_lt: next.idLt } }
8
- if (next.idGt) return { where: { id_gt: next.idGt } }
9
- if (next.idLte) return { where: { id_lte: next.idLte } }
10
- if (next.idGte) return { where: { id_gte: next.idGte } }
7
+ if (next.idLt) return withOrder({ where: { id_lt: next.idLt } }, next.order)
8
+ if (next.idGt) return withOrder({ where: { id_gt: next.idGt } }, next.order)
9
+ if (next.idLte) return withOrder({ where: { id_lte: next.idLte } }, next.order)
10
+ if (next.idGte) return withOrder({ where: { id_gte: next.idGte } }, next.order)
11
11
  if (next.offset) return { offset: Number(next.offset) }
12
12
  return undefined
13
13
  }
14
+
15
+ const withOrder = (param: Partial<RequestData>, order: NextPageCursor['order']) =>
16
+ order ? { ...param, order } : param
17
+
18
+ const BOUND_KEYS = ['id_lt', 'id_lte', 'id_gt', 'id_gte', 'id_around']
19
+
20
+ export const mergePageWhere = (
21
+ argsWhere: RequestData['where'] = {},
22
+ pageWhere: RequestData['where'] = {}
23
+ ): RequestData['where'] => {
24
+ const pageHasCursor = BOUND_KEYS.some(key => key in pageWhere)
25
+ const base = pageHasCursor
26
+ ? Object.fromEntries(Object.entries(argsWhere).filter(([key]) => !BOUND_KEYS.includes(key)))
27
+ : argsWhere
28
+ return { ...base, ...pageWhere }
29
+ }
30
+
31
+ export const getNewerPageParam = (
32
+ page: ApiCollection<ResourceObject>,
33
+ pageParam: Partial<RequestData> | undefined
34
+ ): Partial<RequestData> | undefined => {
35
+ const isAscendingPage = pageParam?.order === 'asc'
36
+ return getNextPageParamFromMeta(isAscendingPage ? page.meta?.next : page.meta?.prev)
37
+ }
@@ -4,7 +4,7 @@ import { ChatContext } from '../contexts/chat_context'
4
4
  import { ApiCollection, ApiResource, ResourceObject } from '../types'
5
5
  import { GetRequest, RequestData } from '../utils/client'
6
6
  import { ResponseError, throwResponseError } from '../utils/response_error'
7
- import { getNextPageParamFromMeta } from './paginator_meta'
7
+ import { getNextPageParamFromMeta, mergePageWhere } from './paginator_meta'
8
8
  import { App, useApiClient } from './use_api_client'
9
9
  import { getRequestQueryKey, RequestQueryKey } from './use_suspense_api'
10
10
 
@@ -61,12 +61,11 @@ export const useApiPaginator = <T extends ResourceObject>(
61
61
  >({
62
62
  queryKey: getRequestQueryKey(args),
63
63
  queryFn: ({ pageParam }) => {
64
- const pageParmWhere = pageParam?.where || {}
65
- const argsWhere = args.data.where || {}
66
- const where = { ...argsWhere, ...pageParmWhere }
64
+ const where = mergePageWhere(args.data.where, pageParam?.where)
67
65
  const app = args.app || 'chat'
68
66
  const offset = pageParam?.offset || args.data.offset
69
- const data = { ...args.data, where, offset }
67
+ const order = pageParam?.order || args.data.order
68
+ const data = { ...args.data, where, offset, order }
70
69
 
71
70
  return apiClient[app].get<ApiCollection<T>>({ url: args.url, data }).catch(throwResponseError)
72
71
  },
@@ -1,4 +1,4 @@
1
- import { InfiniteData, useQueryClient } from '@tanstack/react-query'
1
+ import { hashKey, InfiniteData, useQueryClient } from '@tanstack/react-query'
2
2
  import { ApiCollection, MessageResource } from '../types'
3
3
  import { JoltReactionEvent, JoltTypingEvent } from '../types/jolt_events'
4
4
  import {
@@ -24,16 +24,49 @@ import { useJoltChannel, useJoltEvent } from './use_jolt'
24
24
  import { getRequestQueryKey } from './use_suspense_api'
25
25
  import { TYPING_TIMEOUT_INTERVAL, useTypingStatusCache } from './use_typing_status_cache'
26
26
 
27
+ export type MessagesJoltEvent =
28
+ | 'message.created'
29
+ | 'message.updated'
30
+ | 'message.destroyed'
31
+ | 'reaction'
32
+
33
+ export interface MessagesCache {
34
+ queryKey: unknown[]
35
+ canUpdate?: (event: MessagesJoltEvent) => boolean
36
+ }
37
+
27
38
  interface Props {
28
39
  conversationId: number
40
+ messageCaches?: MessagesCache[]
41
+ }
42
+
43
+ export const resolveMessageCaches = (defaults: unknown[][], overrides: MessagesCache[]) => {
44
+ const overrideByHash = new Map(overrides.map(cache => [hashKey(cache.queryKey), cache]))
45
+ const defaultHashes = new Set(defaults.map(hashKey))
46
+ const resolved = defaults.map(queryKey => overrideByHash.get(hashKey(queryKey)) ?? { queryKey })
47
+ const extras = overrides.filter(cache => !defaultHashes.has(hashKey(cache.queryKey)))
48
+ return [...resolved, ...extras]
29
49
  }
30
50
 
31
- export function useConversationMessagesJoltEvents({ conversationId }: Props) {
51
+ export const queryKeysForEvent = (
52
+ event: MessagesJoltEvent,
53
+ defaults: unknown[][],
54
+ overrides: MessagesCache[]
55
+ ) =>
56
+ resolveMessageCaches(defaults, overrides)
57
+ .filter(cache => !cache.canUpdate || cache.canUpdate(event))
58
+ .map(cache => cache.queryKey)
59
+
60
+ export function useConversationMessagesJoltEvents({ conversationId, messageCaches = [] }: Props) {
32
61
  const queryClient = useQueryClient()
33
62
  const currentPerson = useCurrentPerson()
34
63
  const joltChannel = useJoltChannel(`chat.conversations.${conversationId}`)
35
64
  const messagesRequestArgs = getMessagesRequestArgs({ conversation_id: conversationId })
36
65
  const messagesQueryKey = getRequestQueryKey(messagesRequestArgs)
66
+ const cachesTouchedBy = (replyRootId?: string | null): unknown[][] =>
67
+ replyRootId
68
+ ? [messagesQueryKey, getThreadedMessagesQueryKey(conversationId, replyRootId)]
69
+ : [messagesQueryKey]
37
70
  const apiClient = useApiClient()
38
71
 
39
72
  const { addTypingEvent, removeTypingEventById, removeAllTypingEventsByAuthorId } =
@@ -54,21 +87,16 @@ export function useConversationMessagesJoltEvents({ conversationId }: Props) {
54
87
  }
55
88
  }
56
89
 
57
- // Update the main conversation cache
58
- updateCacheWithMessage(queryClient, messagesQueryKey, message, e.event)
59
-
60
- // If message has a reply_root_id, also update the threaded cache
61
- if (data.reply_root_id) {
62
- const threadedMessagesQueryKey = getThreadedMessagesQueryKey(
63
- conversationId,
64
- data.reply_root_id
65
- )
66
- updateCacheWithMessage(queryClient, threadedMessagesQueryKey, message, e.event)
67
-
68
- if (isReplyRootUpdate) {
69
- updateCacheReplyRoots(queryClient, messagesQueryKey, message)
70
- updateCacheReplyRoots(queryClient, threadedMessagesQueryKey, message)
71
- }
90
+ const targetQueryKeys = queryKeysForEvent(
91
+ e.event,
92
+ cachesTouchedBy(data.reply_root_id),
93
+ messageCaches
94
+ )
95
+ targetQueryKeys.forEach(queryKey =>
96
+ updateCacheWithMessage(queryClient, queryKey, message, e.event)
97
+ )
98
+ if (isReplyRootUpdate) {
99
+ targetQueryKeys.forEach(queryKey => updateCacheReplyRoots(queryClient, queryKey, message))
72
100
  }
73
101
 
74
102
  // Update individual message query cache so reply shadow messages update immediately
@@ -88,34 +116,23 @@ export function useConversationMessagesJoltEvents({ conversationId }: Props) {
88
116
  currentPersonId: currentPerson.id,
89
117
  })
90
118
 
91
- // Update the main conversation cache
92
- queryClient.setQueryData<QueryData>(messagesQueryKey, prev =>
93
- deleteRecordInPagesData({ data: prev, record: message })
94
- )
95
-
96
- // If message has a reply_root_id, also update the threaded cache
97
- if (data.reply_root_id) {
98
- const threadedMessagesQueryKey = getThreadedMessagesQueryKey(
99
- conversationId,
100
- data.reply_root_id
101
- )
102
- queryClient.setQueryData<QueryData>(threadedMessagesQueryKey, prev =>
119
+ queryKeysForEvent(
120
+ 'message.destroyed',
121
+ cachesTouchedBy(data.reply_root_id),
122
+ messageCaches
123
+ ).forEach(queryKey =>
124
+ queryClient.setQueryData<QueryData>(queryKey, prev =>
103
125
  deleteRecordInPagesData({ data: prev, record: message })
104
126
  )
105
- }
127
+ )
106
128
  }
107
129
 
108
130
  const handleReactionJoltEvent = async (e: JoltReactionEvent) => {
109
- // Update the main conversation cache and capture the reply_root_id if present
110
- updateCacheWithReaction(queryClient, messagesQueryKey, e, currentPerson.id)
111
-
112
- const replyRootId = e.data.data.reply_root_id
113
-
114
- // If the message has a reply_root_id, also update the threaded cache
115
- if (replyRootId) {
116
- const threadedMessagesQueryKey = getThreadedMessagesQueryKey(conversationId, replyRootId)
117
- updateCacheWithReaction(queryClient, threadedMessagesQueryKey, e, currentPerson.id)
118
- }
131
+ queryKeysForEvent(
132
+ 'reaction',
133
+ cachesTouchedBy(e.data.data.reply_root_id),
134
+ messageCaches
135
+ ).forEach(queryKey => updateCacheWithReaction(queryClient, queryKey, e, currentPerson.id))
119
136
  }
120
137
 
121
138
  const handleTypingEvent = async (e: JoltTypingEvent) => {
@@ -0,0 +1,40 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { ApiResource, MemberResource } from '../types'
3
+ import { useApiClient } from './use_api_client'
4
+
5
+ export interface DirectMessageEligibility {
6
+ canDirectMessage: boolean
7
+ directMessagesDisabled: boolean
8
+ }
9
+
10
+ export function useDirectMessageEligibility({
11
+ groupId,
12
+ personId,
13
+ enabled = true,
14
+ }: {
15
+ groupId: string
16
+ personId: number
17
+ enabled?: boolean
18
+ }) {
19
+ const apiClient = useApiClient()
20
+ const url = `/me/groups/${groupId}/members/${personId}`
21
+
22
+ const { data, isLoading, isError } = useQuery({
23
+ queryKey: ['chat', url, 'direct_message_eligibility'],
24
+ queryFn: () =>
25
+ apiClient.chat.get({
26
+ url,
27
+ data: { fields: { Member: ['can_direct_message', 'direct_messages_disabled'] } },
28
+ }) as Promise<ApiResource<MemberResource>>,
29
+ enabled,
30
+ retry: false,
31
+ })
32
+
33
+ const member = data?.data
34
+ const eligibility: DirectMessageEligibility | undefined = member && {
35
+ canDirectMessage: member.canDirectMessage ?? false,
36
+ directMessagesDisabled: member.directMessagesDisabled ?? false,
37
+ }
38
+
39
+ return { eligibility, isLoading, isError }
40
+ }
@@ -0,0 +1,37 @@
1
+ import { useQueryClient } from '@tanstack/react-query'
2
+ import { useCallback } from 'react'
3
+ import { ApiCollection, MessageResource } from '../types'
4
+ import { prependNewerPage } from '../utils/cache/prepend_newer_page'
5
+ import { RequestData } from '../utils/client'
6
+ import { getMessagesRequestArgs } from '../utils/request/get_messages'
7
+ import { useApiClient } from './use_api_client'
8
+
9
+ type Options = {
10
+ conversation_id: number
11
+ reply_root_id?: string | null
12
+ queryKey: unknown[]
13
+ }
14
+
15
+ export const useFetchNewerMessages = ({ conversation_id, reply_root_id, queryKey }: Options) => {
16
+ const queryClient = useQueryClient()
17
+ const apiClient = useApiClient()
18
+
19
+ return useCallback(
20
+ (newestMessageId: string) => {
21
+ const { url, data } = getMessagesRequestArgs({ conversation_id, reply_root_id })
22
+ const where = { id_gt: newestMessageId }
23
+ const pageParam: Partial<RequestData> = { where, order: 'asc' }
24
+
25
+ return apiClient.chat
26
+ .get<ApiCollection<MessageResource>>({ url, data: { ...data, where, order: 'asc' } })
27
+ .then(page => {
28
+ queryClient.setQueryData<Parameters<typeof prependNewerPage<MessageResource>>[0]>(
29
+ queryKey,
30
+ prev => prependNewerPage(prev, page, pageParam)
31
+ )
32
+ })
33
+ .catch(() => {})
34
+ },
35
+ [apiClient, conversation_id, queryClient, queryKey, reply_root_id]
36
+ )
37
+ }
@@ -10,7 +10,7 @@ import { FailedResponse } from '../types/api_primitives'
10
10
  import { Log } from '../utils'
11
11
  import { GetRequest, RequestData } from '../utils/client'
12
12
  import { throwResponseError } from '../utils/response_error'
13
- import { getNextPageParamFromMeta } from './paginator_meta'
13
+ import { getNextPageParamFromMeta, mergePageWhere } from './paginator_meta'
14
14
  import { App, useApiClient } from './use_api_client'
15
15
 
16
16
  interface SuspenseGetOptions extends GetRequest {
@@ -65,12 +65,11 @@ export const useSuspensePaginator = <T extends ResourceObject>(
65
65
  queryFn: ({ pageParam }) => {
66
66
  Log.info('useSuspenseGet', { pageParam })
67
67
 
68
- const pageParmWhere = pageParam?.where || {}
69
- const argsWhere = args.data.where || {}
70
- const where = { ...argsWhere, ...pageParmWhere }
68
+ const where = mergePageWhere(args.data.where, pageParam?.where)
71
69
  const app = args.app || 'chat'
72
70
  const offset = pageParam?.offset || args.data.offset
73
- const data = { ...args.data, where, offset }
71
+ const order = pageParam?.order || args.data.order
72
+ const data = { ...args.data, where, offset, order }
74
73
 
75
74
  return apiClient[app]
76
75
  .get<ApiCollection<T>>({
@@ -45,6 +45,7 @@ export const ServicesForm = ({
45
45
  const customAvatarsEnabled = featureEnabled(availableFeatures.custom_conversation_avatars)
46
46
  const safetyLockEnabled = featureEnabled(availableFeatures.conversation_safety_lock)
47
47
  const [selectedPlanId, setSelectedPlanId] = useState<number | undefined>(initialPlanId)
48
+ const [filterByPlan, setFilterByPlan] = useState(!!initialPlanId)
48
49
  const initialState = useMemo(() => uniq(initialTeamIds) || [], [initialTeamIds]) // Uniq here because services can send duplicates in the teams_i_lead response.
49
50
  const [selectedTeamIds, setSelectedTeamIds] = useState<number[]>(initialState)
50
51
 
@@ -52,6 +53,7 @@ export const ServicesForm = ({
52
53
  useEffect(() => {
53
54
  setSelectedTeamIds(initialState)
54
55
  setSelectedPlanId(initialPlanId)
56
+ setFilterByPlan(!!initialPlanId)
55
57
  }, [initialState, initialPlanId])
56
58
 
57
59
  const removeSelection = useCallback(
@@ -61,9 +63,7 @@ export const ServicesForm = ({
61
63
  [selectedTeamIds]
62
64
  )
63
65
 
64
- const [filerByPlan, setFilterByPlan] = useState(false)
65
-
66
- const activePlanId = filerByPlan ? selectedPlanId : undefined
66
+ const activePlanId = filterByPlan ? selectedPlanId : undefined
67
67
 
68
68
  const { payload, isLoading: isLoadingPayload } = useServicesChatConversationPayload({
69
69
  teamIds: selectedTeamIds,
@@ -79,7 +79,7 @@ export const ServicesForm = ({
79
79
 
80
80
  const { members, isError: isMemberError } = useTeamMembersForNewConversation({
81
81
  teamIds: selectedTeamIds,
82
- planId: selectedPlanId,
82
+ planId: activePlanId,
83
83
  })
84
84
  const adultMembers = useMemo(() => members.filter(member => !member.child), [members])
85
85
 
@@ -118,7 +118,7 @@ export const ServicesForm = ({
118
118
  removeSelection={removeSelection}
119
119
  selectedPlanId={selectedPlanId}
120
120
  setSelectedPlanId={setSelectedPlanId}
121
- filterByPlan={filerByPlan}
121
+ filterByPlan={filterByPlan}
122
122
  setFilterByPlan={setFilterByPlan}
123
123
  members={members}
124
124
  isMemberError={isMemberError}