@planningcenter/chat-react-native 4.4.1-qa-staging.26 → 4.4.1-qa-staging.28

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 (79) hide show
  1. package/build/components/conversations/conversations.d.ts.map +1 -1
  2. package/build/components/conversations/conversations.js +3 -2
  3. package/build/components/conversations/conversations.js.map +1 -1
  4. package/build/components/display/filter_dropdown.d.ts.map +1 -1
  5. package/build/components/display/filter_dropdown.js +3 -1
  6. package/build/components/display/filter_dropdown.js.map +1 -1
  7. package/build/components/display/index.d.ts +1 -0
  8. package/build/components/display/index.d.ts.map +1 -1
  9. package/build/components/display/index.js +1 -0
  10. package/build/components/display/index.js.map +1 -1
  11. package/build/components/display/popover_menu_view.d.ts +8 -0
  12. package/build/components/display/popover_menu_view.d.ts.map +1 -0
  13. package/build/components/display/popover_menu_view.js +482 -0
  14. package/build/components/display/popover_menu_view.js.map +1 -0
  15. package/build/contexts/conversations_context.d.ts.map +1 -1
  16. package/build/contexts/conversations_context.js +20 -17
  17. package/build/contexts/conversations_context.js.map +1 -1
  18. package/build/hooks/use_clear_conversation_filters.d.ts.map +1 -1
  19. package/build/hooks/use_clear_conversation_filters.js +2 -1
  20. package/build/hooks/use_clear_conversation_filters.js.map +1 -1
  21. package/build/hooks/use_conversations_cache.d.ts +1 -1
  22. package/build/hooks/use_conversations_cache.d.ts.map +1 -1
  23. package/build/hooks/use_conversations_cache.js +64 -55
  24. package/build/hooks/use_conversations_cache.js.map +1 -1
  25. package/build/hooks/use_conversations_jolt_events.js +2 -2
  26. package/build/hooks/use_conversations_jolt_events.js.map +1 -1
  27. package/build/hooks/use_jolt.d.ts.map +1 -1
  28. package/build/hooks/use_jolt.js +37 -9
  29. package/build/hooks/use_jolt.js.map +1 -1
  30. package/build/screens/conversation_filters/context/conversation_filter_context.d.ts.map +1 -1
  31. package/build/screens/conversation_filters/context/conversation_filter_context.js +8 -4
  32. package/build/screens/conversation_filters/context/conversation_filter_context.js.map +1 -1
  33. package/build/screens/conversation_filters/screen_props.d.ts +1 -0
  34. package/build/screens/conversation_filters/screen_props.d.ts.map +1 -1
  35. package/build/screens/conversation_filters/screen_props.js.map +1 -1
  36. package/build/screens/conversations/components/filter_dropdown_list_header_component.d.ts.map +1 -1
  37. package/build/screens/conversations/components/filter_dropdown_list_header_component.js +199 -206
  38. package/build/screens/conversations/components/filter_dropdown_list_header_component.js.map +1 -1
  39. package/build/screens/conversations/conversations_screen.d.ts +1 -0
  40. package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
  41. package/build/screens/conversations/conversations_screen.js +1 -2
  42. package/build/screens/conversations/conversations_screen.js.map +1 -1
  43. package/build/screens/design_system_screen.d.ts.map +1 -1
  44. package/build/screens/design_system_screen.js +289 -201
  45. package/build/screens/design_system_screen.js.map +1 -1
  46. package/build/utils/native_adapters/menu.d.ts +1 -2
  47. package/build/utils/native_adapters/menu.d.ts.map +1 -1
  48. package/build/utils/native_adapters/menu.js +2 -41
  49. package/build/utils/native_adapters/menu.js.map +1 -1
  50. package/build/utils/request/conversation.d.ts +2 -1
  51. package/build/utils/request/conversation.d.ts.map +1 -1
  52. package/build/utils/request/conversation.js +4 -1
  53. package/build/utils/request/conversation.js.map +1 -1
  54. package/package.json +3 -3
  55. package/src/components/conversations/__tests__/conversations_blank_state.test.tsx +1 -0
  56. package/src/components/conversations/conversations.tsx +12 -2
  57. package/src/components/display/__tests__/filter_dropdown.test.tsx +18 -2
  58. package/src/components/display/__tests__/popover_menu_view.test.tsx +63 -0
  59. package/src/components/display/filter_dropdown.tsx +4 -2
  60. package/src/components/display/index.ts +1 -0
  61. package/src/components/display/popover_menu_view.tsx +239 -0
  62. package/src/contexts/conversations_context.tsx +8 -1
  63. package/src/hooks/__tests__/use_conversations_jolt_events.test.tsx +135 -0
  64. package/src/hooks/__tests__/use_jolt.test.tsx +76 -1
  65. package/src/hooks/use_clear_conversation_filters.ts +1 -0
  66. package/src/hooks/use_conversations_cache.ts +29 -24
  67. package/src/hooks/use_conversations_jolt_events.ts +2 -2
  68. package/src/hooks/use_jolt.ts +26 -2
  69. package/src/screens/conversation_filters/context/conversation_filter_context.tsx +4 -0
  70. package/src/screens/conversation_filters/screen_props.ts +1 -0
  71. package/src/screens/conversations/__tests__/conversations_screen.test.tsx +2 -12
  72. package/src/screens/conversations/components/__tests__/filter_dropdown_list_header_component.test.tsx +57 -38
  73. package/src/screens/conversations/components/filter_dropdown_list_header_component.tsx +25 -27
  74. package/src/screens/conversations/conversations_screen.tsx +2 -2
  75. package/src/screens/design_system_screen.tsx +34 -1
  76. package/src/utils/__tests__/conversation.test.ts +1 -0
  77. package/src/utils/native_adapters/menu.tsx +4 -18
  78. package/src/utils/request/conversation.ts +4 -0
  79. package/src/utils/native_adapters/__tests__/menu.test.tsx +0 -46
@@ -0,0 +1,135 @@
1
+ import { InfiniteData, QueryClient, 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 { ConversationFiltersParams } from '../../screens/conversation_filters/screen_props'
6
+ import type { ApiCollection, ConversationResource } from '../../types'
7
+ import { getConversationsRequestArgs } from '../../utils/request/conversation'
8
+ import { useConversationsJoltEvents } from '../use_conversations_jolt_events'
9
+ import { getRequestQueryKey } from '../use_suspense_api'
10
+
11
+ const handlers: Record<string, (event: unknown) => void> = {}
12
+ jest.mock('../use_jolt', () => ({
13
+ useJoltChannel: () => 'channel',
14
+ useJoltEvent: (_channel: unknown, name: string, handler: (event: unknown) => void) => {
15
+ handlers[name] = handler
16
+ },
17
+ }))
18
+ jest.mock('../use_api', () => ({ useApiGet: () => ({ data: { id: 1 } }) }))
19
+ jest.mock('../use_current_person', () => ({
20
+ currentPersonRequestArgs: {},
21
+ useCurrentPersonCache: () => ({ invalidate: jest.fn() }),
22
+ }))
23
+
24
+ const mockGet = jest.fn()
25
+ jest.mock('../use_api_client', () => ({ useApiClient: () => ({ chat: { get: mockGet } }) }))
26
+
27
+ let mockContextArgs: ConversationFiltersParams = {}
28
+ jest.mock('../../contexts/conversations_context', () => ({
29
+ useConversationsContext: () => ({ args: mockContextArgs }),
30
+ }))
31
+
32
+ type QueryData = InfiniteData<ApiCollection<ConversationResource>>
33
+
34
+ const conversationId = 7
35
+ let queryClient: QueryClient
36
+
37
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
38
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
39
+ )
40
+
41
+ const queryKey = () => getRequestQueryKey(getConversationsRequestArgs(mockContextArgs))
42
+
43
+ const seedList = (conversations: Partial<ConversationResource>[] = []) =>
44
+ queryClient.setQueryData<QueryData>(queryKey(), {
45
+ pages: [
46
+ {
47
+ data: conversations,
48
+ links: {},
49
+ meta: {},
50
+ } as unknown as ApiCollection<ConversationResource>,
51
+ ],
52
+ pageParams: [undefined],
53
+ })
54
+
55
+ const cachedIds = () =>
56
+ queryClient
57
+ .getQueryData<QueryData>(queryKey())!
58
+ .pages.flatMap(page => page.data.map(conversation => conversation.id))
59
+
60
+ const serverReturns = (conversations: Partial<ConversationResource>[]) =>
61
+ mockGet.mockResolvedValue({
62
+ data: conversations.map(conversation => ({
63
+ type: 'Conversation',
64
+ id: conversationId,
65
+ title: 'Youth',
66
+ ...conversation,
67
+ })),
68
+ })
69
+
70
+ const conversationUpdated = () =>
71
+ handlers['conversation.updated']({
72
+ event: 'conversation.updated',
73
+ data: { data: { id: conversationId, last_message_author_id: 2 } },
74
+ })
75
+
76
+ const renderWithFilter = (args: ConversationFiltersParams) => {
77
+ mockContextArgs = args
78
+ queryClient = buildTestQueryClient({ defaultOptions: { queries: { gcTime: Infinity } } })
79
+ renderHook(() => useConversationsJoltEvents(), { wrapper })
80
+ }
81
+
82
+ beforeEach(() => mockGet.mockReset())
83
+
84
+ describe('useConversationsJoltEvents on conversation.updated', () => {
85
+ it('asks the server for the conversation under the active filter', async () => {
86
+ renderWithFilter({ show_unread: true })
87
+ seedList()
88
+ serverReturns([])
89
+
90
+ conversationUpdated()
91
+
92
+ await waitFor(() => expect(mockGet).toHaveBeenCalled())
93
+ expect(mockGet).toHaveBeenCalledWith(
94
+ expect.objectContaining({
95
+ url: '/me/conversations',
96
+ data: expect.objectContaining({
97
+ filter: 'mine_or_not_empty,unread',
98
+ where: { id: conversationId },
99
+ perPage: 1,
100
+ }),
101
+ })
102
+ )
103
+ })
104
+
105
+ it('adds a conversation the server returns for the list', async () => {
106
+ renderWithFilter({ show_unread: true })
107
+ seedList()
108
+ serverReturns([{ unreadCount: 1 }])
109
+
110
+ conversationUpdated()
111
+
112
+ await waitFor(() => expect(cachedIds()).toEqual([conversationId]))
113
+ })
114
+
115
+ it('removes a conversation the server leaves out of the list', async () => {
116
+ renderWithFilter({ show_unread: true })
117
+ seedList([{ id: conversationId, unreadCount: 1 }])
118
+ serverReturns([])
119
+
120
+ conversationUpdated()
121
+
122
+ await waitFor(() => expect(cachedIds()).toEqual([]))
123
+ })
124
+
125
+ it('keeps the list as it was when the fetch fails', async () => {
126
+ renderWithFilter({ show_unread: true })
127
+ seedList([{ id: conversationId, unreadCount: 1 }])
128
+ mockGet.mockRejectedValue(new Error('offline'))
129
+
130
+ conversationUpdated()
131
+
132
+ await waitFor(() => expect(mockGet).toHaveBeenCalled())
133
+ expect(cachedIds()).toEqual([conversationId])
134
+ })
135
+ })
@@ -9,7 +9,7 @@ import { ChatContext, ChatContextValue } from '../../contexts/chat_context'
9
9
  import { Session } from '../../utils'
10
10
  import { Client } from '../../utils/client'
11
11
  import { ResponseError } from '../../utils/response_error'
12
- import { useJoltClient, useJoltForegroundPing } from '../use_jolt'
12
+ import { useJoltChannel, useJoltClient, useJoltEvent, useJoltForegroundPing } from '../use_jolt'
13
13
 
14
14
  let caught: unknown
15
15
 
@@ -167,3 +167,78 @@ describe('useJoltForegroundPing', () => {
167
167
  expect(ping).toHaveBeenCalledTimes(1)
168
168
  })
169
169
  })
170
+
171
+ describe('useJoltChannel', () => {
172
+ type Listener = (_event: unknown) => void
173
+ const channelName = 'chat.people.1'
174
+
175
+ const buildJolt = () => {
176
+ const listeners: Listener[] = []
177
+ const subscription = {
178
+ channel: channelName,
179
+ bind: jest.fn((_event: string, listener: Listener) => {
180
+ listeners.push(listener)
181
+ return () => listeners.splice(listeners.indexOf(listener), 1)
182
+ }),
183
+ }
184
+ const jolt = {
185
+ subscriptions: [] as (typeof subscription)[],
186
+ subscribe: jest.fn(() => {
187
+ jolt.subscriptions.push(subscription)
188
+ return subscription
189
+ }),
190
+ // The real client unbinds every listener on the subscription when it unsubscribes.
191
+ unsubscribe: jest.fn(() => {
192
+ listeners.length = 0
193
+ jolt.subscriptions = []
194
+ }),
195
+ }
196
+
197
+ return { jolt, emit: (event: unknown) => listeners.forEach(listener => listener(event)) }
198
+ }
199
+
200
+ const ChannelProbe = ({ onEvent }: { onEvent: Listener }) => {
201
+ const channel = useJoltChannel(channelName)
202
+ useJoltEvent(channel, 'conversation.updated', onEvent)
203
+ return null
204
+ }
205
+
206
+ const renderConsumers = (jolt: unknown, onEvent: Listener) => {
207
+ const session = new Session({ token: { access_token: 'a-real-token' } })
208
+ const queryClient = buildTestQueryClient({ defaultOptions: { queries: { gcTime: Infinity } } })
209
+ queryClient.setQueryData(['wss-url'], 'wss://jolt.test')
210
+ queryClient.setQueryData(['jolt-client', 'wss://jolt.test', 'a-real-token'], jolt)
211
+
212
+ const tree = (count: number) => (
213
+ <QueryClientProvider client={queryClient}>
214
+ <ChatContext.Provider value={{ session } as unknown as ChatContextValue}>
215
+ <Suspense fallback={null}>
216
+ {Array.from({ length: count }, (_, index) => (
217
+ <ChannelProbe key={index} onEvent={onEvent} />
218
+ ))}
219
+ </Suspense>
220
+ </ChatContext.Provider>
221
+ </QueryClientProvider>
222
+ )
223
+ const result = render(tree(2))
224
+
225
+ return { setConsumers: (count: number) => result.rerender(tree(count)) }
226
+ }
227
+
228
+ it('keeps delivering events to the remaining consumer after another unmounts', async () => {
229
+ const { jolt, emit } = buildJolt()
230
+ const onEvent = jest.fn()
231
+ const { setConsumers } = renderConsumers(jolt, onEvent)
232
+ await waitFor(() => expect(jolt.subscribe).toHaveBeenCalledTimes(1))
233
+
234
+ setConsumers(1)
235
+ emit({ event: 'conversation.updated' })
236
+
237
+ expect(jolt.unsubscribe).not.toHaveBeenCalled()
238
+ expect(onEvent).toHaveBeenCalledTimes(1)
239
+
240
+ setConsumers(0)
241
+
242
+ expect(jolt.unsubscribe).toHaveBeenCalledWith(channelName)
243
+ })
244
+ })
@@ -11,6 +11,7 @@ export function useClearConversationFilters() {
11
11
  group_source_app_name: undefined,
12
12
  show_direct_messages: undefined,
13
13
  show_archived: undefined,
14
+ show_unread: undefined,
14
15
  })
15
16
  }, [navigation])
16
17
  }
@@ -1,5 +1,5 @@
1
1
  import { InfiniteData, useQueryClient } from '@tanstack/react-query'
2
- import { ApiCollection, ApiResource, ConversationResource } from '../types'
2
+ import { ApiCollection, ConversationResource } from '../types'
3
3
  import {
4
4
  deleteRecordInPagesData,
5
5
  updateRecordInPagesData,
@@ -21,16 +21,13 @@ export function useConversationsCache(args?: Partial<ConversationRequestArgs>) {
21
21
  const fetchConversation = async (id: number) => {
22
22
  if (!id) return
23
23
 
24
- const { data: argsData } = conversationsRequestArgs
25
- const { data } = await apiClient.chat.get<ApiResource<ConversationResource>>({
26
- url: `/me/conversations/${id}`,
27
- data: {
28
- fields: argsData.fields,
29
- include: argsData.include,
30
- },
24
+ const { url, data } = conversationsRequestArgs
25
+ const { data: conversations } = await apiClient.chat.get<ApiCollection<ConversationResource>>({
26
+ url,
27
+ data: { ...data, where: { ...data.where, id }, perPage: 1 },
31
28
  })
32
29
 
33
- return data
30
+ return conversations[0]
34
31
  }
35
32
 
36
33
  const updateOrCreate = async (conversation: ConversationResource) => {
@@ -70,20 +67,6 @@ export function useConversationsCache(args?: Partial<ConversationRequestArgs>) {
70
67
  )
71
68
  }
72
69
 
73
- const fetchAndUpdate = async ({ id }: { id: number }) => {
74
- const conversation: ConversationResource = await fetchConversation(id).catch(c => c)
75
- if (!conversation.id) return
76
-
77
- updateOnly(conversation)
78
- }
79
-
80
- const fetchAndUpdateOrCreate = async ({ id }: { id: number }) => {
81
- const conversation: ConversationResource = await fetchConversation(id).catch(c => c)
82
- if (!conversation.id) return
83
-
84
- updateOrCreate(conversation)
85
- }
86
-
87
70
  const handleConversationDestroy = ({ id }: { id: number }) => {
88
71
  if (!id) return
89
72
 
@@ -95,10 +78,32 @@ export function useConversationsCache(args?: Partial<ConversationRequestArgs>) {
95
78
  )
96
79
  }
97
80
 
81
+ const fetchAndUpdate = async ({ id }: { id: number }) => {
82
+ const conversation = await fetchConversation(id).catch(() => undefined)
83
+ if (!conversation) return
84
+
85
+ updateOnly(conversation)
86
+ }
87
+
88
+ const fetchAndSync = async ({ id }: { id: number }) => {
89
+ let conversation: ConversationResource | undefined
90
+ try {
91
+ conversation = await fetchConversation(id)
92
+ } catch {
93
+ return
94
+ }
95
+
96
+ if (conversation) {
97
+ updateOrCreate(conversation)
98
+ } else {
99
+ handleConversationDestroy({ id })
100
+ }
101
+ }
102
+
98
103
  return {
99
104
  create: updateOrCreate,
100
105
  destroy: handleConversationDestroy,
101
- fetchCreate: fetchAndUpdateOrCreate,
106
+ fetchSync: fetchAndSync,
102
107
  fetchUpdate: fetchAndUpdate,
103
108
  invalidate: () => queryClient.invalidateQueries({ queryKey: conversationQueryKey }),
104
109
  update: updateOrCreate,
@@ -31,10 +31,10 @@ export function useConversationsJoltEvents() {
31
31
  idempotentKey: e.data.data.last_message_idempotent_key,
32
32
  })
33
33
  }
34
- cache.fetchUpdate({ id: e.data.data.id })
34
+ cache.fetchSync({ id: e.data.data.id })
35
35
  })
36
36
  useJoltEvent(joltChannel, 'conversation.created', (e: JoltConversationEvent) =>
37
- cache.fetchCreate({ id: e.data.data.id })
37
+ cache.fetchSync({ id: e.data.data.id })
38
38
  )
39
39
  useJoltEvent(joltChannel, 'conversation.destroyed', (e: JoltConversationEvent) =>
40
40
  cache.destroy({ id: e.data.data.id })
@@ -181,6 +181,25 @@ export const useJoltForegroundPing = () => {
181
181
  }, [queryClient])
182
182
  }
183
183
 
184
+ const channelConsumers = new WeakMap<JoltClient, Map<string, number>>()
185
+
186
+ const retainChannel = (jolt: JoltClient, channelName: string) => {
187
+ const counts = channelConsumers.get(jolt) ?? new Map<string, number>()
188
+ channelConsumers.set(jolt, counts)
189
+ counts.set(channelName, (counts.get(channelName) ?? 0) + 1)
190
+ }
191
+
192
+ const releaseChannel = (jolt: JoltClient, channelName: string) => {
193
+ const counts = channelConsumers.get(jolt)
194
+ const remaining = (counts?.get(channelName) ?? 1) - 1
195
+ if (remaining > 0) {
196
+ counts?.set(channelName, remaining)
197
+ } else {
198
+ counts?.delete(channelName)
199
+ }
200
+ return remaining
201
+ }
202
+
184
203
  export function useJoltChannel(
185
204
  channelName: string,
186
205
  enabled: boolean = true
@@ -216,8 +235,13 @@ export function useJoltChannel(
216
235
  }, [queryClient, channelName, jolt])
217
236
 
218
237
  useEffect(() => {
219
- return handleUnsubscribe
220
- }, [handleUnsubscribe])
238
+ if (!jolt) return
239
+
240
+ retainChannel(jolt, channelName)
241
+ return () => {
242
+ if (releaseChannel(jolt, channelName) === 0) handleUnsubscribe()
243
+ }
244
+ }, [jolt, channelName, handleUnsubscribe])
221
245
 
222
246
  return subscription
223
247
  }
@@ -23,6 +23,7 @@ export const FilterContext = createContext<{
23
23
  group_source_app_name: undefined,
24
24
  show_direct_messages: undefined,
25
25
  show_archived: undefined,
26
+ show_unread: undefined,
26
27
  },
27
28
  scrollOffset: 0,
28
29
  setScrollOffset: () => {},
@@ -59,6 +60,7 @@ export const FilterProvider = ({ children }: PropsWithChildren) => {
59
60
  group_source_app_name: undefined,
60
61
  show_direct_messages: undefined,
61
62
  show_archived: undefined,
63
+ show_unread: undefined,
62
64
  })
63
65
  )
64
66
  },
@@ -68,6 +70,7 @@ export const FilterProvider = ({ children }: PropsWithChildren) => {
68
70
  group_source_app_name: undefined,
69
71
  show_direct_messages: undefined,
70
72
  show_archived: undefined,
73
+ show_unread: undefined,
71
74
  })
72
75
  },
73
76
  setAppFilter: (params: Omit<ConversationFiltersParams, 'chat_group_graph_id'>) => {
@@ -76,6 +79,7 @@ export const FilterProvider = ({ children }: PropsWithChildren) => {
76
79
  group_source_app_name: params.group_source_app_name,
77
80
  show_direct_messages: params.show_direct_messages,
78
81
  show_archived: params.show_archived,
82
+ show_unread: undefined,
79
83
  })
80
84
  },
81
85
  applyFilters: (params: ConversationFiltersParams) => {
@@ -6,6 +6,7 @@ export type ConversationFiltersParams = {
6
6
  group_source_app_name?: string
7
7
  show_direct_messages?: boolean
8
8
  show_archived?: boolean
9
+ show_unread?: boolean
9
10
  }
10
11
 
11
12
  export type ConversationFiltersScreenProps = StaticScreenProps<ConversationFiltersParams>
@@ -7,7 +7,6 @@ import {
7
7
  ChatAdapters,
8
8
  ClipboardAdapter,
9
9
  ImagePickerAdapter,
10
- MenuAdapter,
11
10
  VideoAdapter,
12
11
  } from '../../../utils/native_adapters'
13
12
  import { ConversationsScreen } from '../conversations_screen'
@@ -41,7 +40,7 @@ jest.mock('../components/filter_dropdown_list_header_component', () => {
41
40
  return { FilterDropdownListHeaderComponent: () => <Text>Filter dropdown header</Text> }
42
41
  })
43
42
 
44
- const configureAdapters = ({ menu }: { menu: boolean }) => {
43
+ const configureAdapters = () => {
45
44
  ChatAdapters.configure({
46
45
  clipboard: new ClipboardAdapter({
47
46
  getStringAsync: async () => '',
@@ -53,7 +52,6 @@ const configureAdapters = ({ menu }: { menu: boolean }) => {
53
52
  openCameraAsync: async () => ({ canceled: true, assets: null }),
54
53
  openImageLibraryAsync: async () => ({ canceled: true, assets: null }),
55
54
  }),
56
- ...(menu ? { menu: new MenuAdapter({ View: () => null }) } : {}),
57
55
  })
58
56
  }
59
57
 
@@ -80,7 +78,7 @@ const renderWithFeatures = (enabledFeatures: string[]) => {
80
78
 
81
79
  describe('ConversationsScreen header gating', () => {
82
80
  beforeEach(() => {
83
- configureAdapters({ menu: true })
81
+ configureAdapters()
84
82
  })
85
83
 
86
84
  it('shows the search header with only the QA flag on', async () => {
@@ -107,14 +105,6 @@ describe('ConversationsScreen header gating', () => {
107
105
  expect(await view.findByText('Filter dropdown header')).toBeTruthy()
108
106
  })
109
107
 
110
- it('falls back to the search header when the host has no menu adapter', async () => {
111
- configureAdapters({ menu: false })
112
-
113
- const view = renderWithFeatures(['QA_MOBILE_filter_dropdown', 'ROLLOUT_MOBILE_search'])
114
-
115
- expect(await view.findByText('Search header')).toBeTruthy()
116
- })
117
-
118
108
  it('shows the default header with neither flag on', async () => {
119
109
  const view = renderWithFeatures(['ROLLOUT_WEB_search'])
120
110
 
@@ -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,50 +121,26 @@ 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', () => {
128
+ it('caps the trigger to the row so a long label truncates instead of overflowing', () => {
159
129
  render(<FilterDropdownListHeaderComponent />)
160
130
 
161
131
  fireEvent(screen.getByTestId('conversations-filter-heading-row'), 'layout', {
162
132
  nativeEvent: { layout: { width: 360 } },
163
133
  })
164
- fireEvent(screen.getByTestId('conversations-filter-actions'), 'layout', {
165
- nativeEvent: { layout: { width: 160 } },
166
- })
167
134
 
168
135
  expect(screen.getByRole('button', { name: 'All conversations' })).toHaveStyle({
169
- maxWidth: 156,
136
+ maxWidth: 328,
170
137
  })
171
138
  })
172
139
 
173
140
  it('offers every filter the person can use, then More filters', () => {
174
141
  render(<FilterDropdownListHeaderComponent />)
175
142
 
176
- expect(menuItemNames()).toEqual(['All', 'Groups', 'Teams', 'DMs', 'More filters'])
143
+ expect(menuItemNames()).toEqual(['All', 'Groups', 'Teams', 'DMs', 'Unread', 'More filters'])
177
144
  expect(screen.getByRole('button', { name: 'All conversations' })).toBeTruthy()
178
145
  })
179
146
 
@@ -182,7 +149,7 @@ describe('FilterDropdownListHeaderComponent', () => {
182
149
 
183
150
  render(<FilterDropdownListHeaderComponent />)
184
151
 
185
- expect(menuItemNames()).toEqual(['All', 'DMs'])
152
+ expect(menuItemNames()).toEqual(['All', 'DMs', 'Unread'])
186
153
  })
187
154
 
188
155
  it('selecting Groups filters to group conversations', () => {
@@ -195,6 +162,45 @@ describe('FilterDropdownListHeaderComponent', () => {
195
162
  )
196
163
  })
197
164
 
165
+ it('selecting Unread filters to unread conversations', () => {
166
+ setRouteParams({ group_source_app_name: 'Groups' })
167
+
168
+ render(<FilterDropdownListHeaderComponent />)
169
+
170
+ fireEvent.press(screen.getByRole('menuitem', { name: 'Unread' }))
171
+
172
+ expect(mockSetParams).toHaveBeenCalledWith({
173
+ title: undefined,
174
+ chat_group_graph_id: undefined,
175
+ group_source_app_name: undefined,
176
+ show_direct_messages: undefined,
177
+ show_unread: true,
178
+ })
179
+ })
180
+
181
+ it('selecting another filter clears Unread', () => {
182
+ setRouteParams({ show_unread: true })
183
+
184
+ render(<FilterDropdownListHeaderComponent />)
185
+
186
+ fireEvent.press(screen.getByRole('menuitem', { name: 'DMs' }))
187
+
188
+ expect(mockSetParams).toHaveBeenCalledWith(
189
+ expect.objectContaining({ show_direct_messages: true, show_unread: undefined })
190
+ )
191
+ })
192
+
193
+ it('labels the trigger Unread and checks it when filtered to unread', () => {
194
+ setRouteParams({ show_unread: true })
195
+
196
+ render(<FilterDropdownListHeaderComponent />)
197
+
198
+ expect(screen.getByRole('button', { name: 'Unread' })).toBeTruthy()
199
+ expect(screen.getByRole('menuitem', { name: 'Unread' }).props.accessibilityState.selected).toBe(
200
+ true
201
+ )
202
+ })
203
+
198
204
  it('labels the trigger with the active filter and checks it', () => {
199
205
  setRouteParams({ group_source_app_name: 'Services' })
200
206
 
@@ -230,7 +236,7 @@ describe('FilterDropdownListHeaderComponent', () => {
230
236
 
231
237
  render(<FilterDropdownListHeaderComponent />)
232
238
 
233
- expect(menuItemNames()).toEqual(['All', 'DMs', 'More filters'])
239
+ expect(menuItemNames()).toEqual(['All', 'DMs', 'Unread', 'More filters'])
234
240
  })
235
241
 
236
242
  it('labels the archived list and carries it into More filters', () => {
@@ -279,6 +285,19 @@ describe('FilterDropdownListHeaderComponent', () => {
279
285
  expect(mockNavigate).toHaveBeenCalledWith('ConversationFilters', expect.any(Object))
280
286
  })
281
287
 
288
+ it('More filters carries the Unread filter into the sheet', () => {
289
+ setRouteParams({ show_unread: true })
290
+
291
+ render(<FilterDropdownListHeaderComponent />)
292
+
293
+ fireEvent.press(screen.getByRole('menuitem', { name: 'More filters' }))
294
+
295
+ expect(mockNavigate).toHaveBeenCalledWith(
296
+ 'ConversationFilters',
297
+ expect.objectContaining({ show_unread: true })
298
+ )
299
+ })
300
+
282
301
  it('shows the search button only when search is enabled', () => {
283
302
  mockSearchEnabled.mockReturnValue(false)
284
303
  const view = render(<FilterDropdownListHeaderComponent />)