@planningcenter/chat-react-native 4.5.0-rc.3 → 4.5.0-rc.5

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.
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { StyleSheet, View } from 'react-native'
2
+ import { LayoutChangeEvent, StyleSheet, View } from 'react-native'
3
3
  import { useTheme } from '../../hooks'
4
4
  import {
5
5
  MAX_FONT_SIZE_MULTIPLIER_LANDMARK,
@@ -78,30 +78,62 @@ export function FilterDropdown({
78
78
 
79
79
  return (
80
80
  <MenuView actions={actions} onPressAction={handlePressAction} style={styles.container}>
81
- <View
82
- style={[styles.trigger, maxWidth !== undefined && { maxWidth }]}
81
+ <FilterDropdownTrigger
82
+ label={triggerLabel}
83
+ maxWidth={maxWidth}
83
84
  accessible
84
85
  accessibilityRole="button"
85
86
  accessibilityHint="Opens filter options"
86
- >
87
- <Text
88
- style={styles.triggerLabel}
89
- numberOfLines={1}
90
- maxFontSizeMultiplier={MAX_FONT_SIZE_MULTIPLIER_LANDMARK}
91
- >
92
- {triggerLabel}
93
- </Text>
94
- <Icon
95
- name="general.downChevron"
96
- style={styles.triggerIcon}
97
- maxFontSizeMultiplier={MAX_FONT_SIZE_MULTIPLIER_LANDMARK}
98
- accessibilityElementsHidden
99
- />
100
- </View>
87
+ />
101
88
  </MenuView>
102
89
  )
103
90
  }
104
91
 
92
+ // =================================
93
+ // ====== Trigger ==================
94
+ // =================================
95
+
96
+ export interface FilterDropdownTriggerProps {
97
+ label: string
98
+ maxWidth?: number
99
+ accessible?: boolean
100
+ accessibilityRole?: 'button'
101
+ accessibilityHint?: string
102
+ accessibilityElementsHidden?: boolean
103
+ importantForAccessibility?: 'no-hide-descendants'
104
+ onLayout?: (event: LayoutChangeEvent) => void
105
+ }
106
+
107
+ /**
108
+ * The label and chevron the dropdown opens from. Exported so a screen can render a hidden copy
109
+ * to measure how wide a label is at the current font scale.
110
+ */
111
+ export function FilterDropdownTrigger({
112
+ label,
113
+ maxWidth,
114
+ ...viewProps
115
+ }: FilterDropdownTriggerProps) {
116
+ const styles = useStyles()
117
+
118
+ return (
119
+ <View style={[styles.trigger, maxWidth !== undefined && { maxWidth }]} {...viewProps}>
120
+ <Text
121
+ style={styles.triggerLabel}
122
+ numberOfLines={1}
123
+ maxFontSizeMultiplier={MAX_FONT_SIZE_MULTIPLIER_LANDMARK}
124
+ >
125
+ {label}
126
+ </Text>
127
+ <Icon
128
+ name="general.downChevron"
129
+ style={styles.triggerIcon}
130
+ maxFontSizeMultiplier={MAX_FONT_SIZE_MULTIPLIER_LANDMARK}
131
+ accessibilityElementsHidden
132
+ />
133
+ </View>
134
+ )
135
+ }
136
+
105
137
  // =================================
106
138
  // ====== Styles ===================
107
139
  // =================================
@@ -0,0 +1,33 @@
1
+ import { QueryClientProvider } from '@tanstack/react-query'
2
+ import { act, renderHook } from '@testing-library/react-native'
3
+ import React from 'react'
4
+ import { buildTestQueryClient } from '../../__utils__/query_client'
5
+ import { ConversationResource } from '../../types'
6
+ import { getConversationsRequestArgs } from '../../utils/request/conversation'
7
+ import { useConversationsMarkRead } from '../use_conversations_actions'
8
+ import { getRequestQueryKey } from '../use_suspense_api'
9
+
10
+ jest.mock('../use_api_client', () => ({
11
+ useApiClient: () => ({
12
+ chat: { post: jest.fn().mockResolvedValue({}), get: jest.fn().mockResolvedValue({ data: [] }) },
13
+ }),
14
+ }))
15
+
16
+ describe('useConversationsMarkRead', () => {
17
+ it('marks the conversation read in a list outside the current conversations context', async () => {
18
+ const conversation = { id: 7, unreadCount: 8 } as ConversationResource
19
+ const archivedListKey = getRequestQueryKey(getConversationsRequestArgs({ show_archived: true }))
20
+ const queryClient = buildTestQueryClient({ defaultOptions: { queries: { gcTime: Infinity } } })
21
+ queryClient.setQueryData(archivedListKey, { pages: [{ data: [conversation] }], pageParams: [] })
22
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
23
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
24
+ )
25
+ const { result } = renderHook(() => useConversationsMarkRead({ conversation }), { wrapper })
26
+
27
+ await act(async () => result.current.markRead(true))
28
+
29
+ expect(queryClient.getQueryData(archivedListKey)).toMatchObject({
30
+ pages: [{ data: [{ id: 7, unreadCount: 0 }] }],
31
+ })
32
+ })
33
+ })
@@ -17,7 +17,7 @@ export const useConversationsMarkRead = ({
17
17
  const queryClient = useQueryClient()
18
18
  const { args } = useConversationsContext()
19
19
  const currentPersonCache = useCurrentPersonCache()
20
- const { update, invalidate, fetchUpdate } = useConversationsCache(args)
20
+ const { updateInAllLists, invalidateAllLists, fetchUpdate } = useConversationsCache(args)
21
21
  const conversationQueryKey = getRequestQueryKey(
22
22
  getConversationRequestArgs({ conversation_id: conversation.id })
23
23
  )
@@ -32,8 +32,7 @@ export const useConversationsMarkRead = ({
32
32
 
33
33
  const { mutate: handleMarkRead, ...mutation } = useMutation({
34
34
  onMutate: async (read: boolean) => {
35
- update({
36
- ...conversation,
35
+ updateInAllLists(conversation.id, {
37
36
  unreadCount: read ? 0 : 1,
38
37
  ...(read ? { unreadReaction: false } : {}),
39
38
  })
@@ -59,7 +58,7 @@ export const useConversationsMarkRead = ({
59
58
  },
60
59
  onError: () => {
61
60
  Alert.alert('Oops', 'Something went wrong updating this conversation, please try again')
62
- invalidate()
61
+ invalidateAllLists()
63
62
  },
64
63
  })
65
64
 
@@ -67,6 +67,20 @@ export function useConversationsCache(args?: Partial<ConversationRequestArgs>) {
67
67
  )
68
68
  }
69
69
 
70
+ const updateInAllLists = (id: number, update: Partial<ConversationResource>) => {
71
+ queryClient.setQueriesData<QueryData>({ queryKey: [conversationsRequestArgs.url] }, prev => {
72
+ if (!prev?.pages) return prev
73
+
74
+ return updateAllRecordsInPagesData({
75
+ data: prev,
76
+ processRecord: record => (record.id === id ? { ...record, ...update } : record),
77
+ })
78
+ })
79
+ }
80
+
81
+ const invalidateAllLists = () =>
82
+ queryClient.invalidateQueries({ queryKey: [conversationsRequestArgs.url] })
83
+
70
84
  const handleConversationDestroy = ({ id }: { id: number }) => {
71
85
  if (!id) return
72
86
 
@@ -106,7 +120,9 @@ export function useConversationsCache(args?: Partial<ConversationRequestArgs>) {
106
120
  fetchSync: fetchAndSync,
107
121
  fetchUpdate: fetchAndUpdate,
108
122
  invalidate: () => queryClient.invalidateQueries({ queryKey: conversationQueryKey }),
123
+ invalidateAllLists,
109
124
  update: updateOrCreate,
110
125
  updateAll,
126
+ updateInAllLists,
111
127
  }
112
128
  }
@@ -21,15 +21,6 @@ const mockSearchEnabled = jest.fn()
21
21
  const mockCanBrowseArchived = jest.fn()
22
22
  const mockGroupsByGraphId: Record<string, { name: string }> = {}
23
23
 
24
- jest.mock('react-native', () => {
25
- const actual = jest.requireActual('react-native')
26
- return Object.defineProperty(actual, 'useWindowDimensions', {
27
- value: jest.fn(() => ({ fontScale: 1 })),
28
- configurable: true,
29
- })
30
- })
31
- const mockUseWindowDimensions = jest.requireMock('react-native').useWindowDimensions
32
-
33
24
  jest.mock('@react-navigation/native', () => ({
34
25
  ...jest.requireActual('@react-navigation/native'),
35
26
  useNavigation: () => ({ setParams: mockSetParams, navigate: mockNavigate }),
@@ -130,46 +121,10 @@ describe('FilterDropdownListHeaderComponent', () => {
130
121
  mockSetParams.mockClear()
131
122
  mockSearchEnabled.mockReturnValue(true)
132
123
  mockCanBrowseArchived.mockReturnValue(false)
133
- mockUseWindowDimensions.mockReturnValue({ fontScale: 1 })
134
124
  setCapabilities({ groups: true, teams: true })
135
125
  setRouteParams({})
136
126
  })
137
127
 
138
- it('keeps the dropdown and actions on one line at the default font scale', () => {
139
- mockUseWindowDimensions.mockReturnValue({ fontScale: 1 })
140
-
141
- render(<FilterDropdownListHeaderComponent />)
142
-
143
- expect(screen.getByTestId('conversations-filter-heading-row')).toHaveStyle({
144
- flexDirection: 'row',
145
- })
146
- })
147
-
148
- it('stacks the actions under the dropdown at the first font size above the default', () => {
149
- mockUseWindowDimensions.mockReturnValue({ fontScale: 1.12 })
150
-
151
- render(<FilterDropdownListHeaderComponent />)
152
-
153
- expect(screen.getByTestId('conversations-filter-heading-row')).toHaveStyle({
154
- flexDirection: 'column',
155
- })
156
- })
157
-
158
- it('caps the trigger to the room beside the actions so a long label truncates', () => {
159
- render(<FilterDropdownListHeaderComponent />)
160
-
161
- fireEvent(screen.getByTestId('conversations-filter-heading-row'), 'layout', {
162
- nativeEvent: { layout: { width: 360 } },
163
- })
164
- fireEvent(screen.getByTestId('conversations-filter-actions'), 'layout', {
165
- nativeEvent: { layout: { width: 160 } },
166
- })
167
-
168
- expect(screen.getByRole('button', { name: 'All conversations' })).toHaveStyle({
169
- maxWidth: 156,
170
- })
171
- })
172
-
173
128
  it('offers every filter the person can use, then More filters', () => {
174
129
  render(<FilterDropdownListHeaderComponent />)
175
130
 
@@ -344,13 +299,4 @@ describe('FilterDropdownListHeaderComponent', () => {
344
299
 
345
300
  expect(mockNavigate).toHaveBeenCalledWith('Search')
346
301
  })
347
-
348
- it('renders the search button after the other actions', () => {
349
- const view = render(<FilterDropdownListHeaderComponent />)
350
-
351
- const buttons = view.getAllByRole('button').map(button => button.props.accessibilityLabel)
352
-
353
- expect(buttons.indexOf('Search')).toBeGreaterThan(buttons.indexOf('Chat settings'))
354
- expect(buttons.indexOf('Search')).toBeGreaterThan(buttons.indexOf('New conversation'))
355
- })
356
302
  })
@@ -1,13 +1,17 @@
1
1
  import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'
2
2
  import React, { useCallback, useMemo, useState } from 'react'
3
3
  import { Alert, LayoutChangeEvent, StyleSheet, View } from 'react-native'
4
- import { FilterDropdown, FilterDropdownOption, TextButton } from '../../../components'
4
+ import {
5
+ FilterDropdown,
6
+ FilterDropdownOption,
7
+ FilterDropdownTrigger,
8
+ TextButton,
9
+ } from '../../../components'
5
10
  import {
6
11
  useCanBrowseArchivedConversations,
7
12
  useCanCreateConversations,
8
13
  useCurrentPersonCache,
9
14
  useDirectMessagesEligible,
10
- useFontScale,
11
15
  useHasDirectMessages,
12
16
  useNewConversationEntry,
13
17
  useTheme,
@@ -26,7 +30,6 @@ import { ListHeaderActionButton } from './list_header_action_button'
26
30
 
27
31
  const ROW_PADDING_HORIZONTAL = 16
28
32
  const HEADING_ROW_GAP = 12
29
- const DEFAULT_FONT_SCALE = 1
30
33
  const ALL_CONVERSATIONS_LABEL = 'All conversations'
31
34
  const ARCHIVED_LABEL = 'Archived'
32
35
 
@@ -207,31 +210,43 @@ export const FilterDropdownListHeaderComponent = () => {
207
210
 
208
211
  const showMoreFilters = canFilterByGroups || canFilterByTeams || canBrowseArchived
209
212
 
210
- const stacked = useFontScale() > DEFAULT_FONT_SCALE
211
- const styles = useStyles({ stacked })
213
+ const styles = useStyles()
212
214
 
213
- // The Android menu host measures the trigger at its intrinsic width, so flex shrinking never
214
- // truncates the label. Cap it to the room left beside the action buttons instead.
215
+ // Cap the trigger beside the actions while the widest fixed label fits there, so only longer
216
+ // labels truncate; past that, cap it to the row and let the actions wrap.
215
217
  const [rowWidth, setRowWidth] = useState<number>()
216
218
  const [actionsWidth, setActionsWidth] = useState<number>()
219
+ const [widestFixedLabelWidth, setWidestFixedLabelWidth] = useState<number>()
217
220
  const handleRowLayout = useCallback((event: LayoutChangeEvent) => {
218
221
  setRowWidth(event.nativeEvent.layout.width)
219
222
  }, [])
220
223
  const handleActionsLayout = useCallback((event: LayoutChangeEvent) => {
221
224
  setActionsWidth(event.nativeEvent.layout.width)
222
225
  }, [])
223
- const triggerMaxWidth =
224
- !stacked && rowWidth !== undefined && actionsWidth !== undefined
225
- ? rowWidth - ROW_PADDING_HORIZONTAL * 2 - actionsWidth - HEADING_ROW_GAP
226
- : undefined
226
+ const handleWidestFixedLabelLayout = useCallback((event: LayoutChangeEvent) => {
227
+ setWidestFixedLabelWidth(event.nativeEvent.layout.width)
228
+ }, [])
229
+ const triggerMaxWidth = useMemo(() => {
230
+ if (rowWidth === undefined) return undefined
231
+
232
+ const rowContentWidth = rowWidth - ROW_PADDING_HORIZONTAL * 2
233
+ if (actionsWidth === undefined || widestFixedLabelWidth === undefined) return rowContentWidth
234
+
235
+ const roomBesideActions = rowContentWidth - actionsWidth - HEADING_ROW_GAP
236
+ return roomBesideActions >= widestFixedLabelWidth ? roomBesideActions : rowContentWidth
237
+ }, [rowWidth, actionsWidth, widestFixedLabelWidth])
227
238
 
228
239
  return (
229
240
  <View style={styles.listHeader}>
230
- <View
231
- style={styles.headingRow}
232
- onLayout={handleRowLayout}
233
- testID="conversations-filter-heading-row"
234
- >
241
+ <View style={styles.widestFixedLabel} pointerEvents="none">
242
+ <FilterDropdownTrigger
243
+ label={ALL_CONVERSATIONS_LABEL}
244
+ onLayout={handleWidestFixedLabelLayout}
245
+ accessibilityElementsHidden
246
+ importantForAccessibility="no-hide-descendants"
247
+ />
248
+ </View>
249
+ <View style={styles.headingRow} onLayout={handleRowLayout}>
235
250
  <View style={styles.titleContainer}>
236
251
  <FilterDropdown
237
252
  options={options}
@@ -243,11 +258,7 @@ export const FilterDropdownListHeaderComponent = () => {
243
258
  maxWidth={triggerMaxWidth}
244
259
  />
245
260
  </View>
246
- <View
247
- style={styles.actionsContainer}
248
- onLayout={handleActionsLayout}
249
- testID="conversations-filter-actions"
250
- >
261
+ <View style={styles.actionsContainer} onLayout={handleActionsLayout}>
251
262
  <ListHeaderActionButton
252
263
  name="general.cog"
253
264
  onPress={handleChatSettings}
@@ -292,7 +303,7 @@ export const FilterDropdownListHeaderComponent = () => {
292
303
  )
293
304
  }
294
305
 
295
- const useStyles = ({ stacked }: { stacked: boolean }) => {
306
+ const useStyles = () => {
296
307
  const theme = useTheme()
297
308
 
298
309
  return StyleSheet.create({
@@ -304,15 +315,18 @@ const useStyles = ({ stacked }: { stacked: boolean }) => {
304
315
  },
305
316
  headingRow: {
306
317
  gap: HEADING_ROW_GAP,
307
- flexDirection: stacked ? 'column' : 'row',
308
- alignItems: stacked ? 'flex-start' : 'center',
318
+ flexDirection: 'row',
319
+ flexWrap: 'wrap',
320
+ alignItems: 'center',
309
321
  justifyContent: 'space-between',
310
- paddingBottom: stacked ? 4 : 0,
311
322
  paddingHorizontal: ROW_PADDING_HORIZONTAL,
312
323
  },
313
324
  titleContainer: {
314
- flexShrink: 1,
315
- minWidth: 0,
325
+ flexShrink: 0,
326
+ },
327
+ widestFixedLabel: {
328
+ position: 'absolute',
329
+ opacity: 0,
316
330
  },
317
331
  actionsContainer: {
318
332
  flexDirection: 'row',