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