From b283fbd48430d16e0865af83c0094ef93e6d0c7b Mon Sep 17 00:00:00 2001 From: Hailey Date: Wed, 19 Jun 2024 15:09:00 -0700 Subject: [PATCH] UI tweaks and nits for starter packs (#4548) Co-authored-by: Dan Abramov Co-authored-by: Paul Frazee Co-authored-by: Eric Bailey Co-authored-by: Samuel Newman fix truncation on native (#4575) --- __tests__/lib/string.test.ts | 41 +++- assets/icons/starterPack.svg | 1 + src/Navigation.tsx | 6 + src/components/FeedCard.tsx | 6 +- src/components/NewskieDialog.tsx | 31 ++- src/components/StarterPack/Main/FeedsList.tsx | 6 +- .../StarterPack/Main/ProfilesList.tsx | 21 +- src/components/StarterPack/NewskieDialog.tsx | 85 -------- .../StarterPack/ProfileStarterPacks.tsx | 47 +++-- src/components/StarterPack/QrCode.tsx | 2 +- src/components/StarterPack/QrCodeDialog.tsx | 104 ++++++---- .../StarterPack/StarterPackCard.tsx | 17 +- .../Wizard/WizardEditListDialog.tsx | 9 +- .../StarterPack/Wizard/WizardListCard.tsx | 34 +-- src/components/hooks/useStarterPackEntry.ts | 6 +- src/components/icons/StarterPack.tsx | 8 + src/components/icons/StarterPackIcon.tsx | 26 --- src/components/icons/TEMPLATE.tsx | 31 ++- src/components/icons/common.ts | 32 --- src/components/icons/common.tsx | 59 ++++++ src/lib/generate-starterpack.ts | 37 ++-- src/lib/routes/types.ts | 2 + src/lib/statsig/events.ts | 2 +- src/lib/strings/starter-pack.ts | 10 +- src/routes.ts | 5 +- src/screens/Onboarding/StepFinished.tsx | 17 +- src/screens/Signup/index.tsx | 40 +++- .../StarterPack/StarterPackLandingScreen.tsx | 196 +++++++++--------- src/screens/StarterPack/StarterPackScreen.tsx | 38 ++-- src/screens/StarterPack/Wizard/State.tsx | 8 +- .../StarterPack/Wizard/StepDetails.tsx | 6 +- .../StarterPack/Wizard/StepProfiles.tsx | 5 +- src/screens/StarterPack/Wizard/index.tsx | 56 +++-- src/state/queries/useStarterPackQuery.ts | 14 +- src/state/shell/logged-out.tsx | 12 +- src/view/com/auth/LoggedOut.tsx | 18 +- src/view/com/notifications/FeedItem.tsx | 4 +- src/view/com/profile/ProfileSubpageHeader.tsx | 4 +- src/view/screens/Home.tsx | 38 +--- src/view/screens/Profile.tsx | 1 + src/view/screens/Storybook/Icons.tsx | 8 + src/view/shell/desktop/LeftNav.tsx | 8 +- 42 files changed, 615 insertions(+), 486 deletions(-) create mode 100644 assets/icons/starterPack.svg delete mode 100644 src/components/StarterPack/NewskieDialog.tsx create mode 100644 src/components/icons/StarterPack.tsx delete mode 100644 src/components/icons/StarterPackIcon.tsx delete mode 100644 src/components/icons/common.ts create mode 100644 src/components/icons/common.tsx diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts index c5ae06591e..9cab8b6415 100644 --- a/__tests__/lib/string.test.ts +++ b/__tests__/lib/string.test.ts @@ -808,15 +808,21 @@ describe('createStarterPackLinkFromAndroidReferrer', () => { it('returns a link when input contains utm_source and utm_content', () => { expect( createStarterPackLinkFromAndroidReferrer( - 'utm_source=bluesky&utm_content=starterpack-haileyok.com-rkey', + 'utm_source=bluesky&utm_content=starterpack_haileyok.com_rkey', ), ).toEqual(validOutput) + + expect( + createStarterPackLinkFromAndroidReferrer( + 'utm_source=bluesky&utm_content=starterpack_test-lover-9000.com_rkey', + ), + ).toEqual('https://bsky.app/start/test-lover-9000.com/rkey') }) it('returns a link when input contains utm_source and utm_content in different order', () => { expect( createStarterPackLinkFromAndroidReferrer( - 'utm_content=starterpack-haileyok.com-rkey&utm_source=bluesky', + 'utm_content=starterpack_haileyok.com_rkey&utm_source=bluesky', ), ).toEqual(validOutput) }) @@ -824,7 +830,7 @@ describe('createStarterPackLinkFromAndroidReferrer', () => { it('returns a link when input contains other parameters as well', () => { expect( createStarterPackLinkFromAndroidReferrer( - 'utm_source=bluesky&utm_medium=starterpack&utm_content=starterpack-haileyok.com-rkey', + 'utm_source=bluesky&utm_medium=starterpack&utm_content=starterpack_haileyok.com_rkey', ), ).toEqual(validOutput) }) @@ -832,7 +838,7 @@ describe('createStarterPackLinkFromAndroidReferrer', () => { it('returns null when utm_source is not present', () => { expect( createStarterPackLinkFromAndroidReferrer( - 'utm_content=starterpack-haileyok.com-rkey', + 'utm_content=starterpack_haileyok.com_rkey', ), ).toEqual(null) }) @@ -846,7 +852,7 @@ describe('createStarterPackLinkFromAndroidReferrer', () => { it('returns null when utm_content is malformed', () => { expect( createStarterPackLinkFromAndroidReferrer( - 'utm_content=starterpack-haileyok.com', + 'utm_content=starterpack_haileyok.com', ), ).toEqual(null) @@ -856,13 +862,13 @@ describe('createStarterPackLinkFromAndroidReferrer', () => { expect( createStarterPackLinkFromAndroidReferrer( - 'utm_content=starterpack-haileyok.com-rkey-more', + 'utm_content=starterpack_haileyok.com_rkey_more', ), ).toEqual(null) expect( createStarterPackLinkFromAndroidReferrer( - 'utm_content=notastarterpack-haileyok.com-rkey', + 'utm_content=notastarterpack_haileyok.com_rkey', ), ).toEqual(null) }) @@ -906,6 +912,23 @@ describe('parseStarterPackHttpUri', () => { expect(parseStarterPackUri(validHttpUri)).toEqual(null) }) + it('returns null when the route is not /start or /starter-pack', () => { + const validHttpUri = 'https://bsky.app/start/haileyok.com/rkey' + expect(parseStarterPackUri(validHttpUri)).toEqual({ + name: 'haileyok.com', + rkey: 'rkey', + }) + + const validHttpUri2 = 'https://bsky.app/starter-pack/haileyok.com/rkey' + expect(parseStarterPackUri(validHttpUri2)).toEqual({ + name: 'haileyok.com', + rkey: 'rkey', + }) + + const invalidHttpUri = 'https://bsky.app/profile/haileyok.com/rkey' + expect(parseStarterPackUri(invalidHttpUri)).toEqual(null) + }) + it('returns the at uri when the input is a valid starterpack at uri', () => { const validAtUri = 'at://did:123/app.bsky.graph.starterpack/rkey' expect(parseStarterPackUri(validAtUri)).toEqual({ @@ -931,11 +954,11 @@ describe('parseStarterPackHttpUri', () => { describe('createStarterPackGooglePlayUri', () => { const base = - 'https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&referrer=utm_source%3Dbluesky%26utm_medium%3Dstarterpack%26utm_content%3Dstarterpack-' + 'https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&referrer=utm_source%3Dbluesky%26utm_medium%3Dstarterpack%26utm_content%3Dstarterpack_' it('returns valid google play uri when input is valid', () => { expect(createStarterPackGooglePlayUri('name', 'rkey')).toEqual( - `${base}name-rkey`, + `${base}name_rkey`, ) }) diff --git a/assets/icons/starterPack.svg b/assets/icons/starterPack.svg new file mode 100644 index 0000000000..7f0df55952 --- /dev/null +++ b/assets/icons/starterPack.svg @@ -0,0 +1 @@ + diff --git a/src/Navigation.tsx b/src/Navigation.tsx index ec2dc6a69b..a7b9dbe462 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -384,6 +384,7 @@ function HomeTabNavigator() { contentStyle: pal.view, }}> HomeScreen} /> + HomeScreen} /> {commonScreens(HomeTab)} ) @@ -520,6 +521,11 @@ const FlatNavigator = () => { getComponent={() => MessagesScreen} options={{title: title(msg`Messages`), requireAuth: true}} /> + HomeScreen} + options={{title: title(msg`Home`)}} + /> {commonScreens(Flat as typeof HomeTab, numUnread)} ) diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx index 94d97cb620..fae599bc3b 100644 --- a/src/components/FeedCard.tsx +++ b/src/components/FeedCard.tsx @@ -54,7 +54,11 @@ export function Link({ const handleOrDid = feed.creator.handle || feed.creator.did return `/profile/${handleOrDid}/feed/${urip.rkey}` }, [feed]) - return {children} + return ( + + {children} + + ) } export function Outer({children}: {children: React.ReactNode}) { diff --git a/src/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx index 0354bfc432..da57603fa6 100644 --- a/src/components/NewskieDialog.tsx +++ b/src/components/NewskieDialog.tsx @@ -9,11 +9,12 @@ import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {HITSLOP_10} from 'lib/constants' import {sanitizeDisplayName} from 'lib/strings/display-names' -import {atoms as a} from '#/alf' +import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog' import {Newskie} from '#/components/icons/Newskie' +import {Default as StarterPackCard} from '#/components/StarterPack/StarterPackCard' import {Text} from '#/components/Typography' export function NewskieDialog({ @@ -24,6 +25,7 @@ export function NewskieDialog({ disabled?: boolean }) { const {_} = useLingui() + const t = useTheme() const moderationOpts = useModerationOpts() const control = useDialogControl() const profileName = React.useMemo(() => { @@ -72,11 +74,30 @@ export function NewskieDialog({ Say hello! - - {profileName} joined Bluesky{' '} - {timeAgo(createdAt, now, {format: 'long'})} ago - + {profile.joinedViaStarterPack ? ( + + {profileName} joined Bluesky using a starter pack{' '} + {timeAgo(createdAt, now, {format: 'long'})} ago + + ) : ( + + {profileName} joined Bluesky{' '} + {timeAgo(createdAt, now, {format: 'long'})} ago + + )} + {profile.joinedViaStarterPack ? ( + + + + ) : null} diff --git a/src/components/StarterPack/Main/FeedsList.tsx b/src/components/StarterPack/Main/FeedsList.tsx index b2e395ebf6..84110998d8 100644 --- a/src/components/StarterPack/Main/FeedsList.tsx +++ b/src/components/StarterPack/Main/FeedsList.tsx @@ -4,8 +4,7 @@ import {AppBskyFeedDefs} from '@atproto/api' import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {isNative} from 'platform/detection' +import {isNative, isWeb} from 'platform/detection' import {List, ListRef} from 'view/com/util/List' import {SectionRef} from '#/screens/Profile/Sections/types' import {atoms as a, useTheme} from '#/alf' @@ -25,7 +24,6 @@ export const FeedsList = React.forwardRef( function FeedsListImpl({feeds, headerHeight, scrollElRef}, ref) { const [initialHeaderHeight] = React.useState(headerHeight) const bottomBarOffset = useBottomBarOffset(20) - const {isTabletOrDesktop} = useWebMediaQueries() const t = useTheme() const onScrollToTop = useCallback(() => { @@ -44,7 +42,7 @@ export const FeedsList = React.forwardRef( diff --git a/src/components/StarterPack/Main/ProfilesList.tsx b/src/components/StarterPack/Main/ProfilesList.tsx index cce14f59ab..cc3a80ec04 100644 --- a/src/components/StarterPack/Main/ProfilesList.tsx +++ b/src/components/StarterPack/Main/ProfilesList.tsx @@ -40,19 +40,26 @@ export const ProfilesList = React.forwardRef( const {currentAccount} = useSession() const [isPTRing, setIsPTRing] = React.useState(false) - const {data, refetch} = useListMembersQuery(listUri) - const profiles = data?.pages.flatMap(p => p.items.map(i => i.subject)) + const {data, refetch} = useListMembersQuery(listUri, 50) + + // The server returns these sorted by descending creation date, so we want to invert + const profiles = data?.pages + .flatMap(p => p.items.map(i => i.subject)) + .reverse() const isOwn = new AtUri(listUri).host === currentAccount?.did const getSortedProfiles = () => { if (!profiles) return if (!isOwn) return profiles + const myIndex = profiles.findIndex(p => p.did === currentAccount?.did) - return [ - profiles[myIndex], - ...profiles.slice(0, myIndex), - ...profiles.slice(myIndex + 1), - ] + return myIndex !== -1 + ? [ + profiles[myIndex], + ...profiles.slice(0, myIndex), + ...profiles.slice(myIndex + 1), + ] + : profiles } const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({ diff --git a/src/components/StarterPack/NewskieDialog.tsx b/src/components/StarterPack/NewskieDialog.tsx deleted file mode 100644 index 0354bfc432..0000000000 --- a/src/components/StarterPack/NewskieDialog.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react' -import {View} from 'react-native' -import {AppBskyActorDefs, moderateProfile} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {differenceInSeconds} from 'date-fns' - -import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' -import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {HITSLOP_10} from 'lib/constants' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {atoms as a} from '#/alf' -import {Button} from '#/components/Button' -import * as Dialog from '#/components/Dialog' -import {useDialogControl} from '#/components/Dialog' -import {Newskie} from '#/components/icons/Newskie' -import {Text} from '#/components/Typography' - -export function NewskieDialog({ - profile, - disabled, -}: { - profile: AppBskyActorDefs.ProfileViewDetailed - disabled?: boolean -}) { - const {_} = useLingui() - const moderationOpts = useModerationOpts() - const control = useDialogControl() - const profileName = React.useMemo(() => { - const name = profile.displayName || profile.handle - if (!moderationOpts) return name - const moderation = moderateProfile(profile, moderationOpts) - return sanitizeDisplayName(name, moderation.ui('displayName')) - }, [moderationOpts, profile]) - const [now] = React.useState(() => Date.now()) - const timeAgo = useGetTimeAgo() - const createdAt = profile.createdAt as string | undefined - const daysOld = React.useMemo(() => { - if (!createdAt) return Infinity - return differenceInSeconds(now, new Date(createdAt)) / 86400 - }, [createdAt, now]) - - if (!createdAt || daysOld > 7) return null - - return ( - - - - - - - - - Say hello! - - - - {profileName} joined Bluesky{' '} - {timeAgo(createdAt, now, {format: 'long'})} ago - - - - - - - ) -} diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx index 5ad86a82be..c2c2fa7839 100644 --- a/src/components/StarterPack/ProfileStarterPacks.tsx +++ b/src/components/StarterPack/ProfileStarterPacks.tsx @@ -25,12 +25,13 @@ import {useAgent} from 'state/session' import {List, ListRef} from 'view/com/util/List' import {Text} from 'view/com/util/text/Text' import {atoms as a, useTheme} from '#/alf' -import {Button, ButtonText} from '#/components/Button' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' import {LinearGradientBackground} from '#/components/LinearGradientBackground' import {Loader} from '#/components/Loader' import * as Prompt from '#/components/Prompt' import {Default as StarterPackCard} from '#/components/StarterPack/StarterPackCard' +import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '../icons/Plus' interface SectionRef { scrollToTop: () => void @@ -47,6 +48,7 @@ interface ProfileFeedgensProps { style?: StyleProp testID?: string setScrollViewTag: (tag: number | null) => void + isMe: boolean } function keyExtractor(item: AppBskyGraphDefs.StarterPackView) { @@ -65,6 +67,7 @@ export const ProfileStarterPacks = React.forwardRef< style, testID, setScrollViewTag, + isMe, }, ref, ) { @@ -140,7 +143,9 @@ export const ProfileStarterPacks = React.forwardRef< onEndReached={onEndReached} onRefresh={onRefresh} ListEmptyComponent={Empty} - ListFooterComponent={items?.length !== 0 ? CreateAnother : undefined} + ListFooterComponent={ + items?.length !== 0 && isMe ? CreateAnother : undefined + } /> ) @@ -148,19 +153,29 @@ export const ProfileStarterPacks = React.forwardRef< function CreateAnother() { const {_} = useLingui() + const t = useTheme() const navigation = useNavigation() return ( - + ) @@ -203,8 +218,8 @@ function Empty() { return ( - Generate a starter pack? + Generate a starter pack - You can customize your starter pack with feeds and your favorite - people if you create your own. + Bluesky will choose a set of recommended accounts from people in + your network. { - navigation.navigate('StarterPackWizard') - }} + cta={_(msg`Choose for me`)} + onPress={generate} /> { + navigation.navigate('StarterPackWizard') + }} /> diff --git a/src/components/StarterPack/QrCode.tsx b/src/components/StarterPack/QrCode.tsx index deb55222a8..6d348194d6 100644 --- a/src/components/StarterPack/QrCode.tsx +++ b/src/components/StarterPack/QrCode.tsx @@ -69,7 +69,7 @@ export const QrCode = React.forwardRef(function QrCode( on - + diff --git a/src/components/StarterPack/QrCodeDialog.tsx b/src/components/StarterPack/QrCodeDialog.tsx index 3c2068166f..2eb31ce8d5 100644 --- a/src/components/StarterPack/QrCodeDialog.tsx +++ b/src/components/StarterPack/QrCodeDialog.tsx @@ -1,9 +1,9 @@ import React from 'react' import {View} from 'react-native' import ViewShot from 'react-native-view-shot' -import {setImageAsync} from 'expo-clipboard' import * as FS from 'expo-file-system' import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker' +import * as Sharing from 'expo-sharing' import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -12,14 +12,14 @@ import {nanoid} from 'nanoid/non-secure' import {logger} from '#/logger' import {saveImageToMediaLibrary} from 'lib/media/manip' import {logEvent} from 'lib/statsig/statsig' -import {isNative} from 'platform/detection' +import {isNative, isWeb} from 'platform/detection' import * as Toast from '#/view/com/util/Toast' import {atoms as a} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {DialogControlProps} from '#/components/Dialog' +import {Loader} from '#/components/Loader' import {QrCode} from '#/components/StarterPack/QrCode' -import {Text} from '#/components/Typography' export function QrCodeDialog({ control, @@ -29,6 +29,7 @@ export function QrCodeDialog({ starterPack: AppBskyGraphDefs.StarterPackView }) { const {_} = useLingui() + const [isProcessing, setIsProcessing] = React.useState(false) const ref = React.useRef(null) @@ -75,6 +76,8 @@ export function QrCodeDialog({ return } } else { + setIsProcessing(true) + if (!AppBskyGraphStarterpack.isRecord(starterPack.record)) { return } @@ -98,24 +101,25 @@ export function QrCodeDialog({ shareType: 'qrcode', qrShareType: 'save', }) - Toast.show(_(msg`QR code saved to your camera roll!`)) + setIsProcessing(false) + Toast.show( + isWeb + ? _(msg`QR code has been downloaded!`) + : _(msg`QR code saved to your camera roll!`), + ) control.close() }) } const onCopyPress = async () => { + setIsProcessing(true) ref.current?.capture?.().then(async (uri: string) => { - if (isNative) { - const base64 = await FS.readAsStringAsync(uri, {encoding: 'base64'}) - await setImageAsync(base64) - } else { - const canvas = await getCanvas(uri) - // @ts-expect-error web only - canvas.toBlob((blob: Blob) => { - const item = new ClipboardItem({'image/png': blob}) - navigator.clipboard.write([item]) - }) - } + const canvas = await getCanvas(uri) + // @ts-expect-error web only + canvas.toBlob((blob: Blob) => { + const item = new ClipboardItem({'image/png': blob}) + navigator.clipboard.write([item]) + }) logEvent('starterPack:share', { starterPack: starterPack.uri, @@ -123,42 +127,62 @@ export function QrCodeDialog({ qrShareType: 'copy', }) Toast.show(_(msg`QR code copied to your clipboard!`)) + setIsProcessing(false) control.close() }) } + const onSharePress = async () => { + ref.current?.capture?.().then(async (uri: string) => { + control.close(() => { + Sharing.shareAsync(uri, {mimeType: 'image/png', UTI: 'image/png'}).then( + () => { + logEvent('starterPack:share', { + starterPack: starterPack.uri, + shareType: 'qrcode', + qrShareType: 'share', + }) + }, + ) + }) + }) + } + return ( - - Share this starter pack with friends! - - - - - + {isProcessing ? ( + + + + ) : ( + + + + + )} diff --git a/src/components/StarterPack/StarterPackCard.tsx b/src/components/StarterPack/StarterPackCard.tsx index c80e794cc0..b5532fc437 100644 --- a/src/components/StarterPack/StarterPackCard.tsx +++ b/src/components/StarterPack/StarterPackCard.tsx @@ -8,7 +8,7 @@ import {useLingui} from '@lingui/react' import {sanitizeHandle} from 'lib/strings/handles' import {useSession} from 'state/session' import {atoms as a, useTheme} from '#/alf' -import {StarterPackIcon} from '#/components/icons/StarterPackIcon' +import {StarterPack} from '#/components/icons/StarterPack' import {Link} from '#/components/Link' import {Text} from '#/components/Typography' @@ -25,9 +25,9 @@ export function Default({starterPack}: {starterPack: StarterPackViewBasic}) { } return ( - - - + + + {record.name} @@ -43,7 +43,9 @@ export function Default({starterPack}: {starterPack: StarterPackViewBasic}) { {record.description && ( - {record.description} + + {record.description} + )} {!!joinedAllTimeCount && joinedAllTimeCount >= 50 && ( @@ -55,10 +57,12 @@ export function Default({starterPack}: {starterPack: StarterPackViewBasic}) { } function Wrapper({ + name, creator, children, rkey, }: { + name: string creator: AppBskyActorDefs.ProfileViewBasic rkey: string children: React.ReactNode @@ -68,7 +72,8 @@ function Wrapper({ to={{ screen: 'StarterPack', params: {name: creator.handle || creator.did, rkey}, - }}> + }} + label={name}> {children} ) diff --git a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx index 1aaa7d076d..bf250ac354 100644 --- a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx +++ b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx @@ -31,11 +31,13 @@ export function WizardEditListDialog({ state, dispatch, moderationOpts, + profile, }: { control: Dialog.DialogControlProps state: WizardState dispatch: (action: WizardAction) => void moderationOpts: ModerationOpts + profile: AppBskyActorDefs.ProfileViewBasic }) { const {_} = useLingui() const t = useTheme() @@ -46,12 +48,9 @@ export function WizardEditListDialog({ const getData = () => { if (state.currentStep === 'Feeds') return state.feeds - const myIndex = state.profiles.findIndex(p => p.did === currentAccount?.did) - return [ - state.profiles[myIndex], - ...state.profiles.slice(0, myIndex), - ...state.profiles.slice(myIndex + 1), + profile, + ...state.profiles.filter(p => p.did !== currentAccount?.did), ] } diff --git a/src/components/StarterPack/Wizard/WizardListCard.tsx b/src/components/StarterPack/Wizard/WizardListCard.tsx index ec6c3549c2..da217ecbf6 100644 --- a/src/components/StarterPack/Wizard/WizardListCard.tsx +++ b/src/components/StarterPack/Wizard/WizardListCard.tsx @@ -51,6 +51,10 @@ function WizardListCard({ return ( - - - + + + + + ) } @@ -110,9 +116,9 @@ export function WizardProfileCard({ }) { const {currentAccount} = useSession() - const included = state.profiles.some(p => p.did === profile.did) const isMe = profile.did === currentAccount?.did - const disabled = isMe || state.profiles.length >= 50 + const included = isMe || state.profiles.some(p => p.did === profile.did) + const disabled = isMe || (!included && state.profiles.length >= 49) const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar') const displayName = profile.displayName ? sanitizeDisplayName(profile.displayName) diff --git a/src/components/hooks/useStarterPackEntry.ts b/src/components/hooks/useStarterPackEntry.ts index 3518fbced2..dba801e093 100644 --- a/src/components/hooks/useStarterPackEntry.ts +++ b/src/components/hooks/useStarterPackEntry.ts @@ -4,6 +4,8 @@ import {httpStarterPackUriToAtUri} from 'lib/strings/starter-pack' import {useSetActiveStarterPack} from 'state/shell/starter-pack' export function useStarterPackEntry() { + const [ready, setReady] = React.useState(false) + const setActiveStarterPack = useSetActiveStarterPack() React.useEffect(() => { @@ -19,7 +21,9 @@ export function useStarterPackEntry() { isClip, }) } + + setReady(true) }, [setActiveStarterPack]) - return true + return ready } diff --git a/src/components/icons/StarterPack.tsx b/src/components/icons/StarterPack.tsx new file mode 100644 index 0000000000..8c678bca47 --- /dev/null +++ b/src/components/icons/StarterPack.tsx @@ -0,0 +1,8 @@ +import {createMultiPathSVG} from './TEMPLATE' + +export const StarterPack = createMultiPathSVG({ + paths: [ + 'M11.26 5.227 5.02 6.899c-.734.197-1.17.95-.973 1.685l1.672 6.24c.197.734.951 1.17 1.685.973l6.24-1.672c.734-.197 1.17-.951.973-1.685L12.945 6.2a1.375 1.375 0 0 0-1.685-.973Zm-6.566.459a2.632 2.632 0 0 0-1.86 3.223l1.672 6.24a2.632 2.632 0 0 0 3.223 1.861l6.24-1.672a2.631 2.631 0 0 0 1.861-3.223l-1.672-6.24a2.632 2.632 0 0 0-3.223-1.861l-6.24 1.672Z', + 'M15.138 18.411a4.606 4.606 0 1 0 0-9.211 4.606 4.606 0 0 0 0 9.211Zm0 1.257a5.862 5.862 0 1 0 0-11.724 5.862 5.862 0 0 0 0 11.724Z', + ], +}) diff --git a/src/components/icons/StarterPackIcon.tsx b/src/components/icons/StarterPackIcon.tsx deleted file mode 100644 index dea27ea85d..0000000000 --- a/src/components/icons/StarterPackIcon.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import * as React from 'react' -import Svg, {Defs, LinearGradient, Path, Stop} from 'react-native-svg' -export function StarterPackIcon({...props}: React.ComponentProps) { - return ( - - - - - - - - - - ) -} diff --git a/src/components/icons/TEMPLATE.tsx b/src/components/icons/TEMPLATE.tsx index f49c4280bb..47a5c36b2a 100644 --- a/src/components/icons/TEMPLATE.tsx +++ b/src/components/icons/TEMPLATE.tsx @@ -30,7 +30,7 @@ export const IconTemplate_Stroke2_Corner0_Rounded = React.forwardRef( export function createSinglePathSVG({path}: {path: string}) { return React.forwardRef(function LogoImpl(props, ref) { - const {fill, size, style, ...rest} = useCommonSVGProps(props) + const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props) return ( + {gradient} ) }) } + +export function createMultiPathSVG({paths}: {paths: string[]}) { + return React.forwardRef(function LogoImpl(props, ref) { + const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props) + + return ( + + {gradient} + {paths.map((path, i) => ( + + ))} + + ) + }) +} diff --git a/src/components/icons/common.ts b/src/components/icons/common.ts deleted file mode 100644 index 669c157f51..0000000000 --- a/src/components/icons/common.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {StyleSheet, TextProps} from 'react-native' -import type {PathProps, SvgProps} from 'react-native-svg' - -import {tokens} from '#/alf' - -export type Props = { - fill?: PathProps['fill'] - style?: TextProps['style'] - size?: keyof typeof sizes -} & Omit - -export const sizes = { - xs: 12, - sm: 16, - md: 20, - lg: 24, - xl: 28, -} - -export function useCommonSVGProps(props: Props) { - const {fill, size, ...rest} = props - const style = StyleSheet.flatten(rest.style) - const _fill = fill || style?.color || tokens.color.blue_500 - const _size = Number(size ? sizes[size] : rest.width || sizes.md) - - return { - fill: _fill, - size: _size, - style, - ...rest, - } -} diff --git a/src/components/icons/common.tsx b/src/components/icons/common.tsx new file mode 100644 index 0000000000..662718338c --- /dev/null +++ b/src/components/icons/common.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import {StyleSheet, TextProps} from 'react-native' +import type {PathProps, SvgProps} from 'react-native-svg' +import {Defs, LinearGradient, Stop} from 'react-native-svg' +import {nanoid} from 'nanoid/non-secure' + +import {tokens} from '#/alf' + +export type Props = { + fill?: PathProps['fill'] + style?: TextProps['style'] + size?: keyof typeof sizes + gradient?: keyof typeof tokens.gradients +} & Omit + +export const sizes = { + xs: 12, + sm: 16, + md: 20, + lg: 24, + xl: 28, +} + +export function useCommonSVGProps(props: Props) { + const {fill, size, gradient, ...rest} = props + const style = StyleSheet.flatten(rest.style) + const _size = Number(size ? sizes[size] : rest.width || sizes.md) + let _fill = fill || style?.color || tokens.color.blue_500 + let gradientDef = null + + if (gradient && tokens.gradients[gradient]) { + const id = gradient + '_' + nanoid() + const config = tokens.gradients[gradient] + _fill = `url(#${id})` + gradientDef = ( + + + {config.values.map(([stop, fill]) => ( + + ))} + + + ) + } + + return { + fill: _fill, + size: _size, + style, + gradient: gradientDef, + ...rest, + } +} diff --git a/src/lib/generate-starterpack.ts b/src/lib/generate-starterpack.ts index 6c59bec8d8..3d415b690e 100644 --- a/src/lib/generate-starterpack.ts +++ b/src/lib/generate-starterpack.ts @@ -34,16 +34,14 @@ export const createStarterPackList = async ({ if (!list) throw new Error('List creation failed') await agent.com.atproto.repo.applyWrites({ repo: agent.session!.did, - writes: profiles.map(p => ({ - $type: 'com.atproto.repo.applyWrites#create', - collection: 'app.bsky.graph.listitem', - value: { - $type: 'app.bsky.graph.listitem', - subject: p.did, - list: list?.uri, - createdAt: new Date().toISOString(), - }, - })), + writes: [ + createListItem({did: agent.session!.did, listUri: list.uri}), + ].concat( + profiles + // Ensure we don't have ourselves in this list twice + .filter(p => p.did !== agent.session!.did) + .map(p => createListItem({did: p.did, listUri: list.uri})), + ), }) return list @@ -80,8 +78,8 @@ export async function generateStarterpack({ return 'ERROR' } - profiles = [profile, ...profiles] - if (profiles.length < 8) { + // We include ourselves when we make the list + if (profiles.length < 7) { return 'NOT_ENOUGH_FOLLOWERS' } @@ -111,7 +109,20 @@ export async function generateStarterpack({ ) ).uri } catch (e: unknown) { - logger.error('Failed to generate starter pack', {error: e}) + logger.error('Failed to generate starter pack', {safeMessage: e}) return 'ERROR' } } + +function createListItem({did, listUri}: {did: string; listUri: string}) { + return { + $type: 'com.atproto.repo.applyWrites#create', + collection: 'app.bsky.graph.listitem', + value: { + $type: 'app.bsky.graph.listitem', + subject: did, + list: listUri, + createdAt: new Date().toISOString(), + }, + } +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 1077d9a854..14139e40a5 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -42,6 +42,7 @@ export type CommonNavigatorParams = { MessagesConversation: {conversation: string; embed?: string} MessagesSettings: undefined Feeds: undefined + Start: {name: string; rkey: string} StarterPack: {name: string; rkey: string} StarterPackWizard: undefined StarterPackEdit: { @@ -98,6 +99,7 @@ export type AllNavigatorParams = CommonNavigatorParams & { Hashtag: {tag: string; author?: string} MessagesTab: undefined Messages: {animation?: 'push' | 'pop'} + Start: {name: string; rkey: string} StarterPack: {name: string; rkey: string} StarterPackWizard: undefined StarterPackEdit: { diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 10b581ab2f..192e7608c0 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -155,7 +155,7 @@ export type LogEvents = { 'starterPack:share': { starterPack: string shareType: 'link' | 'qrcode' - qrShareType?: 'save' | 'copy' + qrShareType?: 'save' | 'copy' | 'share' } 'starterPack:followAll': { starterPack: string diff --git a/src/lib/strings/starter-pack.ts b/src/lib/strings/starter-pack.ts index 1836c435fd..90c961ab82 100644 --- a/src/lib/strings/starter-pack.ts +++ b/src/lib/strings/starter-pack.ts @@ -14,8 +14,8 @@ export function createStarterPackLinkFromAndroidReferrer( if (!utmContent) return null if (utmSource !== 'bluesky') return null - // This should be a string like `starterpack-haileyok.com-rkey` - const contentParts = utmContent.split('-') + // This should be a string like `starterpack_haileyok.com_rkey` + const contentParts = utmContent.split('_') if (contentParts[0] !== 'starterpack') return null if (contentParts.length !== 3) return null @@ -46,10 +46,10 @@ export function parseStarterPackUri(uri?: string): { } else { const url = new URL(uri) const parts = url.pathname.split('/') - const name = parts[2] - const rkey = parts[3] + const [_, path, name, rkey] = parts if (parts.length !== 4) return null + if (path !== 'starter-pack' && path !== 'start') return null if (!name || !rkey) return null return { name, @@ -66,7 +66,7 @@ export function createStarterPackGooglePlayUri( rkey: string, ): string | null { if (!name || !rkey) return null - return `https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&referrer=utm_source%3Dbluesky%26utm_medium%3Dstarterpack%26utm_content%3Dstarterpack-${name}-${rkey}` + return `https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&referrer=utm_source%3Dbluesky%26utm_medium%3Dstarterpack%26utm_content%3Dstarterpack_${name}_${rkey}` } export function httpStarterPackUriToAtUri(httpUri?: string): string | null { diff --git a/src/routes.ts b/src/routes.ts index 65a0f4be09..f241d37a07 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1,7 +1,7 @@ import {Router} from 'lib/routes/router' export const router = new Router({ - Home: ['/', '/start/:name/:rkey'], + Home: '/', Search: '/search', Feeds: '/feeds', Notifications: '/notifications', @@ -41,7 +41,8 @@ export const router = new Router({ Messages: '/messages', MessagesSettings: '/messages/settings', MessagesConversation: '/messages/:conversation', + Start: '/start/:name/:rkey', + StarterPackEdit: '/starter-pack/edit/:rkey', StarterPack: '/starter-pack/:name/:rkey', StarterPackWizard: '/starter-pack/create', - StarterPackEdit: '/starter-pack/edit/:rkey', }) diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx index b2fbd72efe..be9a766b8f 100644 --- a/src/screens/Onboarding/StepFinished.tsx +++ b/src/screens/Onboarding/StepFinished.tsx @@ -55,11 +55,12 @@ export function StepFinished() { const finishOnboarding = React.useCallback(async () => { setSaving(true) - try { - let starterPack: AppBskyGraphDefs.StarterPackView | undefined - let listItems: AppBskyGraphDefs.ListItemView[] | undefined - if (activeStarterPack?.uri) { + let starterPack: AppBskyGraphDefs.StarterPackView | undefined + let listItems: AppBskyGraphDefs.ListItemView[] | undefined + + if (activeStarterPack?.uri) { + try { const spRes = await agent.app.bsky.graph.getStarterPack({ starterPack: activeStarterPack.uri, }) @@ -72,8 +73,13 @@ export function StepFinished() { }) listItems = listRes.data.items } + } catch (e) { + logger.error('Failed to fetch starter pack', {safeMessage: e}) + // don't tell the user, just get them through onboarding. } + } + try { const {interestsStepResults, profileStepResults} = state const {selectedInterests} = interestsStepResults @@ -144,9 +150,6 @@ export function StepFinished() { } catch (e: any) { logger.info(`onboarding: bulk save failed`) logger.error(e) - // If there was an error encountered, we need to just clear the starter pack so we don't break things for subsequent - // app restarts - setActiveStarterPack(undefined) // don't alert the user, just let them into their account } diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx index 2cc1bcab0b..fd27658ada 100644 --- a/src/screens/Signup/index.tsx +++ b/src/screens/Signup/index.tsx @@ -1,6 +1,11 @@ import React from 'react' import {View} from 'react-native' -import {LayoutAnimationConfig} from 'react-native-reanimated' +import Animated, { + FadeIn, + FadeOut, + LayoutAnimationConfig, +} from 'react-native-reanimated' +import {AppBskyGraphStarterpack} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -11,6 +16,8 @@ import {createFullHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {useServiceQuery} from '#/state/queries/service' import {useAgent} from '#/state/session' +import {useStarterPackQuery} from 'state/queries/useStarterPackQuery' +import {useActiveStarterPack} from 'state/shell/starter-pack' import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout' import { initialState, @@ -26,6 +33,7 @@ import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {AppLanguageDropdown} from '#/components/AppLanguageDropdown' import {Button, ButtonText} from '#/components/Button' import {Divider} from '#/components/Divider' +import {LinearGradientBackground} from '#/components/LinearGradientBackground' import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' @@ -38,6 +46,11 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { const {gtMobile} = useBreakpoints() const agent = useAgent() + const activeStarterPack = useActiveStarterPack() + const {data: starterPack} = useStarterPackQuery({ + uri: activeStarterPack?.uri, + }) + const { data: serviceInfo, isFetching, @@ -142,6 +155,31 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { description={_(msg`We're so excited to have you join us!`)} scrollable> + {state.activeStep === SignupStep.INFO && + starterPack && + AppBskyGraphStarterpack.isRecord(starterPack.record) ? ( + + + + {starterPack.record.name} + + + {starterPack.feeds?.length ? ( + + You'll follow the suggested users and feeds once you + finish creating your account! + + ) : ( + + You'll follow the suggested users once you finish creating + your account! + + )} + + + + ) : null} + style={[ + a.font_bold, + a.text_4xl, + a.text_center, + a.leading_tight, + {color: 'white'}, + ]}> {record.name} + style={[ + a.text_center, + a.font_semibold, + a.text_md, + {color: 'white'}, + ]}> Starter pack by {creator.displayName || `@${creator.handle}`} - + {record.description ? ( {record.description} ) : null} - - {joinedWeekCount && joinedWeekCount >= 25 ? ( - - {joinedWeekCount} joined this week! - - ) : null} - + + + {joinedWeekCount && joinedWeekCount >= 25 ? ( + + + + 123,659 joined this week + + + ) : null} + - {starterPack.feeds?.length ? ( + {Boolean(listItemsSample?.length) && ( - - These great feeds will be available after signing up! + + {listItemsCount <= 8 ? ( + You'll follow these people right away + ) : ( + + You'll follow these people and {listItemsCount - 8} others + + )} + + + {starterPack.listItemsSample?.slice(0, 8).map(item => ( + + + + ))} + + + )} + {feeds?.length ? ( + + + You'll stay updated with these feeds - - {starterPack.feeds?.map((feed, index) => ( + + {feeds?.map(feed => ( @@ -222,69 +265,22 @@ function LandingScreenLoaded({ ) : null} - - {Boolean(listItemsSample?.length) && ( - - - {feeds?.length ? ( - <> - {listItemsCount <= 8 ? ( - - You'll also follow these people right away! - - ) : ( - - You'll also follow these people and{' '} - {listItemsCount - 8} others! - - )} - - ) : ( - <> - {listItemsCount <= 8 ? ( - You'll follow these people right away! - ) : ( - - You'll follow these people and {listItemsCount - 8}{' '} - others! - - )} - - )} - - - {starterPack.listItemsSample - ?.slice(0, 8) - .map((item, index) => ( - - - - ))} - - - )} + - { if (!starterPack.list) return @@ -182,7 +184,7 @@ function Header({ avatar={undefined} creator={creator} avatarType="starter-pack"> - + {isOwn ? (