Bluesky app fork with some witchin' additions 馃挮
at main 417 lines 13 kB view raw
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}