Add ability to convert starter pack to list (#9675)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Samuel Newman <mozzius@users.noreply.github.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
This commit is contained in:
Samuel Newman
2026-02-20 17:27:55 +00:00
committed by GitHub
parent f3ddc074a6
commit 8c4fc087f8
5 changed files with 280 additions and 6 deletions
+4
View File
@@ -571,6 +571,10 @@ export type Events = {
profilesCount: number
feedsCount: number
}
'starterPack:convertToList': {
starterPack: string
memberCount: number
}
'starterPack:ctaPress': {
starterPack: string
}
@@ -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<NavigationProp>()
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<ComAtprotoRepoApplyWrites.Create>[] =
items.map(item => {
const listitemRecord: $Typed<AppBskyGraphListitem.Record> = {
$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 (
<>
<Dialog.Outer
control={control}
testID="createListFromStarterPackDialog"
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Create list from starter pack`)}
style={web({maxWidth: 400})}>
<View style={[a.gap_lg]}>
<Text style={[a.text_xl, a.font_bold]}>
<Trans>Create list from starter pack</Trans>
</Text>
<Text
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
<Trans>
This will create a new list with the same name, description, and
members as this starter pack.
</Trans>
</Text>
<Admonition type="tip">
Changes to the starter pack will not be reflected in the list
after creation. The list will be an independent copy.
</Admonition>
<View
style={[
platform({
web: [a.flex_row_reverse],
native: [a.flex_col],
}),
a.gap_md,
a.pt_sm,
]}>
<Button
label={_(msg`Create list`)}
onPress={onPressCreate}
size={platform({
web: 'small',
native: 'large',
})}
color="primary">
<ButtonText>
<Trans>Create list</Trans>
</ButtonText>
</Button>
<Button
label={_(msg`Cancel`)}
onPress={() => control.close()}
size={platform({
web: 'small',
native: 'large',
})}
color="secondary">
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
</View>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
<CreateOrEditListDialog
control={createDialogControl}
purpose="app.bsky.graph.defs#curatelist"
onSave={onListCreated}
initialValues={{
name: record.name,
description: record.description,
avatar: starterPack.list?.avatar,
}}
/>
<Dialog.Outer
control={loadingDialogControl}
nativeOptions={{preventDismiss: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Adding members to list...`)}
style={web({maxWidth: 400})}>
<View style={[a.align_center, a.gap_lg, a.py_5xl]}>
<Loader size="xl" />
<Text style={[a.text_lg, t.atoms.text_contrast_high]}>
<Trans>Adding members to list...</Trans>
</Text>
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
</>
)
}
@@ -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}
/>
<Prompt.Basic
@@ -102,12 +111,14 @@ function DialogInner({
onSave,
setDirty,
onPressCancel,
initialValues,
}: {
list?: AppBskyGraphDefs.ListView
purpose?: AppBskyGraphDefs.ListPurpose
onSave?: (uri: string) => 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<RichTextAPI>(() => {
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<string | undefined | null>(
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)
@@ -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<NavigationProp>()
const {
@@ -610,6 +613,17 @@ function OverflowMenu({
</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
<Menu.Item
label={_(msg`Create a list from this starter pack`)}
testID="convertToListBtn"
onPress={() => {
convertToListDialogControl.open()
}}>
<Menu.ItemText>
<Trans>Create list from members</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ListSparkle} position="right" />
</Menu.Item>
</>
) : (
<>
@@ -702,6 +716,11 @@ function OverflowMenu({
<Prompt.Cancel />
</Prompt.Actions>
</Prompt.Outer>
<CreateListFromStarterPackDialog
control={convertToListDialogControl}
starterPack={starterPack}
/>
</>
)
}
+1 -1
View File
@@ -67,7 +67,7 @@ export function EmptyState({
}
return (
<View testID={testID} style={style}>
<View testID={testID} style={[a.w_full, style]}>
<View
style={[
a.flex_row,