Bluesky app fork with some witchin' additions 馃挮
at feat/tealfm 389 lines 11 kB view raw
1import {useCallback, useState} from 'react' 2import {View} from 'react-native' 3import { 4 type AppBskyGraphGetStarterPacksWithMembership, 5 AppBskyGraphStarterpack, 6} from '@atproto/api' 7import {msg, Plural, Trans} from '@lingui/macro' 8import {useLingui} from '@lingui/react' 9import {useNavigation} from '@react-navigation/native' 10import {useQueryClient} from '@tanstack/react-query' 11 12import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 13import {type NavigationProp} from '#/lib/routes/types' 14import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 15import { 16 invalidateActorStarterPacksWithMembershipQuery, 17 useActorStarterPacksWithMembershipsQuery, 18} from '#/state/queries/actor-starter-packs' 19import { 20 useListMembershipAddMutation, 21 useListMembershipRemoveMutation, 22} from '#/state/queries/list-memberships' 23import * as Toast from '#/view/com/util/Toast' 24import {atoms as a, useTheme} from '#/alf' 25import {AvatarStack} from '#/components/AvatarStack' 26import {Button, ButtonIcon, ButtonText} from '#/components/Button' 27import * as Dialog from '#/components/Dialog' 28import {Divider} from '#/components/Divider' 29import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 30import {StarterPack} from '#/components/icons/StarterPack' 31import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 32import {Loader} from '#/components/Loader' 33import {Text} from '#/components/Typography' 34import {useAnalytics} from '#/analytics' 35import {IS_WEB} from '#/env' 36import * as bsky from '#/types/bsky' 37 38type StarterPackWithMembership = 39 AppBskyGraphGetStarterPacksWithMembership.StarterPackWithMembership 40 41export type StarterPackDialogProps = { 42 control: Dialog.DialogControlProps 43 targetDid: string 44 enabled?: boolean 45} 46 47export function StarterPackDialog({ 48 control, 49 targetDid, 50 enabled, 51}: StarterPackDialogProps) { 52 const navigation = useNavigation<NavigationProp>() 53 const requireEmailVerification = useRequireEmailVerification() 54 55 const navToWizard = useCallback(() => { 56 control.close() 57 navigation.navigate('StarterPackWizard', { 58 fromDialog: true, 59 targetDid: targetDid, 60 onSuccess: () => { 61 setTimeout(() => { 62 if (!control.isOpen) { 63 control.open() 64 } 65 }, 0) 66 }, 67 }) 68 }, [navigation, control, targetDid]) 69 70 const wrappedNavToWizard = requireEmailVerification(navToWizard, { 71 instructions: [ 72 <Trans key="nav"> 73 Before creating a starter pack, you must first verify your email. 74 </Trans>, 75 ], 76 }) 77 78 return ( 79 <Dialog.Outer control={control}> 80 <Dialog.Handle /> 81 <StarterPackList 82 onStartWizard={wrappedNavToWizard} 83 targetDid={targetDid} 84 enabled={enabled} 85 /> 86 </Dialog.Outer> 87 ) 88} 89 90function Empty({onStartWizard}: {onStartWizard: () => void}) { 91 const {_} = useLingui() 92 const t = useTheme() 93 94 return ( 95 <View style={[a.gap_2xl, {paddingTop: IS_WEB ? 100 : 64}]}> 96 <View style={[a.gap_xs, a.align_center]}> 97 <StarterPack 98 width={48} 99 fill={t.atoms.border_contrast_medium.borderColor} 100 /> 101 <Text style={[a.text_center]}> 102 <Trans>You have no starter packs.</Trans> 103 </Text> 104 </View> 105 106 <View style={[a.align_center]}> 107 <Button 108 label={_(msg`Create starter pack`)} 109 color="secondary_inverted" 110 size="small" 111 onPress={onStartWizard}> 112 <ButtonText> 113 <Trans comment="Text on button to create a new starter pack"> 114 Create 115 </Trans> 116 </ButtonText> 117 <ButtonIcon icon={PlusIcon} /> 118 </Button> 119 </View> 120 </View> 121 ) 122} 123 124function StarterPackList({ 125 onStartWizard, 126 targetDid, 127 enabled, 128}: { 129 onStartWizard: () => void 130 targetDid: string 131 enabled?: boolean 132}) { 133 const control = Dialog.useDialogContext() 134 const {_} = useLingui() 135 136 const enableSquareButtons = useEnableSquareButtons() 137 138 const { 139 data, 140 isError, 141 isLoading, 142 hasNextPage, 143 isFetchingNextPage, 144 fetchNextPage, 145 } = useActorStarterPacksWithMembershipsQuery({did: targetDid, enabled}) 146 147 const membershipItems = 148 data?.pages.flatMap(page => page.starterPacksWithMembership) || [] 149 150 const onEndReached = useCallback(async () => { 151 if (isFetchingNextPage || !hasNextPage || isError) return 152 try { 153 await fetchNextPage() 154 } catch (err) { 155 // Error handling is optional since this is just pagination 156 } 157 }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) 158 159 const renderItem = useCallback( 160 ({item}: {item: StarterPackWithMembership}) => ( 161 <StarterPackItem starterPackWithMembership={item} targetDid={targetDid} /> 162 ), 163 [targetDid], 164 ) 165 166 const onClose = useCallback(() => { 167 control.close() 168 }, [control]) 169 170 const listHeader = ( 171 <> 172 <View 173 style={[ 174 {justifyContent: 'space-between', flexDirection: 'row'}, 175 IS_WEB ? a.mb_2xl : a.my_lg, 176 a.align_center, 177 ]}> 178 <Text style={[a.text_lg, a.font_semi_bold]}> 179 <Trans>Add to starter packs</Trans> 180 </Text> 181 <Button 182 label={_(msg`Close`)} 183 onPress={onClose} 184 variant="ghost" 185 color="secondary" 186 size="small" 187 shape={enableSquareButtons ? 'square' : 'round'}> 188 <ButtonIcon icon={XIcon} /> 189 </Button> 190 </View> 191 {membershipItems.length > 0 && ( 192 <> 193 <View 194 style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}> 195 <Text style={[a.text_md, a.font_semi_bold]}> 196 <Trans>New starter pack</Trans> 197 </Text> 198 <Button 199 label={_(msg`Create starter pack`)} 200 color="secondary_inverted" 201 size="small" 202 onPress={onStartWizard}> 203 <ButtonText> 204 <Trans comment="Text on button to create a new starter pack"> 205 Create 206 </Trans> 207 </ButtonText> 208 <ButtonIcon icon={PlusIcon} /> 209 </Button> 210 </View> 211 <Divider /> 212 </> 213 )} 214 </> 215 ) 216 217 return ( 218 <Dialog.InnerFlatList 219 data={isLoading ? [{}] : membershipItems} 220 renderItem={ 221 isLoading 222 ? () => ( 223 <View style={[a.align_center, a.py_2xl]}> 224 <Loader size="xl" /> 225 </View> 226 ) 227 : renderItem 228 } 229 keyExtractor={ 230 isLoading 231 ? () => 'starter_pack_dialog_loader' 232 : (item: StarterPackWithMembership) => item.starterPack.uri 233 } 234 onEndReached={onEndReached} 235 onEndReachedThreshold={0.1} 236 ListHeaderComponent={listHeader} 237 ListEmptyComponent={<Empty onStartWizard={onStartWizard} />} 238 style={IS_WEB ? [a.px_md, {minHeight: 500}] : [a.px_2xl, a.pt_lg]} 239 /> 240 ) 241} 242 243function StarterPackItem({ 244 starterPackWithMembership, 245 targetDid, 246}: { 247 starterPackWithMembership: StarterPackWithMembership 248 targetDid: string 249}) { 250 const t = useTheme() 251 const ax = useAnalytics() 252 const {_} = useLingui() 253 const queryClient = useQueryClient() 254 255 const starterPack = starterPackWithMembership.starterPack 256 const isInPack = !!starterPackWithMembership.listItem 257 258 const [isPendingRefresh, setIsPendingRefresh] = useState(false) 259 260 const {mutate: addMembership} = useListMembershipAddMutation({ 261 onSuccess: () => { 262 Toast.show(_(msg`Added to starter pack`)) 263 // Use a timeout to wait for the appview to update, matching the pattern 264 // in list-memberships.ts 265 setTimeout(() => { 266 invalidateActorStarterPacksWithMembershipQuery({ 267 queryClient, 268 did: targetDid, 269 }) 270 setIsPendingRefresh(false) 271 }, 1e3) 272 }, 273 onError: () => { 274 Toast.show(_(msg`Failed to add to starter pack`), 'xmark') 275 setIsPendingRefresh(false) 276 }, 277 }) 278 279 const {mutate: removeMembership} = useListMembershipRemoveMutation({ 280 onSuccess: () => { 281 Toast.show(_(msg`Removed from starter pack`)) 282 // Use a timeout to wait for the appview to update, matching the pattern 283 // in list-memberships.ts 284 setTimeout(() => { 285 invalidateActorStarterPacksWithMembershipQuery({ 286 queryClient, 287 did: targetDid, 288 }) 289 setIsPendingRefresh(false) 290 }, 1e3) 291 }, 292 onError: () => { 293 Toast.show(_(msg`Failed to remove from starter pack`), 'xmark') 294 setIsPendingRefresh(false) 295 }, 296 }) 297 298 const handleToggleMembership = () => { 299 if (!starterPack.list?.uri || isPendingRefresh) return 300 301 const listUri = starterPack.list.uri 302 const starterPackUri = starterPack.uri 303 304 setIsPendingRefresh(true) 305 306 if (!isInPack) { 307 addMembership({ 308 listUri: listUri, 309 actorDid: targetDid, 310 }) 311 ax.metric('starterPack:addUser', {starterPack: starterPackUri}) 312 } else { 313 if (!starterPackWithMembership.listItem?.uri) { 314 console.error('Cannot remove: missing membership URI') 315 setIsPendingRefresh(false) 316 return 317 } 318 removeMembership({ 319 listUri: listUri, 320 actorDid: targetDid, 321 membershipUri: starterPackWithMembership.listItem.uri, 322 }) 323 ax.metric('starterPack:removeUser', {starterPack: starterPackUri}) 324 } 325 } 326 327 const {record} = starterPack 328 329 if ( 330 !bsky.dangerousIsType<AppBskyGraphStarterpack.Record>( 331 record, 332 AppBskyGraphStarterpack.isRecord, 333 ) 334 ) { 335 return null 336 } 337 338 return ( 339 <View style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}> 340 <View> 341 <Text emoji style={[a.text_md, a.font_semi_bold]} numberOfLines={1}> 342 {record.name} 343 </Text> 344 345 <View style={[a.flex_row, a.align_center, a.mt_xs]}> 346 {starterPack.listItemsSample && 347 starterPack.listItemsSample.length > 0 && ( 348 <> 349 <AvatarStack 350 size={32} 351 profiles={starterPack.listItemsSample 352 ?.slice(0, 4) 353 .map(p => p.subject)} 354 /> 355 356 {starterPack.list?.listItemCount && 357 starterPack.list.listItemCount > 4 && ( 358 <Text 359 style={[ 360 a.text_sm, 361 t.atoms.text_contrast_medium, 362 a.ml_xs, 363 ]}> 364 <Trans> 365 <Plural 366 value={starterPack.list.listItemCount - 4} 367 other="+# more" 368 /> 369 </Trans> 370 </Text> 371 )} 372 </> 373 )} 374 </View> 375 </View> 376 377 <Button 378 label={isInPack ? _(msg`Remove`) : _(msg`Add`)} 379 color={isInPack ? 'secondary' : 'primary_subtle'} 380 size="tiny" 381 disabled={isPendingRefresh} 382 onPress={handleToggleMembership}> 383 <ButtonText> 384 {isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>} 385 </ButtonText> 386 </Button> 387 </View> 388 ) 389}