@planningcenter/chat-react-native 3.42.2-rc.0 → 3.42.3-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 (56) hide show
  1. package/build/components/conversations/conversation_preview.d.ts.map +1 -1
  2. package/build/components/conversations/conversation_preview.js +46 -12
  3. package/build/components/conversations/conversation_preview.js.map +1 -1
  4. package/build/components/conversations/unread_count_badge.d.ts +2 -1
  5. package/build/components/conversations/unread_count_badge.d.ts.map +1 -1
  6. package/build/components/conversations/unread_count_badge.js +5 -4
  7. package/build/components/conversations/unread_count_badge.js.map +1 -1
  8. package/build/hooks/use_conversation.d.ts.map +1 -1
  9. package/build/hooks/use_conversation.js +2 -0
  10. package/build/hooks/use_conversation.js.map +1 -1
  11. package/build/hooks/use_conversation_jolt_events.d.ts.map +1 -1
  12. package/build/hooks/use_conversation_jolt_events.js +8 -15
  13. package/build/hooks/use_conversation_jolt_events.js.map +1 -1
  14. package/build/hooks/use_conversations_actions.d.ts.map +1 -1
  15. package/build/hooks/use_conversations_actions.js +15 -2
  16. package/build/hooks/use_conversations_actions.js.map +1 -1
  17. package/build/hooks/use_mark_latest_message_read.d.ts.map +1 -1
  18. package/build/hooks/use_mark_latest_message_read.js +3 -4
  19. package/build/hooks/use_mark_latest_message_read.js.map +1 -1
  20. package/build/hooks/use_suspense_api.d.ts.map +1 -1
  21. package/build/hooks/use_suspense_api.js +1 -7
  22. package/build/hooks/use_suspense_api.js.map +1 -1
  23. package/build/types/jolt_events/reaction_events.d.ts +1 -0
  24. package/build/types/jolt_events/reaction_events.d.ts.map +1 -1
  25. package/build/types/jolt_events/reaction_events.js.map +1 -1
  26. package/build/types/resources/conversation.d.ts +10 -1
  27. package/build/types/resources/conversation.d.ts.map +1 -1
  28. package/build/types/resources/conversation.js.map +1 -1
  29. package/build/utils/client/client.d.ts.map +1 -1
  30. package/build/utils/client/client.js +2 -1
  31. package/build/utils/client/client.js.map +1 -1
  32. package/build/utils/request/conversation.d.ts.map +1 -1
  33. package/build/utils/request/conversation.js +2 -0
  34. package/build/utils/request/conversation.js.map +1 -1
  35. package/build/utils/response_error.d.ts +1 -0
  36. package/build/utils/response_error.d.ts.map +1 -1
  37. package/build/utils/response_error.js +20 -1
  38. package/build/utils/response_error.js.map +1 -1
  39. package/build/utils/theme.d.ts.map +1 -1
  40. package/build/utils/theme.js +6 -3
  41. package/build/utils/theme.js.map +1 -1
  42. package/package.json +3 -3
  43. package/src/components/conversations/conversation_preview.tsx +58 -23
  44. package/src/components/conversations/unread_count_badge.tsx +13 -4
  45. package/src/hooks/use_conversation.ts +2 -0
  46. package/src/hooks/use_conversation_jolt_events.ts +6 -17
  47. package/src/hooks/use_conversations_actions.ts +16 -2
  48. package/src/hooks/use_mark_latest_message_read.ts +3 -4
  49. package/src/hooks/use_suspense_api.ts +1 -9
  50. package/src/types/jolt_events/reaction_events.ts +1 -0
  51. package/src/types/resources/conversation.ts +11 -1
  52. package/src/utils/__tests__/response_error.test.ts +47 -0
  53. package/src/utils/client/client.ts +2 -1
  54. package/src/utils/request/conversation.ts +2 -0
  55. package/src/utils/response_error.ts +23 -1
  56. package/src/utils/theme.ts +13 -3
@@ -16,6 +16,7 @@ export const getConversationRequestArgs = ({ conversation_id }: { conversation_i
16
16
  'created_at',
17
17
  'badges',
18
18
  'conversation_membership',
19
+ 'conversation_preview',
19
20
  'gender_option',
20
21
  'groups',
21
22
  'last_message_author_id',
@@ -36,6 +37,7 @@ export const getConversationRequestArgs = ({ conversation_id }: { conversation_i
36
37
  'replies_disabled',
37
38
  'title',
38
39
  'unread_count',
40
+ 'unread_reaction',
39
41
  'updated_at',
40
42
  ],
41
43
  AnalyticsMetadata: ['metadata'],
@@ -63,7 +63,7 @@ export function useConversationJoltEvents({ conversationId }: Props) {
63
63
  data: {
64
64
  ...prev.data,
65
65
  latestReadMessageSortKey: latest_read_message_sort_key,
66
- unreadReactionCount: 0,
66
+ unreadReaction: false,
67
67
  },
68
68
  }
69
69
  })
@@ -87,24 +87,13 @@ export function useConversationJoltEvents({ conversationId }: Props) {
87
87
 
88
88
  const handleReactionJoltEvent = useCallback(
89
89
  (event: JoltReactionEvent) => {
90
+ if (event.event !== 'reaction.created') return
91
+ if (event.data.data.author_id === currentPersonId) return
92
+ if (event.data.data.message_author_id !== currentPersonId) return
93
+
90
94
  queryClient.setQueryData<ApiResource<ConversationResource>>(queryKey, prev => {
91
95
  if (!prev?.data) return prev
92
-
93
- if (event.data.data.author_id === currentPersonId) {
94
- return prev
95
- }
96
-
97
- return {
98
- ...prev,
99
- data: {
100
- ...prev.data,
101
- // Not a real count, just a derived value from Jolt events so we can
102
- // determine if we should mark the conversation as read
103
- unreadReactionCount: prev.data.unreadReactionCount
104
- ? prev.data.unreadReactionCount + 1
105
- : 1,
106
- },
107
- }
96
+ return { ...prev, data: { ...prev.data, unreadReaction: true } }
108
97
  })
109
98
  },
110
99
  [queryClient, queryKey, currentPersonId]
@@ -1,10 +1,12 @@
1
- import { useMutation } from '@tanstack/react-query'
1
+ import { useMutation, useQueryClient } from '@tanstack/react-query'
2
2
  import { Alert } from 'react-native'
3
3
  import { useConversationsContext } from '../contexts/conversations_context'
4
4
  import { ApiResource, ConversationResource } from '../types'
5
5
  import { useApiClient } from './use_api_client'
6
+ import { getConversationRequestArgs } from './use_conversation'
6
7
  import { useConversationsCache } from './use_conversations_cache'
7
8
  import { useCurrentPersonCache } from './use_current_person'
9
+ import { getRequestQueryKey } from './use_suspense_api'
8
10
 
9
11
  export const useConversationsMarkRead = ({
10
12
  conversation,
@@ -12,15 +14,19 @@ export const useConversationsMarkRead = ({
12
14
  conversation: ConversationResource
13
15
  }) => {
14
16
  const apiClient = useApiClient()
17
+ const queryClient = useQueryClient()
15
18
  const { args } = useConversationsContext()
16
19
  const currentPersonCache = useCurrentPersonCache()
17
20
  const { update, invalidate, fetchUpdate } = useConversationsCache(args)
21
+ const conversationQueryKey = getRequestQueryKey(
22
+ getConversationRequestArgs({ conversation_id: conversation.id })
23
+ )
18
24
 
19
25
  function handlePersonUnreadCount(read: boolean) {
20
26
  currentPersonCache.update({}, person => {
21
27
  const currentUnread = person.unreadCount
22
28
  const updatedUnread = read ? Math.max(currentUnread - 1, 0) : currentUnread + 1
23
- return { ...person, unreadCount: updatedUnread, unreadReactionCount: 0 }
29
+ return { ...person, unreadCount: updatedUnread }
24
30
  })
25
31
  }
26
32
 
@@ -29,7 +35,14 @@ export const useConversationsMarkRead = ({
29
35
  update({
30
36
  ...conversation,
31
37
  unreadCount: read ? 0 : 1,
38
+ ...(read ? { unreadReaction: false } : {}),
32
39
  })
40
+ if (read) {
41
+ queryClient.setQueryData<ApiResource<ConversationResource>>(conversationQueryKey, prev => {
42
+ if (!prev?.data) return prev
43
+ return { ...prev, data: { ...prev.data, unreadReaction: false } }
44
+ })
45
+ }
33
46
  handlePersonUnreadCount(read)
34
47
  },
35
48
  mutationKey: ['markRead', conversation.id],
@@ -106,6 +119,7 @@ export const useMarkAllRead = () => {
106
119
  onMutate: () => {
107
120
  updateAll({
108
121
  unreadCount: 0,
122
+ unreadReaction: false,
109
123
  })
110
124
  },
111
125
  mutationKey: ['markAllRead', args],
@@ -17,9 +17,9 @@ export function useMarkLatestMessageRead({ conversation }: Props) {
17
17
  [markRead]
18
18
  )
19
19
  const unreadCount = conversation.unreadCount
20
- const unreadReactionCount = conversation.unreadReactionCount || 0
20
+ const unreadReaction = conversation.unreadReaction
21
21
 
22
- const shouldMarkRead = Boolean(unreadCount >= 1 || unreadReactionCount > 0 || !firedOnce.current)
22
+ const shouldMarkRead = Boolean(unreadCount >= 1 || unreadReaction || !firedOnce.current)
23
23
  const appState = useAppState()
24
24
  const isActive = appState === 'active'
25
25
 
@@ -29,6 +29,5 @@ export function useMarkLatestMessageRead({ conversation }: Props) {
29
29
  firedOnce.current = true
30
30
 
31
31
  debouncedMarkRead(true)
32
- // keeping unreadReactionCount in the dependency array to watch for changes
33
- }, [debouncedMarkRead, isActive, shouldMarkRead, unreadReactionCount])
32
+ }, [debouncedMarkRead, isActive, shouldMarkRead, unreadReaction])
34
33
  }
@@ -9,7 +9,7 @@ import { ApiCollection, ApiResource, ResourceObject } from '../types'
9
9
  import { FailedResponse } from '../types/api_primitives'
10
10
  import { Log } from '../utils'
11
11
  import { GetRequest, RequestData } from '../utils/client'
12
- import { ResponseError } from '../utils/response_error'
12
+ import { throwResponseError } from '../utils/response_error'
13
13
  import { getNextPageParamFromMeta } from './paginator_meta'
14
14
  import { App, useApiClient } from './use_api_client'
15
15
 
@@ -90,14 +90,6 @@ export const useSuspensePaginator = <T extends ResourceObject>(
90
90
  return { ...query, data, totalCount }
91
91
  }
92
92
 
93
- const throwResponseError = (error: unknown) => {
94
- if (error instanceof Response) {
95
- throw new ResponseError(error as FailedResponse)
96
- }
97
-
98
- return Promise.reject(error)
99
- }
100
-
101
93
  export type RequestQueryKey = [
102
94
  SuspenseGetOptions['url'],
103
95
  SuspenseGetOptions['data'],
@@ -6,6 +6,7 @@ interface BaseReactionEventData extends Record<string, unknown> {
6
6
  author_id: number
7
7
  conversation_id: number
8
8
  message_sort_key: string
9
+ message_author_id: number
9
10
  reply_root_id?: string | null
10
11
  created_at: string
11
12
  organization_id: number
@@ -3,15 +3,25 @@ import { ConversationBadgeResource } from './conversation_badge'
3
3
  import { ConversationMembershipResource } from './conversation_membership'
4
4
  import { GroupResource } from './group_resource'
5
5
  import { MemberAbilityResource } from './member_ability'
6
+ import { ReactionCountResource } from './reaction'
6
7
 
7
8
  export type ConversationDisabledReason = 'safety_check' | 'direct_messages_disabled'
8
9
 
10
+ export interface ConversationPreviewData {
11
+ kind: 'reaction' | 'message'
12
+ authorName: string
13
+ value: ReactionCountResource['value'] | null
14
+ text: string
15
+ createdAt: string
16
+ }
17
+
9
18
  export interface ConversationResource {
10
19
  type: 'Conversation'
11
20
  id: number
12
21
  badges?: ConversationBadgeResource[]
13
22
  analyticsMetadata?: AnalyticsMetadataResource
14
23
  conversationMembership?: Partial<ConversationMembershipResource>
24
+ conversationPreview: ConversationPreviewData | null
15
25
  createdAt: string
16
26
  deleted?: boolean
17
27
  directMessage?: boolean
@@ -34,6 +44,6 @@ export interface ConversationResource {
34
44
  repliesDisabled: boolean
35
45
  title: string
36
46
  unreadCount: number
37
- unreadReactionCount?: number // Derived from Jolt events
47
+ unreadReaction: boolean
38
48
  updatedAt: string
39
49
  }
@@ -0,0 +1,47 @@
1
+ import { isResponseLike, ResponseError, throwResponseError } from '../response_error'
2
+
3
+ // Mimics Expo's winter `FetchResponse`: the full Response shape, but NOT an
4
+ // instance of the global `Response` (so `instanceof Response` returns false).
5
+ const fetchResponseLike = (status: number) => ({
6
+ status,
7
+ ok: status >= 200 && status < 300,
8
+ statusText: 'unauthorized',
9
+ url: 'https://api.planningcenteronline.com/chat/v2/me',
10
+ redirected: false,
11
+ headers: {},
12
+ clone() {
13
+ return this
14
+ },
15
+ json: async () => ({ errors: [] }),
16
+ text: async () => '',
17
+ arrayBuffer: async () => new ArrayBuffer(0),
18
+ })
19
+
20
+ describe('isResponseLike', () => {
21
+ it('recognizes a Response by shape even when it is not an instanceof Response', () => {
22
+ const notARealResponse = fetchResponseLike(401)
23
+ expect(notARealResponse instanceof Response).toBe(false)
24
+ expect(isResponseLike(notARealResponse)).toBe(true)
25
+ })
26
+
27
+ it('rejects things that are not responses', () => {
28
+ expect(isResponseLike(null)).toBe(false)
29
+ expect(isResponseLike(new Error('boom'))).toBe(false)
30
+ expect(isResponseLike(new ResponseError({ status: 401 } as never))).toBe(false)
31
+ expect(isResponseLike({ status: 401 })).toBe(false)
32
+ expect(isResponseLike({ status: 401, ok: false, clone() {}, json: async () => ({}) })).toBe(
33
+ false
34
+ )
35
+ })
36
+ })
37
+
38
+ describe('throwResponseError', () => {
39
+ it('wraps a Response-like rejection into a ResponseError', () => {
40
+ expect(() => throwResponseError(fetchResponseLike(401))).toThrow(ResponseError)
41
+ })
42
+
43
+ it('passes through non-response errors untouched', async () => {
44
+ const error = new Error('network down')
45
+ await expect(throwResponseError(error)).rejects.toBe(error)
46
+ })
47
+ })
@@ -1,4 +1,5 @@
1
1
  import { ApiCollection, ApiResource, FailedResponse } from '../../types'
2
+ import { isResponseLike } from '../response_error'
2
3
  import {
3
4
  concatRecords,
4
5
  ensureNoQueryParamsInDev,
@@ -106,7 +107,7 @@ export class Client {
106
107
  }
107
108
 
108
109
  handleNotOk = async (response: Response | Error) => {
109
- if (!(response instanceof Response)) return Promise.reject(response)
110
+ if (!isResponseLike(response)) return Promise.reject(response)
110
111
 
111
112
  const errorData = await this.parseErrorResponse(response)
112
113
  const notOkResponse = response.clone() as FailedResponse
@@ -38,6 +38,7 @@ export const getConversationsRequestArgs = ({
38
38
  Conversation: [
39
39
  'created_at',
40
40
  'badges',
41
+ 'conversation_preview',
41
42
  'disabled',
42
43
  'disabled_reason',
43
44
  'groups',
@@ -55,6 +56,7 @@ export const getConversationsRequestArgs = ({
55
56
  'replies_disabled',
56
57
  'title',
57
58
  'unread_count',
59
+ 'unread_reaction',
58
60
  'updated_at',
59
61
  ],
60
62
  AnalyticsMetadata: ['metadata'],
@@ -16,8 +16,30 @@ export class ResponseError extends Error {
16
16
  }
17
17
  }
18
18
 
19
+ // Detect a fetch Response by shape, not `instanceof`: Expo's winter runtime
20
+ // swaps in a `fetch` whose `FetchResponse` isn't an instance of the global
21
+ // `Response`, so `instanceof` silently misses it under Expo.
22
+ export const isResponseLike = (value: unknown): value is Response => {
23
+ if (typeof value !== 'object' || value === null) return false
24
+
25
+ const candidate = value as Record<string, unknown>
26
+ return (
27
+ typeof candidate.status === 'number' &&
28
+ typeof candidate.statusText === 'string' &&
29
+ typeof candidate.ok === 'boolean' &&
30
+ typeof candidate.redirected === 'boolean' &&
31
+ typeof candidate.url === 'string' &&
32
+ typeof candidate.headers === 'object' &&
33
+ candidate.headers !== null &&
34
+ typeof candidate.clone === 'function' &&
35
+ typeof candidate.json === 'function' &&
36
+ typeof candidate.text === 'function' &&
37
+ typeof candidate.arrayBuffer === 'function'
38
+ )
39
+ }
40
+
19
41
  export const throwResponseError = (error: unknown) => {
20
- if (error instanceof Response) {
42
+ if (isResponseLike(error)) {
21
43
  throw new ResponseError(error as FailedResponse)
22
44
  }
23
45
 
@@ -1,4 +1,10 @@
1
- import { ColorSchemeName, ColorValue, OpaqueColorValue, PlatformColor } from 'react-native'
1
+ import {
2
+ ColorSchemeName,
3
+ ColorValue,
4
+ OpaqueColorValue,
5
+ Platform,
6
+ PlatformColor,
7
+ } from 'react-native'
2
8
  import { aliasTokensColorMap } from '../vendor/tapestry/alias_tokens_color_map'
3
9
  import { tokens } from '../vendor/tapestry/tokens'
4
10
 
@@ -92,10 +98,14 @@ interface ChatColors {
92
98
  statusNeutralComposedBackground: string
93
99
  }
94
100
 
101
+ // 'link' is an iOS-only semantic color name — constructing it on Android is
102
+ // undefined behavior, so only build it when the code is actually running on iOS
103
+ const iOSLinkColor = Platform.OS === 'ios' ? PlatformColor('link') : undefined
104
+
95
105
  const colorsChatLight: ChatColors = {
96
106
  name: 'light',
97
107
  androidModalHeaderButtonTextColor: tokens.fillColorInteractionDefault,
98
- iOSModalHeaderButtonTextColor: PlatformColor('link'),
108
+ iOSModalHeaderButtonTextColor: iOSLinkColor,
99
109
  buttonStart: undefined,
100
110
  buttonEnd: undefined,
101
111
  interaction: tokens.fillColorInteractionDefault,
@@ -138,7 +148,7 @@ const colorsChatLight: ChatColors = {
138
148
  const colorsChatDark: ChatColors = {
139
149
  name: 'dark',
140
150
  androidModalHeaderButtonTextColor: tokens.fillColorInteractionDefaultDark,
141
- iOSModalHeaderButtonTextColor: PlatformColor('link'),
151
+ iOSModalHeaderButtonTextColor: iOSLinkColor,
142
152
  buttonStart: undefined,
143
153
  buttonEnd: undefined,
144
154
  interaction: tokens.fillColorInteractionDefaultDark,