300a50b69a
Phase 3 tasks 3-7 (parallel wave): composer/post pipeline on pdsClient/appviewClient with structural+instance blob guards and a golden CID fixture test; chat Convo/EventBus/queries on the dedicated chat client; preferences sugar to SDK actions on the PDS client; remaining state/queries producers (usePostThread unspecced flip, video scoped-token clients, notifications, starter packs, lists) to client.call; UI runtime sweep (AtUri from @atproto/syntax, moderation fns from @bsky.app/sdk/moderation, SDK RichText, ozone reason tokens, guard rewrites via #/types/bsky). Intermediate checkpoint (hooks skipped): ~114 typecheck errors remain in cross-boundary consumer files, resolved by the type-only codemod next. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
408 lines
13 KiB
TypeScript
408 lines
13 KiB
TypeScript
import {useEffect, useState} from 'react'
|
|
import {Pressable, View} from 'react-native'
|
|
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
|
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
|
|
import {AtUri} from '@atproto/syntax'
|
|
import {type ModerationOpts} from '@bsky.app/sdk/moderation'
|
|
import {msg} from '@lingui/core/macro'
|
|
import {useLingui} from '@lingui/react'
|
|
import {Trans} from '@lingui/react/macro'
|
|
|
|
import {JOINED_THIS_WEEK} from '#/lib/constants'
|
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
|
import {createStarterPackGooglePlayUri} from '#/lib/strings/starter-pack'
|
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
|
import {useStarterPackQuery} from '#/state/queries/starter-packs'
|
|
import {
|
|
useActiveStarterPack,
|
|
useSetActiveStarterPack,
|
|
} from '#/state/shell/landing'
|
|
import {LoggedOutScreenState} from '#/view/com/auth/LoggedOut'
|
|
import {formatCount} from '#/view/com/util/numeric/format'
|
|
import {Logo} from '#/view/icons/Logo'
|
|
import {atoms as a, useTheme} from '#/alf'
|
|
import {Button, ButtonText} from '#/components/Button'
|
|
import {useDialogControl} from '#/components/Dialog'
|
|
import * as FeedCard from '#/components/FeedCard'
|
|
import {useRichText} from '#/components/hooks/useRichText'
|
|
import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending'
|
|
import * as Layout from '#/components/Layout'
|
|
import {LinearGradientBackground} from '#/components/LinearGradientBackground'
|
|
import {ListMaybePlaceholder} from '#/components/Lists'
|
|
import {Default as ProfileCard} from '#/components/ProfileCard'
|
|
import * as Prompt from '#/components/Prompt'
|
|
import {RichText} from '#/components/RichText'
|
|
import {Text} from '#/components/Typography'
|
|
import {useAnalytics} from '#/analytics'
|
|
import {IS_WEB, IS_WEB_MOBILE_ANDROID} from '#/env'
|
|
import {app} from '#/lexicons'
|
|
import * as bsky from '#/types/bsky'
|
|
|
|
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
|
|
|
interface AppClipMessage {
|
|
action: 'present' | 'store'
|
|
keyToStoreAs?: string
|
|
jsonToStore?: string
|
|
}
|
|
|
|
export function postAppClipMessage(message: AppClipMessage) {
|
|
// @ts-expect-error safari webview only
|
|
window.webkit.messageHandlers.onMessage.postMessage(JSON.stringify(message))
|
|
}
|
|
|
|
export function LandingScreen({
|
|
setScreenState,
|
|
}: {
|
|
setScreenState: (state: LoggedOutScreenState) => void
|
|
}) {
|
|
const moderationOpts = useModerationOpts()
|
|
const activeStarterPack = useActiveStarterPack()
|
|
|
|
const {
|
|
data: starterPack,
|
|
isError: isErrorStarterPack,
|
|
isFetching,
|
|
} = useStarterPackQuery({
|
|
uri: activeStarterPack?.uri,
|
|
})
|
|
|
|
const isValid =
|
|
starterPack &&
|
|
starterPack.list &&
|
|
AppBskyGraphDefs.validateStarterPackView(starterPack) &&
|
|
AppBskyGraphStarterpack.validateRecord(starterPack.record)
|
|
|
|
useEffect(() => {
|
|
if (isErrorStarterPack || (starterPack && !isValid)) {
|
|
setScreenState(LoggedOutScreenState.S_LoginOrCreateAccount)
|
|
}
|
|
}, [isErrorStarterPack, setScreenState, isValid, starterPack])
|
|
|
|
if (isFetching || !starterPack || !isValid || !moderationOpts) {
|
|
return <ListMaybePlaceholder isLoading={true} />
|
|
}
|
|
|
|
// Just for types, this cannot be hit
|
|
if (!bsky.isType(app.bsky.graph.starterpack, starterPack.record)) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<LandingScreenLoaded
|
|
starterPack={starterPack}
|
|
starterPackRecord={starterPack.record}
|
|
setScreenState={setScreenState}
|
|
moderationOpts={moderationOpts}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function LandingScreenLoaded({
|
|
starterPack,
|
|
starterPackRecord: record,
|
|
setScreenState,
|
|
// TODO apply this to profile card
|
|
|
|
moderationOpts,
|
|
}: {
|
|
starterPack: AppBskyGraphDefs.StarterPackView
|
|
starterPackRecord: AppBskyGraphStarterpack.Record
|
|
setScreenState: (state: LoggedOutScreenState) => void
|
|
moderationOpts: ModerationOpts
|
|
}) {
|
|
const {creator, listItemsSample, feeds} = starterPack
|
|
const {_, i18n} = useLingui()
|
|
const ax = useAnalytics()
|
|
const t = useTheme()
|
|
const activeStarterPack = useActiveStarterPack()
|
|
const setActiveStarterPack = useSetActiveStarterPack()
|
|
const {isTabletOrDesktop} = useWebMediaQueries()
|
|
const androidDialogControl = useDialogControl()
|
|
const [descriptionRt] = useRichText(record.description || '')
|
|
|
|
const [appClipOverlayVisible, setAppClipOverlayVisible] = useState(false)
|
|
|
|
const listItemsCount = starterPack.list?.listItemCount ?? 0
|
|
|
|
const onContinue = () => {
|
|
setScreenState(LoggedOutScreenState.S_CreateAccount)
|
|
}
|
|
|
|
const onJoinPress = () => {
|
|
if (activeStarterPack?.isClip) {
|
|
setAppClipOverlayVisible(true)
|
|
postAppClipMessage({
|
|
action: 'present',
|
|
})
|
|
} else if (IS_WEB_MOBILE_ANDROID) {
|
|
androidDialogControl.open()
|
|
} else {
|
|
onContinue()
|
|
}
|
|
ax.metric('starterPack:ctaPress', {
|
|
starterPack: starterPack.uri,
|
|
})
|
|
}
|
|
|
|
const onJoinWithoutPress = () => {
|
|
if (activeStarterPack?.isClip) {
|
|
setAppClipOverlayVisible(true)
|
|
postAppClipMessage({
|
|
action: 'present',
|
|
})
|
|
} else {
|
|
setActiveStarterPack(undefined)
|
|
setScreenState(LoggedOutScreenState.S_CreateAccount)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<View style={[a.flex_1]}>
|
|
<Layout.Content ignoreTabletLayoutOffset>
|
|
<LinearGradientBackground
|
|
style={[
|
|
a.align_center,
|
|
a.gap_sm,
|
|
a.px_lg,
|
|
a.py_2xl,
|
|
isTabletOrDesktop && [a.mt_2xl, a.rounded_md],
|
|
activeStarterPack?.isClip && {
|
|
paddingTop: 100,
|
|
},
|
|
]}>
|
|
<View style={[a.flex_row, a.gap_md, a.pb_sm]}>
|
|
<Logo allowVariants={false} width={76} fill="white" />
|
|
</View>
|
|
<Text
|
|
style={[
|
|
a.font_semi_bold,
|
|
a.text_4xl,
|
|
a.text_center,
|
|
a.leading_tight,
|
|
{color: 'white'},
|
|
]}>
|
|
{record.name}
|
|
</Text>
|
|
<Text
|
|
style={[
|
|
a.text_center,
|
|
a.font_semi_bold,
|
|
a.text_md,
|
|
{color: 'white'},
|
|
]}>
|
|
Starter pack by {`@${creator.handle}`}
|
|
</Text>
|
|
</LinearGradientBackground>
|
|
<View style={[a.gap_2xl, a.mx_lg, a.my_2xl]}>
|
|
{record.description ? (
|
|
<RichText value={descriptionRt} style={[a.text_md]} />
|
|
) : null}
|
|
<View style={[a.gap_sm]}>
|
|
<Button
|
|
label={_(msg`Join Bluesky`)}
|
|
onPress={onJoinPress}
|
|
color="primary"
|
|
size="large">
|
|
<ButtonText style={[a.text_lg]}>
|
|
<Trans>Join Bluesky</Trans>
|
|
</ButtonText>
|
|
</Button>
|
|
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
|
<TrendingIcon
|
|
width={16}
|
|
style={{color: t.atoms.text_contrast_medium.color}}
|
|
/>
|
|
<Text
|
|
style={[
|
|
a.font_semi_bold,
|
|
a.text_sm,
|
|
t.atoms.text_contrast_medium,
|
|
]}
|
|
numberOfLines={1}>
|
|
<Trans>
|
|
{formatCount(i18n, JOINED_THIS_WEEK)} joined this week
|
|
</Trans>
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<View style={[a.gap_3xl]}>
|
|
{Boolean(listItemsSample?.length) && (
|
|
<View style={[a.gap_md]}>
|
|
<Text style={[a.font_bold, a.text_lg]}>
|
|
{listItemsCount <= 8 ? (
|
|
<Trans>You'll follow these people right away</Trans>
|
|
) : (
|
|
<Trans>
|
|
You'll follow these people and {listItemsCount - 8} others
|
|
</Trans>
|
|
)}
|
|
</Text>
|
|
<View
|
|
style={
|
|
isTabletOrDesktop && [
|
|
a.border,
|
|
a.rounded_md,
|
|
t.atoms.border_contrast_low,
|
|
]
|
|
}>
|
|
{starterPack.listItemsSample
|
|
?.filter(p => !p.subject.associated?.labeler)
|
|
.slice(0, 8)
|
|
.map((item, i) => (
|
|
<View
|
|
key={item.subject.did}
|
|
style={[
|
|
a.py_lg,
|
|
a.px_md,
|
|
(!isTabletOrDesktop || i !== 0) && a.border_t,
|
|
t.atoms.border_contrast_low,
|
|
{pointerEvents: 'none'},
|
|
]}>
|
|
<ProfileCard
|
|
profile={item.subject}
|
|
moderationOpts={moderationOpts}
|
|
/>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
)}
|
|
{feeds?.length ? (
|
|
<View style={[a.gap_md]}>
|
|
<Text style={[a.font_bold, a.text_lg]}>
|
|
<Trans>You'll stay updated with these feeds</Trans>
|
|
</Text>
|
|
|
|
<View
|
|
style={[
|
|
{pointerEvents: 'none'},
|
|
isTabletOrDesktop && [
|
|
a.border,
|
|
a.rounded_md,
|
|
t.atoms.border_contrast_low,
|
|
],
|
|
]}>
|
|
{feeds?.map((feed, i) => (
|
|
<View
|
|
style={[
|
|
a.py_lg,
|
|
a.px_md,
|
|
(!isTabletOrDesktop || i !== 0) && a.border_t,
|
|
t.atoms.border_contrast_low,
|
|
]}
|
|
key={feed.uri}>
|
|
<FeedCard.Default view={feed} />
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
<Button
|
|
label={_(msg`Create an account without using this starter pack`)}
|
|
variant="solid"
|
|
color="secondary"
|
|
size="large"
|
|
style={[a.py_lg]}
|
|
onPress={onJoinWithoutPress}>
|
|
<ButtonText>
|
|
<Trans>Create an account without using this starter pack</Trans>
|
|
</ButtonText>
|
|
</Button>
|
|
</View>
|
|
</Layout.Content>
|
|
<AppClipOverlay
|
|
visible={appClipOverlayVisible}
|
|
setIsVisible={setAppClipOverlayVisible}
|
|
/>
|
|
<Prompt.Outer control={androidDialogControl}>
|
|
<Prompt.Content>
|
|
<Prompt.TitleText>
|
|
<Trans>Download Bluesky</Trans>
|
|
</Prompt.TitleText>
|
|
<Prompt.DescriptionText>
|
|
<Trans>
|
|
The experience is better in the app. Download Bluesky now and
|
|
we'll pick back up where you left off.
|
|
</Trans>
|
|
</Prompt.DescriptionText>
|
|
</Prompt.Content>
|
|
<Prompt.Actions>
|
|
<Prompt.Action
|
|
cta="Download on Google Play"
|
|
color="primary"
|
|
onPress={() => {
|
|
const rkey = new AtUri(starterPack.uri).rkey
|
|
if (!rkey) return
|
|
|
|
const googlePlayUri = createStarterPackGooglePlayUri(
|
|
creator.handle,
|
|
rkey,
|
|
)
|
|
if (!googlePlayUri) return
|
|
|
|
window.location.href = googlePlayUri
|
|
}}
|
|
/>
|
|
<Prompt.Action
|
|
cta="Continue on web"
|
|
color="secondary"
|
|
onPress={onContinue}
|
|
/>
|
|
</Prompt.Actions>
|
|
</Prompt.Outer>
|
|
{IS_WEB && (
|
|
<meta
|
|
name="apple-itunes-app"
|
|
content="app-id=xyz.blueskyweb.app, app-clip-bundle-id=xyz.blueskyweb.app.AppClip, app-clip-display=card"
|
|
/>
|
|
)}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
export function AppClipOverlay({
|
|
visible,
|
|
setIsVisible,
|
|
}: {
|
|
visible: boolean
|
|
setIsVisible: (visible: boolean) => void
|
|
}) {
|
|
if (!visible) return
|
|
|
|
return (
|
|
<AnimatedPressable
|
|
accessibilityRole="button"
|
|
style={[
|
|
a.absolute,
|
|
a.inset_0,
|
|
{
|
|
backgroundColor: 'rgba(0, 0, 0, 0.95)',
|
|
zIndex: 1,
|
|
},
|
|
]}
|
|
entering={FadeIn}
|
|
exiting={FadeOut}
|
|
onPress={() => setIsVisible(false)}>
|
|
<View style={[a.flex_1, a.px_lg, {marginTop: 250}]}>
|
|
{/* Webkit needs this to have a zindex of 2? */}
|
|
<View style={[a.gap_md, {zIndex: 2}]}>
|
|
<Text
|
|
style={[
|
|
a.font_semi_bold,
|
|
a.text_4xl,
|
|
{lineHeight: 40, color: 'white'},
|
|
]}>
|
|
Download Bluesky to get started!
|
|
</Text>
|
|
<Text style={[a.text_lg, {color: 'white'}]}>
|
|
We'll remember the starter pack you chose and use it when you create
|
|
an account in the app.
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</AnimatedPressable>
|
|
)
|
|
}
|