forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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 {convertCdnPreset} from '#/lib/media/util'
28import {makeProfileLink} from '#/lib/routes/links'
29import {sanitizeDisplayName} from '#/lib/strings/display-names'
30import {isCancelledError} from '#/lib/strings/errors'
31import {sanitizeHandle} from '#/lib/strings/handles'
32import {logger} from '#/logger'
33import {
34 type ComposerImage,
35 compressImage,
36 createComposerImage,
37} from '#/state/gallery'
38import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars'
39import {useHighQualityImages} from '#/state/preferences/high-quality-images'
40import {
41 applyImageTransforms,
42 useImageCdnHost,
43} from '#/state/preferences/image-cdn-host'
44import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
45import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog'
46import {atoms as a, tokens, useTheme} from '#/alf'
47import {Button} from '#/components/Button'
48import {useDialogControl} from '#/components/Dialog'
49import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
50import {
51 Camera_Filled_Stroke2_Corner0_Rounded as CameraFilledIcon,
52 Camera_Stroke2_Corner0_Rounded as CameraIcon,
53} from '#/components/icons/Camera'
54import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive'
55import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
56import {Link} from '#/components/Link'
57import {MediaInsetBorder} from '#/components/MediaInsetBorder'
58import * as Menu from '#/components/Menu'
59import {ProfileHoverCard} from '#/components/ProfileHoverCard'
60import {useAnalytics} from '#/analytics'
61import {IS_ANDROID, IS_NATIVE, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env'
62import {useActorStatus} from '#/features/liveNow'
63import {LiveIndicator} from '#/features/liveNow/components/LiveIndicator'
64import {LiveStatusDialog} from '#/features/liveNow/components/LiveStatusDialog'
65import type * as bsky from '#/types/bsky'
66
67export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
68
69interface BaseUserAvatarProps {
70 type?: UserAvatarType
71 shape?: 'circle' | 'square'
72 size: number
73 avatar?: string | null
74 live?: boolean
75 hideLiveBadge?: boolean
76}
77
78interface UserAvatarProps extends BaseUserAvatarProps {
79 type: UserAvatarType
80 moderation?: ModerationUI
81 usePlainRNImage?: boolean
82 noBorder?: boolean
83 onLoad?: () => void
84 style?: StyleProp<ViewStyle>
85}
86
87interface EditableUserAvatarProps extends BaseUserAvatarProps {
88 onSelectNewAvatar: (img: PickerImage | null) => void
89}
90
91interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
92 moderation?: ModerationUI
93 profile: bsky.profile.AnyProfileView
94 disableHoverCard?: boolean
95 disableNavigation?: boolean
96 onBeforePress?: () => void
97}
98
99const BLUR_AMOUNT = IS_WEB ? 5 : 100
100
101let DefaultAvatar = ({
102 type,
103 shape: overrideShape,
104 size,
105}: {
106 type: UserAvatarType
107 shape?: 'square' | 'circle'
108 size: number
109}): React.ReactNode => {
110 const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
111 const t = useTheme()
112
113 const aviStyle = useMemo(() => {
114 if (finalShape === 'square') {
115 return {borderRadius: size > 32 ? 8 : 3, overflow: 'hidden'} as const
116 }
117 }, [finalShape, size])
118
119 if (type === 'algo') {
120 // TODO: shape=circle
121 // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc.
122 return (
123 <Svg
124 testID="userAvatarFallback"
125 width={size}
126 height={size}
127 viewBox="0 0 32 32"
128 fill="none"
129 stroke="none"
130 style={aviStyle}>
131 <Rect width="32" height="32" rx="4" fill={t.palette.primary_500} />
132 <Path
133 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"
134 fill="white"
135 />
136 </Svg>
137 )
138 }
139 if (type === 'list') {
140 // TODO: shape=circle
141 // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc.
142 return (
143 <Svg
144 testID="userAvatarFallback"
145 width={size}
146 height={size}
147 viewBox="0 0 32 32"
148 fill="none"
149 stroke="none"
150 style={aviStyle}>
151 <Path
152 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"
153 fill={t.palette.primary_500}
154 />
155 <Path
156 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"
157 fill="white"
158 />
159 <Path
160 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"
161 fill="white"
162 />
163 </Svg>
164 )
165 }
166 if (type === 'labeler') {
167 return (
168 <Svg
169 testID="userAvatarFallback"
170 width={size}
171 height={size}
172 viewBox="0 0 32 32"
173 fill="none"
174 stroke="none"
175 style={aviStyle}>
176 {finalShape === 'square' ? (
177 <Rect
178 x="0"
179 y="0"
180 width="32"
181 height="32"
182 rx="3"
183 fill={tokens.color.temp_purple}
184 />
185 ) : (
186 <Circle cx="16" cy="16" r="16" fill={tokens.color.temp_purple} />
187 )}
188 <Path
189 d="M24 9.75L16 7L8 9.75V15.9123C8 20.8848 12 23 16 25.1579C20 23 24 20.8848 24 15.9123V9.75Z"
190 stroke="white"
191 strokeWidth="2"
192 strokeLinecap="square"
193 strokeLinejoin="round"
194 />
195 </Svg>
196 )
197 }
198 return (
199 <Svg
200 testID="userAvatarFallback"
201 width={size}
202 height={size}
203 viewBox="0 0 24 24"
204 fill="none"
205 stroke="none"
206 style={aviStyle}>
207 {finalShape === 'square' ? (
208 <Rect
209 x="0"
210 y="0"
211 width="24"
212 height="24"
213 rx="3"
214 fill={t.palette.primary_500}
215 />
216 ) : (
217 <Circle cx="12" cy="12" r="12" fill={t.palette.primary_500} />
218 )}
219 <Circle cx="12" cy="9.5" r="3.5" fill="#fff" />
220 <Path
221 strokeLinecap="round"
222 strokeLinejoin="round"
223 fill="#fff"
224 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"
225 />
226 </Svg>
227 )
228}
229DefaultAvatar = memo(DefaultAvatar)
230export {DefaultAvatar}
231
232let UserAvatar = ({
233 type = 'user',
234 shape: overrideShape,
235 size,
236 avatar,
237 moderation,
238 usePlainRNImage = false,
239 onLoad,
240 style,
241 live,
242 hideLiveBadge,
243 noBorder,
244}: UserAvatarProps): React.ReactNode => {
245 const t = useTheme()
246
247 const enableSquareAvatars = useEnableSquareAvatars()
248 const avishapeforce = enableSquareAvatars ? 'square' : 'circle'
249
250 const finalShape =
251 overrideShape ?? (type === 'user' ? avishapeforce : 'square')
252 const highQualityImages = useHighQualityImages()
253 const imageCdnHost = useImageCdnHost()
254
255 const aviStyle = useMemo(() => {
256 let borderRadius
257
258 if (finalShape === 'square') {
259 borderRadius = size > 32 ? 8 : 3
260 } else {
261 borderRadius = Math.floor(size / 2)
262 }
263
264 return {
265 width: size,
266 height: size,
267 borderRadius,
268 backgroundColor: t.palette.contrast_25,
269 }
270 }, [finalShape, size, t])
271
272 const borderStyle = useMemo(() => {
273 return [
274 {borderRadius: aviStyle.borderRadius},
275 live && {
276 borderColor: t.palette.negative_500,
277 borderWidth: size > 16 ? 2 : 1,
278 opacity: 1,
279 },
280 ]
281 }, [aviStyle.borderRadius, live, t, size])
282
283 const alert = useMemo(() => {
284 if (!moderation?.alert) {
285 return null
286 }
287 return (
288 <View
289 style={[
290 a.absolute,
291 a.right_0,
292 a.bottom_0,
293 a.rounded_full,
294 {backgroundColor: t.palette.white},
295 ]}>
296 <FontAwesomeIcon
297 icon="exclamation-circle"
298 style={{color: t.palette.negative_400}}
299 size={Math.floor(size / 3)}
300 />
301 </View>
302 )
303 }, [moderation?.alert, size, t])
304
305 const containerStyle = useMemo(() => {
306 return [
307 {
308 width: size,
309 height: size,
310 },
311 style,
312 ]
313 }, [size, style])
314
315 return avatar &&
316 !((moderation?.blur && IS_ANDROID) /* android crashes with blur */) ? (
317 <View style={containerStyle}>
318 {usePlainRNImage ? (
319 <RNImage
320 accessibilityIgnoresInvertColors
321 testID="userAvatarImage"
322 style={aviStyle}
323 resizeMode="cover"
324 source={{
325 uri: applyImageTransforms(
326 hackModifyThumbnailPath(avatar, size < 90),
327 {imageCdnHost, highQualityImages},
328 ),
329 }}
330 blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
331 onLoad={onLoad}
332 />
333 ) : (
334 <ExpoImage
335 testID="userAvatarImage"
336 style={aviStyle}
337 contentFit="cover"
338 source={{
339 uri: applyImageTransforms(
340 hackModifyThumbnailPath(avatar, size < 90),
341 {imageCdnHost, highQualityImages},
342 ),
343 }}
344 blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
345 onLoad={onLoad}
346 />
347 )}
348 {!noBorder && <MediaInsetBorder style={borderStyle} />}
349 {live && size > 16 && !hideLiveBadge && (
350 <LiveIndicator size={size > 32 ? 'small' : 'tiny'} />
351 )}
352 {alert}
353 </View>
354 ) : (
355 <View style={containerStyle}>
356 <DefaultAvatar type={type} shape={finalShape} size={size} />
357 {!noBorder && <MediaInsetBorder style={borderStyle} />}
358 {live && size > 16 && !hideLiveBadge && (
359 <LiveIndicator size={size > 32 ? 'small' : 'tiny'} />
360 )}
361 {alert}
362 </View>
363 )
364}
365UserAvatar = memo(UserAvatar)
366export {UserAvatar}
367
368let EditableUserAvatar = ({
369 type = 'user',
370 size,
371 avatar,
372 onSelectNewAvatar,
373}: EditableUserAvatarProps): React.ReactNode => {
374 const t = useTheme()
375 const {_} = useLingui()
376 const {requestCameraAccessIfNeeded} = useCameraPermission()
377 const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
378 const [rawImage, setRawImage] = useState<ComposerImage | undefined>()
379 const editImageDialogControl = useDialogControl()
380
381 const sheetWrapper = useSheetWrapper()
382 const highQualityImages = useHighQualityImages()
383 const imageCdnHost = useImageCdnHost()
384
385 const enableSquareAvatars = useEnableSquareAvatars()
386
387 const circular = type !== 'algo' && type !== 'list' && !enableSquareAvatars
388
389 const aviStyle = useMemo(() => {
390 if (!circular) {
391 return {
392 width: size,
393 height: size,
394 borderRadius: size > 32 ? 8 : 3,
395 }
396 }
397 return {
398 width: size,
399 height: size,
400 borderRadius: Math.floor(size / 2),
401 }
402 }, [circular, size])
403
404 const onOpenCamera = useCallback(async () => {
405 if (!(await requestCameraAccessIfNeeded())) {
406 return
407 }
408
409 onSelectNewAvatar(
410 await compressIfNeeded(
411 await openCamera({
412 aspect: [1, 1],
413 }),
414 ),
415 )
416 }, [onSelectNewAvatar, requestCameraAccessIfNeeded])
417
418 const onOpenLibrary = useCallback(async () => {
419 if (!(await requestPhotoAccessIfNeeded())) {
420 return
421 }
422
423 const items = await sheetWrapper(
424 openPicker({
425 aspect: [1, 1],
426 }),
427 )
428 const item = items[0]
429 if (!item) {
430 return
431 }
432
433 try {
434 if (IS_NATIVE) {
435 onSelectNewAvatar(
436 await compressIfNeeded(
437 await openCropper({
438 imageUri: item.path,
439 shape: circular ? 'circle' : 'rectangle',
440 aspectRatio: 1,
441 }),
442 ),
443 )
444 } else {
445 setRawImage(await createComposerImage(item))
446 editImageDialogControl.open()
447 }
448 } catch (e) {
449 // Don't log errors for cancelling selection to sentry on ios or android
450 if (!isCancelledError(e)) {
451 logger.error('Failed to crop avatar', {error: e})
452 }
453 }
454 }, [
455 onSelectNewAvatar,
456 requestPhotoAccessIfNeeded,
457 sheetWrapper,
458 editImageDialogControl,
459 circular,
460 ])
461
462 const onRemoveAvatar = useCallback(() => {
463 onSelectNewAvatar(null)
464 }, [onSelectNewAvatar])
465
466 const onChangeEditImage = useCallback(
467 async (image: ComposerImage) => {
468 const compressed = await compressImage(image)
469 onSelectNewAvatar(compressed)
470 },
471 [onSelectNewAvatar],
472 )
473
474 return (
475 <>
476 <Menu.Root>
477 <Menu.Trigger label={_(msg`Edit avatar`)}>
478 {({props}) => (
479 <Pressable {...props} testID="changeAvatarBtn">
480 {avatar ? (
481 <ExpoImage
482 testID="userAvatarImage"
483 style={aviStyle}
484 source={{
485 uri: applyImageTransforms(avatar, {
486 imageCdnHost,
487 highQualityImages,
488 }),
489 }}
490 accessibilityRole="image"
491 />
492 ) : (
493 <DefaultAvatar type={type} size={size} />
494 )}
495 <View
496 style={[
497 styles.editButtonContainer,
498 t.atoms.bg_contrast_25,
499 a.border,
500 t.atoms.border_contrast_low,
501 ]}>
502 <CameraFilledIcon height={14} width={14} style={t.atoms.text} />
503 </View>
504 </Pressable>
505 )}
506 </Menu.Trigger>
507 <Menu.Outer showCancel>
508 <Menu.Group>
509 {IS_NATIVE && (
510 <Menu.Item
511 testID="changeAvatarCameraBtn"
512 label={_(msg`Upload from Camera`)}
513 onPress={onOpenCamera}>
514 <Menu.ItemText>
515 <Trans>Upload from Camera</Trans>
516 </Menu.ItemText>
517 <Menu.ItemIcon icon={CameraIcon} />
518 </Menu.Item>
519 )}
520
521 <Menu.Item
522 testID="changeAvatarLibraryBtn"
523 label={_(msg`Upload from Library`)}
524 onPress={onOpenLibrary}>
525 <Menu.ItemText>
526 {IS_NATIVE ? (
527 <Trans>Upload from Library</Trans>
528 ) : (
529 <Trans>Upload from Files</Trans>
530 )}
531 </Menu.ItemText>
532 <Menu.ItemIcon icon={LibraryIcon} />
533 </Menu.Item>
534 </Menu.Group>
535 {!!avatar && (
536 <>
537 <Menu.Divider />
538 <Menu.Group>
539 <Menu.Item
540 testID="changeAvatarRemoveBtn"
541 label={_(msg`Remove Avatar`)}
542 onPress={onRemoveAvatar}>
543 <Menu.ItemText>
544 <Trans>Remove Avatar</Trans>
545 </Menu.ItemText>
546 <Menu.ItemIcon icon={TrashIcon} />
547 </Menu.Item>
548 </Menu.Group>
549 </>
550 )}
551 </Menu.Outer>
552 </Menu.Root>
553
554 <EditImageDialog
555 control={editImageDialogControl}
556 image={rawImage}
557 onChange={onChangeEditImage}
558 aspectRatio={1}
559 circularCrop={circular}
560 />
561 </>
562 )
563}
564EditableUserAvatar = memo(EditableUserAvatar)
565export {EditableUserAvatar}
566
567let PreviewableUserAvatar = ({
568 moderation,
569 profile,
570 disableHoverCard,
571 disableNavigation,
572 onBeforePress,
573 live,
574 ...props
575}: PreviewableUserAvatarProps): React.ReactNode => {
576 const ax = useAnalytics()
577 const {_} = useLingui()
578 const queryClient = useQueryClient()
579 const status = useActorStatus(profile)
580 const liveControl = useDialogControl()
581 const playHaptic = useHaptics()
582
583 const onPress = useCallback(() => {
584 onBeforePress?.()
585 unstableCacheProfileView(queryClient, profile)
586 }, [profile, queryClient, onBeforePress])
587
588 const onOpenLiveStatus = useCallback(() => {
589 playHaptic('Light')
590 ax.metric('live:card:open', {subject: profile.did, from: 'post'})
591 liveControl.open()
592 }, [liveControl, playHaptic, profile.did])
593
594 const avatarEl = (
595 <UserAvatar
596 avatar={profile.avatar}
597 moderation={moderation}
598 type={profile.associated?.labeler ? 'labeler' : 'user'}
599 live={status.isActive || live}
600 {...props}
601 />
602 )
603
604 const linkStyle =
605 props.type !== 'algo' && props.type !== 'list'
606 ? a.rounded_full
607 : {borderRadius: props.size > 32 ? 8 : 3}
608
609 return (
610 <ProfileHoverCard did={profile.did} disable={disableHoverCard}>
611 {disableNavigation ? (
612 avatarEl
613 ) : status.isActive && (IS_NATIVE || IS_WEB_TOUCH_DEVICE) ? (
614 <>
615 <Button
616 label={_(
617 msg`${sanitizeDisplayName(
618 profile.displayName || sanitizeHandle(profile.handle),
619 )}'s avatar`,
620 )}
621 accessibilityHint={_(msg`Opens live status dialog`)}
622 onPress={onOpenLiveStatus}>
623 {avatarEl}
624 </Button>
625 <LiveStatusDialog
626 control={liveControl}
627 profile={profile}
628 status={status}
629 embed={status.embed}
630 />
631 </>
632 ) : (
633 <Link
634 label={_(
635 msg`${sanitizeDisplayName(
636 profile.displayName || sanitizeHandle(profile.handle),
637 )}'s avatar`,
638 )}
639 accessibilityHint={_(msg`Opens this profile`)}
640 to={makeProfileLink({
641 did: profile.did,
642 handle: profile.handle,
643 })}
644 onPress={onPress}
645 style={linkStyle}>
646 {avatarEl}
647 </Link>
648 )}
649 </ProfileHoverCard>
650 )
651}
652PreviewableUserAvatar = memo(PreviewableUserAvatar)
653export {PreviewableUserAvatar}
654
655// HACK
656// We have started serving smaller avis but haven't updated lexicons to give the data properly
657// manually string-replace to use the smaller ones
658// -prf
659function hackModifyThumbnailPath(uri: string, isEnabled: boolean): string {
660 return isEnabled ? convertCdnPreset(uri, 'avatar_thumbnail') : uri
661}
662
663const styles = StyleSheet.create({
664 editButtonContainer: {
665 position: 'absolute',
666 width: 24,
667 height: 24,
668 bottom: 0,
669 right: 0,
670 borderRadius: 12,
671 alignItems: 'center',
672 justifyContent: 'center',
673 },
674})