Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
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})