Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at main 388 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} from '@lingui/core/macro' 8import {useLingui} from '@lingui/react' 9import {Plural, Trans} from '@lingui/react/macro' 10import {useNavigation} from '@react-navigation/native' 11 12import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 13import {type NavigationProp} from '#/lib/routes/types' 14import {isNetworkError} from '#/lib/strings/errors' 15import {logger} from '#/logger' 16import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 17import {useActorStarterPacksWithMembershipsQuery} from '#/state/queries/actor-starter-packs' 18import { 19 useListMembershipAddMutation, 20 useListMembershipRemoveMutation, 21} from '#/state/queries/list-memberships' 22import {useProfileQuery} from '#/state/queries/profile' 23import {atoms as a, native, platform, 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 * as Toast from '#/components/Toast' 33import {Text} from '#/components/Typography' 34import {useAnalytics} from '#/analytics' 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 const {data: subject} = useProfileQuery({did: targetDid}) 136 137 const enableSquareButtons = useEnableSquareButtons() 138 139 const { 140 data, 141 isError, 142 isLoading, 143 hasNextPage, 144 isFetchingNextPage, 145 fetchNextPage, 146 } = useActorStarterPacksWithMembershipsQuery({did: targetDid, enabled}) 147 148 const membershipItems = 149 data?.pages.flatMap(page => page.starterPacksWithMembership) || [] 150 151 const onEndReached = useCallback(async () => { 152 if (isFetchingNextPage || !hasNextPage || isError) return 153 try { 154 await fetchNextPage() 155 } catch (err) { 156 // Error handling is optional since this is just pagination 157 } 158 }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) 159 160 const renderItem = useCallback( 161 ({item}: {item: StarterPackWithMembership}) => ( 162 <StarterPackItem 163 starterPackWithMembership={item} 164 targetDid={targetDid} 165 subject={subject} 166 /> 167 ), 168 [targetDid, subject], 169 ) 170 171 const onClose = useCallback(() => { 172 control.close() 173 }, [control]) 174 175 const listHeader = ( 176 <> 177 <View 178 style={[ 179 a.justify_between, 180 a.align_center, 181 a.flex_row, 182 a.pb_lg, 183 native(a.pt_lg), 184 ]}> 185 <Text style={[a.text_lg, a.font_semi_bold]}> 186 <Trans>Add to starter packs</Trans> 187 </Text> 188 <Button 189 label={_(msg`Close`)} 190 onPress={onClose} 191 variant="ghost" 192 color="secondary" 193 size="small" 194 shape={enableSquareButtons ? 'square' : 'round'} 195 style={{margin: -8}}> 196 <ButtonIcon icon={XIcon} /> 197 </Button> 198 </View> 199 {membershipItems.length > 0 && ( 200 <> 201 <View 202 style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}> 203 <Text style={[a.text_md, a.font_semi_bold]}> 204 <Trans>New starter pack</Trans> 205 </Text> 206 <Button 207 label={_(msg`Create starter pack`)} 208 color="secondary_inverted" 209 size="small" 210 onPress={onStartWizard}> 211 <ButtonText> 212 <Trans comment="Text on button to create a new starter pack"> 213 Create 214 </Trans> 215 </ButtonText> 216 <ButtonIcon icon={PlusIcon} /> 217 </Button> 218 </View> 219 <Divider /> 220 </> 221 )} 222 </> 223 ) 224 225 return ( 226 <Dialog.InnerFlatList 227 data={isLoading ? [{}] : membershipItems} 228 renderItem={ 229 isLoading 230 ? () => ( 231 <View style={[a.align_center, a.py_2xl]}> 232 <Loader size="xl" /> 233 </View> 234 ) 235 : renderItem 236 } 237 keyExtractor={ 238 isLoading 239 ? () => 'starter_pack_dialog_loader' 240 : (item: StarterPackWithMembership) => item.starterPack.uri 241 } 242 onEndReached={onEndReached} 243 onEndReachedThreshold={0.1} 244 ListHeaderComponent={listHeader} 245 ListEmptyComponent={<Empty onStartWizard={onStartWizard} />} 246 style={platform({ 247 web: [a.px_2xl, {minHeight: 500}], 248 native: [a.px_2xl, a.pt_lg], 249 })} 250 /> 251 ) 252} 253 254function StarterPackItem({ 255 starterPackWithMembership, 256 targetDid, 257 subject, 258}: { 259 starterPackWithMembership: StarterPackWithMembership 260 targetDid: string 261 subject?: bsky.profile.AnyProfileView 262}) { 263 const t = useTheme() 264 const ax = useAnalytics() 265 const {_} = useLingui() 266 267 const starterPack = starterPackWithMembership.starterPack 268 const isInPack = !!starterPackWithMembership.listItem 269 270 const {mutate: addMembership, isPending: isPendingAdd} = 271 useListMembershipAddMutation({ 272 subject, 273 onSuccess: () => { 274 Toast.show(_(msg`Added to starter pack`)) 275 }, 276 onError: err => { 277 if (!isNetworkError(err)) { 278 logger.error('Failed to add to starter pack', {safeMessage: err}) 279 } 280 Toast.show(_(msg`Failed to add to starter pack`), {type: 'error'}) 281 }, 282 }) 283 284 const {mutate: removeMembership, isPending: isPendingRemove} = 285 useListMembershipRemoveMutation({ 286 onSuccess: () => { 287 Toast.show(_(msg`Removed from starter pack`)) 288 }, 289 onError: err => { 290 if (!isNetworkError(err)) { 291 logger.error('Failed to remove from starter pack', {safeMessage: err}) 292 } 293 Toast.show(_(msg`Failed to remove from starter pack`), {type: 'error'}) 294 }, 295 }) 296 297 const isPending = isPendingAdd || isPendingRemove 298 299 const handleToggleMembership = () => { 300 if (!starterPack.list?.uri || isPending) return 301 302 const listUri = starterPack.list.uri 303 const starterPackUri = starterPack.uri 304 305 if (!isInPack) { 306 addMembership({ 307 listUri: listUri, 308 actorDid: targetDid, 309 }) 310 ax.metric('starterPack:addUser', {starterPack: starterPackUri}) 311 } else { 312 if (!starterPackWithMembership.listItem?.uri) { 313 console.error('Cannot remove: missing membership URI') 314 return 315 } 316 removeMembership({ 317 listUri: listUri, 318 actorDid: targetDid, 319 membershipUri: starterPackWithMembership.listItem.uri, 320 }) 321 ax.metric('starterPack:removeUser', {starterPack: starterPackUri}) 322 } 323 } 324 325 const {record} = starterPack 326 327 if ( 328 !bsky.dangerousIsType<AppBskyGraphStarterpack.Record>( 329 record, 330 AppBskyGraphStarterpack.isRecord, 331 ) 332 ) { 333 return null 334 } 335 336 return ( 337 <View style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}> 338 <View> 339 <Text emoji style={[a.text_md, a.font_semi_bold]} numberOfLines={1}> 340 {record.name} 341 </Text> 342 343 <View style={[a.flex_row, a.align_center, a.mt_xs]}> 344 {starterPack.listItemsSample && 345 starterPack.listItemsSample.length > 0 && ( 346 <> 347 <AvatarStack 348 size={24} 349 profiles={starterPack.listItemsSample 350 ?.slice(0, 4) 351 .map(p => p.subject)} 352 /> 353 354 {starterPack.list?.listItemCount && 355 starterPack.list.listItemCount > 4 && ( 356 <Text 357 style={[ 358 a.text_sm, 359 t.atoms.text_contrast_medium, 360 a.ml_xs, 361 ]}> 362 <Trans> 363 <Plural 364 value={starterPack.list.listItemCount - 4} 365 other="+# more" 366 /> 367 </Trans> 368 </Text> 369 )} 370 </> 371 )} 372 </View> 373 </View> 374 375 <Button 376 label={isInPack ? _(msg`Remove`) : _(msg`Add`)} 377 color={isInPack ? 'secondary' : 'primary_subtle'} 378 size="tiny" 379 disabled={isPending} 380 onPress={handleToggleMembership}> 381 {isPending && <ButtonIcon icon={Loader} />} 382 <ButtonText> 383 {isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>} 384 </ButtonText> 385 </Button> 386 </View> 387 ) 388}