// https://raw.githubusercontent.com/bluesky-social/social-app/refs/heads/main/src/view/com/util/UserAvatar.tsx import React, {memo, useMemo} from 'react' import {Image, Pressable, StyleSheet, View} from 'react-native' import {Image as RNImage} from 'react-native-image-crop-picker' import Svg, {Circle, Path, Rect} from 'react-native-svg' import {AppBskyActorDefs, ModerationUI} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' import {usePalette} from '#/lib/hooks/usePalette' import { useCameraPermission, usePhotoLibraryPermission, } from '#/lib/hooks/usePermissions' import {makeProfileLink} from '#/lib/routes/links' import {colors} from '#/lib/styles' import {logger} from '#/logger' import {isAndroid, isNative, isWeb} from '#/platform/detection' import {precacheProfile} from '#/state/queries/profile' import {HighPriorityImage} from '#/view/com/util/images/Image' import {tokens, useTheme} from '#/alf' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import { Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, Camera_Stroke2_Corner0_Rounded as Camera, } from '#/components/icons/Camera' import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive' import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' import {Link} from '#/components/Link' import {MediaInsetBorder} from '#/components/MediaInsetBorder' import * as Menu from '#/components/Menu' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler' interface BaseUserAvatarProps { type?: UserAvatarType shape?: 'circle' | 'square' size: number avatar?: string | null } interface UserAvatarProps extends BaseUserAvatarProps { moderation?: ModerationUI usePlainRNImage?: boolean onLoad?: () => void } interface EditableUserAvatarProps extends BaseUserAvatarProps { onSelectNewAvatar: (img: RNImage | null) => void } interface PreviewableUserAvatarProps extends BaseUserAvatarProps { moderation?: ModerationUI profile: AppBskyActorDefs.ProfileViewBasic disableHoverCard?: boolean onBeforePress?: () => void } const BLUR_AMOUNT = isWeb ? 5 : 100 let DefaultAvatar = ({ type, shape: overrideShape, size, }: { type: UserAvatarType shape?: 'square' | 'circle' size: number }): React.ReactNode => { const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square') if (type === 'algo') { // TODO: shape=circle // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. return ( ) } if (type === 'list') { // TODO: shape=circle // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. return ( ) } if (type === 'labeler') { return ( {finalShape === 'square' ? ( ) : ( )} ) } // TODO: shape=square return ( ) } DefaultAvatar = memo(DefaultAvatar) export {DefaultAvatar} let UserAvatar = ({ type = 'user', shape: overrideShape, size, avatar, moderation, usePlainRNImage = false, onLoad, }: UserAvatarProps): React.ReactNode => { const pal = usePalette('default') const backgroundColor = pal.colors.backgroundLight const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square') const aviStyle = useMemo(() => { if (finalShape === 'square') { return { width: size, height: size, borderRadius: size > 32 ? 8 : 3, backgroundColor, } } return { width: size, height: size, borderRadius: Math.floor(size / 2), backgroundColor, } }, [finalShape, size, backgroundColor]) const alert = useMemo(() => { if (!moderation?.alert) { return null } return ( ) }, [moderation?.alert, size, pal]) return avatar && !((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( {usePlainRNImage ? ( ) : ( )} {alert} ) : ( {alert} ) } UserAvatar = memo(UserAvatar) export {UserAvatar} let EditableUserAvatar = ({ type = 'user', size, avatar, onSelectNewAvatar, }: EditableUserAvatarProps): React.ReactNode => { const t = useTheme() const pal = usePalette('default') const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const sheetWrapper = useSheetWrapper() const aviStyle = useMemo(() => { if (type === 'algo' || type === 'list') { return { width: size, height: size, borderRadius: size > 32 ? 8 : 3, } } return { width: size, height: size, borderRadius: Math.floor(size / 2), } }, [type, size]) const onOpenCamera = React.useCallback(async () => { if (!(await requestCameraAccessIfNeeded())) { return } onSelectNewAvatar( await openCamera({ width: 1000, height: 1000, cropperCircleOverlay: true, }), ) }, [onSelectNewAvatar, requestCameraAccessIfNeeded]) const onOpenLibrary = React.useCallback(async () => { if (!(await requestPhotoAccessIfNeeded())) { return } const items = await sheetWrapper( openPicker({ aspect: [1, 1], }), ) const item = items[0] if (!item) { return } try { const croppedImage = await openCropper({ mediaType: 'photo', cropperCircleOverlay: true, height: 1000, width: 1000, path: item.path, webAspectRatio: 1, webCircularCrop: true, }) onSelectNewAvatar(croppedImage) } catch (e: any) { // Don't log errors for cancelling selection to sentry on ios or android if (!String(e).toLowerCase().includes('cancel')) { logger.error('Failed to crop banner', {error: e}) } } }, [onSelectNewAvatar, requestPhotoAccessIfNeeded, sheetWrapper]) const onRemoveAvatar = React.useCallback(() => { onSelectNewAvatar(null) }, [onSelectNewAvatar]) return ( {({props}) => ( {avatar ? ( 0), }} accessibilityRole="image" /> ) : ( )} )} {isNative && ( Upload from Camera )} {isNative ? ( Upload from Library ) : ( Upload from Files )} {!!avatar && ( <> Remove Avatar )} ) } EditableUserAvatar = memo(EditableUserAvatar) export {EditableUserAvatar} let PreviewableUserAvatar = ({ moderation, profile, disableHoverCard, onBeforePress, ...rest }: PreviewableUserAvatarProps): React.ReactNode => { const {_} = useLingui() const queryClient = useQueryClient() const onPress = React.useCallback(() => { onBeforePress?.() precacheProfile(queryClient, profile) }, [profile, queryClient, onBeforePress]) return ( ) } PreviewableUserAvatar = memo(PreviewableUserAvatar) export {PreviewableUserAvatar} // HACK // We have started serving smaller avis but haven't updated lexicons to give the data properly // manually string-replace to use the smaller ones // -prf function hackModifyThumbnailPath(uri: string, isEnabled: boolean): string { return isEnabled // ? uri.replace('/img/avatar/plain/', '/img/avatar_thumbnail/plain/') ? uri.replace('https://cdn.bsky.app/img/avatar/plain/', 'https://bsky.syu.is/img/avatar/plain/') : uri } const styles = StyleSheet.create({ editButtonContainer: { position: 'absolute', width: 24, height: 24, bottom: 0, right: 0, borderRadius: 12, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.gray5, }, alertIconContainer: { position: 'absolute', right: 0, bottom: 0, borderRadius: 100, }, alertIcon: { color: colors.red3, }, })