@planningcenter/chat-react-native 4.4.1-qa-staging.26 → 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 (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 +211 -188
  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 +55 -3
  73. package/src/screens/conversations/components/filter_dropdown_list_header_component.tsx +17 -2
  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
 
@@ -173,7 +173,7 @@ describe('FilterDropdownListHeaderComponent', () => {
173
173
  it('offers every filter the person can use, then More filters', () => {
174
174
  render(<FilterDropdownListHeaderComponent />)
175
175
 
176
- expect(menuItemNames()).toEqual(['All', 'Groups', 'Teams', 'DMs', 'More filters'])
176
+ expect(menuItemNames()).toEqual(['All', 'Groups', 'Teams', 'DMs', 'Unread', 'More filters'])
177
177
  expect(screen.getByRole('button', { name: 'All conversations' })).toBeTruthy()
178
178
  })
179
179
 
@@ -182,7 +182,7 @@ describe('FilterDropdownListHeaderComponent', () => {
182
182
 
183
183
  render(<FilterDropdownListHeaderComponent />)
184
184
 
185
- expect(menuItemNames()).toEqual(['All', 'DMs'])
185
+ expect(menuItemNames()).toEqual(['All', 'DMs', 'Unread'])
186
186
  })
187
187
 
188
188
  it('selecting Groups filters to group conversations', () => {
@@ -195,6 +195,45 @@ describe('FilterDropdownListHeaderComponent', () => {
195
195
  )
196
196
  })
197
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
+
198
237
  it('labels the trigger with the active filter and checks it', () => {
199
238
  setRouteParams({ group_source_app_name: 'Services' })
200
239
 
@@ -230,7 +269,7 @@ describe('FilterDropdownListHeaderComponent', () => {
230
269
 
231
270
  render(<FilterDropdownListHeaderComponent />)
232
271
 
233
- expect(menuItemNames()).toEqual(['All', 'DMs', 'More filters'])
272
+ expect(menuItemNames()).toEqual(['All', 'DMs', 'Unread', 'More filters'])
234
273
  })
235
274
 
236
275
  it('labels the archived list and carries it into More filters', () => {
@@ -279,6 +318,19 @@ describe('FilterDropdownListHeaderComponent', () => {
279
318
  expect(mockNavigate).toHaveBeenCalledWith('ConversationFilters', expect.any(Object))
280
319
  })
281
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
+
282
334
  it('shows the search button only when search is enabled', () => {
283
335
  mockSearchEnabled.mockReturnValue(false)
284
336
  const view = render(<FilterDropdownListHeaderComponent />)
@@ -35,6 +35,7 @@ enum FilterTypes {
35
35
  Groups = 'Groups',
36
36
  Teams = 'Teams',
37
37
  DMs = 'DMs',
38
+ Unread = 'Unread',
38
39
  More = 'More',
39
40
  }
40
41
 
@@ -43,6 +44,7 @@ const FILTER_PARAMS: Record<Exclude<FilterTypes, FilterTypes.More>, Conversation
43
44
  [FilterTypes.Groups]: { group_source_app_name: 'Groups' },
44
45
  [FilterTypes.Teams]: { group_source_app_name: 'Services' },
45
46
  [FilterTypes.DMs]: { show_direct_messages: true },
47
+ [FilterTypes.Unread]: { show_unread: true },
46
48
  }
47
49
 
48
50
  export const FilterDropdownListHeaderComponent = () => {
@@ -61,6 +63,7 @@ export const FilterDropdownListHeaderComponent = () => {
61
63
  group_source_app_name = '',
62
64
  show_direct_messages,
63
65
  show_archived,
66
+ show_unread,
64
67
  } = route.params || {}
65
68
  const currentPersonCache = useCurrentPersonCache()
66
69
  const { markAllRead, isPending } = useMarkAllRead()
@@ -83,10 +86,12 @@ export const FilterDropdownListHeaderComponent = () => {
83
86
  return FilterTypes.Teams
84
87
  } else if (show_direct_messages) {
85
88
  return FilterTypes.DMs
89
+ } else if (show_unread) {
90
+ return FilterTypes.Unread
86
91
  }
87
92
 
88
93
  return FilterTypes.All
89
- }, [chat_group_graph_id, group_source_app_name, show_direct_messages, show_archived])
94
+ }, [chat_group_graph_id, group_source_app_name, show_direct_messages, show_archived, show_unread])
90
95
 
91
96
  const options: FilterDropdownOption[] = useMemo(
92
97
  () => [
@@ -94,6 +99,7 @@ export const FilterDropdownListHeaderComponent = () => {
94
99
  ...(canFilterByGroups ? [{ id: FilterTypes.Groups, label: FilterTypes.Groups }] : []),
95
100
  ...(canFilterByTeams ? [{ id: FilterTypes.Teams, label: FilterTypes.Teams }] : []),
96
101
  ...(canFilterByDMs ? [{ id: FilterTypes.DMs, label: FilterTypes.DMs }] : []),
102
+ { id: FilterTypes.Unread, label: FilterTypes.Unread },
97
103
  ],
98
104
  [canFilterByGroups, canFilterByTeams, canFilterByDMs]
99
105
  )
@@ -144,6 +150,7 @@ export const FilterDropdownListHeaderComponent = () => {
144
150
  group_source_app_name: undefined,
145
151
  show_direct_messages: undefined,
146
152
  show_archived: undefined,
153
+ show_unread: undefined,
147
154
  ...params,
148
155
  })
149
156
  },
@@ -172,8 +179,16 @@ export const FilterDropdownListHeaderComponent = () => {
172
179
  group_source_app_name,
173
180
  show_direct_messages,
174
181
  show_archived,
182
+ show_unread,
175
183
  })
176
- }, [navigation, chat_group_graph_id, group_source_app_name, show_direct_messages, show_archived])
184
+ }, [
185
+ navigation,
186
+ chat_group_graph_id,
187
+ group_source_app_name,
188
+ show_direct_messages,
189
+ show_archived,
190
+ show_unread,
191
+ ])
177
192
 
178
193
  const handleMarkAllRead = useCallback(() => {
179
194
  currentPersonCache.update({ unreadCount: 0 })
@@ -8,7 +8,6 @@ import { analyticsEvents, usePublishProductAnalyticsEvent } from '../../hooks/us
8
8
  import { useSearchEnabled } from '../../hooks/use_search_enabled'
9
9
  import { AppName } from '../../types/resources/app_name'
10
10
  import { GraphId } from '../../types/resources/group_resource'
11
- import { Menu } from '../../utils/native_adapters'
12
11
  import { FilterDropdownListHeaderComponent } from './components/filter_dropdown_list_header_component'
13
12
  import { ListHeaderComponent } from './components/list_header_component'
14
13
  import { SearchListHeaderComponent } from './components/search_list_header_component'
@@ -19,6 +18,7 @@ export type ConversationsScreenProps = StaticScreenProps<{
19
18
  group_source_app_name?: AppName
20
19
  show_direct_messages?: boolean
21
20
  show_archived?: boolean
21
+ show_unread?: boolean
22
22
  }>
23
23
 
24
24
  export function ConversationsScreen({ route }: ConversationsScreenProps) {
@@ -40,7 +40,7 @@ const useListHeaderComponent = () => {
40
40
  const filterDropdownEnabled = useFilterDropdownEnabled()
41
41
  const searchEnabled = useSearchEnabled()
42
42
 
43
- if (filterDropdownEnabled && Menu.isConfigured) return FilterDropdownListHeaderComponent
43
+ if (filterDropdownEnabled) return FilterDropdownListHeaderComponent
44
44
  if (searchEnabled) return SearchListHeaderComponent
45
45
 
46
46
  return ListHeaderComponent