Bluesky app fork with some witchin' additions 馃挮
at main 388 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 {logger} from '#/logger' 15import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 16import { 17 invalidateActorStarterPacksWithMembershipQuery, 18 useActorStarterPacksWithMembershipsQuery, 19} from '#/state/queries/actor-starter-packs' 20import { 21 useListMembershipAddMutation, 22 useListMembershipRemoveMutation, 23} from '#/state/queries/list-memberships' 24import * as Toast from '#/view/com/util/Toast' 25import {atoms as a, useTheme} from '#/alf' 26import {AvatarStack} from '#/components/AvatarStack' 27import {Button, ButtonIcon, ButtonText} from '#/components/Button' 28import * as Dialog from '#/components/Dialog' 29import {Divider} from '#/components/Divider' 30import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 31import {StarterPack} from '#/components/icons/StarterPack' 32import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 33import {Loader} from '#/components/Loader' 34import {Text} from '#/components/Typography' 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 {_} = useLingui() 251 const t = useTheme() 252 const queryClient = useQueryClient() 253 254 const starterPack = starterPackWithMembership.starterPack 255 const isInPack = !!starterPackWithMembership.listItem 256 257 const [isPendingRefresh, setIsPendingRefresh] = useState(false) 258 259 const {mutate: addMembership} = useListMembershipAddMutation({ 260 onSuccess: () => { 261 Toast.show(_(msg`Added to starter pack`)) 262 // Use a timeout to wait for the appview to update, matching the pattern 263 // in list-memberships.ts 264 setTimeout(() => { 265 invalidateActorStarterPacksWithMembershipQuery({ 266 queryClient, 267 did: targetDid, 268 }) 269 setIsPendingRefresh(false) 270 }, 1e3) 271 }, 272 onError: () => { 273 Toast.show(_(msg`Failed to add to starter pack`), 'xmark') 274 setIsPendingRefresh(false) 275 }, 276 }) 277 278 const {mutate: removeMembership} = useListMembershipRemoveMutation({ 279 onSuccess: () => { 280 Toast.show(_(msg`Removed from starter pack`)) 281 // Use a timeout to wait for the appview to update, matching the pattern 282 // in list-memberships.ts 283 setTimeout(() => { 284 invalidateActorStarterPacksWithMembershipQuery({ 285 queryClient, 286 did: targetDid, 287 }) 288 setIsPendingRefresh(false) 289 }, 1e3) 290 }, 291 onError: () => { 292 Toast.show(_(msg`Failed to remove from starter pack`), 'xmark') 293 setIsPendingRefresh(false) 294 }, 295 }) 296 297 const handleToggleMembership = () => { 298 if (!starterPack.list?.uri || isPendingRefresh) return 299 300 const listUri = starterPack.list.uri 301 const starterPackUri = starterPack.uri 302 303 setIsPendingRefresh(true) 304 305 if (!isInPack) { 306 addMembership({ 307 listUri: listUri, 308 actorDid: targetDid, 309 }) 310 logger.metric('starterPack:addUser', {starterPack: starterPackUri}) 311 } else { 312 if (!starterPackWithMembership.listItem?.uri) { 313 console.error('Cannot remove: missing membership URI') 314 setIsPendingRefresh(false) 315 return 316 } 317 removeMembership({ 318 listUri: listUri, 319 actorDid: targetDid, 320 membershipUri: starterPackWithMembership.listItem.uri, 321 }) 322 logger.metric('starterPack:removeUser', {starterPack: starterPackUri}) 323 } 324 } 325 326 const {record} = starterPack 327 328 if ( 329 !bsky.dangerousIsType<AppBskyGraphStarterpack.Record>( 330 record, 331 AppBskyGraphStarterpack.isRecord, 332 ) 333 ) { 334 return null 335 } 336 337 return ( 338 <View style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}> 339 <View> 340 <Text emoji style={[a.text_md, a.font_semi_bold]} numberOfLines={1}> 341 {record.name} 342 </Text> 343 344 <View style={[a.flex_row, a.align_center, a.mt_xs]}> 345 {starterPack.listItemsSample && 346 starterPack.listItemsSample.length > 0 && ( 347 <> 348 <AvatarStack 349 size={32} 350 profiles={starterPack.listItemsSample 351 ?.slice(0, 4) 352 .map(p => p.subject)} 353 /> 354 355 {starterPack.list?.listItemCount && 356 starterPack.list.listItemCount > 4 && ( 357 <Text 358 style={[ 359 a.text_sm, 360 t.atoms.text_contrast_medium, 361 a.ml_xs, 362 ]}> 363 <Trans> 364 <Plural 365 value={starterPack.list.listItemCount - 4} 366 other="+# more" 367 /> 368 </Trans> 369 </Text> 370 )} 371 </> 372 )} 373 </View> 374 </View> 375 376 <Button 377 label={isInPack ? _(msg`Remove`) : _(msg`Add`)} 378 color={isInPack ? 'secondary' : 'primary_subtle'} 379 size="tiny" 380 disabled={isPendingRefresh} 381 onPress={handleToggleMembership}> 382 <ButtonText> 383 {isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>} 384 </ButtonText> 385 </Button> 386 </View> 387 ) 388}