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

Configure Feed

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

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