Complete client UI i18n migration
Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage.
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
|
||||
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
|
||||
@@ -60,6 +61,12 @@ import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useComposerHeightMirror } from "./height-mirror";
|
||||
import {
|
||||
resolveSendTooltipLabel,
|
||||
resolveSubmitAccessibilityLabel,
|
||||
resolveVoiceAccessibilityLabel,
|
||||
resolveVoiceTooltipText,
|
||||
} from "./labels";
|
||||
import { computeCanStartDictation } from "./state";
|
||||
|
||||
export interface AttachmentMenuItem {
|
||||
@@ -142,7 +149,6 @@ const MIN_INPUT_HEIGHT_DESKTOP = 46;
|
||||
const DEFAULT_MAX_INPUT_HEIGHT = 160;
|
||||
const MAX_INPUT_VIEWPORT_RATIO = 0.5;
|
||||
const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
|
||||
const ATTACHMENT_SHEET_HEADER: SheetHeader = { title: "Add attachment" };
|
||||
const ATTACHMENT_SHEET_SNAP_POINTS = ["34%", "45%"];
|
||||
|
||||
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
||||
@@ -259,18 +265,24 @@ function AttachmentDropdown({
|
||||
attachButtonStyle,
|
||||
renderAttachButtonIcon,
|
||||
attachmentMenuItems,
|
||||
addAttachmentLabel,
|
||||
}: {
|
||||
isConnected: boolean;
|
||||
disabled: boolean;
|
||||
attachButtonStyle: React.ComponentProps<typeof DropdownMenuTrigger>["style"];
|
||||
renderAttachButtonIcon: (input: { hovered?: boolean }) => React.ReactElement;
|
||||
attachmentMenuItems: AttachmentMenuItem[];
|
||||
addAttachmentLabel: string;
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
useDismissKeyboardOnOpen(isSheetOpen, isCompact);
|
||||
|
||||
const isButtonDisabled = !isConnected || disabled;
|
||||
const attachmentSheetHeader = useMemo<SheetHeader>(
|
||||
() => ({ title: addAttachmentLabel }),
|
||||
[addAttachmentLabel],
|
||||
);
|
||||
const handleOpenSheet = useCallback(() => {
|
||||
if (isButtonDisabled) return;
|
||||
setIsSheetOpen(true);
|
||||
@@ -306,7 +318,7 @@ function AttachmentDropdown({
|
||||
<>
|
||||
<Pressable
|
||||
disabled={isButtonDisabled}
|
||||
accessibilityLabel="Add attachment"
|
||||
accessibilityLabel={addAttachmentLabel}
|
||||
accessibilityRole="button"
|
||||
testID="message-input-attach-button"
|
||||
onPress={handleOpenSheet}
|
||||
@@ -315,7 +327,7 @@ function AttachmentDropdown({
|
||||
{renderMobileAttachButtonIcon}
|
||||
</Pressable>
|
||||
<AdaptiveModalSheet
|
||||
header={ATTACHMENT_SHEET_HEADER}
|
||||
header={attachmentSheetHeader}
|
||||
visible={isSheetOpen}
|
||||
onClose={handleCloseSheet}
|
||||
snapPoints={ATTACHMENT_SHEET_SNAP_POINTS}
|
||||
@@ -333,7 +345,7 @@ function AttachmentDropdown({
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger
|
||||
disabled={isButtonDisabled}
|
||||
accessibilityLabel="Add attachment"
|
||||
accessibilityLabel={addAttachmentLabel}
|
||||
accessibilityRole="button"
|
||||
testID="message-input-attach-button"
|
||||
style={attachButtonStyle}
|
||||
@@ -342,7 +354,7 @@ function AttachmentDropdown({
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>Add attachment</Text>
|
||||
<Text style={styles.tooltipText}>{addAttachmentLabel}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
@@ -429,49 +441,6 @@ function SendButtonContent({
|
||||
return <ThemedArrowUp size={buttonIconSize} uniProps={iconAccentForegroundMapping} />;
|
||||
}
|
||||
|
||||
function resolveSubmitAccessibilityLabel(input: {
|
||||
submitButtonAccessibilityLabel: string | undefined;
|
||||
canPressLoadingButton: boolean;
|
||||
defaultActionQueues: boolean;
|
||||
isAgentRunning: boolean;
|
||||
}): string {
|
||||
if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel;
|
||||
if (input.canPressLoadingButton) return "Interrupt agent";
|
||||
if (input.defaultActionQueues) return "Queue message";
|
||||
if (input.isAgentRunning) return "Send and interrupt";
|
||||
return "Send message";
|
||||
}
|
||||
|
||||
function resolveVoiceAccessibilityLabel(input: {
|
||||
isRealtimeVoiceForCurrentAgent: boolean;
|
||||
isMuted: boolean;
|
||||
isDictating: boolean;
|
||||
}): string {
|
||||
if (input.isRealtimeVoiceForCurrentAgent) {
|
||||
return input.isMuted ? "Unmute Voice mode" : "Mute Voice mode";
|
||||
}
|
||||
if (input.isDictating) return "Stop dictation";
|
||||
return "Start dictation";
|
||||
}
|
||||
|
||||
function resolveVoiceTooltipText(input: {
|
||||
isRealtimeVoiceForCurrentAgent: boolean;
|
||||
isMuted: boolean;
|
||||
}): string {
|
||||
if (input.isRealtimeVoiceForCurrentAgent) {
|
||||
return input.isMuted ? "Unmute voice" : "Mute voice";
|
||||
}
|
||||
return "Dictation";
|
||||
}
|
||||
|
||||
function resolveSendTooltipLabel(input: {
|
||||
submitButtonAccessibilityLabel: string | undefined;
|
||||
defaultActionQueues: boolean;
|
||||
}): string {
|
||||
if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel;
|
||||
return input.defaultActionQueues ? "Queue" : "Send";
|
||||
}
|
||||
|
||||
interface DesktopKeyPressContext {
|
||||
onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined;
|
||||
submitOnEnter: boolean;
|
||||
@@ -749,14 +718,16 @@ function MessageInputOverlay({
|
||||
function FocusHint({
|
||||
visible,
|
||||
focusInputKeys,
|
||||
label,
|
||||
}: {
|
||||
visible: boolean;
|
||||
focusInputKeys: ShortcutChord | null | undefined;
|
||||
label: string;
|
||||
}) {
|
||||
if (!visible || !focusInputKeys) return null;
|
||||
if (!visible || !focusInputKeys || !label.trim()) return null;
|
||||
return (
|
||||
<Text style={styles.focusHintText} pointerEvents="none">
|
||||
{formatShortcut(focusInputKeys[0], getShortcutOs())} to focus
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -812,9 +783,8 @@ function SendButtonTooltip({
|
||||
isSubmitLoading,
|
||||
submitIcon,
|
||||
buttonIconSize,
|
||||
submitButtonAccessibilityLabel,
|
||||
defaultActionQueues,
|
||||
sendKeys,
|
||||
sendTooltipLabel,
|
||||
}: {
|
||||
shouldShow: boolean;
|
||||
canPressLoadingButton: boolean;
|
||||
@@ -826,9 +796,8 @@ function SendButtonTooltip({
|
||||
isSubmitLoading: boolean;
|
||||
submitIcon: "arrow" | "return";
|
||||
buttonIconSize: number;
|
||||
submitButtonAccessibilityLabel: string | undefined;
|
||||
defaultActionQueues: boolean;
|
||||
sendKeys: ShortcutChord | null | undefined;
|
||||
sendTooltipLabel: string;
|
||||
}) {
|
||||
if (!shouldShow) return null;
|
||||
return (
|
||||
@@ -847,10 +816,7 @@ function SendButtonTooltip({
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<SendTooltipBody
|
||||
label={resolveSendTooltipLabel({ submitButtonAccessibilityLabel, defaultActionQueues })}
|
||||
sendKeys={sendKeys}
|
||||
/>
|
||||
<SendTooltipBody label={sendTooltipLabel} sendKeys={sendKeys} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -908,6 +874,7 @@ interface ToggleRealtimeVoiceContext {
|
||||
isAgentRunning: boolean;
|
||||
handleStopRealtimeVoice: () => Promise<unknown> | void;
|
||||
toast: { error: (msg: string) => void };
|
||||
interruptBeforeVoiceMessage: string;
|
||||
}
|
||||
|
||||
function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
|
||||
@@ -920,7 +887,7 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
|
||||
return;
|
||||
}
|
||||
if (ctx.isAgentRunning) {
|
||||
ctx.toast.error("Interrupt the agent before starting voice mode");
|
||||
ctx.toast.error(ctx.interruptBeforeVoiceMessage);
|
||||
return;
|
||||
}
|
||||
void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => {
|
||||
@@ -1199,7 +1166,7 @@ interface ResolvedMessageInputProps {
|
||||
onAddImages: ((images: ImageAttachment[]) => void) | undefined;
|
||||
client: DaemonClient | null;
|
||||
isReadyForDictation: boolean | undefined;
|
||||
placeholder: string;
|
||||
placeholder: string | undefined;
|
||||
autoFocus: boolean;
|
||||
autoFocusKey: string | undefined;
|
||||
disabled: boolean;
|
||||
@@ -1239,7 +1206,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
|
||||
onAddImages: props.onAddImages,
|
||||
client: props.client,
|
||||
isReadyForDictation: props.isReadyForDictation,
|
||||
placeholder: props.placeholder ?? "Message...",
|
||||
placeholder: props.placeholder,
|
||||
autoFocus: props.autoFocus ?? false,
|
||||
autoFocusKey: props.autoFocusKey,
|
||||
disabled: props.disabled ?? false,
|
||||
@@ -1308,6 +1275,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
inputWrapperStyle,
|
||||
attachmentSlot,
|
||||
} = resolveMessageInputProps(props);
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
const maxInputHeight = resolveMaxInputHeight(windowHeight);
|
||||
@@ -1564,12 +1532,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
isAgentRunning,
|
||||
handleStopRealtimeVoice,
|
||||
toast,
|
||||
interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"),
|
||||
});
|
||||
}, [
|
||||
disabled,
|
||||
handleStopRealtimeVoice,
|
||||
isAgentRunning,
|
||||
isConnected,
|
||||
t,
|
||||
toast,
|
||||
voice,
|
||||
voiceAgentId,
|
||||
@@ -1747,17 +1717,26 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
canPressLoadingButton,
|
||||
defaultActionQueues,
|
||||
isAgentRunning,
|
||||
t,
|
||||
});
|
||||
|
||||
const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({
|
||||
isRealtimeVoiceForCurrentAgent,
|
||||
isMuted: Boolean(voice?.isMuted),
|
||||
isDictating,
|
||||
t,
|
||||
});
|
||||
|
||||
const voiceTooltipText = resolveVoiceTooltipText({
|
||||
isRealtimeVoiceForCurrentAgent,
|
||||
isMuted: Boolean(voice?.isMuted),
|
||||
t,
|
||||
});
|
||||
|
||||
const sendTooltipLabel = resolveSendTooltipLabel({
|
||||
submitButtonAccessibilityLabel,
|
||||
defaultActionQueues,
|
||||
t,
|
||||
});
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
@@ -1854,9 +1833,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
ref={textInputRef}
|
||||
value={value}
|
||||
onChangeText={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
placeholder={placeholder ?? t("composer.placeholders.fallback")}
|
||||
uniProps={textInputPlaceholderColorMapping}
|
||||
accessibilityLabel="Message agent..."
|
||||
accessibilityLabel={t("composer.input.accessibilityLabel")}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
style={textInputStyle}
|
||||
@@ -1872,6 +1851,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
<FocusHint
|
||||
visible={isWeb && isPaneFocused && !isInputFocused && !value}
|
||||
focusInputKeys={focusInputKeys}
|
||||
label={t("composer.input.focusHint", {
|
||||
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1885,6 +1867,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
attachButtonStyle={attachButtonStyle}
|
||||
renderAttachButtonIcon={renderAttachButtonIcon}
|
||||
attachmentMenuItems={attachmentMenuItems}
|
||||
addAttachmentLabel={t("composer.input.addAttachment")}
|
||||
/>
|
||||
{leftContent}
|
||||
</View>
|
||||
@@ -1915,9 +1898,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
submitIcon={submitIcon}
|
||||
buttonIconSize={buttonIconSize}
|
||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||
defaultActionQueues={defaultActionQueues}
|
||||
sendKeys={sendKeys}
|
||||
sendTooltipLabel={sendTooltipLabel}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveSendTooltipLabel,
|
||||
resolveSubmitAccessibilityLabel,
|
||||
resolveVoiceAccessibilityLabel,
|
||||
resolveVoiceTooltipText,
|
||||
} from "./labels";
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
"composer.input.interruptAgent": "Interrupt agent",
|
||||
"composer.input.queueMessage": "Queue message",
|
||||
"composer.input.sendAndInterrupt": "Send and interrupt",
|
||||
"composer.input.sendMessage": "Send message",
|
||||
"composer.input.queue": "Queue",
|
||||
"composer.input.send": "Send",
|
||||
"composer.voice.unmuteVoiceMode": "Unmute Voice mode",
|
||||
"composer.voice.muteVoiceMode": "Mute Voice mode",
|
||||
"composer.voice.stopDictation": "Stop dictation",
|
||||
"composer.voice.startDictation": "Start dictation",
|
||||
"composer.voice.unmuteVoice": "Unmute voice",
|
||||
"composer.voice.muteVoice": "Mute voice",
|
||||
"composer.voice.dictation": "Dictation",
|
||||
};
|
||||
|
||||
const t = ((key: string) => translations[key] ?? key) as never;
|
||||
|
||||
describe("composer input labels", () => {
|
||||
it("resolves submit accessibility labels from translations", () => {
|
||||
expect(
|
||||
resolveSubmitAccessibilityLabel({
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
canPressLoadingButton: true,
|
||||
defaultActionQueues: false,
|
||||
isAgentRunning: true,
|
||||
t,
|
||||
}),
|
||||
).toBe("Interrupt agent");
|
||||
expect(
|
||||
resolveSubmitAccessibilityLabel({
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
canPressLoadingButton: false,
|
||||
defaultActionQueues: true,
|
||||
isAgentRunning: true,
|
||||
t,
|
||||
}),
|
||||
).toBe("Queue message");
|
||||
expect(
|
||||
resolveSubmitAccessibilityLabel({
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
canPressLoadingButton: false,
|
||||
defaultActionQueues: false,
|
||||
isAgentRunning: true,
|
||||
t,
|
||||
}),
|
||||
).toBe("Send and interrupt");
|
||||
expect(
|
||||
resolveSubmitAccessibilityLabel({
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
canPressLoadingButton: false,
|
||||
defaultActionQueues: false,
|
||||
isAgentRunning: false,
|
||||
t,
|
||||
}),
|
||||
).toBe("Send message");
|
||||
});
|
||||
|
||||
it("keeps explicit submit labels untouched", () => {
|
||||
expect(
|
||||
resolveSubmitAccessibilityLabel({
|
||||
submitButtonAccessibilityLabel: "Run now",
|
||||
canPressLoadingButton: false,
|
||||
defaultActionQueues: false,
|
||||
isAgentRunning: false,
|
||||
t,
|
||||
}),
|
||||
).toBe("Run now");
|
||||
});
|
||||
|
||||
it("resolves voice labels from translations", () => {
|
||||
expect(
|
||||
resolveVoiceAccessibilityLabel({
|
||||
isRealtimeVoiceForCurrentAgent: true,
|
||||
isMuted: true,
|
||||
isDictating: false,
|
||||
t,
|
||||
}),
|
||||
).toBe("Unmute Voice mode");
|
||||
expect(
|
||||
resolveVoiceAccessibilityLabel({
|
||||
isRealtimeVoiceForCurrentAgent: true,
|
||||
isMuted: false,
|
||||
isDictating: false,
|
||||
t,
|
||||
}),
|
||||
).toBe("Mute Voice mode");
|
||||
expect(
|
||||
resolveVoiceAccessibilityLabel({
|
||||
isRealtimeVoiceForCurrentAgent: false,
|
||||
isMuted: false,
|
||||
isDictating: true,
|
||||
t,
|
||||
}),
|
||||
).toBe("Stop dictation");
|
||||
expect(
|
||||
resolveVoiceAccessibilityLabel({
|
||||
isRealtimeVoiceForCurrentAgent: false,
|
||||
isMuted: false,
|
||||
isDictating: false,
|
||||
t,
|
||||
}),
|
||||
).toBe("Start dictation");
|
||||
});
|
||||
|
||||
it("resolves tooltip labels from translations", () => {
|
||||
expect(
|
||||
resolveVoiceTooltipText({
|
||||
isRealtimeVoiceForCurrentAgent: false,
|
||||
isMuted: false,
|
||||
t,
|
||||
}),
|
||||
).toBe("Dictation");
|
||||
expect(
|
||||
resolveSendTooltipLabel({
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
defaultActionQueues: true,
|
||||
t,
|
||||
}),
|
||||
).toBe("Queue");
|
||||
expect(
|
||||
resolveSendTooltipLabel({
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
defaultActionQueues: false,
|
||||
t,
|
||||
}),
|
||||
).toBe("Send");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
export function resolveSubmitAccessibilityLabel(input: {
|
||||
submitButtonAccessibilityLabel: string | undefined;
|
||||
canPressLoadingButton: boolean;
|
||||
defaultActionQueues: boolean;
|
||||
isAgentRunning: boolean;
|
||||
t: TFunction;
|
||||
}): string {
|
||||
if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel;
|
||||
if (input.canPressLoadingButton) return input.t("composer.input.interruptAgent");
|
||||
if (input.defaultActionQueues) return input.t("composer.input.queueMessage");
|
||||
if (input.isAgentRunning) return input.t("composer.input.sendAndInterrupt");
|
||||
return input.t("composer.input.sendMessage");
|
||||
}
|
||||
|
||||
export function resolveVoiceAccessibilityLabel(input: {
|
||||
isRealtimeVoiceForCurrentAgent: boolean;
|
||||
isMuted: boolean;
|
||||
isDictating: boolean;
|
||||
t: TFunction;
|
||||
}): string {
|
||||
if (input.isRealtimeVoiceForCurrentAgent) {
|
||||
return input.isMuted
|
||||
? input.t("composer.voice.unmuteVoiceMode")
|
||||
: input.t("composer.voice.muteVoiceMode");
|
||||
}
|
||||
if (input.isDictating) return input.t("composer.voice.stopDictation");
|
||||
return input.t("composer.voice.startDictation");
|
||||
}
|
||||
|
||||
export function resolveVoiceTooltipText(input: {
|
||||
isRealtimeVoiceForCurrentAgent: boolean;
|
||||
isMuted: boolean;
|
||||
t: TFunction;
|
||||
}): string {
|
||||
if (input.isRealtimeVoiceForCurrentAgent) {
|
||||
return input.isMuted
|
||||
? input.t("composer.voice.unmuteVoice")
|
||||
: input.t("composer.voice.muteVoice");
|
||||
}
|
||||
return input.t("composer.voice.dictation");
|
||||
}
|
||||
|
||||
export function resolveSendTooltipLabel(input: {
|
||||
submitButtonAccessibilityLabel: string | undefined;
|
||||
defaultActionQueues: boolean;
|
||||
t: TFunction;
|
||||
}): string {
|
||||
if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel;
|
||||
return input.defaultActionQueues
|
||||
? input.t("composer.input.queue")
|
||||
: input.t("composer.input.send");
|
||||
}
|
||||
Reference in New Issue
Block a user