forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useMemo, useState} from 'react'
2import {View} from 'react-native'
3import {
4 type ChatBskyConvoDefs,
5 type ChatBskyConvoListConvos,
6} from '@atproto/api'
7import {msg} from '@lingui/core/macro'
8import {useLingui} from '@lingui/react'
9import {Trans} from '@lingui/react/macro'
10import {useFocusEffect, useNavigation} from '@react-navigation/native'
11import {
12 type InfiniteData,
13 type UseInfiniteQueryResult,
14} from '@tanstack/react-query'
15
16import {useAppState} from '#/lib/appState'
17import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
18import {
19 type CommonNavigatorParams,
20 type NativeStackScreenProps,
21 type NavigationProp,
22} from '#/lib/routes/types'
23import {cleanError} from '#/lib/strings/errors'
24import {logger} from '#/logger'
25import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const'
26import {useMessagesEventBus} from '#/state/messages/events'
27import {useLeftConvos} from '#/state/queries/messages/leave-conversation'
28import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
29import {useUpdateAllRead} from '#/state/queries/messages/update-all-read'
30import {FAB} from '#/view/com/util/fab/FAB'
31import {List} from '#/view/com/util/List'
32import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
33import {atoms as a, useBreakpoints, useTheme} from '#/alf'
34import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
35import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
36import {Button, ButtonIcon, ButtonText} from '#/components/Button'
37import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
38import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
39import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
40import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
41import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
42import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
43import * as Layout from '#/components/Layout'
44import {ListFooter} from '#/components/Lists'
45import * as Toast from '#/components/Toast'
46import {Text} from '#/components/Typography'
47import {IS_NATIVE} from '#/env'
48import {RequestListItem} from './components/RequestListItem'
49
50type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesInbox'>
51
52export function MessagesInboxScreen(props: Props) {
53 const {_} = useLingui()
54 const aaCopy = useAgeAssuranceCopy()
55 return (
56 <AgeRestrictedScreen
57 screenTitle={_(msg`Chat requests`)}
58 infoText={aaCopy.chatsInfoText}>
59 <MessagesInboxScreenInner {...props} />
60 </AgeRestrictedScreen>
61 )
62}
63
64export function MessagesInboxScreenInner({}: Props) {
65 const {gtTablet} = useBreakpoints()
66
67 const listConvosQuery = useListConvosQuery({status: 'request'})
68 const {data} = listConvosQuery
69
70 const leftConvos = useLeftConvos()
71
72 const conversations = useMemo(() => {
73 if (data?.pages) {
74 const convos = data.pages
75 .flatMap(page => page.convos)
76 // filter out convos that are actively being left
77 .filter(convo => !leftConvos.includes(convo.id))
78
79 return convos
80 }
81 return []
82 }, [data, leftConvos])
83
84 const hasUnreadConvos = useMemo(() => {
85 return conversations.some(
86 conversation =>
87 conversation.members.every(
88 member => member.handle !== 'missing.invalid',
89 ) && conversation.unreadCount > 0,
90 )
91 }, [conversations])
92
93 return (
94 <Layout.Screen testID="messagesInboxScreen">
95 <Layout.Header.Outer>
96 <Layout.Header.BackButton />
97 <Layout.Header.Content align={gtTablet ? 'left' : 'platform'}>
98 <Layout.Header.TitleText>
99 <Trans>Chat requests</Trans>
100 </Layout.Header.TitleText>
101 </Layout.Header.Content>
102 {hasUnreadConvos && gtTablet ? (
103 <MarkAsReadHeaderButton />
104 ) : (
105 <Layout.Header.Slot />
106 )}
107 </Layout.Header.Outer>
108 <RequestList
109 listConvosQuery={listConvosQuery}
110 conversations={conversations}
111 hasUnreadConvos={hasUnreadConvos}
112 />
113 </Layout.Screen>
114 )
115}
116
117function RequestList({
118 listConvosQuery,
119 conversations,
120 hasUnreadConvos,
121}: {
122 listConvosQuery: UseInfiniteQueryResult<
123 InfiniteData<ChatBskyConvoListConvos.OutputSchema>,
124 Error
125 >
126 conversations: ChatBskyConvoDefs.ConvoView[]
127 hasUnreadConvos: boolean
128}) {
129 const {_} = useLingui()
130 const t = useTheme()
131 const navigation = useNavigation<NavigationProp>()
132
133 // Request the poll interval to be 10s (or whatever the MESSAGE_SCREEN_POLL_INTERVAL is set to in the future)
134 // but only when the screen is active
135 const messagesBus = useMessagesEventBus()
136 const state = useAppState()
137 const isActive = state === 'active'
138 useFocusEffect(
139 useCallback(() => {
140 if (isActive) {
141 const unsub = messagesBus.requestPollInterval(
142 MESSAGE_SCREEN_POLL_INTERVAL,
143 )
144 return () => unsub()
145 }
146 }, [messagesBus, isActive]),
147 )
148
149 const initialNumToRender = useInitialNumToRender({minItemHeight: 130})
150 const [isPTRing, setIsPTRing] = useState(false)
151
152 const {
153 isLoading,
154 isFetchingNextPage,
155 hasNextPage,
156 fetchNextPage,
157 isError,
158 error,
159 refetch,
160 } = listConvosQuery
161
162 useRefreshOnFocus(refetch)
163
164 const onRefresh = useCallback(async () => {
165 setIsPTRing(true)
166 try {
167 await refetch()
168 } catch (err) {
169 logger.error('Failed to refresh conversations', {message: err})
170 }
171 setIsPTRing(false)
172 }, [refetch, setIsPTRing])
173
174 const onEndReached = useCallback(async () => {
175 if (isFetchingNextPage || !hasNextPage || isError) return
176 try {
177 await fetchNextPage()
178 } catch (err) {
179 logger.error('Failed to load more conversations', {message: err})
180 }
181 }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
182
183 if (conversations.length < 1) {
184 return (
185 <Layout.Center>
186 {isLoading ? (
187 <ChatListLoadingPlaceholder />
188 ) : (
189 <>
190 {isError ? (
191 <>
192 <View style={[a.pt_3xl, a.align_center]}>
193 <CircleInfoIcon
194 width={48}
195 fill={t.atoms.text_contrast_low.color}
196 />
197 <Text
198 style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_semi_bold]}>
199 <Trans>Whoops!</Trans>
200 </Text>
201 <Text
202 style={[
203 a.text_md,
204 a.pb_xl,
205 a.text_center,
206 a.leading_snug,
207 t.atoms.text_contrast_medium,
208 {maxWidth: 360},
209 ]}>
210 {cleanError(error) || _(msg`Failed to load conversations`)}
211 </Text>
212
213 <Button
214 label={_(msg`Reload conversations`)}
215 size="small"
216 color="secondary_inverted"
217 variant="solid"
218 onPress={() => refetch()}>
219 <ButtonText>
220 <Trans>Retry</Trans>
221 </ButtonText>
222 <ButtonIcon icon={RetryIcon} position="right" />
223 </Button>
224 </View>
225 </>
226 ) : (
227 <>
228 <View style={[a.pt_3xl, a.align_center]}>
229 <MessageIcon width={48} fill={t.palette.primary_500} />
230 <Text
231 style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_semi_bold]}>
232 <Trans comment="Title message shown in chat requests inbox when it's empty">
233 Inbox zero!
234 </Trans>
235 </Text>
236 <Text
237 style={[
238 a.text_md,
239 a.pb_xl,
240 a.text_center,
241 a.leading_snug,
242 t.atoms.text_contrast_medium,
243 ]}>
244 <Trans>
245 You don't have any chat requests at the moment.
246 </Trans>
247 </Text>
248 <Button
249 variant="solid"
250 color="secondary"
251 size="small"
252 label={_(msg`Go back`)}
253 onPress={() => {
254 if (navigation.canGoBack()) {
255 navigation.goBack()
256 } else {
257 navigation.navigate('Messages', {animation: 'pop'})
258 }
259 }}>
260 <ButtonIcon icon={ArrowLeftIcon} />
261 <ButtonText>
262 <Trans>Back to Chats</Trans>
263 </ButtonText>
264 </Button>
265 </View>
266 </>
267 )}
268 </>
269 )}
270 </Layout.Center>
271 )
272 }
273
274 return (
275 <>
276 <List
277 data={conversations}
278 renderItem={renderItem}
279 keyExtractor={keyExtractor}
280 refreshing={isPTRing}
281 onRefresh={onRefresh}
282 onEndReached={onEndReached}
283 ListFooterComponent={
284 <ListFooter
285 isFetchingNextPage={isFetchingNextPage}
286 error={cleanError(error)}
287 onRetry={fetchNextPage}
288 style={{borderColor: 'transparent'}}
289 hasNextPage={hasNextPage}
290 />
291 }
292 onEndReachedThreshold={IS_NATIVE ? 1.5 : 0}
293 initialNumToRender={initialNumToRender}
294 windowSize={11}
295 desktopFixedHeight
296 sideBorders={false}
297 />
298 {hasUnreadConvos && <MarkAllReadFAB />}
299 </>
300 )
301}
302
303function keyExtractor(item: ChatBskyConvoDefs.ConvoView) {
304 return item.id
305}
306
307function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) {
308 return <RequestListItem convo={item} />
309}
310
311function MarkAllReadFAB() {
312 const {_} = useLingui()
313 const t = useTheme()
314 const {mutate: markAllRead} = useUpdateAllRead('request', {
315 onMutate: () => {
316 Toast.show(_(msg`Marked all as read`), {
317 type: 'success',
318 })
319 },
320 onError: () => {
321 Toast.show(_(msg`Failed to mark all requests as read`), {
322 type: 'error',
323 })
324 },
325 })
326
327 return (
328 <FAB
329 testID="markAllAsReadFAB"
330 onPress={() => markAllRead()}
331 icon={<CheckIcon size="lg" fill={t.palette.white} />}
332 accessibilityRole="button"
333 accessibilityLabel={_(msg`Mark all as read`)}
334 accessibilityHint=""
335 />
336 )
337}
338
339function MarkAsReadHeaderButton() {
340 const {_} = useLingui()
341 const {mutate: markAllRead} = useUpdateAllRead('request', {
342 onMutate: () => {
343 Toast.show(_(msg`Marked all as read`), {
344 type: 'success',
345 })
346 },
347 onError: () => {
348 Toast.show(_(msg`Failed to mark all requests as read`), {
349 type: 'error',
350 })
351 },
352 })
353
354 return (
355 <Button
356 label={_(msg`Mark all as read`)}
357 size="small"
358 color="secondary"
359 variant="solid"
360 onPress={() => markAllRead()}>
361 <ButtonIcon icon={CheckIcon} />
362 <ButtonText>
363 <Trans>Mark all as read</Trans>
364 </ButtonText>
365 </Button>
366 )
367}