forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}