fix(i18n): review translations and fix i18n library misuse (#2750)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-13 22:58:09 +02:00
1 parent 78f783f0c4
commit 8cb097f954
575 files changed
+192671 -160540

No files matched your search

@@ -24,7 +24,7 @@ import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
const YOU_CAN_T_ADD_NEW_REACTIONS_WHILE_YOU_DESCRIPTOR = msg({
message: "You can't add new reactions while you're on timeout.",
message: "You can't add new reactions while you're timed out.",
comment: 'Error message in the messaging commands.',
});
const logger = new Logger('MessageReactions');
@@ -15,7 +15,7 @@ const ATTACHMENT_UPLOAD_UNAVAILABLE_DESCRIPTOR = msg({
});
export const AttachmentUploadConnectivityModal = observer(() => {
const {i18n} = useLingui();
const fallbackSizeFormatted = formatFileSize(MULTIPART_ATTACHMENT_FALLBACK_MAX_REQUEST_SIZE);
const fallbackSizeFormatted = formatFileSize(i18n.locale, MULTIPART_ATTACHMENT_FALLBACK_MAX_REQUEST_SIZE);
return (
<GenericErrorModal
title={i18n._(ATTACHMENT_UPLOAD_UNAVAILABLE_DESCRIPTOR)}
@@ -42,7 +42,7 @@ const INSTANCE_ADMIN_LIMIT_DESCRIPTOR = msg({
});
const PREMIUM_FILE_SIZE_UPSELL_DESCRIPTOR = msg({
message:
'With {premiumProductName}, your per-file upload limit increases to {fileSizeLimit}, plus animated avatars, longer messages, and many other {premiumProductName} perks.',
'With {premiumProductName}, your per-file upload limit increases to {fileSizeLimit}. You also get animated avatars, longer messages, and many other {premiumProductName} perks.',
comment:
'Premium upsell sentence in the file-too-large alert. premiumProductName is the paid product name; fileSizeLimit is a localized file size.',
});
@@ -58,7 +58,7 @@ export const FileSizeTooLargeModal = observer(({oversizedFileCount}: FileSizeToo
const maxAttachmentFileSize = user?.maxAttachmentFileSize ?? 25 * 1024 * 1024;
const premiumMaxAttachmentFileSize = Limits.getPremiumValue('max_attachment_file_size', ATTACHMENT_MAX_SIZE_PREMIUM);
const canUpgradeAttachmentLimit = maxAttachmentFileSize < premiumMaxAttachmentFileSize;
const maxSizeFormatted = formatFileSize(maxAttachmentFileSize);
const maxSizeFormatted = formatFileSize(i18n.locale, maxAttachmentFileSize);
const hasKnownOversizedFileCount = oversizedFileCount != null;
const hasMultipleOversizedFiles = (oversizedFileCount ?? 0) > 1;
const handleGetPlutoniumClick = useCallback(() => {
@@ -85,7 +85,7 @@ export const FileSizeTooLargeModal = observer(({oversizedFileCount}: FileSizeToo
title={i18n._(FILE_SIZE_LIMIT_EXCEEDED_DESCRIPTOR)}
description={`${baseDescription} ${i18n._(PREMIUM_FILE_SIZE_UPSELL_DESCRIPTOR, {
premiumProductName: PREMIUM_PRODUCT_NAME,
fileSizeLimit: formatFileSize(premiumMaxAttachmentFileSize),
fileSizeLimit: formatFileSize(i18n.locale, premiumMaxAttachmentFileSize),
})}`}
primaryText={i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
primaryVariant="primary"
@@ -6,7 +6,7 @@ import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
const THAT_MESSAGE_DIDN_T_DELETE_DESCRIPTOR = msg({
message: "That message didn't delete",
message: "That message couldn't be deleted",
comment: 'Label in the message delete failed modal. Keep the tone plain and specific.',
});
const TRY_DELETING_THAT_MESSAGE_AGAIN_DESCRIPTOR = msg({
@@ -26,8 +26,8 @@ import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useMemo, useState} from 'react';
const FAILED_TO_LOAD_DESCRIPTOR = msg({
message: '(failed to load)',
const EMOJI_FAILED_TO_LOAD_DESCRIPTOR = msg({
message: '{emojiName} (failed to load)',
comment: 'Error message in the messaging emoji renderer.',
});
@@ -216,7 +216,7 @@ const EmojiRendererInner = observer(function EmojiRendererInner({
<img
draggable={false}
className={className}
alt={hasFailed ? `${emojiData.name} ${i18n._(FAILED_TO_LOAD_DESCRIPTOR)}` : emojiData.name}
alt={hasFailed ? i18n._(EMOJI_FAILED_TO_LOAD_DESCRIPTOR, {emojiName: emojiData.name}) : emojiData.name}
src={emojiUrl}
style={hasFailed ? FAILED_EMOJI_STYLE : undefined}
data-message-id={messageId}
@@ -40,6 +40,7 @@ const CHANNEL_LINK_DESCRIPTOR = msg({
message: 'channel link',
comment: 'Short label in the messaging mention renderer. Keep it concise.',
});
const UNKNOWN_MENTION_DESCRIPTOR = msg({message: 'unknown-mention'});
interface InteractiveChannelMentionProps {
channel: Channel;
@@ -366,7 +367,7 @@ export const MentionRenderer = observer(function MentionRenderer({
default:
return (
<span key={id} data-flx="messaging.markdown.renderers.mention-renderer.span--13">
{'<unknown-mention>'}
{`<${i18n._(UNKNOWN_MENTION_DESCRIPTOR)}>`}
</span>
);
}
@@ -31,7 +31,7 @@ export const TimestampRenderer = observer(function TimestampRenderer({
const relativeDisplayTime = useMemo(() => {
return isValidTimestamp ? formatTimestamp(timestamp, style, i18n) : '';
}, [tick, isValidTimestamp, timestamp, style, i18n.locale]);
const relativeTime = date !== null ? DateTime.fromJSDate(date).toRelative() : null;
const relativeTime = date !== null ? DateTime.fromJSDate(date).setLocale(locale).toRelative() : null;
if (date === null || fullDateTime === null) {
return React.createElement('span', {className: markupStyles.timestamp}, String(timestamp));
}
@@ -4,6 +4,7 @@ import * as Modal from '@app/features/app/components/dialogs/Modal';
import {cropAnimatedImageWithWorkerPool} from '@app/features/expressions/workers/AnimatedImageCropWorkerManager';
import {showMessagingErrorModal} from '@app/features/messaging/components/alerts/MessagingErrorModalUtils';
import styles from '@app/features/messaging/components/modals/ImageCropModal.module.css';
import {formatFileSize} from '@app/features/messaging/utils/FileUtils';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {Button} from '@app/features/ui/button/Button';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
@@ -28,6 +29,11 @@ const COULDN_T_CROP_IMAGE_DESCRIPTOR = msg({
message: "Couldn't crop image",
comment: 'Title of the error modal shown when image cropping fails.',
});
const CROPPED_IMAGE_IS_TOO_LARGE_PLEASE_CHOOSE_A_DESCRIPTOR = msg({
message: 'Cropped image is too large. Choose a smaller area or a smaller file (max {maxSizeLabel}).',
comment:
'Error modal body shown when a cropped image still exceeds the size limit. {maxSizeLabel} is a formatted file size.',
});
const logger = new Logger('ImageCropModal');
interface Point {
@@ -47,6 +53,16 @@ interface DragBoundaries {
right: number;
}
class CropSizeLimitError extends Error {
readonly maxBytes: number;
constructor(actualBytes: number, maxBytes: number) {
super(`Cropped image is ${actualBytes} bytes and exceeds the limit of ${maxBytes} bytes`);
this.name = 'CropSizeLimitError';
this.maxBytes = maxBytes;
}
}
interface AnimatedImageCropOptions {
x: number;
y: number;
@@ -285,7 +301,7 @@ async function exportStaticImage(
}, 'image/png');
});
if (blob.size > maxBytes) {
throw new Error(`Image size ${(blob.size / 1024).toFixed(1)} KB exceeds max ${(maxBytes / 1024).toFixed(0)} KB`);
throw new CropSizeLimitError(blob.size, maxBytes);
}
return blob;
}
@@ -343,9 +359,7 @@ async function exportAnimatedImage(
throw new Error('Empty animated image blob returned');
}
if (resultBlob.size > maxBytes) {
throw new Error(
`Animated image size ${(resultBlob.size / 1024).toFixed(1)} KB exceeds max ${(maxBytes / 1024).toFixed(0)} KB`,
);
throw new CropSizeLimitError(resultBlob.size, maxBytes);
}
return resultBlob;
}
@@ -663,7 +677,12 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
ModalCommands.pop();
} catch (error) {
logger.error('Error cropping image:', error);
const message = error instanceof Error && error.message ? error.message : errorMessage;
const message =
error instanceof CropSizeLimitError
? i18n._(CROPPED_IMAGE_IS_TOO_LARGE_PLEASE_CHOOSE_A_DESCRIPTOR, {
maxSizeLabel: formatFileSize(i18n.locale, error.maxBytes),
})
: errorMessage;
showMessagingErrorModal({
title: i18n._(COULDN_T_CROP_IMAGE_DESCRIPTOR),
message,
@@ -42,6 +42,7 @@ import LayerManager from '@app/features/ui/state/LayerManager';
import MobileLayout from '@app/features/ui/state/MobileLayout';
import OverlayStack from '@app/features/ui/state/OverlayStack';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import {formatRoundedPercentage} from '@app/features/ui/utils/PercentageFormatting';
import {MobileVideoViewer} from '@app/features/voice/components/modals/MobileVideoViewer';
import {useLingui} from '@lingui/react/macro';
import {CaretLeftIcon, CaretRightIcon} from '@phosphor-icons/react';
@@ -663,7 +664,7 @@ export const MediaModal: FC<MediaModalProps> = observer(
onPointerLeave={handleHudPointerLeave}
data-flx="messaging.media-modal.bottom-info-bar"
>
{[fileName, dimensions, fileSize, `${panZoomInfo.zoomPercent}%`]
{[fileName, dimensions, fileSize, formatRoundedPercentage(i18n.locale, panZoomInfo.zoomPercent)]
.filter(Boolean)
.map((part, index) => (
<span
@@ -523,7 +523,7 @@ const MediaViewerModalComponent: FC = observer(() => {
? `${currentItem.naturalWidth}×${currentItem.naturalHeight}`
: undefined;
const fileName = currentItem.filename || currentItem.originalSrc.split('/').pop()?.split('?')[0] || 'media';
const fileSize = currentItem.fileSize != null ? formatFileSize(currentItem.fileSize) : undefined;
const fileSize = currentItem.fileSize != null ? formatFileSize(i18n.locale, currentItem.fileSize) : undefined;
const expiryInfo =
currentItem.expiresAt && currentItem.expiresAt.length > 0
? {
@@ -26,7 +26,7 @@ const YOU_CAN_ADD_COMMENTS_BEFORE_UPLOADING_DIRECT_UPLOAD_DESCRIPTOR = msg({
comment: 'Helper text in the upload drop modal during slowmode, when direct upload is disabled.',
});
const YOU_CAN_ADD_COMMENTS_BEFORE_UPLOADING_HOLD_SHIFT_DESCRIPTOR = msg({
message: 'You can add comments before uploading. Hold shift to upload directly.',
message: 'You can add comments before uploading. Hold Shift to upload directly.',
comment: 'Helper text in the upload drop modal explaining the shift-to-direct-upload behavior.',
});
const DIRECT_UPLOAD_ACTIVE_DESCRIPTOR = msg({
@@ -95,7 +95,7 @@ export const ROTATE_CLOCKWISE_DESCRIPTOR = msg({
comment: 'Tooltip on the media viewer button that rotates the current media item 90 degrees clockwise.',
});
export const ROTATE_ANTICLOCKWISE_DESCRIPTOR = msg({
message: 'Rotate anticlockwise',
message: 'Rotate counterclockwise',
comment: 'Tooltip on the media viewer button that rotates the current media item 90 degrees anticlockwise.',
});
export const FORWARD_MEDIA_DESCRIPTOR = msg({
@@ -5,7 +5,7 @@ import type {MessageDescriptor} from '@lingui/core';
import {msg} from '@lingui/core/macro';
const AUTO_DETECT_LANGUAGE_DESCRIPTOR = msg({
message: 'Auto detect language',
message: 'Auto-detect language',
comment: 'Short label in the attachment preview utils helper. Keep it concise.',
});
const PLAIN_TEXT_DESCRIPTOR = msg({
@@ -1,9 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}`;
import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
const FILE_SIZE_UNIT_BYTES = 1024;
const FILE_SIZE_UNITS = ['byte', 'kilobyte', 'megabyte', 'gigabyte'] as const;
export function formatFileSize(locale: string, bytes: number): string {
if (!Number.isSafeInteger(bytes) || bytes < 0) {
throw new RangeError('File size must be a non-negative safe integer');
}
const unitIndex =
bytes === 0
? 0
: Math.min(Math.floor(Math.log(bytes) / Math.log(FILE_SIZE_UNIT_BYTES)), FILE_SIZE_UNITS.length - 1);
return getCachedNumberFormat(locale, {
style: 'unit',
unit: FILE_SIZE_UNITS[unitIndex],
unitDisplay: 'narrow',
maximumFractionDigits: 1,
}).format(bytes / FILE_SIZE_UNIT_BYTES ** unitIndex);
}
@@ -67,7 +67,7 @@ describe('reaction tooltip catalog', () => {
expect(render('nl', 'test', 1)).toBe('test reageerde met :100:');
expect(render('nl', 'test en alex', 2)).toBe('test en alex reageerden met :100:');
expect(render('pt-BR', 'test', 1)).toBe('test reagiu com :100:');
expect(render('de', 'test', 1)).toBe(':100: Reaktion von test');
expect(render('de', 'test', 1)).toBe('test hat mit :100: reagiert');
});
it('leaves English as the emoji-first phrasing the design intends', () => {