import {useCallback, useEffect, useState} from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {AtUri} from '@atproto/syntax'
import {type ModerationOpts} from '@bsky/sdk/moderation'
import {RichText as RichTextAPI} from '@bsky/sdk/richtext'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Plural, Trans} from '@lingui/react/macro'
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 {cleanError} from '#/lib/strings/errors'
import {getStarterPackOgCard} from '#/lib/strings/starter-pack'
import {logger} from '#/logger'
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,
useReferenceListOptOutMutation,
useStarterPackQuery,
} from '#/state/queries/starter-packs'
import {useAppviewClient, usePdsClient, useSession} from '#/state/session'
import {useSetActiveStarterPack} from '#/state/shell/landing'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {
ProgressGuideAction,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
import {ProfileSubpageHeader} from '#/view/com/profile/ProfileSubpageHeader'
import {type ListRef} from '#/view/com/util/List'
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 {CreateListFromStarterPackDialog} from '#/components/dialogs/lists/CreateListFromStarterPackDialog'
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 {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending'
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 * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import {app} from '#/lexicons'
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) &&
bsky.starterPack.isTrustedView(starterPack) &&
bsky.matches(app.bsky.graph.starterpack, starterPack.record)
if (!did || !starterPack || !isValid || !moderationOpts) {
return (
)
}
if (!starterPack.list && starterPack.creator.did === currentAccount?.did) {
return
}
return (
)
}
function StarterPackScreenLoaded({
starterPack,
routeParams,
moderationOpts,
}: {
starterPack: app.bsky.graph.defs.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 ax = useAnalytics()
const tabs = [
...(showPeopleTab ? [_(msg`People`)] : []),
...(showFeedsTab ? [_(msg`Feeds`)] : []),
...(showPostsTab ? [_(msg`Posts`)] : []),
]
const qrCodeDialogControl = useDialogControl()
const shareDialogControl = useDialogControl()
const shortenLink = useShortenLink()
const [link, setLink] = useState()
const [imageLoaded, setImageLoaded] = useState(false)
useEffect(() => {
ax.metric('starterPack:opened', {
starterPack: starterPack.uri,
})
}, [ax, starterPack.uri])
const onOpenShareDialog = 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])
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: app.bsky.graph.defs.StarterPackView
routeParams: StarterPackScreeProps['route']['params']
onOpenShareDialog: () => void
}) {
const {_} = useLingui()
const t = useTheme()
const {currentAccount, hasSession} = useSession()
const appviewClient = useAppviewClient()
const pdsClient = usePdsClient()
const queryClient = useQueryClient()
const setActiveStarterPack = useSetActiveStarterPack()
const {requestSwitchToAccount} = useLoggedOutViewControls()
const {captureAction} = useProgressGuideControls()
const [isProcessing, setIsProcessing] = useState(false)
const {record, creator} = starterPack
const isOwn = creator?.did === currentAccount?.did
const joinedAllTimeCount = starterPack.joinedAllTimeCount ?? 0
const ax = useAnalytics()
const navigation = useNavigation()
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: app.bsky.graph.defs.ListItemView[] = []
try {
listItems = await getAllListMembers(appviewClient, starterPack.list.uri)
} catch (e) {
setIsProcessing(false)
Toast.show(_(msg`An error occurred while trying to follow all`), {
type: 'error',
})
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(pdsClient, appviewClient, dids, {
uri: starterPack.uri,
cid: starterPack.cid,
})
} catch (e) {
setIsProcessing(false)
Toast.show(_(msg`An error occurred while trying to follow all`), {
type: 'error',
})
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)
ax.metric('starterPack:followAll', {
logContext: 'StarterPackProfilesList',
starterPack: starterPack.uri,
count: dids.length,
})
}
if (!bsky.isType(app.bsky.graph.starterpack, record)) {
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 ? (
{' '}
joined Bluesky via this starter pack!
) : null}
) : null}
>
)
}
function OverflowMenu({
starterPack,
routeParams,
onOpenShareDialog,
}: {
starterPack: app.bsky.graph.defs.StarterPackView
routeParams: StarterPackScreeProps['route']['params']
onOpenShareDialog: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const ax = useAnalytics()
const {gtMobile} = useBreakpoints()
const {currentAccount} = useSession()
const reportDialogControl = useReportDialogControl()
const deleteDialogControl = useDialogControl()
const convertToListDialogControl = useDialogControl()
const optOutDialogControl = useDialogControl()
const navigation = useNavigation()
const {
mutate: deleteStarterPack,
isPending: isDeletePending,
error: deleteError,
} = useDeleteStarterPackMutation({
onSuccess: () => {
ax.metric('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 referenceListOptOut = starterPack.list?.viewer?.referenceListOptOut
const {mutate: setReferenceListOptOut, isPending: isOptOutPending} =
useReferenceListOptOutMutation({
starterPack,
onSuccess: action => {
ax.metric('starterPack:optOut', {
starterPack: starterPack.uri,
action,
})
},
onError: error => {
logger.error('Failed to update starter pack opt-out', {
safeMessage: error,
})
Toast.show(_(msg`Failed to update starter pack opt-out`), {
type: 'error',
})
},
})
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,
})
ax.metric('starterPack:delete', {})
}
return (
<>
{({props}) => (
)}
{isOwn ? (
<>
{
navigation.navigate('StarterPackEdit', {
rkey: routeParams.rkey,
})
}}>
Edit
{
deleteDialogControl.open()
}}>
Delete
{
convertToListDialogControl.open()
}}>
Create list from members
>
) : (
<>
{IS_WEB ? (
Copy link
) : (
Share via...
)}
reportDialogControl.open()}>
Report starter pack
{starterPack.list ? (
optOutDialogControl.open()}>
{referenceListOptOut ? (
Undo opt-out
) : (
Opt out of starter pack
)}
) : null}
>
)}
{starterPack.list && (
)}
Delete starter pack?
Are you sure you want to delete this starter pack?
{deleteError && (
Unable to delete
{cleanError(deleteError)}
)}
{starterPack.list ? (
{referenceListOptOut ? (
Undo opt-out?
) : (
Opt out of this starter pack?
)}
{referenceListOptOut ? (
You will be eligible to appear in this starter pack again.
) : (
You will no longer appear in this starter pack. The creator will
be able to see that you've opted out and remove you if they
wish.
)}
) : null}
>
)
}
function InvalidStarterPack({rkey}: {rkey: string}) {
const {_} = useLingui()
const t = useTheme()
const navigation = useNavigation()
const {gtMobile} = useBreakpoints()
const [isProcessing, setIsProcessing] = 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`), {
type: 'error',
})
},
})
return (
Starter pack is invalid
The starter pack that you are trying to view is invalid. You may
delete this starter pack instead.
)
}