mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at samuel/exp-cli 389 lines 13 kB view raw
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}