forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import React from 'react'
2import {Pressable, View} from 'react-native'
3import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
4import {
5 AppBskyGraphDefs,
6 AppBskyGraphStarterpack,
7 AtUri,
8 type ModerationOpts,
9} from '@atproto/api'
10import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
11import {msg} from '@lingui/core/macro'
12import {useLingui} from '@lingui/react'
13import {Trans} from '@lingui/react/macro'
14
15import {JOINED_THIS_WEEK} from '#/lib/constants'
16import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
17import {createStarterPackGooglePlayUri} from '#/lib/strings/starter-pack'
18import {useModerationOpts} from '#/state/preferences/moderation-opts'
19import {useStarterPackQuery} from '#/state/queries/starter-packs'
20import {
21 useActiveStarterPack,
22 useSetActiveStarterPack,
23} from '#/state/shell/starter-pack'
24import {LoggedOutScreenState} from '#/view/com/auth/LoggedOut'
25import {formatCount} from '#/view/com/util/numeric/format'
26import {Logo} from '#/view/icons/Logo'
27import {atoms as a, useTheme} from '#/alf'
28import {Button, ButtonText} from '#/components/Button'
29import {useDialogControl} from '#/components/Dialog'
30import * as FeedCard from '#/components/FeedCard'
31import {useRichText} from '#/components/hooks/useRichText'
32import * as Layout from '#/components/Layout'
33import {LinearGradientBackground} from '#/components/LinearGradientBackground'
34import {ListMaybePlaceholder} from '#/components/Lists'
35import {Default as ProfileCard} from '#/components/ProfileCard'
36import * as Prompt from '#/components/Prompt'
37import {RichText} from '#/components/RichText'
38import {Text} from '#/components/Typography'
39import {useAnalytics} from '#/analytics'
40import {IS_WEB, IS_WEB_MOBILE_ANDROID} from '#/env'
41import * as bsky from '#/types/bsky'
42
43const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
44
45interface AppClipMessage {
46 action: 'present' | 'store'
47 keyToStoreAs?: string
48 jsonToStore?: string
49}
50
51export function postAppClipMessage(message: AppClipMessage) {
52 // @ts-expect-error safari webview only
53 window.webkit.messageHandlers.onMessage.postMessage(JSON.stringify(message))
54}
55
56export function LandingScreen({
57 setScreenState,
58}: {
59 setScreenState: (state: LoggedOutScreenState) => void
60}) {
61 const moderationOpts = useModerationOpts()
62 const activeStarterPack = useActiveStarterPack()
63
64 const {
65 data: starterPack,
66 isError: isErrorStarterPack,
67 isFetching,
68 } = useStarterPackQuery({
69 uri: activeStarterPack?.uri,
70 })
71
72 const isValid =
73 starterPack &&
74 starterPack.list &&
75 AppBskyGraphDefs.validateStarterPackView(starterPack) &&
76 AppBskyGraphStarterpack.validateRecord(starterPack.record)
77
78 React.useEffect(() => {
79 if (isErrorStarterPack || (starterPack && !isValid)) {
80 setScreenState(LoggedOutScreenState.S_LoginOrCreateAccount)
81 }
82 }, [isErrorStarterPack, setScreenState, isValid, starterPack])
83
84 if (isFetching || !starterPack || !isValid || !moderationOpts) {
85 return <ListMaybePlaceholder isLoading={true} />
86 }
87
88 // Just for types, this cannot be hit
89 if (
90 !bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
91 starterPack.record,
92 AppBskyGraphStarterpack.isRecord,
93 )
94 ) {
95 return null
96 }
97
98 return (
99 <LandingScreenLoaded
100 starterPack={starterPack}
101 starterPackRecord={starterPack.record}
102 setScreenState={setScreenState}
103 moderationOpts={moderationOpts}
104 />
105 )
106}
107
108function LandingScreenLoaded({
109 starterPack,
110 starterPackRecord: record,
111 setScreenState,
112 // TODO apply this to profile card
113
114 moderationOpts,
115}: {
116 starterPack: AppBskyGraphDefs.StarterPackView
117 starterPackRecord: AppBskyGraphStarterpack.Record
118 setScreenState: (state: LoggedOutScreenState) => void
119 moderationOpts: ModerationOpts
120}) {
121 const {creator, listItemsSample, feeds} = starterPack
122 const {_, i18n} = useLingui()
123 const ax = useAnalytics()
124 const t = useTheme()
125 const activeStarterPack = useActiveStarterPack()
126 const setActiveStarterPack = useSetActiveStarterPack()
127 const {isTabletOrDesktop} = useWebMediaQueries()
128 const androidDialogControl = useDialogControl()
129 const [descriptionRt] = useRichText(record.description || '')
130
131 const [appClipOverlayVisible, setAppClipOverlayVisible] =
132 React.useState(false)
133
134 const listItemsCount = starterPack.list?.listItemCount ?? 0
135
136 const onContinue = () => {
137 setScreenState(LoggedOutScreenState.S_CreateAccount)
138 }
139
140 const onJoinPress = () => {
141 if (activeStarterPack?.isClip) {
142 setAppClipOverlayVisible(true)
143 postAppClipMessage({
144 action: 'present',
145 })
146 } else if (IS_WEB_MOBILE_ANDROID) {
147 androidDialogControl.open()
148 } else {
149 onContinue()
150 }
151 ax.metric('starterPack:ctaPress', {
152 starterPack: starterPack.uri,
153 })
154 }
155
156 const onJoinWithoutPress = () => {
157 if (activeStarterPack?.isClip) {
158 setAppClipOverlayVisible(true)
159 postAppClipMessage({
160 action: 'present',
161 })
162 } else {
163 setActiveStarterPack(undefined)
164 setScreenState(LoggedOutScreenState.S_CreateAccount)
165 }
166 }
167
168 return (
169 <View style={[a.flex_1]}>
170 <Layout.Content ignoreTabletLayoutOffset>
171 <LinearGradientBackground
172 style={[
173 a.align_center,
174 a.gap_sm,
175 a.px_lg,
176 a.py_2xl,
177 isTabletOrDesktop && [a.mt_2xl, a.rounded_md],
178 activeStarterPack?.isClip && {
179 paddingTop: 100,
180 },
181 ]}>
182 <View style={[a.flex_row, a.gap_md, a.pb_sm]}>
183 <Logo width={76} fill="white" />
184 </View>
185 <Text
186 style={[
187 a.font_semi_bold,
188 a.text_4xl,
189 a.text_center,
190 a.leading_tight,
191 {color: 'white'},
192 ]}>
193 {record.name}
194 </Text>
195 <Text
196 style={[
197 a.text_center,
198 a.font_semi_bold,
199 a.text_md,
200 {color: 'white'},
201 ]}>
202 Starter pack by {`@${creator.handle}`}
203 </Text>
204 </LinearGradientBackground>
205 <View style={[a.gap_2xl, a.mx_lg, a.my_2xl]}>
206 {record.description ? (
207 <RichText value={descriptionRt} style={[a.text_md]} />
208 ) : null}
209 <View style={[a.gap_sm]}>
210 <Button
211 label={_(msg`Join Bluesky`)}
212 onPress={onJoinPress}
213 variant="solid"
214 color="primary"
215 size="large">
216 <ButtonText style={[a.text_lg]}>
217 <Trans>Join Bluesky</Trans>
218 </ButtonText>
219 </Button>
220 <View style={[a.flex_row, a.align_center, a.gap_sm]}>
221 <FontAwesomeIcon
222 icon="arrow-trend-up"
223 size={12}
224 color={t.atoms.text_contrast_medium.color}
225 />
226 <Text
227 style={[
228 a.font_semi_bold,
229 a.text_sm,
230 t.atoms.text_contrast_medium,
231 ]}
232 numberOfLines={1}>
233 <Trans>
234 {formatCount(i18n, JOINED_THIS_WEEK)} joined this week
235 </Trans>
236 </Text>
237 </View>
238 </View>
239 <View style={[a.gap_3xl]}>
240 {Boolean(listItemsSample?.length) && (
241 <View style={[a.gap_md]}>
242 <Text style={[a.font_bold, a.text_lg]}>
243 {listItemsCount <= 8 ? (
244 <Trans>You'll follow these people right away</Trans>
245 ) : (
246 <Trans>
247 You'll follow these people and {listItemsCount - 8} others
248 </Trans>
249 )}
250 </Text>
251 <View
252 style={
253 isTabletOrDesktop && [
254 a.border,
255 a.rounded_md,
256 t.atoms.border_contrast_low,
257 ]
258 }>
259 {starterPack.listItemsSample
260 ?.filter(p => !p.subject.associated?.labeler)
261 .slice(0, 8)
262 .map((item, i) => (
263 <View
264 key={item.subject.did}
265 style={[
266 a.py_lg,
267 a.px_md,
268 (!isTabletOrDesktop || i !== 0) && a.border_t,
269 t.atoms.border_contrast_low,
270 {pointerEvents: 'none'},
271 ]}>
272 <ProfileCard
273 profile={item.subject}
274 moderationOpts={moderationOpts}
275 />
276 </View>
277 ))}
278 </View>
279 </View>
280 )}
281 {feeds?.length ? (
282 <View style={[a.gap_md]}>
283 <Text style={[a.font_bold, a.text_lg]}>
284 <Trans>You'll stay updated with these feeds</Trans>
285 </Text>
286
287 <View
288 style={[
289 {pointerEvents: 'none'},
290 isTabletOrDesktop && [
291 a.border,
292 a.rounded_md,
293 t.atoms.border_contrast_low,
294 ],
295 ]}>
296 {feeds?.map((feed, i) => (
297 <View
298 style={[
299 a.py_lg,
300 a.px_md,
301 (!isTabletOrDesktop || i !== 0) && a.border_t,
302 t.atoms.border_contrast_low,
303 ]}
304 key={feed.uri}>
305 <FeedCard.Default view={feed} />
306 </View>
307 ))}
308 </View>
309 </View>
310 ) : null}
311 </View>
312 <Button
313 label={_(msg`Create an account without using this starter pack`)}
314 variant="solid"
315 color="secondary"
316 size="large"
317 style={[a.py_lg]}
318 onPress={onJoinWithoutPress}>
319 <ButtonText>
320 <Trans>Create an account without using this starter pack</Trans>
321 </ButtonText>
322 </Button>
323 </View>
324 </Layout.Content>
325 <AppClipOverlay
326 visible={appClipOverlayVisible}
327 setIsVisible={setAppClipOverlayVisible}
328 />
329 <Prompt.Outer control={androidDialogControl}>
330 <Prompt.Content>
331 <Prompt.TitleText>
332 <Trans>Download Bluesky</Trans>
333 </Prompt.TitleText>
334 <Prompt.DescriptionText>
335 <Trans>
336 The experience is better in the app. Download Bluesky now and
337 we'll pick back up where you left off.
338 </Trans>
339 </Prompt.DescriptionText>
340 </Prompt.Content>
341 <Prompt.Actions>
342 <Prompt.Action
343 cta="Download on Google Play"
344 color="primary"
345 onPress={() => {
346 const rkey = new AtUri(starterPack.uri).rkey
347 if (!rkey) return
348
349 const googlePlayUri = createStarterPackGooglePlayUri(
350 creator.handle,
351 rkey,
352 )
353 if (!googlePlayUri) return
354
355 window.location.href = googlePlayUri
356 }}
357 />
358 <Prompt.Action
359 cta="Continue on web"
360 color="secondary"
361 onPress={onContinue}
362 />
363 </Prompt.Actions>
364 </Prompt.Outer>
365 {IS_WEB && (
366 <meta
367 name="apple-itunes-app"
368 content="app-id=app.witchsky, app-clip-bundle-id=app.witchsky.AppClip, app-clip-display=card"
369 />
370 )}
371 </View>
372 )
373}
374
375export function AppClipOverlay({
376 visible,
377 setIsVisible,
378}: {
379 visible: boolean
380 setIsVisible: (visible: boolean) => void
381}) {
382 if (!visible) return
383
384 return (
385 <AnimatedPressable
386 accessibilityRole="button"
387 style={[
388 a.absolute,
389 a.inset_0,
390 {
391 backgroundColor: 'rgba(0, 0, 0, 0.95)',
392 zIndex: 1,
393 },
394 ]}
395 entering={FadeIn}
396 exiting={FadeOut}
397 onPress={() => setIsVisible(false)}>
398 <View style={[a.flex_1, a.px_lg, {marginTop: 250}]}>
399 {/* Webkit needs this to have a zindex of 2? */}
400 <View style={[a.gap_md, {zIndex: 2}]}>
401 <Text
402 style={[
403 a.font_semi_bold,
404 a.text_4xl,
405 {lineHeight: 40, color: 'white'},
406 ]}>
407 Download Bluesky to get started!
408 </Text>
409 <Text style={[a.text_lg, {color: 'white'}]}>
410 We'll remember the starter pack you chose and use it when you create
411 an account in the app.
412 </Text>
413 </View>
414 </View>
415 </AnimatedPressable>
416 )
417}