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