Bluesky app fork with some witchin' additions 💫

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>

+280 -6
+4
src/analytics/metrics/types.ts
··· 571 571 profilesCount: number 572 572 feedsCount: number 573 573 } 574 + 'starterPack:convertToList': { 575 + starterPack: string 576 + memberCount: number 577 + } 574 578 'starterPack:ctaPress': { 575 579 starterPack: string 576 580 }
+234
src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx
··· 1 + import {View} from 'react-native' 2 + import { 3 + type $Typed, 4 + type AppBskyGraphDefs, 5 + type AppBskyGraphListitem, 6 + type AppBskyGraphStarterpack, 7 + AtUri, 8 + type ComAtprotoRepoApplyWrites, 9 + } from '@atproto/api' 10 + import {TID} from '@atproto/common-web' 11 + import {msg, Trans} from '@lingui/macro' 12 + import {useLingui} from '@lingui/react' 13 + import {useNavigation} from '@react-navigation/native' 14 + import {useQueryClient} from '@tanstack/react-query' 15 + import chunk from 'lodash.chunk' 16 + 17 + import {until} from '#/lib/async/until' 18 + import {wait} from '#/lib/async/wait' 19 + import {type NavigationProp} from '#/lib/routes/types' 20 + import {logger} from '#/logger' 21 + import {getAllListMembers} from '#/state/queries/list-members' 22 + import {useAgent, useSession} from '#/state/session' 23 + import {atoms as a, platform, useTheme, web} from '#/alf' 24 + import {Admonition} from '#/components/Admonition' 25 + import {Button, ButtonText} from '#/components/Button' 26 + import * as Dialog from '#/components/Dialog' 27 + import {Loader} from '#/components/Loader' 28 + import * as Toast from '#/components/Toast' 29 + import {Text} from '#/components/Typography' 30 + import {useAnalytics} from '#/analytics' 31 + import {CreateOrEditListDialog} from './CreateOrEditListDialog' 32 + 33 + export function CreateListFromStarterPackDialog({ 34 + control, 35 + starterPack, 36 + }: { 37 + control: Dialog.DialogControlProps 38 + starterPack: AppBskyGraphDefs.StarterPackView 39 + }) { 40 + const {_} = useLingui() 41 + const t = useTheme() 42 + const agent = useAgent() 43 + const ax = useAnalytics() 44 + const {currentAccount} = useSession() 45 + const navigation = useNavigation<NavigationProp>() 46 + const queryClient = useQueryClient() 47 + const createDialogControl = Dialog.useDialogControl() 48 + const loadingDialogControl = Dialog.useDialogControl() 49 + 50 + const record = starterPack.record as AppBskyGraphStarterpack.Record 51 + 52 + const onPressCreate = () => { 53 + control.close(() => createDialogControl.open()) 54 + } 55 + 56 + const addMembersAndNavigate = async (listUri: string) => { 57 + const navigateToList = () => { 58 + const urip = new AtUri(listUri) 59 + navigation.navigate('ProfileList', { 60 + name: urip.hostname, 61 + rkey: urip.rkey, 62 + }) 63 + } 64 + 65 + if (!starterPack.list || !currentAccount) { 66 + loadingDialogControl.close(navigateToList) 67 + return 68 + } 69 + 70 + try { 71 + // Fetch all members and add them, with minimum 3s duration for UX 72 + const listItems = await wait( 73 + 3000, 74 + (async () => { 75 + const items = await getAllListMembers(agent, starterPack.list!.uri) 76 + 77 + if (items.length > 0) { 78 + const listitemWrites: $Typed<ComAtprotoRepoApplyWrites.Create>[] = 79 + items.map(item => { 80 + const listitemRecord: $Typed<AppBskyGraphListitem.Record> = { 81 + $type: 'app.bsky.graph.listitem', 82 + subject: item.subject.did, 83 + list: listUri, 84 + createdAt: new Date().toISOString(), 85 + } 86 + return { 87 + $type: 'com.atproto.repo.applyWrites#create', 88 + collection: 'app.bsky.graph.listitem', 89 + rkey: TID.nextStr(), 90 + value: listitemRecord, 91 + } 92 + }) 93 + 94 + const chunks = chunk(listitemWrites, 50) 95 + for (const c of chunks) { 96 + await agent.com.atproto.repo.applyWrites({ 97 + repo: currentAccount.did, 98 + writes: c, 99 + }) 100 + } 101 + 102 + await until( 103 + 5, 104 + 1e3, 105 + (res: {data: {items: unknown[]}}) => res.data.items.length > 0, 106 + () => 107 + agent.app.bsky.graph.getList({ 108 + list: listUri, 109 + limit: 1, 110 + }), 111 + ) 112 + } 113 + 114 + return items 115 + })(), 116 + ) 117 + 118 + queryClient.invalidateQueries({queryKey: ['list-members', listUri]}) 119 + 120 + ax.metric('starterPack:convertToList', { 121 + starterPack: starterPack.uri, 122 + memberCount: listItems.length, 123 + }) 124 + } catch (e) { 125 + logger.error('Failed to add members to list', {safeMessage: e}) 126 + Toast.show(_(msg`List created, but failed to add some members`), { 127 + type: 'error', 128 + }) 129 + } 130 + 131 + loadingDialogControl.close(navigateToList) 132 + } 133 + 134 + const onListCreated = (listUri: string) => { 135 + loadingDialogControl.open() 136 + addMembersAndNavigate(listUri) 137 + } 138 + 139 + return ( 140 + <> 141 + <Dialog.Outer 142 + control={control} 143 + testID="createListFromStarterPackDialog" 144 + nativeOptions={{preventExpansion: true}}> 145 + <Dialog.Handle /> 146 + <Dialog.ScrollableInner 147 + label={_(msg`Create list from starter pack`)} 148 + style={web({maxWidth: 400})}> 149 + <View style={[a.gap_lg]}> 150 + <Text style={[a.text_xl, a.font_bold]}> 151 + <Trans>Create list from starter pack</Trans> 152 + </Text> 153 + 154 + <Text 155 + style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}> 156 + <Trans> 157 + This will create a new list with the same name, description, and 158 + members as this starter pack. 159 + </Trans> 160 + </Text> 161 + 162 + <Admonition type="tip"> 163 + Changes to the starter pack will not be reflected in the list 164 + after creation. The list will be an independent copy. 165 + </Admonition> 166 + 167 + <View 168 + style={[ 169 + platform({ 170 + web: [a.flex_row_reverse], 171 + native: [a.flex_col], 172 + }), 173 + a.gap_md, 174 + a.pt_sm, 175 + ]}> 176 + <Button 177 + label={_(msg`Create list`)} 178 + onPress={onPressCreate} 179 + size={platform({ 180 + web: 'small', 181 + native: 'large', 182 + })} 183 + color="primary"> 184 + <ButtonText> 185 + <Trans>Create list</Trans> 186 + </ButtonText> 187 + </Button> 188 + <Button 189 + label={_(msg`Cancel`)} 190 + onPress={() => control.close()} 191 + size={platform({ 192 + web: 'small', 193 + native: 'large', 194 + })} 195 + color="secondary"> 196 + <ButtonText> 197 + <Trans>Cancel</Trans> 198 + </ButtonText> 199 + </Button> 200 + </View> 201 + </View> 202 + <Dialog.Close /> 203 + </Dialog.ScrollableInner> 204 + </Dialog.Outer> 205 + 206 + <CreateOrEditListDialog 207 + control={createDialogControl} 208 + purpose="app.bsky.graph.defs#curatelist" 209 + onSave={onListCreated} 210 + initialValues={{ 211 + name: record.name, 212 + description: record.description, 213 + avatar: starterPack.list?.avatar, 214 + }} 215 + /> 216 + 217 + <Dialog.Outer 218 + control={loadingDialogControl} 219 + nativeOptions={{preventDismiss: true}}> 220 + <Dialog.Handle /> 221 + <Dialog.ScrollableInner 222 + label={_(msg`Adding members to list...`)} 223 + style={web({maxWidth: 400})}> 224 + <View style={[a.align_center, a.gap_lg, a.py_5xl]}> 225 + <Loader size="xl" /> 226 + <Text style={[a.text_lg, t.atoms.text_contrast_high]}> 227 + <Trans>Adding members to list...</Trans> 228 + </Text> 229 + </View> 230 + </Dialog.ScrollableInner> 231 + </Dialog.Outer> 232 + </> 233 + ) 234 + }
+22 -5
src/components/dialogs/lists/CreateOrEditListDialog.tsx
··· 30 30 const DISPLAY_NAME_MAX_GRAPHEMES = 64 31 31 const DESCRIPTION_MAX_GRAPHEMES = 300 32 32 33 + export type InitialListValues = { 34 + name?: string 35 + description?: string 36 + avatar?: string 37 + } 38 + 33 39 export function CreateOrEditListDialog({ 34 40 control, 35 41 list, 36 42 purpose, 37 43 onSave, 44 + initialValues, 38 45 }: { 39 46 control: Dialog.DialogControlProps 40 47 list?: AppBskyGraphDefs.ListView 41 48 purpose?: AppBskyGraphDefs.ListPurpose 42 49 onSave?: (uri: string) => void 50 + initialValues?: InitialListValues 43 51 }) { 44 52 const {_} = useLingui() 45 53 const cancelControl = Dialog.useDialogControl() ··· 82 90 onSave={onSave} 83 91 setDirty={setDirty} 84 92 onPressCancel={onPressCancel} 93 + initialValues={initialValues} 85 94 /> 86 95 87 96 <Prompt.Basic ··· 102 111 onSave, 103 112 setDirty, 104 113 onPressCancel, 114 + initialValues, 105 115 }: { 106 116 list?: AppBskyGraphDefs.ListView 107 117 purpose?: AppBskyGraphDefs.ListPurpose 108 118 onSave?: (uri: string) => void 109 119 setDirty: (dirty: boolean) => void 110 120 onPressCancel: () => void 121 + initialValues?: InitialListValues 111 122 }) { 112 123 const activePurpose = useMemo(() => { 113 124 if (list?.purpose) { ··· 138 149 } = useListMetadataMutation() 139 150 const [imageError, setImageError] = useState('') 140 151 const [displayNameTooShort, setDisplayNameTooShort] = useState(false) 141 - const initialDisplayName = list?.name || '' 152 + const initialDisplayName = list?.name || initialValues?.name || '' 142 153 const [displayName, setDisplayName] = useState(initialDisplayName) 143 - const initialDescription = list?.description || '' 154 + const initialDescription = 155 + list?.description || initialValues?.description || '' 144 156 const [descriptionRt, setDescriptionRt] = useState<RichTextAPI>(() => { 145 - const text = list?.description 157 + const text = list?.description ?? initialValues?.description 146 158 const facets = list?.descriptionFacets 147 159 148 160 if (!text || !facets) { ··· 159 171 return richText 160 172 }) 161 173 174 + const initialAvatar = list?.avatar ?? initialValues?.avatar 162 175 const [listAvatar, setListAvatar] = useState<string | undefined | null>( 163 - list?.avatar, 176 + initialAvatar, 164 177 ) 165 178 const [newListAvatar, setNewListAvatar] = useState< 166 179 ImageMeta | undefined | null 167 180 >() 168 181 182 + // When creating with pre-filled values (from starter pack), consider dirty 183 + // immediately so the Save button is enabled 184 + const hasInitialValuesForCreate = !list && initialValues != null 169 185 const dirty = 186 + hasInitialValuesForCreate || 170 187 displayName !== initialDisplayName || 171 188 descriptionRt.text !== initialDescription || 172 - listAvatar !== list?.avatar 189 + listAvatar !== initialAvatar 173 190 174 191 useEffect(() => { 175 192 setDirty(dirty)
+19
src/screens/StarterPack/StarterPackScreen.tsx
··· 50 50 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 51 51 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 52 52 import {useDialogControl} from '#/components/Dialog' 53 + import {CreateListFromStarterPackDialog} from '#/components/dialogs/lists/CreateListFromStarterPackDialog' 53 54 import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' 54 55 import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' 55 56 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 56 57 import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' 58 + import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' 57 59 import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil' 58 60 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 59 61 import * as Layout from '#/components/Layout' ··· 528 530 const {currentAccount} = useSession() 529 531 const reportDialogControl = useReportDialogControl() 530 532 const deleteDialogControl = useDialogControl() 533 + const convertToListDialogControl = useDialogControl() 531 534 const navigation = useNavigation<NavigationProp>() 532 535 533 536 const { ··· 610 613 </Menu.ItemText> 611 614 <Menu.ItemIcon icon={Trash} position="right" /> 612 615 </Menu.Item> 616 + <Menu.Item 617 + label={_(msg`Create a list from this starter pack`)} 618 + testID="convertToListBtn" 619 + onPress={() => { 620 + convertToListDialogControl.open() 621 + }}> 622 + <Menu.ItemText> 623 + <Trans>Create list from members</Trans> 624 + </Menu.ItemText> 625 + <Menu.ItemIcon icon={ListSparkle} position="right" /> 626 + </Menu.Item> 613 627 </> 614 628 ) : ( 615 629 <> ··· 702 716 <Prompt.Cancel /> 703 717 </Prompt.Actions> 704 718 </Prompt.Outer> 719 + 720 + <CreateListFromStarterPackDialog 721 + control={convertToListDialogControl} 722 + starterPack={starterPack} 723 + /> 705 724 </> 706 725 ) 707 726 }
+1 -1
src/view/com/util/EmptyState.tsx
··· 67 67 } 68 68 69 69 return ( 70 - <View testID={testID} style={style}> 70 + <View testID={testID} style={[a.w_full, style]}> 71 71 <View 72 72 style={[ 73 73 a.flex_row,