Bluesky app fork with some witchin' additions 馃挮
at main 436 lines 14 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} 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}