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

Configure Feed

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

at 3a9526d55eccacaf65fcbc885744d8ef4e50cf6a 358 lines 12 kB view raw
1import {memo, useCallback, useMemo, useState} from 'react' 2import {View} from 'react-native' 3import { 4 type AppBskyActorDefs, 5 type AppBskyLabelerDefs, 6 moderateProfile, 7 type ModerationOpts, 8 type RichText as RichTextAPI, 9} from '@atproto/api' 10import {msg, Plural, plural, Trans} from '@lingui/macro' 11import {useLingui} from '@lingui/react' 12 13// eslint-disable-next-line @typescript-eslint/no-unused-vars 14import {MAX_LABELERS} from '#/lib/constants' 15import {useHaptics} from '#/lib/haptics' 16import {isAppLabeler} from '#/lib/moderation' 17import {useProfileShadow} from '#/state/cache/profile-shadow' 18import {type Shadow} from '#/state/cache/types' 19import {useLabelerSubscriptionMutation} from '#/state/queries/labeler' 20import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' 21import {usePreferencesQuery} from '#/state/queries/preferences' 22import {useRequireAuth, useSession} from '#/state/session' 23import {ProfileMenu} from '#/view/com/profile/ProfileMenu' 24import {atoms as a, tokens, useTheme} from '#/alf' 25import {Button, ButtonText} from '#/components/Button' 26import {type DialogOuterProps, useDialogControl} from '#/components/Dialog' 27import { 28 Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, 29 Heart2_Stroke2_Corner0_Rounded as Heart, 30} from '#/components/icons/Heart2' 31import {Link} from '#/components/Link' 32import * as Prompt from '#/components/Prompt' 33import {RichText} from '#/components/RichText' 34import * as Toast from '#/components/Toast' 35import {Text} from '#/components/Typography' 36import {useAnalytics} from '#/analytics' 37import {IS_IOS} from '#/env' 38import {ProfileHeaderDisplayName} from './DisplayName' 39import {EditProfileDialog} from './EditProfileDialog' 40import {ProfileHeaderHandle} from './Handle' 41import {ProfileHeaderMetrics} from './Metrics' 42import {ProfileHeaderShell} from './Shell' 43 44interface Props { 45 profile: AppBskyActorDefs.ProfileViewDetailed 46 labeler: AppBskyLabelerDefs.LabelerViewDetailed 47 descriptionRT: RichTextAPI | null 48 moderationOpts: ModerationOpts 49 hideBackButton?: boolean 50 isPlaceholderProfile?: boolean 51} 52 53let ProfileHeaderLabeler = ({ 54 profile: profileUnshadowed, 55 labeler, 56 descriptionRT, 57 moderationOpts, 58 hideBackButton = false, 59 isPlaceholderProfile, 60}: Props): React.ReactNode => { 61 const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> = 62 useProfileShadow(profileUnshadowed) 63 const t = useTheme() 64 const ax = useAnalytics() 65 const {_} = useLingui() 66 const {currentAccount, hasSession} = useSession() 67 const playHaptic = useHaptics() 68 const isSelf = currentAccount?.did === profile.did 69 70 const moderation = useMemo( 71 () => moderateProfile(profile, moderationOpts), 72 [profile, moderationOpts], 73 ) 74 const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation() 75 const {mutateAsync: unlikeMod, isPending: isUnlikePending} = 76 useUnlikeMutation() 77 const [likeUri, setLikeUri] = useState(labeler.viewer?.like || '') 78 const [likeCount, setLikeCount] = useState(labeler.likeCount || 0) 79 80 const onToggleLiked = useCallback(async () => { 81 if (!labeler) { 82 return 83 } 84 try { 85 playHaptic() 86 87 if (likeUri) { 88 await unlikeMod({uri: likeUri}) 89 setLikeCount(c => c - 1) 90 setLikeUri('') 91 } else { 92 const res = await likeMod({uri: labeler.uri, cid: labeler.cid}) 93 setLikeCount(c => c + 1) 94 setLikeUri(res.uri) 95 } 96 } catch (e: any) { 97 Toast.show( 98 _( 99 msg`There was an issue contacting the server, please check your internet connection and try again.`, 100 ), 101 {type: 'error'}, 102 ) 103 ax.logger.error(`Failed to toggle labeler like`, {message: e.message}) 104 } 105 }, [ax, labeler, playHaptic, likeUri, unlikeMod, likeMod, _]) 106 107 return ( 108 <ProfileHeaderShell 109 profile={profile} 110 moderation={moderation} 111 hideBackButton={hideBackButton} 112 isPlaceholderProfile={isPlaceholderProfile}> 113 <View 114 style={[a.px_lg, a.pt_md, a.pb_sm]} 115 pointerEvents={IS_IOS ? 'auto' : 'box-none'}> 116 <View 117 style={[a.flex_row, a.justify_end, a.align_center, a.gap_xs, a.pb_lg]} 118 pointerEvents={IS_IOS ? 'auto' : 'box-none'}> 119 <HeaderLabelerButtons profile={profile} /> 120 </View> 121 <View style={[a.flex_col, a.gap_2xs, a.pt_2xs, a.pb_md]}> 122 <ProfileHeaderDisplayName profile={profile} moderation={moderation} /> 123 <ProfileHeaderHandle profile={profile} /> 124 </View> 125 {!isPlaceholderProfile && ( 126 <> 127 {isSelf && <ProfileHeaderMetrics profile={profile} />} 128 {descriptionRT && !moderation.ui('profileView').blur ? ( 129 <View pointerEvents="auto"> 130 <RichText 131 testID="profileHeaderDescription" 132 style={[a.text_md]} 133 numberOfLines={15} 134 value={descriptionRT} 135 enableTags 136 authorHandle={profile.handle} 137 /> 138 </View> 139 ) : undefined} 140 {!isAppLabeler(profile.did) && ( 141 <View style={[a.flex_row, a.gap_xs, a.align_center, a.pt_lg]}> 142 <Button 143 testID="toggleLikeBtn" 144 size="small" 145 color="secondary" 146 shape="round" 147 label={_(msg`Like this labeler`)} 148 disabled={!hasSession || isLikePending || isUnlikePending} 149 onPress={onToggleLiked}> 150 {likeUri ? ( 151 <HeartFilled fill={t.palette.negative_400} /> 152 ) : ( 153 <Heart fill={t.atoms.text_contrast_medium.color} /> 154 )} 155 </Button> 156 157 {typeof likeCount === 'number' && ( 158 <Link 159 to={{ 160 screen: 'ProfileLabelerLikedBy', 161 params: { 162 name: labeler.creator.handle || labeler.creator.did, 163 }, 164 }} 165 size="tiny" 166 label={_( 167 msg`Liked by ${plural(likeCount, { 168 one: '# user', 169 other: '# users', 170 })}`, 171 )}> 172 {({hovered, focused, pressed}) => ( 173 <Text 174 style={[ 175 a.font_semi_bold, 176 a.text_sm, 177 t.atoms.text_contrast_medium, 178 (hovered || focused || pressed) && 179 t.atoms.text_contrast_high, 180 ]}> 181 <Trans> 182 Liked by{' '} 183 <Plural 184 value={likeCount} 185 one="# user" 186 other="# users" 187 /> 188 </Trans> 189 </Text> 190 )} 191 </Link> 192 )} 193 </View> 194 )} 195 </> 196 )} 197 </View> 198 </ProfileHeaderShell> 199 ) 200} 201ProfileHeaderLabeler = memo(ProfileHeaderLabeler) 202export {ProfileHeaderLabeler} 203 204/** 205 * Keep this in sync with the value of {@link MAX_LABELERS} 206 */ 207function CantSubscribePrompt({ 208 control, 209}: { 210 control: DialogOuterProps['control'] 211}) { 212 const {_} = useLingui() 213 return ( 214 <Prompt.Outer control={control}> 215 <Prompt.Content> 216 <Prompt.TitleText>Unable to subscribe</Prompt.TitleText> 217 <Prompt.DescriptionText> 218 <Trans> 219 We're sorry! You can only subscribe to twenty labelers, and you've 220 reached your limit of twenty. 221 </Trans> 222 </Prompt.DescriptionText> 223 </Prompt.Content> 224 <Prompt.Actions> 225 <Prompt.Action onPress={() => control.close()} cta={_(msg`OK`)} /> 226 </Prompt.Actions> 227 </Prompt.Outer> 228 ) 229} 230 231export function HeaderLabelerButtons({ 232 profile, 233 minimal = false, 234}: { 235 profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> 236 /** disable the subscribe button */ 237 minimal?: boolean 238}) { 239 const t = useTheme() 240 const ax = useAnalytics() 241 const {_} = useLingui() 242 const {currentAccount} = useSession() 243 const requireAuth = useRequireAuth() 244 const playHaptic = useHaptics() 245 const editProfileControl = useDialogControl() 246 const {data: preferences} = usePreferencesQuery() 247 const { 248 mutateAsync: toggleSubscription, 249 variables, 250 reset, 251 } = useLabelerSubscriptionMutation() 252 const isSubscribed = 253 variables?.subscribe ?? 254 preferences?.moderationPrefs.labelers.find(l => l.did === profile.did) 255 256 const cantSubscribePrompt = Prompt.usePromptControl() 257 258 const isMe = currentAccount?.did === profile.did 259 260 const onPressSubscribe = () => 261 requireAuth(async (): Promise<void> => { 262 playHaptic() 263 const subscribe = !isSubscribed 264 265 try { 266 await toggleSubscription({ 267 did: profile.did, 268 subscribe, 269 }) 270 271 ax.metric( 272 subscribe 273 ? 'moderation:subscribedToLabeler' 274 : 'moderation:unsubscribedFromLabeler', 275 {}, 276 ) 277 } catch (e: any) { 278 reset() 279 if (e.message === 'MAX_LABELERS') { 280 cantSubscribePrompt.open() 281 return 282 } 283 ax.logger.error(`Failed to subscribe to labeler`, {message: e.message}) 284 } 285 }) 286 return ( 287 <> 288 {isMe ? ( 289 <> 290 <Button 291 testID="profileHeaderEditProfileButton" 292 size="small" 293 color="secondary" 294 onPress={editProfileControl.open} 295 label={_(msg`Edit profile`)} 296 style={a.rounded_full}> 297 <ButtonText> 298 <Trans>Edit Profile</Trans> 299 </ButtonText> 300 </Button> 301 <EditProfileDialog profile={profile} control={editProfileControl} /> 302 </> 303 ) : !isAppLabeler(profile.did) && !minimal ? ( 304 // hidden in the minimal header, because it's not shadowed so the two buttons 305 // can get out of sync. if you want to reenable, you'll need to add shadowing 306 // to the subscribed state -sfn 307 <Button 308 testID="toggleSubscribeBtn" 309 label={ 310 isSubscribed 311 ? _(msg`Unsubscribe from this labeler`) 312 : _(msg`Subscribe to this labeler`) 313 } 314 onPress={onPressSubscribe}> 315 {state => ( 316 <View 317 style={[ 318 { 319 paddingVertical: 9, 320 paddingHorizontal: 12, 321 borderRadius: 6, 322 gap: 6, 323 backgroundColor: isSubscribed 324 ? state.hovered || state.pressed 325 ? t.palette.contrast_50 326 : t.palette.contrast_25 327 : state.hovered || state.pressed 328 ? tokens.color.temp_purple_dark 329 : tokens.color.temp_purple, 330 }, 331 ]}> 332 <Text 333 style={[ 334 { 335 color: isSubscribed 336 ? t.palette.contrast_700 337 : t.palette.white, 338 }, 339 a.font_semi_bold, 340 a.text_center, 341 a.leading_tight, 342 ]}> 343 {isSubscribed ? ( 344 <Trans>Unsubscribe</Trans> 345 ) : ( 346 <Trans>Subscribe to Labeler</Trans> 347 )} 348 </Text> 349 </View> 350 )} 351 </Button> 352 ) : null} 353 <ProfileMenu profile={profile} /> 354 355 <CantSubscribePrompt control={cantSubscribePrompt} /> 356 </> 357 ) 358}