From 8c4fc087f8119de5c007b71baaaf02089a346b38 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 20 Feb 2026 17:27:55 +0000 Subject: [PATCH] Add ability to convert starter pack to list (#9675) Co-authored-by: Claude Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Samuel Newman Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> --- src/analytics/metrics/types.ts | 4 + .../lists/CreateListFromStarterPackDialog.tsx | 234 ++++++++++++++++++ .../dialogs/lists/CreateOrEditListDialog.tsx | 27 +- src/screens/StarterPack/StarterPackScreen.tsx | 19 ++ src/view/com/util/EmptyState.tsx | 2 +- 5 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 636c0287a2..8f14159c6e 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -571,6 +571,10 @@ export type Events = { profilesCount: number feedsCount: number } + 'starterPack:convertToList': { + starterPack: string + memberCount: number + } 'starterPack:ctaPress': { starterPack: string } diff --git a/src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx b/src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx new file mode 100644 index 0000000000..396da2aa97 --- /dev/null +++ b/src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx @@ -0,0 +1,234 @@ +import {View} from 'react-native' +import { + type $Typed, + type AppBskyGraphDefs, + type AppBskyGraphListitem, + type AppBskyGraphStarterpack, + AtUri, + type ComAtprotoRepoApplyWrites, +} from '@atproto/api' +import {TID} from '@atproto/common-web' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' +import chunk from 'lodash.chunk' + +import {until} from '#/lib/async/until' +import {wait} from '#/lib/async/wait' +import {type NavigationProp} from '#/lib/routes/types' +import {logger} from '#/logger' +import {getAllListMembers} from '#/state/queries/list-members' +import {useAgent, useSession} from '#/state/session' +import {atoms as a, platform, useTheme, web} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {Loader} from '#/components/Loader' +import * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' +import {CreateOrEditListDialog} from './CreateOrEditListDialog' + +export function CreateListFromStarterPackDialog({ + control, + starterPack, +}: { + control: Dialog.DialogControlProps + starterPack: AppBskyGraphDefs.StarterPackView +}) { + const {_} = useLingui() + const t = useTheme() + const agent = useAgent() + const ax = useAnalytics() + const {currentAccount} = useSession() + const navigation = useNavigation() + const queryClient = useQueryClient() + const createDialogControl = Dialog.useDialogControl() + const loadingDialogControl = Dialog.useDialogControl() + + const record = starterPack.record as AppBskyGraphStarterpack.Record + + const onPressCreate = () => { + control.close(() => createDialogControl.open()) + } + + const addMembersAndNavigate = async (listUri: string) => { + const navigateToList = () => { + const urip = new AtUri(listUri) + navigation.navigate('ProfileList', { + name: urip.hostname, + rkey: urip.rkey, + }) + } + + if (!starterPack.list || !currentAccount) { + loadingDialogControl.close(navigateToList) + return + } + + try { + // Fetch all members and add them, with minimum 3s duration for UX + const listItems = await wait( + 3000, + (async () => { + const items = await getAllListMembers(agent, starterPack.list!.uri) + + if (items.length > 0) { + const listitemWrites: $Typed[] = + items.map(item => { + const listitemRecord: $Typed = { + $type: 'app.bsky.graph.listitem', + subject: item.subject.did, + list: listUri, + createdAt: new Date().toISOString(), + } + return { + $type: 'com.atproto.repo.applyWrites#create', + collection: 'app.bsky.graph.listitem', + rkey: TID.nextStr(), + value: listitemRecord, + } + }) + + const chunks = chunk(listitemWrites, 50) + for (const c of chunks) { + await agent.com.atproto.repo.applyWrites({ + repo: currentAccount.did, + writes: c, + }) + } + + await until( + 5, + 1e3, + (res: {data: {items: unknown[]}}) => res.data.items.length > 0, + () => + agent.app.bsky.graph.getList({ + list: listUri, + limit: 1, + }), + ) + } + + return items + })(), + ) + + queryClient.invalidateQueries({queryKey: ['list-members', listUri]}) + + ax.metric('starterPack:convertToList', { + starterPack: starterPack.uri, + memberCount: listItems.length, + }) + } catch (e) { + logger.error('Failed to add members to list', {safeMessage: e}) + Toast.show(_(msg`List created, but failed to add some members`), { + type: 'error', + }) + } + + loadingDialogControl.close(navigateToList) + } + + const onListCreated = (listUri: string) => { + loadingDialogControl.open() + addMembersAndNavigate(listUri) + } + + return ( + <> + + + + + + Create list from starter pack + + + + + This will create a new list with the same name, description, and + members as this starter pack. + + + + + Changes to the starter pack will not be reflected in the list + after creation. The list will be an independent copy. + + + + + + + + + + + + + + + + + + + + Adding members to list... + + + + + + ) +} diff --git a/src/components/dialogs/lists/CreateOrEditListDialog.tsx b/src/components/dialogs/lists/CreateOrEditListDialog.tsx index c97990ace2..fa3b8903ef 100644 --- a/src/components/dialogs/lists/CreateOrEditListDialog.tsx +++ b/src/components/dialogs/lists/CreateOrEditListDialog.tsx @@ -30,16 +30,24 @@ import {IS_WEB} from '#/env' const DISPLAY_NAME_MAX_GRAPHEMES = 64 const DESCRIPTION_MAX_GRAPHEMES = 300 +export type InitialListValues = { + name?: string + description?: string + avatar?: string +} + export function CreateOrEditListDialog({ control, list, purpose, onSave, + initialValues, }: { control: Dialog.DialogControlProps list?: AppBskyGraphDefs.ListView purpose?: AppBskyGraphDefs.ListPurpose onSave?: (uri: string) => void + initialValues?: InitialListValues }) { const {_} = useLingui() const cancelControl = Dialog.useDialogControl() @@ -82,6 +90,7 @@ export function CreateOrEditListDialog({ onSave={onSave} setDirty={setDirty} onPressCancel={onPressCancel} + initialValues={initialValues} /> void setDirty: (dirty: boolean) => void onPressCancel: () => void + initialValues?: InitialListValues }) { const activePurpose = useMemo(() => { if (list?.purpose) { @@ -138,11 +149,12 @@ function DialogInner({ } = useListMetadataMutation() const [imageError, setImageError] = useState('') const [displayNameTooShort, setDisplayNameTooShort] = useState(false) - const initialDisplayName = list?.name || '' + const initialDisplayName = list?.name || initialValues?.name || '' const [displayName, setDisplayName] = useState(initialDisplayName) - const initialDescription = list?.description || '' + const initialDescription = + list?.description || initialValues?.description || '' const [descriptionRt, setDescriptionRt] = useState(() => { - const text = list?.description + const text = list?.description ?? initialValues?.description const facets = list?.descriptionFacets if (!text || !facets) { @@ -159,17 +171,22 @@ function DialogInner({ return richText }) + const initialAvatar = list?.avatar ?? initialValues?.avatar const [listAvatar, setListAvatar] = useState( - list?.avatar, + initialAvatar, ) const [newListAvatar, setNewListAvatar] = useState< ImageMeta | undefined | null >() + // When creating with pre-filled values (from starter pack), consider dirty + // immediately so the Save button is enabled + const hasInitialValuesForCreate = !list && initialValues != null const dirty = + hasInitialValuesForCreate || displayName !== initialDisplayName || descriptionRt.text !== initialDescription || - listAvatar !== list?.avatar + listAvatar !== initialAvatar useEffect(() => { setDirty(dirty) diff --git a/src/screens/StarterPack/StarterPackScreen.tsx b/src/screens/StarterPack/StarterPackScreen.tsx index e4fbc81891..46fb2006d5 100644 --- a/src/screens/StarterPack/StarterPackScreen.tsx +++ b/src/screens/StarterPack/StarterPackScreen.tsx @@ -50,10 +50,12 @@ 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 {DotGrid_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 * as Layout from '#/components/Layout' @@ -528,6 +530,7 @@ function OverflowMenu({ const {currentAccount} = useSession() const reportDialogControl = useReportDialogControl() const deleteDialogControl = useDialogControl() + const convertToListDialogControl = useDialogControl() const navigation = useNavigation() const { @@ -610,6 +613,17 @@ function OverflowMenu({ + { + convertToListDialogControl.open() + }}> + + Create list from members + + + ) : ( <> @@ -702,6 +716,11 @@ function OverflowMenu({ + + ) } diff --git a/src/view/com/util/EmptyState.tsx b/src/view/com/util/EmptyState.tsx index 5300767b45..ca084e72a1 100644 --- a/src/view/com/util/EmptyState.tsx +++ b/src/view/com/util/EmptyState.tsx @@ -67,7 +67,7 @@ export function EmptyState({ } return ( - +