fix(app): stop own profile preview trapping mobile web (#3463)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-14 16:04:09 +02:00
1 parent d7bbe933fa
commit 381699623e
4 files changed
+215 -187

No files matched your search

@@ -766,6 +766,7 @@ export const UserProfileModal: UserProfileModalComponent = observer(
return (
<Modal.Root
size="medium"
centered
initialFocusRef={autoFocusNote ? noteRef : undefined}
className={userProfileModalStyles.modalRoot}
data-flx="user.user-profile-modal.modal-root"
@@ -30,7 +30,10 @@ import * as UserCommands from '@app/features/user/commands/UserCommands';
import * as UserProfileCommands from '@app/features/user/commands/UserProfileCommands';
import styles from '@app/features/user/components/modals/tabs/MyProfileTab.module.css';
import {AccentColorPicker} from '@app/features/user/components/modals/tabs/my_profile_tab/AccentColorPicker';
import {AvatarUploader} from '@app/features/user/components/modals/tabs/my_profile_tab/AvatarUploader';
import {
AvatarUploader,
useChangeAvatarAction,
} from '@app/features/user/components/modals/tabs/my_profile_tab/AvatarUploader';
import {BannerUploader} from '@app/features/user/components/modals/tabs/my_profile_tab/BannerUploader';
import {BIO_MARKDOWN_PARSER_FLAGS, BioEditor} from '@app/features/user/components/modals/tabs/my_profile_tab/BioEditor';
import {UsernameSection} from '@app/features/user/components/modals/tabs/my_profile_tab/MyProfileTabUsernameSection';
@@ -615,6 +618,7 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
form.setValue('avatar', null);
transitionAvatarAsset({type: 'asset.cleared'});
}, [form, transitionAvatarAsset]);
const {openChangeAvatar} = useChangeAvatarAction({onAvatarChange: handleAvatarChange});
const handleBannerChange = useCallback(
(base64: string) => {
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"
/>
</div>
@@ -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(() => (
<ConfirmModal
title={i18n._(ANIMATED_AVATARS_REQUIRE_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
description={
<>
<p data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.p">
<Trans>
Animated avatars ({ANIMATED_AVATAR_FORMATS}) are exclusively available to {PREMIUM_PRODUCT_NAME}{' '}
subscribers.
</Trans>
</p>
<p
className={styles.spacedParagraph}
data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.spaced-paragraph"
>
{RuntimeConfig.usesUniqueUsernames ? (
<Trans>
With {PREMIUM_PRODUCT_NAME}, you can use animated avatars ({ANIMATED_AVATAR_FORMATS}) and
profile banners, and unlock many other {PREMIUM_PRODUCT_NAME} perks.
</Trans>
) : (
<Trans>
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.
</Trans>
)}
</p>
</>
}
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(() => (
<GenericErrorModal
title={i18n._(ANIMATED_AVATARS_NOT_AVAILABLE_DESCRIPTOR)}
message={
<Trans>
Animated avatars ({ANIMATED_AVATAR_FORMATS}) are not available on this instance. Upload a static
image instead.
</Trans>
}
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(() => (
<AssetCropModal
assetType={AssetType.AVATAR}
imageUrl={base64}
onCropComplete={(croppedBlob) => {
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(() => (
<ConfirmModal
title={i18n._(ANIMATED_AVATARS_REQUIRE_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
description={
<>
<p data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.p">
<Trans>
Animated avatars ({ANIMATED_AVATAR_FORMATS}) are exclusively available to{' '}
{PREMIUM_PRODUCT_NAME} subscribers.
</Trans>
</p>
<p
className={styles.spacedParagraph}
data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.spaced-paragraph"
>
{RuntimeConfig.usesUniqueUsernames ? (
<Trans>
With {PREMIUM_PRODUCT_NAME}, you can use animated avatars ({ANIMATED_AVATAR_FORMATS}) and
profile banners, and unlock many other {PREMIUM_PRODUCT_NAME} perks.
</Trans>
) : (
<Trans>
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.
</Trans>
)}
</p>
</>
}
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(() => (
<GenericErrorModal
title={i18n._(ANIMATED_AVATARS_NOT_AVAILABLE_DESCRIPTOR)}
message={
<Trans>
Animated avatars ({ANIMATED_AVATAR_FORMATS}) are not available on this instance. Upload a static
image instead.
</Trans>
}
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(() => (
<AssetCropModal
assetType={AssetType.AVATAR}
imageUrl={base64}
onCropComplete={(croppedBlob) => {
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') {
@@ -65,6 +65,7 @@ interface ProfilePreviewProps {
showMessageButton?: boolean;
showPreviewLabel?: boolean;
previewCustomStatus?: CustomStatus | null;
onAvatarClick?: () => void;
}
export const ProfilePreview: React.FC<ProfilePreviewProps> = observer(
@@ -90,6 +91,7 @@ export const ProfilePreview: React.FC<ProfilePreviewProps> = observer(
showMessageButton = true,
showPreviewLabel = true,
previewCustomStatus,
onAvatarClick,
}) => {
const {i18n} = useLingui();
const previewOverrides = useMemo<ProfilePreviewOverrides>(
@@ -245,7 +247,7 @@ export const ProfilePreview: React.FC<ProfilePreviewProps> = observer(
avatarUrl={finalAvatarUrl}
hoverAvatarUrl={finalHoverAvatarUrl}
isClickable={true}
onAvatarClick={openMockProfile}
onAvatarClick={onAvatarClick ?? openMockProfile}
data-flx="user.profile.profile-preview.profile-card-banner"
/>
<UserProfileBadges