Bluesky app fork with some witchin' additions 馃挮
at linkat-integration 431 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, 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}