mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {useCallback, useEffect, useMemo, useState} from 'react'
2import {View} from 'react-native'
3import {useAnimatedRef} from 'react-native-reanimated'
4import {type ChatBskyActorDefs, type ChatBskyConvoDefs} from '@atproto/api'
5import {msg, Trans} from '@lingui/macro'
6import {useLingui} from '@lingui/react'
7import {useFocusEffect, useIsFocused} from '@react-navigation/native'
8import {type NativeStackScreenProps} from '@react-navigation/native-stack'
9
10import {useAppState} from '#/lib/hooks/useAppState'
11import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
12import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
13import {type MessagesTabNavigatorParams} from '#/lib/routes/types'
14import {cleanError} from '#/lib/strings/errors'
15import {logger} from '#/logger'
16import {isNative} from '#/platform/detection'
17import {listenSoftReset} from '#/state/events'
18import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const'
19import {useMessagesEventBus} from '#/state/messages/events'
20import {useLeftConvos} from '#/state/queries/messages/leave-conversation'
21import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
22import {useSession} from '#/state/session'
23import {List, type ListRef} from '#/view/com/util/List'
24import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
25import {atoms as a, useBreakpoints, useTheme} from '#/alf'
26import {Button, ButtonIcon, ButtonText} from '#/components/Button'
27import {type DialogControlProps, useDialogControl} from '#/components/Dialog'
28import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
29import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
30import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise'
31import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
32import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
33import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
34import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon} from '#/components/icons/SettingsGear2'
35import * as Layout from '#/components/Layout'
36import {Link} from '#/components/Link'
37import {ListFooter} from '#/components/Lists'
38import {Text} from '#/components/Typography'
39import {ChatListItem} from './components/ChatListItem'
40import {InboxPreview} from './components/InboxPreview'
41
42type ListItem =
43 | {
44 type: 'INBOX'
45 count: number
46 profiles: ChatBskyActorDefs.ProfileViewBasic[]
47 }
48 | {
49 type: 'CONVERSATION'
50 conversation: ChatBskyConvoDefs.ConvoView
51 }
52
53function renderItem({item}: {item: ListItem}) {
54 switch (item.type) {
55 case 'INBOX':
56 return <InboxPreview count={item.count} profiles={item.profiles} />
57 case 'CONVERSATION':
58 return <ChatListItem convo={item.conversation} />
59 }
60}
61
62function keyExtractor(item: ListItem) {
63 return item.type === 'INBOX' ? 'INBOX' : item.conversation.id
64}
65
66type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
67export function MessagesScreen({navigation, route}: Props) {
68 const {_} = useLingui()
69 const t = useTheme()
70 const {currentAccount} = useSession()
71 const newChatControl = useDialogControl()
72 const scrollElRef: ListRef = useAnimatedRef()
73 const pushToConversation = route.params?.pushToConversation
74
75 // Whenever we have `pushToConversation` set, it means we pressed a notification for a chat without being on
76 // this tab. We should immediately push to the conversation after pressing the notification.
77 // After we push, reset with `setParams` so that this effect will fire next time we press a notification, even if
78 // the conversation is the same as before
79 useEffect(() => {
80 if (pushToConversation) {
81 navigation.navigate('MessagesConversation', {
82 conversation: pushToConversation,
83 })
84 navigation.setParams({pushToConversation: undefined})
85 }
86 }, [navigation, pushToConversation])
87
88 // Request the poll interval to be 10s (or whatever the MESSAGE_SCREEN_POLL_INTERVAL is set to in the future)
89 // but only when the screen is active
90 const messagesBus = useMessagesEventBus()
91 const state = useAppState()
92 const isActive = state === 'active'
93 useFocusEffect(
94 useCallback(() => {
95 if (isActive) {
96 const unsub = messagesBus.requestPollInterval(
97 MESSAGE_SCREEN_POLL_INTERVAL,
98 )
99 return () => unsub()
100 }
101 }, [messagesBus, isActive]),
102 )
103
104 const initialNumToRender = useInitialNumToRender({minItemHeight: 80})
105 const [isPTRing, setIsPTRing] = useState(false)
106
107 const {
108 data,
109 isLoading,
110 isFetchingNextPage,
111 hasNextPage,
112 fetchNextPage,
113 isError,
114 error,
115 refetch,
116 } = useListConvosQuery({status: 'accepted'})
117
118 const {data: inboxData, refetch: refetchInbox} = useListConvosQuery({
119 status: 'request',
120 })
121
122 useRefreshOnFocus(refetch)
123 useRefreshOnFocus(refetchInbox)
124
125 const leftConvos = useLeftConvos()
126
127 const inboxPreviewConvos = useMemo(() => {
128 const inbox =
129 inboxData?.pages
130 .flatMap(page => page.convos)
131 .filter(
132 convo =>
133 !leftConvos.includes(convo.id) &&
134 !convo.muted &&
135 convo.unreadCount > 0 &&
136 convo.members.every(member => member.handle !== 'missing.invalid'),
137 ) ?? []
138
139 return inbox
140 .map(x => x.members.find(y => y.did !== currentAccount?.did))
141 .filter(x => !!x)
142 }, [inboxData, leftConvos, currentAccount?.did])
143
144 const conversations = useMemo(() => {
145 if (data?.pages) {
146 const conversations = data.pages
147 .flatMap(page => page.convos)
148 // filter out convos that are actively being left
149 .filter(convo => !leftConvos.includes(convo.id))
150
151 return [
152 {
153 type: 'INBOX',
154 count: inboxPreviewConvos.length,
155 profiles: inboxPreviewConvos.slice(0, 3),
156 },
157 ...conversations.map(
158 convo => ({type: 'CONVERSATION', conversation: convo}) as const,
159 ),
160 ] satisfies ListItem[]
161 }
162 return []
163 }, [data, leftConvos, inboxPreviewConvos])
164
165 const onRefresh = useCallback(async () => {
166 setIsPTRing(true)
167 try {
168 await Promise.all([refetch(), refetchInbox()])
169 } catch (err) {
170 logger.error('Failed to refresh conversations', {message: err})
171 }
172 setIsPTRing(false)
173 }, [refetch, refetchInbox, setIsPTRing])
174
175 const onEndReached = useCallback(async () => {
176 if (isFetchingNextPage || !hasNextPage || isError) return
177 try {
178 await fetchNextPage()
179 } catch (err) {
180 logger.error('Failed to load more conversations', {message: err})
181 }
182 }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
183
184 const onNewChat = useCallback(
185 (conversation: string) =>
186 navigation.navigate('MessagesConversation', {conversation}),
187 [navigation],
188 )
189
190 const onSoftReset = useCallback(async () => {
191 scrollElRef.current?.scrollToOffset({
192 animated: isNative,
193 offset: 0,
194 })
195 try {
196 await refetch()
197 } catch (err) {
198 logger.error('Failed to refresh conversations', {message: err})
199 }
200 }, [scrollElRef, refetch])
201
202 const isScreenFocused = useIsFocused()
203 useEffect(() => {
204 if (!isScreenFocused) {
205 return
206 }
207 return listenSoftReset(onSoftReset)
208 }, [onSoftReset, isScreenFocused])
209
210 // Will always have 1 item - the inbox button
211 if (conversations.length < 2) {
212 return (
213 <Layout.Screen>
214 <Header newChatControl={newChatControl} />
215 <Layout.Center>
216 <InboxPreview
217 count={inboxPreviewConvos.length}
218 profiles={inboxPreviewConvos}
219 />
220 {isLoading ? (
221 <ChatListLoadingPlaceholder />
222 ) : (
223 <>
224 {isError ? (
225 <>
226 <View style={[a.pt_3xl, a.align_center]}>
227 <CircleInfoIcon
228 width={48}
229 fill={t.atoms.text_contrast_low.color}
230 />
231 <Text style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_bold]}>
232 <Trans>Whoops!</Trans>
233 </Text>
234 <Text
235 style={[
236 a.text_md,
237 a.pb_xl,
238 a.text_center,
239 a.leading_snug,
240 t.atoms.text_contrast_medium,
241 {maxWidth: 360},
242 ]}>
243 {cleanError(error) ||
244 _(msg`Failed to load conversations`)}
245 </Text>
246
247 <Button
248 label={_(msg`Reload conversations`)}
249 size="small"
250 color="secondary_inverted"
251 variant="solid"
252 onPress={() => refetch()}>
253 <ButtonText>
254 <Trans>Retry</Trans>
255 </ButtonText>
256 <ButtonIcon icon={RetryIcon} position="right" />
257 </Button>
258 </View>
259 </>
260 ) : (
261 <>
262 <View style={[a.pt_3xl, a.align_center]}>
263 <MessageIcon width={48} fill={t.palette.primary_500} />
264 <Text style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_bold]}>
265 <Trans>Nothing here</Trans>
266 </Text>
267 <Text
268 style={[
269 a.text_md,
270 a.pb_xl,
271 a.text_center,
272 a.leading_snug,
273 t.atoms.text_contrast_medium,
274 ]}>
275 <Trans>You have no conversations yet. Start one!</Trans>
276 </Text>
277 </View>
278 </>
279 )}
280 </>
281 )}
282 </Layout.Center>
283
284 {!isLoading && !isError && (
285 <NewChat onNewChat={onNewChat} control={newChatControl} />
286 )}
287 </Layout.Screen>
288 )
289 }
290
291 return (
292 <Layout.Screen testID="messagesScreen">
293 <Header newChatControl={newChatControl} />
294 <NewChat onNewChat={onNewChat} control={newChatControl} />
295 <List
296 ref={scrollElRef}
297 data={conversations}
298 renderItem={renderItem}
299 keyExtractor={keyExtractor}
300 refreshing={isPTRing}
301 onRefresh={onRefresh}
302 onEndReached={onEndReached}
303 ListFooterComponent={
304 <ListFooter
305 isFetchingNextPage={isFetchingNextPage}
306 error={cleanError(error)}
307 onRetry={fetchNextPage}
308 style={{borderColor: 'transparent'}}
309 hasNextPage={hasNextPage}
310 />
311 }
312 onEndReachedThreshold={isNative ? 1.5 : 0}
313 initialNumToRender={initialNumToRender}
314 windowSize={11}
315 desktopFixedHeight
316 sideBorders={false}
317 />
318 </Layout.Screen>
319 )
320}
321
322function Header({newChatControl}: {newChatControl: DialogControlProps}) {
323 const {_} = useLingui()
324 const {gtMobile} = useBreakpoints()
325 const requireEmailVerification = useRequireEmailVerification()
326
327 const openChatControl = useCallback(() => {
328 newChatControl.open()
329 }, [newChatControl])
330 const wrappedOpenChatControl = requireEmailVerification(openChatControl, {
331 instructions: [
332 <Trans key="new-chat">
333 Before you can message another user, you must first verify your email.
334 </Trans>,
335 ],
336 })
337
338 const settingsLink = (
339 <Link
340 to="/messages/settings"
341 label={_(msg`Chat settings`)}
342 size="small"
343 variant="ghost"
344 color="secondary"
345 shape="round"
346 style={[a.justify_center]}>
347 <ButtonIcon icon={SettingsIcon} size="lg" />
348 </Link>
349 )
350
351 return (
352 <Layout.Header.Outer>
353 {gtMobile ? (
354 <>
355 <Layout.Header.Content>
356 <Layout.Header.TitleText>
357 <Trans>Chats</Trans>
358 </Layout.Header.TitleText>
359 </Layout.Header.Content>
360
361 <View style={[a.flex_row, a.align_center, a.gap_sm]}>
362 {settingsLink}
363 <Button
364 label={_(msg`New chat`)}
365 color="primary"
366 size="small"
367 variant="solid"
368 onPress={wrappedOpenChatControl}>
369 <ButtonIcon icon={PlusIcon} position="left" />
370 <ButtonText>
371 <Trans>New chat</Trans>
372 </ButtonText>
373 </Button>
374 </View>
375 </>
376 ) : (
377 <>
378 <Layout.Header.MenuButton />
379 <Layout.Header.Content>
380 <Layout.Header.TitleText>
381 <Trans>Chats</Trans>
382 </Layout.Header.TitleText>
383 </Layout.Header.Content>
384 <Layout.Header.Slot>{settingsLink}</Layout.Header.Slot>
385 </>
386 )}
387 </Layout.Header.Outer>
388 )
389}