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:
@@ -571,6 +571,10 @@ export type Events = {
|
|||||||
profilesCount: number
|
profilesCount: number
|
||||||
feedsCount: number
|
feedsCount: number
|
||||||
}
|
}
|
||||||
|
'starterPack:convertToList': {
|
||||||
|
starterPack: string
|
||||||
|
memberCount: number
|
||||||
|
}
|
||||||
'starterPack:ctaPress': {
|
'starterPack:ctaPress': {
|
||||||
starterPack: string
|
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 DISPLAY_NAME_MAX_GRAPHEMES = 64
|
||||||
const DESCRIPTION_MAX_GRAPHEMES = 300
|
const DESCRIPTION_MAX_GRAPHEMES = 300
|
||||||
|
|
||||||
|
export type InitialListValues = {
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
avatar?: string
|
||||||
|
}
|
||||||
|
|
||||||
export function CreateOrEditListDialog({
|
export function CreateOrEditListDialog({
|
||||||
control,
|
control,
|
||||||
list,
|
list,
|
||||||
purpose,
|
purpose,
|
||||||
onSave,
|
onSave,
|
||||||
|
initialValues,
|
||||||
}: {
|
}: {
|
||||||
control: Dialog.DialogControlProps
|
control: Dialog.DialogControlProps
|
||||||
list?: AppBskyGraphDefs.ListView
|
list?: AppBskyGraphDefs.ListView
|
||||||
purpose?: AppBskyGraphDefs.ListPurpose
|
purpose?: AppBskyGraphDefs.ListPurpose
|
||||||
onSave?: (uri: string) => void
|
onSave?: (uri: string) => void
|
||||||
|
initialValues?: InitialListValues
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const cancelControl = Dialog.useDialogControl()
|
const cancelControl = Dialog.useDialogControl()
|
||||||
@@ -82,6 +90,7 @@ export function CreateOrEditListDialog({
|
|||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
setDirty={setDirty}
|
setDirty={setDirty}
|
||||||
onPressCancel={onPressCancel}
|
onPressCancel={onPressCancel}
|
||||||
|
initialValues={initialValues}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Prompt.Basic
|
<Prompt.Basic
|
||||||
@@ -102,12 +111,14 @@ function DialogInner({
|
|||||||
onSave,
|
onSave,
|
||||||
setDirty,
|
setDirty,
|
||||||
onPressCancel,
|
onPressCancel,
|
||||||
|
initialValues,
|
||||||
}: {
|
}: {
|
||||||
list?: AppBskyGraphDefs.ListView
|
list?: AppBskyGraphDefs.ListView
|
||||||
purpose?: AppBskyGraphDefs.ListPurpose
|
purpose?: AppBskyGraphDefs.ListPurpose
|
||||||
onSave?: (uri: string) => void
|
onSave?: (uri: string) => void
|
||||||
setDirty: (dirty: boolean) => void
|
setDirty: (dirty: boolean) => void
|
||||||
onPressCancel: () => void
|
onPressCancel: () => void
|
||||||
|
initialValues?: InitialListValues
|
||||||
}) {
|
}) {
|
||||||
const activePurpose = useMemo(() => {
|
const activePurpose = useMemo(() => {
|
||||||
if (list?.purpose) {
|
if (list?.purpose) {
|
||||||
@@ -138,11 +149,12 @@ function DialogInner({
|
|||||||
} = useListMetadataMutation()
|
} = useListMetadataMutation()
|
||||||
const [imageError, setImageError] = useState('')
|
const [imageError, setImageError] = useState('')
|
||||||
const [displayNameTooShort, setDisplayNameTooShort] = useState(false)
|
const [displayNameTooShort, setDisplayNameTooShort] = useState(false)
|
||||||
const initialDisplayName = list?.name || ''
|
const initialDisplayName = list?.name || initialValues?.name || ''
|
||||||
const [displayName, setDisplayName] = useState(initialDisplayName)
|
const [displayName, setDisplayName] = useState(initialDisplayName)
|
||||||
const initialDescription = list?.description || ''
|
const initialDescription =
|
||||||
|
list?.description || initialValues?.description || ''
|
||||||
const [descriptionRt, setDescriptionRt] = useState<RichTextAPI>(() => {
|
const [descriptionRt, setDescriptionRt] = useState<RichTextAPI>(() => {
|
||||||
const text = list?.description
|
const text = list?.description ?? initialValues?.description
|
||||||
const facets = list?.descriptionFacets
|
const facets = list?.descriptionFacets
|
||||||
|
|
||||||
if (!text || !facets) {
|
if (!text || !facets) {
|
||||||
@@ -159,17 +171,22 @@ function DialogInner({
|
|||||||
return richText
|
return richText
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const initialAvatar = list?.avatar ?? initialValues?.avatar
|
||||||
const [listAvatar, setListAvatar] = useState<string | undefined | null>(
|
const [listAvatar, setListAvatar] = useState<string | undefined | null>(
|
||||||
list?.avatar,
|
initialAvatar,
|
||||||
)
|
)
|
||||||
const [newListAvatar, setNewListAvatar] = useState<
|
const [newListAvatar, setNewListAvatar] = useState<
|
||||||
ImageMeta | undefined | null
|
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 =
|
const dirty =
|
||||||
|
hasInitialValuesForCreate ||
|
||||||
displayName !== initialDisplayName ||
|
displayName !== initialDisplayName ||
|
||||||
descriptionRt.text !== initialDescription ||
|
descriptionRt.text !== initialDescription ||
|
||||||
listAvatar !== list?.avatar
|
listAvatar !== initialAvatar
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDirty(dirty)
|
setDirty(dirty)
|
||||||
|
|||||||
@@ -50,10 +50,12 @@ import {bulkWriteFollows} from '#/screens/Onboarding/util'
|
|||||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import {useDialogControl} from '#/components/Dialog'
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
|
import {CreateListFromStarterPackDialog} from '#/components/dialogs/lists/CreateListFromStarterPackDialog'
|
||||||
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
|
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
|
||||||
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
|
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||||
import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
|
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 {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil'
|
||||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
@@ -528,6 +530,7 @@ function OverflowMenu({
|
|||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const reportDialogControl = useReportDialogControl()
|
const reportDialogControl = useReportDialogControl()
|
||||||
const deleteDialogControl = useDialogControl()
|
const deleteDialogControl = useDialogControl()
|
||||||
|
const convertToListDialogControl = useDialogControl()
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -610,6 +613,17 @@ function OverflowMenu({
|
|||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={Trash} position="right" />
|
<Menu.ItemIcon icon={Trash} position="right" />
|
||||||
</Menu.Item>
|
</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.Cancel />
|
||||||
</Prompt.Actions>
|
</Prompt.Actions>
|
||||||
</Prompt.Outer>
|
</Prompt.Outer>
|
||||||
|
|
||||||
|
<CreateListFromStarterPackDialog
|
||||||
|
control={convertToListDialogControl}
|
||||||
|
starterPack={starterPack}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function EmptyState({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View testID={testID} style={style}>
|
<View testID={testID} style={[a.w_full, style]}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
|
|||||||
Reference in New Issue
Block a user