diff options
Diffstat (limited to 'src/components')
23 files changed, 1868 insertions, 44 deletions
diff --git a/src/components/LinearGradientBackground.tsx b/src/components/LinearGradientBackground.tsx new file mode 100644 index 000000000..f516b19f5 --- /dev/null +++ b/src/components/LinearGradientBackground.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import {StyleProp, ViewStyle} from 'react-native' +import {LinearGradient} from 'expo-linear-gradient' + +import {gradients} from '#/alf/tokens' + +export function LinearGradientBackground({ + style, + children, +}: { + style: StyleProp<ViewStyle> + children: React.ReactNode +}) { + const gradient = gradients.sky.values.map(([_, color]) => { + return color + }) + + return ( + <LinearGradient colors={gradient} style={style}> + {children} + </LinearGradient> + ) +} diff --git a/src/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx index 0354bfc43..6743a592b 100644 --- a/src/components/NewskieDialog.tsx +++ b/src/components/NewskieDialog.tsx @@ -9,11 +9,13 @@ 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 {isWeb} from 'platform/detection' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog' import {Newskie} from '#/components/icons/Newskie' +import * as StarterPackCard from '#/components/StarterPack/StarterPackCard' import {Text} from '#/components/Typography' export function NewskieDialog({ @@ -24,6 +26,7 @@ export function NewskieDialog({ disabled?: boolean }) { const {_} = useLingui() + const t = useTheme() const moderationOpts = useModerationOpts() const control = useDialogControl() const profileName = React.useMemo(() => { @@ -68,15 +71,62 @@ export function NewskieDialog({ label={_(msg`New user info dialog`)} style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}> <View style={[a.gap_sm]}> - <Text style={[a.font_bold, a.text_xl]}> - <Trans>Say hello!</Trans> - </Text> - <Text style={[a.text_md]}> - <Trans> - {profileName} joined Bluesky{' '} - {timeAgo(createdAt, now, {format: 'long'})} ago - </Trans> + <View style={[a.align_center]}> + <Newskie + width={64} + height={64} + fill="#FFC404" + style={{marginTop: -10}} + /> + <Text style={[a.font_bold, a.text_xl, {marginTop: -10}]}> + <Trans>Say hello!</Trans> + </Text> + </View> + <Text style={[a.text_md, a.text_center, a.leading_tight]}> + {profile.joinedViaStarterPack ? ( + <Trans> + {profileName} joined Bluesky using a starter pack{' '} + {timeAgo(createdAt, now, {format: 'long'})} ago + </Trans> + ) : ( + <Trans> + {profileName} joined Bluesky{' '} + {timeAgo(createdAt, now, {format: 'long'})} ago + </Trans> + )} </Text> + {profile.joinedViaStarterPack ? ( + <StarterPackCard.Link + starterPack={profile.joinedViaStarterPack} + onPress={() => { + control.close() + }}> + <View + style={[ + a.flex_1, + a.mt_sm, + a.p_lg, + a.border, + a.rounded_sm, + t.atoms.border_contrast_low, + ]}> + <StarterPackCard.Card + starterPack={profile.joinedViaStarterPack} + /> + </View> + </StarterPackCard.Link> + ) : null} + <Button + label={_(msg`Close`)} + variant="solid" + color="secondary" + size="small" + style={[a.mt_sm, isWeb && [a.self_center, {marginLeft: 'auto'}]]} + onPress={() => control.close()}> + <ButtonText> + <Trans>Close</Trans> + </ButtonText> + </Button> </View> </Dialog.ScrollableInner> </Dialog.Outer> diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx new file mode 100644 index 000000000..a0d222854 --- /dev/null +++ b/src/components/ProfileCard.tsx @@ -0,0 +1,91 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' + +import {createSanitizedDisplayName} from 'lib/moderation/create-sanitized-display-name' +import {sanitizeHandle} from 'lib/strings/handles' +import {useProfileShadow} from 'state/cache/profile-shadow' +import {useSession} from 'state/session' +import {FollowButton} from 'view/com/profile/FollowButton' +import {ProfileCardPills} from 'view/com/profile/ProfileCard' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {atoms as a, useTheme} from '#/alf' +import {Link} from '#/components/Link' +import {Text} from '#/components/Typography' + +export function Default({ + profile: profileUnshadowed, + moderationOpts, + logContext = 'ProfileCard', +}: { + profile: AppBskyActorDefs.ProfileViewDetailed + moderationOpts: ModerationOpts + logContext?: 'ProfileCard' | 'StarterPackProfilesList' +}) { + const t = useTheme() + const {currentAccount, hasSession} = useSession() + + const profile = useProfileShadow(profileUnshadowed) + const name = createSanitizedDisplayName(profile) + const handle = `@${sanitizeHandle(profile.handle)}` + const moderation = moderateProfile(profile, moderationOpts) + + return ( + <Wrapper did={profile.did}> + <View style={[a.flex_row, a.gap_sm]}> + <UserAvatar + size={42} + avatar={profile.avatar} + type={ + profile.associated?.labeler + ? 'labeler' + : profile.associated?.feedgens + ? 'algo' + : 'user' + } + moderation={moderation.ui('avatar')} + /> + <View style={[a.flex_1]}> + <Text + style={[a.text_md, a.font_bold, a.leading_snug]} + numberOfLines={1}> + {name} + </Text> + <Text + style={[a.leading_snug, t.atoms.text_contrast_medium]} + numberOfLines={1}> + {handle} + </Text> + </View> + {hasSession && profile.did !== currentAccount?.did && ( + <View style={[a.justify_center, {marginLeft: 'auto'}]}> + <FollowButton profile={profile} logContext={logContext} /> + </View> + )} + </View> + <View style={[a.mb_xs]}> + <ProfileCardPills + followedBy={Boolean(profile.viewer?.followedBy)} + moderation={moderation} + /> + </View> + {profile.description && ( + <Text numberOfLines={3} style={[a.leading_snug]}> + {profile.description} + </Text> + )} + </Wrapper> + ) +} + +function Wrapper({did, children}: {did: string; children: React.ReactNode}) { + return ( + <Link + to={{ + screen: 'Profile', + params: {name: did}, + }}> + <View style={[a.flex_1, a.gap_xs]}>{children}</View> + </Link> + ) +} diff --git a/src/components/ReportDialog/SelectReportOptionView.tsx b/src/components/ReportDialog/SelectReportOptionView.tsx index 4413cbe89..169c07d73 100644 --- a/src/components/ReportDialog/SelectReportOptionView.tsx +++ b/src/components/ReportDialog/SelectReportOptionView.tsx @@ -55,6 +55,9 @@ export function SelectReportOptionView({ } else if (props.params.type === 'feedgen') { title = _(msg`Report this feed`) description = _(msg`Why should this feed be reviewed?`) + } else if (props.params.type === 'starterpack') { + title = _(msg`Report this starter pack`) + description = _(msg`Why should this starter pack be reviewed?`) } else if (props.params.type === 'convoMessage') { title = _(msg`Report this message`) description = _(msg`Why should this message be reviewed?`) diff --git a/src/components/ReportDialog/types.ts b/src/components/ReportDialog/types.ts index ceabe0b90..3f43db4a1 100644 --- a/src/components/ReportDialog/types.ts +++ b/src/components/ReportDialog/types.ts @@ -4,7 +4,7 @@ export type ReportDialogProps = { control: Dialog.DialogOuterProps['control'] params: | { - type: 'post' | 'list' | 'feedgen' | 'other' + type: 'post' | 'list' | 'feedgen' | 'starterpack' | 'other' uri: string cid: string } diff --git a/src/components/StarterPack/Main/FeedsList.tsx b/src/components/StarterPack/Main/FeedsList.tsx new file mode 100644 index 000000000..e350a422c --- /dev/null +++ b/src/components/StarterPack/Main/FeedsList.tsx @@ -0,0 +1,68 @@ +import React, {useCallback} from 'react' +import {ListRenderItemInfo, View} from 'react-native' +import {AppBskyFeedDefs} from '@atproto/api' +import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' + +import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset' +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' +import * as FeedCard from '#/components/FeedCard' + +function keyExtractor(item: AppBskyFeedDefs.GeneratorView) { + return item.uri +} + +interface ProfilesListProps { + feeds: AppBskyFeedDefs.GeneratorView[] + headerHeight: number + scrollElRef: ListRef +} + +export const FeedsList = React.forwardRef<SectionRef, ProfilesListProps>( + function FeedsListImpl({feeds, headerHeight, scrollElRef}, ref) { + const [initialHeaderHeight] = React.useState(headerHeight) + const bottomBarOffset = useBottomBarOffset(20) + const t = useTheme() + + const onScrollToTop = useCallback(() => { + scrollElRef.current?.scrollToOffset({ + animated: isNative, + offset: -headerHeight, + }) + }, [scrollElRef, headerHeight]) + + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const renderItem = ({item, index}: ListRenderItemInfo<GeneratorView>) => { + return ( + <View + style={[ + a.p_lg, + (isWeb || index !== 0) && a.border_t, + t.atoms.border_contrast_low, + ]}> + <FeedCard.Default type="feed" view={item} /> + </View> + ) + } + + return ( + <List + data={feeds} + renderItem={renderItem} + keyExtractor={keyExtractor} + ref={scrollElRef} + headerOffset={headerHeight} + ListFooterComponent={ + <View style={[{height: initialHeaderHeight + bottomBarOffset}]} /> + } + showsVerticalScrollIndicator={false} + desktopFixedHeight={true} + /> + ) + }, +) diff --git a/src/components/StarterPack/Main/ProfilesList.tsx b/src/components/StarterPack/Main/ProfilesList.tsx new file mode 100644 index 000000000..72d35fe2b --- /dev/null +++ b/src/components/StarterPack/Main/ProfilesList.tsx @@ -0,0 +1,119 @@ +import React, {useCallback} from 'react' +import {ListRenderItemInfo, View} from 'react-native' +import { + AppBskyActorDefs, + AppBskyGraphGetList, + AtUri, + ModerationOpts, +} from '@atproto/api' +import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query' + +import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset' +import {isNative, isWeb} from 'platform/detection' +import {useSession} from 'state/session' +import {List, ListRef} from 'view/com/util/List' +import {SectionRef} from '#/screens/Profile/Sections/types' +import {atoms as a, useTheme} from '#/alf' +import {Default as ProfileCard} from '#/components/ProfileCard' + +function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) { + return `${item.did}-${index}` +} + +interface ProfilesListProps { + listUri: string + listMembersQuery: UseInfiniteQueryResult< + InfiniteData<AppBskyGraphGetList.OutputSchema> + > + moderationOpts: ModerationOpts + headerHeight: number + scrollElRef: ListRef +} + +export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>( + function ProfilesListImpl( + {listUri, listMembersQuery, moderationOpts, headerHeight, scrollElRef}, + ref, + ) { + const t = useTheme() + const [initialHeaderHeight] = React.useState(headerHeight) + const bottomBarOffset = useBottomBarOffset(20) + const {currentAccount} = useSession() + + const [isPTRing, setIsPTRing] = React.useState(false) + + const {data, refetch} = listMembersQuery + + // 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 myIndex !== -1 + ? [ + profiles[myIndex], + ...profiles.slice(0, myIndex), + ...profiles.slice(myIndex + 1), + ] + : profiles + } + const onScrollToTop = useCallback(() => { + scrollElRef.current?.scrollToOffset({ + animated: isNative, + offset: -headerHeight, + }) + }, [scrollElRef, headerHeight]) + + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const renderItem = ({ + item, + index, + }: ListRenderItemInfo<AppBskyActorDefs.ProfileViewBasic>) => { + return ( + <View + style={[ + a.p_lg, + t.atoms.border_contrast_low, + (isWeb || index !== 0) && a.border_t, + ]}> + <ProfileCard + profile={item} + moderationOpts={moderationOpts} + logContext="StarterPackProfilesList" + /> + </View> + ) + } + + if (listMembersQuery) + return ( + <List + data={getSortedProfiles()} + renderItem={renderItem} + keyExtractor={keyExtractor} + ref={scrollElRef} + headerOffset={headerHeight} + ListFooterComponent={ + <View style={[{height: initialHeaderHeight + bottomBarOffset}]} /> + } + showsVerticalScrollIndicator={false} + desktopFixedHeight + refreshing={isPTRing} + onRefresh={async () => { + setIsPTRing(true) + await refetch() + setIsPTRing(false) + }} + /> + ) + }, +) diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx new file mode 100644 index 000000000..096f04f2d --- /dev/null +++ b/src/components/StarterPack/ProfileStarterPacks.tsx @@ -0,0 +1,320 @@ +import React from 'react' +import { + findNodeHandle, + ListRenderItemInfo, + StyleProp, + View, + ViewStyle, +} from 'react-native' +import {AppBskyGraphDefs, AppBskyGraphGetActorStarterPacks} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' +import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {useGenerateStarterPackMutation} from 'lib/generate-starterpack' +import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {NavigationProp} from 'lib/routes/types' +import {parseStarterPackUri} from 'lib/strings/starter-pack' +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, 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 +} + +interface ProfileFeedgensProps { + starterPacksQuery: UseInfiniteQueryResult< + InfiniteData<AppBskyGraphGetActorStarterPacks.OutputSchema, unknown>, + Error + > + scrollElRef: ListRef + headerOffset: number + enabled?: boolean + style?: StyleProp<ViewStyle> + testID?: string + setScrollViewTag: (tag: number | null) => void + isMe: boolean +} + +function keyExtractor(item: AppBskyGraphDefs.StarterPackView) { + return item.uri +} + +export const ProfileStarterPacks = React.forwardRef< + SectionRef, + ProfileFeedgensProps +>(function ProfileFeedgensImpl( + { + starterPacksQuery: query, + scrollElRef, + headerOffset, + enabled, + style, + testID, + setScrollViewTag, + isMe, + }, + ref, +) { + const t = useTheme() + const bottomBarOffset = useBottomBarOffset(100) + const [isPTRing, setIsPTRing] = React.useState(false) + const {data, refetch, isFetching, hasNextPage, fetchNextPage} = query + const {isTabletOrDesktop} = useWebMediaQueries() + + const items = data?.pages.flatMap(page => page.starterPacks) + + React.useImperativeHandle(ref, () => ({ + scrollToTop: () => {}, + })) + + const onRefresh = React.useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh starter packs', {message: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) + + const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage) return + + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more starter packs', {message: err}) + } + }, [isFetching, hasNextPage, fetchNextPage]) + + React.useEffect(() => { + if (enabled && scrollElRef.current) { + const nativeTag = findNodeHandle(scrollElRef.current) + setScrollViewTag(nativeTag) + } + }, [enabled, scrollElRef, setScrollViewTag]) + + const renderItem = ({ + item, + index, + }: ListRenderItemInfo<AppBskyGraphDefs.StarterPackView>) => { + return ( + <View + style={[ + a.p_lg, + (isTabletOrDesktop || index !== 0) && a.border_t, + t.atoms.border_contrast_low, + ]}> + <StarterPackCard starterPack={item} /> + </View> + ) + } + + return ( + <View testID={testID} style={style}> + <List + testID={testID ? `${testID}-flatlist` : undefined} + ref={scrollElRef} + data={items} + renderItem={renderItem} + keyExtractor={keyExtractor} + refreshing={isPTRing} + headerOffset={headerOffset} + contentContainerStyle={{paddingBottom: headerOffset + bottomBarOffset}} + indicatorStyle={t.name === 'light' ? 'black' : 'white'} + removeClippedSubviews={true} + desktopFixedHeight + onEndReached={onEndReached} + onRefresh={onRefresh} + ListEmptyComponent={Empty} + ListFooterComponent={ + items?.length !== 0 && isMe ? CreateAnother : undefined + } + /> + </View> + ) +}) + +function CreateAnother() { + const {_} = useLingui() + const t = useTheme() + const navigation = useNavigation<NavigationProp>() + + return ( + <View + style={[ + a.pr_md, + a.pt_lg, + a.gap_lg, + a.border_t, + t.atoms.border_contrast_low, + ]}> + <Button + label={_(msg`Create a starter pack`)} + variant="solid" + color="secondary" + size="small" + style={[a.self_center]} + onPress={() => navigation.navigate('StarterPackWizard')}> + <ButtonText> + <Trans>Create another</Trans> + </ButtonText> + <ButtonIcon icon={Plus} position="right" /> + </Button> + </View> + ) +} + +function Empty() { + const {_} = useLingui() + const t = useTheme() + const navigation = useNavigation<NavigationProp>() + const confirmDialogControl = useDialogControl() + const followersDialogControl = useDialogControl() + const errorDialogControl = useDialogControl() + + const [isGenerating, setIsGenerating] = React.useState(false) + + const {mutate: generateStarterPack} = useGenerateStarterPackMutation({ + onSuccess: ({uri}) => { + const parsed = parseStarterPackUri(uri) + if (parsed) { + navigation.push('StarterPack', { + name: parsed.name, + rkey: parsed.rkey, + }) + } + setIsGenerating(false) + }, + onError: e => { + logger.error('Failed to generate starter pack', {safeMessage: e}) + setIsGenerating(false) + if (e.name === 'NOT_ENOUGH_FOLLOWERS') { + followersDialogControl.open() + } else { + errorDialogControl.open() + } + }, + }) + + const generate = () => { + setIsGenerating(true) + generateStarterPack() + } + + return ( + <LinearGradientBackground + style={[ + a.px_lg, + a.py_lg, + a.justify_between, + a.gap_lg, + a.shadow_lg, + {marginTop: 2}, + ]}> + <View style={[a.gap_xs]}> + <Text + style={[ + a.font_bold, + a.text_lg, + t.atoms.text_contrast_medium, + {color: 'white'}, + ]}> + You haven't created a starter pack yet! + </Text> + <Text style={[a.text_md, {color: 'white'}]}> + Starter packs let you easily share your favorite feeds and people with + your friends. + </Text> + </View> + <View style={[a.flex_row, a.gap_md, {marginLeft: 'auto'}]}> + <Button + label={_(msg`Create a starter pack for me`)} + variant="ghost" + color="primary" + size="small" + disabled={isGenerating} + onPress={confirmDialogControl.open} + style={{backgroundColor: 'transparent'}}> + <ButtonText style={{color: 'white'}}> + <Trans>Make one for me</Trans> + </ButtonText> + {isGenerating && <Loader size="md" />} + </Button> + <Button + label={_(msg`Create a starter pack`)} + variant="ghost" + color="primary" + size="small" + disabled={isGenerating} + onPress={() => navigation.navigate('StarterPackWizard')} + style={{ + backgroundColor: 'white', + borderColor: 'white', + width: 100, + }} + hoverStyle={[{backgroundColor: '#dfdfdf'}]}> + <ButtonText> + <Trans>Create</Trans> + </ButtonText> + </Button> + </View> + + <Prompt.Outer control={confirmDialogControl}> + <Prompt.TitleText> + <Trans>Generate a starter pack</Trans> + </Prompt.TitleText> + <Prompt.DescriptionText> + <Trans> + Bluesky will choose a set of recommended accounts from people in + your network. + </Trans> + </Prompt.DescriptionText> + <Prompt.Actions> + <Prompt.Action + color="primary" + cta={_(msg`Choose for me`)} + onPress={generate} + /> + <Prompt.Action + color="secondary" + cta={_(msg`Let me choose`)} + onPress={() => { + navigation.navigate('StarterPackWizard') + }} + /> + </Prompt.Actions> + </Prompt.Outer> + <Prompt.Basic + control={followersDialogControl} + title={_(msg`Oops!`)} + description={_( + msg`You must be following at least seven other people to generate a starter pack.`, + )} + onConfirm={() => {}} + showCancel={false} + /> + <Prompt.Basic + control={errorDialogControl} + title={_(msg`Oops!`)} + description={_( + msg`An error occurred while generating your starter pack. Want to try again?`, + )} + onConfirm={generate} + confirmButtonCta={_(msg`Retry`)} + /> + </LinearGradientBackground> + ) +} diff --git a/src/components/StarterPack/QrCode.tsx b/src/components/StarterPack/QrCode.tsx new file mode 100644 index 000000000..08ee03d62 --- /dev/null +++ b/src/components/StarterPack/QrCode.tsx @@ -0,0 +1,119 @@ +import React from 'react' +import {View} from 'react-native' +import QRCode from 'react-native-qrcode-styled' +import ViewShot from 'react-native-view-shot' +import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {isWeb} from 'platform/detection' +import {Logo} from 'view/icons/Logo' +import {Logotype} from 'view/icons/Logotype' +import {useTheme} from '#/alf' +import {atoms as a} from '#/alf' +import {LinearGradientBackground} from '#/components/LinearGradientBackground' +import {Text} from '#/components/Typography' + +interface Props { + starterPack: AppBskyGraphDefs.StarterPackView + link: string +} + +export const QrCode = React.forwardRef<ViewShot, Props>(function QrCode( + {starterPack, link}, + ref, +) { + const {record} = starterPack + + if (!AppBskyGraphStarterpack.isRecord(record)) { + return null + } + + return ( + <ViewShot ref={ref}> + <LinearGradientBackground + style={[ + {width: 300, minHeight: 390}, + a.align_center, + a.px_sm, + a.py_xl, + a.rounded_sm, + a.justify_between, + a.gap_md, + ]}> + <View style={[a.gap_sm]}> + <Text + style={[a.font_bold, a.text_3xl, a.text_center, {color: 'white'}]}> + {record.name} + </Text> + </View> + <View style={[a.gap_xl, a.align_center]}> + <Text + style={[ + a.font_bold, + a.text_center, + {color: 'white', fontSize: 18}, + ]}> + <Trans>Join the conversation</Trans> + </Text> + <View style={[a.rounded_sm, a.overflow_hidden]}> + <QrCodeInner link={link} /> + </View> + + <View style={[a.flex_row, a.align_center, {gap: 5}]}> + <Text + style={[ + a.font_bold, + a.text_center, + {color: 'white', fontSize: 18}, + ]}> + <Trans>on</Trans> + </Text> + <Logo width={26} fill="white" /> + <View style={[{marginTop: 5, marginLeft: 2.5}]}> + <Logotype width={68} fill="white" /> + </View> + </View> + </View> + </LinearGradientBackground> + </ViewShot> + ) +}) + +export function QrCodeInner({link}: {link: string}) { + const t = useTheme() + + return ( + <QRCode + data={link} + style={[ + a.rounded_sm, + {height: 225, width: 225, backgroundColor: '#f3f3f3'}, + ]} + pieceSize={isWeb ? 8 : 6} + padding={20} + // pieceLiquidRadius={2} + pieceBorderRadius={isWeb ? 4.5 : 3.5} + outerEyesOptions={{ + topLeft: { + borderRadius: [12, 12, 0, 12], + color: t.palette.primary_500, + }, + topRight: { + borderRadius: [12, 12, 12, 0], + color: t.palette.primary_500, + }, + bottomLeft: { + borderRadius: [12, 0, 12, 12], + color: t.palette.primary_500, + }, + }} + innerEyesOptions={{borderRadius: 3}} + logo={{ + href: require('../../../assets/logo.png'), + scale: 1.2, + padding: 2, + hidePieces: true, + }} + /> + ) +} diff --git a/src/components/StarterPack/QrCodeDialog.tsx b/src/components/StarterPack/QrCodeDialog.tsx new file mode 100644 index 000000000..580c6cc7c --- /dev/null +++ b/src/components/StarterPack/QrCodeDialog.tsx @@ -0,0 +1,201 @@ +import React from 'react' +import {View} from 'react-native' +import ViewShot from 'react-native-view-shot' +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' +import {nanoid} from 'nanoid/non-secure' + +import {logger} from '#/logger' +import {saveImageToMediaLibrary} from 'lib/media/manip' +import {logEvent} from 'lib/statsig/statsig' +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' + +export function QrCodeDialog({ + starterPack, + link, + control, +}: { + starterPack: AppBskyGraphDefs.StarterPackView + link?: string + control: DialogControlProps +}) { + const {_} = useLingui() + const [isProcessing, setIsProcessing] = React.useState(false) + + const ref = React.useRef<ViewShot>(null) + + const getCanvas = (base64: string): Promise<HTMLCanvasElement> => { + return new Promise(resolve => { + const image = new Image() + image.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = image.width + canvas.height = image.height + + const ctx = canvas.getContext('2d') + ctx?.drawImage(image, 0, 0) + resolve(canvas) + } + image.src = base64 + }) + } + + const onSavePress = async () => { + ref.current?.capture?.().then(async (uri: string) => { + if (isNative) { + const res = await requestMediaLibraryPermissionsAsync() + + if (!res) { + Toast.show( + _( + msg`You must grant access to your photo library to save a QR code`, + ), + ) + return + } + + const filename = `${FS.documentDirectory}/${nanoid(12)}.png` + + // Incase of a FS failure, don't crash the app + try { + await FS.copyAsync({from: uri, to: filename}) + await saveImageToMediaLibrary({uri: filename}) + await FS.deleteAsync(filename) + } catch (e: unknown) { + Toast.show(_(msg`An error occurred while saving the QR code!`)) + logger.error('Failed to save QR code', {error: e}) + return + } + } else { + setIsProcessing(true) + + if (!AppBskyGraphStarterpack.isRecord(starterPack.record)) { + return + } + + const canvas = await getCanvas(uri) + const imgHref = canvas + .toDataURL('image/png') + .replace('image/png', 'image/octet-stream') + + const link = document.createElement('a') + link.setAttribute( + 'download', + `${starterPack.record.name.replaceAll(' ', '_')}_Share_Card.png`, + ) + link.setAttribute('href', imgHref) + link.click() + } + + logEvent('starterPack:share', { + starterPack: starterPack.uri, + shareType: 'qrcode', + qrShareType: 'save', + }) + 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) => { + 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, + shareType: 'qrcode', + 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 ( + <Dialog.Outer control={control}> + <Dialog.Handle /> + <Dialog.ScrollableInner + label={_(msg`Create a QR code for a starter pack`)}> + <View style={[a.flex_1, a.align_center, a.gap_5xl]}> + {!link ? ( + <View style={[a.align_center, a.p_xl]}> + <Loader size="xl" /> + </View> + ) : ( + <> + <QrCode starterPack={starterPack} link={link} ref={ref} /> + {isProcessing ? ( + <View> + <Loader size="xl" /> + </View> + ) : ( + <View + style={[a.w_full, a.gap_md, isWeb && [a.flex_row_reverse]]}> + <Button + label={_(msg`Copy QR code`)} + variant="solid" + color="secondary" + size="small" + onPress={isWeb ? onCopyPress : onSharePress}> + <ButtonText> + {isWeb ? <Trans>Copy</Trans> : <Trans>Share</Trans>} + </ButtonText> + </Button> + <Button + label={_(msg`Save QR code`)} + variant="solid" + color="secondary" + size="small" + onPress={onSavePress}> + <ButtonText> + <Trans>Save</Trans> + </ButtonText> + </Button> + </View> + )} + </> + )} + </View> + </Dialog.ScrollableInner> + </Dialog.Outer> + ) +} diff --git a/src/components/StarterPack/ShareDialog.tsx b/src/components/StarterPack/ShareDialog.tsx new file mode 100644 index 000000000..23fa10fb3 --- /dev/null +++ b/src/components/StarterPack/ShareDialog.tsx @@ -0,0 +1,180 @@ +import React from 'react' +import {View} from 'react-native' +import * as FS from 'expo-file-system' +import {Image} from 'expo-image' +import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker' +import {AppBskyGraphDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {nanoid} from 'nanoid/non-secure' + +import {logger} from '#/logger' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {saveImageToMediaLibrary} from 'lib/media/manip' +import {shareUrl} from 'lib/sharing' +import {logEvent} from 'lib/statsig/statsig' +import {getStarterPackOgCard} from 'lib/strings/starter-pack' +import {isNative, isWeb} from 'platform/detection' +import * as Toast from 'view/com/util/Toast' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {DialogControlProps} from '#/components/Dialog' +import * as Dialog from '#/components/Dialog' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +interface Props { + starterPack: AppBskyGraphDefs.StarterPackView + link?: string + imageLoaded?: boolean + qrDialogControl: DialogControlProps + control: DialogControlProps +} + +export function ShareDialog(props: Props) { + return ( + <Dialog.Outer control={props.control}> + <ShareDialogInner {...props} /> + </Dialog.Outer> + ) +} + +function ShareDialogInner({ + starterPack, + link, + imageLoaded, + qrDialogControl, + control, +}: Props) { + const {_} = useLingui() + const t = useTheme() + const {isTabletOrDesktop} = useWebMediaQueries() + + const imageUrl = getStarterPackOgCard(starterPack) + + const onShareLink = async () => { + if (!link) return + shareUrl(link) + logEvent('starterPack:share', { + starterPack: starterPack.uri, + shareType: 'link', + }) + control.close() + } + + const onSave = async () => { + const res = await requestMediaLibraryPermissionsAsync() + + if (!res) { + Toast.show( + _(msg`You must grant access to your photo library to save the image.`), + ) + return + } + + const cachePath = await Image.getCachePathAsync(imageUrl) + const filename = `${FS.documentDirectory}/${nanoid(12)}.png` + + if (!cachePath) { + Toast.show(_(msg`An error occurred while saving the image.`)) + return + } + + try { + await FS.copyAsync({from: cachePath, to: filename}) + await saveImageToMediaLibrary({uri: filename}) + await FS.deleteAsync(filename) + + Toast.show(_(msg`Image saved to your camera roll!`)) + control.close() + } catch (e: unknown) { + Toast.show(_(msg`An error occurred while saving the QR code!`)) + logger.error('Failed to save QR code', {error: e}) + return + } + } + + return ( + <> + <Dialog.Handle /> + <Dialog.ScrollableInner label={_(msg`Share link dialog`)}> + {!imageLoaded || !link ? ( + <View style={[a.p_xl, a.align_center]}> + <Loader size="xl" /> + </View> + ) : ( + <View style={[!isTabletOrDesktop && a.gap_lg]}> + <View style={[a.gap_sm, isTabletOrDesktop && a.pb_lg]}> + <Text style={[a.font_bold, a.text_2xl]}> + <Trans>Invite people to this starter pack!</Trans> + </Text> + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> + <Trans> + Share this starter pack and help people join your community on + Bluesky. + </Trans> + </Text> + </View> + <Image + source={{uri: imageUrl}} + style={[ + a.rounded_sm, + { + aspectRatio: 1200 / 630, + transform: [{scale: isTabletOrDesktop ? 0.85 : 1}], + marginTop: isTabletOrDesktop ? -20 : 0, + }, + ]} + accessibilityIgnoresInvertColors={true} + /> + <View + style={[ + a.gap_md, + isWeb && [a.gap_sm, a.flex_row_reverse, {marginLeft: 'auto'}], + ]}> + <Button + label="Share link" + variant="solid" + color="secondary" + size="small" + style={[isWeb && a.self_center]} + onPress={onShareLink}> + <ButtonText> + {isWeb ? <Trans>Copy Link</Trans> : <Trans>Share Link</Trans>} + </ButtonText> + </Button> + <Button + label="Create QR code" + variant="solid" + color="secondary" + size="small" + style={[isWeb && a.self_center]} + onPress={() => { + control.close(() => { + qrDialogControl.open() + }) + }}> + <ButtonText> + <Trans>Create QR code</Trans> + </ButtonText> + </Button> + {isNative && ( + <Button + label={_(msg`Save image`)} + variant="ghost" + color="secondary" + size="small" + style={[isWeb && a.self_center]} + onPress={onSave}> + <ButtonText> + <Trans>Save image</Trans> + </ButtonText> + </Button> + )} + </View> + </View> + )} + </Dialog.ScrollableInner> + </> + ) +} diff --git a/src/components/StarterPack/StarterPackCard.tsx b/src/components/StarterPack/StarterPackCard.tsx new file mode 100644 index 000000000..ab904d7ff --- /dev/null +++ b/src/components/StarterPack/StarterPackCard.tsx @@ -0,0 +1,117 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyGraphStarterpack, AtUri} from '@atproto/api' +import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {sanitizeHandle} from 'lib/strings/handles' +import {useSession} from 'state/session' +import {atoms as a, useTheme} from '#/alf' +import {StarterPack} from '#/components/icons/StarterPack' +import {Link as InternalLink, LinkProps} from '#/components/Link' +import {Text} from '#/components/Typography' + +export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) { + if (!starterPack) return null + return ( + <Link starterPack={starterPack}> + <Card starterPack={starterPack} /> + </Link> + ) +} + +export function Notification({ + starterPack, +}: { + starterPack?: StarterPackViewBasic +}) { + if (!starterPack) return null + return ( + <Link starterPack={starterPack}> + <Card starterPack={starterPack} noIcon={true} noDescription={true} /> + </Link> + ) +} + +export function Card({ + starterPack, + noIcon, + noDescription, +}: { + starterPack: StarterPackViewBasic + noIcon?: boolean + noDescription?: boolean +}) { + const {record, creator, joinedAllTimeCount} = starterPack + + const {_} = useLingui() + const t = useTheme() + const {currentAccount} = useSession() + + if (!AppBskyGraphStarterpack.isRecord(record)) { + return null + } + + return ( + <View style={[a.flex_1, a.gap_md]}> + <View style={[a.flex_row, a.gap_sm]}> + {!noIcon ? <StarterPack width={40} gradient="sky" /> : null} + <View> + <Text style={[a.text_md, a.font_bold, a.leading_snug]}> + {record.name} + </Text> + <Text style={[a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + Starter pack by{' '} + {creator?.did === currentAccount?.did + ? _(msg`you`) + : `@${sanitizeHandle(creator.handle)}`} + </Trans> + </Text> + </View> + </View> + {!noDescription && record.description ? ( + <Text numberOfLines={3} style={[a.leading_snug]}> + {record.description} + </Text> + ) : null} + {!!joinedAllTimeCount && joinedAllTimeCount >= 50 && ( + <Text style={[a.font_bold, t.atoms.text_contrast_medium]}> + {joinedAllTimeCount} users have joined! + </Text> + )} + </View> + ) +} + +export function Link({ + starterPack, + children, + ...rest +}: { + starterPack: StarterPackViewBasic +} & Omit<LinkProps, 'to'>) { + const {record} = starterPack + const {rkey, handleOrDid} = React.useMemo(() => { + const rkey = new AtUri(starterPack.uri).rkey + const {creator} = starterPack + return {rkey, handleOrDid: creator.handle || creator.did} + }, [starterPack]) + + if (!AppBskyGraphStarterpack.isRecord(record)) { + return null + } + + return ( + <InternalLink + label={record.name} + {...rest} + to={{ + screen: 'StarterPack', + params: {name: handleOrDid, rkey}, + }}> + {children} + </InternalLink> + ) +} diff --git a/src/components/StarterPack/Wizard/ScreenTransition.tsx b/src/components/StarterPack/Wizard/ScreenTransition.tsx new file mode 100644 index 000000000..b7cd4e4c1 --- /dev/null +++ b/src/components/StarterPack/Wizard/ScreenTransition.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import {StyleProp, ViewStyle} from 'react-native' +import Animated, { + FadeIn, + FadeOut, + SlideInLeft, + SlideInRight, +} from 'react-native-reanimated' + +import {isWeb} from 'platform/detection' + +export function ScreenTransition({ + direction, + style, + children, +}: { + direction: 'Backward' | 'Forward' + style?: StyleProp<ViewStyle> + children: React.ReactNode +}) { + const entering = direction === 'Forward' ? SlideInRight : SlideInLeft + + return ( + <Animated.View + entering={isWeb ? FadeIn.duration(90) : entering} + exiting={FadeOut.duration(90)} // Totally vibes based + style={style}> + {children} + </Animated.View> + ) +} diff --git a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx new file mode 100644 index 000000000..bf250ac35 --- /dev/null +++ b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx @@ -0,0 +1,152 @@ +import React, {useRef} from 'react' +import type {ListRenderItemInfo} from 'react-native' +import {View} from 'react-native' +import {AppBskyActorDefs, ModerationOpts} from '@atproto/api' +import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' +import {BottomSheetFlatListMethods} from '@discord/bottom-sheet' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {isWeb} from 'platform/detection' +import {useSession} from 'state/session' +import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State' +import {atoms as a, native, useTheme, web} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import { + WizardFeedCard, + WizardProfileCard, +} from '#/components/StarterPack/Wizard/WizardListCard' +import {Text} from '#/components/Typography' + +function keyExtractor( + item: AppBskyActorDefs.ProfileViewBasic | GeneratorView, + index: number, +) { + return `${item.did}-${index}` +} + +export function WizardEditListDialog({ + control, + state, + dispatch, + moderationOpts, + profile, +}: { + control: Dialog.DialogControlProps + state: WizardState + dispatch: (action: WizardAction) => void + moderationOpts: ModerationOpts + profile: AppBskyActorDefs.ProfileViewBasic +}) { + const {_} = useLingui() + const t = useTheme() + const {currentAccount} = useSession() + + const listRef = useRef<BottomSheetFlatListMethods>(null) + + const getData = () => { + if (state.currentStep === 'Feeds') return state.feeds + + return [ + profile, + ...state.profiles.filter(p => p.did !== currentAccount?.did), + ] + } + + const renderItem = ({item}: ListRenderItemInfo<any>) => + state.currentStep === 'Profiles' ? ( + <WizardProfileCard + profile={item} + state={state} + dispatch={dispatch} + moderationOpts={moderationOpts} + /> + ) : ( + <WizardFeedCard + generator={item} + state={state} + dispatch={dispatch} + moderationOpts={moderationOpts} + /> + ) + + return ( + <Dialog.Outer + control={control} + testID="newChatDialog" + nativeOptions={{sheet: {snapPoints: ['95%']}}}> + <Dialog.Handle /> + <Dialog.InnerFlatList + ref={listRef} + data={getData()} + renderItem={renderItem} + keyExtractor={keyExtractor} + ListHeaderComponent={ + <View + style={[ + a.flex_row, + a.justify_between, + a.border_b, + a.px_sm, + a.mb_sm, + t.atoms.bg, + t.atoms.border_contrast_medium, + isWeb + ? [ + a.align_center, + { + height: 48, + }, + ] + : [ + a.pb_sm, + a.align_end, + { + height: 68, + }, + ], + ]}> + <View style={{width: 60}} /> + <Text style={[a.font_bold, a.text_xl]}> + {state.currentStep === 'Profiles' ? ( + <Trans>Edit People</Trans> + ) : ( + <Trans>Edit Feeds</Trans> + )} + </Text> + <View style={{width: 60}}> + {isWeb && ( + <Button + label={_(msg`Close`)} + variant="ghost" + color="primary" + size="xsmall" + onPress={() => control.close()}> + <ButtonText> + <Trans>Close</Trans> + </ButtonText> + </Button> + )} + </View> + </View> + } + stickyHeaderIndices={[0]} + style={[ + web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), + native({ + height: '100%', + paddingHorizontal: 0, + marginTop: 0, + paddingTop: 0, + borderTopLeftRadius: 40, + borderTopRightRadius: 40, + }), + ]} + webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} + keyboardDismissMode="on-drag" + removeClippedSubviews={true} + /> + </Dialog.Outer> + ) +} diff --git a/src/components/StarterPack/Wizard/WizardListCard.tsx b/src/components/StarterPack/Wizard/WizardListCard.tsx new file mode 100644 index 000000000..f1332011d --- /dev/null +++ b/src/components/StarterPack/Wizard/WizardListCard.tsx @@ -0,0 +1,182 @@ +import React from 'react' +import {Keyboard, View} from 'react-native' +import { + AppBskyActorDefs, + AppBskyFeedDefs, + moderateFeedGenerator, + moderateProfile, + ModerationOpts, + ModerationUI, +} from '@atproto/api' +import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {DISCOVER_FEED_URI} from 'lib/constants' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' +import {useSession} from 'state/session' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State' +import {atoms as a, useTheme} from '#/alf' +import * as Toggle from '#/components/forms/Toggle' +import {Checkbox} from '#/components/forms/Toggle' +import {Text} from '#/components/Typography' + +function WizardListCard({ + type, + displayName, + subtitle, + onPress, + avatar, + included, + disabled, + moderationUi, +}: { + type: 'user' | 'algo' + profile?: AppBskyActorDefs.ProfileViewBasic + feed?: AppBskyFeedDefs.GeneratorView + displayName: string + subtitle: string + onPress: () => void + avatar?: string + included?: boolean + disabled?: boolean + moderationUi: ModerationUI +}) { + const t = useTheme() + const {_} = useLingui() + + return ( + <Toggle.Item + name={type === 'user' ? _(msg`Person toggle`) : _(msg`Feed toggle`)} + label={ + included + ? _(msg`Remove ${displayName} from starter pack`) + : _(msg`Add ${displayName} to starter pack`) + } + value={included} + disabled={disabled} + onChange={onPress} + style={[ + a.flex_row, + a.align_center, + a.px_lg, + a.py_md, + a.gap_md, + a.border_b, + t.atoms.border_contrast_low, + ]}> + <UserAvatar + size={45} + avatar={avatar} + moderation={moderationUi} + type={type} + /> + <View style={[a.flex_1, a.gap_2xs]}> + <Text + style={[a.flex_1, a.font_bold, a.text_md, a.leading_tight]} + numberOfLines={1}> + {displayName} + </Text> + <Text + style={[a.flex_1, a.leading_tight, t.atoms.text_contrast_medium]} + numberOfLines={1}> + {subtitle} + </Text> + </View> + <Checkbox /> + </Toggle.Item> + ) +} + +export function WizardProfileCard({ + state, + dispatch, + profile, + moderationOpts, +}: { + state: WizardState + dispatch: (action: WizardAction) => void + profile: AppBskyActorDefs.ProfileViewBasic + moderationOpts: ModerationOpts +}) { + const {currentAccount} = useSession() + + const isMe = profile.did === currentAccount?.did + 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) + : `@${sanitizeHandle(profile.handle)}` + + const onPress = () => { + if (disabled) return + + Keyboard.dismiss() + if (profile.did === currentAccount?.did) return + + if (!included) { + dispatch({type: 'AddProfile', profile}) + } else { + dispatch({type: 'RemoveProfile', profileDid: profile.did}) + } + } + + return ( + <WizardListCard + type="user" + displayName={displayName} + subtitle={`@${sanitizeHandle(profile.handle)}`} + onPress={onPress} + avatar={profile.avatar} + included={included} + disabled={disabled} + moderationUi={moderationUi} + /> + ) +} + +export function WizardFeedCard({ + generator, + state, + dispatch, + moderationOpts, +}: { + generator: GeneratorView + state: WizardState + dispatch: (action: WizardAction) => void + moderationOpts: ModerationOpts +}) { + const isDiscover = generator.uri === DISCOVER_FEED_URI + const included = isDiscover || state.feeds.some(f => f.uri === generator.uri) + const disabled = isDiscover || (!included && state.feeds.length >= 3) + const moderationUi = moderateFeedGenerator(generator, moderationOpts).ui( + 'avatar', + ) + + const onPress = () => { + if (disabled) return + + Keyboard.dismiss() + if (included) { + dispatch({type: 'RemoveFeed', feedUri: generator.uri}) + } else { + dispatch({type: 'AddFeed', feed: generator}) + } + } + + return ( + <WizardListCard + type="algo" + displayName={sanitizeDisplayName(generator.displayName)} + subtitle={`Feed by @${sanitizeHandle(generator.creator.handle)}`} + onPress={onPress} + avatar={generator.avatar} + included={included} + disabled={disabled} + moderationUi={moderationUi} + /> + ) +} diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index f7a827b49..d513a6db9 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -140,6 +140,7 @@ export function createInput(Component: typeof TextInput) { onChangeText, isInvalid, inputRef, + style, ...rest }: InputProps) { const t = useTheme() @@ -206,6 +207,7 @@ export function createInput(Component: typeof TextInput) { android({ paddingBottom: 16, }), + style, ]} /> diff --git a/src/components/hooks/useStarterPackEntry.native.ts b/src/components/hooks/useStarterPackEntry.native.ts new file mode 100644 index 000000000..b6e4ab05b --- /dev/null +++ b/src/components/hooks/useStarterPackEntry.native.ts @@ -0,0 +1,68 @@ +import React from 'react' + +import { + createStarterPackLinkFromAndroidReferrer, + httpStarterPackUriToAtUri, +} from 'lib/strings/starter-pack' +import {isAndroid} from 'platform/detection' +import {useHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' +import {useSetActiveStarterPack} from 'state/shell/starter-pack' +import {DevicePrefs, Referrer} from '../../../modules/expo-bluesky-swiss-army' + +export function useStarterPackEntry() { + const [ready, setReady] = React.useState(false) + const setActiveStarterPack = useSetActiveStarterPack() + const hasCheckedForStarterPack = useHasCheckedForStarterPack() + + React.useEffect(() => { + if (ready) return + + // On Android, we cannot clear the referral link. It gets stored for 90 days and all we can do is query for it. So, + // let's just ensure we never check again after the first time. + if (hasCheckedForStarterPack) { + setReady(true) + return + } + + // Safety for Android. Very unlike this could happen, but just in case. The response should be nearly immediate + const timeout = setTimeout(() => { + setReady(true) + }, 500) + + ;(async () => { + let uri: string | null | undefined + + if (isAndroid) { + const res = await Referrer.getGooglePlayReferrerInfoAsync() + + if (res && res.installReferrer) { + uri = createStarterPackLinkFromAndroidReferrer(res.installReferrer) + } + } else { + const res = await DevicePrefs.getStringValueAsync( + 'starterPackUri', + true, + ) + + if (res) { + uri = httpStarterPackUriToAtUri(res) + DevicePrefs.setStringValueAsync('starterPackUri', null, true) + } + } + + if (uri) { + setActiveStarterPack({ + uri, + }) + } + + setReady(true) + })() + + return () => { + clearTimeout(timeout) + } + }, [ready, setActiveStarterPack, hasCheckedForStarterPack]) + + return ready +} diff --git a/src/components/hooks/useStarterPackEntry.ts b/src/components/hooks/useStarterPackEntry.ts new file mode 100644 index 000000000..dba801e09 --- /dev/null +++ b/src/components/hooks/useStarterPackEntry.ts @@ -0,0 +1,29 @@ +import React from 'react' + +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(() => { + const href = window.location.href + const atUri = httpStarterPackUriToAtUri(href) + + if (atUri) { + const url = new URL(href) + // Determines if an App Clip is loading this landing page + const isClip = url.searchParams.get('clip') === 'true' + setActiveStarterPack({ + uri: atUri, + isClip, + }) + } + + setReady(true) + }, [setActiveStarterPack]) + + return ready +} diff --git a/src/components/icons/QrCode.tsx b/src/components/icons/QrCode.tsx new file mode 100644 index 000000000..e841071f7 --- /dev/null +++ b/src/components/icons/QrCode.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const QrCode_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm6 0H5v4h4V5ZM3 15a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4Zm6 0H5v4h4v-4ZM13 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V5Zm6 0h-4v4h4V5ZM14 13a1 1 0 0 1 1 1v1h1a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Zm3 1a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm0 4a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-1v1a1 1 0 1 1-2 0v-2Z', +}) diff --git a/src/components/icons/StarterPack.tsx b/src/components/icons/StarterPack.tsx new file mode 100644 index 000000000..8c678bca4 --- /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/TEMPLATE.tsx b/src/components/icons/TEMPLATE.tsx index f49c4280b..47a5c36b2 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<Svg, Props>(function LogoImpl(props, ref) { - const {fill, size, style, ...rest} = useCommonSVGProps(props) + const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props) return ( <Svg @@ -41,8 +41,37 @@ export function createSinglePathSVG({path}: {path: string}) { width={size} height={size} style={[style]}> + {gradient} <Path fill={fill} fillRule="evenodd" clipRule="evenodd" d={path} /> </Svg> ) }) } + +export function createMultiPathSVG({paths}: {paths: string[]}) { + return React.forwardRef<Svg, Props>(function LogoImpl(props, ref) { + const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props) + + return ( + <Svg + fill="none" + {...rest} + ref={ref} + viewBox="0 0 24 24" + width={size} + height={size} + style={[style]}> + {gradient} + {paths.map((path, i) => ( + <Path + key={i} + fill={fill} + fillRule="evenodd" + clipRule="evenodd" + d={path} + /> + ))} + </Svg> + ) + }) +} diff --git a/src/components/icons/common.ts b/src/components/icons/common.ts deleted file mode 100644 index 669c157f5..000000000 --- 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<SvgProps, 'style' | 'size'> - -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 000000000..662718338 --- /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<SvgProps, 'style' | 'size'> + +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 = ( + <Defs> + <LinearGradient + id={id} + x1="0" + y1="0" + x2="100%" + y2="0" + gradientTransform="rotate(45)"> + {config.values.map(([stop, fill]) => ( + <Stop key={stop} offset={stop} stopColor={fill} /> + ))} + </LinearGradient> + </Defs> + ) + } + + return { + fill: _fill, + size: _size, + style, + gradient: gradientDef, + ...rest, + } +} |