Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at main 677 lines 22 kB view raw
1import {memo, useCallback, useMemo, useState} from 'react' 2import { 3 Image as RNImage, 4 Pressable, 5 type StyleProp, 6 StyleSheet, 7 View, 8 type ViewStyle, 9} from 'react-native' 10import Svg, {Circle, Path, Rect} from 'react-native-svg' 11import {Image as ExpoImage} from 'expo-image' 12import {type ModerationUI} from '@atproto/api' 13import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 14import {msg} from '@lingui/core/macro' 15import {useLingui} from '@lingui/react' 16import {Trans} from '@lingui/react/macro' 17import {useQueryClient} from '@tanstack/react-query' 18 19import {useHaptics} from '#/lib/haptics' 20import { 21 useCameraPermission, 22 usePhotoLibraryPermission, 23} from '#/lib/hooks/usePermissions' 24import {compressIfNeeded} from '#/lib/media/manip' 25import {openCamera, openCropper, openPicker} from '#/lib/media/picker' 26import {type PickerImage} from '#/lib/media/picker.shared' 27import {makeProfileLink} from '#/lib/routes/links' 28import {sanitizeDisplayName} from '#/lib/strings/display-names' 29import {isCancelledError} from '#/lib/strings/errors' 30import {sanitizeHandle} from '#/lib/strings/handles' 31import {logger} from '#/logger' 32import { 33 type ComposerImage, 34 compressImage, 35 createComposerImage, 36} from '#/state/gallery' 37import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars' 38import {useHighQualityImages} from '#/state/preferences/high-quality-images' 39import { 40 applyImageTransforms, 41 useImageCdnHost, 42} from '#/state/preferences/image-cdn-host' 43import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' 44import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog' 45import {atoms as a, tokens, useTheme} from '#/alf' 46import {Button} from '#/components/Button' 47import {useDialogControl} from '#/components/Dialog' 48import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' 49import { 50 Camera_Filled_Stroke2_Corner0_Rounded as CameraFilledIcon, 51 Camera_Stroke2_Corner0_Rounded as CameraIcon, 52} from '#/components/icons/Camera' 53import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive' 54import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 55import {Link} from '#/components/Link' 56import {MediaInsetBorder} from '#/components/MediaInsetBorder' 57import * as Menu from '#/components/Menu' 58import {ProfileHoverCard} from '#/components/ProfileHoverCard' 59import {useAnalytics} from '#/analytics' 60import {IS_ANDROID, IS_NATIVE, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env' 61import {useActorStatus} from '#/features/liveNow' 62import {LiveIndicator} from '#/features/liveNow/components/LiveIndicator' 63import {LiveStatusDialog} from '#/features/liveNow/components/LiveStatusDialog' 64import type * as bsky from '#/types/bsky' 65 66export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler' 67 68interface BaseUserAvatarProps { 69 type?: UserAvatarType 70 shape?: 'circle' | 'square' 71 size: number 72 avatar?: string | null 73 live?: boolean 74 hideLiveBadge?: boolean 75} 76 77interface UserAvatarProps extends BaseUserAvatarProps { 78 type: UserAvatarType 79 moderation?: ModerationUI 80 usePlainRNImage?: boolean 81 noBorder?: boolean 82 onLoad?: () => void 83 style?: StyleProp<ViewStyle> 84} 85 86interface EditableUserAvatarProps extends BaseUserAvatarProps { 87 onSelectNewAvatar: (img: PickerImage | null) => void 88} 89 90interface PreviewableUserAvatarProps extends BaseUserAvatarProps { 91 moderation?: ModerationUI 92 profile: bsky.profile.AnyProfileView 93 disableHoverCard?: boolean 94 disableNavigation?: boolean 95 onBeforePress?: () => void 96} 97 98const BLUR_AMOUNT = IS_WEB ? 5 : 100 99 100let DefaultAvatar = ({ 101 type, 102 shape: overrideShape, 103 size, 104}: { 105 type: UserAvatarType 106 shape?: 'square' | 'circle' 107 size: number 108}): React.ReactNode => { 109 const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square') 110 const t = useTheme() 111 112 const enableSquareAvatars = useEnableSquareAvatars() 113 114 const aviStyle = useMemo(() => { 115 if (finalShape === 'square') { 116 return {borderRadius: size > 32 ? 8 : 3, overflow: 'hidden'} as const 117 } 118 }, [finalShape, size]) 119 120 if (type === 'algo') { 121 // TODO: shape=circle 122 // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. 123 return ( 124 <Svg 125 testID="userAvatarFallback" 126 width={size} 127 height={size} 128 viewBox="0 0 32 32" 129 fill="none" 130 stroke="none" 131 style={aviStyle}> 132 <Rect width="32" height="32" rx="4" fill={t.palette.primary_500} /> 133 <Path 134 d="M13.5 7.25C13.5 6.55859 14.0586 6 14.75 6C20.9648 6 26 11.0352 26 17.25C26 17.9414 25.4414 18.5 24.75 18.5C24.0586 18.5 23.5 17.9414 23.5 17.25C23.5 12.418 19.582 8.5 14.75 8.5C14.0586 8.5 13.5 7.94141 13.5 7.25ZM8.36719 14.6172L12.4336 18.6836L13.543 17.5742C13.5156 17.4727 13.5 17.3633 13.5 17.25C13.5 16.5586 14.0586 16 14.75 16C15.4414 16 16 16.5586 16 17.25C16 17.9414 15.4414 18.5 14.75 18.5C14.6367 18.5 14.5312 18.4844 14.4258 18.457L13.3164 19.5664L17.3828 23.6328C17.9492 24.1992 17.8438 25.1484 17.0977 25.4414C16.1758 25.8008 15.1758 26 14.125 26C9.63672 26 6 22.3633 6 17.875C6 16.8242 6.19922 15.8242 6.5625 14.9023C6.85547 14.1602 7.80469 14.0508 8.37109 14.6172H8.36719ZM14.75 9.75C18.8906 9.75 22.25 13.1094 22.25 17.25C22.25 17.9414 21.6914 18.5 21 18.5C20.3086 18.5 19.75 17.9414 19.75 17.25C19.75 14.4883 17.5117 12.25 14.75 12.25C14.0586 12.25 13.5 11.6914 13.5 11C13.5 10.3086 14.0586 9.75 14.75 9.75Z" 135 fill="white" 136 /> 137 </Svg> 138 ) 139 } 140 if (type === 'list') { 141 // TODO: shape=circle 142 // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. 143 return ( 144 <Svg 145 testID="userAvatarFallback" 146 width={size} 147 height={size} 148 viewBox="0 0 32 32" 149 fill="none" 150 stroke="none" 151 style={aviStyle}> 152 <Path 153 d="M28 0H4C1.79086 0 0 1.79086 0 4V28C0 30.2091 1.79086 32 4 32H28C30.2091 32 32 30.2091 32 28V4C32 1.79086 30.2091 0 28 0Z" 154 fill={t.palette.primary_500} 155 /> 156 <Path 157 d="M22.1529 22.3542C23.4522 22.4603 24.7593 22.293 25.9899 21.8629C26.0369 21.2838 25.919 20.7032 25.6497 20.1884C25.3805 19.6735 24.9711 19.2454 24.4687 18.9535C23.9663 18.6617 23.3916 18.518 22.8109 18.5392C22.2303 18.5603 21.6676 18.7454 21.1878 19.0731M22.1529 22.3542C22.1489 21.1917 21.8142 20.0534 21.1878 19.0741ZM10.8111 19.0741C10.3313 18.7468 9.7687 18.5619 9.18826 18.5409C8.60781 18.5199 8.03327 18.6636 7.53107 18.9554C7.02888 19.2472 6.61953 19.6752 6.35036 20.1899C6.08119 20.7046 5.96319 21.285 6.01001 21.8639C7.23969 22.2964 8.5461 22.4632 9.84497 22.3531M10.8111 19.0741C10.1851 20.0535 9.84865 21.1908 9.84497 22.3531ZM19.0759 10.077C19.0759 10.8931 18.7518 11.6757 18.1747 12.2527C17.5977 12.8298 16.815 13.154 15.9989 13.154C15.1829 13.154 14.4002 12.8298 13.8232 12.2527C13.2461 11.6757 12.922 10.8931 12.922 10.077C12.922 9.26092 13.2461 8.47828 13.8232 7.90123C14.4002 7.32418 15.1829 7 15.9989 7C16.815 7 17.5977 7.32418 18.1747 7.90123C18.7518 8.47828 19.0759 9.26092 19.0759 10.077ZM25.2299 13.154C25.2299 13.457 25.1702 13.7571 25.0542 14.0371C24.9383 14.3171 24.7683 14.5715 24.554 14.7858C24.3397 15.0001 24.0853 15.1701 23.8053 15.2861C23.5253 15.402 23.2252 15.4617 22.9222 15.4617C22.6191 15.4617 22.319 15.402 22.039 15.2861C21.759 15.1701 21.5046 15.0001 21.2903 14.7858C21.0761 14.5715 20.9061 14.3171 20.7901 14.0371C20.6741 13.7571 20.6144 13.457 20.6144 13.154C20.6144 12.5419 20.8576 11.9549 21.2903 11.5222C21.7231 11.0894 22.3101 10.8462 22.9222 10.8462C23.5342 10.8462 24.1212 11.0894 24.554 11.5222C24.9868 11.9549 25.2299 12.5419 25.2299 13.154ZM11.3835 13.154C11.3835 13.457 11.3238 13.7571 11.2078 14.0371C11.0918 14.3171 10.9218 14.5715 10.7075 14.7858C10.4932 15.0001 10.2388 15.1701 9.95886 15.2861C9.67887 15.402 9.37878 15.4617 9.07572 15.4617C8.77266 15.4617 8.47257 15.402 8.19259 15.2861C7.9126 15.1701 7.6582 15.0001 7.4439 14.7858C7.22961 14.5715 7.05962 14.3171 6.94365 14.0371C6.82767 13.7571 6.76798 13.457 6.76798 13.154C6.76798 12.5419 7.01112 11.9549 7.4439 11.5222C7.87669 11.0894 8.46367 10.8462 9.07572 10.8462C9.68777 10.8462 10.2748 11.0894 10.7075 11.5222C11.1403 11.9549 11.3835 12.5419 11.3835 13.154Z" 158 fill="white" 159 /> 160 <Path 161 d="M22 22C22 25.3137 19.3137 25.5 16 25.5C12.6863 25.5 10 25.3137 10 22C10 18.6863 12.6863 16 16 16C19.3137 16 22 18.6863 22 22Z" 162 fill="white" 163 /> 164 </Svg> 165 ) 166 } 167 if (type === 'labeler') { 168 return ( 169 <Svg 170 testID="userAvatarFallback" 171 width={size} 172 height={size} 173 viewBox="0 0 32 32" 174 fill="none" 175 stroke="none" 176 style={aviStyle}> 177 {finalShape === 'square' ? ( 178 <Rect 179 x="0" 180 y="0" 181 width="32" 182 height="32" 183 rx="3" 184 fill={tokens.color.temp_purple} 185 /> 186 ) : ( 187 <Circle cx="16" cy="16" r="16" fill={tokens.color.temp_purple} /> 188 )} 189 <Path 190 d="M24 9.75L16 7L8 9.75V15.9123C8 20.8848 12 23 16 25.1579C20 23 24 20.8848 24 15.9123V9.75Z" 191 stroke="white" 192 strokeWidth="2" 193 strokeLinecap="square" 194 strokeLinejoin="round" 195 /> 196 </Svg> 197 ) 198 } 199 return ( 200 <Svg 201 testID="userAvatarFallback" 202 width={size} 203 height={size} 204 viewBox="0 0 24 24" 205 fill="none" 206 stroke="none" 207 style={aviStyle}> 208 {enableSquareAvatars ? ( 209 <Rect 210 x="0" 211 y="0" 212 width="24" 213 height="24" 214 rx="3" 215 fill={t.palette.primary_500} 216 /> 217 ) : ( 218 <Circle cx="12" cy="12" r="12" fill={t.palette.primary_500} /> 219 )} 220 <Circle cx="12" cy="9.5" r="3.5" fill="#fff" /> 221 <Path 222 strokeLinecap="round" 223 strokeLinejoin="round" 224 fill="#fff" 225 d="M 12.058 22.784 C 9.422 22.784 7.007 21.836 5.137 20.262 C 5.667 17.988 8.534 16.25 11.99 16.25 C 15.494 16.25 18.391 18.036 18.864 20.357 C 17.01 21.874 14.64 22.784 12.058 22.784 Z" 226 /> 227 </Svg> 228 ) 229} 230DefaultAvatar = memo(DefaultAvatar) 231export {DefaultAvatar} 232 233let UserAvatar = ({ 234 type = 'user', 235 shape: overrideShape, 236 size, 237 avatar, 238 moderation, 239 usePlainRNImage = false, 240 onLoad, 241 style, 242 live, 243 hideLiveBadge, 244 noBorder, 245}: UserAvatarProps): React.ReactNode => { 246 const t = useTheme() 247 248 const enableSquareAvatars = useEnableSquareAvatars() 249 const avishapeforce = enableSquareAvatars ? 'square' : 'circle' 250 251 const finalShape = 252 overrideShape ?? (type === 'user' ? avishapeforce : 'square') 253 const highQualityImages = useHighQualityImages() 254 const imageCdnHost = useImageCdnHost() 255 256 const aviStyle = useMemo(() => { 257 let borderRadius 258 259 if (finalShape === 'square') { 260 borderRadius = size > 32 ? 8 : 3 261 } else { 262 borderRadius = Math.floor(size / 2) 263 } 264 265 return { 266 width: size, 267 height: size, 268 borderRadius, 269 backgroundColor: t.palette.contrast_25, 270 } 271 }, [finalShape, size, t]) 272 273 const borderStyle = useMemo(() => { 274 return [ 275 {borderRadius: aviStyle.borderRadius}, 276 live && { 277 borderColor: t.palette.negative_500, 278 borderWidth: size > 16 ? 2 : 1, 279 opacity: 1, 280 }, 281 ] 282 }, [aviStyle.borderRadius, live, t, size]) 283 284 const alert = useMemo(() => { 285 if (!moderation?.alert) { 286 return null 287 } 288 return ( 289 <View 290 style={[ 291 a.absolute, 292 a.right_0, 293 a.bottom_0, 294 a.rounded_full, 295 {backgroundColor: t.palette.white}, 296 ]}> 297 <FontAwesomeIcon 298 icon="exclamation-circle" 299 style={{color: t.palette.negative_400}} 300 size={Math.floor(size / 3)} 301 /> 302 </View> 303 ) 304 }, [moderation?.alert, size, t]) 305 306 const containerStyle = useMemo(() => { 307 return [ 308 { 309 width: size, 310 height: size, 311 }, 312 style, 313 ] 314 }, [size, style]) 315 316 return avatar && 317 !((moderation?.blur && IS_ANDROID) /* android crashes with blur */) ? ( 318 <View style={containerStyle}> 319 {usePlainRNImage ? ( 320 <RNImage 321 accessibilityIgnoresInvertColors 322 testID="userAvatarImage" 323 style={aviStyle} 324 resizeMode="cover" 325 source={{ 326 uri: applyImageTransforms( 327 hackModifyThumbnailPath(avatar, size < 90), 328 {imageCdnHost, highQualityImages}, 329 ), 330 }} 331 blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} 332 onLoad={onLoad} 333 /> 334 ) : ( 335 <ExpoImage 336 testID="userAvatarImage" 337 style={aviStyle} 338 contentFit="cover" 339 source={{ 340 uri: applyImageTransforms( 341 hackModifyThumbnailPath(avatar, size < 90), 342 {imageCdnHost, highQualityImages}, 343 ), 344 }} 345 blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} 346 onLoad={onLoad} 347 /> 348 )} 349 {!noBorder && <MediaInsetBorder style={borderStyle} />} 350 {live && size > 16 && !hideLiveBadge && ( 351 <LiveIndicator size={size > 32 ? 'small' : 'tiny'} /> 352 )} 353 {alert} 354 </View> 355 ) : ( 356 <View style={containerStyle}> 357 <DefaultAvatar type={type} shape={finalShape} size={size} /> 358 {!noBorder && <MediaInsetBorder style={borderStyle} />} 359 {live && size > 16 && !hideLiveBadge && ( 360 <LiveIndicator size={size > 32 ? 'small' : 'tiny'} /> 361 )} 362 {alert} 363 </View> 364 ) 365} 366UserAvatar = memo(UserAvatar) 367export {UserAvatar} 368 369let EditableUserAvatar = ({ 370 type = 'user', 371 size, 372 avatar, 373 onSelectNewAvatar, 374}: EditableUserAvatarProps): React.ReactNode => { 375 const t = useTheme() 376 const {_} = useLingui() 377 const {requestCameraAccessIfNeeded} = useCameraPermission() 378 const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() 379 const [rawImage, setRawImage] = useState<ComposerImage | undefined>() 380 const editImageDialogControl = useDialogControl() 381 382 const sheetWrapper = useSheetWrapper() 383 const highQualityImages = useHighQualityImages() 384 const imageCdnHost = useImageCdnHost() 385 386 const enableSquareAvatars = useEnableSquareAvatars() 387 388 const circular = type !== 'algo' && type !== 'list' && !enableSquareAvatars 389 390 const aviStyle = useMemo(() => { 391 if (!circular) { 392 return { 393 width: size, 394 height: size, 395 borderRadius: size > 32 ? 8 : 3, 396 } 397 } 398 return { 399 width: size, 400 height: size, 401 borderRadius: Math.floor(size / 2), 402 } 403 }, [circular, size]) 404 405 const onOpenCamera = useCallback(async () => { 406 if (!(await requestCameraAccessIfNeeded())) { 407 return 408 } 409 410 onSelectNewAvatar( 411 await compressIfNeeded( 412 await openCamera({ 413 aspect: [1, 1], 414 }), 415 ), 416 ) 417 }, [onSelectNewAvatar, requestCameraAccessIfNeeded]) 418 419 const onOpenLibrary = useCallback(async () => { 420 if (!(await requestPhotoAccessIfNeeded())) { 421 return 422 } 423 424 const items = await sheetWrapper( 425 openPicker({ 426 aspect: [1, 1], 427 }), 428 ) 429 const item = items[0] 430 if (!item) { 431 return 432 } 433 434 try { 435 if (IS_NATIVE) { 436 onSelectNewAvatar( 437 await compressIfNeeded( 438 await openCropper({ 439 imageUri: item.path, 440 shape: circular ? 'circle' : 'rectangle', 441 aspectRatio: 1, 442 }), 443 ), 444 ) 445 } else { 446 setRawImage(await createComposerImage(item)) 447 editImageDialogControl.open() 448 } 449 } catch (e) { 450 // Don't log errors for cancelling selection to sentry on ios or android 451 if (!isCancelledError(e)) { 452 logger.error('Failed to crop avatar', {error: e}) 453 } 454 } 455 }, [ 456 onSelectNewAvatar, 457 requestPhotoAccessIfNeeded, 458 sheetWrapper, 459 editImageDialogControl, 460 circular, 461 ]) 462 463 const onRemoveAvatar = useCallback(() => { 464 onSelectNewAvatar(null) 465 }, [onSelectNewAvatar]) 466 467 const onChangeEditImage = useCallback( 468 async (image: ComposerImage) => { 469 const compressed = await compressImage(image) 470 onSelectNewAvatar(compressed) 471 }, 472 [onSelectNewAvatar], 473 ) 474 475 return ( 476 <> 477 <Menu.Root> 478 <Menu.Trigger label={_(msg`Edit avatar`)}> 479 {({props}) => ( 480 <Pressable {...props} testID="changeAvatarBtn"> 481 {avatar ? ( 482 <ExpoImage 483 testID="userAvatarImage" 484 style={aviStyle} 485 source={{ 486 uri: applyImageTransforms(avatar, { 487 imageCdnHost, 488 highQualityImages, 489 }), 490 }} 491 accessibilityRole="image" 492 /> 493 ) : ( 494 <DefaultAvatar type={type} size={size} /> 495 )} 496 <View 497 style={[ 498 styles.editButtonContainer, 499 t.atoms.bg_contrast_25, 500 a.border, 501 t.atoms.border_contrast_low, 502 ]}> 503 <CameraFilledIcon height={14} width={14} style={t.atoms.text} /> 504 </View> 505 </Pressable> 506 )} 507 </Menu.Trigger> 508 <Menu.Outer showCancel> 509 <Menu.Group> 510 {IS_NATIVE && ( 511 <Menu.Item 512 testID="changeAvatarCameraBtn" 513 label={_(msg`Upload from Camera`)} 514 onPress={onOpenCamera}> 515 <Menu.ItemText> 516 <Trans>Upload from Camera</Trans> 517 </Menu.ItemText> 518 <Menu.ItemIcon icon={CameraIcon} /> 519 </Menu.Item> 520 )} 521 522 <Menu.Item 523 testID="changeAvatarLibraryBtn" 524 label={_(msg`Upload from Library`)} 525 onPress={onOpenLibrary}> 526 <Menu.ItemText> 527 {IS_NATIVE ? ( 528 <Trans>Upload from Library</Trans> 529 ) : ( 530 <Trans>Upload from Files</Trans> 531 )} 532 </Menu.ItemText> 533 <Menu.ItemIcon icon={LibraryIcon} /> 534 </Menu.Item> 535 </Menu.Group> 536 {!!avatar && ( 537 <> 538 <Menu.Divider /> 539 <Menu.Group> 540 <Menu.Item 541 testID="changeAvatarRemoveBtn" 542 label={_(msg`Remove Avatar`)} 543 onPress={onRemoveAvatar}> 544 <Menu.ItemText> 545 <Trans>Remove Avatar</Trans> 546 </Menu.ItemText> 547 <Menu.ItemIcon icon={TrashIcon} /> 548 </Menu.Item> 549 </Menu.Group> 550 </> 551 )} 552 </Menu.Outer> 553 </Menu.Root> 554 555 <EditImageDialog 556 control={editImageDialogControl} 557 image={rawImage} 558 onChange={onChangeEditImage} 559 aspectRatio={1} 560 circularCrop={circular} 561 /> 562 </> 563 ) 564} 565EditableUserAvatar = memo(EditableUserAvatar) 566export {EditableUserAvatar} 567 568let PreviewableUserAvatar = ({ 569 moderation, 570 profile, 571 disableHoverCard, 572 disableNavigation, 573 onBeforePress, 574 live, 575 ...props 576}: PreviewableUserAvatarProps): React.ReactNode => { 577 const ax = useAnalytics() 578 const {_} = useLingui() 579 const queryClient = useQueryClient() 580 const status = useActorStatus(profile) 581 const liveControl = useDialogControl() 582 const playHaptic = useHaptics() 583 584 const onPress = useCallback(() => { 585 onBeforePress?.() 586 unstableCacheProfileView(queryClient, profile) 587 }, [profile, queryClient, onBeforePress]) 588 589 const onOpenLiveStatus = useCallback(() => { 590 playHaptic('Light') 591 ax.metric('live:card:open', {subject: profile.did, from: 'post'}) 592 liveControl.open() 593 }, [liveControl, playHaptic, profile.did]) 594 595 const avatarEl = ( 596 <UserAvatar 597 avatar={profile.avatar} 598 moderation={moderation} 599 type={profile.associated?.labeler ? 'labeler' : 'user'} 600 live={status.isActive || live} 601 {...props} 602 /> 603 ) 604 605 const linkStyle = 606 props.type !== 'algo' && props.type !== 'list' 607 ? a.rounded_full 608 : {borderRadius: props.size > 32 ? 8 : 3} 609 610 return ( 611 <ProfileHoverCard did={profile.did} disable={disableHoverCard}> 612 {disableNavigation ? ( 613 avatarEl 614 ) : status.isActive && (IS_NATIVE || IS_WEB_TOUCH_DEVICE) ? ( 615 <> 616 <Button 617 label={_( 618 msg`${sanitizeDisplayName( 619 profile.displayName || sanitizeHandle(profile.handle), 620 )}'s avatar`, 621 )} 622 accessibilityHint={_(msg`Opens live status dialog`)} 623 onPress={onOpenLiveStatus}> 624 {avatarEl} 625 </Button> 626 <LiveStatusDialog 627 control={liveControl} 628 profile={profile} 629 status={status} 630 embed={status.embed} 631 /> 632 </> 633 ) : ( 634 <Link 635 label={_( 636 msg`${sanitizeDisplayName( 637 profile.displayName || sanitizeHandle(profile.handle), 638 )}'s avatar`, 639 )} 640 accessibilityHint={_(msg`Opens this profile`)} 641 to={makeProfileLink({ 642 did: profile.did, 643 handle: profile.handle, 644 })} 645 onPress={onPress} 646 style={linkStyle}> 647 {avatarEl} 648 </Link> 649 )} 650 </ProfileHoverCard> 651 ) 652} 653PreviewableUserAvatar = memo(PreviewableUserAvatar) 654export {PreviewableUserAvatar} 655 656// HACK 657// We have started serving smaller avis but haven't updated lexicons to give the data properly 658// manually string-replace to use the smaller ones 659// -prf 660function hackModifyThumbnailPath(uri: string, isEnabled: boolean): string { 661 return isEnabled 662 ? uri.replace('/img/avatar/plain/', '/img/avatar_thumbnail/plain/') 663 : uri 664} 665 666const styles = StyleSheet.create({ 667 editButtonContainer: { 668 position: 'absolute', 669 width: 24, 670 height: 24, 671 bottom: 0, 672 right: 0, 673 borderRadius: 12, 674 alignItems: 'center', 675 justifyContent: 'center', 676 }, 677})