forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import React, {useCallback, useEffect} from 'react'
2import {View} from 'react-native'
3import {
4 type AppBskyActorDefs,
5 moderateProfile,
6 type ModerationDecision,
7} from '@atproto/api'
8import {msg, Trans} from '@lingui/macro'
9import {useLingui} from '@lingui/react'
10import {
11 type RouteProp,
12 useFocusEffect,
13 useNavigation,
14 useRoute,
15} from '@react-navigation/native'
16import {type NativeStackScreenProps} from '@react-navigation/native-stack'
17
18import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
19import {
20 type CommonNavigatorParams,
21 type NavigationProp,
22} from '#/lib/routes/types'
23import {type Shadow, useMaybeProfileShadow} from '#/state/cache/profile-shadow'
24import {useEmail} from '#/state/email-verification'
25import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo'
26import {ConvoStatus} from '#/state/messages/convo/types'
27import {useCurrentConvoId} from '#/state/messages/current-convo-id'
28import {useModerationOpts} from '#/state/preferences/moderation-opts'
29import {useProfileQuery} from '#/state/queries/profile'
30import {useSetMinimalShellMode} from '#/state/shell'
31import {MessagesList} from '#/screens/Messages/components/MessagesList'
32import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
33import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
34import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
35import {
36 EmailDialogScreenID,
37 useEmailDialogControl,
38} from '#/components/dialogs/EmailDialog'
39import {MessagesListBlockedFooter} from '#/components/dms/MessagesListBlockedFooter'
40import {MessagesListHeader} from '#/components/dms/MessagesListHeader'
41import {Error} from '#/components/Error'
42import * as Layout from '#/components/Layout'
43import {Loader} from '#/components/Loader'
44import {IS_WEB} from '#/env'
45
46type Props = NativeStackScreenProps<
47 CommonNavigatorParams,
48 'MessagesConversation'
49>
50
51export function MessagesConversationScreen(props: Props) {
52 const {_} = useLingui()
53 const aaCopy = useAgeAssuranceCopy()
54 return (
55 <AgeRestrictedScreen
56 screenTitle={_(msg`Conversation`)}
57 infoText={aaCopy.chatsInfoText}>
58 <MessagesConversationScreenInner {...props} />
59 </AgeRestrictedScreen>
60 )
61}
62
63export function MessagesConversationScreenInner({route}: Props) {
64 const {gtMobile} = useBreakpoints()
65 const setMinimalShellMode = useSetMinimalShellMode()
66
67 const convoId = route.params.conversation
68 const {setCurrentConvoId} = useCurrentConvoId()
69
70 useFocusEffect(
71 useCallback(() => {
72 setCurrentConvoId(convoId)
73
74 if (IS_WEB && !gtMobile) {
75 setMinimalShellMode(true)
76 } else {
77 setMinimalShellMode(false)
78 }
79
80 return () => {
81 setCurrentConvoId(undefined)
82 setMinimalShellMode(false)
83 }
84 }, [gtMobile, convoId, setCurrentConvoId, setMinimalShellMode]),
85 )
86
87 return (
88 <Layout.Screen testID="convoScreen" style={web([{minHeight: 0}, a.flex_1])}>
89 <ConvoProvider key={convoId} convoId={convoId}>
90 <Inner />
91 </ConvoProvider>
92 </Layout.Screen>
93 )
94}
95
96function Inner() {
97 const t = useTheme()
98 const convoState = useConvo()
99 const {_} = useLingui()
100
101 const moderationOpts = useModerationOpts()
102 const {data: recipientUnshadowed} = useProfileQuery({
103 did: convoState.recipients?.[0].did,
104 })
105 const recipient = useMaybeProfileShadow(recipientUnshadowed)
106
107 const moderation = React.useMemo(() => {
108 if (!recipient || !moderationOpts) return null
109 return moderateProfile(recipient, moderationOpts)
110 }, [recipient, moderationOpts])
111
112 // Because we want to give the list a chance to asynchronously scroll to the end before it is visible to the user,
113 // we use `hasScrolled` to determine when to render. With that said however, there is a chance that the chat will be
114 // empty. So, we also check for that possible state as well and render once we can.
115 const [hasScrolled, setHasScrolled] = React.useState(false)
116 const readyToShow =
117 hasScrolled ||
118 (isConvoActive(convoState) &&
119 !convoState.isFetchingHistory &&
120 convoState.items.length === 0)
121
122 // Any time that we re-render the `Initializing` state, we have to reset `hasScrolled` to false. After entering this
123 // state, we know that we're resetting the list of messages and need to re-scroll to the bottom when they get added.
124 React.useEffect(() => {
125 if (convoState.status === ConvoStatus.Initializing) {
126 setHasScrolled(false)
127 }
128 }, [convoState.status])
129
130 if (convoState.status === ConvoStatus.Error) {
131 return (
132 <>
133 <Layout.Center style={[a.flex_1]}>
134 {moderation ? (
135 <MessagesListHeader moderation={moderation} profile={recipient} />
136 ) : (
137 <MessagesListHeader />
138 )}
139 </Layout.Center>
140 <Error
141 title={_(msg`Something went wrong`)}
142 message={_(msg`We couldn't load this conversation`)}
143 onRetry={() => convoState.error.retry()}
144 sideBorders={false}
145 />
146 </>
147 )
148 }
149
150 return (
151 <Layout.Center style={[a.flex_1]}>
152 {!readyToShow &&
153 (moderation ? (
154 <MessagesListHeader moderation={moderation} profile={recipient} />
155 ) : (
156 <MessagesListHeader />
157 ))}
158 <View style={[a.flex_1]}>
159 {moderation && recipient ? (
160 <InnerReady
161 moderation={moderation}
162 recipient={recipient}
163 hasScrolled={hasScrolled}
164 setHasScrolled={setHasScrolled}
165 />
166 ) : (
167 <View style={[a.align_center, a.gap_sm, a.flex_1]} />
168 )}
169 {!readyToShow && (
170 <View
171 style={[
172 a.absolute,
173 a.z_10,
174 a.w_full,
175 a.h_full,
176 a.justify_center,
177 a.align_center,
178 t.atoms.bg,
179 ]}>
180 <View style={[{marginBottom: 75}]}>
181 <Loader size="xl" />
182 </View>
183 </View>
184 )}
185 </View>
186 </Layout.Center>
187 )
188}
189
190function InnerReady({
191 moderation,
192 recipient,
193 hasScrolled,
194 setHasScrolled,
195}: {
196 moderation: ModerationDecision
197 recipient: Shadow<AppBskyActorDefs.ProfileViewDetailed>
198 hasScrolled: boolean
199 setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
200}) {
201 const convoState = useConvo()
202 const navigation = useNavigation<NavigationProp>()
203 const {params} =
204 useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
205 const {needsEmailVerification} = useEmail()
206 const emailDialogControl = useEmailDialogControl()
207
208 /**
209 * Must be non-reactive, otherwise the update to open the global dialog will
210 * cause a re-render loop.
211 */
212 const maybeBlockForEmailVerification = useNonReactiveCallback(() => {
213 if (needsEmailVerification) {
214 /*
215 * HACKFIX
216 *
217 * Load bearing timeout, to bump this state update until the after the
218 * `navigator.addListener('state')` handler closes elements from
219 * `shell/index.*.tsx` - sfn & esb
220 */
221 setTimeout(() =>
222 emailDialogControl.open({
223 id: EmailDialogScreenID.Verify,
224 instructions: [
225 <Trans key="pre-compose">
226 Before you can message another user, you must first verify your
227 email.
228 </Trans>,
229 ],
230 onCloseWithoutVerifying: () => {
231 if (navigation.canGoBack()) {
232 navigation.goBack()
233 } else {
234 navigation.navigate('Messages', {animation: 'pop'})
235 }
236 },
237 }),
238 )
239 }
240 })
241
242 useEffect(() => {
243 maybeBlockForEmailVerification()
244 }, [maybeBlockForEmailVerification])
245
246 return (
247 <>
248 <MessagesListHeader profile={recipient} moderation={moderation} />
249 {isConvoActive(convoState) && (
250 <MessagesList
251 hasScrolled={hasScrolled}
252 setHasScrolled={setHasScrolled}
253 blocked={moderation?.blocked}
254 hasAcceptOverride={!!params.accept}
255 footer={
256 <MessagesListBlockedFooter
257 recipient={recipient}
258 convoId={convoState.convo.id}
259 hasMessages={convoState.items.length > 0}
260 moderation={moderation}
261 />
262 }
263 />
264 )}
265 </>
266 )
267}