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