diff --git a/.gitignore b/.gitignore index 74f50bc2..69c6cd38 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ yarn-error.log* # Pre commit /.husky tsconfig.tsbuildinfo + +# private fork-only notes +/.fork-local diff --git a/src/components/message/MessageActions.tsx b/src/components/message/MessageActions.tsx index 1ca03b6e..cafc526b 100644 --- a/src/components/message/MessageActions.tsx +++ b/src/components/message/MessageActions.tsx @@ -1,5 +1,5 @@ import type React from "react"; -import { FaExpand, FaReply, FaTrash } from "react-icons/fa"; +import { FaExpand, FaLanguage, FaReply, FaTrash } from "react-icons/fa"; import type { MessageType } from "../../types"; import { MdAddReaction } from "./icons"; @@ -7,11 +7,14 @@ interface MessageActionsProps { message: MessageType; onReplyClick: () => void; onReactClick: (buttonElement: Element) => void; + onTranslateClick?: () => void; onRedactClick?: () => void; onOpenMedia?: () => void; canRedact?: boolean; canReply?: boolean; + canTranslate?: boolean; canOpenMedia?: boolean; + isTranslating?: boolean; inline?: boolean; } @@ -19,11 +22,14 @@ export const MessageActions: React.FC = ({ message, onReplyClick, onReactClick, + onTranslateClick, onRedactClick, onOpenMedia, canRedact = false, canReply = !!message.msgid, + canTranslate = false, canOpenMedia = false, + isTranslating = false, inline = false, }) => { return ( @@ -62,6 +68,20 @@ export const MessageActions: React.FC = ({ )} + {canTranslate && onTranslateClick && ( + + )} + ), + StandardReplyNotification: () => null, + SystemMessage: () => null, + WhisperMessage: () => null, + }; +}); + +import { MessageItem } from "../../../src/components/message/MessageItem"; +import type { MessageType } from "../../../src/types"; + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} + +const message: MessageType = { + id: "msg-1", + msgid: "abc123", + type: "message", + content: "hello world", + timestamp: new Date("2026-04-22T12:00:00Z"), + userId: "alice", + channelId: "channel-1", + serverId: "server-1", + reactions: [], + replyMessage: null, + mentioned: [], +}; + +describe("MessageItem translation", () => { + beforeEach(() => { + mockAvailability.mockReset(); + mockDetectLanguage.mockReset(); + mockSourceLanguage.mockReset(); + mockTranslate.mockReset(); + mockTargetLanguage.mockReset(); + mockState.globalSettings.translationTargetLanguage = "es"; + mockAvailability.mockResolvedValue("available"); + mockDetectLanguage.mockResolvedValue("fr-CA"); + mockSourceLanguage.mockReturnValue("en"); + mockTranslate.mockResolvedValue("hola mundo"); + mockTargetLanguage.mockReturnValue("es"); + }); + + test("uses the explicit target language setting and renders translated output", async () => { + render( + , + ); + + fireEvent.click( + screen.getByRole("button", { name: /translate from actions/i }), + ); + + await waitFor(() => { + expect(screen.getByText("hola mundo")).toBeInTheDocument(); + }); + + expect(mockTargetLanguage).toHaveBeenCalledWith("es"); + expect(mockTranslate).toHaveBeenCalledWith( + expect.objectContaining({ + sourceLanguage: "en", + targetLanguage: "es", + text: "hello world", + }), + ); + }); + + test("detects the source language when the message has no language tag", async () => { + mockState.globalSettings.translationTargetLanguage = ""; + mockSourceLanguage.mockReturnValue(null); + mockTargetLanguage.mockReturnValue("pt-BR"); + + render( + , + ); + + fireEvent.click( + screen.getByRole("button", { name: /translate from actions/i }), + ); + + await waitFor(() => { + expect(mockDetectLanguage).toHaveBeenCalledWith( + expect.objectContaining({ text: "hello world" }), + ); + }); + expect(mockTranslate).toHaveBeenCalledWith( + expect.objectContaining({ + sourceLanguage: "fr-CA", + targetLanguage: "pt-BR", + }), + ); + }); + + test("ignores stale translation results when a newer request starts", async () => { + const firstTranslation = createDeferred(); + const secondTranslation = createDeferred(); + + mockTranslate + .mockReturnValueOnce(firstTranslation.promise) + .mockReturnValueOnce(secondTranslation.promise); + + render( + , + ); + + const translateButton = screen.getByRole("button", { + name: /translate from actions/i, + }); + + fireEvent.click(translateButton); + + await waitFor(() => { + expect(mockTranslate).toHaveBeenCalledTimes(1); + }); + + fireEvent.click(translateButton); + + await waitFor(() => { + expect(mockTranslate).toHaveBeenCalledTimes(2); + }); + + firstTranslation.resolve("hola viejo"); + + await waitFor(() => { + expect(screen.queryByText("hola viejo")).not.toBeInTheDocument(); + }); + + secondTranslation.resolve("hola nuevo"); + + await waitFor(() => { + expect(screen.getByText("hola nuevo")).toBeInTheDocument(); + }); + + expect(screen.queryByText("hola viejo")).not.toBeInTheDocument(); + }); +}); diff --git a/tests/lib/browserTranslation.test.ts b/tests/lib/browserTranslation.test.ts new file mode 100644 index 00000000..762bd4d3 --- /dev/null +++ b/tests/lib/browserTranslation.test.ts @@ -0,0 +1,285 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + canUseBrowserTranslation, + detectMessageSourceLanguage, + getBrowserTranslationAvailability, + getMessageSourceLanguage, + getPreferredTranslationTargetLanguage, + getPreferredTranslationTargetLanguageFromSetting, + normalizeTranslationLanguageTag, + translateWithBrowser, +} from "../../src/lib/browserTranslation"; + +const originalNavigatorDescriptor = Object.getOwnPropertyDescriptor( + window, + "navigator", +); + +function setSecureContext(value: boolean) { + Object.defineProperty(window, "isSecureContext", { + configurable: true, + value, + }); +} + +function setNavigatorLanguages(language: string, languages: string[]) { + Object.defineProperty(window, "navigator", { + configurable: true, + value: { + ...window.navigator, + language, + languages, + }, + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + setSecureContext(true); + if (originalNavigatorDescriptor) { + Object.defineProperty(window, "navigator", originalNavigatorDescriptor); + } +}); + +beforeEach(() => { + setSecureContext(true); +}); + +describe("browserTranslation", () => { + test("preserves and canonicalizes full BCP 47 language tags", () => { + expect(normalizeTranslationLanguageTag("en-US")).toBe("en-US"); + expect(normalizeTranslationLanguageTag("zh-hant")).toBe("zh-Hant"); + expect(normalizeTranslationLanguageTag(undefined)).toBeNull(); + }); + + test("derives preferred target language from navigator", () => { + setNavigatorLanguages("pt-BR", ["pt-BR", "en-US"]); + + expect(getPreferredTranslationTargetLanguage()).toBe("pt-BR"); + }); + + test("prefers an explicit target language setting over navigator", () => { + setNavigatorLanguages("pt-BR", ["pt-BR", "en-US"]); + + expect(getPreferredTranslationTargetLanguageFromSetting("es-MX")).toBe( + "es-MX", + ); + }); + + test("falls back to the browser language when the setting is empty", () => { + setNavigatorLanguages("pt-BR", ["pt-BR", "en-US"]); + + expect(getPreferredTranslationTargetLanguageFromSetting("")).toBe("pt-BR"); + }); + + test("derives source language from message tags without forcing english", () => { + expect( + getMessageSourceLanguage({ + "+draft/language": "fr-CA", + }), + ).toBe("fr-CA"); + expect(getMessageSourceLanguage()).toBeNull(); + }); + + test("detects source language with the browser language detector", async () => { + const detect = vi + .fn() + .mockResolvedValue([{ detectedLanguage: "pt-BR", confidence: 0.91 }]); + const destroy = vi.fn(); + + vi.stubGlobal("LanguageDetector", { + create: vi.fn().mockResolvedValue({ detect, destroy }), + }); + + await expect( + detectMessageSourceLanguage({ text: "ola mundo como voce esta hoje" }), + ).resolves.toBe("pt-BR"); + expect(destroy).toHaveBeenCalledOnce(); + }); + + test("returns null for short text because language detection would be unreliable", async () => { + const detect = vi.fn(); + + vi.stubGlobal("LanguageDetector", { + create: vi.fn().mockResolvedValue({ detect, destroy: vi.fn() }), + }); + + await expect( + detectMessageSourceLanguage({ text: "hola mundo" }), + ).resolves.toBe(null); + expect(detect).not.toHaveBeenCalled(); + }); + + test("returns null for low-confidence language detection results", async () => { + const detect = vi + .fn() + .mockResolvedValue([{ detectedLanguage: "pt-BR", confidence: 0.2 }]); + const destroy = vi.fn(); + + vi.stubGlobal("LanguageDetector", { + create: vi.fn().mockResolvedValue({ detect, destroy }), + }); + + await expect( + detectMessageSourceLanguage({ text: "ola mundo como voce esta hoje" }), + ).resolves.toBeNull(); + expect(destroy).toHaveBeenCalledOnce(); + }); + + test("returns null for undetermined language detection results", async () => { + const detect = vi + .fn() + .mockResolvedValue([{ detectedLanguage: "und", confidence: 0.99 }]); + const destroy = vi.fn(); + + vi.stubGlobal("LanguageDetector", { + create: vi.fn().mockResolvedValue({ detect, destroy }), + }); + + await expect( + detectMessageSourceLanguage({ text: "ola mundo como voce esta hoje" }), + ).resolves.toBeNull(); + expect(destroy).toHaveBeenCalledOnce(); + }); + + test("returns null when language detection is unavailable", async () => { + vi.stubGlobal("LanguageDetector", undefined); + + await expect( + detectMessageSourceLanguage({ + text: "hola mundo desde una prueba larga", + }), + ).resolves.toBeNull(); + }); + + test("reports unsupported when Translator is missing", async () => { + setSecureContext(true); + vi.stubGlobal("Translator", undefined); + + expect(canUseBrowserTranslation()).toBe(false); + await expect( + getBrowserTranslationAvailability({ + sourceLanguage: "en", + targetLanguage: "es", + }), + ).resolves.toBe("unsupported"); + }); + + test("reports insecure-context before touching Translator", async () => { + const availability = vi.fn(); + setSecureContext(false); + vi.stubGlobal("Translator", { availability, create: vi.fn() }); + + expect(canUseBrowserTranslation()).toBe(false); + await expect( + getBrowserTranslationAvailability({ + sourceLanguage: "en", + targetLanguage: "es", + }), + ).resolves.toBe("insecure-context"); + expect(availability).not.toHaveBeenCalled(); + }); + + test("maps native availability values", async () => { + setSecureContext(true); + vi.stubGlobal("Translator", { + availability: vi.fn().mockResolvedValue("downloadable"), + create: vi.fn(), + }); + + await expect( + getBrowserTranslationAvailability({ + sourceLanguage: "en", + targetLanguage: "es", + }), + ).resolves.toBe("downloadable"); + }); + + test("returns unavailable for same-language requests", async () => { + const availability = vi.fn(); + setSecureContext(true); + vi.stubGlobal("Translator", { + availability, + create: vi.fn(), + }); + + await expect( + getBrowserTranslationAvailability({ + sourceLanguage: "en", + targetLanguage: "en", + }), + ).resolves.toBe("unavailable"); + expect(availability).not.toHaveBeenCalled(); + }); + + test("creates, translates, reports progress, and destroys", async () => { + const addEventListener = vi.fn( + ( + _event: string, + listener: (event: Pick) => void, + ) => { + listener({ loaded: 0.5 }); + }, + ); + const destroy = vi.fn(); + const translate = vi.fn().mockResolvedValue("hola"); + const create = vi.fn().mockResolvedValue({ translate, destroy }); + const progress = vi.fn(); + + setSecureContext(true); + vi.stubGlobal("Translator", { + availability: vi.fn(), + create, + }); + + await expect( + translateWithBrowser({ + sourceLanguage: "en", + targetLanguage: "es", + text: "hello", + onDownloadProgress: progress, + }), + ).resolves.toBe("hola"); + + expect(create).toHaveBeenCalledOnce(); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + sourceLanguage: "en", + targetLanguage: "es", + monitor: expect.any(Function), + }), + ); + + const [{ monitor }] = create.mock.calls[0]; + monitor({ addEventListener } as unknown as EventTarget); + + expect(addEventListener).toHaveBeenCalledWith( + "downloadprogress", + expect.any(Function), + ); + expect(progress).toHaveBeenCalledWith(0.5); + expect(translate).toHaveBeenCalledWith("hello", { signal: undefined }); + expect(destroy).toHaveBeenCalledOnce(); + }); + + test("still destroys the translator when translation fails", async () => { + const destroy = vi.fn(); + const translate = vi.fn().mockRejectedValue(new Error("boom")); + + setSecureContext(true); + vi.stubGlobal("Translator", { + availability: vi.fn(), + create: vi.fn().mockResolvedValue({ translate, destroy }), + }); + + await expect( + translateWithBrowser({ + sourceLanguage: "en", + targetLanguage: "es", + text: "hello", + }), + ).rejects.toThrow("boom"); + expect(destroy).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/store/index.test.ts b/tests/store/index.test.ts index 08dce17f..f6dd909a 100644 --- a/tests/store/index.test.ts +++ b/tests/store/index.test.ts @@ -58,6 +58,15 @@ describe("Store", () => { const newTheme = useStore.getState().ui.isDarkMode; expect(newTheme).not.toBe(initialTheme); }); + + test("should update translation target language", () => { + const { updateGlobalSettings } = useStore.getState(); + + updateGlobalSettings({ translationTargetLanguage: "es" }); + expect(useStore.getState().globalSettings.translationTargetLanguage).toBe( + "es", + ); + }); }); describe("server selection", () => { diff --git a/tests/store/join.test.ts b/tests/store/join.test.ts index 935bfaad..a34b3547 100644 --- a/tests/store/join.test.ts +++ b/tests/store/join.test.ts @@ -38,6 +38,7 @@ function setupServer(channelOverrides: Partial = {}) { globalSettings: { showEvents: true, showJoinsParts: true, + translationTargetLanguage: "", }, } as unknown as AppState); } diff --git a/tests/store/localStorage.test.ts b/tests/store/localStorage.test.ts index 67df7fe7..26d872f8 100644 --- a/tests/store/localStorage.test.ts +++ b/tests/store/localStorage.test.ts @@ -72,6 +72,12 @@ describe("settings.load migration", () => { // Old flag is left as-is when not migrating (no need to clean it up) }); + test("preserves explicit translation target language", () => { + mockStored({ translationTargetLanguage: "es" }); + const result = settings.load(); + expect(result.translationTargetLanguage).toBe("es"); + }); + test("returns empty object on invalid JSON", () => { vi.mocked(window.localStorage.getItem).mockReturnValue("not-json"); const result = settings.load();