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