@planningcenter/chat-react-native 4.4.1-qa-staging.12 → 4.4.1-qa-staging.14

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 (127) hide show
  1. package/build/components/conversation/jump_to_bottom_button.d.ts.map +1 -1
  2. package/build/components/conversation/jump_to_bottom_button.js +42 -40
  3. package/build/components/conversation/jump_to_bottom_button.js.map +1 -1
  4. package/build/components/conversation/message.d.ts +2 -1
  5. package/build/components/conversation/message.d.ts.map +1 -1
  6. package/build/components/conversation/message.js +3 -1
  7. package/build/components/conversation/message.js.map +1 -1
  8. package/build/components/conversation/message_list.d.ts.map +1 -1
  9. package/build/components/conversation/message_list.js +35 -31
  10. package/build/components/conversation/message_list.js.map +1 -1
  11. package/build/components/conversation/message_reaction.d.ts +2 -1
  12. package/build/components/conversation/message_reaction.d.ts.map +1 -1
  13. package/build/components/conversation/message_reaction.js +67 -60
  14. package/build/components/conversation/message_reaction.js.map +1 -1
  15. package/build/components/conversation/messages_disabled_banners.d.ts +7 -5
  16. package/build/components/conversation/messages_disabled_banners.d.ts.map +1 -1
  17. package/build/components/conversation/messages_disabled_banners.js +15 -14
  18. package/build/components/conversation/messages_disabled_banners.js.map +1 -1
  19. package/build/components/conversation/system_message.d.ts +2 -1
  20. package/build/components/conversation/system_message.d.ts.map +1 -1
  21. package/build/components/conversation/system_message.js +87 -65
  22. package/build/components/conversation/system_message.js.map +1 -1
  23. package/build/components/display/action_button.d.ts.map +1 -1
  24. package/build/components/display/action_button.js +18 -16
  25. package/build/components/display/action_button.js.map +1 -1
  26. package/build/components/display/filter_dropdown.d.ts +13 -0
  27. package/build/components/display/filter_dropdown.d.ts.map +1 -0
  28. package/build/components/display/filter_dropdown.js +188 -0
  29. package/build/components/display/filter_dropdown.js.map +1 -0
  30. package/build/components/display/index.d.ts +1 -0
  31. package/build/components/display/index.d.ts.map +1 -1
  32. package/build/components/display/index.js +1 -0
  33. package/build/components/display/index.js.map +1 -1
  34. package/build/hooks/index.d.ts +1 -0
  35. package/build/hooks/index.d.ts.map +1 -1
  36. package/build/hooks/index.js +1 -0
  37. package/build/hooks/index.js.map +1 -1
  38. package/build/hooks/use_animated_message_background_color.d.ts.map +1 -1
  39. package/build/hooks/use_animated_message_background_color.js +5 -3
  40. package/build/hooks/use_animated_message_background_color.js.map +1 -1
  41. package/build/hooks/use_conversation.d.ts +73 -1
  42. package/build/hooks/use_conversation.d.ts.map +1 -1
  43. package/build/hooks/use_conversation.js +122 -3
  44. package/build/hooks/use_conversation.js.map +1 -1
  45. package/build/hooks/use_reduce_motion.d.ts +2 -0
  46. package/build/hooks/use_reduce_motion.d.ts.map +1 -0
  47. package/build/hooks/use_reduce_motion.js +27 -0
  48. package/build/hooks/use_reduce_motion.js.map +1 -0
  49. package/build/hooks/use_unarchive_conversation_action.d.ts +7 -0
  50. package/build/hooks/use_unarchive_conversation_action.d.ts.map +1 -0
  51. package/build/hooks/use_unarchive_conversation_action.js +52 -0
  52. package/build/hooks/use_unarchive_conversation_action.js.map +1 -0
  53. package/build/screens/attachment_actions/attachment_actions_screen.d.ts.map +1 -1
  54. package/build/screens/attachment_actions/attachment_actions_screen.js +84 -61
  55. package/build/screens/attachment_actions/attachment_actions_screen.js.map +1 -1
  56. package/build/screens/conversation_details_screen.d.ts.map +1 -1
  57. package/build/screens/conversation_details_screen.js +487 -393
  58. package/build/screens/conversation_details_screen.js.map +1 -1
  59. package/build/screens/conversation_screen.d.ts.map +1 -1
  60. package/build/screens/conversation_screen.js +12 -9
  61. package/build/screens/conversation_screen.js.map +1 -1
  62. package/build/screens/design_system_screen.d.ts.map +1 -1
  63. package/build/screens/design_system_screen.js +275 -215
  64. package/build/screens/design_system_screen.js.map +1 -1
  65. package/build/screens/message_actions_screen.d.ts.map +1 -1
  66. package/build/screens/message_actions_screen.js +112 -92
  67. package/build/screens/message_actions_screen.js.map +1 -1
  68. package/build/types/resources/member_ability.d.ts +2 -0
  69. package/build/types/resources/member_ability.d.ts.map +1 -1
  70. package/build/types/resources/member_ability.js.map +1 -1
  71. package/build/utils/archived_conversation.d.ts +5 -0
  72. package/build/utils/archived_conversation.d.ts.map +1 -0
  73. package/build/utils/archived_conversation.js +5 -0
  74. package/build/utils/archived_conversation.js.map +1 -0
  75. package/build/utils/index.d.ts +1 -0
  76. package/build/utils/index.d.ts.map +1 -1
  77. package/build/utils/index.js +1 -0
  78. package/build/utils/index.js.map +1 -1
  79. package/build/utils/native_adapters/configuration.d.ts +3 -0
  80. package/build/utils/native_adapters/configuration.d.ts.map +1 -1
  81. package/build/utils/native_adapters/configuration.js +3 -0
  82. package/build/utils/native_adapters/configuration.js.map +1 -1
  83. package/build/utils/native_adapters/index.d.ts +1 -0
  84. package/build/utils/native_adapters/index.d.ts.map +1 -1
  85. package/build/utils/native_adapters/index.js +1 -0
  86. package/build/utils/native_adapters/index.js.map +1 -1
  87. package/build/utils/native_adapters/menu.d.ts +40 -0
  88. package/build/utils/native_adapters/menu.d.ts.map +1 -0
  89. package/build/utils/native_adapters/menu.js +55 -0
  90. package/build/utils/native_adapters/menu.js.map +1 -0
  91. package/package.json +3 -3
  92. package/src/__tests__/screens/message_actions_screen.test.tsx +65 -2
  93. package/src/components/conversation/__tests__/message_list.test.tsx +68 -2
  94. package/src/components/conversation/__tests__/system_message.test.tsx +3 -1
  95. package/src/components/conversation/jump_to_bottom_button.tsx +4 -3
  96. package/src/components/conversation/message.tsx +3 -0
  97. package/src/components/conversation/message_list.tsx +9 -1
  98. package/src/components/conversation/message_reaction.tsx +7 -2
  99. package/src/components/conversation/messages_disabled_banners.tsx +17 -18
  100. package/src/components/conversation/system_message.tsx +24 -10
  101. package/src/components/display/__tests__/filter_dropdown.test.tsx +106 -0
  102. package/src/components/display/action_button.tsx +4 -3
  103. package/src/components/display/filter_dropdown.tsx +123 -0
  104. package/src/components/display/index.ts +1 -0
  105. package/src/hooks/__tests__/use_conversation_archive.test.tsx +130 -0
  106. package/src/hooks/__tests__/use_conversation_jolt_events.test.tsx +74 -0
  107. package/src/hooks/__tests__/use_reduce_motion.test.tsx +49 -0
  108. package/src/hooks/__tests__/use_unarchive_conversation_action.test.tsx +67 -0
  109. package/src/hooks/index.ts +1 -0
  110. package/src/hooks/use_animated_message_background_color.ts +6 -3
  111. package/src/hooks/use_conversation.ts +41 -3
  112. package/src/hooks/use_reduce_motion.ts +18 -0
  113. package/src/hooks/use_unarchive_conversation_action.ts +24 -0
  114. package/src/screens/__tests__/conversation_details_screen.test.tsx +289 -0
  115. package/src/screens/attachment_actions/__tests__/attachment_actions_screen.test.tsx +72 -0
  116. package/src/screens/attachment_actions/attachment_actions_screen.tsx +3 -1
  117. package/src/screens/conversation_details_screen.tsx +78 -14
  118. package/src/screens/conversation_screen.tsx +3 -1
  119. package/src/screens/design_system_screen.tsx +26 -0
  120. package/src/screens/message_actions_screen.tsx +29 -20
  121. package/src/types/resources/member_ability.ts +2 -0
  122. package/src/utils/archived_conversation.ts +13 -0
  123. package/src/utils/index.ts +1 -0
  124. package/src/utils/native_adapters/__tests__/menu.test.tsx +42 -0
  125. package/src/utils/native_adapters/configuration.ts +5 -0
  126. package/src/utils/native_adapters/index.ts +1 -0
  127. package/src/utils/native_adapters/menu.tsx +68 -0
@@ -0,0 +1,106 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react-native'
2
+ import React from 'react'
3
+ import { Pressable, Text } from 'react-native'
4
+ import {
5
+ AudioAdapter,
6
+ ChatAdapters,
7
+ ClipboardAdapter,
8
+ ImagePickerAdapter,
9
+ MenuAction,
10
+ MenuAdapter,
11
+ MenuViewProps,
12
+ VideoAdapter,
13
+ } from '../../../utils/native_adapters'
14
+ import { FilterDropdown } from '../filter_dropdown'
15
+
16
+ // Stands in for the host's native menu: every action, inline sections
17
+ // included, becomes a pressable menu item.
18
+ const FakeMenuView = ({ actions, onPressAction, children }: MenuViewProps) => {
19
+ const flatten = (items: MenuAction[]): MenuAction[] =>
20
+ items.flatMap(item => (item.displayInline && item.subactions ? item.subactions : [item]))
21
+
22
+ return (
23
+ <>
24
+ {children}
25
+ {flatten(actions).map(action => (
26
+ <Pressable
27
+ key={action.id}
28
+ accessibilityRole="menuitem"
29
+ accessibilityState={{ selected: action.state === 'on' }}
30
+ onPress={() => onPressAction?.({ nativeEvent: { event: action.id ?? action.title } })}
31
+ >
32
+ <Text>{action.title}</Text>
33
+ </Pressable>
34
+ ))}
35
+ </>
36
+ )
37
+ }
38
+
39
+ beforeAll(() => {
40
+ ChatAdapters.configure({
41
+ clipboard: new ClipboardAdapter({
42
+ getStringAsync: async () => '',
43
+ setStringAsync: async () => {},
44
+ }),
45
+ audio: new AudioAdapter({ useAudio: () => ({}) as any }),
46
+ video: new VideoAdapter({ Player: (() => null) as any }),
47
+ imagePicker: new ImagePickerAdapter({
48
+ openCameraAsync: async () => ({ canceled: true, assets: null }),
49
+ openImageLibraryAsync: async () => ({ canceled: true, assets: null }),
50
+ }),
51
+ menu: new MenuAdapter({ View: FakeMenuView }),
52
+ })
53
+ })
54
+
55
+ const options = [
56
+ { id: 'all', label: 'All' },
57
+ { id: 'groups', label: 'Groups' },
58
+ { id: 'teams', label: 'Teams' },
59
+ ]
60
+
61
+ const renderFilterDropdown = () => {
62
+ const onSelect = jest.fn()
63
+ const onMoreFilters = jest.fn()
64
+
65
+ render(
66
+ <FilterDropdown
67
+ options={options}
68
+ selectedId="groups"
69
+ onSelect={onSelect}
70
+ onMoreFilters={onMoreFilters}
71
+ triggerLabel="Groups"
72
+ />
73
+ )
74
+
75
+ return { onSelect, onMoreFilters }
76
+ }
77
+
78
+ describe('FilterDropdown', () => {
79
+ it('hands the menu every option, the selected one checked, then More filters', () => {
80
+ renderFilterDropdown()
81
+
82
+ expect(screen.getByRole('button', { name: 'Groups' })).toBeTruthy()
83
+ expect(
84
+ screen.getAllByRole('menuitem').map(item => [item.props.accessibilityState.selected])
85
+ ).toEqual([[false], [true], [false], [false]])
86
+ expect(screen.getByRole('menuitem', { name: 'More filters' })).toBeTruthy()
87
+ })
88
+
89
+ it('calls onSelect with the id of the pressed option', () => {
90
+ const { onSelect, onMoreFilters } = renderFilterDropdown()
91
+
92
+ fireEvent.press(screen.getByRole('menuitem', { name: 'Teams' }))
93
+
94
+ expect(onSelect).toHaveBeenCalledWith('teams')
95
+ expect(onMoreFilters).not.toHaveBeenCalled()
96
+ })
97
+
98
+ it('calls onMoreFilters for the More filters action', () => {
99
+ const { onSelect, onMoreFilters } = renderFilterDropdown()
100
+
101
+ fireEvent.press(screen.getByRole('menuitem', { name: 'More filters' }))
102
+
103
+ expect(onMoreFilters).toHaveBeenCalledTimes(1)
104
+ expect(onSelect).not.toHaveBeenCalled()
105
+ })
106
+ })
@@ -7,7 +7,7 @@ import Animated, {
7
7
  ReduceMotion,
8
8
  } from 'react-native-reanimated'
9
9
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
10
- import { useTheme } from '../../hooks'
10
+ import { useReduceMotion, useTheme } from '../../hooks'
11
11
  import { ConversationWarning } from '../../types'
12
12
  import { MAX_FONT_SIZE_MULTIPLIER_LANDMARK } from '../../utils'
13
13
  import { Banner } from './banner'
@@ -37,13 +37,14 @@ export const ActionButton = ({
37
37
  warnings?: ConversationWarning[]
38
38
  }) => {
39
39
  const styles = useStyles()
40
+ const reduceMotion = useReduceMotion() ? ReduceMotion.Always : ReduceMotion.Never
40
41
 
41
42
  return (
42
43
  <LayoutAnimationConfig skipEntering>
43
44
  {visible && (
44
45
  <Animated.View
45
- entering={FadeIn.duration(300).reduceMotion(ReduceMotion.System)}
46
- exiting={FadeOut.duration(300).reduceMotion(ReduceMotion.System)}
46
+ entering={FadeIn.duration(300).reduceMotion(reduceMotion)}
47
+ exiting={FadeOut.duration(300).reduceMotion(reduceMotion)}
47
48
  style={styles.container}
48
49
  >
49
50
  {warnings.length > 0 && (
@@ -0,0 +1,123 @@
1
+ import React from 'react'
2
+ import { StyleSheet, View } from 'react-native'
3
+ import { useTheme } from '../../hooks'
4
+ import {
5
+ MAX_FONT_SIZE_MULTIPLIER_LANDMARK,
6
+ Menu,
7
+ MenuAction,
8
+ MenuActionEvent,
9
+ platformFontWeightBold,
10
+ } from '../../utils'
11
+ import { tokens } from '../../vendor/tapestry/tokens'
12
+ import { Icon } from './icon'
13
+ import { Text } from './text'
14
+
15
+ // =================================
16
+ // ====== Constants ================
17
+ // =================================
18
+
19
+ const MORE_FILTERS_ID = 'more_filters'
20
+ const MORE_FILTERS_LABEL = 'More filters'
21
+
22
+ // =================================
23
+ // ====== Component ================
24
+ // =================================
25
+
26
+ export interface FilterDropdownOption {
27
+ id: string
28
+ label: string
29
+ }
30
+
31
+ export interface FilterDropdownProps {
32
+ options: FilterDropdownOption[]
33
+ selectedId: string
34
+ onSelect: (id: string) => void
35
+ onMoreFilters: () => void
36
+ triggerLabel: string
37
+ }
38
+
39
+ export function FilterDropdown({
40
+ options,
41
+ selectedId,
42
+ onSelect,
43
+ onMoreFilters,
44
+ triggerLabel,
45
+ }: FilterDropdownProps) {
46
+ const styles = useStyles()
47
+
48
+ const actions: MenuAction[] = [
49
+ ...options.map(option => ({
50
+ id: option.id,
51
+ title: option.label,
52
+ state: option.id === selectedId ? ('on' as const) : ('off' as const),
53
+ })),
54
+ {
55
+ id: MORE_FILTERS_ID,
56
+ title: '',
57
+ displayInline: true,
58
+ subactions: [{ id: MORE_FILTERS_ID, title: MORE_FILTERS_LABEL }],
59
+ },
60
+ ]
61
+
62
+ const handlePressAction = ({ nativeEvent }: MenuActionEvent) => {
63
+ if (nativeEvent.event === MORE_FILTERS_ID) {
64
+ onMoreFilters()
65
+ } else {
66
+ onSelect(nativeEvent.event)
67
+ }
68
+ }
69
+
70
+ return (
71
+ <Menu.View actions={actions} onPressAction={handlePressAction} style={styles.container}>
72
+ <View
73
+ style={styles.trigger}
74
+ accessible
75
+ accessibilityRole="button"
76
+ accessibilityHint="Opens filter options"
77
+ >
78
+ <Text
79
+ style={styles.triggerLabel}
80
+ numberOfLines={1}
81
+ maxFontSizeMultiplier={MAX_FONT_SIZE_MULTIPLIER_LANDMARK}
82
+ >
83
+ {triggerLabel}
84
+ </Text>
85
+ <Icon
86
+ name="general.downChevron"
87
+ style={styles.triggerIcon}
88
+ maxFontSizeMultiplier={MAX_FONT_SIZE_MULTIPLIER_LANDMARK}
89
+ accessibilityElementsHidden
90
+ />
91
+ </View>
92
+ </Menu.View>
93
+ )
94
+ }
95
+
96
+ // =================================
97
+ // ====== Styles ===================
98
+ // =================================
99
+
100
+ const useStyles = () => {
101
+ const { colors } = useTheme()
102
+
103
+ return StyleSheet.create({
104
+ container: {
105
+ alignSelf: 'flex-start',
106
+ },
107
+ trigger: {
108
+ flexDirection: 'row',
109
+ alignItems: 'center',
110
+ gap: 6,
111
+ paddingVertical: 4,
112
+ },
113
+ triggerLabel: {
114
+ flexShrink: 1,
115
+ fontWeight: platformFontWeightBold,
116
+ color: colors.textColorDefaultPrimary,
117
+ },
118
+ triggerIcon: {
119
+ fontSize: tokens.fontSizeSm,
120
+ color: colors.iconColorDefaultPrimary,
121
+ },
122
+ })
123
+ }
@@ -23,6 +23,7 @@ export * from './text_button'
23
23
  export * from './text_inline_button'
24
24
  export * from './text'
25
25
  export * from './toggle_button'
26
+ export * from './filter_dropdown'
26
27
  export * from './keyboard_scroll_view'
27
28
  export * from './keyboard_view'
28
29
  export * from './platform_modal_header_buttons'
@@ -0,0 +1,130 @@
1
+ import { QueryClientProvider, useQuery } from '@tanstack/react-query'
2
+ import { act, renderHook, waitFor } from '@testing-library/react-native'
3
+ import React from 'react'
4
+ import { Alert } from 'react-native'
5
+ import { buildTestQueryClient } from '../../__utils__/query_client'
6
+ import { getConversationsRequestArgs } from '../../utils/request/conversation'
7
+ import { getConversationRequestArgs, useConversationArchive } from '../use_conversation'
8
+ import { getRequestQueryKey } from '../use_suspense_api'
9
+
10
+ const mockPost = jest.fn()
11
+ jest.mock('../use_api_client', () => ({
12
+ useApiClient: () => ({ chat: { post: mockPost } }),
13
+ }))
14
+
15
+ const conversation_id = 7
16
+ const conversationKey = getRequestQueryKey(getConversationRequestArgs({ conversation_id }))
17
+ const conversationsKey = getRequestQueryKey(getConversationsRequestArgs({ gids: 'all' }))
18
+
19
+ const buildQueryClient = () =>
20
+ buildTestQueryClient({ defaultOptions: { queries: { gcTime: Infinity } } })
21
+
22
+ let queryClient = buildQueryClient()
23
+
24
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
25
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
26
+ )
27
+
28
+ const isInvalidated = (queryKey: unknown[]) =>
29
+ queryClient.getQueryCache().find({ queryKey, exact: true })?.state.isInvalidated
30
+
31
+ describe('useConversationArchive', () => {
32
+ beforeEach(() => {
33
+ queryClient = buildQueryClient()
34
+ queryClient.setQueryData(conversationKey, { data: { id: conversation_id } })
35
+ queryClient.setQueryData(conversationsKey, { data: [] })
36
+ mockPost.mockReset()
37
+ jest.spyOn(Alert, 'alert').mockImplementation(() => {})
38
+ })
39
+
40
+ afterEach(() => {
41
+ jest.restoreAllMocks()
42
+ })
43
+
44
+ it.each([
45
+ [true, 'archive'],
46
+ [false, 'unarchive'],
47
+ ])(
48
+ 'posts archived=%s to the %s action, then invalidates the conversation and list',
49
+ async (archived, action) => {
50
+ mockPost.mockResolvedValue({ data: { id: conversation_id } })
51
+ const { result } = renderHook(() => useConversationArchive({ conversation_id }), { wrapper })
52
+
53
+ await act(() => result.current.mutateAsync(archived))
54
+
55
+ expect(mockPost).toHaveBeenCalledWith({
56
+ url: `/me/conversations/7/${action}`,
57
+ data: { data: { type: 'Conversation', attributes: {} } },
58
+ })
59
+ expect(isInvalidated(conversationKey)).toBe(true)
60
+ expect(isInvalidated(conversationsKey)).toBe(true)
61
+ }
62
+ )
63
+
64
+ it('stays pending until the conversation refetch finishes', async () => {
65
+ mockPost.mockResolvedValue({ data: { id: conversation_id } })
66
+ let finishRefetch = () => {}
67
+ const { result } = renderHook(
68
+ () => {
69
+ useQuery({
70
+ queryKey: conversationKey,
71
+ queryFn: () =>
72
+ new Promise(resolve => {
73
+ finishRefetch = () => resolve({ data: { id: conversation_id } })
74
+ }),
75
+ staleTime: Infinity,
76
+ })
77
+ return useConversationArchive({ conversation_id })
78
+ },
79
+ { wrapper }
80
+ )
81
+
82
+ let archiving = Promise.resolve()
83
+ await act(async () => {
84
+ archiving = result.current.mutateAsync(true).then(() => {})
85
+ })
86
+
87
+ await waitFor(() => expect(result.current.isPending).toBe(true))
88
+
89
+ await act(async () => {
90
+ finishRefetch()
91
+ await archiving
92
+ })
93
+
94
+ await waitFor(() => expect(result.current.isPending).toBe(false))
95
+ })
96
+
97
+ it("doesn't wait on the list refetch", async () => {
98
+ mockPost.mockResolvedValue({ data: { id: conversation_id } })
99
+ const { result } = renderHook(
100
+ () => {
101
+ useQuery({
102
+ queryKey: conversationsKey,
103
+ queryFn: () => new Promise(() => {}),
104
+ staleTime: Infinity,
105
+ })
106
+ return useConversationArchive({ conversation_id })
107
+ },
108
+ { wrapper }
109
+ )
110
+
111
+ await act(() => result.current.mutateAsync(true))
112
+
113
+ expect(result.current.isPending).toBe(false)
114
+ })
115
+
116
+ it.each([
117
+ [true, 'archive'],
118
+ [false, 'unarchive'],
119
+ ])('alerts when archived=%s fails', async (archived, action) => {
120
+ mockPost.mockRejectedValue(new Error('nope'))
121
+ const { result } = renderHook(() => useConversationArchive({ conversation_id }), { wrapper })
122
+
123
+ await act(() => result.current.mutateAsync(archived).catch(() => {}))
124
+
125
+ expect(Alert.alert).toHaveBeenCalledWith(
126
+ 'Oops',
127
+ `Couldn't ${action} this conversation. Please try again.`
128
+ )
129
+ })
130
+ })
@@ -0,0 +1,74 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
+ import { renderHook } from '@testing-library/react-native'
3
+ import React from 'react'
4
+ import { buildTestQueryClient } from '../../__utils__/query_client'
5
+ import type { ApiResource, ConversationResource } from '../../types'
6
+ import { getConversationRequestArgs } from '../use_conversation'
7
+ import { useConversationJoltEvents } from '../use_conversation_jolt_events'
8
+ import { getRequestQueryKey } from '../use_suspense_api'
9
+
10
+ const handlers: Record<string, (event: unknown) => void> = {}
11
+ jest.mock('../use_jolt', () => ({
12
+ useJoltChannel: () => 'channel',
13
+ useJoltEvent: (_channel: unknown, name: string, handler: (event: unknown) => void) => {
14
+ handlers[name] = handler
15
+ },
16
+ }))
17
+ jest.mock('../use_current_person', () => ({ useCurrentPerson: () => ({ id: 1 }) }))
18
+ jest.mock('../use_api_client', () => ({ useApiClient: () => ({ chat: {} }) }))
19
+
20
+ const conversationId = 7
21
+ const queryKey = getRequestQueryKey(getConversationRequestArgs({ conversation_id: conversationId }))
22
+ let queryClient: QueryClient
23
+
24
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
25
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
26
+ )
27
+
28
+ const archivedAttributes = {
29
+ disabled: true,
30
+ disabled_reason: 'archived',
31
+ disabled_message: 'This conversation is archived.',
32
+ }
33
+ const activeAttributes = { disabled: false, disabled_reason: null, disabled_message: null }
34
+
35
+ const seed = (conversation: Partial<ConversationResource>) =>
36
+ queryClient.setQueryData<ApiResource<ConversationResource>>(queryKey, {
37
+ data: { type: 'Conversation', id: conversationId, title: 'Youth', ...conversation },
38
+ } as ApiResource<ConversationResource>)
39
+
40
+ const cached = () => queryClient.getQueryData<ApiResource<ConversationResource>>(queryKey)!.data
41
+
42
+ const conversationUpdated = (attributes: Record<string, unknown>) =>
43
+ handlers['conversation.updated']({
44
+ event: 'conversation.updated',
45
+ data: { data: { id: conversationId, ...attributes } },
46
+ })
47
+
48
+ beforeEach(() => {
49
+ queryClient = buildTestQueryClient()
50
+ renderHook(() => useConversationJoltEvents({ conversationId }), { wrapper })
51
+ })
52
+
53
+ describe('useConversationJoltEvents on conversation.updated', () => {
54
+ it('switches the cached conversation into the archived state', () => {
55
+ seed({ disabled: false, disabledReason: null, disabledMessage: null })
56
+
57
+ conversationUpdated(archivedAttributes)
58
+
59
+ expect(cached()).toMatchObject({
60
+ title: 'Youth',
61
+ disabled: true,
62
+ disabledReason: 'archived',
63
+ disabledMessage: 'This conversation is archived.',
64
+ })
65
+ })
66
+
67
+ it('switches the cached conversation back out of the archived state', () => {
68
+ seed({ disabled: true, disabledReason: 'archived', disabledMessage: 'Archived' })
69
+
70
+ conversationUpdated(activeAttributes)
71
+
72
+ expect(cached()).toMatchObject({ disabled: false, disabledReason: null, disabledMessage: null })
73
+ })
74
+ })
@@ -0,0 +1,49 @@
1
+ import { act, renderHook, waitFor } from '@testing-library/react-native'
2
+ import { AccessibilityInfo, EmitterSubscription } from 'react-native'
3
+ import { useReduceMotion } from '../use_reduce_motion'
4
+
5
+ type ReduceMotionListener = (enabled: boolean) => void
6
+
7
+ describe('useReduceMotion', () => {
8
+ let listener: ReduceMotionListener | undefined
9
+ const remove = jest.fn()
10
+
11
+ beforeEach(() => {
12
+ listener = undefined
13
+ remove.mockClear()
14
+ jest.spyOn(AccessibilityInfo, 'isReduceMotionEnabled').mockResolvedValue(true)
15
+ const addEventListener: typeof AccessibilityInfo.addEventListener = (_event, handler) => {
16
+ listener = handler as ReduceMotionListener
17
+ return { remove } as unknown as EmitterSubscription
18
+ }
19
+ jest.spyOn(AccessibilityInfo, 'addEventListener').mockImplementation(addEventListener)
20
+ })
21
+
22
+ afterEach(() => jest.restoreAllMocks())
23
+
24
+ it('seeds from the system setting', async () => {
25
+ const { result } = renderHook(() => useReduceMotion())
26
+
27
+ expect(result.current).toBe(false)
28
+ await waitFor(() => expect(result.current).toBe(true))
29
+ })
30
+
31
+ it('follows reduceMotionChanged while mounted', async () => {
32
+ const { result } = renderHook(() => useReduceMotion())
33
+ await waitFor(() => expect(result.current).toBe(true))
34
+
35
+ act(() => listener?.(false))
36
+ expect(result.current).toBe(false)
37
+
38
+ act(() => listener?.(true))
39
+ expect(result.current).toBe(true)
40
+ })
41
+
42
+ it('removes the listener on unmount', () => {
43
+ const { unmount } = renderHook(() => useReduceMotion())
44
+
45
+ expect(remove).not.toHaveBeenCalled()
46
+ unmount()
47
+ expect(remove).toHaveBeenCalledTimes(1)
48
+ })
49
+ })
@@ -0,0 +1,67 @@
1
+ import { act, renderHook } from '@testing-library/react-native'
2
+ import type { ConversationResource } from '../../types'
3
+ import { availableFeatures } from '../use_features'
4
+ import { useUnarchiveConversationAction } from '../use_unarchive_conversation_action'
5
+
6
+ const mockSetArchived = jest.fn()
7
+ jest.mock('../use_conversation', () => ({
8
+ useConversationArchive: () => ({ setArchived: mockSetArchived, isPending: false }),
9
+ }))
10
+
11
+ let mockEnabledFeatures: string[] = []
12
+ jest.mock('../use_features', () => ({
13
+ ...jest.requireActual('../use_features'),
14
+ useFeatures: () => ({
15
+ features: [],
16
+ featureEnabled: (name: string) => mockEnabledFeatures.includes(name),
17
+ }),
18
+ }))
19
+
20
+ const conversation = ({
21
+ disabledReason = 'archived',
22
+ canUnarchive = true,
23
+ }: {
24
+ disabledReason?: string | null
25
+ canUnarchive?: boolean
26
+ } = {}) =>
27
+ ({
28
+ type: 'Conversation',
29
+ id: 7,
30
+ disabled: !!disabledReason,
31
+ disabledReason,
32
+ memberAbility: { type: 'MemberAbility', canUnarchive },
33
+ }) as unknown as ConversationResource
34
+
35
+ const renderAction = (resource: ConversationResource) =>
36
+ renderHook(() => useUnarchiveConversationAction(resource)).result.current
37
+
38
+ describe('useUnarchiveConversationAction', () => {
39
+ beforeEach(() => {
40
+ mockEnabledFeatures = [availableFeatures.manual_archiving_rollout]
41
+ jest.clearAllMocks()
42
+ })
43
+
44
+ it('unarchives an archived conversation the person can unarchive', () => {
45
+ const action = renderAction(conversation())
46
+
47
+ expect(action?.label).toBe('Unarchive')
48
+
49
+ act(() => action?.onPress())
50
+
51
+ expect(mockSetArchived).toHaveBeenCalledWith(false)
52
+ })
53
+
54
+ it.each([
55
+ ['neither mobile flag is on', () => (mockEnabledFeatures = []), conversation()],
56
+ ['the person cannot unarchive', () => {}, conversation({ canUnarchive: false })],
57
+ [
58
+ 'the conversation is disabled for another reason',
59
+ () => {},
60
+ conversation({ disabledReason: 'safety_check' }),
61
+ ],
62
+ ])('offers nothing when %s', (_case, setup, resource) => {
63
+ setup()
64
+
65
+ expect(renderAction(resource)).toBeUndefined()
66
+ })
67
+ })
@@ -23,6 +23,7 @@ export * from './use_people_person'
23
23
  export * from './use_preview_avatar_diameter'
24
24
  export * from './use_product_analytics'
25
25
  export * from './use_qualified_by_age'
26
+ export * from './use_reduce_motion'
26
27
  export * from './use_scalable_number_of_lines'
27
28
  export * from './use_submit_age_check'
28
29
  export * from './use_suspense_api'
@@ -10,6 +10,7 @@ import {
10
10
  Easing,
11
11
  ReduceMotion,
12
12
  } from 'react-native-reanimated'
13
+ import { useReduceMotion } from './use_reduce_motion'
13
14
  import { useTheme } from './use_theme'
14
15
 
15
16
  const FLASH_IN_MS = 150
@@ -18,10 +19,12 @@ const FLASH_OUT_MS = 1500
18
19
 
19
20
  export function useAnimatedMessageBackgroundColor() {
20
21
  const { colors } = useTheme()
22
+ const reduceMotion = useReduceMotion()
21
23
  const pressProgress = useSharedValue(0)
22
24
  const flashProgress = useSharedValue(0)
23
25
 
24
- const tintColor = colors.fillColorNeutral050Base
26
+ const tintColor =
27
+ colors.name === 'dark' ? colors.fillColorNeutral040 : colors.fillColorNeutral050Base
25
28
  const tintsOnPress = Platform.OS === 'ios'
26
29
 
27
30
  const animatedBackgroundColor = useAnimatedStyle(() => {
@@ -53,12 +56,12 @@ export function useAnimatedMessageBackgroundColor() {
53
56
  withTiming(0, {
54
57
  duration: FLASH_OUT_MS,
55
58
  easing: Easing.out(Easing.ease),
56
- reduceMotion: ReduceMotion.System,
59
+ reduceMotion: reduceMotion ? ReduceMotion.Always : ReduceMotion.Never,
57
60
  }),
58
61
  ReduceMotion.Never
59
62
  )
60
63
  )
61
- }, [flashProgress])
64
+ }, [flashProgress, reduceMotion])
62
65
 
63
66
  return {
64
67
  animatedBackgroundColor,