import React from 'react' import {View} from 'react-native' import {Image} from 'expo-image' import { AppBskyGraphDefs, AppBskyGraphStarterpack, AtUri, type ModerationOpts, RichText as RichTextAPI, } from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {useQueryClient} from '@tanstack/react-query' import {batchedUpdates} from '#/lib/batchedUpdates' import {HITSLOP_20} from '#/lib/constants' import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' import {makeProfileLink, makeStarterPackLink} from '#/lib/routes/links' import { type CommonNavigatorParams, type NavigationProp, } from '#/lib/routes/types' import {logEvent} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' import {getStarterPackOgCard} from '#/lib/strings/starter-pack' import {logger} from '#/logger' import {isWeb} from '#/platform/detection' import {updateProfileShadow} from '#/state/cache/profile-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {getAllListMembers} from '#/state/queries/list-members' import {useResolvedStarterPackShortLink} from '#/state/queries/resolve-short-link' import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {useShortenLink} from '#/state/queries/shorten-link' import {useDeleteStarterPackMutation} from '#/state/queries/starter-packs' import {useStarterPackQuery} from '#/state/queries/starter-packs' import {useAgent, useSession} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import { ProgressGuideAction, useProgressGuideControls, } from '#/state/shell/progress-guide' import {useSetActiveStarterPack} from '#/state/shell/starter-pack' import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader' import {ProfileSubpageHeader} from '#/view/com/profile/ProfileSubpageHeader' import * as Toast from '#/view/com/util/Toast' import {bulkWriteFollows} from '#/screens/Onboarding/util' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil' import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' import * as Layout from '#/components/Layout' import {ListMaybePlaceholder} from '#/components/Lists' import {Loader} from '#/components/Loader' import * as Menu from '#/components/Menu' import { ReportDialog, useReportDialogControl, } from '#/components/moderation/ReportDialog' import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' import {FeedsList} from '#/components/StarterPack/Main/FeedsList' import {PostsList} from '#/components/StarterPack/Main/PostsList' import {ProfilesList} from '#/components/StarterPack/Main/ProfilesList' import {QrCodeDialog} from '#/components/StarterPack/QrCodeDialog' import {ShareDialog} from '#/components/StarterPack/ShareDialog' import {Text} from '#/components/Typography' import * as bsky from '#/types/bsky' type StarterPackScreeProps = NativeStackScreenProps< CommonNavigatorParams, 'StarterPack' > type StarterPackScreenShortProps = NativeStackScreenProps< CommonNavigatorParams, 'StarterPackShort' > export function StarterPackScreen({route}: StarterPackScreeProps) { return ( ) } export function StarterPackScreenShort({route}: StarterPackScreenShortProps) { const {_} = useLingui() const { data: resolvedStarterPack, isLoading, isError, } = useResolvedStarterPackShortLink({ code: route.params.code, }) if (isLoading || isError || !resolvedStarterPack) { return ( ) } return ( ) } export function StarterPackScreenInner({ routeParams, }: { routeParams: StarterPackScreeProps['route']['params'] }) { const {name, rkey} = routeParams const {_} = useLingui() const {currentAccount} = useSession() const moderationOpts = useModerationOpts() const { data: did, isLoading: isLoadingDid, isError: isErrorDid, } = useResolveDidQuery(name) const { data: starterPack, isLoading: isLoadingStarterPack, isError: isErrorStarterPack, } = useStarterPackQuery({did, rkey}) const isValid = starterPack && (starterPack.list || starterPack?.creator?.did === currentAccount?.did) && AppBskyGraphDefs.validateStarterPackView(starterPack) && AppBskyGraphStarterpack.validateRecord(starterPack.record) if (!did || !starterPack || !isValid || !moderationOpts) { return ( ) } if (!starterPack.list && starterPack.creator.did === currentAccount?.did) { return } return ( ) } function StarterPackScreenLoaded({ starterPack, routeParams, moderationOpts, }: { starterPack: AppBskyGraphDefs.StarterPackView routeParams: StarterPackScreeProps['route']['params'] moderationOpts: ModerationOpts }) { const showPeopleTab = Boolean(starterPack.list) const showFeedsTab = Boolean(starterPack.feeds?.length) const showPostsTab = Boolean(starterPack.list) const {_} = useLingui() const tabs = [ ...(showPeopleTab ? [_(msg`People`)] : []), ...(showFeedsTab ? [_(msg`Feeds`)] : []), ...(showPostsTab ? [_(msg`Posts`)] : []), ] const qrCodeDialogControl = useDialogControl() const shareDialogControl = useDialogControl() const shortenLink = useShortenLink() const [link, setLink] = React.useState() const [imageLoaded, setImageLoaded] = React.useState(false) React.useEffect(() => { logEvent('starterPack:opened', { starterPack: starterPack.uri, }) }, [starterPack.uri]) const onOpenShareDialog = React.useCallback(() => { const rkey = new AtUri(starterPack.uri).rkey shortenLink(makeStarterPackLink(starterPack.creator.did, rkey)).then( res => { setLink(res.url) }, ) Image.prefetch(getStarterPackOgCard(starterPack)) .then(() => { setImageLoaded(true) }) .catch(() => { setImageLoaded(true) }) shareDialogControl.open() }, [shareDialogControl, shortenLink, starterPack]) React.useEffect(() => { if (routeParams.new) { onOpenShareDialog() } }, [onOpenShareDialog, routeParams.new, shareDialogControl]) return ( <> (
)}> {showPeopleTab ? ({headerHeight, scrollElRef}) => ( ) : null} {showFeedsTab ? ({headerHeight, scrollElRef}) => ( ) : null} {showPostsTab ? ({headerHeight, scrollElRef}) => ( ) : null} ) } function Header({ starterPack, routeParams, onOpenShareDialog, }: { starterPack: AppBskyGraphDefs.StarterPackView routeParams: StarterPackScreeProps['route']['params'] onOpenShareDialog: () => void }) { const {_} = useLingui() const t = useTheme() const {currentAccount, hasSession} = useSession() const agent = useAgent() const queryClient = useQueryClient() const setActiveStarterPack = useSetActiveStarterPack() const {requestSwitchToAccount} = useLoggedOutViewControls() const {captureAction} = useProgressGuideControls() const [isProcessing, setIsProcessing] = React.useState(false) const {record, creator} = starterPack const isOwn = creator?.did === currentAccount?.did const joinedAllTimeCount = starterPack.joinedAllTimeCount ?? 0 const navigation = useNavigation() React.useEffect(() => { const onFocus = () => { if (hasSession) return setActiveStarterPack({ uri: starterPack.uri, }) } const onBeforeRemove = () => { if (hasSession) return setActiveStarterPack(undefined) } navigation.addListener('focus', onFocus) navigation.addListener('beforeRemove', onBeforeRemove) return () => { navigation.removeListener('focus', onFocus) navigation.removeListener('beforeRemove', onBeforeRemove) } }, [hasSession, navigation, setActiveStarterPack, starterPack.uri]) const onFollowAll = async () => { if (!starterPack.list) return setIsProcessing(true) let listItems: AppBskyGraphDefs.ListItemView[] = [] try { listItems = await getAllListMembers(agent, starterPack.list.uri) } catch (e) { setIsProcessing(false) Toast.show(_(msg`An error occurred while trying to follow all`), 'xmark') logger.error('Failed to get list members for starter pack', { safeMessage: e, }) return } const dids = listItems .filter( li => li.subject.did !== currentAccount?.did && !isBlockedOrBlocking(li.subject) && !isMuted(li.subject) && !li.subject.viewer?.following, ) .map(li => li.subject.did) let followUris: Map try { followUris = await bulkWriteFollows(agent, dids) } catch (e) { setIsProcessing(false) Toast.show(_(msg`An error occurred while trying to follow all`), 'xmark') logger.error('Failed to follow all accounts', {safeMessage: e}) } setIsProcessing(false) batchedUpdates(() => { for (let did of dids) { updateProfileShadow(queryClient, did, { followingUri: followUris.get(did), }) } }) Toast.show(_(msg`All accounts have been followed!`)) captureAction(ProgressGuideAction.Follow, dids.length) logEvent('starterPack:followAll', { logContext: 'StarterPackProfilesList', starterPack: starterPack.uri, count: dids.length, }) } if ( !bsky.dangerousIsType( record, AppBskyGraphStarterpack.isRecord, ) ) { return null } const richText = record.description ? new RichTextAPI({ text: record.description, facets: record.descriptionFacets, }) : undefined return ( <> {hasSession ? ( {isOwn ? ( ) : ( )} ) : null} {!hasSession || richText || joinedAllTimeCount >= 25 ? ( {richText ? ( ) : null} {!hasSession ? ( ) : null} {joinedAllTimeCount >= 25 ? ( {' '} used this starter pack! ) : null} ) : null} ) } function OverflowMenu({ starterPack, routeParams, onOpenShareDialog, }: { starterPack: AppBskyGraphDefs.StarterPackView routeParams: StarterPackScreeProps['route']['params'] onOpenShareDialog: () => void }) { const t = useTheme() const {_} = useLingui() const {gtMobile} = useBreakpoints() const {currentAccount} = useSession() const reportDialogControl = useReportDialogControl() const deleteDialogControl = useDialogControl() const navigation = useNavigation() const { mutate: deleteStarterPack, isPending: isDeletePending, error: deleteError, } = useDeleteStarterPackMutation({ onSuccess: () => { logEvent('starterPack:delete', {}) deleteDialogControl.close(() => { if (navigation.canGoBack()) { navigation.popToTop() } else { navigation.navigate('Home') } }) }, onError: e => { logger.error('Failed to delete starter pack', {safeMessage: e}) }, }) const isOwn = starterPack.creator.did === currentAccount?.did const onDeleteStarterPack = async () => { if (!starterPack.list) { logger.error(`Unable to delete starterpack because list is missing`) return } deleteStarterPack({ rkey: routeParams.rkey, listUri: starterPack.list.uri, }) logEvent('starterPack:delete', {}) } return ( <> {({props}) => ( )} {isOwn ? ( <> { navigation.navigate('StarterPackEdit', { rkey: routeParams.rkey, }) }}> Edit { deleteDialogControl.open() }}> Delete ) : ( <> {isWeb ? ( Copy link ) : ( Share via... )} reportDialogControl.open()}> Report starter pack )} {starterPack.list && ( )} Delete starter pack? Are you sure you want to delete this starter pack? {deleteError && ( Unable to delete {cleanError(deleteError)} )} ) } function InvalidStarterPack({rkey}: {rkey: string}) { const {_} = useLingui() const t = useTheme() const navigation = useNavigation() const {gtMobile} = useBreakpoints() const [isProcessing, setIsProcessing] = React.useState(false) const goBack = () => { if (navigation.canGoBack()) { navigation.goBack() } else { navigation.replace('Home') } } const {mutate: deleteStarterPack} = useDeleteStarterPackMutation({ onSuccess: () => { setIsProcessing(false) goBack() }, onError: e => { setIsProcessing(false) logger.error('Failed to delete invalid starter pack', {safeMessage: e}) Toast.show(_(msg`Failed to delete starter pack`), 'xmark') }, }) return ( Starter pack is invalid The starter pack that you are trying to view is invalid. You may delete this starter pack instead. ) }