diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 5dec73ff..5f032806 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1274,6 +1274,10 @@ "Voice dictation is not configured": "Voice dictation is not configured", "Microphone is unavailable or already in use": "Microphone is unavailable or already in use", "Audio recording is not available in this browser/context": "Audio recording is not available in this browser/context", + "Dictation": "Dictation", + "Dictation becomes available once the page finishes connecting": "Dictation becomes available once the page finishes connecting", + "No connection to the collaboration server — dictation unavailable": "No connection to the collaboration server — dictation unavailable", + "This page is read-only": "This page is read-only", "Request format": "Request format", "How transcription requests are sent to the endpoint": "How transcription requests are sent to the endpoint", "OpenAI-compatible (multipart/form-data)": "OpenAI-compatible (multipart/form-data)", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index be16c5c9..1b3c5510 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -393,6 +393,17 @@ "No speech detected": "Речь не распознана", "Transcription failed": "Не удалось распознать речь", "Voice dictation is not configured": "Голосовой ввод не настроен", + "Start dictation": "Начать диктовку", + "Stop recording": "Остановить запись", + "Microphone access denied": "Доступ к микрофону запрещён", + "No microphone found": "Микрофон не найден", + "Microphone is unavailable or already in use": "Микрофон недоступен или уже используется", + "Could not start recording": "Не удалось начать запись", + "Audio recording is not available in this browser/context": "Запись аудио недоступна в этом браузере/контексте", + "Dictation": "Диктовка", + "Dictation becomes available once the page finishes connecting": "Диктовка станет доступна после подключения к документу", + "No connection to the collaboration server — dictation unavailable": "Нет связи с сервером совместного редактирования — диктовка недоступна", + "This page is read-only": "Страница открыта только для чтения", "Embed PDF": "Встроить PDF", "Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.", "Embed as PDF": "Встроить как PDF", diff --git a/apps/client/src/features/dictation/components/mic-button.test.tsx b/apps/client/src/features/dictation/components/mic-button.test.tsx new file mode 100644 index 00000000..17436a35 --- /dev/null +++ b/apps/client/src/features/dictation/components/mic-button.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; + +// A disabled mic must explain WHY it is unavailable rather than silently saying +// "Start dictation". This renders MicButton in its idle+disabled state with a +// forwarded reason and asserts the accessible label resolves to that reason's +// text via the shared resolver (dictation-status.resolveUnavailableLabel). + +// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts. + +// Pass i18n keys through verbatim so we assert the exact resolved string. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (s: string) => s }), +})); + +// Keep both controllers inert and idle so MicButton renders the idle branch. +const idleCtl = { + status: "idle" as const, + start: vi.fn(async () => {}), + stop: vi.fn(), + cancel: vi.fn(), + audioLevel: 0, + errorMessage: null, +}; +vi.mock("@/features/dictation/hooks/use-dictation", () => ({ + useDictation: () => idleCtl, +})); +vi.mock("@/features/dictation/hooks/use-streaming-dictation", () => ({ + useStreamingDictation: () => idleCtl, +})); + +import { MicButton } from "./mic-button"; + +function renderButton(props: React.ComponentProps) { + render( + + + , + ); +} + +describe("MicButton — disabled reason label", () => { + // jsdom has no MediaRecorder / mediaDevices, so isDictationSupported() would + // report "unsupported" and mask the forwarded reason. Stub both so the button + // is considered supported and the availability reason is what surfaces. + beforeEach(() => { + (globalThis as unknown as { MediaRecorder: unknown }).MediaRecorder = + class {}; + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: vi.fn() }, + }); + }); + afterEach(() => { + delete (globalThis as unknown as { MediaRecorder?: unknown }).MediaRecorder; + }); + + it("shows the cause-specific reason instead of 'Start dictation' when disabled with a reason", () => { + renderButton({ onText: () => {}, disabled: true, unavailableReason: "offline" }); + const expected = + "No connection to the collaboration server — dictation unavailable"; + // The reason surfaces as the accessible label (and the tooltip text). + const button = screen.getByRole("button", { name: expected }); + expect(button).toBeDefined(); + // It is marked disabled the Mantine way (data-disabled), NOT the native + // `disabled` attribute — otherwise pointer-events:none would kill the tooltip. + expect(button.getAttribute("data-disabled")).toBe("true"); + expect(button.hasAttribute("disabled")).toBe(false); + // And it no longer silently reads "Start dictation". + expect(screen.queryByRole("button", { name: "Start dictation" })).toBeNull(); + }); + + it("reads 'Start dictation' when enabled with no reason", () => { + renderButton({ onText: () => {} }); + expect( + screen.getByRole("button", { name: "Start dictation" }), + ).toBeDefined(); + }); + + it("does not advertise 'Start dictation' when disabled with no reason", () => { + // A consumer passing bare `disabled` (e.g. the AI chat's isStreaming) with no + // unavailableReason must not get a hoverable mic whose tooltip invites + // "Start dictation" on a click that is rejected. + renderButton({ onText: () => {}, disabled: true }); + expect( + screen.queryByRole("button", { name: "Start dictation" }), + ).toBeNull(); + const button = screen.getByRole("button"); + expect(button.getAttribute("data-disabled")).toBe("true"); + }); +}); diff --git a/apps/client/src/features/dictation/components/mic-button.tsx b/apps/client/src/features/dictation/components/mic-button.tsx index 70ead74e..532bab30 100644 --- a/apps/client/src/features/dictation/components/mic-button.tsx +++ b/apps/client/src/features/dictation/components/mic-button.tsx @@ -4,6 +4,11 @@ import { IconMicrophone, IconPlayerStopFilled } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useDictation } from "@/features/dictation/hooks/use-dictation"; import { useStreamingDictation } from "@/features/dictation/hooks/use-streaming-dictation"; +import { + isDictationSupported, + resolveUnavailableLabel, + type DictationUnavailableReason, +} from "@/features/dictation/dictation-status"; import classes from "./mic-button.module.css"; interface MicButtonProps { @@ -21,6 +26,9 @@ interface MicButtonProps { // When true, use the streaming (Silero-VAD) dictation controller, which emits // text progressively as the user pauses; otherwise use the batch controller. streaming?: boolean; + // When the mic is disabled for an availability reason, this is the cause the + // idle tooltip explains (e.g. pre-sync "connecting", "offline", "read-only"). + unavailableReason?: DictationUnavailableReason; } /** @@ -37,6 +45,7 @@ export const MicButton: FC = ({ color, iconSize, streaming = false, + unavailableReason, }) => { const { t } = useTranslation(); // Call BOTH hooks unconditionally to respect the rules of hooks: which one is @@ -46,7 +55,7 @@ export const MicButton: FC = ({ const batchCtl = useDictation({ onText, onStart }); const streamingCtl = useStreamingDictation({ onText, onStart }); const ctl = streaming ? streamingCtl : batchCtl; - const { status, start, stop, audioLevel } = ctl; + const { status, start, stop, audioLevel, errorMessage } = ctl; const resolvedIconSize = iconSize ?? (size === "lg" ? 18 : 16); if (status === "recording") { @@ -82,15 +91,28 @@ export const MicButton: FC = ({ ) { // "loading" (streaming hook fetching the VAD model on first use) shows the // same spinner+disabled state so the first click is visibly acknowledged and - // a confusing second click can't fire while the model loads. - const label = status === "loading" ? t("Preparing…") : t("Transcribing…"); + // a confusing second click can't fire while the model loads. The error case + // explains the failure via the hook's resolved errorMessage instead of the + // transient "Transcribing…" label. + const label = + status === "error" + ? (errorMessage ?? t("Transcription failed")) + : status === "loading" + ? t("Preparing…") + : t("Transcribing…"); return ( @@ -99,18 +121,56 @@ export const MicButton: FC = ({ ); } + // Idle branch. A grey/disabled mic must explain WHY it can't record. An + // unsupported browser/context is detected here; otherwise the parent forwards + // a cause-specific reason. We must NOT pass the native `disabled` prop: Mantine + // renders `