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

Configure Feed

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

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