import {memo, useCallback, useMemo, useState} from 'react' import { Image, Pressable, type StyleProp, StyleSheet, View, type ViewStyle, } from 'react-native' import Svg, {Circle, Path, Rect} from 'react-native-svg' import {type 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 {useActorStatus} from '#/lib/actor-status' import {isTouchDevice} from '#/lib/browser' import {useHaptics} from '#/lib/haptics' import { useCameraPermission, usePhotoLibraryPermission, } from '#/lib/hooks/usePermissions' import {compressIfNeeded} from '#/lib/media/manip' import {openCamera, openCropper, openPicker} from '#/lib/media/picker' import {type PickerImage} from '#/lib/media/picker.shared' import {makeProfileLink} from '#/lib/routes/links' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {isAndroid, isNative, isWeb} from '#/platform/detection' import { type ComposerImage, compressImage, createComposerImage, } from '#/state/gallery' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog' import {HighPriorityImage} from '#/view/com/util/images/Image' import {atoms as a, tokens, useTheme} from '#/alf' import {Button} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import { Camera_Filled_Stroke2_Corner0_Rounded as CameraFilledIcon, Camera_Stroke2_Corner0_Rounded as CameraIcon, } from '#/components/icons/Camera' import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import {Link} from '#/components/Link' import {LiveIndicator} from '#/components/live/LiveIndicator' import {LiveStatusDialog} from '#/components/live/LiveStatusDialog' import {MediaInsetBorder} from '#/components/MediaInsetBorder' import * as Menu from '#/components/Menu' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import type * as bsky from '#/types/bsky' export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler' interface BaseUserAvatarProps { type?: UserAvatarType shape?: 'circle' | 'square' size: number avatar?: string | null live?: boolean hideLiveBadge?: boolean } interface UserAvatarProps extends BaseUserAvatarProps { type: UserAvatarType moderation?: ModerationUI usePlainRNImage?: boolean noBorder?: boolean onLoad?: () => void style?: StyleProp } interface EditableUserAvatarProps extends BaseUserAvatarProps { onSelectNewAvatar: (img: PickerImage | null) => void } interface PreviewableUserAvatarProps extends BaseUserAvatarProps { moderation?: ModerationUI profile: bsky.profile.AnyProfileView disableHoverCard?: boolean disableNavigation?: 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') const aviStyle = useMemo(() => { if (finalShape === 'square') { return {borderRadius: size > 32 ? 8 : 3, overflow: 'hidden'} as const } }, [finalShape, size]) 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, style, live, hideLiveBadge, noBorder, }: UserAvatarProps): React.ReactNode => { const t = useTheme() const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square') const aviStyle = useMemo(() => { let borderRadius if (finalShape === 'square') { borderRadius = size > 32 ? 8 : 3 } else { borderRadius = Math.floor(size / 2) } return { width: size, height: size, borderRadius, backgroundColor: t.palette.contrast_25, } }, [finalShape, size, t]) const borderStyle = useMemo(() => { return [ {borderRadius: aviStyle.borderRadius}, live && { borderColor: t.palette.negative_500, borderWidth: size > 16 ? 2 : 1, opacity: 1, }, ] }, [aviStyle.borderRadius, live, t, size]) const alert = useMemo(() => { if (!moderation?.alert) { return null } return ( ) }, [moderation?.alert, size, t]) const containerStyle = useMemo(() => { return [ { width: size, height: size, }, style, ] }, [size, style]) return avatar && !((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( {usePlainRNImage ? ( ) : ( )} {!noBorder && } {live && size > 16 && !hideLiveBadge && ( 32 ? 'small' : 'tiny'} /> )} {alert} ) : ( {!noBorder && } {live && size > 16 && !hideLiveBadge && ( 32 ? 'small' : 'tiny'} /> )} {alert} ) } UserAvatar = memo(UserAvatar) export {UserAvatar} let EditableUserAvatar = ({ type = 'user', size, avatar, onSelectNewAvatar, }: EditableUserAvatarProps): React.ReactNode => { const t = useTheme() const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const [rawImage, setRawImage] = useState() const editImageDialogControl = useDialogControl() const sheetWrapper = useSheetWrapper() const circular = type !== 'algo' && type !== 'list' const aviStyle = useMemo(() => { if (!circular) { return { width: size, height: size, borderRadius: size > 32 ? 8 : 3, } } return { width: size, height: size, borderRadius: Math.floor(size / 2), } }, [circular, size]) const onOpenCamera = useCallback(async () => { if (!(await requestCameraAccessIfNeeded())) { return } onSelectNewAvatar( await compressIfNeeded( await openCamera({ aspect: [1, 1], }), ), ) }, [onSelectNewAvatar, requestCameraAccessIfNeeded]) const onOpenLibrary = useCallback(async () => { if (!(await requestPhotoAccessIfNeeded())) { return } const items = await sheetWrapper( openPicker({ aspect: [1, 1], }), ) const item = items[0] if (!item) { return } try { if (isNative) { onSelectNewAvatar( await compressIfNeeded( await openCropper({ imageUri: item.path, shape: circular ? 'circle' : 'rectangle', aspectRatio: 1, }), ), ) } else { setRawImage(await createComposerImage(item)) editImageDialogControl.open() } } 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, editImageDialogControl, circular, ]) const onRemoveAvatar = useCallback(() => { onSelectNewAvatar(null) }, [onSelectNewAvatar]) const onChangeEditImage = useCallback( async (image: ComposerImage) => { const compressed = await compressImage(image) onSelectNewAvatar(compressed) }, [onSelectNewAvatar], ) return ( <> {({props}) => ( {avatar ? ( ) : ( )} )} {isNative && ( Upload from Camera )} {isNative ? ( Upload from Library ) : ( Upload from Files )} {!!avatar && ( <> Remove Avatar )} ) } EditableUserAvatar = memo(EditableUserAvatar) export {EditableUserAvatar} let PreviewableUserAvatar = ({ moderation, profile, disableHoverCard, disableNavigation, onBeforePress, live, ...props }: PreviewableUserAvatarProps): React.ReactNode => { const {_} = useLingui() const queryClient = useQueryClient() const status = useActorStatus(profile) const liveControl = useDialogControl() const playHaptic = useHaptics() const onPress = useCallback(() => { onBeforePress?.() unstableCacheProfileView(queryClient, profile) }, [profile, queryClient, onBeforePress]) const onOpenLiveStatus = useCallback(() => { playHaptic('Light') logger.metric( 'live:card:open', {subject: profile.did, from: 'post'}, {statsig: true}, ) liveControl.open() }, [liveControl, playHaptic, profile.did]) const avatarEl = ( ) const linkStyle = props.type !== 'algo' && props.type !== 'list' ? a.rounded_full : {borderRadius: props.size > 32 ? 8 : 3} return ( {disableNavigation ? ( avatarEl ) : status.isActive && (isNative || isTouchDevice) ? ( <> ) : ( {avatarEl} )} ) } 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 } const styles = StyleSheet.create({ editButtonContainer: { position: 'absolute', width: 24, height: 24, bottom: 0, right: 0, borderRadius: 12, alignItems: 'center', justifyContent: 'center', }, })