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