From 122d1258bf576c7f9b58c2d29f948c08bb75e863 Mon Sep 17 00:00:00 2001 From: syui Date: Fri, 27 Dec 2024 21:36:13 +0900 Subject: [PATCH] fix --- icons/UserAvatar.tsx | 484 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 484 insertions(+) create mode 100644 icons/UserAvatar.tsx diff --git a/icons/UserAvatar.tsx b/icons/UserAvatar.tsx new file mode 100644 index 0000000..ab39982 --- /dev/null +++ b/icons/UserAvatar.tsx @@ -0,0 +1,484 @@ +// 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, + }, +})