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

Configure Feed

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

at post-text-option 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 {logger} from '#/logger' 18import {isIOS} from '#/platform/detection' 19import {useProfileShadow} from '#/state/cache/profile-shadow' 20import {type Shadow} from '#/state/cache/types' 21import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 22import {useLabelerSubscriptionMutation} from '#/state/queries/labeler' 23import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' 24import {usePreferencesQuery} from '#/state/queries/preferences' 25import {useRequireAuth, useSession} from '#/state/session' 26import {ProfileMenu} from '#/view/com/profile/ProfileMenu' 27import {atoms as a, tokens, useTheme} from '#/alf' 28import {Button, ButtonText} from '#/components/Button' 29import {type DialogOuterProps, useDialogControl} from '#/components/Dialog' 30import { 31 Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, 32 Heart2_Stroke2_Corner0_Rounded as Heart, 33} from '#/components/icons/Heart2' 34import {Link} from '#/components/Link' 35import * as Prompt from '#/components/Prompt' 36import {RichText} from '#/components/RichText' 37import * as Toast from '#/components/Toast' 38import {Text} from '#/components/Typography' 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 {_} = useLingui() 66 const {currentAccount, hasSession} = useSession() 67 const playHaptic = useHaptics() 68 const isSelf = currentAccount?.did === profile.did 69 70 const enableSquareButtons = useEnableSquareButtons() 71 72 const moderation = useMemo( 73 () => moderateProfile(profile, moderationOpts), 74 [profile, moderationOpts], 75 ) 76 const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation() 77 const {mutateAsync: unlikeMod, isPending: isUnlikePending} = 78 useUnlikeMutation() 79 const [likeUri, setLikeUri] = useState(labeler.viewer?.like || '') 80 const [likeCount, setLikeCount] = useState(labeler.likeCount || 0) 81 82 const onToggleLiked = useCallback(async () => { 83 if (!labeler) { 84 return 85 } 86 try { 87 playHaptic() 88 89 if (likeUri) { 90 await unlikeMod({uri: likeUri}) 91 setLikeCount(c => c - 1) 92 setLikeUri('') 93 } else { 94 const res = await likeMod({uri: labeler.uri, cid: labeler.cid}) 95 setLikeCount(c => c + 1) 96 setLikeUri(res.uri) 97 } 98 } catch (e: any) { 99 Toast.show( 100 _( 101 msg`There was an issue contacting the server, please check your internet connection and try again.`, 102 ), 103 {type: 'error'}, 104 ) 105 logger.error(`Failed to toggle labeler like`, {message: e.message}) 106 } 107 }, [labeler, playHaptic, likeUri, unlikeMod, likeMod, _]) 108 109 return ( 110 <ProfileHeaderShell 111 profile={profile} 112 moderation={moderation} 113 hideBackButton={hideBackButton} 114 isPlaceholderProfile={isPlaceholderProfile}> 115 <View 116 style={[a.px_lg, a.pt_md, a.pb_sm]} 117 pointerEvents={isIOS ? 'auto' : 'box-none'}> 118 <View 119 style={[a.flex_row, a.justify_end, a.align_center, a.gap_xs, a.pb_lg]} 120 pointerEvents={isIOS ? 'auto' : 'box-none'}> 121 <HeaderLabelerButtons profile={profile} /> 122 </View> 123 <View style={[a.flex_col, a.gap_2xs, a.pt_2xs, a.pb_md]}> 124 <ProfileHeaderDisplayName profile={profile} moderation={moderation} /> 125 <ProfileHeaderHandle profile={profile} /> 126 </View> 127 {!isPlaceholderProfile && ( 128 <> 129 {isSelf && <ProfileHeaderMetrics profile={profile} />} 130 {descriptionRT && !moderation.ui('profileView').blur ? ( 131 <View pointerEvents="auto"> 132 <RichText 133 testID="profileHeaderDescription" 134 style={[a.text_md]} 135 numberOfLines={15} 136 value={descriptionRT} 137 enableTags 138 authorHandle={profile.handle} 139 /> 140 </View> 141 ) : undefined} 142 {!isAppLabeler(profile.did) && ( 143 <View style={[a.flex_row, a.gap_xs, a.align_center, a.pt_lg]}> 144 <Button 145 testID="toggleLikeBtn" 146 size="small" 147 color="secondary" 148 shape={enableSquareButtons ? 'square' : 'round'} 149 label={_(msg`Like this labeler`)} 150 disabled={!hasSession || isLikePending || isUnlikePending} 151 onPress={onToggleLiked}> 152 {likeUri ? ( 153 <HeartFilled fill={t.palette.negative_400} /> 154 ) : ( 155 <Heart fill={t.atoms.text_contrast_medium.color} /> 156 )} 157 </Button> 158 159 {typeof likeCount === 'number' && ( 160 <Link 161 to={{ 162 screen: 'ProfileLabelerLikedBy', 163 params: { 164 name: labeler.creator.handle || labeler.creator.did, 165 }, 166 }} 167 size="tiny" 168 label={_( 169 msg`Liked by ${plural(likeCount, { 170 one: '# user', 171 other: '# users', 172 })}`, 173 )}> 174 {({hovered, focused, pressed}) => ( 175 <Text 176 style={[ 177 a.font_semi_bold, 178 a.text_sm, 179 t.atoms.text_contrast_medium, 180 (hovered || focused || pressed) && 181 t.atoms.text_contrast_high, 182 ]}> 183 <Trans> 184 Liked by{' '} 185 <Plural 186 value={likeCount} 187 one="# user" 188 other="# users" 189 /> 190 </Trans> 191 </Text> 192 )} 193 </Link> 194 )} 195 </View> 196 )} 197 </> 198 )} 199 </View> 200 </ProfileHeaderShell> 201 ) 202} 203ProfileHeaderLabeler = memo(ProfileHeaderLabeler) 204export {ProfileHeaderLabeler} 205 206/** 207 * Keep this in sync with the value of {@link MAX_LABELERS} 208 */ 209function CantSubscribePrompt({ 210 control, 211}: { 212 control: DialogOuterProps['control'] 213}) { 214 const {_} = useLingui() 215 return ( 216 <Prompt.Outer control={control}> 217 <Prompt.TitleText>Unable to subscribe</Prompt.TitleText> 218 <Prompt.DescriptionText> 219 <Trans> 220 We're sorry! You can only subscribe to twenty labelers, and you've 221 reached your limit of twenty. 222 </Trans> 223 </Prompt.DescriptionText> 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 {_} = useLingui() 240 const t = useTheme() 241 const {currentAccount} = useSession() 242 const requireAuth = useRequireAuth() 243 const playHaptic = useHaptics() 244 const editProfileControl = useDialogControl() 245 const {data: preferences} = usePreferencesQuery() 246 const { 247 mutateAsync: toggleSubscription, 248 variables, 249 reset, 250 } = useLabelerSubscriptionMutation() 251 const isSubscribed = 252 variables?.subscribe ?? 253 preferences?.moderationPrefs.labelers.find(l => l.did === profile.did) 254 255 const cantSubscribePrompt = Prompt.usePromptControl() 256 257 const isMe = currentAccount?.did === profile.did 258 259 const onPressSubscribe = () => 260 requireAuth(async (): Promise<void> => { 261 playHaptic() 262 const subscribe = !isSubscribed 263 264 try { 265 await toggleSubscription({ 266 did: profile.did, 267 subscribe, 268 }) 269 270 logger.metric( 271 subscribe 272 ? 'moderation:subscribedToLabeler' 273 : 'moderation:unsubscribedFromLabeler', 274 {}, 275 {statsig: true}, 276 ) 277 } catch (e: any) { 278 reset() 279 if (e.message === 'MAX_LABELERS') { 280 cantSubscribePrompt.open() 281 return 282 } 283 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}