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

Configure Feed

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

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