diff --git a/fluxer_app/src/features/user/components/modals/UserProfileModal.tsx b/fluxer_app/src/features/user/components/modals/UserProfileModal.tsx index 0214feada..52a1df1c5 100644 --- a/fluxer_app/src/features/user/components/modals/UserProfileModal.tsx +++ b/fluxer_app/src/features/user/components/modals/UserProfileModal.tsx @@ -766,6 +766,7 @@ export const UserProfileModal: UserProfileModalComponent = observer( return ( { form.setValue('banner', base64); @@ -674,6 +678,7 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({ const guilds = Guilds.getGuilds(); const selectedGuild = selectedGuildId ? guilds.find((g) => g.id === selectedGuildId) : null; const isPerGuildProfileCustomizationDisabled = isPerGuildProfile && !hasPerGuildProfiles; + const isAvatarCustomizationDisabled = isProfileCustomizationLocked || isPerGuildProfileCustomizationDisabled; const isPronounsDisabled = isProfileCustomizationLocked; const profileCustomizationDescription = isProfileEmailLocked ? isPerGuildProfile @@ -840,7 +845,7 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({ hasAvatar={hasAvatar} onAvatarChange={handleAvatarChange} onAvatarClear={handleAvatarClear} - disabled={isProfileCustomizationLocked || isPerGuildProfileCustomizationDisabled} + disabled={isAvatarCustomizationDisabled} disableModeSelection={isProfileCustomizationLocked} isPerGuildProfile={isPerGuildProfile} errorMessage={form.formState.errors.avatar?.message} @@ -934,6 +939,9 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({ }} ignoreGuildAvatarInPreview={isPerGuildProfile && avatarAsset.mode === 'inherit'} ignoreGuildBannerInPreview={isPerGuildProfile && bannerAsset.mode === 'inherit'} + onAvatarClick={ + mobileLayout.enabled && !isAvatarCustomizationDisabled ? openChangeAvatar : undefined + } data-flx="user.my-profile-tab.my-profile-tab-component.profile-preview" /> diff --git a/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/AvatarUploader.tsx b/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/AvatarUploader.tsx index 44280e003..735ddbc55 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/AvatarUploader.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/AvatarUploader.tsx @@ -116,6 +116,203 @@ interface AvatarUploaderProps { onAvatarModeChange?: (mode: AvatarMode) => void; } +interface ChangeAvatarActionOptions { + onAvatarChange: (base64: string) => void; + requireAnimatedAvatarEntitlement?: boolean; +} + +export function useChangeAvatarAction({ + onAvatarChange, + requireAnimatedAvatarEntitlement = true, +}: ChangeAvatarActionOptions) { + const {i18n} = useLingui(); + const PREMIUM_PRODUCT_NAME = getPremiumProductName(); + const hasAnimatedAvatarEntitlement = isLimitToggleEnabled( + {feature_animated_avatar: LimitResolver.resolve({key: 'feature_animated_avatar', fallback: 0})}, + 'feature_animated_avatar', + ); + const canUploadAnimatedAvatar = !requireAnimatedAvatarEntitlement || hasAnimatedAvatarEntitlement; + const processAvatarFile = useCallback( + async (file: File) => { + if (file.size > GlobalLimits.getAvatarMaxSize()) { + showGenericErrorModal({ + title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR), + message: i18n._(AVATAR_FILE_IS_TOO_LARGE_PLEASE_CHOOSE_A_DESCRIPTOR, { + imageMaxSizeLabel: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()), + }), + dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal', + }); + return; + } + const svg = isSvgFile(file); + if (!svg && !(await canCropFile(file))) { + showGenericErrorModal({ + title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR), + message: getAssetFormatErrorMessage(i18n, 'avatar', 'unsupported_mime'), + dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal', + }); + return; + } + const {format, animated} = svg ? {format: 'unknown', animated: false} : await inspectImageFile(file); + if (animated && !canUploadAnimatedAvatar) { + if (shouldShowPremiumFeatures()) { + ModalCommands.push( + modal(() => ( + +

+ + Animated avatars ({ANIMATED_AVATAR_FORMATS}) are exclusively available to {PREMIUM_PRODUCT_NAME}{' '} + subscribers. + +

+

+ {RuntimeConfig.usesUniqueUsernames ? ( + + With {PREMIUM_PRODUCT_NAME}, you can use animated avatars ({ANIMATED_AVATAR_FORMATS}) and + profile banners, and unlock many other {PREMIUM_PRODUCT_NAME} perks. + + ) : ( + + With {PREMIUM_PRODUCT_NAME}, you can use animated avatars ({ANIMATED_AVATAR_FORMATS}) and + profile banners, customize your tag, and unlock many other {PREMIUM_PRODUCT_NAME} perks. + + )} +

+ + } + primaryText={i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: getPremiumProductName()})} + primaryVariant="primary" + secondaryText={i18n._(CANCEL_DESCRIPTOR)} + onPrimary={() => { + window.setTimeout(() => { + PremiumModalCommands.open(); + }, 0); + }} + data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.confirm-modal" + /> + )), + ); + } else { + ModalCommands.push( + modal(() => ( + + Animated avatars ({ANIMATED_AVATAR_FORMATS}) are not available on this instance. Upload a static + image instead. + + } + data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.confirm-modal--2" + /> + )), + ); + } + return; + } + const isAnimatedAvif = animated && format === 'avif'; + if (isAnimatedAvif && !(await canDecodeAnimatedAvif())) { + showAnimatedAvifUnsupportedModal({i18n}); + return; + } + const base64 = svg ? await readImageFileAsUploadDataUrl(file) : await readBlobAsDataUrl(file); + ModalCommands.push( + modal(() => ( + { + void readBlobAsDataUrl(croppedBlob).then( + (croppedBase64) => { + onAvatarChange(croppedBase64); + }, + () => { + showGenericErrorModal({ + title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR), + message: i18n._(FAILED_TO_PROCESS_CROPPED_IMAGE_DESCRIPTOR), + dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal', + }); + }, + ); + }} + onSkip={ + isAnimatedAvif + ? undefined + : () => { + onAvatarChange(base64); + } + } + data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.asset-crop-modal" + /> + )), + ); + }, + [canUploadAnimatedAvatar, onAvatarChange, i18n], + ); + const showAvatarUploadError = useCallback(() => { + showGenericErrorModal({ + title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR), + message: i18n._(INVALID_IMAGE_TRY_ANOTHER_DESCRIPTOR), + dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal', + }); + }, [i18n]); + const handlePickAvatarFile = useCallback(async () => { + try { + const [file] = await openFilePicker({accept: getAcceptString('avatar')}); + if (!file) return; + await processAvatarFile(file); + } catch { + showAvatarUploadError(); + } + }, [processAvatarFile, showAvatarUploadError]); + const handleSelectAvatarGif = useCallback( + async (gif: Gif) => { + try { + await processAvatarFile(await downloadGifAsImageFile(gif)); + } catch { + showAvatarUploadError(); + } + }, + [processAvatarFile, showAvatarUploadError], + ); + const handleAvatarUpload = useCallback(() => { + const uploadHint = canUploadAnimatedAvatar + ? formatImageUploadRecommendedHint(i18n, { + formats: ANIMATED_IMAGE_FORMATS, + maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()), + recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL, + }) + : shouldShowPremiumFeatures() + ? formatImageUploadRecommendedHintWithNote(i18n, { + formats: STATIC_IMAGE_FORMATS, + maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()), + recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL, + note: i18n._(ANIMATED_AVATARS_REQUIRE_PREMIUM_NOTE_DESCRIPTOR, { + animatedAvatarFormats: ANIMATED_AVATAR_FORMATS, + premiumProductName: getPremiumProductName(), + }), + }) + : formatImageUploadRecommendedHint(i18n, { + formats: STATIC_IMAGE_FORMATS, + maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()), + recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL, + }); + openAssetSourceModal({ + title: i18n._(CHANGE_AVATAR_DESCRIPTOR), + uploadHint, + onPickUpload: handlePickAvatarFile, + onSelectGif: (gif) => void handleSelectAvatarGif(gif), + }); + }, [canUploadAnimatedAvatar, handlePickAvatarFile, handleSelectAvatarGif, i18n]); + return {canUploadAnimatedAvatar, openChangeAvatar: handleAvatarUpload}; +} + export const AvatarUploader = observer( ({ hasAvatar, @@ -129,13 +326,11 @@ export const AvatarUploader = observer( avatarMode = 'inherit', onAvatarModeChange, }: AvatarUploaderProps) => { - const PREMIUM_PRODUCT_NAME = getPremiumProductName(); const {i18n} = useLingui(); - const hasAnimatedAvatarEntitlement = isLimitToggleEnabled( - {feature_animated_avatar: LimitResolver.resolve({key: 'feature_animated_avatar', fallback: 0})}, - 'feature_animated_avatar', - ); - const canUploadAnimatedAvatar = !requireAnimatedAvatarEntitlement || hasAnimatedAvatarEntitlement; + const {canUploadAnimatedAvatar, openChangeAvatar: handleAvatarUpload} = useChangeAvatarAction({ + onAvatarChange, + requireAnimatedAvatarEntitlement, + }); const getAvatarModeOptions = useCallback( () => [ { @@ -157,184 +352,6 @@ export const AvatarUploader = observer( ], [canUploadAnimatedAvatar, i18n], ); - const processAvatarFile = useCallback( - async (file: File) => { - if (file.size > GlobalLimits.getAvatarMaxSize()) { - showGenericErrorModal({ - title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR), - message: i18n._(AVATAR_FILE_IS_TOO_LARGE_PLEASE_CHOOSE_A_DESCRIPTOR, { - imageMaxSizeLabel: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()), - }), - dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal', - }); - return; - } - const svg = isSvgFile(file); - if (!svg && !(await canCropFile(file))) { - showGenericErrorModal({ - title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR), - message: getAssetFormatErrorMessage(i18n, 'avatar', 'unsupported_mime'), - dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal', - }); - return; - } - const {format, animated} = svg ? {format: 'unknown', animated: false} : await inspectImageFile(file); - if (animated && !canUploadAnimatedAvatar) { - if (shouldShowPremiumFeatures()) { - ModalCommands.push( - modal(() => ( - -

- - Animated avatars ({ANIMATED_AVATAR_FORMATS}) are exclusively available to{' '} - {PREMIUM_PRODUCT_NAME} subscribers. - -

-

- {RuntimeConfig.usesUniqueUsernames ? ( - - With {PREMIUM_PRODUCT_NAME}, you can use animated avatars ({ANIMATED_AVATAR_FORMATS}) and - profile banners, and unlock many other {PREMIUM_PRODUCT_NAME} perks. - - ) : ( - - With {PREMIUM_PRODUCT_NAME}, you can use animated avatars ({ANIMATED_AVATAR_FORMATS}) and - profile banners, customize your tag, and unlock many other {PREMIUM_PRODUCT_NAME} perks. - - )} -

- - } - primaryText={i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: getPremiumProductName()})} - primaryVariant="primary" - secondaryText={i18n._(CANCEL_DESCRIPTOR)} - onPrimary={() => { - window.setTimeout(() => { - PremiumModalCommands.open(); - }, 0); - }} - data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.confirm-modal" - /> - )), - ); - } else { - ModalCommands.push( - modal(() => ( - - Animated avatars ({ANIMATED_AVATAR_FORMATS}) are not available on this instance. Upload a static - image instead. - - } - data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.confirm-modal--2" - /> - )), - ); - } - return; - } - const isAnimatedAvif = animated && format === 'avif'; - if (isAnimatedAvif && !(await canDecodeAnimatedAvif())) { - showAnimatedAvifUnsupportedModal({i18n}); - return; - } - const base64 = svg ? await readImageFileAsUploadDataUrl(file) : await readBlobAsDataUrl(file); - ModalCommands.push( - modal(() => ( - { - void readBlobAsDataUrl(croppedBlob).then( - (croppedBase64) => { - onAvatarChange(croppedBase64); - }, - () => { - showGenericErrorModal({ - title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR), - message: i18n._(FAILED_TO_PROCESS_CROPPED_IMAGE_DESCRIPTOR), - dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal', - }); - }, - ); - }} - onSkip={ - isAnimatedAvif - ? undefined - : () => { - onAvatarChange(base64); - } - } - data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.asset-crop-modal" - /> - )), - ); - }, - [canUploadAnimatedAvatar, onAvatarChange, i18n], - ); - const showAvatarUploadError = useCallback(() => { - showGenericErrorModal({ - title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR), - message: i18n._(INVALID_IMAGE_TRY_ANOTHER_DESCRIPTOR), - dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal', - }); - }, [i18n]); - const handlePickAvatarFile = useCallback(async () => { - try { - const [file] = await openFilePicker({accept: getAcceptString('avatar')}); - if (!file) return; - await processAvatarFile(file); - } catch { - showAvatarUploadError(); - } - }, [processAvatarFile, showAvatarUploadError]); - const handleSelectAvatarGif = useCallback( - async (gif: Gif) => { - try { - await processAvatarFile(await downloadGifAsImageFile(gif)); - } catch { - showAvatarUploadError(); - } - }, - [processAvatarFile, showAvatarUploadError], - ); - const handleAvatarUpload = useCallback(() => { - const uploadHint = canUploadAnimatedAvatar - ? formatImageUploadRecommendedHint(i18n, { - formats: ANIMATED_IMAGE_FORMATS, - maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()), - recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL, - }) - : shouldShowPremiumFeatures() - ? formatImageUploadRecommendedHintWithNote(i18n, { - formats: STATIC_IMAGE_FORMATS, - maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()), - recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL, - note: i18n._(ANIMATED_AVATARS_REQUIRE_PREMIUM_NOTE_DESCRIPTOR, { - animatedAvatarFormats: ANIMATED_AVATAR_FORMATS, - premiumProductName: getPremiumProductName(), - }), - }) - : formatImageUploadRecommendedHint(i18n, { - formats: STATIC_IMAGE_FORMATS, - maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()), - recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL, - }); - openAssetSourceModal({ - title: i18n._(CHANGE_AVATAR_DESCRIPTOR), - uploadHint, - onPickUpload: handlePickAvatarFile, - onSelectGif: (gif) => void handleSelectAvatarGif(gif), - }); - }, [canUploadAnimatedAvatar, handlePickAvatarFile, handleSelectAvatarGif, i18n]); const handleModeChange = useCallback( (mode: AvatarMode) => { if (mode === 'custom') { diff --git a/fluxer_app/src/features/user/components/profile/ProfilePreview.tsx b/fluxer_app/src/features/user/components/profile/ProfilePreview.tsx index fb9823a91..86e36d475 100644 --- a/fluxer_app/src/features/user/components/profile/ProfilePreview.tsx +++ b/fluxer_app/src/features/user/components/profile/ProfilePreview.tsx @@ -65,6 +65,7 @@ interface ProfilePreviewProps { showMessageButton?: boolean; showPreviewLabel?: boolean; previewCustomStatus?: CustomStatus | null; + onAvatarClick?: () => void; } export const ProfilePreview: React.FC = observer( @@ -90,6 +91,7 @@ export const ProfilePreview: React.FC = observer( showMessageButton = true, showPreviewLabel = true, previewCustomStatus, + onAvatarClick, }) => { const {i18n} = useLingui(); const previewOverrides = useMemo( @@ -245,7 +247,7 @@ export const ProfilePreview: React.FC = observer( avatarUrl={finalAvatarUrl} hoverAvatarUrl={finalHoverAvatarUrl} isClickable={true} - onAvatarClick={openMockProfile} + onAvatarClick={onAvatarClick ?? openMockProfile} data-flx="user.profile.profile-preview.profile-card-banner" />