Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at f82bc98d8d7d34edbf9b75c664987b000ae2db2b 386 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 { 15 invalidateActorStarterPacksWithMembershipQuery, 16 useActorStarterPacksWithMembershipsQuery, 17} from '#/state/queries/actor-starter-packs' 18import { 19 useListMembershipAddMutation, 20 useListMembershipRemoveMutation, 21} from '#/state/queries/list-memberships' 22import * as Toast from '#/view/com/util/Toast' 23import {atoms as a, useTheme} from '#/alf' 24import {AvatarStack} from '#/components/AvatarStack' 25import {Button, ButtonIcon, ButtonText} from '#/components/Button' 26import * as Dialog from '#/components/Dialog' 27import {Divider} from '#/components/Divider' 28import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 29import {StarterPack} from '#/components/icons/StarterPack' 30import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 31import {Loader} from '#/components/Loader' 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 135 const { 136 data, 137 isError, 138 isLoading, 139 hasNextPage, 140 isFetchingNextPage, 141 fetchNextPage, 142 } = useActorStarterPacksWithMembershipsQuery({did: targetDid, enabled}) 143 144 const membershipItems = 145 data?.pages.flatMap(page => page.starterPacksWithMembership) || [] 146 147 const onEndReached = useCallback(async () => { 148 if (isFetchingNextPage || !hasNextPage || isError) return 149 try { 150 await fetchNextPage() 151 } catch (err) { 152 // Error handling is optional since this is just pagination 153 } 154 }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) 155 156 const renderItem = useCallback( 157 ({item}: {item: StarterPackWithMembership}) => ( 158 <StarterPackItem starterPackWithMembership={item} targetDid={targetDid} /> 159 ), 160 [targetDid], 161 ) 162 163 const onClose = useCallback(() => { 164 control.close() 165 }, [control]) 166 167 const listHeader = ( 168 <> 169 <View 170 style={[ 171 {justifyContent: 'space-between', flexDirection: 'row'}, 172 IS_WEB ? a.mb_2xl : a.my_lg, 173 a.align_center, 174 ]}> 175 <Text style={[a.text_lg, a.font_semi_bold]}> 176 <Trans>Add to starter packs</Trans> 177 </Text> 178 <Button 179 label={_(msg`Close`)} 180 onPress={onClose} 181 variant="ghost" 182 color="secondary" 183 size="small" 184 shape="round"> 185 <ButtonIcon icon={XIcon} /> 186 </Button> 187 </View> 188 {membershipItems.length > 0 && ( 189 <> 190 <View 191 style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}> 192 <Text style={[a.text_md, a.font_semi_bold]}> 193 <Trans>New starter pack</Trans> 194 </Text> 195 <Button 196 label={_(msg`Create starter pack`)} 197 color="secondary_inverted" 198 size="small" 199 onPress={onStartWizard}> 200 <ButtonText> 201 <Trans comment="Text on button to create a new starter pack"> 202 Create 203 </Trans> 204 </ButtonText> 205 <ButtonIcon icon={PlusIcon} /> 206 </Button> 207 </View> 208 <Divider /> 209 </> 210 )} 211 </> 212 ) 213 214 return ( 215 <Dialog.InnerFlatList 216 data={isLoading ? [{}] : membershipItems} 217 renderItem={ 218 isLoading 219 ? () => ( 220 <View style={[a.align_center, a.py_2xl]}> 221 <Loader size="xl" /> 222 </View> 223 ) 224 : renderItem 225 } 226 keyExtractor={ 227 isLoading 228 ? () => 'starter_pack_dialog_loader' 229 : (item: StarterPackWithMembership) => item.starterPack.uri 230 } 231 onEndReached={onEndReached} 232 onEndReachedThreshold={0.1} 233 ListHeaderComponent={listHeader} 234 ListEmptyComponent={<Empty onStartWizard={onStartWizard} />} 235 style={IS_WEB ? [a.px_md, {minHeight: 500}] : [a.px_2xl, a.pt_lg]} 236 /> 237 ) 238} 239 240function StarterPackItem({ 241 starterPackWithMembership, 242 targetDid, 243}: { 244 starterPackWithMembership: StarterPackWithMembership 245 targetDid: string 246}) { 247 const t = useTheme() 248 const ax = useAnalytics() 249 const {_} = useLingui() 250 const queryClient = useQueryClient() 251 252 const starterPack = starterPackWithMembership.starterPack 253 const isInPack = !!starterPackWithMembership.listItem 254 255 const [isPendingRefresh, setIsPendingRefresh] = useState(false) 256 257 const {mutate: addMembership} = useListMembershipAddMutation({ 258 onSuccess: () => { 259 Toast.show(_(msg`Added to starter pack`)) 260 // Use a timeout to wait for the appview to update, matching the pattern 261 // in list-memberships.ts 262 setTimeout(() => { 263 invalidateActorStarterPacksWithMembershipQuery({ 264 queryClient, 265 did: targetDid, 266 }) 267 setIsPendingRefresh(false) 268 }, 1e3) 269 }, 270 onError: () => { 271 Toast.show(_(msg`Failed to add to starter pack`), 'xmark') 272 setIsPendingRefresh(false) 273 }, 274 }) 275 276 const {mutate: removeMembership} = useListMembershipRemoveMutation({ 277 onSuccess: () => { 278 Toast.show(_(msg`Removed from starter pack`)) 279 // Use a timeout to wait for the appview to update, matching the pattern 280 // in list-memberships.ts 281 setTimeout(() => { 282 invalidateActorStarterPacksWithMembershipQuery({ 283 queryClient, 284 did: targetDid, 285 }) 286 setIsPendingRefresh(false) 287 }, 1e3) 288 }, 289 onError: () => { 290 Toast.show(_(msg`Failed to remove from starter pack`), 'xmark') 291 setIsPendingRefresh(false) 292 }, 293 }) 294 295 const handleToggleMembership = () => { 296 if (!starterPack.list?.uri || isPendingRefresh) return 297 298 const listUri = starterPack.list.uri 299 const starterPackUri = starterPack.uri 300 301 setIsPendingRefresh(true) 302 303 if (!isInPack) { 304 addMembership({ 305 listUri: listUri, 306 actorDid: targetDid, 307 }) 308 ax.metric('starterPack:addUser', {starterPack: starterPackUri}) 309 } else { 310 if (!starterPackWithMembership.listItem?.uri) { 311 console.error('Cannot remove: missing membership URI') 312 setIsPendingRefresh(false) 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={32} 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={isPendingRefresh} 379 onPress={handleToggleMembership}> 380 <ButtonText> 381 {isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>} 382 </ButtonText> 383 </Button> 384 </View> 385 ) 386}