@planningcenter/chat-react-native 4.4.1-qa-staging.25 → 4.4.1-qa-staging.27

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 (80) 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 +6 -1
  5. package/build/components/display/filter_dropdown.d.ts.map +1 -1
  6. package/build/components/display/filter_dropdown.js +57 -35
  7. package/build/components/display/filter_dropdown.js.map +1 -1
  8. package/build/components/display/index.d.ts +1 -0
  9. package/build/components/display/index.d.ts.map +1 -1
  10. package/build/components/display/index.js +1 -0
  11. package/build/components/display/index.js.map +1 -1
  12. package/build/components/display/popover_menu_view.d.ts +8 -0
  13. package/build/components/display/popover_menu_view.d.ts.map +1 -0
  14. package/build/components/display/popover_menu_view.js +482 -0
  15. package/build/components/display/popover_menu_view.js.map +1 -0
  16. package/build/contexts/conversations_context.d.ts.map +1 -1
  17. package/build/contexts/conversations_context.js +20 -17
  18. package/build/contexts/conversations_context.js.map +1 -1
  19. package/build/hooks/use_clear_conversation_filters.d.ts.map +1 -1
  20. package/build/hooks/use_clear_conversation_filters.js +2 -1
  21. package/build/hooks/use_clear_conversation_filters.js.map +1 -1
  22. package/build/hooks/use_conversations_cache.d.ts +1 -1
  23. package/build/hooks/use_conversations_cache.d.ts.map +1 -1
  24. package/build/hooks/use_conversations_cache.js +64 -55
  25. package/build/hooks/use_conversations_cache.js.map +1 -1
  26. package/build/hooks/use_conversations_jolt_events.js +2 -2
  27. package/build/hooks/use_conversations_jolt_events.js.map +1 -1
  28. package/build/hooks/use_jolt.d.ts.map +1 -1
  29. package/build/hooks/use_jolt.js +37 -9
  30. package/build/hooks/use_jolt.js.map +1 -1
  31. package/build/screens/conversation_filters/context/conversation_filter_context.d.ts.map +1 -1
  32. package/build/screens/conversation_filters/context/conversation_filter_context.js +8 -4
  33. package/build/screens/conversation_filters/context/conversation_filter_context.js.map +1 -1
  34. package/build/screens/conversation_filters/screen_props.d.ts +1 -0
  35. package/build/screens/conversation_filters/screen_props.d.ts.map +1 -1
  36. package/build/screens/conversation_filters/screen_props.js.map +1 -1
  37. package/build/screens/conversations/components/filter_dropdown_list_header_component.d.ts.map +1 -1
  38. package/build/screens/conversations/components/filter_dropdown_list_header_component.js +251 -185
  39. package/build/screens/conversations/components/filter_dropdown_list_header_component.js.map +1 -1
  40. package/build/screens/conversations/conversations_screen.d.ts +1 -0
  41. package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
  42. package/build/screens/conversations/conversations_screen.js +1 -2
  43. package/build/screens/conversations/conversations_screen.js.map +1 -1
  44. package/build/screens/design_system_screen.d.ts.map +1 -1
  45. package/build/screens/design_system_screen.js +289 -201
  46. package/build/screens/design_system_screen.js.map +1 -1
  47. package/build/utils/native_adapters/menu.d.ts +1 -2
  48. package/build/utils/native_adapters/menu.d.ts.map +1 -1
  49. package/build/utils/native_adapters/menu.js +2 -41
  50. package/build/utils/native_adapters/menu.js.map +1 -1
  51. package/build/utils/request/conversation.d.ts +2 -1
  52. package/build/utils/request/conversation.d.ts.map +1 -1
  53. package/build/utils/request/conversation.js +4 -1
  54. package/build/utils/request/conversation.js.map +1 -1
  55. package/package.json +3 -3
  56. package/src/components/conversations/__tests__/conversations_blank_state.test.tsx +1 -0
  57. package/src/components/conversations/conversations.tsx +12 -2
  58. package/src/components/display/__tests__/filter_dropdown.test.tsx +18 -2
  59. package/src/components/display/__tests__/popover_menu_view.test.tsx +63 -0
  60. package/src/components/display/filter_dropdown.tsx +11 -3
  61. package/src/components/display/index.ts +1 -0
  62. package/src/components/display/popover_menu_view.tsx +239 -0
  63. package/src/contexts/conversations_context.tsx +8 -1
  64. package/src/hooks/__tests__/use_conversations_jolt_events.test.tsx +135 -0
  65. package/src/hooks/__tests__/use_jolt.test.tsx +76 -1
  66. package/src/hooks/use_clear_conversation_filters.ts +1 -0
  67. package/src/hooks/use_conversations_cache.ts +29 -24
  68. package/src/hooks/use_conversations_jolt_events.ts +2 -2
  69. package/src/hooks/use_jolt.ts +26 -2
  70. package/src/screens/conversation_filters/context/conversation_filter_context.tsx +4 -0
  71. package/src/screens/conversation_filters/screen_props.ts +1 -0
  72. package/src/screens/conversations/__tests__/conversations_screen.test.tsx +2 -12
  73. package/src/screens/conversations/components/__tests__/filter_dropdown_list_header_component.test.tsx +74 -7
  74. package/src/screens/conversations/components/filter_dropdown_list_header_component.tsx +55 -14
  75. package/src/screens/conversations/conversations_screen.tsx +2 -2
  76. package/src/screens/design_system_screen.tsx +34 -1
  77. package/src/utils/__tests__/conversation.test.ts +1 -0
  78. package/src/utils/native_adapters/menu.tsx +4 -18
  79. package/src/utils/request/conversation.ts +4 -0
  80. 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
 
@@ -135,8 +135,8 @@ describe('FilterDropdownListHeaderComponent', () => {
135
135
  setRouteParams({})
136
136
  })
137
137
 
138
- it('keeps the dropdown and actions on one line below the header font cap', () => {
139
- mockUseWindowDimensions.mockReturnValue({ fontScale: 1.15 })
138
+ it('keeps the dropdown and actions on one line at the default font scale', () => {
139
+ mockUseWindowDimensions.mockReturnValue({ fontScale: 1 })
140
140
 
141
141
  render(<FilterDropdownListHeaderComponent />)
142
142
 
@@ -145,8 +145,8 @@ describe('FilterDropdownListHeaderComponent', () => {
145
145
  })
146
146
  })
147
147
 
148
- it('stacks the actions under the dropdown once header text stops scaling', () => {
149
- mockUseWindowDimensions.mockReturnValue({ fontScale: 1.2 })
148
+ it('stacks the actions under the dropdown at the first font size above the default', () => {
149
+ mockUseWindowDimensions.mockReturnValue({ fontScale: 1.12 })
150
150
 
151
151
  render(<FilterDropdownListHeaderComponent />)
152
152
 
@@ -155,10 +155,25 @@ describe('FilterDropdownListHeaderComponent', () => {
155
155
  })
156
156
  })
157
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
+
158
173
  it('offers every filter the person can use, then More filters', () => {
159
174
  render(<FilterDropdownListHeaderComponent />)
160
175
 
161
- expect(menuItemNames()).toEqual(['All', 'Groups', 'Teams', 'DMs', 'More filters'])
176
+ expect(menuItemNames()).toEqual(['All', 'Groups', 'Teams', 'DMs', 'Unread', 'More filters'])
162
177
  expect(screen.getByRole('button', { name: 'All conversations' })).toBeTruthy()
163
178
  })
164
179
 
@@ -167,7 +182,7 @@ describe('FilterDropdownListHeaderComponent', () => {
167
182
 
168
183
  render(<FilterDropdownListHeaderComponent />)
169
184
 
170
- expect(menuItemNames()).toEqual(['All', 'DMs'])
185
+ expect(menuItemNames()).toEqual(['All', 'DMs', 'Unread'])
171
186
  })
172
187
 
173
188
  it('selecting Groups filters to group conversations', () => {
@@ -180,6 +195,45 @@ describe('FilterDropdownListHeaderComponent', () => {
180
195
  )
181
196
  })
182
197
 
198
+ it('selecting Unread filters to unread conversations', () => {
199
+ setRouteParams({ group_source_app_name: 'Groups' })
200
+
201
+ render(<FilterDropdownListHeaderComponent />)
202
+
203
+ fireEvent.press(screen.getByRole('menuitem', { name: 'Unread' }))
204
+
205
+ expect(mockSetParams).toHaveBeenCalledWith({
206
+ title: undefined,
207
+ chat_group_graph_id: undefined,
208
+ group_source_app_name: undefined,
209
+ show_direct_messages: undefined,
210
+ show_unread: true,
211
+ })
212
+ })
213
+
214
+ it('selecting another filter clears Unread', () => {
215
+ setRouteParams({ show_unread: true })
216
+
217
+ render(<FilterDropdownListHeaderComponent />)
218
+
219
+ fireEvent.press(screen.getByRole('menuitem', { name: 'DMs' }))
220
+
221
+ expect(mockSetParams).toHaveBeenCalledWith(
222
+ expect.objectContaining({ show_direct_messages: true, show_unread: undefined })
223
+ )
224
+ })
225
+
226
+ it('labels the trigger Unread and checks it when filtered to unread', () => {
227
+ setRouteParams({ show_unread: true })
228
+
229
+ render(<FilterDropdownListHeaderComponent />)
230
+
231
+ expect(screen.getByRole('button', { name: 'Unread' })).toBeTruthy()
232
+ expect(screen.getByRole('menuitem', { name: 'Unread' }).props.accessibilityState.selected).toBe(
233
+ true
234
+ )
235
+ })
236
+
183
237
  it('labels the trigger with the active filter and checks it', () => {
184
238
  setRouteParams({ group_source_app_name: 'Services' })
185
239
 
@@ -215,7 +269,7 @@ describe('FilterDropdownListHeaderComponent', () => {
215
269
 
216
270
  render(<FilterDropdownListHeaderComponent />)
217
271
 
218
- expect(menuItemNames()).toEqual(['All', 'DMs', 'More filters'])
272
+ expect(menuItemNames()).toEqual(['All', 'DMs', 'Unread', 'More filters'])
219
273
  })
220
274
 
221
275
  it('labels the archived list and carries it into More filters', () => {
@@ -264,6 +318,19 @@ describe('FilterDropdownListHeaderComponent', () => {
264
318
  expect(mockNavigate).toHaveBeenCalledWith('ConversationFilters', expect.any(Object))
265
319
  })
266
320
 
321
+ it('More filters carries the Unread filter into the sheet', () => {
322
+ setRouteParams({ show_unread: true })
323
+
324
+ render(<FilterDropdownListHeaderComponent />)
325
+
326
+ fireEvent.press(screen.getByRole('menuitem', { name: 'More filters' }))
327
+
328
+ expect(mockNavigate).toHaveBeenCalledWith(
329
+ 'ConversationFilters',
330
+ expect.objectContaining({ show_unread: true })
331
+ )
332
+ })
333
+
267
334
  it('shows the search button only when search is enabled', () => {
268
335
  mockSearchEnabled.mockReturnValue(false)
269
336
  const view = render(<FilterDropdownListHeaderComponent />)