Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

at main 373 lines 12 kB view raw
1import {useCallback, useEffect, useMemo, useRef, useState} from 'react' 2import {type LayoutChangeEvent, View} from 'react-native' 3import {useSafeAreaInsets} from 'react-native-safe-area-context' 4import {moderateProfile} from '@atproto/api' 5import { 6 ScrollEdgeEffect, 7 ScrollEdgeEffectProvider, 8} from '@bsky.app/expo-scroll-edge-effect' 9import {Trans, useLingui} from '@lingui/react/macro' 10import { 11 type RouteProp, 12 useFocusEffect, 13 useIsFocused, 14 useNavigation, 15 useRoute, 16} from '@react-navigation/native' 17import {type NativeStackScreenProps} from '@react-navigation/native-stack' 18import {RemoveScrollBar} from 'react-remove-scroll-bar' 19 20import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 21import {useViewportZoomLock} from '#/lib/hooks/useViewportZoomLock' 22import { 23 type CommonNavigatorParams, 24 type NavigationProp, 25} from '#/lib/routes/types' 26import {useMaybeProfileShadow} from '#/state/cache/profile-shadow' 27import {useEmail} from '#/state/email-verification' 28import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo' 29import {ConvoStatus} from '#/state/messages/convo/types' 30import {useCurrentConvoId} from '#/state/messages/current-convo-id' 31import {useModerationOpts} from '#/state/preferences/moderation-opts' 32import {useConvoQuery} from '#/state/queries/messages/conversation' 33import {useSession} from '#/state/session' 34import {MessagesList} from '#/screens/Messages/components/MessagesList' 35import {atoms as a, useTheme, web} from '#/alf' 36import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen' 37import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' 38import * as Dialog from '#/components/Dialog' 39import { 40 EmailDialogScreenID, 41 useEmailDialogControl, 42} from '#/components/dialogs/EmailDialog' 43import {MessagesListBlockedFooter} from '#/components/dms/MessagesListBlockedFooter' 44import {MessagesListHeader} from '#/components/dms/MessagesListHeader' 45import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util' 46import {Error} from '#/components/Error' 47import * as Layout from '#/components/Layout' 48import {Loader} from '#/components/Loader' 49import * as Prompt from '#/components/Prompt' 50import {Text} from '#/components/Typography' 51import {useAnalytics} from '#/analytics' 52import {IS_LIQUID_GLASS, IS_WEB} from '#/env' 53import {ChatDisabled} from './components/ChatDisabled' 54 55type Props = NativeStackScreenProps< 56 CommonNavigatorParams, 57 'MessagesConversation' 58> 59 60export function MessagesConversationScreen(props: Props) { 61 const {t: l} = useLingui() 62 const aaCopy = useAgeAssuranceCopy() 63 return ( 64 <AgeRestrictedScreen 65 screenTitle={l`Conversation`} 66 infoText={aaCopy.chatsInfoText}> 67 <MessagesConversationScreenInner {...props} /> 68 </AgeRestrictedScreen> 69 ) 70} 71 72export function MessagesConversationScreenInner({route}: Props) { 73 const convoId = route.params.conversation 74 const {setCurrentConvoId} = useCurrentConvoId() 75 76 useFocusEffect( 77 useCallback(() => { 78 setCurrentConvoId(convoId) 79 80 return () => { 81 setCurrentConvoId(undefined) 82 } 83 }, [convoId, setCurrentConvoId]), 84 ) 85 86 return ( 87 <Layout.Screen 88 minimalShell 89 testID="convoScreen" 90 noInsetTop={IS_LIQUID_GLASS} 91 style={web([{minHeight: 0}, a.flex_1])}> 92 <ScrollEdgeEffectProvider> 93 <ConvoProvider key={convoId} convoId={convoId}> 94 <Inner convoId={convoId} /> 95 </ConvoProvider> 96 </ScrollEdgeEffectProvider> 97 </Layout.Screen> 98 ) 99} 100 101function Inner({convoId}: {convoId: string}) { 102 const t = useTheme() 103 const convoState = useConvo() 104 const {t: l} = useLingui() 105 const {currentAccount} = useSession() 106 const isFocused = useIsFocused() 107 const {top: topInset} = useSafeAreaInsets() 108 const {data: convoData} = useConvoQuery({convoId}) 109 110 useViewportZoomLock({enabled: isFocused}) 111 112 const convo = convoData 113 ? parseConvoView(convoData, currentAccount?.did) 114 : null 115 116 // Because we want to give the list a chance to asynchronously scroll to the end before it is visible to the user, 117 // we use `hasScrolled` to determine when to render. With that said however, there is a chance that the chat will be 118 // empty. So, we also check for that possible state as well and render once we can. 119 const [hasScrolled, setHasScrolled] = useState(false) 120 const readyToShow = 121 hasScrolled || 122 (isConvoActive(convoState) && 123 !convoState.isFetchingHistory && 124 convoState.items.length === 0) 125 126 // Any time that we re-render the `Initializing` state, we have to reset `hasScrolled` to false. After entering this 127 // state, we know that we're resetting the list of messages and need to re-scroll to the bottom when they get added. 128 const [prevState, setPrevState] = useState(convoState.status) 129 if (prevState !== convoState.status) { 130 setPrevState(convoState.status) 131 if (convoState.status === ConvoStatus.Initializing) { 132 setHasScrolled(false) 133 } 134 } 135 136 if (convoState.status === ConvoStatus.Error) { 137 return ( 138 <> 139 <Layout.Center 140 style={[a.w_full, IS_LIQUID_GLASS && {paddingTop: topInset}]}> 141 <MessagesListHeader convo={convo} /> 142 </Layout.Center> 143 <Error 144 title={l`Something went wrong`} 145 message={l`We couldn't load this conversation`} 146 onRetry={() => convoState.error.retry()} 147 sideBorders={false} 148 /> 149 </> 150 ) 151 } 152 153 return ( 154 <Layout.Center style={[a.flex_1]}> 155 {/* MessagesList does not use the body scroll */} 156 {isFocused && IS_WEB && <RemoveScrollBar />} 157 {!readyToShow && ( 158 <View style={IS_LIQUID_GLASS && {paddingTop: topInset}}> 159 <MessagesListHeader convo={convo} /> 160 </View> 161 )} 162 <View style={[a.flex_1]}> 163 <InnerReady 164 convo={convo} 165 hasScrolled={hasScrolled} 166 setHasScrolled={setHasScrolled} 167 isActive={isConvoActive(convoState)} 168 isDisabled={convoState.status === ConvoStatus.Disabled} 169 hasMessages={isConvoActive(convoState) && convoState.items.length > 0} 170 /> 171 {!readyToShow && ( 172 <View 173 style={[ 174 a.absolute, 175 a.z_10, 176 a.w_full, 177 a.h_full, 178 a.justify_center, 179 a.align_center, 180 t.atoms.bg, 181 ]}> 182 <View style={[{marginBottom: 75}]}> 183 <Loader size="xl" /> 184 </View> 185 </View> 186 )} 187 </View> 188 </Layout.Center> 189 ) 190} 191 192function InnerReady({ 193 hasScrolled, 194 setHasScrolled, 195 convo, 196 isActive, 197 isDisabled, 198 hasMessages, 199}: { 200 hasScrolled: boolean 201 setHasScrolled: React.Dispatch<React.SetStateAction<boolean>> 202 convo: ConvoWithDetails | null 203 isActive: boolean 204 isDisabled: boolean 205 hasMessages: boolean 206}) { 207 const navigation = useNavigation<NavigationProp>() 208 const {top: topInset} = useSafeAreaInsets() 209 const [headerHeight, setHeaderHeight] = useState(0) 210 const onHeaderLayout = (e: LayoutChangeEvent) => { 211 setHeaderHeight(e.nativeEvent.layout.height) 212 } 213 const {params} = 214 useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>() 215 const {needsEmailVerification} = useEmail() 216 const emailDialogControl = useEmailDialogControl() 217 218 /** 219 * Must be non-reactive, otherwise the update to open the global dialog will 220 * cause a re-render loop. 221 */ 222 const maybeBlockForEmailVerification = useNonReactiveCallback(() => { 223 if (needsEmailVerification) { 224 /* 225 * HACKFIX 226 * 227 * Load bearing timeout, to bump this state update until the after the 228 * `navigator.addListener('state')` handler closes elements from 229 * `shell/index.*.tsx` - sfn & esb 230 */ 231 setTimeout(() => 232 emailDialogControl.open({ 233 id: EmailDialogScreenID.Verify, 234 instructions: [ 235 <Trans key="pre-compose"> 236 Before you can message another user, you must first verify your 237 email. 238 </Trans>, 239 ], 240 onCloseWithoutVerifying: () => { 241 if (navigation.canGoBack()) { 242 navigation.goBack() 243 } else { 244 navigation.navigate('Messages', {animation: 'pop'}) 245 } 246 }, 247 }), 248 ) 249 } 250 }) 251 252 useEffect(() => { 253 maybeBlockForEmailVerification() 254 }, [maybeBlockForEmailVerification]) 255 256 const primaryMember = useMaybeProfileShadow(convo?.primaryMember) 257 const moderationOpts = useModerationOpts() 258 const primaryMemberModeration = useMemo(() => { 259 if (!primaryMember || !moderationOpts) return null 260 return moderateProfile(primaryMember, moderationOpts) 261 }, [primaryMember, moderationOpts]) 262 263 const header = <MessagesListHeader convo={convo} /> 264 265 return ( 266 <> 267 {IS_LIQUID_GLASS ? ( 268 <ScrollEdgeEffect 269 edge="top" 270 style={[a.absolute, a.w_full, a.z_10, {paddingTop: topInset}]} 271 onLayout={onHeaderLayout}> 272 {header} 273 </ScrollEdgeEffect> 274 ) : ( 275 header 276 )} 277 {isActive && ( 278 <MessagesList 279 hasScrolled={hasScrolled} 280 setHasScrolled={setHasScrolled} 281 hasAcceptOverride={!!params.accept} 282 transparentHeaderHeight={IS_LIQUID_GLASS ? headerHeight : 0} 283 footer={ 284 isDisabled ? ( 285 <ChatDisabled /> 286 ) : convo && primaryMember && primaryMemberModeration?.blocked ? ( 287 <MessagesListBlockedFooter 288 recipient={primaryMember} 289 convoId={convo.view.id} 290 hasMessages={hasMessages} 291 moderation={primaryMemberModeration} 292 /> 293 ) : null 294 } 295 /> 296 )} 297 298 {/*{!IS_INTERNAL && convo?.kind === 'group' && <GroupChatGate />}*/} 299 </> 300 ) 301} 302 303// eslint-disable-next-line @typescript-eslint/no-unused-vars 304function GroupChatGate() { 305 const {t: l} = useLingui() 306 const ax = useAnalytics() 307 const navigation = useNavigation<NavigationProp>() 308 309 const groupChatGateDialogControl = Dialog.useDialogControl() 310 311 const isGatedGroupChat = !ax.features.enabled(ax.features.GroupChatsEnable) 312 313 useEffect(() => { 314 if (isGatedGroupChat) { 315 setTimeout(() => groupChatGateDialogControl.open()) 316 } 317 }, [isGatedGroupChat, groupChatGateDialogControl]) 318 319 const hasBeenReleased = ax.features.enabled( 320 ax.features.GroupChatsHasBeenReleased, 321 ) 322 323 const isAlreadyGoingBackRef = useRef(false) 324 const onGoBack = () => { 325 if (isAlreadyGoingBackRef.current) return 326 isAlreadyGoingBackRef.current = true 327 if (navigation.canGoBack()) { 328 navigation.goBack() 329 } else { 330 navigation.replace('Messages', {animation: 'pop'}) 331 } 332 } 333 334 return ( 335 <Prompt.Outer 336 control={groupChatGateDialogControl} 337 onClose={onGoBack} 338 nativeOptions={{preventDismiss: true, preventExpansion: true}} 339 testID="groupChatGateDialog"> 340 <Prompt.Content> 341 <View style={[a.w_full, a.align_center, a.py_3xl]}> 342 <Text style={{fontSize: 72}} emoji> 343 馃惔 344 </Text> 345 </View> 346 <Prompt.TitleText style={[a.text_center]}> 347 {hasBeenReleased ? ( 348 <Trans>Group chats are now available</Trans> 349 ) : ( 350 <Trans>Group chats are not yet available</Trans> 351 )} 352 </Prompt.TitleText> 353 <Prompt.DescriptionText style={[a.text_center]}> 354 {hasBeenReleased ? ( 355 <Trans>Update your app to the latest version to join in!</Trans> 356 ) : ( 357 <Trans> 358 Hold your horses! This feature isn't available to you yet. Please 359 check back later. 360 </Trans> 361 )} 362 </Prompt.DescriptionText> 363 </Prompt.Content> 364 <Prompt.Actions> 365 <Prompt.Action 366 cta={l`Go Back`} 367 onPress={onGoBack} 368 color="primary_subtle" 369 /> 370 </Prompt.Actions> 371 </Prompt.Outer> 372 ) 373}