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

@@ -48,11 +48,11 @@ import type React from 'react';
const OPEN_IN_COMMUNITY_DESCRIPTOR = msg({
message: 'Open in community',
comment: 'Action that opens the selected user profile inside the current community.',
comment: 'Favorites menu action that opens the favorited channel in the community it belongs to.',
});
const MOVE_TO_DESCRIPTOR = msg({
message: 'Move to',
comment: 'Submenu label that moves the selected message to another channel.',
comment: 'Favorites menu submenu label that moves the favorited channel into another favorites category.',
});
interface FavoritesChannelContextMenuProps {
@@ -4,6 +4,7 @@ import styles from '@app/features/ui/action_menu/MenuItem.module.css';
import {Slider} from '@app/features/ui/components/Slider';
import {formatRoundedPercentage} from '@app/features/ui/utils/PercentageFormatting';
import {ContextMenu as BaseContextMenu} from '@base-ui/react/context-menu';
import {useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
import React, {useCallback, useEffect, useState} from 'react';
@@ -20,11 +21,12 @@ interface MenuItemSliderProps {
export const MenuItemSlider = React.forwardRef<HTMLDivElement, MenuItemSliderProps>(
({label, value, minValue = 0, maxValue = 100, disabled = false, onChange, onFormat, step = 1}, forwardedRef) => {
const {i18n} = useLingui();
const [localValue, setLocalValue] = useState(value);
useEffect(() => {
setLocalValue(value);
}, [value]);
const formattedValue = onFormat ? onFormat(localValue) : formatRoundedPercentage(localValue);
const formattedValue = onFormat ? onFormat(localValue) : formatRoundedPercentage(i18n.locale, localValue);
const handleValueChange = useCallback(
(newValue: number) => {
setLocalValue(newValue);
@@ -17,6 +17,7 @@ import {
COPY_TEXT_DESCRIPTOR,
OPEN_LINK_DESCRIPTOR,
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
import * as ReactionCommands from '@app/features/messaging/commands/ReactionCommands';
import {MessageReactionsModal} from '@app/features/messaging/components/modals/MessageReactionsModal';
import {ReactionImage} from '@app/features/messaging/components/ReactionImage';
@@ -41,6 +42,7 @@ import {MenuItemSubmenu} from '@app/features/ui/action_menu/MenuItemSubmenu';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands';
import {CTRL_DESCRIPTOR} from '@app/features/ui/keybind_hint/KeybindHint';
import type {MenuGroupType, MenuItemType} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import {openExternalUrl} from '@app/features/ui/utils/NativeUtils';
@@ -158,7 +160,7 @@ const RemoveReactionsSubmenuItem = observer(
icon={renderEmojiPreview()}
onClick={handleSelect}
closeOnSelect={false}
hint={`${reaction.count}`}
hint={getCachedNumberFormat(i18n.locale).format(reaction.count)}
data-flx="ui.action-menu.message-context-menu.remove-reactions-submenu-item.menu-item.select"
>
{label}
@@ -380,8 +382,8 @@ export const MessageContextMenu: React.FC<MessageContextMenuProps> = observer(
return () => document.removeEventListener('selectionchange', handleSelectionChange);
}, [restoreSelection, normalizeSelectionText]);
const copyShortcut = useMemo(() => {
return /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? '⌘C' : 'Ctrl+C';
}, []);
return /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? '⌘C' : `${i18n._(CTRL_DESCRIPTOR)}+C`;
}, [i18n.locale]);
const handleCopySelection = useCallback(async () => {
if (!selectionText) return;
await TextCopyCommands.copy(i18n, selectionText, true);
@@ -40,6 +40,7 @@ const ACTIONS_DESCRIPTOR = msg({
});
const MESSAGE_DESCRIPTOR = msg({
message: 'Message',
context: 'message-action',
comment: 'Action that opens a DM conversation with the selected user.',
});
const logger = new Logger('ActionsSubmenu');
@@ -103,7 +103,7 @@ import {
FAILED_TO_PIN_GROUP_DM_DESCRIPTOR,
FAILED_TO_UNPIN_DM_DESCRIPTOR,
FAILED_TO_UNPIN_GROUP_DM_DESCRIPTOR,
INVITE_SENT_TO_DESCRIPTOR,
INVITE_SENT_FOR_DESCRIPTOR,
PINNED_GROUP_DM_DESCRIPTOR,
UNPINNED_GROUP_DM_DESCRIPTOR,
USER_ID_COPIED_DESCRIPTOR,
@@ -328,7 +328,7 @@ export function useDMMenuData(
if (result) {
ToastCommands.createToast({
type: 'success',
children: i18n._(INVITE_SENT_TO_DESCRIPTOR, {guildName}),
children: i18n._(INVITE_SENT_FOR_DESCRIPTOR, {guildName}),
});
}
} catch (error) {
@@ -12,6 +12,7 @@ import GuildMembers from '@app/features/member/state/GuildMembers';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {SendInviteToCommunityIcon} from '@app/features/ui/action_menu/ContextMenuIcons';
import {INVITE_SENT_FOR_DESCRIPTOR} from '@app/features/ui/action_menu/items/dm_menu_data/shared';
import {
beginInviteToCommunityGuard,
getInviteToCommunityGuardKey,
@@ -24,7 +25,7 @@ import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
import type {User} from '@app/features/user/models/User';
import type {Invite} from '@fluxer/schema/src/domains/invite/InviteSchemas';
import {fromTimestamp} from '@fluxer/snowflake/src/SnowflakeUtils';
import {Trans, useLingui} from '@lingui/react/macro';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useMemo, useRef, useState} from 'react';
@@ -89,7 +90,7 @@ export const InviteToCommunityMenuItem: React.FC<InviteToCommunityMenuItemProps>
if (result) {
ToastCommands.createToast({
type: 'success',
children: <Trans>Invite sent to {candidate.guild.name}</Trans>,
children: i18n._(INVITE_SENT_FOR_DESCRIPTOR, {guildName: candidate.guild.name}),
});
}
} catch (error) {
@@ -104,7 +105,7 @@ export const InviteToCommunityMenuItem: React.FC<InviteToCommunityMenuItemProps>
scheduleInviteToCommunityGuardRelease(guardKey);
}
},
[onClose, user.id],
[onClose, user.id, i18n],
);
if (user.bot || candidates.length === 0) {
return null;
@@ -131,7 +131,7 @@ const DOWNLOAD_MEDIA_DESCRIPTOR = msg({
comment: 'Generic media context menu download action when the specific type is unknown.',
});
const ATTACHMENT_IS_EXPIRED_OR_UNAVAILABLE_DESCRIPTOR = msg({
message: 'Attachment is expired or unavailable',
message: 'Attachment has expired or is unavailable',
comment: 'Toast shown when an attachment URL is no longer accessible.',
});
const URL_COPIED_TO_CLIPBOARD_DESCRIPTOR = msg({
@@ -25,6 +25,7 @@ import {useCallback} from 'react';
const MESSAGE_DESCRIPTOR = msg({
message: 'Message',
context: 'message-action',
comment: 'Action that opens a DM conversation with the selected user.',
});
const logger = new Logger('MessageUserMenuItem');
@@ -14,7 +14,7 @@ import type React from 'react';
import {useCallback} from 'react';
const STAFF_DM_ACCESS_FAILED_TITLE_DESCRIPTOR = msg({
message: "Couldn't update staff DM access",
message: "Couldn't update direct message permission",
comment: 'Title of the error modal shown when toggling staff DM access fails.',
});
const STAFF_DM_ACCESS_FAILED_MESSAGE_DESCRIPTOR = msg({
@@ -33,9 +33,9 @@ const MENTION_BYPASS_FAILED_MESSAGE_DESCRIPTOR = msg({
function showStaffControlErrorModal(title: string, message: string, flxKey: string): void {
ModalCommands.push(modal(() => <GenericErrorModal title={title} message={message} data-flx={flxKey} />));
}
const ALLOW_STAFF_DM_ACCESS_DESCRIPTOR = msg({
message: 'Allow staff DM access',
comment: 'Staff setting that allows the Fluxer staff to message the selected user.',
const ALLOW_DIRECT_MESSAGES_WITH_THIS_USER_DESCRIPTOR = msg({
message: 'Allow direct messages with this user',
comment: 'Staff-only setting that allows the current staff member and selected user to exchange direct messages.',
});
const BYPASS_MENTION_SUPPRESSION_DESCRIPTOR = msg({
message: 'Bypass mention suppression',
@@ -106,7 +106,7 @@ export const StaffUserControlsMenuItems: React.FC<StaffUserControlsMenuItemsProp
onCheckedChange={handleStaffDmAccessChange}
data-flx="ui.action-menu.items.staff-user-controls-menu-items.checkbox-item"
>
{i18n._(ALLOW_STAFF_DM_ACCESS_DESCRIPTOR)}
{i18n._(ALLOW_DIRECT_MESSAGES_WITH_THIS_USER_DESCRIPTOR)}
</CheckboxItem>
<CheckboxItem
checked={mentionBypassUserIds.includes(user.id)}
@@ -105,8 +105,9 @@ import {
FOCUS_THIS_PERSON_DESCRIPTOR,
MENTION_DESCRIPTOR,
MESSAGE_DESCRIPTOR,
MUTE_DESCRIPTOR,
MUTE_DEVICE_DESCRIPTOR,
MUTE_MICROPHONE_DESCRIPTOR,
MUTE_PLAYBACK_DESCRIPTOR,
POP_OUT_CAMERA_DESCRIPTOR,
POP_OUT_USER_DESCRIPTOR,
PREVIEW_CAMERA_DESCRIPTOR,
@@ -128,6 +129,7 @@ import type {
MenuSliderType,
MenuSubmenuItemType,
} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet';
import {formatRoundedPercentage} from '@app/features/ui/utils/PercentageFormatting';
import * as UserProfileCommands from '@app/features/user/commands/UserProfileCommands';
import {ChangeNicknameModal} from '@app/features/user/components/modals/ChangeNicknameModal';
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
@@ -411,7 +413,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
MediaEngine.applyLocalAudioPreferencesForUser(user.id);
}
},
onFormat: (value: number) => `${Math.round(value)}%`,
onFormat: (value: number) => formatRoundedPercentage(i18n.locale, value),
factoryDefaultValue: 100,
});
const buildConnectionVolumeSlider = (targetConnectionId: string): MenuSliderType => ({
@@ -425,7 +427,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
MediaEngine.applyLocalAudioPreferencesForUser(user.id);
}
},
onFormat: (value: number) => `${Math.round(value)}%`,
onFormat: (value: number) => formatRoundedPercentage(i18n.locale, value),
factoryDefaultValue: 100,
});
const buildVolumeControls = (targetConnectionId?: string): Array<MenuSliderType> => {
@@ -449,7 +451,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
minValue: 0,
maxValue: 200,
onChange: (value: number) => EntranceSoundListenerPrefs.setVolume(user.id, value),
onFormat: (value: number) => `${Math.round(value)}%`,
onFormat: (value: number) => formatRoundedPercentage(i18n.locale, value),
factoryDefaultValue: 100,
},
],
@@ -771,7 +773,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.self-mute-icon--2"
/>
),
label: i18n._(MUTE_DESCRIPTOR),
label: i18n._(MUTE_MICROPHONE_DESCRIPTOR),
checked: isSelfMuted,
onChange: () => {
VoiceStateCommands.toggleSelfMute(null);
@@ -889,7 +891,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
data-flx="ui.action-menu.items.voice-participant-menu-data.menu-assembly.local-mute-icon"
/>
),
label: i18n._(MUTE_DESCRIPTOR),
label: i18n._(MUTE_PLAYBACK_DESCRIPTOR),
checked: isParticipantLocallyMuted,
onChange: (checked: boolean) => {
ParticipantVolume.setLocalMute(user.id, checked);
@@ -64,6 +64,7 @@ import {useCallback, useMemo} from 'react';
const MUTE_DESCRIPTOR = msg({
message: 'Mute',
context: 'voice-control-action',
comment: "Voice menu checkbox label. Mutes the current user's microphone, not what they hear.",
});
const TURN_OFF_STREAM_DESCRIPTOR = msg({
@@ -72,6 +73,7 @@ const TURN_OFF_STREAM_DESCRIPTOR = msg({
});
const MUTE_2_DESCRIPTOR = msg({
message: 'Mute',
context: 'playback-control-action',
comment: 'Voice menu checkbox label. Locally mutes another participant only for the current user.',
});
const DISABLE_VIDEO_LOCALLY_DESCRIPTOR = msg({
@@ -300,7 +302,6 @@ export const ParticipantVolumeSlider: React.FC<ParticipantVolumeSliderProps> = o
minValue={0}
maxValue={200}
onChange={handleChange}
onFormat={(value) => `${Math.round(value)}%`}
data-flx="ui.action-menu.items.voice-participant-menu-items.participant-volume-slider.menu-item-slider.change"
/>
);
@@ -358,7 +359,6 @@ export const EntranceSoundListenerSubmenu: React.FC<EntranceSoundListenerSubmenu
minValue={0}
maxValue={200}
onChange={handleVolumeChange}
onFormat={(value) => `${Math.round(value)}%`}
data-flx="ui.action-menu.items.voice-participant-menu-items.entrance-sound-volume-slider"
/>
</>
@@ -12,7 +12,7 @@ import {
import type {VoiceParticipantMenuScreenShareSource} from '@app/features/ui/action_menu/items/VoiceParticipantMenuTypes';
import {
CHANGE_STREAM_DESCRIPTOR,
MUTE_DESCRIPTOR,
MUTE_PLAYBACK_DESCRIPTOR,
PAUSE_OWN_STREAM_PREVIEW_DESCRIPTOR,
POP_OUT_STREAM_DESCRIPTOR,
SCREEN_SHARE_PRIVACY_DESCRIPTOR,
@@ -30,6 +30,7 @@ import type {
MenuSliderType,
MenuSubmenuItemType,
} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet';
import {formatRoundedPercentage} from '@app/features/ui/utils/PercentageFormatting';
import {copyVoiceDiagnostics} from '@app/features/voice/commands/VoiceDiagnosticsCommands';
import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands';
import {changeActiveScreenShare, stopActiveScreenShare} from '@app/features/voice/components/ActiveScreenShareMenu';
@@ -150,7 +151,7 @@ function buildStreamAudioActions(
data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.build-stream-audio-actions.local-mute-icon"
/>
),
label: i18n._(MUTE_DESCRIPTOR),
label: i18n._(MUTE_PLAYBACK_DESCRIPTOR),
checked: isStreamMuted,
onChange: (checked: boolean) => {
StreamAudioPrefs.setMuted(source.streamKey, checked);
@@ -166,7 +167,7 @@ function buildStreamAudioActions(
StreamAudioPrefs.setVolume(source.streamKey, value);
MediaEngine.applyLocalAudioPreferencesForUser(userId);
},
onFormat: (value: number) => `${Math.round(value)}%`,
onFormat: (value: number) => formatRoundedPercentage(i18n.locale, value),
factoryDefaultValue: 100,
},
];
@@ -2,9 +2,9 @@
import {msg} from '@lingui/core/macro';
export const INVITE_SENT_TO_DESCRIPTOR = msg({
message: 'Invite sent to {guildName}',
comment: 'Toast confirming an invite was sent to the named community.',
export const INVITE_SENT_FOR_DESCRIPTOR = msg({
message: 'Invite sent for {guildName}',
comment: 'Toast confirming a community invite was sent to a DM recipient. guildName is the community name.',
});
export const ARE_YOU_SURE_YOU_WANT_TO_CLOSE_YOUR_DESCRIPTOR = msg({
message: 'Close your DM with {recipientUsername}? You can reopen it anytime.',
@@ -33,11 +33,16 @@ export const SCREEN_SHARE_PRIVACY_DESCRIPTOR = msg({
message: 'Screen share privacy',
comment: 'Voice screen share menu action that opens the screen share preview privacy controls.',
});
export const MUTE_DESCRIPTOR = msg({
export const MUTE_MICROPHONE_DESCRIPTOR = msg({
message: 'Mute',
context: 'voice-control-action',
comment:
'Voice menu toggle label. Mutes the current user microphone or, for a stream, mutes that stream audio locally.',
});
export const MUTE_PLAYBACK_DESCRIPTOR = msg({
message: 'Mute',
context: 'playback-control-action',
});
export const UNFOCUS_DESCRIPTOR = msg({
message: 'Unfocus',
comment: 'Voice call layout action that removes focus from the currently pinned participant.',
@@ -72,6 +77,7 @@ export const MENTION_DESCRIPTOR = msg({
});
export const MESSAGE_DESCRIPTOR = msg({
message: 'Message',
context: 'message-action',
comment: 'Voice participant menu action that opens a DM conversation with the selected user.',
});
export const MUTE_DEVICE_DESCRIPTOR = msg({
@@ -19,6 +19,7 @@ import {useEffect, useState} from 'react';
const MUTED_VOICE_BADGE_LABEL = msg({
message: 'Muted',
context: 'microphone-state',
comment: 'Accessible label for the muted voice badge shown on an avatar.',
});
const DEAFENED_VOICE_BADGE_LABEL = msg({
@@ -3,12 +3,13 @@
import {msg} from '@lingui/core/macro';
export const CHARACTERS_LEFT_DESCRIPTOR = msg({
message: '{remaining} characters left',
message: '{remaining, plural, one {# character left} other {# characters left}}',
comment: 'Character counter status showing the number of characters remaining.',
});
export const CHARACTERS_LEFT_GET_TO_WRITE_UP_TO_CHARACTERS_DESCRIPTOR = msg({
message: '{remaining} characters left. Get {premiumProductName} to write up to {premiumMaxLength} characters.',
message:
'{remaining, plural, one {# character left} other {# characters left}}. Get {premiumProductName} to write up to {premiumMaxLength, plural, one {# character} other {# characters}}.',
comment: 'Character counter status with an upsell to the premium tier.',
});
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
import {CharacterCountAnnouncer} from '@app/features/ui/character_counter/CharacterCountAnnouncer';
import styles from '@app/features/ui/character_counter/CharacterCounter.module.css';
@@ -28,6 +29,7 @@ export const CharacterCounter = observer(
({currentLength, maxLength, canUpgrade, premiumMaxLength, onUpgradeClick, className}: CharacterCounterProps) => {
const {i18n} = useLingui();
const remaining = maxLength - currentLength;
const formattedRemaining = getCachedNumberFormat(i18n.locale).format(remaining);
const isOverLimit = remaining < 0;
const isNearingLimit = remaining < 50;
const showPremiumFeatures = shouldShowPremiumFeatures();
@@ -53,7 +55,7 @@ export const CharacterCounter = observer(
className={clsx(styles.counterButton, colorClass, className)}
>
<span aria-hidden="true" data-flx="ui.character-counter.character-counter.span">
{remaining}
{formattedRemaining}
</span>
</button>
</FocusRing>
@@ -65,7 +67,7 @@ export const CharacterCounter = observer(
data-flx="ui.character-counter.character-counter.counter-span"
className={clsx(styles.counterSpan, colorClass, className)}
>
{remaining}
{formattedRemaining}
</span>
</Tooltip>
);
@@ -48,7 +48,7 @@ function resolveMentionBadgeMotion(useReducedMotion: boolean): MentionBadgeMotio
const formatMentionCount = (mentionCount: number) => {
const locale = getCurrentLocale();
if (mentionCount > 99 && mentionCount < 1000) {
return '99+';
return `${formatNumber(99, locale)}+`;
}
if (mentionCount >= 1000) {
return formatCompactNumber(mentionCount, locale, 0).replace(/\s/g, '');
@@ -1,11 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getStatusTypeLabel} from '@app/features/app/constants/AppConstants';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import styles from '@app/features/ui/components/StatusIndicator.module.css';
import {StatusTypes} from '@fluxer/constants/src/StatusConstants';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import clsx from 'clsx';
import {memo} from 'react';
const STATUS_DESCRIPTOR = msg({message: '{effectiveStatusLabel} status'});
interface StatusIndicatorProps {
status: string;
size?: number;
@@ -17,7 +22,9 @@ interface StatusIndicatorProps {
const normalizeStatus = (status: string) => (status === StatusTypes.INVISIBLE ? StatusTypes.OFFLINE : status);
export const StatusIndicator = memo(
({status, size = 12, className, appearance = 'default', monochromeColor}: StatusIndicatorProps) => {
const {i18n} = useLingui();
const normalizedStatus = normalizeStatus(status);
const effectiveStatusLabel = getStatusTypeLabel(i18n, normalizedStatus);
const maskId = `flx-mask-presence-${normalizedStatus}`;
const fill =
appearance === 'monochrome' ? (monochromeColor ?? 'currentColor') : `var(--status-${normalizedStatus})`;
@@ -29,7 +36,7 @@ export const StatusIndicator = memo(
viewBox="0 0 1 1"
preserveAspectRatio="none"
aria-hidden={false}
aria-label={`status-${normalizedStatus}`}
aria-label={i18n._(STATUS_DESCRIPTOR, {effectiveStatusLabel})}
role="img"
data-flx="ui.status-indicator.display-block"
>
@@ -293,7 +293,7 @@ export const ColorPickerField: React.FC<ColorPickerFieldProps> = observer((props
className={clsx(styles.description, descriptionClassName)}
data-flx="ui.form.color-picker-field.description"
>
{description ?? <Trans>Type a color (hex, rgb(), hsl, or name), or use the picker.</Trans>}
{description ?? <Trans>Type a color (hex, rgb(), hsl(), or name) or use the picker.</Trans>}
</p>
)}
{showError && (
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getCachedDateTimeFormat} from '@app/features/i18n/utils/IntlCache';
import {PASSWORD_MANAGER_IGNORE_ATTRIBUTES} from '@app/features/platform/utils/PasswordManagerAutocomplete';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import styles from '@app/features/ui/components/form/DateTimePickerField.module.css';
@@ -9,7 +10,6 @@ import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {CalendarBlankIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {DateTime} from 'luxon';
import type React from 'react';
import {useCallback, useMemo, useState} from 'react';
import {Button, Dialog, DialogTrigger, Popover} from 'react-aria-components';
@@ -48,12 +48,11 @@ interface DateTimePickerFieldProps {
className?: string;
}
function formatDisplayDate(date: Date | null): string {
function formatDisplayDate(locale: string, date: Date | null): string {
if (!date) {
return '';
}
const dt = DateTime.fromJSDate(date);
return dt.toFormat('d LLL yyyy, HH:mm');
return getCachedDateTimeFormat(locale, {dateStyle: 'medium', timeStyle: 'short'}).format(date);
}
function toTimeString(date: Date): string {
@@ -64,7 +63,7 @@ export const DateTimePickerField: React.FC<DateTimePickerFieldProps> = (props) =
const {i18n} = useLingui();
const {label, description, value, onChange, minDate, maxDate, disabled, error, className} = props;
const [popoutOpen, setPopoutOpen] = useState(false);
const displayValue = useMemo(() => formatDisplayDate(value), [value]);
const displayValue = useMemo(() => formatDisplayDate(i18n.locale, value), [i18n.locale, value]);
const timeValue = useMemo(() => (value ? toTimeString(value) : '00:00'), [value]);
const handleDaySelect = useCallback(
(selected: Date | undefined) => {
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
import {shouldDisableAutofocusOnMobile} from '@app/features/platform/utils/AutofocusUtils';
import type {TextareaAutosizeProps} from '@app/features/platform/utils/AutoResizingTextarea';
import {TextareaAutosize} from '@app/features/platform/utils/AutoResizingTextarea';
@@ -372,7 +373,9 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
},
forwardedRef,
) => {
const {i18n} = useLingui();
const disableAutofocus = shouldDisableAutofocusOnMobile();
const numberFormat = getCachedNumberFormat(i18n.locale);
const currentValue = useMemo(() => value || '', [value]);
const currentLength = useMemo(() => String(currentValue).length, [currentValue]);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -437,7 +440,7 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
aria-hidden="true"
data-flx="ui.form.input.character-counter.character-count"
>
{currentLength}/{maxLength}
{numberFormat.format(currentLength)}/{numberFormat.format(maxLength)}
</span>
<CharacterCountAnnouncer
currentLength={currentLength}
@@ -446,7 +449,7 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
/>
</>
),
[showCharacterCount, maxLength, currentLength],
[showCharacterCount, maxLength, currentLength, numberFormat],
);
const labelRight = useMemo(
() => (
@@ -487,7 +490,7 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
aria-hidden="true"
data-flx="ui.form.input.textarea-with-actions.character-count"
>
{currentLength}/{maxLength}
{numberFormat.format(currentLength)}/{numberFormat.format(maxLength)}
</span>
<CharacterCountAnnouncer
currentLength={currentLength}
@@ -511,6 +514,7 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
maxLength,
characterCountTooltip,
currentLength,
numberFormat,
],
);
const simpleTextarea = useMemo(
@@ -4,17 +4,18 @@ import Accessibility from '@app/features/accessibility/state/Accessibility';
import type {KeybindCommand, KeyCombo} from '@app/features/input/state/InputKeybind';
import Keybind from '@app/features/input/state/InputKeybind';
import {
formatPrimaryKeySymbol,
getPrimaryKeyForComboDisplay,
isPrimaryKeyAlreadyRepresentedByModifier,
} from '@app/features/input/utils/KeybindUtils';
import {SHIFT_KEY_LABEL} from '@app/features/input/utils/KeyboardUtils';
import {SHIFT_KEY_DESCRIPTOR} from '@app/features/input/utils/KeyboardUtils';
import styles from '@app/features/ui/keybind_hint/KeybindHint.module.css';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
const CTRL_DESCRIPTOR = msg({
export const CTRL_DESCRIPTOR = msg({
message: 'Ctrl',
comment: 'Keyboard shortcut keycap label for Control on non-Mac platforms. Keep it short.',
});
@@ -75,7 +76,7 @@ const formatKeyParts = (i18n: I18n, combo: KeyCombo): Array<KeyPart> => {
parts.push(mac ? {label: '⌘', isSymbol: true} : {label: i18n._(WIN_DESCRIPTOR)});
}
if (combo.shift) {
parts.push({label: SHIFT_KEY_LABEL});
parts.push({label: i18n._(SHIFT_KEY_DESCRIPTOR)});
}
if (combo.alt) {
parts.push(mac ? {label: '⌥', isSymbol: true} : {label: i18n._(ALT_DESCRIPTOR)});
@@ -109,17 +110,17 @@ const formatKeyParts = (i18n: I18n, combo: KeyCombo): Array<KeyPart> => {
} else if (key === 'PageDown') {
parts.push({label: i18n._(PGDN_DESCRIPTOR)});
} else if (isShiftKeyName(key)) {
parts.push({label: SHIFT_KEY_LABEL});
parts.push({label: i18n._(SHIFT_KEY_DESCRIPTOR)});
} else if (/^Key[A-Z]$/.test(key)) {
parts.push({label: key.slice(3)});
} else if (/^Digit[0-9]$/.test(key)) {
parts.push({label: key.slice(5)});
} else if (/^Numpad[0-9]$/.test(key)) {
parts.push({label: `Numpad ${key.slice(6)}`});
parts.push({label: formatPrimaryKeySymbol(i18n, key)});
} else if (key.length === 1) {
parts.push({label: key.toUpperCase()});
} else if (key) {
parts.push({label: key});
parts.push({label: formatPrimaryKeySymbol(i18n, key)});
}
return parts;
};
@@ -54,6 +54,10 @@ const IS_NOW_YOUR_FRIEND_DESCRIPTOR = msg({
message: '{displayName} is now your friend!',
comment: 'Toast title announcing a newly accepted friend request.',
});
const GROUP_DM_DESCRIPTOR = msg({
message: 'Group DM',
comment: 'Fallback name shown in a desktop notification for a group DM that has no custom name.',
});
const logger = new Logger('Notification');
const shouldManagePushSubscriptions = (): boolean => isInstalledPwa();
@@ -335,13 +339,15 @@ class NotificationState {
}
}
break;
case ChannelTypes.GROUP_DM:
case ChannelTypes.GROUP_DM: {
const groupDmName = channel.name || i18n._(GROUP_DM_DESCRIPTOR);
if (useMacOSNotificationPresentation) {
subtitle = channel.name || 'Group DM';
subtitle = groupDmName;
} else {
title = `${title} (${channel.name || 'Group DM'})`;
title = `${title} (${groupDmName})`;
}
break;
}
}
const body = buildMessageNotificationBody(data.messageRecord, i18n);
const notificationUrl =
@@ -1,9 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
export function roundPercentage(value: number): number {
return Number.isFinite(value) ? Math.round(value) : 0;
}
export function formatRoundedPercentage(value: number): string {
return `${roundPercentage(value)}%`;
export function formatRoundedPercentage(locale: string, value: number): string {
return getCachedNumberFormat(locale, {style: 'percent', maximumFractionDigits: 0}).format(
roundPercentage(value) / 100,
);
}
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getShortRelativeTimeParts} from '@fluxer/date_utils/src/DateDuration';
import type {DateInput} from '@fluxer/date_utils/src/DateTypes';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
const NOW_DESCRIPTOR = msg({
message: 'now',
context: 'short-relative-time',
comment:
'Compact age label shown beside a list row (DM list, invite list) when the event happened less than a minute ago. Keep it as short as possible; it sits in a narrow column.',
});
const MINUTES_DESCRIPTOR = msg({
message: '{count, plural, one {#m} other {#m}}',
context: 'short-relative-time',
comment:
'Compact age label shown beside a list row (DM list, invite list). count is a number of minutes; the "m" is an abbreviation of "minutes". Keep it as short as possible; it sits in a narrow column.',
});
const HOURS_DESCRIPTOR = msg({
message: '{count, plural, one {#h} other {#h}}',
context: 'short-relative-time',
comment:
'Compact age label shown beside a list row (DM list, invite list). count is a number of hours; the "h" is an abbreviation of "hours". Keep it as short as possible; it sits in a narrow column.',
});
const DAYS_DESCRIPTOR = msg({
message: '{count, plural, one {#d} other {#d}}',
context: 'short-relative-time',
comment:
'Compact age label shown beside a list row (DM list, invite list). count is a number of days; the "d" is an abbreviation of "days". Keep it as short as possible; it sits in a narrow column.',
});
const WEEKS_DESCRIPTOR = msg({
message: '{count, plural, one {#w} other {#w}}',
context: 'short-relative-time',
comment:
'Compact age label shown beside a list row (DM list, invite list). count is a number of weeks; the "w" is an abbreviation of "weeks". Keep it as short as possible; it sits in a narrow column.',
});
const MONTHS_DESCRIPTOR = msg({
message: '{count, plural, one {#mo} other {#mo}}',
context: 'short-relative-time',
comment:
'Compact age label shown beside a list row (DM list, invite list). count is a number of months; the "mo" is an abbreviation of "months". Keep it as short as possible; it sits in a narrow column.',
});
const YEARS_DESCRIPTOR = msg({
message: '{count, plural, one {#y} other {#y}}',
context: 'short-relative-time',
comment:
'Compact age label shown beside a list row (DM list, invite list). count is a number of years; the "y" is an abbreviation of "years". Keep it as short as possible; it sits in a narrow column.',
});
export function formatShortRelativeTime(i18n: I18n, timestamp: DateInput, minUnit: '1m' | 'now' = 'now'): string {
const {unit, value} = getShortRelativeTimeParts(timestamp, minUnit);
switch (unit) {
case 'now':
return i18n._(NOW_DESCRIPTOR);
case 'minute':
return i18n._(MINUTES_DESCRIPTOR, {count: value});
case 'hour':
return i18n._(HOURS_DESCRIPTOR, {count: value});
case 'day':
return i18n._(DAYS_DESCRIPTOR, {count: value});
case 'week':
return i18n._(WEEKS_DESCRIPTOR, {count: value});
case 'month':
return i18n._(MONTHS_DESCRIPTOR, {count: value});
default:
return i18n._(YEARS_DESCRIPTOR, {count: value});
}
}