Bluesky app fork with some witchin' additions 馃挮
at main 358 lines 11 kB view raw
1import {useCallback, useMemo, useState} from 'react' 2import {View} from 'react-native' 3import { 4 type ChatBskyConvoDefs, 5 type ChatBskyConvoListConvos, 6} from '@atproto/api' 7import {msg, Trans} from '@lingui/macro' 8import {useLingui} from '@lingui/react' 9import {useFocusEffect, useNavigation} from '@react-navigation/native' 10import { 11 type InfiniteData, 12 type UseInfiniteQueryResult, 13} from '@tanstack/react-query' 14 15import {useAppState} from '#/lib/appState' 16import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 17import { 18 type CommonNavigatorParams, 19 type NativeStackScreenProps, 20 type NavigationProp, 21} from '#/lib/routes/types' 22import {cleanError} from '#/lib/strings/errors' 23import {logger} from '#/logger' 24import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const' 25import {useMessagesEventBus} from '#/state/messages/events' 26import {useLeftConvos} from '#/state/queries/messages/leave-conversation' 27import {useListConvosQuery} from '#/state/queries/messages/list-conversations' 28import {useUpdateAllRead} from '#/state/queries/messages/update-all-read' 29import {FAB} from '#/view/com/util/fab/FAB' 30import {List} from '#/view/com/util/List' 31import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 32import * as Toast from '#/view/com/util/Toast' 33import {atoms as a, useBreakpoints, useTheme} from '#/alf' 34import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen' 35import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' 36import {Button, ButtonIcon, ButtonText} from '#/components/Button' 37import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus' 38import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' 39import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate' 40import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 41import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' 42import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message' 43import * as Layout from '#/components/Layout' 44import {ListFooter} from '#/components/Lists' 45import {Text} from '#/components/Typography' 46import {IS_NATIVE} from '#/env' 47import {RequestListItem} from './components/RequestListItem' 48 49type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesInbox'> 50 51export function MessagesInboxScreen(props: Props) { 52 const {_} = useLingui() 53 const aaCopy = useAgeAssuranceCopy() 54 return ( 55 <AgeRestrictedScreen 56 screenTitle={_(msg`Chat requests`)} 57 infoText={aaCopy.chatsInfoText}> 58 <MessagesInboxScreenInner {...props} /> 59 </AgeRestrictedScreen> 60 ) 61} 62 63export function MessagesInboxScreenInner({}: Props) { 64 const {gtTablet} = useBreakpoints() 65 66 const listConvosQuery = useListConvosQuery({status: 'request'}) 67 const {data} = listConvosQuery 68 69 const leftConvos = useLeftConvos() 70 71 const conversations = useMemo(() => { 72 if (data?.pages) { 73 const convos = data.pages 74 .flatMap(page => page.convos) 75 // filter out convos that are actively being left 76 .filter(convo => !leftConvos.includes(convo.id)) 77 78 return convos 79 } 80 return [] 81 }, [data, leftConvos]) 82 83 const hasUnreadConvos = useMemo(() => { 84 return conversations.some( 85 conversation => 86 conversation.members.every( 87 member => member.handle !== 'missing.invalid', 88 ) && conversation.unreadCount > 0, 89 ) 90 }, [conversations]) 91 92 return ( 93 <Layout.Screen testID="messagesInboxScreen"> 94 <Layout.Header.Outer> 95 <Layout.Header.BackButton /> 96 <Layout.Header.Content align={gtTablet ? 'left' : 'platform'}> 97 <Layout.Header.TitleText> 98 <Trans>Chat requests</Trans> 99 </Layout.Header.TitleText> 100 </Layout.Header.Content> 101 {hasUnreadConvos && gtTablet ? ( 102 <MarkAsReadHeaderButton /> 103 ) : ( 104 <Layout.Header.Slot /> 105 )} 106 </Layout.Header.Outer> 107 <RequestList 108 listConvosQuery={listConvosQuery} 109 conversations={conversations} 110 hasUnreadConvos={hasUnreadConvos} 111 /> 112 </Layout.Screen> 113 ) 114} 115 116function RequestList({ 117 listConvosQuery, 118 conversations, 119 hasUnreadConvos, 120}: { 121 listConvosQuery: UseInfiniteQueryResult< 122 InfiniteData<ChatBskyConvoListConvos.OutputSchema>, 123 Error 124 > 125 conversations: ChatBskyConvoDefs.ConvoView[] 126 hasUnreadConvos: boolean 127}) { 128 const {_} = useLingui() 129 const t = useTheme() 130 const navigation = useNavigation<NavigationProp>() 131 132 // Request the poll interval to be 10s (or whatever the MESSAGE_SCREEN_POLL_INTERVAL is set to in the future) 133 // but only when the screen is active 134 const messagesBus = useMessagesEventBus() 135 const state = useAppState() 136 const isActive = state === 'active' 137 useFocusEffect( 138 useCallback(() => { 139 if (isActive) { 140 const unsub = messagesBus.requestPollInterval( 141 MESSAGE_SCREEN_POLL_INTERVAL, 142 ) 143 return () => unsub() 144 } 145 }, [messagesBus, isActive]), 146 ) 147 148 const initialNumToRender = useInitialNumToRender({minItemHeight: 130}) 149 const [isPTRing, setIsPTRing] = useState(false) 150 151 const { 152 isLoading, 153 isFetchingNextPage, 154 hasNextPage, 155 fetchNextPage, 156 isError, 157 error, 158 refetch, 159 } = listConvosQuery 160 161 useRefreshOnFocus(refetch) 162 163 const onRefresh = useCallback(async () => { 164 setIsPTRing(true) 165 try { 166 await refetch() 167 } catch (err) { 168 logger.error('Failed to refresh conversations', {message: err}) 169 } 170 setIsPTRing(false) 171 }, [refetch, setIsPTRing]) 172 173 const onEndReached = useCallback(async () => { 174 if (isFetchingNextPage || !hasNextPage || isError) return 175 try { 176 await fetchNextPage() 177 } catch (err) { 178 logger.error('Failed to load more conversations', {message: err}) 179 } 180 }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) 181 182 if (conversations.length < 1) { 183 return ( 184 <Layout.Center> 185 {isLoading ? ( 186 <ChatListLoadingPlaceholder /> 187 ) : ( 188 <> 189 {isError ? ( 190 <> 191 <View style={[a.pt_3xl, a.align_center]}> 192 <CircleInfoIcon 193 width={48} 194 fill={t.atoms.text_contrast_low.color} 195 /> 196 <Text 197 style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_semi_bold]}> 198 <Trans>Whoops!</Trans> 199 </Text> 200 <Text 201 style={[ 202 a.text_md, 203 a.pb_xl, 204 a.text_center, 205 a.leading_snug, 206 t.atoms.text_contrast_medium, 207 {maxWidth: 360}, 208 ]}> 209 {cleanError(error) || _(msg`Failed to load conversations`)} 210 </Text> 211 212 <Button 213 label={_(msg`Reload conversations`)} 214 size="small" 215 color="secondary_inverted" 216 variant="solid" 217 onPress={() => refetch()}> 218 <ButtonText> 219 <Trans>Retry</Trans> 220 </ButtonText> 221 <ButtonIcon icon={RetryIcon} position="right" /> 222 </Button> 223 </View> 224 </> 225 ) : ( 226 <> 227 <View style={[a.pt_3xl, a.align_center]}> 228 <MessageIcon width={48} fill={t.palette.primary_500} /> 229 <Text 230 style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_semi_bold]}> 231 <Trans comment="Title message shown in chat requests inbox when it's empty"> 232 Inbox zero! 233 </Trans> 234 </Text> 235 <Text 236 style={[ 237 a.text_md, 238 a.pb_xl, 239 a.text_center, 240 a.leading_snug, 241 t.atoms.text_contrast_medium, 242 ]}> 243 <Trans> 244 You don't have any chat requests at the moment. 245 </Trans> 246 </Text> 247 <Button 248 variant="solid" 249 color="secondary" 250 size="small" 251 label={_(msg`Go back`)} 252 onPress={() => { 253 if (navigation.canGoBack()) { 254 navigation.goBack() 255 } else { 256 navigation.navigate('Messages', {animation: 'pop'}) 257 } 258 }}> 259 <ButtonIcon icon={ArrowLeftIcon} /> 260 <ButtonText> 261 <Trans>Back to Chats</Trans> 262 </ButtonText> 263 </Button> 264 </View> 265 </> 266 )} 267 </> 268 )} 269 </Layout.Center> 270 ) 271 } 272 273 return ( 274 <> 275 <List 276 data={conversations} 277 renderItem={renderItem} 278 keyExtractor={keyExtractor} 279 refreshing={isPTRing} 280 onRefresh={onRefresh} 281 onEndReached={onEndReached} 282 ListFooterComponent={ 283 <ListFooter 284 isFetchingNextPage={isFetchingNextPage} 285 error={cleanError(error)} 286 onRetry={fetchNextPage} 287 style={{borderColor: 'transparent'}} 288 hasNextPage={hasNextPage} 289 /> 290 } 291 onEndReachedThreshold={IS_NATIVE ? 1.5 : 0} 292 initialNumToRender={initialNumToRender} 293 windowSize={11} 294 desktopFixedHeight 295 sideBorders={false} 296 /> 297 {hasUnreadConvos && <MarkAllReadFAB />} 298 </> 299 ) 300} 301 302function keyExtractor(item: ChatBskyConvoDefs.ConvoView) { 303 return item.id 304} 305 306function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) { 307 return <RequestListItem convo={item} /> 308} 309 310function MarkAllReadFAB() { 311 const {_} = useLingui() 312 const t = useTheme() 313 const {mutate: markAllRead} = useUpdateAllRead('request', { 314 onMutate: () => { 315 Toast.show(_(msg`Marked all as read`), 'check') 316 }, 317 onError: () => { 318 Toast.show(_(msg`Failed to mark all requests as read`), 'xmark') 319 }, 320 }) 321 322 return ( 323 <FAB 324 testID="markAllAsReadFAB" 325 onPress={() => markAllRead()} 326 icon={<CheckIcon size="lg" fill={t.palette.white} />} 327 accessibilityRole="button" 328 accessibilityLabel={_(msg`Mark all as read`)} 329 accessibilityHint="" 330 /> 331 ) 332} 333 334function MarkAsReadHeaderButton() { 335 const {_} = useLingui() 336 const {mutate: markAllRead} = useUpdateAllRead('request', { 337 onMutate: () => { 338 Toast.show(_(msg`Marked all as read`), 'check') 339 }, 340 onError: () => { 341 Toast.show(_(msg`Failed to mark all requests as read`), 'xmark') 342 }, 343 }) 344 345 return ( 346 <Button 347 label={_(msg`Mark all as read`)} 348 size="small" 349 color="secondary" 350 variant="solid" 351 onPress={() => markAllRead()}> 352 <ButtonIcon icon={CheckIcon} /> 353 <ButtonText> 354 <Trans>Mark all as read</Trans> 355 </ButtonText> 356 </Button> 357 ) 358}