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