From 31601dd83c81e7122dcf595fd5463157509a3212 Mon Sep 17 00:00:00 2001 From: MaxDac <25101798+MaxDac@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:44:32 +0100 Subject: [PATCH 1/2] Add five-language website settings and API validation localization Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6c41f680-5231-4682-a852-fd0830939bfe --- README.md | 15 + apps/privee_web/assets/.prettierignore | 2 + apps/privee_web/assets/e2e/locale.e2e.mjs | 118 ++++ apps/privee_web/assets/js/app.js | 6 +- .../privee_web/assets/js/hooks/chat-hooks.mjs | 12 +- .../assets/js/hooks/flash-hooks.mjs | 2 +- apps/privee_web/assets/js/tests/chat.test.mjs | 27 +- .../assets/js/tests/clipboard.test.mjs | 33 + .../js/tests/dark-mode-switcher.test.mjs | 26 +- .../assets/js/tests/gettext-fixture.mjs | 44 ++ .../assets/js/tests/locale.test.mjs | 96 +++ .../privee_web/assets/js/tests/mock-utils.mjs | 8 +- .../js/tests/push-notification.test.mjs | 26 + apps/privee_web/assets/js/utils/chat.mjs | 88 +-- apps/privee_web/assets/js/utils/clipboard.mjs | 10 +- .../assets/js/utils/dark-mode-switcher.mjs | 14 + apps/privee_web/assets/js/utils/locale.mjs | 93 +++ .../assets/js/utils/push-notifications.mjs | 8 +- apps/privee_web/lib/privee_web.ex | 1 + .../lib/privee_web/app/session_controller.ex | 6 +- .../privee_web/lib/privee_web/client_texts.ex | 62 ++ .../privee_web/components/core_components.ex | 40 +- .../lib/privee_web/components/layouts.ex | 30 +- .../components/layouts/menu.html.heex | 133 ++-- .../components/layouts/root.html.heex | 33 +- .../lib/privee_web/controllers/error_html.ex | 6 +- .../controllers/session_controller.ex | 17 +- .../controllers/session_share_controller.ex | 4 +- apps/privee_web/lib/privee_web/endpoint.ex | 1 + .../lib/privee_web/live/chat/chat_live.ex | 8 +- .../privee_web/live/chat/chat_live.html.heex | 21 +- .../live/chat/components/chat_input.html.heex | 11 +- .../live/privee_selector_live.html.heex | 20 +- .../lib/privee_web/live/session_login_live.ex | 6 +- .../live/session_login_live.html.heex | 36 +- .../live/session_registration_live.ex | 2 +- .../live/session_registration_live.html.heex | 67 +- apps/privee_web/lib/privee_web/locale.ex | 88 +++ .../lib/privee_web/locale_helpers.ex | 17 + apps/privee_web/lib/privee_web/locale_live.ex | 59 ++ apps/privee_web/lib/privee_web/router.ex | 4 + .../privee_web/lib/privee_web/session_auth.ex | 8 +- apps/privee_web/priv/gettext/default.pot | 595 ++++++++++++++++++ .../priv/gettext/en/LC_MESSAGES/default.po | 278 ++++++++ .../priv/gettext/en/LC_MESSAGES/errors.po | 160 ++--- apps/privee_web/priv/gettext/errors.pot | 21 + .../priv/gettext/es/LC_MESSAGES/default.po | 278 ++++++++ .../priv/gettext/es/LC_MESSAGES/errors.po | 116 ++++ .../priv/gettext/fr/LC_MESSAGES/default.po | 278 ++++++++ .../priv/gettext/fr/LC_MESSAGES/errors.po | 116 ++++ .../priv/gettext/it/LC_MESSAGES/default.po | 278 ++++++++ .../priv/gettext/it/LC_MESSAGES/errors.po | 116 ++++ .../priv/gettext/pt-PT/LC_MESSAGES/default.po | 278 ++++++++ .../priv/gettext/pt-PT/LC_MESSAGES/errors.po | 116 ++++ .../controllers/session_controller_test.exs | 2 +- .../test/privee_web/live/chat_live_test.exs | 2 +- .../live/session_login_live_test.exs | 6 +- .../test/privee_web/locale_test.exs | 191 ++++++ docs/client-api.md | 15 + 59 files changed, 3841 insertions(+), 313 deletions(-) create mode 100644 apps/privee_web/assets/e2e/locale.e2e.mjs create mode 100644 apps/privee_web/assets/js/tests/gettext-fixture.mjs create mode 100644 apps/privee_web/assets/js/tests/locale.test.mjs create mode 100644 apps/privee_web/assets/js/utils/locale.mjs create mode 100644 apps/privee_web/lib/privee_web/client_texts.ex create mode 100644 apps/privee_web/lib/privee_web/locale.ex create mode 100644 apps/privee_web/lib/privee_web/locale_helpers.ex create mode 100644 apps/privee_web/lib/privee_web/locale_live.ex create mode 100644 apps/privee_web/priv/gettext/default.pot create mode 100644 apps/privee_web/priv/gettext/en/LC_MESSAGES/default.po create mode 100644 apps/privee_web/priv/gettext/es/LC_MESSAGES/default.po create mode 100644 apps/privee_web/priv/gettext/es/LC_MESSAGES/errors.po create mode 100644 apps/privee_web/priv/gettext/fr/LC_MESSAGES/default.po create mode 100644 apps/privee_web/priv/gettext/fr/LC_MESSAGES/errors.po create mode 100644 apps/privee_web/priv/gettext/it/LC_MESSAGES/default.po create mode 100644 apps/privee_web/priv/gettext/it/LC_MESSAGES/errors.po create mode 100644 apps/privee_web/priv/gettext/pt-PT/LC_MESSAGES/default.po create mode 100644 apps/privee_web/priv/gettext/pt-PT/LC_MESSAGES/errors.po create mode 100644 apps/privee_web/test/privee_web/locale_test.exs diff --git a/README.md b/README.md index 544a5963..53b9138b 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,21 @@ Chats are end-to-end encrypted with the Signal Protocol. See manual release checklist, and the [E2EE audit](docs/security/e2ee-audit.md) for the independent review of the web, server and Android implementations. +## Language + +Open **Settings > Language** before or after signing in to choose English, +Italian, European Portuguese, Spanish or French. English is the default, +regardless of browser language. The preference is stored in a browser-local +cookie for one year, independently of your session, and survives login/logout. +It is not synced to other browsers or the Android app. + +Switching language updates the current page without navigating or restarting +encryption. Form drafts, messages and local chat history are preserved; existing +validation errors are cleared and subsequent validation uses the new language. +Messages, session names, recovery phrases and safety numbers are never translated. +Server and browser-owned UI share the Gettext catalogs in +`apps/privee_web/priv/gettext`. + ## Run your own server Anyone can run their own Privee server on Fly.io: fork diff --git a/apps/privee_web/assets/.prettierignore b/apps/privee_web/assets/.prettierignore index ac7c4a0f..c75f7e3c 100644 --- a/apps/privee_web/assets/.prettierignore +++ b/apps/privee_web/assets/.prettierignore @@ -1 +1,3 @@ vendor/libsignal-wasm/ +playwright-report/ +test-results/ diff --git a/apps/privee_web/assets/e2e/locale.e2e.mjs b/apps/privee_web/assets/e2e/locale.e2e.mjs new file mode 100644 index 00000000..1733f69d --- /dev/null +++ b/apps/privee_web/assets/e2e/locale.e2e.mjs @@ -0,0 +1,118 @@ +import { test, expect } from "@playwright/test" +import { + waitForLiveView, + registerUser, + openChat, + send, + expectMessage, + expectNoPlaintextOnTheWire, +} from "./helpers.mjs" + +test("language switching preserves registration/login drafts, cookie and live navigation", async ({ + page, +}) => { + await page.goto("/") + await waitForLiveView(page) + await page.locator("#session_recovery_phrase").fill("a draft citation that must remain unchanged") + const name = await page.locator("#session_session_name").inputValue() + await page.locator("#settings-toggle").click() + for (const locale of ["it", "pt-PT", "es", "fr", "en"]) { + await page.locator("#language-select").selectOption(locale) + await expect(page.locator("html")).toHaveAttribute("lang", locale) + await expect(page.locator("#session_recovery_phrase")).toHaveValue( + "a draft citation that must remain unchanged", + ) + await expect(page.locator("#session_session_name")).toHaveValue(name) + await expect(page).toHaveURL(/\/$/) + } + await page.locator("#language-select").selectOption("fr") + await expect(page.locator("html")).toHaveAttribute("lang", "fr") + await page.locator("a[href='/login']").first().click() + await expect(page.locator("html")).toHaveAttribute("lang", "fr") + await page.locator("#session_session_name").fill("login-draft") + await page.locator("#session_recovery_phrase").fill("login phrase") + await page.locator("#session_remember_me").check() + await page.locator("#settings-toggle").click() + await page.locator("#language-select").selectOption("it") + await expect(page.locator("html")).toHaveAttribute("lang", "it") + await expect(page.locator("#session_session_name")).toHaveValue("login-draft") + await expect(page.locator("#session_recovery_phrase")).toHaveValue("login phrase") + await expect(page.locator("#session_remember_me")).toBeChecked() + await page.reload() + await expect(page.locator("html")).toHaveAttribute("lang", "it") + await page.locator("a[href='/']").first().click() + await waitForLiveView(page) + await page + .locator("#session_recovery_phrase") + .fill("a new valid recovery phrase preserved in its own language") + await page.locator("#registration_form button[name=action]").click() + await page.waitForURL(/\/privee$/) + await expect(page.locator("html")).toHaveAttribute("lang", "it") + await page.locator("#privee_form_session_name").fill("contact-draft") + await page.locator("#settings-toggle").click() + await page.locator("#language-select").selectOption("pt-PT") + await expect(page.locator("html")).toHaveAttribute("lang", "pt-PT") + await expect(page.locator("#privee_form_session_name")).toHaveValue("contact-draft") + await page.locator("a[href='/sessions/log_out']").click() + await expect(page.locator("html")).toHaveAttribute("lang", "pt-PT") +}) + +test("switches encrypted chat in place without replacing plaintext, draft or encryption state", async ({ + browser, +}) => { + const alice = await registerUser(browser, "locale-alice") + const bob = await registerUser(browser, "locale-bob") + try { + await openChat(alice, bob) + await openChat(bob, alice) + const message = "a private message never translated" + const draft = "a private composer draft" + await send(alice, message) + await expectMessage(alice, message) + await expectMessage(bob, message) + for (let i = 0; i < 12; i++) { + await send(alice, `scrollable history ${i} ${"long private text ".repeat(60)}`) + } + await expectMessage(alice, `scrollable history 11 ${"long private text ".repeat(60)}`) + await alice.page.locator("#chat-text").fill(draft) + await alice.page.locator("#chat-safety-number").click() + const safety = await alice.page.locator("#chat-banner-safety .font-mono").textContent() + if (!safety) throw new Error("The safety number was not rendered") + const url = alice.page.url() + const scrolling = await alice.page.locator("#chat-screen").evaluate((el) => { + el.scrollTop = 0 + return el.scrollHeight > el.clientHeight + }) + expect(scrolling, "the history is actually scrollable").toBe(true) + await alice.page.locator("#settings-toggle").click() + for (const locale of ["it", "pt-PT", "es", "fr", "en"]) { + await alice.page.locator("#language-select").selectOption(locale) + await expect(alice.page.locator("html")).toHaveAttribute("lang", locale) + await expect(alice.page.locator("#chat-text")).toHaveValue(draft) + await expect(alice.page.locator("#chat-text")).toBeEnabled() + await expect(alice.page.locator("#chat-banner-safety .font-mono")).toHaveText(safety) + await expectMessage(alice, message) + expect(await alice.page.locator("#chat-screen").evaluate((el) => el.scrollTop)).toBe(0) + expect(alice.page.url()).toBe(url) + } + await alice.page.locator("#language-select").selectOption("fr") + await expect(alice.page.locator("html")).toHaveAttribute("lang", "fr") + await alice.page.evaluate(() => { + // @ts-ignore - LiveSocket is exposed by the bundled app. + window.liveSocket.disconnect() + // @ts-ignore - LiveSocket is exposed by the bundled app. + window.liveSocket.connect() + }) + await waitForLiveView(alice.page) + await expect(alice.page.locator("html")).toHaveAttribute("lang", "fr") + await expect(alice.page.locator("#chat-text")).toHaveValue(draft) + await alice.page.locator("#chat-send").click() + await expectMessage(bob, draft) + expectNoPlaintextOnTheWire([alice, bob], [message, draft]) + await alice.page.locator("a[href='/sessions/log_out']").click() + await expect(alice.page.locator("html")).toHaveAttribute("lang", "fr") + } finally { + await alice.context.close() + await bob.context.close() + } +}) diff --git a/apps/privee_web/assets/js/app.js b/apps/privee_web/assets/js/app.js index 41bc0cb2..72df7bf1 100644 --- a/apps/privee_web/assets/js/app.js +++ b/apps/privee_web/assets/js/app.js @@ -30,6 +30,8 @@ import topbar from "../vendor/topbar" // Import only the core functionality that's needed on every page import { addBackEndEventHandlers } from "./hooks/event-handlers.mjs" import { addFlashAutoHideHook } from "./hooks/flash-hooks.mjs" +import { addLocaleHook, currentLocale } from "./utils/locale.mjs" +import { addThemeEventHandlers } from "./utils/dark-mode-switcher.mjs" import { createLazyChatScreenHook, createLazyRegistrationScreenHook, @@ -41,6 +43,8 @@ const Hooks = {} // Flash hook is used on all pages - load immediately addFlashAutoHideHook(Hooks) +addLocaleHook(Hooks) +addThemeEventHandlers() // Lazy-load page-specific hooks (loaded only when their pages are visited) Hooks.ChatScreen = createLazyChatScreenHook() @@ -52,7 +56,7 @@ const csrfToken = /** @type {HTMLMetaElement} */ ( ).getAttribute("content") const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, - params: { _csrf_token: csrfToken }, + params: () => ({ _csrf_token: csrfToken, locale: currentLocale() }), hooks: Hooks, }) diff --git a/apps/privee_web/assets/js/hooks/chat-hooks.mjs b/apps/privee_web/assets/js/hooks/chat-hooks.mjs index 180d8afd..8afd7176 100644 --- a/apps/privee_web/assets/js/hooks/chat-hooks.mjs +++ b/apps/privee_web/assets/js/hooks/chat-hooks.mjs @@ -1,4 +1,5 @@ -import { ChatController, Texts } from "../utils/chat.mjs" +import { ChatController } from "../utils/chat.mjs" +import { clientText } from "../utils/locale.mjs" import { addSessionNameCopyListener } from "../utils/clipboard.mjs" import { addDarkModeToggleHandlers } from "../utils/dark-mode-switcher.mjs" import { UnsupportedBrowserError } from "../utils/signal-locks.mjs" @@ -36,7 +37,7 @@ export const addChatHooks = (Hooks) => { } catch (e) { showStartupError( this.el, - e instanceof UnsupportedBrowserError ? Texts.unsupported : Texts.failedToStart, + e instanceof UnsupportedBrowserError ? "unsupported" : "failedToStart", ) console.error("Unable to open the Signal store", e) return @@ -76,9 +77,9 @@ export const addChatHooks = (Hooks) => { /** * @param {HTMLElement} el - * @param {string} message + * @param {string} key */ -const showStartupError = (el, message) => { +const showStartupError = (el, key) => { const container = el.ownerDocument.getElementById("chat-banner") if (!container) return const banner = el.ownerDocument.createElement("div") @@ -86,6 +87,7 @@ const showStartupError = (el, message) => { banner.setAttribute("role", "alert") banner.className = "my-2 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-900 dark:border-red-700 dark:bg-red-950 dark:text-red-100" - banner.textContent = message + banner.dataset.clientText = key + banner.textContent = clientText(key, el.ownerDocument) container.replaceChildren(banner) } diff --git a/apps/privee_web/assets/js/hooks/flash-hooks.mjs b/apps/privee_web/assets/js/hooks/flash-hooks.mjs index 03acaf61..65e1e4ee 100644 --- a/apps/privee_web/assets/js/hooks/flash-hooks.mjs +++ b/apps/privee_web/assets/js/hooks/flash-hooks.mjs @@ -36,7 +36,7 @@ export const pushFlash = (pushEvent) => (kind, message, title = null) => new Promise((res, _rej) => { - pushEvent(flashEventName, { kind, message, title }, (reply) => { + pushEvent(flashEventName, { kind: kind.toLowerCase(), message, title }, (reply) => { res(reply) }) }) diff --git a/apps/privee_web/assets/js/tests/chat.test.mjs b/apps/privee_web/assets/js/tests/chat.test.mjs index ee3e207b..5258632a 100644 --- a/apps/privee_web/assets/js/tests/chat.test.mjs +++ b/apps/privee_web/assets/js/tests/chat.test.mjs @@ -3,13 +3,16 @@ import "./signal-wasm-setup.mjs" import { IDBFactory } from "fake-indexeddb" import { JSDOM } from "jsdom" import { describe, it, expect, beforeEach, vi } from "vitest" -import { ChatController, Texts } from "../utils/chat.mjs" +import { ChatController } from "../utils/chat.mjs" +import { catalogTexts, installCatalog } from "./gettext-fixture.mjs" +import { refreshClientTexts } from "../utils/locale.mjs" import { SignalClient } from "../utils/signal-client.mjs" import { createMemoryLocks } from "../utils/signal-locks.mjs" import { FakeServer } from "./signal-fake-server.mjs" const ALICE = 1 const BOB = 2 +const Texts = catalogTexts() const layout = (/** @type {string} */ epoch) => `
@@ -58,6 +61,7 @@ const appendEntry = (doc, message) => { const party = async (server, ownId, peerId, factory = new IDBFactory()) => { const dom = new JSDOM(`${layout(server.currentEpoch(ownId, peerId))}`) const doc = dom.window.document + installCatalog(doc) const client = await SignalClient.open({ ownId, push: server.connect(ownId, peerId), @@ -109,6 +113,27 @@ describe("ChatController", () => { ) }) + it("switches existing client presentation without reinitializing keys or scrolling", async () => { + const alice = await party(server, ALICE, BOB) + await alice.controller.start() + alice.controller.showBanner("notice", alice.controller.texts.noPeerKeys) + const input = /** @type {HTMLInputElement} */ (alice.doc.getElementById("chat-text")) + input.value = "draft stays private" + alice.controller.el.scrollTop = 123 + const ensureKeys = vi.spyOn(alice.client, "ensureKeys") + installCatalog(alice.doc, "it") + refreshClientTexts(alice.doc) + await alice.controller.processEntries() + expect(alice.controller.el.scrollTop).toBe(123) + expect(input.value).toBe("draft stays private") + expect(input.disabled).toBe(false) + expect(ensureKeys).not.toHaveBeenCalled() + expect(alice.doc.getElementById("chat-banner-notice")?.textContent).toContain( + catalogTexts("it").noPeerKeys, + ) + await alice.client.close() + }) + it("sends from the composer and renders both sides", async () => { const alice = await party(server, ALICE, BOB) const bob = await party(server, BOB, ALICE) diff --git a/apps/privee_web/assets/js/tests/clipboard.test.mjs b/apps/privee_web/assets/js/tests/clipboard.test.mjs index 5011693a..e55c2f95 100644 --- a/apps/privee_web/assets/js/tests/clipboard.test.mjs +++ b/apps/privee_web/assets/js/tests/clipboard.test.mjs @@ -1,5 +1,6 @@ import { describe, it, expect, vi, afterEach } from "vitest" import { JSDOM } from "jsdom" +import { installCatalog } from "./gettext-fixture.mjs" // Mock the flash-hooks module to avoid phoenix_live_view dependency vi.mock("../hooks/flash-hooks.mjs", () => ({ @@ -12,6 +13,24 @@ import { testExports, } from "../utils/clipboard.mjs" +describe("clipboard language changes", () => { + afterEach(() => vi.unstubAllGlobals()) + + it("uses the latest Gettext presentation without changing copied session content", async () => { + const dom = new JSDOM("", { url: "https://example.com" }) + installCatalog(dom.window.document) + vi.stubGlobal("window", dom.window) + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal("navigator", { clipboard: { writeText } }) + const flash = vi.fn().mockResolvedValue(undefined) + const handler = testExports.createCopyButtonHandler(flash) + installCatalog(dom.window.document, "it") + await handler.call({ dataset: { sessionName: "original-session-name", action: "code" } }) + expect(writeText).toHaveBeenCalledWith("original-session-name") + expect(flash).toHaveBeenCalledWith("Info", "Sessione copiata") + }) +}) + describe("copySessionNameToClipboardBackEndEventHandler", () => { afterEach(() => { vi.unstubAllGlobals() @@ -76,6 +95,7 @@ describe("getSessionLoginMarkdownLink", () => { it("generates correct URL with basic inputs", () => { const dom = new JSDOM("", { url: "https://example.com" }) + installCatalog(dom.window.document) vi.stubGlobal("window", dom.window) const result = testExports.getSessionLoginMarkdownLink("abc123", "Login Here") @@ -84,6 +104,7 @@ describe("getSessionLoginMarkdownLink", () => { it("properly encodes special characters in session code", () => { const dom = new JSDOM("", { url: "https://example.com" }) + installCatalog(dom.window.document) vi.stubGlobal("window", dom.window) const result = testExports.getSessionLoginMarkdownLink("test@#$%", "Session Link") @@ -92,6 +113,7 @@ describe("getSessionLoginMarkdownLink", () => { it("handles different host origins", () => { const dom = new JSDOM("", { url: "http://localhost:4000" }) + installCatalog(dom.window.document) vi.stubGlobal("window", dom.window) const result = testExports.getSessionLoginMarkdownLink("session123", "Dev Link") @@ -100,6 +122,7 @@ describe("getSessionLoginMarkdownLink", () => { it("handles empty title (title parameter ignored)", () => { const dom = new JSDOM("", { url: "https://example.com" }) + installCatalog(dom.window.document) vi.stubGlobal("window", dom.window) const result = testExports.getSessionLoginMarkdownLink("abc123", "") @@ -118,6 +141,7 @@ describe("createCopyButtonHandler", () => { const mockWriteText = vi.fn().mockResolvedValue(undefined) const dom = new JSDOM("", { url: "https://example.com" }) + installCatalog(dom.window.document) vi.stubGlobal("window", dom.window) vi.stubGlobal("navigator", { clipboard: { @@ -147,6 +171,7 @@ describe("createCopyButtonHandler", () => { const mockWriteText = vi.fn().mockResolvedValue(undefined) const dom = new JSDOM("", { url: "https://example.com" }) + installCatalog(dom.window.document) vi.stubGlobal("window", dom.window) vi.stubGlobal("navigator", { clipboard: { @@ -176,6 +201,7 @@ describe("createCopyButtonHandler", () => { const mockWriteText = vi.fn().mockResolvedValue(undefined) const dom = new JSDOM("", { url: "https://example.com" }) + installCatalog(dom.window.document) vi.stubGlobal("window", dom.window) vi.stubGlobal("navigator", { clipboard: { @@ -205,6 +231,7 @@ describe("createCopyButtonHandler", () => { const mockWriteText = vi.fn().mockResolvedValue(undefined) const dom = new JSDOM("", { url: "https://example.com" }) + installCatalog(dom.window.document) vi.stubGlobal("window", dom.window) vi.stubGlobal("navigator", { clipboard: { @@ -253,6 +280,7 @@ describe("addSessionNameCopyListener - Original Test Behavior", () => { const dom = new JSDOM(buttonHtml, { url: "https://example.com" }) vi.stubGlobal("document", dom.window.document) + installCatalog(dom.window.document) vi.stubGlobal("window", dom.window) vi.stubGlobal("navigator", { ...dom.window.navigator, @@ -293,6 +321,7 @@ describe("addSessionNameCopyListener", () => { const dom = new JSDOM(buttonHtml, { url: "https://example.com" }) vi.stubGlobal("document", dom.window.document) + installCatalog(dom.window.document) vi.stubGlobal("window", dom.window) vi.stubGlobal("navigator", { ...dom.window.navigator, @@ -328,6 +357,7 @@ describe("addSessionNameCopyListener", () => { const dom = new JSDOM(buttonHtml, { url: "https://example.com" }) vi.stubGlobal("document", dom.window.document) + installCatalog(dom.window.document) vi.stubGlobal("window", dom.window) vi.stubGlobal("navigator", { ...dom.window.navigator, @@ -363,6 +393,7 @@ describe("addSessionNameCopyListener", () => { const dom = new JSDOM(buttonHtml, { url: "https://example.com" }) vi.stubGlobal("document", dom.window.document) + installCatalog(dom.window.document) vi.stubGlobal("window", dom.window) vi.stubGlobal("navigator", { ...dom.window.navigator, @@ -400,6 +431,7 @@ describe("addSessionNameCopyListener", () => { const dom = new JSDOM(buttonHtml, { url: "https://example.com" }) vi.stubGlobal("document", dom.window.document) + installCatalog(dom.window.document) vi.stubGlobal("window", dom.window) vi.stubGlobal("navigator", { ...dom.window.navigator, @@ -442,6 +474,7 @@ describe("addSessionNameCopyListener", () => { const dom = new JSDOM(buttonHtml, { url: "https://example.com" }) vi.stubGlobal("document", dom.window.document) + installCatalog(dom.window.document) vi.stubGlobal("window", dom.window) vi.stubGlobal("navigator", { ...dom.window.navigator, diff --git a/apps/privee_web/assets/js/tests/dark-mode-switcher.test.mjs b/apps/privee_web/assets/js/tests/dark-mode-switcher.test.mjs index 1ccc4655..ddc35bcc 100644 --- a/apps/privee_web/assets/js/tests/dark-mode-switcher.test.mjs +++ b/apps/privee_web/assets/js/tests/dark-mode-switcher.test.mjs @@ -1,5 +1,9 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest" -import { addDarkModeToggleHandlers, setStartupTheme } from "../utils/dark-mode-switcher.mjs" +import { + addDarkModeToggleHandlers, + setStartupTheme, + addThemeEventHandlers, +} from "../utils/dark-mode-switcher.mjs" import { getDom } from "./mock-utils.mjs" const html = ` @@ -62,6 +66,26 @@ beforeEach(() => { }) describe("setStartupTheme", () => { + it("preserves cross-tab and LiveView theme events from the bundled script", () => { + const dom = getDom(html) + vi.stubGlobal("window", dom.window) + vi.stubGlobal("document", dom.window.document) + vi.stubGlobal("localStorage", dom.window.localStorage) + addThemeEventHandlers() + dom.window.dispatchEvent( + new dom.window.StorageEvent("storage", { key: "phx:theme", newValue: "dark" }), + ) + expect(document.documentElement.dataset.theme).toBe("dark") + document.documentElement.dataset.phxTheme = "light" + document.documentElement.dispatchEvent( + new dom.window.CustomEvent("phx:set-theme", { bubbles: true }), + ) + expect(document.documentElement.dataset.theme).toBe("light") + dom.window.dispatchEvent( + new dom.window.StorageEvent("storage", { key: "phx:theme", newValue: null }), + ) + expect(document.documentElement.hasAttribute("data-theme")).toBe(false) + }) it("select automatically the light mode", () => { setStartupTheme() diff --git a/apps/privee_web/assets/js/tests/gettext-fixture.mjs b/apps/privee_web/assets/js/tests/gettext-fixture.mjs new file mode 100644 index 00000000..18b83bfc --- /dev/null +++ b/apps/privee_web/assets/js/tests/gettext-fixture.mjs @@ -0,0 +1,44 @@ +import { readFileSync } from "node:fs" + +/** + * Use the real catalogs and server key mapping in client tests, not a second dictionary. + * @param {string} [locale] + * @returns {Record} + */ +export const catalogTexts = (locale = "en") => { + const po = readFileSync( + new URL(`../../../priv/gettext/${locale}/LC_MESSAGES/default.po`, import.meta.url), + "utf8", + ) + const entries = new Map( + [ + ...po + .replace(/\r\n/g, "\n") + .matchAll(/^msgid ("(?:[^"\\]|\\.)*")\nmsgstr ("(?:[^"\\]|\\.)*")$/gm), + ].map(([, id, value]) => [JSON.parse(id), JSON.parse(value)]), + ) + const source = readFileSync( + new URL("../../../lib/privee_web/client_texts.ex", import.meta.url), + "utf8", + ) + return Object.fromEntries( + [...source.matchAll(/(\w+):\s*gettext\(\s*("(?:[^"\\]|\\.)*")/g)].map(([, key, id]) => { + const value = entries.get(JSON.parse(id)) + if (!value) throw new Error(`Missing catalog fixture: ${locale}/${key}`) + return [key, value] + }), + ) +} + +/** + * @param {Document} doc + * @param {string} [locale] + */ +export const installCatalog = (doc, locale = "en") => { + const el = doc.getElementById("language-settings") ?? doc.createElement("div") + el.id = "language-settings" + el.dataset.locale = locale + el.dataset.texts = JSON.stringify(catalogTexts(locale)) + if (!el.parentNode) doc.body.append(el) + return el +} diff --git a/apps/privee_web/assets/js/tests/locale.test.mjs b/apps/privee_web/assets/js/tests/locale.test.mjs new file mode 100644 index 00000000..73ebe862 --- /dev/null +++ b/apps/privee_web/assets/js/tests/locale.test.mjs @@ -0,0 +1,96 @@ +import { JSDOM } from "jsdom" +import { describe, it, expect, vi } from "vitest" +import { currentLocale, refreshClientTexts, addLocaleHook } from "../utils/locale.mjs" +import { installCatalog, catalogTexts } from "./gettext-fixture.mjs" + +describe("browser language presentation", () => { + it("reports a browser refusing preference persistence", () => { + const dom = new JSDOM("", { url: "https://example.com" }) + vi.stubGlobal("document", dom.window.document) + vi.stubGlobal("location", dom.window.location) + const el = installCatalog(dom.window.document) + Object.defineProperty(dom.window.document, "cookie", { + get: () => "", + set: () => {}, + }) + const hooks = {} + addLocaleHook(hooks) + const events = {} + const hook = { + ...hooks.Locale, + el, + handleEvent: (name, fn) => { + events[name] = fn + }, + pushEvent: vi.fn(), + } + hook.mounted() + events.locale_changed({ locale: "it" }) + expect(hook.pushEvent).toHaveBeenCalledWith("locale_persistence_failed", {}) + vi.unstubAllGlobals() + }) + it("defaults English and rejects unsupported cookie values", () => { + const doc = new JSDOM("", { url: "https://example.com" }).window.document + expect(currentLocale(doc)).toBe("en") + doc.cookie = "privee_locale=xx" + expect(currentLocale(doc)).toBe("en") + for (const locale of ["en", "it", "pt-PT", "es", "fr"]) { + doc.cookie = `privee_locale=${locale}` + expect(currentLocale(doc)).toBe(locale) + } + }) + + it("updates only presentation, preserving input values, disabled state and user text", () => { + const doc = new JSDOM(` + +

This message could not be decrypted.

+

+ + `).window.document + const input = doc.getElementById("draft") + const message = doc.getElementById("message") + installCatalog(doc, "it") + refreshClientTexts(doc) + expect(input.value).toBe("private draft") + expect(input.disabled).toBe(false) + expect(input.placeholder).toBe(catalogTexts("it").messagePlaceholder) + expect(message.textContent).toBe("This message could not be decrypted.") + expect(doc.getElementById("placeholder").textContent).toBe(catalogTexts("it").undecryptable) + expect(doc.getElementById("action").textContent).toBe(catalogTexts("it").resetIdentity) + }) + + it("saves only an allowlisted cookie and updates document presentation without navigation", () => { + const dom = new JSDOM( + "Privee", + { + url: "https://example.com/login", + }, + ) + vi.stubGlobal("document", dom.window.document) + vi.stubGlobal("location", dom.window.location) + vi.stubGlobal("localStorage", dom.window.localStorage) + const el = installCatalog(dom.window.document, "fr") + el.dataset.description = "Description française" + el.dataset.titleSuffix = "Application de confidentialité" + const hooks = {} + addLocaleHook(hooks) + const events = {} + const hook = { + ...hooks.Locale, + el, + handleEvent: (name, fn) => { + events[name] = fn + }, + pushEvent: vi.fn(), + } + hook.mounted() + events.locale_changed({ locale: "fr" }) + expect(currentLocale()).toBe("fr") + expect(document.documentElement.lang).toBe("fr") + expect(document.title).toBe("Privee · Application de confidentialité") + expect(dom.window.location.pathname).toBe("/login") + events.locale_changed({ locale: "../invalid" }) + expect(currentLocale()).toBe("fr") + vi.unstubAllGlobals() + }) +}) diff --git a/apps/privee_web/assets/js/tests/mock-utils.mjs b/apps/privee_web/assets/js/tests/mock-utils.mjs index 0a5fb664..3e1411a8 100644 --- a/apps/privee_web/assets/js/tests/mock-utils.mjs +++ b/apps/privee_web/assets/js/tests/mock-utils.mjs @@ -1,17 +1,21 @@ import { JSDOM } from "jsdom" +import { installCatalog } from "./gettext-fixture.mjs" /** * Returns an already configured JSDOM instance. * @param {string} html The HTML content. It is defaulted to an empty string. * @returns {JSDOM} The JSDOM instance. */ -export const getDom = (html = "") => - new JSDOM(html, { +export const getDom = (html = "") => { + const dom = new JSDOM(html, { // Defining the url is necessary to avoid errors with the localStorage. // For more information, refer the following issue: // https://github.com/jsdom/jsdom/issues/2383#issuecomment-442199291 url: "http://localhost", }) + installCatalog(dom.window.document) + return dom +} /** * Mocks the Notification object. diff --git a/apps/privee_web/assets/js/tests/push-notification.test.mjs b/apps/privee_web/assets/js/tests/push-notification.test.mjs index 3b257ba8..9569fd23 100644 --- a/apps/privee_web/assets/js/tests/push-notification.test.mjs +++ b/apps/privee_web/assets/js/tests/push-notification.test.mjs @@ -1,5 +1,6 @@ import { describe, it, expect, vi, afterEach } from "vitest" import { NotificationMock, getDom } from "./mock-utils.mjs" +import { installCatalog } from "./gettext-fixture.mjs" import { askNotificationPermission, pushBackEndNotification, @@ -23,6 +24,31 @@ const alwaysSuppressCoordinator = { destroy: () => {}, } +describe("notification language changes", () => { + afterEach(() => vi.unstubAllGlobals()) + + it("uses localized generic presentation rather than private message content", async () => { + setNotificationCoordinator(alwaysAllowCoordinator) + const dom = getDom() + installCatalog(dom.window.document, "pt-PT") + vi.stubGlobal("window", addRequiredMockedMethod(dom.window)) + vi.stubGlobal("document", { + ...dom.window.document, + hidden: false, + visibilityState: "visible", + addEventListener: (type, callback) => { + if (type === "visibilitychange") callback() + }, + }) + vi.stubGlobal("Notification", NotificationMock) + const notification = await pushBackEndNotification({ + detail: { check_focus: false, session_name: "unchanged-name", text: "private message" }, + }) + expect(notification.title).toBe("Privee - Mensagem recebida") + expect(notification.body).toBe("Nova mensagem recebida") + }) +}) + describe("askNotificationPermission", () => { afterEach(() => { vi.unstubAllGlobals() diff --git a/apps/privee_web/assets/js/utils/chat.mjs b/apps/privee_web/assets/js/utils/chat.mjs index d6ace504..b2840464 100644 --- a/apps/privee_web/assets/js/utils/chat.mjs +++ b/apps/privee_web/assets/js/utils/chat.mjs @@ -13,6 +13,7 @@ import { NoPeerKeysError, wasQueued, } from "./signal-client.mjs" +import { clientTexts } from "./locale.mjs" export const Selectors = Object.freeze({ entry: "[data-signal-message]", @@ -26,29 +27,6 @@ export const Selectors = Object.freeze({ forgetDevice: "#chat-forget-device", }) -export const Texts = Object.freeze({ - undecryptable: "This message could not be decrypted.", - unavailable: "Sent from another device.", - noPeerKeys: "Your contact has not set up encryption yet. Try again once they are online.", - sendFailed: "The message could not be sent. Please try again.", - sendQueued: "The message could not be delivered yet. It will be sent automatically.", - identityChanged: - "Your contact's security code changed. They may have reset their device, or someone may be intercepting the conversation. Verify the safety number with them before continuing.", - superseded: - "Encryption for this session was reset on another device, so this device can no longer send or receive messages.", - needsReset: - "This device has no encryption keys for this session. Reset the encryption identity to continue; messages sent to your previous device will not be readable here.", - unsupported: - "This browser does not support the features required for end-to-end encryption (Web Locks). Please use an up-to-date browser.", - failedToStart: "Encryption could not be initialized. Please reload the page.", - earlier: "Earlier on this device", - confirmClear: "Delete the local history of this conversation from this device?", - confirmForget: - "Delete all encryption keys and history of this session from this device? You will need to reset encryption to chat again.", - confirmReset: - "Reset the encryption identity of this session? Your contacts will be asked to verify your new safety number.", -}) - /** * @typedef {object} ChatControllerOptions * @property {HTMLElement} el The `#chat-screen` element. @@ -83,6 +61,19 @@ export class ChatController { return /** @type {HTMLElement | null} */ (this.doc.querySelector(selector)) } + get texts() { + return clientTexts(this.doc) + } + + /** + * @param {HTMLElement} entry + * @param {string} key + */ + placeholder(entry, key) { + entry.dataset.clientText = key + return this.texts[key] + } + /** Initializes keys, delivers pending messages and renders the conversation. */ async start() { this.bindComposer() @@ -92,7 +83,7 @@ export class ChatController { this.setState(await this.client.ensureKeys()) } catch (e) { console.error("Unable to initialize encryption", e) - this.showBanner("error", Texts.failedToStart) + this.showBanner("error", this.texts.failedToStart) return } @@ -114,9 +105,10 @@ export class ChatController { this.state = state this.updateComposer() - if (state === "superseded") this.showBanner("superseded", Texts.superseded, this.resetAction()) + if (state === "superseded") + this.showBanner("superseded", this.texts.superseded, this.resetAction()) else if (state === "needs_reset") - this.showBanner("needs_reset", Texts.needsReset, this.resetAction()) + this.showBanner("needs_reset", this.texts.needsReset, this.resetAction()) else if (state === "ready") this.clearBanner(["superseded", "needs_reset"]) } @@ -175,15 +167,17 @@ export class ChatController { /** @param {HTMLElement[]} entries */ async renderEntries(entries) { + let rendered = false for (const entry of entries) { if (entry.dataset.converted !== "false") continue if (this.blockedByIdentity && entry.dataset.direction === "in") break const text = await this.textFor(entry) if (text === undefined) break this.fill(entry, text) + rendered = true } await this.bannerTask - this.scrollToEnd() + if (rendered) this.scrollToEnd() } /** @@ -192,7 +186,7 @@ export class ChatController { */ async textFor(entry) { const { id, seq, epoch, type, body, direction, clientNonce } = entry.dataset - if (!id) return Texts.undecryptable + if (!id) return this.placeholder(entry, "undecryptable") if (direction === "out") { // The outbox is checked before the history: the send reply moves the row @@ -200,7 +194,7 @@ export class ChatController { const row = clientNonce ? await this.client.acknowledge(clientNonce, { id, seq: Number(seq), epoch: String(epoch) }) : await this.client.historyEntry(id) - return row?.plaintext ?? Texts.unavailable + return row?.plaintext ?? this.placeholder(entry, "unavailable") } try { @@ -212,7 +206,7 @@ export class ChatController { body: String(body), direction: "in", }) - return plaintext ?? Texts.undecryptable + return plaintext ?? this.placeholder(entry, "undecryptable") } catch (e) { this.handleError(e) return undefined @@ -251,7 +245,8 @@ export class ChatController { const heading = this.doc.createElement("p") heading.className = "py-2 text-center text-xs text-zinc-500" - heading.textContent = Texts.earlier + heading.dataset.clientText = "earlier" + heading.textContent = this.texts.earlier container.append(heading) for (const row of rows) container.append(this.bubble(row)) } @@ -283,7 +278,8 @@ export class ChatController { text.className = `text-sm text-left break-word w-max max-w-[calc(100vw-62px)] sm:max-w-[450px] font-normal ${ out ? "text-zinc-50" : "text-zinc-900" }` - text.textContent = row.plaintext ?? (out ? Texts.unavailable : Texts.undecryptable) + text.textContent = + row.plaintext ?? this.placeholder(text, out ? "unavailable" : "undecryptable") bubble.append(text) wrapper.append(bubble) @@ -331,9 +327,9 @@ export class ChatController { const queued = wasQueued(e) if (!queued && !input.value) input.value = text if (e instanceof NoPeerKeysError) { - this.showBanner("notice", Texts.noPeerKeys) + this.showBanner("notice", this.texts.noPeerKeys) } else if (!this.handleError(e)) { - this.showBanner("notice", queued ? Texts.sendQueued : Texts.sendFailed) + this.showBanner("notice", queued ? this.texts.sendQueued : this.texts.sendFailed) console.error("Unable to send message", e) } } @@ -402,8 +398,8 @@ export class ChatController { const number = await this.client.safetyNumber(this.peerId) this.showBanner( "identity", - Texts.identityChanged, - { label: "Accept new security code", run: () => this.approveIdentity() }, + this.texts.identityChanged, + { label: this.texts.acceptIdentity, run: () => this.approveIdentity() }, number, ) } @@ -420,15 +416,15 @@ export class ChatController { /** @returns {{label: string, run: () => Promise}} */ resetAction() { return { - label: "Reset encryption identity", + label: this.texts.resetIdentity, run: async () => { - if (!this.confirm(Texts.confirmReset)) return + if (!this.confirm(this.texts.confirmReset)) return try { this.setState(await this.client.resetIdentity()) await this.flush() } catch (e) { console.error("Unable to reset identity", e) - this.showBanner("error", Texts.failedToStart) + this.showBanner("error", this.texts.failedToStart) } }, } @@ -454,6 +450,7 @@ export class ChatController { const text = this.doc.createElement("p") text.textContent = message + text.dataset.clientText = Object.keys(this.texts).find((key) => this.texts[key] === message) banner.append(text) if (safetyNumber) banner.append(this.safetyNumberElement(safetyNumber)) @@ -465,6 +462,9 @@ export class ChatController { button.className = "mt-2 rounded-md bg-amber-900 px-3 py-1 text-xs font-semibold text-amber-50 transition hover:bg-amber-700 active:scale-95 dark:bg-amber-100 dark:text-amber-900" button.textContent = action.label + button.dataset.clientText = Object.keys(this.texts).find( + (key) => this.texts[key] === action.label, + ) button.addEventListener("click", () => action.run()) banner.append(button) } @@ -513,25 +513,25 @@ export class ChatController { async showSafetyNumber() { const number = await this.client.safetyNumber(this.peerId) if (!number) { - this.showBanner("safety", "Exchange a message first to compare safety numbers.") + this.showBanner("safety", this.texts.exchangeFirst) return } this.showBanner( "safety", - "Compare this number with your contact, in person or over another channel. If it matches, the conversation is end-to-end encrypted.", - { label: "Close", run: () => this.clearBanner(["safety"]) }, + this.texts.compareSafety, + { label: this.texts.close, run: () => this.clearBanner(["safety"]) }, number, ) } async clearHistory() { - if (!this.confirm(Texts.confirmClear)) return + if (!this.confirm(this.texts.confirmClear)) return await this.client.clearHistory(this.peerId) this.query(Selectors.localHistory)?.replaceChildren() } async forgetDevice() { - if (!this.confirm(Texts.confirmForget)) return + if (!this.confirm(this.texts.confirmForget)) return await this.client.forgetDevice() this.reload() } diff --git a/apps/privee_web/assets/js/utils/clipboard.mjs b/apps/privee_web/assets/js/utils/clipboard.mjs index e16c9c96..f24bd713 100644 --- a/apps/privee_web/assets/js/utils/clipboard.mjs +++ b/apps/privee_web/assets/js/utils/clipboard.mjs @@ -1,3 +1,5 @@ +import { clientText } from "./locale.mjs" + /** * Copies the given text into the user clipboard. * @param {import("./back-end-event-handlers.mjs").PhoenixSessionNameEvent} [event] The event sent by the back-end. @@ -58,10 +60,14 @@ const createCopyButtonHandler = (pushFlash) => { } if (action === "code") { - return copyTextToClipboard(sessionName).then(() => pushFlash("Info", "Session copied")) + return copyTextToClipboard(sessionName) + .then(() => pushFlash("Info", clientText("sessionCopied", window.document))) + .catch(() => pushFlash("Error", clientText("copyFailed", window.document))) } else { const sessionUrl = getSessionLoginMarkdownLink(sessionName) - return copyTextToClipboard(sessionUrl).then(() => pushFlash("Info", "Url copied")) + return copyTextToClipboard(sessionUrl) + .then(() => pushFlash("Info", clientText("urlCopied", window.document))) + .catch(() => pushFlash("Error", clientText("copyFailed", window.document))) } } } diff --git a/apps/privee_web/assets/js/utils/dark-mode-switcher.mjs b/apps/privee_web/assets/js/utils/dark-mode-switcher.mjs index 436cc61b..98ed4fd7 100644 --- a/apps/privee_web/assets/js/utils/dark-mode-switcher.mjs +++ b/apps/privee_web/assets/js/utils/dark-mode-switcher.mjs @@ -116,6 +116,20 @@ export const addToggleDarkModeHandling = () => { addDarkModeToggleHandlers() } +/** Preserve theme synchronization in the bundle, rather than an inline layout script. */ +export const addThemeEventHandlers = () => { + window.addEventListener("storage", (event) => { + if (event.key !== localStorageDarkModeKey) return + const theme = event.newValue || "system" + if (theme === "dark" || theme === "light" || theme === "system") trySetTheme(theme) + }) + window.addEventListener("phx:set-theme", (event) => { + if (!(event.target instanceof window.HTMLElement)) return + const theme = event.target.dataset.phxTheme + if (theme === "dark" || theme === "light" || theme === "system") trySetTheme(theme) + }) +} + /** * Adds the handlers for the buttons that toggle the dark mode theme. */ diff --git a/apps/privee_web/assets/js/utils/locale.mjs b/apps/privee_web/assets/js/utils/locale.mjs new file mode 100644 index 00000000..1a601d59 --- /dev/null +++ b/apps/privee_web/assets/js/utils/locale.mjs @@ -0,0 +1,93 @@ +import { addDarkModeToggleHandlers } from "./dark-mode-switcher.mjs" + +export const supportedLocales = Object.freeze(["en", "it", "pt-PT", "es", "fr"]) +const cookieName = "privee_locale" + +/** @param {Document} [doc] */ +export const currentLocale = (doc = document) => { + const value = doc.cookie + .split("; ") + .find((entry) => entry.startsWith(`${cookieName}=`)) + ?.slice(cookieName.length + 1) + return value && supportedLocales.includes(value) ? value : "en" +} + +/** + * Read application-owned strings delivered by the server's Gettext backend. + * @param {Document} [doc] + * @returns {Record} + */ +export const clientTexts = (doc = document) => { + const data = doc.getElementById("language-settings")?.dataset.texts + if (!data) throw new Error("Missing Gettext client presentation") + return JSON.parse(data) +} + +/** + * @param {string} key + * @param {Document} [doc] + */ +export const clientText = (key, doc = document) => { + const text = clientTexts(doc)[key] + if (typeof text !== "string") throw new Error(`Missing client translation: ${key}`) + return text +} + +/** + * Update only nodes explicitly marked as application-owned text, never messages. + * @param {Document} doc + */ +export const refreshClientTexts = (doc) => { + const texts = clientTexts(doc) + for (const el of doc.querySelectorAll("[data-client-text]")) { + const key = /** @type {HTMLElement} */ (el).dataset.clientText + if (key && typeof texts[key] === "string") el.textContent = texts[key] + } + for (const el of doc.querySelectorAll("[data-client-placeholder]")) { + const key = /** @type {HTMLElement} */ (el).dataset.clientPlaceholder + if (key && texts[key]) el.setAttribute("placeholder", texts[key]) + } + for (const el of doc.querySelectorAll("[data-client-label]")) { + const key = /** @type {HTMLElement} */ (el).dataset.clientLabel + if (key && texts[key]) el.setAttribute("aria-label", texts[key]) + } +} + +/** @param {any} Hooks */ +export const addLocaleHook = (Hooks) => { + Hooks.Locale = { + /** @this {any} */ + mounted() { + addDarkModeToggleHandlers() + this.handleEvent("locale_changed", (/** @type {{locale: string}} */ { locale }) => { + if (!supportedLocales.includes(locale)) return + try { + document.cookie = `${cookieName}=${locale}; Path=/; Max-Age=31536000; SameSite=Lax${ + location.protocol === "https:" ? "; Secure" : "" + }` + if (currentLocale() !== locale) this.pushEvent("locale_persistence_failed", {}) + } catch (error) { + console.error("Could not persist the language preference", error) + this.pushEvent("locale_persistence_failed", {}) + } + }) + this.updatePresentation() + }, + /** @this {any} */ + updated() { + this.updatePresentation() + }, + /** @this {any} */ + updatePresentation() { + const { locale, description, titleSuffix } = this.el.dataset + document.documentElement.lang = locale + document.querySelector("meta[name=description]")?.setAttribute("content", description) + const title = document.querySelector("title") + if (title) { + title.dataset.suffix = ` · ${titleSuffix}` + title.textContent = `Privee · ${titleSuffix}` + } + refreshClientTexts(document) + }, + } +} diff --git a/apps/privee_web/assets/js/utils/push-notifications.mjs b/apps/privee_web/assets/js/utils/push-notifications.mjs index 8ac363ef..ac3d5039 100644 --- a/apps/privee_web/assets/js/utils/push-notifications.mjs +++ b/apps/privee_web/assets/js/utils/push-notifications.mjs @@ -1,4 +1,5 @@ import { createNotificationCoordinator } from "./notification-coordinator.mjs" +import { clientText } from "./locale.mjs" /** @type {import("./notification-coordinator.mjs").NotificationCoordinator} */ let coordinator = createNotificationCoordinator() @@ -17,11 +18,12 @@ export const setNotificationCoordinator = (c) => { * @returns {Promise} The result of the permission request. */ export const askNotificationPermission = async () => { + const doc = window.document if ("Notification" in window) { await Notification.requestPermission() return `Permission: ${Notification.permission === "granted" ? "granted" : "denied"}` } else { - return Promise.reject("This browser does not support notifications.") + return Promise.reject(clientText("notificationsUnsupported", doc)) } } @@ -58,7 +60,7 @@ export const pushBackEndNotification = async (event) => { const allowed = await coordinator.shouldShowNotification(sessionName, dedupKey) if (!allowed) return undefined - const title = "Privee - Text received" + const title = clientText("notificationTitle", window.document) const url = `/chat/${sessionName}` const message = await getNotificationMessage(event) @@ -99,7 +101,7 @@ export const pushBackEndNotification = async (event) => { * @returns {Promise} The notification message. */ const getNotificationMessage = (_event) => { - return Promise.resolve("New message received") + return Promise.resolve(clientText("notificationBody", window.document)) } /** diff --git a/apps/privee_web/lib/privee_web.ex b/apps/privee_web/lib/privee_web.ex index ef4d198f..07cb4fd1 100644 --- a/apps/privee_web/lib/privee_web.ex +++ b/apps/privee_web/lib/privee_web.ex @@ -85,6 +85,7 @@ defmodule PriveeWeb do use Gettext, backend: PriveeWeb.Gettext # HTML escaping functionality + import PriveeWeb.LocaleHelpers import Phoenix.HTML # Core UI components import PriveeWeb.CoreComponents diff --git a/apps/privee_web/lib/privee_web/app/session_controller.ex b/apps/privee_web/lib/privee_web/app/session_controller.ex index 833152ca..3ed62b4d 100644 --- a/apps/privee_web/lib/privee_web/app/session_controller.ex +++ b/apps/privee_web/lib/privee_web/app/session_controller.ex @@ -98,11 +98,7 @@ defmodule PriveeWeb.App.SessionController do do: %{id: session.id, session_name: session.session_name, is_quick: session.is_quick} defp errors_on(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> - Regex.replace(~r"%{(\w+)}", message, fn _, key -> - opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() - end) - end) + Ecto.Changeset.traverse_errors(changeset, &PriveeWeb.CoreComponents.translate_error/1) end defp rate_limit(conn, _opts) do diff --git a/apps/privee_web/lib/privee_web/client_texts.ex b/apps/privee_web/lib/privee_web/client_texts.ex new file mode 100644 index 00000000..13cffcc4 --- /dev/null +++ b/apps/privee_web/lib/privee_web/client_texts.ex @@ -0,0 +1,62 @@ +defmodule PriveeWeb.ClientTexts do + @moduledoc "The browser client uses the same Gettext catalogs as server-rendered UI." + use Gettext, backend: PriveeWeb.Gettext + + def translations(locale) do + Gettext.with_locale(PriveeWeb.Gettext, locale, fn -> + %{ + undecryptable: gettext("This message could not be decrypted."), + unavailable: gettext("Sent from another device."), + noPeerKeys: + gettext("Your contact has not set up encryption yet. Try again once they are online."), + sendFailed: gettext("The message could not be sent. Please try again."), + sendQueued: + gettext("The message could not be delivered yet. It will be sent automatically."), + identityChanged: + gettext( + "Your contact's security code changed. They may have reset their device, or someone may be intercepting the conversation. Verify the safety number with them before continuing." + ), + superseded: + gettext( + "Encryption for this session was reset on another device, so this device can no longer send or receive messages." + ), + needsReset: + gettext( + "This device has no encryption keys for this session. Reset the encryption identity to continue; messages sent to your previous device will not be readable here." + ), + unsupported: + gettext( + "This browser does not support the features required for end-to-end encryption (Web Locks). Please use an up-to-date browser." + ), + failedToStart: gettext("Encryption could not be initialized. Please reload the page."), + earlier: gettext("Earlier on this device"), + confirmClear: gettext("Delete the local history of this conversation from this device?"), + confirmForget: + gettext( + "Delete all encryption keys and history of this session from this device? You will need to reset encryption to chat again." + ), + confirmReset: + gettext( + "Reset the encryption identity of this session? Your contacts will be asked to verify your new safety number." + ), + acceptIdentity: gettext("Accept new security code"), + resetIdentity: gettext("Reset encryption identity"), + exchangeFirst: gettext("Exchange a message first to compare safety numbers."), + compareSafety: + gettext( + "Compare this number with your contact, in person or over another channel. If it matches, the conversation is end-to-end encrypted." + ), + close: gettext("Close"), + sessionCopied: gettext("Session copied"), + urlCopied: gettext("Url copied"), + copyFailed: gettext("Could not copy to the clipboard."), + notificationTitle: gettext("Privee - Text received"), + notificationBody: gettext("New message received"), + notificationsUnsupported: gettext("This browser does not support notifications."), + messagePlaceholder: gettext("Write your message here"), + messageLabel: gettext("Message"), + send: gettext("Send") + } + end) + end +end diff --git a/apps/privee_web/lib/privee_web/components/core_components.ex b/apps/privee_web/lib/privee_web/components/core_components.ex index cff82cac..6ee12e7f 100644 --- a/apps/privee_web/lib/privee_web/components/core_components.ex +++ b/apps/privee_web/lib/privee_web/components/core_components.ex @@ -28,6 +28,7 @@ defmodule PriveeWeb.CoreComponents do """ use Phoenix.Component use Gettext, backend: PriveeWeb.Gettext + import PriveeWeb.LocaleHelpers alias Phoenix.LiveView.JS @@ -53,6 +54,7 @@ defmodule PriveeWeb.CoreComponents do attr :id, :string, required: true attr :show, :boolean, default: false attr :on_cancel, JS, default: %JS{} + attr :locale, :string, default: "en" slot :inner_block, required: true def modal(assigns) do @@ -73,16 +75,18 @@ defmodule PriveeWeb.CoreComponents do > ✕ +
{render_slot(@inner_block)}
+ @@ -103,6 +107,7 @@ defmodule PriveeWeb.CoreComponents do attr :flash, :map, default: %{}, doc: "the map of flash messages to display" attr :title, :string, default: nil attr :kind, :atom, values: [:info, :error, :warning], doc: "used for styling and flash lookup" + attr :locale, :string, default: "en" attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container" slot :inner_block, doc: "the optional inner block that renders the flash message" @@ -140,6 +145,7 @@ defmodule PriveeWeb.CoreComponents do d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> + + -
- {@title} - {msg} + {@title} {msg}
- @@ -369,9 +373,9 @@ defmodule PriveeWeb.CoreComponents do checked={@checked} class="rounded border-zinc-300 text-zinc-900 focus:ring-0" {@rest} - /> - {@label} + /> {@label} + <.error :for={msg <- @errors}>{msg} """ @@ -381,6 +385,7 @@ defmodule PriveeWeb.CoreComponents do ~H"""
<.label for={@id}>{@label} + + <.error :for={msg <- @errors}>{msg}
""" @@ -449,6 +455,7 @@ defmodule PriveeWeb.CoreComponents do {@label} + <.error :for={msg <- @errors}>{msg} """ @@ -459,6 +466,7 @@ defmodule PriveeWeb.CoreComponents do ~H"""
<.label for={@id} errors={@errors}>{@label} + {@inline_description || ""} -
-
""" @@ -612,9 +621,11 @@ defmodule PriveeWeb.CoreComponents do

{render_slot(@inner_block)}

+

{render_slot(@subtitle)}

+

+

{render_slot(@actions)}
""" @@ -664,11 +676,13 @@ defmodule PriveeWeb.CoreComponents do {col[:label]} + {gettext("Actions")} + +
@@ -725,6 +740,7 @@ defmodule PriveeWeb.CoreComponents do
{item.title}
+
{render_slot(item)}
diff --git a/apps/privee_web/lib/privee_web/components/layouts.ex b/apps/privee_web/lib/privee_web/components/layouts.ex index 7b1c7eab..eb760327 100644 --- a/apps/privee_web/lib/privee_web/components/layouts.ex +++ b/apps/privee_web/lib/privee_web/components/layouts.ex @@ -15,17 +15,18 @@ defmodule PriveeWeb.Layouts do attr :flash, :map, required: true, doc: "the map of flash messages" attr :current_session, :any, default: nil, doc: "the currently authenticated session" attr :navigate_to, :string, default: "/", doc: "the logo link destination" + attr :locale, :string, required: true slot :inner_block, required: true def app(assigns) do ~H""" <.sticky_header> - <.menu current_session={@current_session} navigate_to={@navigate_to} /> + <.menu current_session={@current_session} navigate_to={@navigate_to} locale={@locale} /> +
- <.flash_group flash={@flash} /> - {render_slot(@inner_block)} + <.flash_group flash={@flash} locale={@locale} /> {render_slot(@inner_block)}
""" @@ -37,15 +38,15 @@ defmodule PriveeWeb.Layouts do attr :flash, :map, required: true, doc: "the map of flash messages" attr :current_session, :any, default: nil, doc: "the currently authenticated session" attr :navigate_to, :string, default: "/", doc: "the logo link destination" + attr :locale, :string, required: true slot :inner_block, required: true def chat(assigns) do ~H""" <.sticky_header> - <.menu current_session={@current_session} navigate_to={@navigate_to} /> + <.menu current_session={@current_session} navigate_to={@navigate_to} locale={@locale} /> - <.flash_group flash={@flash} /> - {render_slot(@inner_block)} + <.flash_group flash={@flash} locale={@locale} /> {render_slot(@inner_block)} """ end @@ -58,22 +59,24 @@ defmodule PriveeWeb.Layouts do """ attr :flash, :map, required: true, doc: "the map of flash messages" attr :id, :string, default: "flash-group", doc: "the optional id of flash container" + attr :locale, :string, default: "en" def flash_group(assigns) do ~H"""
- <.flash kind={:info} title={gettext("Success!")} flash={@flash} /> - <.flash kind={:error} title={gettext("Error!")} flash={@flash} /> - <.flash kind={:warning} title={gettext("Warning!")} flash={@flash} /> + <.flash kind={:info} title={lgettext(@locale, "Success!")} flash={@flash} locale={@locale} /> + <.flash kind={:error} title={lgettext(@locale, "Error!")} flash={@flash} locale={@locale} /> + <.flash kind={:warning} title={lgettext(@locale, "Warning!")} flash={@flash} locale={@locale} /> <.flash id="client-error" kind={:error} - title={gettext("We can't find the internet")} + title={lgettext(@locale, "We can't find the internet")} + locale={@locale} phx-disconnected={show(".phx-client-error #client-error")} phx-connected={hide("#client-error")} hidden > - {gettext("Attempting to reconnect")} + {lgettext(@locale, "Attempting to reconnect")}
+
- <.button - id="theme-toggle" - data-theme-toggle="theme-toggle" - aria-label="dark-theme-selector" - class="cursor-pointer" + +
- - - - + + +
+ <%= if @current_session do %> <.button_link id="copy-session-code-btn" data-session-name={@current_session.session_name} data-action="code" - aria-label="copy-session-name" - legend="Copy session name" + aria-label={lgettext(@locale, "Copy session name")} + legend={lgettext(@locale, "Copy session name")} > + <.button_link id="copy-session-url-btn" data-session-name={@current_session.session_name} data-action="url" - aria-label="copy-session-url" - legend="Copy link" + aria-label={lgettext(@locale, "Copy link")} + legend={lgettext(@locale, "Copy link")} > + <.button_link href={~p"/sessions/log_out"} method="delete" class="cursor-pointer" - legend="Log Out" + legend={lgettext(@locale, "Log Out")} + aria-label={lgettext(@locale, "Log Out")} > + <.button_link navigate={~p"/login"} class="cursor-pointer" - legend="log in" + legend={lgettext(@locale, "Sign in")} + aria-label={lgettext(@locale, "Sign in")} > - + + - - - <.live_title default="Privee" suffix=" · Privacy App"> + <.live_title + default="Privee" + suffix={" · " <> lgettext(assigns[:locale] || "en", "Privacy App")} + > {assigns[:page_title] || "Privee"} - + {@inner_content} diff --git a/apps/privee_web/lib/privee_web/controllers/error_html.ex b/apps/privee_web/lib/privee_web/controllers/error_html.ex index f52ed826..415c0b0c 100644 --- a/apps/privee_web/lib/privee_web/controllers/error_html.ex +++ b/apps/privee_web/lib/privee_web/controllers/error_html.ex @@ -19,6 +19,10 @@ defmodule PriveeWeb.ErrorHTML do # the template name. For example, "404.html" becomes # "Not Found". def render(template, _assigns) do - Phoenix.Controller.status_message_from_template(template) + case template do + "404.html" -> gettext("Not Found") + "500.html" -> gettext("Internal Server Error") + _ -> gettext("Something went wrong!") + end end end diff --git a/apps/privee_web/lib/privee_web/controllers/session_controller.ex b/apps/privee_web/lib/privee_web/controllers/session_controller.ex index 9617a890..6ad55b6e 100644 --- a/apps/privee_web/lib/privee_web/controllers/session_controller.ex +++ b/apps/privee_web/lib/privee_web/controllers/session_controller.ex @@ -5,14 +5,17 @@ defmodule PriveeWeb.SessionController do alias PriveeWeb.SessionAuth def create(conn, %{"_action" => "registered"} = params) do - create(conn, params, """ - Session created successfully! - Your session name has been automatically copied to the clipboard. - """) + create( + conn, + params, + gettext( + "Session created successfully! Your session name has been automatically copied to the clipboard." + ) + ) end def create(conn, params) do - create(conn, params, "Welcome back!") + create(conn, params, gettext("Welcome back!")) end defp create(conn, %{"session" => session_params} = params, info) do @@ -43,7 +46,7 @@ defmodule PriveeWeb.SessionController do defp put_error_flash(conn, _session_params) do # In order to prevent user enumeration attacks, don't disclose whether the recovery_phrase is registered. conn - |> put_flash(:error, "Invalid recovery_phrase or session_name") + |> put_flash(:error, gettext("Invalid recovery phrase or session name")) |> redirect(to: ~p"/") end @@ -62,7 +65,7 @@ defmodule PriveeWeb.SessionController do def delete(conn, _params) do conn - |> put_flash(:info, "Logged out successfully.") + |> put_flash(:info, gettext("Logged out successfully.")) |> SessionAuth.log_out_session() end end diff --git a/apps/privee_web/lib/privee_web/controllers/session_share_controller.ex b/apps/privee_web/lib/privee_web/controllers/session_share_controller.ex index 577876b7..8a409488 100644 --- a/apps/privee_web/lib/privee_web/controllers/session_share_controller.ex +++ b/apps/privee_web/lib/privee_web/controllers/session_share_controller.ex @@ -44,7 +44,7 @@ defmodule PriveeWeb.SessionShareController do nil -> conn - |> put_flash(:error, "The session you're trying to join doesn't exist.") + |> put_flash(:error, gettext("The session you're trying to join doesn't exist.")) |> redirect(to: ~p"/privee") end end @@ -57,7 +57,7 @@ defmodule PriveeWeb.SessionShareController do nil -> conn - |> put_flash(:error, "The session you're trying to join doesn't exist.") + |> put_flash(:error, gettext("The session you're trying to join doesn't exist.")) |> redirect(to: ~p"/") end end diff --git a/apps/privee_web/lib/privee_web/endpoint.ex b/apps/privee_web/lib/privee_web/endpoint.ex index fc044d6d..453da1fc 100644 --- a/apps/privee_web/lib/privee_web/endpoint.ex +++ b/apps/privee_web/lib/privee_web/endpoint.ex @@ -51,6 +51,7 @@ defmodule PriveeWeb.Endpoint do plug PriveeWeb.Plugs.ForwardedRemoteIp plug Plug.RequestId plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + plug PriveeWeb.Locale, :request # Security headers plug PriveeWeb.Plugs.SecurityHeaders diff --git a/apps/privee_web/lib/privee_web/live/chat/chat_live.ex b/apps/privee_web/lib/privee_web/live/chat/chat_live.ex index b4786a04..4fb4d5a9 100644 --- a/apps/privee_web/lib/privee_web/live/chat/chat_live.ex +++ b/apps/privee_web/lib/privee_web/live/chat/chat_live.ex @@ -50,7 +50,7 @@ defmodule PriveeWeb.Chat.ChatLive do def mount(_params, _session, socket) do {:ok, socket - |> put_flash(:info, "You have to select a session to continue") + |> put_flash(:info, gettext("You have to select a session to continue")) |> push_navigate(to: ~p"/privee")} end @@ -79,7 +79,9 @@ defmodule PriveeWeb.Chat.ChatLive do # Legacy events from a cached client. def handle_event(event, _params, socket) do Logger.debug("Unexpected chat event #{inspect(event)}") - {:noreply, put_flash(socket, :error, "The application was updated. Please reload the page.")} + + {:noreply, + put_flash(socket, :error, gettext("The application was updated. Please reload the page."))} end @impl true @@ -108,7 +110,7 @@ defmodule PriveeWeb.Chat.ChatLive do :error -> {:halt, socket - |> put_flash(:info, "You have to select a session to continue") + |> put_flash(:info, gettext("You have to select a session to continue")) |> push_navigate(to: ~p"/privee")} end end diff --git a/apps/privee_web/lib/privee_web/live/chat/chat_live.html.heex b/apps/privee_web/lib/privee_web/live/chat/chat_live.html.heex index 2a1d29b5..cfac0351 100644 --- a/apps/privee_web/lib/privee_web/live/chat/chat_live.html.heex +++ b/apps/privee_web/lib/privee_web/live/chat/chat_live.html.heex @@ -1,33 +1,42 @@ - +
<.header class="h-10 pt-5 pb-10 text-center"> {@selected_session_name} +
+ +
<%!-- Client-rendered notices (identity change, superseded device, unsupported browser) --%>
+
<%!-- Messages decrypted earlier on this device that are no longer on the server --%>
+ <.chat_screen current_session={@current_session} selected_session={@selected_session} streams={@streams} />
+
- <.chat_input /> + <.chat_input locale={@locale} />
diff --git a/apps/privee_web/lib/privee_web/live/chat/components/chat_input.html.heex b/apps/privee_web/lib/privee_web/live/chat/components/chat_input.html.heex index 321900d5..41a4ba91 100644 --- a/apps/privee_web/lib/privee_web/live/chat/components/chat_input.html.heex +++ b/apps/privee_web/lib/privee_web/live/chat/components/chat_input.html.heex @@ -3,23 +3,26 @@ plaintext must never be serialized to the server. The ChatScreen hook reads it, encrypts it and pushes only the ciphertext. --%> -
+
diff --git a/apps/privee_web/lib/privee_web/live/privee_selector_live.html.heex b/apps/privee_web/lib/privee_web/live/privee_selector_live.html.heex index 327a3301..39204638 100644 --- a/apps/privee_web/lib/privee_web/live/privee_selector_live.html.heex +++ b/apps/privee_web/lib/privee_web/live/privee_selector_live.html.heex @@ -1,4 +1,9 @@ - +
<.header class="text-center"> - Create a new Privee + {lgettext(@locale, "Create a new Privee")} <:subtitle> - Specify the session name you would like to chat with. + {lgettext(@locale, "Specify the session name you would like to chat with.")} @@ -16,14 +21,13 @@ <.input field={@form[:session_name]} type="text" - label="Session Name" - placeholder="The session name you'd like to contact." + label={lgettext(@locale, "Session Name")} + placeholder={lgettext(@locale, "The session name you'd like to contact.")} required /> - <:actions> - <.button phx-disable-with="Creating..." class="w-full"> - Start Privee + <.button phx-disable-with={lgettext(@locale, "Creating...")} class="w-full"> + {lgettext(@locale, "Start Privee")} diff --git a/apps/privee_web/lib/privee_web/live/session_login_live.ex b/apps/privee_web/lib/privee_web/live/session_login_live.ex index 895767ec..a210f261 100644 --- a/apps/privee_web/lib/privee_web/live/session_login_live.ex +++ b/apps/privee_web/lib/privee_web/live/session_login_live.ex @@ -4,6 +4,10 @@ defmodule PriveeWeb.SessionLoginLive do def mount(_params, _session, socket) do session_name = Phoenix.Flash.get(socket.assigns.flash, :session_name) form = to_form(%{"session_name" => session_name}, as: "session") - {:ok, assign(socket, form: form), temporary_assigns: [form: form]} + {:ok, assign(socket, form: form)} + end + + def handle_event("validate", %{"session" => params}, socket) do + {:noreply, assign(socket, :form, to_form(params, as: "session"))} end end diff --git a/apps/privee_web/lib/privee_web/live/session_login_live.html.heex b/apps/privee_web/lib/privee_web/live/session_login_live.html.heex index 9b4e5325..3a062c98 100644 --- a/apps/privee_web/lib/privee_web/live/session_login_live.html.heex +++ b/apps/privee_web/lib/privee_web/live/session_login_live.html.heex @@ -1,46 +1,52 @@ - +
<.header class="text-center"> - Sign in to existing session + {lgettext(@locale, "Sign in to existing session")} <:subtitle> - Don't have a session? + {lgettext(@locale, "Don't have a session?")} <.link navigate={~p"/"} class="font-semibold text-brand dark:text-brand-dark hover:underline" > - Create a new one + {lgettext(@locale, "Create a new one")} - now. - <.simple_form for={@form} id="login_form" action={~p"/sessions/log_in"} phx-update="ignore"> + <.simple_form for={@form} id="login_form" action={~p"/sessions/log_in"} phx-change="validate"> <.input field={@form[:session_name]} type="text" - label="Session Name" - placeholder="Write your session name." + label={lgettext(@locale, "Session Name")} + placeholder={lgettext(@locale, "Write your session name.")} phx-debounce="500" required /> - <.input field={@form[:recovery_phrase]} type="textarea" - label="Recovery phrase" - placeholder="Your citation." + label={lgettext(@locale, "Recovery phrase")} + placeholder={lgettext(@locale, "Your citation.")} rows="5" phx-debounce="1000" required /> - <:actions> - <.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" /> + <.input + field={@form[:remember_me]} + type="checkbox" + label={lgettext(@locale, "Keep me logged in")} + /> <:actions> - <.button phx-disable-with="Signing in..." class="w-full"> - Sign in + <.button phx-disable-with={lgettext(@locale, "Signing in...")} class="w-full"> + {lgettext(@locale, "Sign in")} diff --git a/apps/privee_web/lib/privee_web/live/session_registration_live.ex b/apps/privee_web/lib/privee_web/live/session_registration_live.ex index 54a6736f..cb7da496 100644 --- a/apps/privee_web/lib/privee_web/live/session_registration_live.ex +++ b/apps/privee_web/lib/privee_web/live/session_registration_live.ex @@ -23,7 +23,7 @@ defmodule PriveeWeb.SessionRegistrationLive do |> assign_form(changeset) |> maybe_set_quick_session(is_quick) - {:ok, socket, temporary_assigns: [form: nil]} + {:ok, socket} end @impl true diff --git a/apps/privee_web/lib/privee_web/live/session_registration_live.html.heex b/apps/privee_web/lib/privee_web/live/session_registration_live.html.heex index 13ed1ab2..aa3af896 100644 --- a/apps/privee_web/lib/privee_web/live/session_registration_live.html.heex +++ b/apps/privee_web/lib/privee_web/live/session_registration_live.html.heex @@ -1,22 +1,27 @@ - +
<.header class="text-center"> - Create a new session + {lgettext(@locale, "Create a new session")} <:subtitle> - You want to reconnect with an existing session? + {lgettext(@locale, "Want to reconnect with an existing session?")} <.link navigate={~p"/login"} class="font-semibold text-brand dark:text-brand-dark hover:underline" > - Sign in + {lgettext(@locale, "Sign in")} - to your account now. <:description> - To register a session, you have to define a Session Name, a series of alphanumeric characters - divided by hyphens with a minimum length of 24 characters, and a recovery phrase, that can contain only - alphabetic character, spaces and punctuation. + {lgettext( + @locale, + "To register a session, choose a session name of at least 24 alphanumeric characters separated by hyphens, and a recovery phrase containing only letters, spaces and punctuation." + )} @@ -31,7 +36,7 @@ phx-hook="RegistrationScreen" > <.error :if={@check_errors}> - Oops, something went wrong! Please check the errors below. + {lgettext(@locale, "Oops, something went wrong! Please check the errors below.")} - <.input field={@form[:session_name]} type="search" - label="Session Name" - placeholder="Write your own session name" - value={@automatic_session_name} + label={lgettext(@locale, "Session Name")} + placeholder={lgettext(@locale, "Write your own session name")} + value={@form[:session_name].value || @automatic_session_name} phx-debounce="1000" required /> -
<.input field={@form[:is_quick]} type="toggle" - label="Quick session" + label={lgettext(@locale, "Quick session")} /> - <.info_icon - inline_description="What's this?" - short_description="This allows you to create a session quickly without the recovery phrase" - long_description={[ - "By checking the \"Quick session\" toggle, you will be able to create a session without writing a recovery phrase.", - "This means that you will be able to login quickly, but the session will be used only once, and you would not be able", - "to login to this session again." - ]} + id="quick-session-info" + locale={@locale} + inline_description={lgettext(@locale, "What's this?")} + short_description={ + lgettext(@locale, "Create a session quickly without a recovery phrase") + } + long_description={ + lgettext( + @locale, + "A quick session needs no recovery phrase. You can sign in only once; you will not be able to recover this session later." + ) + } />
@@ -73,21 +80,25 @@ :if={!quick_session?(@form)} field={@form[:recovery_phrase]} type="textarea" - label="Recovery phrase" - placeholder="Write your preferred citation. This will be used to recover the session, so keep it saved." + label={lgettext(@locale, "Recovery phrase")} + placeholder={ + lgettext( + @locale, + "Write your preferred citation. This will be used to recover the session, so keep it saved." + ) + } rows="5" phx-debounce="1000" required /> - <:actions> <.button - phx-disable-with="Creating account..." + phx-disable-with={lgettext(@locale, "Creating account...")} class="w-full" name="action" value="create" > - Create an account + {lgettext(@locale, "Create an account")} diff --git a/apps/privee_web/lib/privee_web/locale.ex b/apps/privee_web/lib/privee_web/locale.ex new file mode 100644 index 00000000..cb1f3c8b --- /dev/null +++ b/apps/privee_web/lib/privee_web/locale.ex @@ -0,0 +1,88 @@ +defmodule PriveeWeb.Locale do + @moduledoc "Browser-local language preference and native API language negotiation." + + import Plug.Conn + + @languages [ + {"English", "en"}, + {"Italiano", "it"}, + {"Português", "pt-PT"}, + {"Español", "es"}, + {"Français", "fr"} + ] + @tags Enum.map(@languages, &elem(&1, 1)) + @cookie "privee_locale" + + def languages, do: @languages + def valid?(tag), do: tag in @tags + def normalize(tag), do: if(valid?(tag), do: tag, else: "en") + + def put_locale(tag) do + locale = normalize(tag) + Gettext.put_locale(PriveeWeb.Gettext, locale) + locale + end + + def init(mode), do: mode + + def call(conn, :request) do + conn = fetch_cookies(conn) + locale = put_locale(conn.cookies[@cookie]) + + assign(conn, :locale, locale) + end + + def call(conn, :browser) do + conn = call(conn, :request) + put_session(conn, "locale", conn.assigns.locale) + end + + def call(conn, :api) do + locale = conn |> get_req_header("accept-language") |> Enum.join(",") |> negotiate() + assign(conn, :locale, put_locale(locale)) + end + + def negotiate(header) do + header + |> String.split(",") + |> Enum.with_index() + |> Enum.map(fn {entry, index} -> + [tag | parameters] = String.split(String.trim(entry), ";") + + quality = Enum.find_value(parameters, 1.0, &quality_parameter/1) + + {match_tag(tag), quality, index} + end) + |> Enum.filter(fn {tag, q, _} -> tag != nil and q > 0 end) + |> Enum.sort_by(fn {_, q, index} -> {-q, index} end) + |> case do + [{tag, _, _} | _] -> tag + [] -> "en" + end + end + + defp quality_parameter(parameter) do + case String.split(String.trim(parameter), "=", parts: 2) do + ["q", value] -> parse_quality(value) + _ -> nil + end + end + + defp parse_quality(value) do + case Float.parse(value) do + {q, ""} when q >= 0 and q <= 1 -> q + _ -> 0.0 + end + end + + defp match_tag(tag) do + case tag |> String.downcase() |> String.split("-") |> hd() do + "en" -> "en" + "it" -> "it" + "pt" -> "pt-PT" + "es" -> "es" + "fr" -> "fr" + _ -> nil + end + end +end diff --git a/apps/privee_web/lib/privee_web/locale_helpers.ex b/apps/privee_web/lib/privee_web/locale_helpers.ex new file mode 100644 index 00000000..c8e39623 --- /dev/null +++ b/apps/privee_web/lib/privee_web/locale_helpers.ex @@ -0,0 +1,17 @@ +defmodule PriveeWeb.LocaleHelpers do + @moduledoc "Explicit locale dependencies for LiveView's template change tracking." + + defmacro lgettext(locale, message, bindings \\ []) do + quote do + require Gettext.Macros + + Gettext.with_locale(PriveeWeb.Gettext, unquote(locale), fn -> + Gettext.Macros.gettext_with_backend( + PriveeWeb.Gettext, + unquote(message), + unquote(bindings) + ) + end) + end + end +end diff --git a/apps/privee_web/lib/privee_web/locale_live.ex b/apps/privee_web/lib/privee_web/locale_live.ex new file mode 100644 index 00000000..42532899 --- /dev/null +++ b/apps/privee_web/lib/privee_web/locale_live.ex @@ -0,0 +1,59 @@ +defmodule PriveeWeb.LocaleLive do + @moduledoc "Changes presentation in the existing LiveView without touching conversation state." + + use Gettext, backend: PriveeWeb.Gettext + import Phoenix.Component, only: [assign: 3] + import Phoenix.LiveView + + alias PriveeWeb.Locale + + def on_mount(:default, _params, session, socket) do + params = if connected?(socket), do: get_connect_params(socket) || %{}, else: %{} + locale = Locale.put_locale(Map.get(params, "locale", session["locale"])) + + {:cont, + socket + |> assign(:locale, locale) + |> attach_hook(:locale, :handle_event, &handle_event/3)} + end + + defp handle_event("set_locale", %{"locale" => locale}, socket) do + if Locale.valid?(locale) do + Locale.put_locale(locale) + + {:halt, + socket + |> assign(:locale, locale) + |> clear_flash() + |> clear_form_errors() + |> push_event("locale_changed", %{locale: locale})} + else + {:halt, put_flash(socket, :error, gettext("This language is not supported."))} + end + end + + defp handle_event("locale_persistence_failed", _params, socket) do + {:halt, put_flash(socket, :error, gettext("Your language preference could not be saved."))} + end + + defp handle_event(_event, _params, socket), do: {:cont, socket} + + defp clear_form_errors(%{assigns: %{form: %Phoenix.HTML.Form{} = form}} = socket) do + source = + case form.source do + %Ecto.Changeset{} = changeset -> %{changeset | errors: [], action: nil} + source -> source + end + + socket + |> assign(:form, %{form | source: source, errors: []}) + |> maybe_clear_check_errors() + end + + defp clear_form_errors(socket), do: socket + + defp maybe_clear_check_errors(%{assigns: %{check_errors: _}} = socket), + do: assign(socket, :check_errors, false) + + defp maybe_clear_check_errors(socket), do: socket +end diff --git a/apps/privee_web/lib/privee_web/router.ex b/apps/privee_web/lib/privee_web/router.ex index d63517a1..eb4f45fc 100644 --- a/apps/privee_web/lib/privee_web/router.ex +++ b/apps/privee_web/lib/privee_web/router.ex @@ -7,6 +7,7 @@ defmodule PriveeWeb.Router do pipeline :browser do plug :accepts, ["html"] plug :fetch_session + plug PriveeWeb.Locale, :browser plug :fetch_live_flash plug :put_root_layout, html: {PriveeWeb.Layouts, :root} plug :protect_from_forgery @@ -16,6 +17,7 @@ defmodule PriveeWeb.Router do pipeline :api do plug :accepts, ["json"] + plug PriveeWeb.Locale, :api end pipeline :app_session do @@ -46,6 +48,7 @@ defmodule PriveeWeb.Router do live_session :redirect_if_session_is_authenticated, on_mount: [ + PriveeWeb.LocaleLive, {PriveeWeb.SessionAuth, :redirect_if_session_is_authenticated}, {PriveeWeb.Navigation, :home} ] do @@ -61,6 +64,7 @@ defmodule PriveeWeb.Router do live_session :require_authenticated_session, on_mount: [ + PriveeWeb.LocaleLive, {PriveeWeb.SessionAuth, :ensure_authenticated}, {PriveeWeb.Navigation, :logged}, PriveeWeb.SignalKeysLive diff --git a/apps/privee_web/lib/privee_web/session_auth.ex b/apps/privee_web/lib/privee_web/session_auth.ex index 5d6949ed..4f254d06 100644 --- a/apps/privee_web/lib/privee_web/session_auth.ex +++ b/apps/privee_web/lib/privee_web/session_auth.ex @@ -4,6 +4,7 @@ defmodule PriveeWeb.SessionAuth do """ use PriveeWeb, :verified_routes + use Gettext, backend: PriveeWeb.Gettext import Plug.Conn import Phoenix.Controller @@ -159,7 +160,10 @@ defmodule PriveeWeb.SessionAuth do else socket = socket - |> Phoenix.LiveView.put_flash(:error, "You must have a session to access this page.") + |> Phoenix.LiveView.put_flash( + :error, + gettext("You must have a session to access this page.") + ) |> Phoenix.LiveView.redirect(to: ~p"/") {:halt, socket} @@ -207,7 +211,7 @@ defmodule PriveeWeb.SessionAuth do conn else conn - |> put_flash(:error, "You must have a session to access this page.") + |> put_flash(:error, gettext("You must have a session to access this page.")) |> maybe_store_return_to() |> redirect(to: ~p"/") |> halt() diff --git a/apps/privee_web/priv/gettext/default.pot b/apps/privee_web/priv/gettext/default.pot new file mode 100644 index 00000000..e74332d8 --- /dev/null +++ b/apps/privee_web/priv/gettext/default.pot @@ -0,0 +1,595 @@ +#: lib/privee_web/live/session_registration_live.html.heex:71 +#: lib/privee_web/live/session_registration_live.html.heex:71 +#, elixir-autogen +msgid "A quick session needs no recovery phrase. You can sign in only once; you will not be able to recover this session later." +msgstr "" + +#: lib/privee_web/client_texts.ex:42 +#: lib/privee_web/client_texts.ex:42 +#, elixir-autogen +msgid "Accept new security code" +msgstr "" + +#: lib/privee_web/components/core_components.ex:681 +#: lib/privee_web/components/core_components.ex:681 +#, elixir-autogen +msgid "Actions" +msgstr "" + +#: lib/privee_web/components/layouts.ex:79 +#: lib/privee_web/components/layouts.ex:79 +#, elixir-autogen +msgid "Attempting to reconnect" +msgstr "" + +#: lib/privee_web/components/layouts/menu.html.heex:52 +#: lib/privee_web/components/layouts/menu.html.heex:52 +#: lib/privee_web/components/layouts/root.html.heex:11 +#: lib/privee_web/components/layouts/root.html.heex:11 +#, elixir-autogen +msgid "Chatting app with focus on privacy" +msgstr "" + +#: lib/privee_web/live/chat/chat_live.html.heex:26 +#: lib/privee_web/live/chat/chat_live.html.heex:26 +#, elixir-autogen +msgid "Clear history on this device" +msgstr "" + +#: lib/privee_web/client_texts.ex:49 +#: lib/privee_web/client_texts.ex:49 +#: lib/privee_web/components/core_components.ex:89 +#: lib/privee_web/components/core_components.ex:89 +#: lib/privee_web/components/core_components.ex:186 +#: lib/privee_web/components/core_components.ex:186 +#: lib/privee_web/components/core_components.ex:571 +#: lib/privee_web/components/core_components.ex:571 +#, elixir-autogen +msgid "Close" +msgstr "" + +#: lib/privee_web/client_texts.ex:46 +#: lib/privee_web/client_texts.ex:46 +#, elixir-autogen +msgid "Compare this number with your contact, in person or over another channel. If it matches, the conversation is end-to-end encrypted." +msgstr "" + +#: lib/privee_web/components/layouts/menu.html.heex:154 +#: lib/privee_web/components/layouts/menu.html.heex:154 +#: lib/privee_web/components/layouts/menu.html.heex:155 +#: lib/privee_web/components/layouts/menu.html.heex:155 +#, elixir-autogen +msgid "Copy link" +msgstr "" + +#: lib/privee_web/components/layouts/menu.html.heex:129 +#: lib/privee_web/components/layouts/menu.html.heex:129 +#: lib/privee_web/components/layouts/menu.html.heex:130 +#: lib/privee_web/components/layouts/menu.html.heex:130 +#, elixir-autogen +msgid "Copy session name" +msgstr "" + +#: lib/privee_web/client_texts.ex:52 +#: lib/privee_web/client_texts.ex:52 +#, elixir-autogen +msgid "Could not copy to the clipboard." +msgstr "" + +#: lib/privee_web/components/layouts/menu.html.heex:205 +#: lib/privee_web/components/layouts/menu.html.heex:205 +#: lib/privee_web/components/layouts/menu.html.heex:206 +#: lib/privee_web/components/layouts/menu.html.heex:206 +#, elixir-autogen +msgid "Create" +msgstr "" + +#: lib/privee_web/live/privee_selector_live.html.heex:14 +#: lib/privee_web/live/privee_selector_live.html.heex:14 +#, elixir-autogen +msgid "Create a new Privee" +msgstr "" + +#: lib/privee_web/live/session_login_live.html.heex:16 +#: lib/privee_web/live/session_login_live.html.heex:16 +#, elixir-autogen +msgid "Create a new one" +msgstr "" + +#: lib/privee_web/live/session_registration_live.html.heex:9 +#: lib/privee_web/live/session_registration_live.html.heex:9 +#, elixir-autogen +msgid "Create a new session" +msgstr "" + +#: lib/privee_web/live/session_registration_live.html.heex:68 +#: lib/privee_web/live/session_registration_live.html.heex:68 +#, elixir-autogen +msgid "Create a session quickly without a recovery phrase" +msgstr "" + +#: lib/privee_web/live/session_registration_live.html.heex:101 +#: lib/privee_web/live/session_registration_live.html.heex:101 +#, elixir-autogen +msgid "Create an account" +msgstr "" + +#: lib/privee_web/live/session_registration_live.html.heex:96 +#: lib/privee_web/live/session_registration_live.html.heex:96 +#, elixir-autogen +msgid "Creating account..." +msgstr "" + +#: lib/privee_web/live/privee_selector_live.html.heex:29 +#: lib/privee_web/live/privee_selector_live.html.heex:29 +#, elixir-autogen +msgid "Creating..." +msgstr "" + +#: lib/privee_web/client_texts.ex:35 +#: lib/privee_web/client_texts.ex:35 +#, elixir-autogen +msgid "Delete all encryption keys and history of this session from this device? You will need to reset encryption to chat again." +msgstr "" + +#: lib/privee_web/client_texts.ex:33 +#: lib/privee_web/client_texts.ex:33 +#, elixir-autogen +msgid "Delete the local history of this conversation from this device?" +msgstr "" + +#: lib/privee_web/live/session_login_live.html.heex:11 +#: lib/privee_web/live/session_login_live.html.heex:11 +#, elixir-autogen +msgid "Don't have a session?" +msgstr "" + +#: lib/privee_web/client_texts.ex:32 +#: lib/privee_web/client_texts.ex:32 +#, elixir-autogen +msgid "Earlier on this device" +msgstr "" + +#: lib/privee_web/client_texts.ex:31 +#: lib/privee_web/client_texts.ex:31 +#, elixir-autogen +msgid "Encryption could not be initialized. Please reload the page." +msgstr "" + +#: lib/privee_web/client_texts.ex:20 +#: lib/privee_web/client_texts.ex:20 +#, elixir-autogen +msgid "Encryption for this session was reset on another device, so this device can no longer send or receive messages." +msgstr "" + +#: lib/privee_web/components/layouts.ex:68 +#: lib/privee_web/components/layouts.ex:68 +#, elixir-autogen +msgid "Error!" +msgstr "" + +#: lib/privee_web/client_texts.ex:44 +#: lib/privee_web/client_texts.ex:44 +#, elixir-autogen +msgid "Exchange a message first to compare safety numbers." +msgstr "" + +#: lib/privee_web/live/chat/chat_live.html.heex:34 +#: lib/privee_web/live/chat/chat_live.html.heex:34 +#, elixir-autogen +msgid "Forget this device" +msgstr "" + +#: lib/privee_web/components/layouts.ex:108 +#: lib/privee_web/components/layouts.ex:108 +#, elixir-autogen +msgid "Hang in there while we get back on track" +msgstr "" + +#: lib/privee_web/components/core_components.ex:559 +#: lib/privee_web/components/core_components.ex:559 +#, elixir-autogen +msgid "Information" +msgstr "" + +#: lib/privee_web/controllers/error_html.ex:24 +#: lib/privee_web/controllers/error_html.ex:24 +#, elixir-autogen +msgid "Internal Server Error" +msgstr "" + +#: lib/privee_web/controllers/session_controller.ex:49 +#: lib/privee_web/controllers/session_controller.ex:49 +#, elixir-autogen +msgid "Invalid recovery phrase or session name" +msgstr "" + +#: lib/privee_web/live/session_login_live.html.heex:43 +#: lib/privee_web/live/session_login_live.html.heex:43 +#, elixir-autogen +msgid "Keep me logged in" +msgstr "" + +#: lib/privee_web/components/layouts/menu.html.heex:80 +#: lib/privee_web/components/layouts/menu.html.heex:80 +#, elixir-autogen +msgid "Language" +msgstr "" + +#: lib/privee_web/components/layouts/menu.html.heex:180 +#: lib/privee_web/components/layouts/menu.html.heex:180 +#: lib/privee_web/components/layouts/menu.html.heex:181 +#: lib/privee_web/components/layouts/menu.html.heex:181 +#, elixir-autogen +msgid "Log Out" +msgstr "" + +#: lib/privee_web/controllers/session_controller.ex:68 +#: lib/privee_web/controllers/session_controller.ex:68 +#, elixir-autogen +msgid "Logged out successfully." +msgstr "" + +#: lib/privee_web/client_texts.ex:57 +#: lib/privee_web/client_texts.ex:57 +#: lib/privee_web/live/chat/components/chat_input.html.heex:13 +#: lib/privee_web/live/chat/components/chat_input.html.heex:13 +#, elixir-autogen +msgid "Message" +msgstr "" + +#: lib/privee_web/client_texts.ex:54 +#: lib/privee_web/client_texts.ex:54 +#, elixir-autogen +msgid "New message received" +msgstr "" + +#: lib/privee_web/controllers/error_html.ex:23 +#: lib/privee_web/controllers/error_html.ex:23 +#, elixir-autogen +msgid "Not Found" +msgstr "" + +#: lib/privee_web/live/session_registration_live.html.heex:39 +#: lib/privee_web/live/session_registration_live.html.heex:39 +#, elixir-autogen +msgid "Oops, something went wrong! Please check the errors below." +msgstr "" + +#: lib/privee_web/components/layouts/menu.html.heex:53 +#: lib/privee_web/components/layouts/menu.html.heex:53 +#: lib/privee_web/components/layouts/root.html.heex:15 +#: lib/privee_web/components/layouts/root.html.heex:15 +#, elixir-autogen +msgid "Privacy App" +msgstr "" + +#: lib/privee_web/client_texts.ex:53 +#: lib/privee_web/client_texts.ex:53 +#, elixir-autogen +msgid "Privee - Text received" +msgstr "" + +#: lib/privee_web/components/layouts/menu.html.heex:10 +#: lib/privee_web/components/layouts/menu.html.heex:10 +#: lib/privee_web/components/layouts/menu.html.heex:11 +#: lib/privee_web/components/layouts/menu.html.heex:11 +#, elixir-autogen +msgid "Privee logo" +msgstr "" + +#: lib/privee_web/live/session_registration_live.html.heex:61 +#: lib/privee_web/live/session_registration_live.html.heex:61 +#, elixir-autogen +msgid "Quick session" +msgstr "" + +#: lib/privee_web/live/session_login_live.html.heex:33 +#: lib/privee_web/live/session_login_live.html.heex:33 +#: lib/privee_web/live/session_registration_live.html.heex:83 +#: lib/privee_web/live/session_registration_live.html.heex:83 +#, elixir-autogen +msgid "Recovery phrase" +msgstr "" + +#: lib/privee_web/client_texts.ex:43 +#: lib/privee_web/client_texts.ex:43 +#, elixir-autogen +msgid "Reset encryption identity" +msgstr "" + +#: lib/privee_web/client_texts.ex:39 +#: lib/privee_web/client_texts.ex:39 +#, elixir-autogen +msgid "Reset the encryption identity of this session? Your contacts will be asked to verify your new safety number." +msgstr "" + +#: lib/privee_web/live/chat/chat_live.html.heex:18 +#: lib/privee_web/live/chat/chat_live.html.heex:18 +#, elixir-autogen +msgid "Safety number" +msgstr "" + +#: lib/privee_web/client_texts.ex:58 +#: lib/privee_web/client_texts.ex:58 +#: lib/privee_web/live/chat/components/chat_input.html.heex:26 +#: lib/privee_web/live/chat/components/chat_input.html.heex:26 +#, elixir-autogen +msgid "Send" +msgstr "" + +#: lib/privee_web/client_texts.ex:9 +#: lib/privee_web/client_texts.ex:9 +#, elixir-autogen +msgid "Sent from another device." +msgstr "" + +#: lib/privee_web/live/privee_selector_live.html.heex:24 +#: lib/privee_web/live/privee_selector_live.html.heex:24 +#: lib/privee_web/live/session_login_live.html.heex:25 +#: lib/privee_web/live/session_login_live.html.heex:25 +#: lib/privee_web/live/session_registration_live.html.heex:51 +#: lib/privee_web/live/session_registration_live.html.heex:51 +#, elixir-autogen +msgid "Session Name" +msgstr "" + +#: lib/privee_web/client_texts.ex:50 +#: lib/privee_web/client_texts.ex:50 +#, elixir-autogen +msgid "Session copied" +msgstr "" + +#: lib/privee_web/controllers/session_controller.ex:11 +#: lib/privee_web/controllers/session_controller.ex:11 +#, elixir-autogen +msgid "Session created successfully! Your session name has been automatically copied to the clipboard." +msgstr "" + +#: lib/privee_web/components/layouts/menu.html.heex:60 +#: lib/privee_web/components/layouts/menu.html.heex:60 +#: lib/privee_web/components/layouts/menu.html.heex:64 +#: lib/privee_web/components/layouts/menu.html.heex:64 +#, elixir-autogen +msgid "Settings" +msgstr "" + +#: lib/privee_web/components/layouts/menu.html.heex:230 +#: lib/privee_web/components/layouts/menu.html.heex:230 +#: lib/privee_web/components/layouts/menu.html.heex:231 +#: lib/privee_web/components/layouts/menu.html.heex:231 +#: lib/privee_web/live/session_login_live.html.heex:49 +#: lib/privee_web/live/session_login_live.html.heex:49 +#: lib/privee_web/live/session_registration_live.html.heex:16 +#: lib/privee_web/live/session_registration_live.html.heex:16 +#, elixir-autogen +msgid "Sign in" +msgstr "" + +#: lib/privee_web/live/session_login_live.html.heex:9 +#: lib/privee_web/live/session_login_live.html.heex:9 +#, elixir-autogen +msgid "Sign in to existing session" +msgstr "" + +#: lib/privee_web/live/session_login_live.html.heex:48 +#: lib/privee_web/live/session_login_live.html.heex:48 +#, elixir-autogen +msgid "Signing in..." +msgstr "" + +#: lib/privee_web/components/layouts.ex:102 +#: lib/privee_web/components/layouts.ex:102 +#: lib/privee_web/controllers/error_html.ex:25 +#: lib/privee_web/controllers/error_html.ex:25 +#, elixir-autogen +msgid "Something went wrong!" +msgstr "" + +#: lib/privee_web/components/layouts/menu.html.heex:25 +#: lib/privee_web/components/layouts/menu.html.heex:25 +#: lib/privee_web/components/layouts/menu.html.heex:26 +#: lib/privee_web/components/layouts/menu.html.heex:26 +#, elixir-autogen +msgid "Source code (AGPL-3.0)" +msgstr "" + +#: lib/privee_web/live/privee_selector_live.html.heex:16 +#: lib/privee_web/live/privee_selector_live.html.heex:16 +#, elixir-autogen +msgid "Specify the session name you would like to chat with." +msgstr "" + +#: lib/privee_web/live/privee_selector_live.html.heex:30 +#: lib/privee_web/live/privee_selector_live.html.heex:30 +#, elixir-autogen +msgid "Start Privee" +msgstr "" + +#: lib/privee_web/components/layouts.ex:67 +#: lib/privee_web/components/layouts.ex:67 +#, elixir-autogen +msgid "Success!" +msgstr "" + +#: lib/privee_web/components/layouts/menu.html.heex:90 +#: lib/privee_web/components/layouts/menu.html.heex:90 +#, elixir-autogen +msgid "Switch theme" +msgstr "" + +#: lib/privee_web/live/chat/chat_live.ex:84 +#: lib/privee_web/live/chat/chat_live.ex:84 +#, elixir-autogen +msgid "The application was updated. Please reload the page." +msgstr "" + +#: lib/privee_web/client_texts.ex:14 +#: lib/privee_web/client_texts.ex:14 +#, elixir-autogen +msgid "The message could not be delivered yet. It will be sent automatically." +msgstr "" + +#: lib/privee_web/client_texts.ex:12 +#: lib/privee_web/client_texts.ex:12 +#, elixir-autogen +msgid "The message could not be sent. Please try again." +msgstr "" + +#: lib/privee_web/live/privee_selector_live.html.heex:25 +#: lib/privee_web/live/privee_selector_live.html.heex:25 +#, elixir-autogen +msgid "The session name you'd like to contact." +msgstr "" + +#: lib/privee_web/controllers/session_share_controller.ex:47 +#: lib/privee_web/controllers/session_share_controller.ex:47 +#: lib/privee_web/controllers/session_share_controller.ex:60 +#: lib/privee_web/controllers/session_share_controller.ex:60 +#, elixir-autogen +msgid "The session you're trying to join doesn't exist." +msgstr "" + +#: lib/privee_web/components/layouts/menu.html.heex:86 +#: lib/privee_web/components/layouts/menu.html.heex:86 +#, elixir-autogen +msgid "Theme" +msgstr "" + +#: lib/privee_web/client_texts.ex:55 +#: lib/privee_web/client_texts.ex:55 +#, elixir-autogen +msgid "This browser does not support notifications." +msgstr "" + +#: lib/privee_web/client_texts.ex:28 +#: lib/privee_web/client_texts.ex:28 +#, elixir-autogen +msgid "This browser does not support the features required for end-to-end encryption (Web Locks). Please use an up-to-date browser." +msgstr "" + +#: lib/privee_web/client_texts.ex:24 +#: lib/privee_web/client_texts.ex:24 +#, elixir-autogen +msgid "This device has no encryption keys for this session. Reset the encryption identity to continue; messages sent to your previous device will not be readable here." +msgstr "" + +#: lib/privee_web/locale_live.ex:31 +#: lib/privee_web/locale_live.ex:31 +#, elixir-autogen +msgid "This language is not supported." +msgstr "" + +#: lib/privee_web/client_texts.ex:8 +#: lib/privee_web/client_texts.ex:8 +#, elixir-autogen +msgid "This message could not be decrypted." +msgstr "" + +#: lib/privee_web/live/session_registration_live.html.heex:21 +#: lib/privee_web/live/session_registration_live.html.heex:21 +#, elixir-autogen +msgid "To register a session, choose a session name of at least 24 alphanumeric characters separated by hyphens, and a recovery phrase containing only letters, spaces and punctuation." +msgstr "" + +#: lib/privee_web/client_texts.ex:51 +#: lib/privee_web/client_texts.ex:51 +#, elixir-autogen +msgid "Url copied" +msgstr "" + +#: lib/privee_web/live/session_registration_live.html.heex:11 +#: lib/privee_web/live/session_registration_live.html.heex:11 +#, elixir-autogen +msgid "Want to reconnect with an existing session?" +msgstr "" + +#: lib/privee_web/components/layouts.ex:69 +#: lib/privee_web/components/layouts.ex:69 +#, elixir-autogen +msgid "Warning!" +msgstr "" + +#: lib/privee_web/components/layouts.ex:73 +#: lib/privee_web/components/layouts.ex:73 +#, elixir-autogen +msgid "We can't find the internet" +msgstr "" + +#: lib/privee_web/controllers/session_controller.ex:18 +#: lib/privee_web/controllers/session_controller.ex:18 +#, elixir-autogen +msgid "Welcome back!" +msgstr "" + +#: lib/privee_web/live/session_registration_live.html.heex:66 +#: lib/privee_web/live/session_registration_live.html.heex:66 +#, elixir-autogen +msgid "What's this?" +msgstr "" + +#: lib/privee_web/client_texts.ex:56 +#: lib/privee_web/client_texts.ex:56 +#: lib/privee_web/live/chat/components/chat_input.html.heex:12 +#: lib/privee_web/live/chat/components/chat_input.html.heex:12 +#, elixir-autogen +msgid "Write your message here" +msgstr "" + +#: lib/privee_web/live/session_registration_live.html.heex:52 +#: lib/privee_web/live/session_registration_live.html.heex:52 +#, elixir-autogen +msgid "Write your own session name" +msgstr "" + +#: lib/privee_web/live/session_registration_live.html.heex:85 +#: lib/privee_web/live/session_registration_live.html.heex:85 +#, elixir-autogen +msgid "Write your preferred citation. This will be used to recover the session, so keep it saved." +msgstr "" + +#: lib/privee_web/live/session_login_live.html.heex:26 +#: lib/privee_web/live/session_login_live.html.heex:26 +#, elixir-autogen +msgid "Write your session name." +msgstr "" + +#: lib/privee_web/live/chat/chat_live.ex:53 +#: lib/privee_web/live/chat/chat_live.ex:53 +#: lib/privee_web/live/chat/chat_live.ex:113 +#: lib/privee_web/live/chat/chat_live.ex:113 +#, elixir-autogen +msgid "You have to select a session to continue" +msgstr "" + +#: lib/privee_web/session_auth.ex:165 +#: lib/privee_web/session_auth.ex:165 +#: lib/privee_web/session_auth.ex:214 +#: lib/privee_web/session_auth.ex:214 +#, elixir-autogen +msgid "You must have a session to access this page." +msgstr "" + +#: lib/privee_web/live/session_login_live.html.heex:34 +#: lib/privee_web/live/session_login_live.html.heex:34 +#, elixir-autogen +msgid "Your citation." +msgstr "" + +#: lib/privee_web/client_texts.ex:11 +#: lib/privee_web/client_texts.ex:11 +#, elixir-autogen +msgid "Your contact has not set up encryption yet. Try again once they are online." +msgstr "" + +#: lib/privee_web/client_texts.ex:16 +#: lib/privee_web/client_texts.ex:16 +#, elixir-autogen +msgid "Your contact's security code changed. They may have reset their device, or someone may be intercepting the conversation. Verify the safety number with them before continuing." +msgstr "" + +#: lib/privee_web/locale_live.ex:36 +#: lib/privee_web/locale_live.ex:36 +#, elixir-autogen +msgid "Your language preference could not be saved." +msgstr "" diff --git a/apps/privee_web/priv/gettext/en/LC_MESSAGES/default.po b/apps/privee_web/priv/gettext/en/LC_MESSAGES/default.po new file mode 100644 index 00000000..57219e61 --- /dev/null +++ b/apps/privee_web/priv/gettext/en/LC_MESSAGES/default.po @@ -0,0 +1,278 @@ +msgid "" +msgstr "" +"Language: en\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgid "A quick session needs no recovery phrase. You can sign in only once; you will not be able to recover this session later." +msgstr "A quick session needs no recovery phrase. You can sign in only once; you will not be able to recover this session later." + +msgid "Accept new security code" +msgstr "Accept new security code" + +msgid "Actions" +msgstr "Actions" + +msgid "Attempting to reconnect" +msgstr "Attempting to reconnect" + +msgid "Chatting app with focus on privacy" +msgstr "Chatting app with focus on privacy" + +msgid "Clear history on this device" +msgstr "Clear history on this device" + +msgid "Close" +msgstr "Close" + +msgid "Compare this number with your contact, in person or over another channel. If it matches, the conversation is end-to-end encrypted." +msgstr "Compare this number with your contact, in person or over another channel. If it matches, the conversation is end-to-end encrypted." + +msgid "Copy link" +msgstr "Copy link" + +msgid "Copy session name" +msgstr "Copy session name" + +msgid "Could not copy to the clipboard." +msgstr "Could not copy to the clipboard." + +msgid "Create" +msgstr "Create" + +msgid "Create a new Privee" +msgstr "Create a new Privee" + +msgid "Create a new one" +msgstr "Create a new one" + +msgid "Create a new session" +msgstr "Create a new session" + +msgid "Create a session quickly without a recovery phrase" +msgstr "Create a session quickly without a recovery phrase" + +msgid "Create an account" +msgstr "Create an account" + +msgid "Creating account..." +msgstr "Creating account..." + +msgid "Creating..." +msgstr "Creating..." + +msgid "Delete all encryption keys and history of this session from this device? You will need to reset encryption to chat again." +msgstr "Delete all encryption keys and history of this session from this device? You will need to reset encryption to chat again." + +msgid "Delete the local history of this conversation from this device?" +msgstr "Delete the local history of this conversation from this device?" + +msgid "Don't have a session?" +msgstr "Don't have a session?" + +msgid "Earlier on this device" +msgstr "Earlier on this device" + +msgid "Encryption could not be initialized. Please reload the page." +msgstr "Encryption could not be initialized. Please reload the page." + +msgid "Encryption for this session was reset on another device, so this device can no longer send or receive messages." +msgstr "Encryption for this session was reset on another device, so this device can no longer send or receive messages." + +msgid "Error!" +msgstr "Error!" + +msgid "Exchange a message first to compare safety numbers." +msgstr "Exchange a message first to compare safety numbers." + +msgid "Forget this device" +msgstr "Forget this device" + +msgid "Hang in there while we get back on track" +msgstr "Hang in there while we get back on track" + +msgid "Information" +msgstr "Information" + +msgid "Internal Server Error" +msgstr "Internal Server Error" + +msgid "Invalid recovery phrase or session name" +msgstr "Invalid recovery phrase or session name" + +msgid "Keep me logged in" +msgstr "Keep me logged in" + +msgid "Language" +msgstr "Language" + +msgid "Log Out" +msgstr "Log Out" + +msgid "Logged out successfully." +msgstr "Logged out successfully." + +msgid "Message" +msgstr "Message" + +msgid "New message received" +msgstr "New message received" + +msgid "Not Found" +msgstr "Not Found" + +msgid "Oops, something went wrong! Please check the errors below." +msgstr "Oops, something went wrong! Please check the errors below." + +msgid "Privacy App" +msgstr "Privacy App" + +msgid "Privee - Text received" +msgstr "Privee - Text received" + +msgid "Privee logo" +msgstr "Privee logo" + +msgid "Quick session" +msgstr "Quick session" + +msgid "Recovery phrase" +msgstr "Recovery phrase" + +msgid "Reset encryption identity" +msgstr "Reset encryption identity" + +msgid "Reset the encryption identity of this session? Your contacts will be asked to verify your new safety number." +msgstr "Reset the encryption identity of this session? Your contacts will be asked to verify your new safety number." + +msgid "Safety number" +msgstr "Safety number" + +msgid "Send" +msgstr "Send" + +msgid "Sent from another device." +msgstr "Sent from another device." + +msgid "Session Name" +msgstr "Session Name" + +msgid "Session copied" +msgstr "Session copied" + +msgid "Session created successfully! Your session name has been automatically copied to the clipboard." +msgstr "Session created successfully! Your session name has been automatically copied to the clipboard." + +msgid "Settings" +msgstr "Settings" + +msgid "Sign in" +msgstr "Sign in" + +msgid "Sign in to existing session" +msgstr "Sign in to existing session" + +msgid "Signing in..." +msgstr "Signing in..." + +msgid "Something went wrong!" +msgstr "Something went wrong!" + +msgid "Source code (AGPL-3.0)" +msgstr "Source code (AGPL-3.0)" + +msgid "Specify the session name you would like to chat with." +msgstr "Specify the session name you would like to chat with." + +msgid "Start Privee" +msgstr "Start Privee" + +msgid "Success!" +msgstr "Success!" + +msgid "Switch theme" +msgstr "Switch theme" + +msgid "The application was updated. Please reload the page." +msgstr "The application was updated. Please reload the page." + +msgid "The message could not be delivered yet. It will be sent automatically." +msgstr "The message could not be delivered yet. It will be sent automatically." + +msgid "The message could not be sent. Please try again." +msgstr "The message could not be sent. Please try again." + +msgid "The session name you'd like to contact." +msgstr "The session name you'd like to contact." + +msgid "The session you're trying to join doesn't exist." +msgstr "The session you're trying to join doesn't exist." + +msgid "Theme" +msgstr "Theme" + +msgid "This browser does not support notifications." +msgstr "This browser does not support notifications." + +msgid "This browser does not support the features required for end-to-end encryption (Web Locks). Please use an up-to-date browser." +msgstr "This browser does not support the features required for end-to-end encryption (Web Locks). Please use an up-to-date browser." + +msgid "This device has no encryption keys for this session. Reset the encryption identity to continue; messages sent to your previous device will not be readable here." +msgstr "This device has no encryption keys for this session. Reset the encryption identity to continue; messages sent to your previous device will not be readable here." + +msgid "This language is not supported." +msgstr "This language is not supported." + +msgid "This message could not be decrypted." +msgstr "This message could not be decrypted." + +msgid "To register a session, choose a session name of at least 24 alphanumeric characters separated by hyphens, and a recovery phrase containing only letters, spaces and punctuation." +msgstr "To register a session, choose a session name of at least 24 alphanumeric characters separated by hyphens, and a recovery phrase containing only letters, spaces and punctuation." + +msgid "Url copied" +msgstr "Url copied" + +msgid "Want to reconnect with an existing session?" +msgstr "Want to reconnect with an existing session?" + +msgid "Warning!" +msgstr "Warning!" + +msgid "We can't find the internet" +msgstr "We can't find the internet" + +msgid "Welcome back!" +msgstr "Welcome back!" + +msgid "What's this?" +msgstr "What's this?" + +msgid "Write your message here" +msgstr "Write your message here" + +msgid "Write your own session name" +msgstr "Write your own session name" + +msgid "Write your preferred citation. This will be used to recover the session, so keep it saved." +msgstr "Write your preferred citation. This will be used to recover the session, so keep it saved." + +msgid "Write your session name." +msgstr "Write your session name." + +msgid "You have to select a session to continue" +msgstr "You have to select a session to continue" + +msgid "You must have a session to access this page." +msgstr "You must have a session to access this page." + +msgid "Your citation." +msgstr "Your citation." + +msgid "Your contact has not set up encryption yet. Try again once they are online." +msgstr "Your contact has not set up encryption yet. Try again once they are online." + +msgid "Your contact's security code changed. They may have reset their device, or someone may be intercepting the conversation. Verify the safety number with them before continuing." +msgstr "Your contact's security code changed. They may have reset their device, or someone may be intercepting the conversation. Verify the safety number with them before continuing." + +msgid "Your language preference could not be saved." +msgstr "Your language preference could not be saved." diff --git a/apps/privee_web/priv/gettext/en/LC_MESSAGES/errors.po b/apps/privee_web/priv/gettext/en/LC_MESSAGES/errors.po index 844c4f5c..79813ea6 100644 --- a/apps/privee_web/priv/gettext/en/LC_MESSAGES/errors.po +++ b/apps/privee_web/priv/gettext/en/LC_MESSAGES/errors.po @@ -1,112 +1,116 @@ -## `msgid`s in this file come from POT (.pot) files. -## -## Do not add, change, or remove `msgid`s manually here as -## they're tied to the ones in the corresponding POT file -## (with the same domain). -## -## Use `mix gettext.extract --merge` or `mix gettext.merge` -## to merge POT files into PO files. msgid "" msgstr "" "Language: en\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgid "The session name does not exist" +msgstr "The session name does not exist" + +msgid "You selected your session name" +msgstr "You selected your session name" + +msgid "are still associated with this entry" +msgstr "are still associated with this entry" -## From Ecto.Changeset.cast/4 msgid "can't be blank" -msgstr "" +msgstr "can't be blank" + +msgid "cannot be the sender" +msgstr "cannot be the sender" + +msgid "does not match confirmation" +msgstr "does not match confirmation" -## From Ecto.Changeset.unique_constraint/3 msgid "has already been taken" -msgstr "" +msgstr "has already been taken" + +msgid "has an invalid entry" +msgstr "has an invalid entry" + +msgid "has invalid format" +msgstr "has invalid format" -## From Ecto.Changeset.put_change/3 msgid "is invalid" -msgstr "" +msgstr "is invalid" + +msgid "is not valid" +msgstr "is not valid" + +msgid "is reserved" +msgstr "is reserved" + +msgid "is still associated with this entry" +msgstr "is still associated with this entry" -## From Ecto.Changeset.validate_acceptance/3 msgid "must be accepted" -msgstr "" +msgstr "must be accepted" -## From Ecto.Changeset.validate_format/3 -msgid "has invalid format" -msgstr "" +msgid "must be empty for quick sessions" +msgstr "must be empty for quick sessions" -## From Ecto.Changeset.validate_subset/3 -msgid "has an invalid entry" -msgstr "" +msgid "must be equal to %{number}" +msgstr "must be equal to %{number}" -## From Ecto.Changeset.validate_exclusion/3 -msgid "is reserved" -msgstr "" +msgid "must be greater than %{number}" +msgstr "must be greater than %{number}" -## From Ecto.Changeset.validate_confirmation/3 -msgid "does not match confirmation" -msgstr "" +msgid "must be greater than or equal to %{number}" +msgstr "must be greater than or equal to %{number}" -## From Ecto.Changeset.no_assoc_constraint/3 -msgid "is still associated with this entry" -msgstr "" +msgid "must be less than %{number}" +msgstr "must be less than %{number}" -msgid "are still associated with this entry" -msgstr "" +msgid "must be less than or equal to %{number}" +msgstr "must be less than or equal to %{number}" -## From Ecto.Changeset.validate_length/3 -msgid "should have %{count} item(s)" -msgid_plural "should have %{count} item(s)" -msgstr[0] "" -msgstr[1] "" +msgid "must contain only alphabetic characters and punctuation" +msgstr "must contain only alphabetic characters and punctuation" -msgid "should be %{count} character(s)" -msgid_plural "should be %{count} character(s)" -msgstr[0] "" -msgstr[1] "" +msgid "must contain only alphanumeric characters and hyphens" +msgstr "must contain only alphanumeric characters and hyphens" msgid "should be %{count} byte(s)" msgid_plural "should be %{count} byte(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should have at least %{count} item(s)" -msgid_plural "should have at least %{count} item(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "should be %{count} byte" +msgstr[1] "should be %{count} bytes" -msgid "should be at least %{count} character(s)" -msgid_plural "should be at least %{count} character(s)" -msgstr[0] "" -msgstr[1] "" +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "should be %{count} character" +msgstr[1] "should be %{count} characters" msgid "should be at least %{count} byte(s)" msgid_plural "should be at least %{count} byte(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "should be at least %{count} byte" +msgstr[1] "should be at least %{count} bytes" -msgid "should have at most %{count} item(s)" -msgid_plural "should have at most %{count} item(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should be at most %{count} character(s)" -msgid_plural "should be at most %{count} character(s)" -msgstr[0] "" -msgstr[1] "" +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "should be at least %{count} character" +msgstr[1] "should be at least %{count} characters" msgid "should be at most %{count} byte(s)" msgid_plural "should be at most %{count} byte(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "should be at most %{count} byte" +msgstr[1] "should be at most %{count} bytes" -## From Ecto.Changeset.validate_number/3 -msgid "must be less than %{number}" -msgstr "" - -msgid "must be greater than %{number}" -msgstr "" +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "should be at most %{count} character" +msgstr[1] "should be at most %{count} characters" -msgid "must be less than or equal to %{number}" -msgstr "" +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "should have %{count} item" +msgstr[1] "should have %{count} items" -msgid "must be greater than or equal to %{number}" -msgstr "" +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "should have at least %{count} item" +msgstr[1] "should have at least %{count} items" -msgid "must be equal to %{number}" -msgstr "" +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "should have at most %{count} item" +msgstr[1] "should have at most %{count} items" diff --git a/apps/privee_web/priv/gettext/errors.pot b/apps/privee_web/priv/gettext/errors.pot index eef2de2b..35357450 100644 --- a/apps/privee_web/priv/gettext/errors.pot +++ b/apps/privee_web/priv/gettext/errors.pot @@ -107,3 +107,24 @@ msgstr "" msgid "must be equal to %{number}" msgstr "" + +msgid "must contain only alphanumeric characters and hyphens" +msgstr "" + +msgid "must be empty for quick sessions" +msgstr "" + +msgid "must contain only alphabetic characters and punctuation" +msgstr "" + +msgid "is not valid" +msgstr "" + +msgid "The session name does not exist" +msgstr "" + +msgid "You selected your session name" +msgstr "" + +msgid "cannot be the sender" +msgstr "" diff --git a/apps/privee_web/priv/gettext/es/LC_MESSAGES/default.po b/apps/privee_web/priv/gettext/es/LC_MESSAGES/default.po new file mode 100644 index 00000000..b462f814 --- /dev/null +++ b/apps/privee_web/priv/gettext/es/LC_MESSAGES/default.po @@ -0,0 +1,278 @@ +msgid "" +msgstr "" +"Language: es\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgid "A quick session needs no recovery phrase. You can sign in only once; you will not be able to recover this session later." +msgstr "Una sesión rápida no necesita frase de recuperación. Solo puedes iniciar sesión una vez; no podrás recuperarla más adelante." + +msgid "Accept new security code" +msgstr "Aceptar nuevo código de seguridad" + +msgid "Actions" +msgstr "Acciones" + +msgid "Attempting to reconnect" +msgstr "Intentando reconectar" + +msgid "Chatting app with focus on privacy" +msgstr "Aplicación de chat centrada en la privacidad" + +msgid "Clear history on this device" +msgstr "Borrar historial en este dispositivo" + +msgid "Close" +msgstr "Cerrar" + +msgid "Compare this number with your contact, in person or over another channel. If it matches, the conversation is end-to-end encrypted." +msgstr "Compara este número con tu contacto, en persona o por otro canal. Si coincide, la conversación está cifrada de extremo a extremo." + +msgid "Copy link" +msgstr "Copiar enlace" + +msgid "Copy session name" +msgstr "Copiar nombre de sesión" + +msgid "Could not copy to the clipboard." +msgstr "No se ha podido copiar al portapapeles." + +msgid "Create" +msgstr "Crear" + +msgid "Create a new Privee" +msgstr "Crear un nuevo Privee" + +msgid "Create a new one" +msgstr "Crear una nueva" + +msgid "Create a new session" +msgstr "Crear una nueva sesión" + +msgid "Create a session quickly without a recovery phrase" +msgstr "Crea una sesión rápidamente sin frase de recuperación" + +msgid "Create an account" +msgstr "Crear una cuenta" + +msgid "Creating account..." +msgstr "Creando cuenta..." + +msgid "Creating..." +msgstr "Creando..." + +msgid "Delete all encryption keys and history of this session from this device? You will need to reset encryption to chat again." +msgstr "¿Eliminar todas las claves de cifrado y el historial de esta sesión de este dispositivo? Tendrás que restablecer el cifrado para volver a chatear." + +msgid "Delete the local history of this conversation from this device?" +msgstr "¿Eliminar el historial local de esta conversación de este dispositivo?" + +msgid "Don't have a session?" +msgstr "¿No tienes una sesión?" + +msgid "Earlier on this device" +msgstr "Anteriormente en este dispositivo" + +msgid "Encryption could not be initialized. Please reload the page." +msgstr "No se ha podido inicializar el cifrado. Recarga la página." + +msgid "Encryption for this session was reset on another device, so this device can no longer send or receive messages." +msgstr "El cifrado de esta sesión se ha restablecido en otro dispositivo, por lo que este dispositivo ya no puede enviar ni recibir mensajes." + +msgid "Error!" +msgstr "¡Error!" + +msgid "Exchange a message first to compare safety numbers." +msgstr "Intercambia un mensaje primero para comparar los números de seguridad." + +msgid "Forget this device" +msgstr "Olvidar este dispositivo" + +msgid "Hang in there while we get back on track" +msgstr "Espera mientras restablecemos el servicio" + +msgid "Information" +msgstr "Información" + +msgid "Internal Server Error" +msgstr "Error interno del servidor" + +msgid "Invalid recovery phrase or session name" +msgstr "Frase de recuperación o nombre de sesión no válidos" + +msgid "Keep me logged in" +msgstr "Mantener la sesión iniciada" + +msgid "Language" +msgstr "Idioma" + +msgid "Log Out" +msgstr "Cerrar sesión" + +msgid "Logged out successfully." +msgstr "Sesión cerrada correctamente." + +msgid "Message" +msgstr "Mensaje" + +msgid "New message received" +msgstr "Nuevo mensaje recibido" + +msgid "Not Found" +msgstr "Página no encontrada" + +msgid "Oops, something went wrong! Please check the errors below." +msgstr "¡Algo ha salido mal! Revisa los errores de abajo." + +msgid "Privacy App" +msgstr "Aplicación de privacidad" + +msgid "Privee - Text received" +msgstr "Privee - Mensaje recibido" + +msgid "Privee logo" +msgstr "Logotipo de Privee" + +msgid "Quick session" +msgstr "Sesión rápida" + +msgid "Recovery phrase" +msgstr "Frase de recuperación" + +msgid "Reset encryption identity" +msgstr "Restablecer identidad de cifrado" + +msgid "Reset the encryption identity of this session? Your contacts will be asked to verify your new safety number." +msgstr "¿Restablecer la identidad de cifrado de esta sesión? Se pedirá a tus contactos que verifiquen tu nuevo número de seguridad." + +msgid "Safety number" +msgstr "Número de seguridad" + +msgid "Send" +msgstr "Enviar" + +msgid "Sent from another device." +msgstr "Enviado desde otro dispositivo." + +msgid "Session Name" +msgstr "Nombre de sesión" + +msgid "Session copied" +msgstr "Sesión copiada" + +msgid "Session created successfully! Your session name has been automatically copied to the clipboard." +msgstr "¡Sesión creada! El nombre de sesión se ha copiado automáticamente al portapapeles." + +msgid "Settings" +msgstr "Ajustes" + +msgid "Sign in" +msgstr "Iniciar sesión" + +msgid "Sign in to existing session" +msgstr "Iniciar sesión en una sesión existente" + +msgid "Signing in..." +msgstr "Iniciando sesión..." + +msgid "Something went wrong!" +msgstr "¡Algo ha salido mal!" + +msgid "Source code (AGPL-3.0)" +msgstr "Código fuente (AGPL-3.0)" + +msgid "Specify the session name you would like to chat with." +msgstr "Indica el nombre de la sesión con la que quieres chatear." + +msgid "Start Privee" +msgstr "Iniciar Privee" + +msgid "Success!" +msgstr "¡Éxito!" + +msgid "Switch theme" +msgstr "Cambiar tema" + +msgid "The application was updated. Please reload the page." +msgstr "La aplicación se ha actualizado. Recarga la página." + +msgid "The message could not be delivered yet. It will be sent automatically." +msgstr "El mensaje aún no se ha podido entregar. Se enviará automáticamente." + +msgid "The message could not be sent. Please try again." +msgstr "No se ha podido enviar el mensaje. Inténtalo de nuevo." + +msgid "The session name you'd like to contact." +msgstr "El nombre de la sesión que quieres contactar." + +msgid "The session you're trying to join doesn't exist." +msgstr "La sesión a la que intentas unirte no existe." + +msgid "Theme" +msgstr "Tema" + +msgid "This browser does not support notifications." +msgstr "Este navegador no admite notificaciones." + +msgid "This browser does not support the features required for end-to-end encryption (Web Locks). Please use an up-to-date browser." +msgstr "Este navegador no admite las funciones necesarias para el cifrado de extremo a extremo (Web Locks). Usa un navegador actualizado." + +msgid "This device has no encryption keys for this session. Reset the encryption identity to continue; messages sent to your previous device will not be readable here." +msgstr "Este dispositivo no tiene claves de cifrado para esta sesión. Restablece la identidad de cifrado para continuar; los mensajes enviados a tu dispositivo anterior no se podrán leer aquí." + +msgid "This language is not supported." +msgstr "Este idioma no es compatible." + +msgid "This message could not be decrypted." +msgstr "No se ha podido descifrar este mensaje." + +msgid "To register a session, choose a session name of at least 24 alphanumeric characters separated by hyphens, and a recovery phrase containing only letters, spaces and punctuation." +msgstr "Para registrar una sesión, elige un nombre de al menos 24 caracteres alfanuméricos separados por guiones y una frase de recuperación que contenga solo letras, espacios y puntuación." + +msgid "Url copied" +msgstr "Enlace copiado" + +msgid "Want to reconnect with an existing session?" +msgstr "¿Quieres reconectar con una sesión existente?" + +msgid "Warning!" +msgstr "¡Advertencia!" + +msgid "We can't find the internet" +msgstr "No hay conexión a Internet" + +msgid "Welcome back!" +msgstr "¡Bienvenido de nuevo!" + +msgid "What's this?" +msgstr "¿Qué es esto?" + +msgid "Write your message here" +msgstr "Escribe tu mensaje aquí" + +msgid "Write your own session name" +msgstr "Escribe tu nombre de sesión" + +msgid "Write your preferred citation. This will be used to recover the session, so keep it saved." +msgstr "Escribe tu cita preferida. Se usará para recuperar la sesión, así que guárdala." + +msgid "Write your session name." +msgstr "Escribe tu nombre de sesión." + +msgid "You have to select a session to continue" +msgstr "Debes seleccionar una sesión para continuar" + +msgid "You must have a session to access this page." +msgstr "Debes tener una sesión para acceder a esta página." + +msgid "Your citation." +msgstr "Tu cita." + +msgid "Your contact has not set up encryption yet. Try again once they are online." +msgstr "Tu contacto aún no ha configurado el cifrado. Inténtalo cuando esté en línea." + +msgid "Your contact's security code changed. They may have reset their device, or someone may be intercepting the conversation. Verify the safety number with them before continuing." +msgstr "El código de seguridad de tu contacto ha cambiado. Puede haber restablecido su dispositivo, o alguien podría estar interceptando la conversación. Verifica el número de seguridad con tu contacto antes de continuar." + +msgid "Your language preference could not be saved." +msgstr "No se ha podido guardar tu preferencia de idioma." diff --git a/apps/privee_web/priv/gettext/es/LC_MESSAGES/errors.po b/apps/privee_web/priv/gettext/es/LC_MESSAGES/errors.po new file mode 100644 index 00000000..eeb7f2c1 --- /dev/null +++ b/apps/privee_web/priv/gettext/es/LC_MESSAGES/errors.po @@ -0,0 +1,116 @@ +msgid "" +msgstr "" +"Language: es\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgid "The session name does not exist" +msgstr "El nombre de sesión no existe" + +msgid "You selected your session name" +msgstr "Has seleccionado tu propio nombre de sesión" + +msgid "are still associated with this entry" +msgstr "siguen asociados a esta entrada" + +msgid "can't be blank" +msgstr "no puede estar en blanco" + +msgid "cannot be the sender" +msgstr "no puede ser el remitente" + +msgid "does not match confirmation" +msgstr "no coincide con la confirmación" + +msgid "has already been taken" +msgstr "ya está en uso" + +msgid "has an invalid entry" +msgstr "contiene una entrada no válida" + +msgid "has invalid format" +msgstr "tiene un formato no válido" + +msgid "is invalid" +msgstr "no es válido" + +msgid "is not valid" +msgstr "no es válido" + +msgid "is reserved" +msgstr "está reservado" + +msgid "is still associated with this entry" +msgstr "sigue asociado a esta entrada" + +msgid "must be accepted" +msgstr "debe aceptarse" + +msgid "must be empty for quick sessions" +msgstr "debe estar vacío para sesiones rápidas" + +msgid "must be equal to %{number}" +msgstr "debe ser igual a %{number}" + +msgid "must be greater than %{number}" +msgstr "debe ser mayor que %{number}" + +msgid "must be greater than or equal to %{number}" +msgstr "debe ser mayor o igual que %{number}" + +msgid "must be less than %{number}" +msgstr "debe ser menor que %{number}" + +msgid "must be less than or equal to %{number}" +msgstr "debe ser menor o igual que %{number}" + +msgid "must contain only alphabetic characters and punctuation" +msgstr "debe contener solo letras y puntuación" + +msgid "must contain only alphanumeric characters and hyphens" +msgstr "debe contener solo caracteres alfanuméricos y guiones" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "debe tener %{count} byte" +msgstr[1] "debe tener %{count} bytes" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "debe tener %{count} carácter" +msgstr[1] "debe tener %{count} caracteres" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "debe tener al menos %{count} byte" +msgstr[1] "debe tener al menos %{count} bytes" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "debe tener al menos %{count} carácter" +msgstr[1] "debe tener al menos %{count} caracteres" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "debe tener como máximo %{count} byte" +msgstr[1] "debe tener como máximo %{count} bytes" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "debe tener como máximo %{count} carácter" +msgstr[1] "debe tener como máximo %{count} caracteres" + +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "debe tener %{count} elemento" +msgstr[1] "debe tener %{count} elementos" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "debe tener al menos %{count} elemento" +msgstr[1] "debe tener al menos %{count} elementos" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "debe tener como máximo %{count} elemento" +msgstr[1] "debe tener como máximo %{count} elementos" diff --git a/apps/privee_web/priv/gettext/fr/LC_MESSAGES/default.po b/apps/privee_web/priv/gettext/fr/LC_MESSAGES/default.po new file mode 100644 index 00000000..c05ba720 --- /dev/null +++ b/apps/privee_web/priv/gettext/fr/LC_MESSAGES/default.po @@ -0,0 +1,278 @@ +msgid "" +msgstr "" +"Language: fr\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +msgid "A quick session needs no recovery phrase. You can sign in only once; you will not be able to recover this session later." +msgstr "Une session rapide ne nécessite pas de phrase de récupération. Vous ne pouvez vous connecter qu'une seule fois ; vous ne pourrez pas récupérer cette session plus tard." + +msgid "Accept new security code" +msgstr "Accepter le nouveau code de sécurité" + +msgid "Actions" +msgstr "Actions" + +msgid "Attempting to reconnect" +msgstr "Tentative de reconnexion" + +msgid "Chatting app with focus on privacy" +msgstr "Application de discussion axée sur la confidentialité" + +msgid "Clear history on this device" +msgstr "Effacer l'historique sur cet appareil" + +msgid "Close" +msgstr "Fermer" + +msgid "Compare this number with your contact, in person or over another channel. If it matches, the conversation is end-to-end encrypted." +msgstr "Comparez ce numéro avec votre contact, en personne ou par un autre canal. S'il correspond, la conversation est chiffrée de bout en bout." + +msgid "Copy link" +msgstr "Copier le lien" + +msgid "Copy session name" +msgstr "Copier le nom de session" + +msgid "Could not copy to the clipboard." +msgstr "Impossible de copier dans le presse-papiers." + +msgid "Create" +msgstr "Créer" + +msgid "Create a new Privee" +msgstr "Créer un nouveau Privee" + +msgid "Create a new one" +msgstr "En créer une" + +msgid "Create a new session" +msgstr "Créer une nouvelle session" + +msgid "Create a session quickly without a recovery phrase" +msgstr "Créez rapidement une session sans phrase de récupération" + +msgid "Create an account" +msgstr "Créer un compte" + +msgid "Creating account..." +msgstr "Création du compte..." + +msgid "Creating..." +msgstr "Création..." + +msgid "Delete all encryption keys and history of this session from this device? You will need to reset encryption to chat again." +msgstr "Supprimer toutes les clés de chiffrement et l'historique de cette session sur cet appareil ? Vous devrez réinitialiser le chiffrement pour discuter à nouveau." + +msgid "Delete the local history of this conversation from this device?" +msgstr "Supprimer l'historique local de cette conversation sur cet appareil ?" + +msgid "Don't have a session?" +msgstr "Vous n'avez pas de session ?" + +msgid "Earlier on this device" +msgstr "Plus tôt sur cet appareil" + +msgid "Encryption could not be initialized. Please reload the page." +msgstr "Impossible d'initialiser le chiffrement. Rechargez la page." + +msgid "Encryption for this session was reset on another device, so this device can no longer send or receive messages." +msgstr "Le chiffrement de cette session a été réinitialisé sur un autre appareil. Cet appareil ne peut plus envoyer ni recevoir de messages." + +msgid "Error!" +msgstr "Erreur !" + +msgid "Exchange a message first to compare safety numbers." +msgstr "Échangez d'abord un message pour comparer les numéros de sécurité." + +msgid "Forget this device" +msgstr "Oublier cet appareil" + +msgid "Hang in there while we get back on track" +msgstr "Patientez pendant le rétablissement du service" + +msgid "Information" +msgstr "Informations" + +msgid "Internal Server Error" +msgstr "Erreur interne du serveur" + +msgid "Invalid recovery phrase or session name" +msgstr "Phrase de récupération ou nom de session incorrect" + +msgid "Keep me logged in" +msgstr "Rester connecté" + +msgid "Language" +msgstr "Langue" + +msgid "Log Out" +msgstr "Se déconnecter" + +msgid "Logged out successfully." +msgstr "Déconnexion réussie." + +msgid "Message" +msgstr "Message" + +msgid "New message received" +msgstr "Nouveau message reçu" + +msgid "Not Found" +msgstr "Page introuvable" + +msgid "Oops, something went wrong! Please check the errors below." +msgstr "Une erreur s'est produite ! Vérifiez les erreurs ci-dessous." + +msgid "Privacy App" +msgstr "Application de confidentialité" + +msgid "Privee - Text received" +msgstr "Privee - Message reçu" + +msgid "Privee logo" +msgstr "Logo de Privee" + +msgid "Quick session" +msgstr "Session rapide" + +msgid "Recovery phrase" +msgstr "Phrase de récupération" + +msgid "Reset encryption identity" +msgstr "Réinitialiser l'identité de chiffrement" + +msgid "Reset the encryption identity of this session? Your contacts will be asked to verify your new safety number." +msgstr "Réinitialiser l'identité de chiffrement de cette session ? Vos contacts devront vérifier votre nouveau numéro de sécurité." + +msgid "Safety number" +msgstr "Numéro de sécurité" + +msgid "Send" +msgstr "Envoyer" + +msgid "Sent from another device." +msgstr "Envoyé depuis un autre appareil." + +msgid "Session Name" +msgstr "Nom de session" + +msgid "Session copied" +msgstr "Session copiée" + +msgid "Session created successfully! Your session name has been automatically copied to the clipboard." +msgstr "Session créée ! Le nom de session a été copié automatiquement dans le presse-papiers." + +msgid "Settings" +msgstr "Paramètres" + +msgid "Sign in" +msgstr "Se connecter" + +msgid "Sign in to existing session" +msgstr "Se connecter à une session existante" + +msgid "Signing in..." +msgstr "Connexion..." + +msgid "Something went wrong!" +msgstr "Une erreur s'est produite !" + +msgid "Source code (AGPL-3.0)" +msgstr "Code source (AGPL-3.0)" + +msgid "Specify the session name you would like to chat with." +msgstr "Indiquez le nom de la session avec laquelle vous souhaitez discuter." + +msgid "Start Privee" +msgstr "Démarrer Privee" + +msgid "Success!" +msgstr "Succès !" + +msgid "Switch theme" +msgstr "Changer de thème" + +msgid "The application was updated. Please reload the page." +msgstr "L'application a été mise à jour. Rechargez la page." + +msgid "The message could not be delivered yet. It will be sent automatically." +msgstr "Le message n'a pas encore pu être livré. Il sera envoyé automatiquement." + +msgid "The message could not be sent. Please try again." +msgstr "Impossible d'envoyer le message. Réessayez." + +msgid "The session name you'd like to contact." +msgstr "Le nom de la session à contacter." + +msgid "The session you're trying to join doesn't exist." +msgstr "La session que vous essayez de rejoindre n'existe pas." + +msgid "Theme" +msgstr "Thème" + +msgid "This browser does not support notifications." +msgstr "Ce navigateur ne prend pas en charge les notifications." + +msgid "This browser does not support the features required for end-to-end encryption (Web Locks). Please use an up-to-date browser." +msgstr "Ce navigateur ne prend pas en charge les fonctionnalités nécessaires au chiffrement de bout en bout (Web Locks). Utilisez un navigateur à jour." + +msgid "This device has no encryption keys for this session. Reset the encryption identity to continue; messages sent to your previous device will not be readable here." +msgstr "Cet appareil n'a pas de clés de chiffrement pour cette session. Réinitialisez l'identité de chiffrement pour continuer ; les messages envoyés à votre ancien appareil ne seront pas lisibles ici." + +msgid "This language is not supported." +msgstr "Cette langue n'est pas prise en charge." + +msgid "This message could not be decrypted." +msgstr "Impossible de déchiffrer ce message." + +msgid "To register a session, choose a session name of at least 24 alphanumeric characters separated by hyphens, and a recovery phrase containing only letters, spaces and punctuation." +msgstr "Pour créer une session, choisissez un nom d'au moins 24 caractères alphanumériques séparés par des tirets et une phrase de récupération contenant uniquement des lettres, des espaces et de la ponctuation." + +msgid "Url copied" +msgstr "Lien copié" + +msgid "Want to reconnect with an existing session?" +msgstr "Vous souhaitez retrouver une session existante ?" + +msgid "Warning!" +msgstr "Attention !" + +msgid "We can't find the internet" +msgstr "Connexion Internet indisponible" + +msgid "Welcome back!" +msgstr "Bon retour !" + +msgid "What's this?" +msgstr "Qu'est-ce que c'est ?" + +msgid "Write your message here" +msgstr "Saisissez votre message ici" + +msgid "Write your own session name" +msgstr "Saisissez votre nom de session" + +msgid "Write your preferred citation. This will be used to recover the session, so keep it saved." +msgstr "Saisissez votre citation préférée. Elle servira à récupérer la session, alors conservez-la." + +msgid "Write your session name." +msgstr "Saisissez votre nom de session." + +msgid "You have to select a session to continue" +msgstr "Vous devez sélectionner une session pour continuer" + +msgid "You must have a session to access this page." +msgstr "Vous devez avoir une session pour accéder à cette page." + +msgid "Your citation." +msgstr "Votre citation." + +msgid "Your contact has not set up encryption yet. Try again once they are online." +msgstr "Votre contact n'a pas encore configuré le chiffrement. Réessayez lorsqu'il sera en ligne." + +msgid "Your contact's security code changed. They may have reset their device, or someone may be intercepting the conversation. Verify the safety number with them before continuing." +msgstr "Le code de sécurité de votre contact a changé. Il a peut-être réinitialisé son appareil, ou quelqu'un intercepte la conversation. Vérifiez le numéro de sécurité avec lui avant de continuer." + +msgid "Your language preference could not be saved." +msgstr "Impossible d'enregistrer votre préférence de langue." diff --git a/apps/privee_web/priv/gettext/fr/LC_MESSAGES/errors.po b/apps/privee_web/priv/gettext/fr/LC_MESSAGES/errors.po new file mode 100644 index 00000000..0858e800 --- /dev/null +++ b/apps/privee_web/priv/gettext/fr/LC_MESSAGES/errors.po @@ -0,0 +1,116 @@ +msgid "" +msgstr "" +"Language: fr\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +msgid "The session name does not exist" +msgstr "Le nom de session n'existe pas" + +msgid "You selected your session name" +msgstr "Vous avez sélectionné votre propre nom de session" + +msgid "are still associated with this entry" +msgstr "sont toujours associés à cette entrée" + +msgid "can't be blank" +msgstr "ne peut pas être vide" + +msgid "cannot be the sender" +msgstr "ne peut pas être l'expéditeur" + +msgid "does not match confirmation" +msgstr "ne correspond pas à la confirmation" + +msgid "has already been taken" +msgstr "est déjà utilisé" + +msgid "has an invalid entry" +msgstr "contient une entrée incorrecte" + +msgid "has invalid format" +msgstr "a un format incorrect" + +msgid "is invalid" +msgstr "n'est pas valide" + +msgid "is not valid" +msgstr "n'est pas valide" + +msgid "is reserved" +msgstr "est réservé" + +msgid "is still associated with this entry" +msgstr "est toujours associé à cette entrée" + +msgid "must be accepted" +msgstr "doit être accepté" + +msgid "must be empty for quick sessions" +msgstr "doit être vide pour les sessions rapides" + +msgid "must be equal to %{number}" +msgstr "doit être égal à %{number}" + +msgid "must be greater than %{number}" +msgstr "doit être supérieur à %{number}" + +msgid "must be greater than or equal to %{number}" +msgstr "doit être supérieur ou égal à %{number}" + +msgid "must be less than %{number}" +msgstr "doit être inférieur à %{number}" + +msgid "must be less than or equal to %{number}" +msgstr "doit être inférieur ou égal à %{number}" + +msgid "must contain only alphabetic characters and punctuation" +msgstr "doit contenir uniquement des lettres et de la ponctuation" + +msgid "must contain only alphanumeric characters and hyphens" +msgstr "doit contenir uniquement des caractères alphanumériques et des tirets" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "doit contenir %{count} octet" +msgstr[1] "doit contenir %{count} octets" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "doit contenir %{count} caractère" +msgstr[1] "doit contenir %{count} caractères" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "doit contenir au moins %{count} octet" +msgstr[1] "doit contenir au moins %{count} octets" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "doit contenir au moins %{count} caractère" +msgstr[1] "doit contenir au moins %{count} caractères" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "doit contenir au plus %{count} octet" +msgstr[1] "doit contenir au plus %{count} octets" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "doit contenir au plus %{count} caractère" +msgstr[1] "doit contenir au plus %{count} caractères" + +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "doit contenir %{count} élément" +msgstr[1] "doit contenir %{count} éléments" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "doit contenir au moins %{count} élément" +msgstr[1] "doit contenir au moins %{count} éléments" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "doit contenir au plus %{count} élément" +msgstr[1] "doit contenir au plus %{count} éléments" diff --git a/apps/privee_web/priv/gettext/it/LC_MESSAGES/default.po b/apps/privee_web/priv/gettext/it/LC_MESSAGES/default.po new file mode 100644 index 00000000..10cb64b8 --- /dev/null +++ b/apps/privee_web/priv/gettext/it/LC_MESSAGES/default.po @@ -0,0 +1,278 @@ +msgid "" +msgstr "" +"Language: it\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgid "A quick session needs no recovery phrase. You can sign in only once; you will not be able to recover this session later." +msgstr "Una sessione rapida non richiede una frase di recupero. Puoi accedere una sola volta e non potrai recuperare questa sessione in seguito." + +msgid "Accept new security code" +msgstr "Accetta il nuovo codice di sicurezza" + +msgid "Actions" +msgstr "Azioni" + +msgid "Attempting to reconnect" +msgstr "Tentativo di riconnessione" + +msgid "Chatting app with focus on privacy" +msgstr "App di chat incentrata sulla privacy" + +msgid "Clear history on this device" +msgstr "Cancella cronologia su questo dispositivo" + +msgid "Close" +msgstr "Chiudi" + +msgid "Compare this number with your contact, in person or over another channel. If it matches, the conversation is end-to-end encrypted." +msgstr "Confronta questo numero con il tuo contatto, di persona o tramite un altro canale. Se coincide, la conversazione è crittografata end-to-end." + +msgid "Copy link" +msgstr "Copia link" + +msgid "Copy session name" +msgstr "Copia nome della sessione" + +msgid "Could not copy to the clipboard." +msgstr "Non è stato possibile copiare negli appunti." + +msgid "Create" +msgstr "Crea" + +msgid "Create a new Privee" +msgstr "Crea un nuovo Privee" + +msgid "Create a new one" +msgstr "Creane una nuova" + +msgid "Create a new session" +msgstr "Crea una nuova sessione" + +msgid "Create a session quickly without a recovery phrase" +msgstr "Crea rapidamente una sessione senza frase di recupero" + +msgid "Create an account" +msgstr "Crea un account" + +msgid "Creating account..." +msgstr "Creazione dell'account..." + +msgid "Creating..." +msgstr "Creazione in corso..." + +msgid "Delete all encryption keys and history of this session from this device? You will need to reset encryption to chat again." +msgstr "Eliminare tutte le chiavi crittografiche e la cronologia di questa sessione da questo dispositivo? Dovrai reimpostare la crittografia per chattare di nuovo." + +msgid "Delete the local history of this conversation from this device?" +msgstr "Eliminare la cronologia locale di questa conversazione da questo dispositivo?" + +msgid "Don't have a session?" +msgstr "Non hai una sessione?" + +msgid "Earlier on this device" +msgstr "In precedenza su questo dispositivo" + +msgid "Encryption could not be initialized. Please reload the page." +msgstr "Non è stato possibile inizializzare la crittografia. Ricarica la pagina." + +msgid "Encryption for this session was reset on another device, so this device can no longer send or receive messages." +msgstr "La crittografia di questa sessione è stata reimpostata su un altro dispositivo. Questo dispositivo non può più inviare o ricevere messaggi." + +msgid "Error!" +msgstr "Errore!" + +msgid "Exchange a message first to compare safety numbers." +msgstr "Scambia prima un messaggio per confrontare i numeri di sicurezza." + +msgid "Forget this device" +msgstr "Dimentica questo dispositivo" + +msgid "Hang in there while we get back on track" +msgstr "Attendi mentre ripristiniamo il servizio" + +msgid "Information" +msgstr "Informazioni" + +msgid "Internal Server Error" +msgstr "Errore interno del server" + +msgid "Invalid recovery phrase or session name" +msgstr "Frase di recupero o nome della sessione non validi" + +msgid "Keep me logged in" +msgstr "Mantieni l'accesso" + +msgid "Language" +msgstr "Lingua" + +msgid "Log Out" +msgstr "Esci" + +msgid "Logged out successfully." +msgstr "Disconnessione riuscita." + +msgid "Message" +msgstr "Messaggio" + +msgid "New message received" +msgstr "Nuovo messaggio ricevuto" + +msgid "Not Found" +msgstr "Pagina non trovata" + +msgid "Oops, something went wrong! Please check the errors below." +msgstr "Si è verificato un problema! Controlla gli errori qui sotto." + +msgid "Privacy App" +msgstr "App per la privacy" + +msgid "Privee - Text received" +msgstr "Privee - Messaggio ricevuto" + +msgid "Privee logo" +msgstr "Logo di Privee" + +msgid "Quick session" +msgstr "Sessione rapida" + +msgid "Recovery phrase" +msgstr "Frase di recupero" + +msgid "Reset encryption identity" +msgstr "Reimposta identità crittografica" + +msgid "Reset the encryption identity of this session? Your contacts will be asked to verify your new safety number." +msgstr "Reimpostare l'identità crittografica di questa sessione? Ai tuoi contatti verrà chiesto di verificare il nuovo numero di sicurezza." + +msgid "Safety number" +msgstr "Numero di sicurezza" + +msgid "Send" +msgstr "Invia" + +msgid "Sent from another device." +msgstr "Inviato da un altro dispositivo." + +msgid "Session Name" +msgstr "Nome della sessione" + +msgid "Session copied" +msgstr "Sessione copiata" + +msgid "Session created successfully! Your session name has been automatically copied to the clipboard." +msgstr "Sessione creata! Il nome della sessione è stato copiato automaticamente negli appunti." + +msgid "Settings" +msgstr "Impostazioni" + +msgid "Sign in" +msgstr "Accedi" + +msgid "Sign in to existing session" +msgstr "Accedi a una sessione esistente" + +msgid "Signing in..." +msgstr "Accesso in corso..." + +msgid "Something went wrong!" +msgstr "Qualcosa è andato storto!" + +msgid "Source code (AGPL-3.0)" +msgstr "Codice sorgente (AGPL-3.0)" + +msgid "Specify the session name you would like to chat with." +msgstr "Indica il nome della sessione con cui vuoi chattare." + +msgid "Start Privee" +msgstr "Avvia Privee" + +msgid "Success!" +msgstr "Operazione riuscita!" + +msgid "Switch theme" +msgstr "Cambia tema" + +msgid "The application was updated. Please reload the page." +msgstr "L'applicazione è stata aggiornata. Ricarica la pagina." + +msgid "The message could not be delivered yet. It will be sent automatically." +msgstr "Il messaggio non è stato ancora recapitato. Verrà inviato automaticamente." + +msgid "The message could not be sent. Please try again." +msgstr "Non è stato possibile inviare il messaggio. Riprova." + +msgid "The session name you'd like to contact." +msgstr "Il nome della sessione da contattare." + +msgid "The session you're trying to join doesn't exist." +msgstr "La sessione a cui vuoi accedere non esiste." + +msgid "Theme" +msgstr "Tema" + +msgid "This browser does not support notifications." +msgstr "Questo browser non supporta le notifiche." + +msgid "This browser does not support the features required for end-to-end encryption (Web Locks). Please use an up-to-date browser." +msgstr "Questo browser non supporta le funzionalità necessarie per la crittografia end-to-end (Web Locks). Usa un browser aggiornato." + +msgid "This device has no encryption keys for this session. Reset the encryption identity to continue; messages sent to your previous device will not be readable here." +msgstr "Questo dispositivo non ha chiavi crittografiche per questa sessione. Reimposta l'identità crittografica per continuare; i messaggi inviati al dispositivo precedente non saranno leggibili qui." + +msgid "This language is not supported." +msgstr "Questa lingua non è supportata." + +msgid "This message could not be decrypted." +msgstr "Non è stato possibile decifrare questo messaggio." + +msgid "To register a session, choose a session name of at least 24 alphanumeric characters separated by hyphens, and a recovery phrase containing only letters, spaces and punctuation." +msgstr "Per registrare una sessione, scegli un nome di almeno 24 caratteri alfanumerici separati da trattini e una frase di recupero contenente solo lettere, spazi e punteggiatura." + +msgid "Url copied" +msgstr "Link copiato" + +msgid "Want to reconnect with an existing session?" +msgstr "Vuoi riconnetterti a una sessione esistente?" + +msgid "Warning!" +msgstr "Attenzione!" + +msgid "We can't find the internet" +msgstr "Connessione a Internet non disponibile" + +msgid "Welcome back!" +msgstr "Bentornato!" + +msgid "What's this?" +msgstr "Che cos'è?" + +msgid "Write your message here" +msgstr "Scrivi qui il tuo messaggio" + +msgid "Write your own session name" +msgstr "Scrivi il nome della tua sessione" + +msgid "Write your preferred citation. This will be used to recover the session, so keep it saved." +msgstr "Scrivi la tua citazione preferita. Servirà a recuperare la sessione, quindi conservala." + +msgid "Write your session name." +msgstr "Scrivi il nome della tua sessione." + +msgid "You have to select a session to continue" +msgstr "Devi selezionare una sessione per continuare" + +msgid "You must have a session to access this page." +msgstr "Devi avere una sessione per accedere a questa pagina." + +msgid "Your citation." +msgstr "La tua citazione." + +msgid "Your contact has not set up encryption yet. Try again once they are online." +msgstr "Il tuo contatto non ha ancora configurato la crittografia. Riprova quando sarà online." + +msgid "Your contact's security code changed. They may have reset their device, or someone may be intercepting the conversation. Verify the safety number with them before continuing." +msgstr "Il codice di sicurezza del tuo contatto è cambiato. Potrebbe aver reimpostato il dispositivo, oppure qualcuno potrebbe intercettare la conversazione. Verifica il numero di sicurezza con il contatto prima di continuare." + +msgid "Your language preference could not be saved." +msgstr "Non è stato possibile salvare la lingua preferita." diff --git a/apps/privee_web/priv/gettext/it/LC_MESSAGES/errors.po b/apps/privee_web/priv/gettext/it/LC_MESSAGES/errors.po new file mode 100644 index 00000000..83e26f84 --- /dev/null +++ b/apps/privee_web/priv/gettext/it/LC_MESSAGES/errors.po @@ -0,0 +1,116 @@ +msgid "" +msgstr "" +"Language: it\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgid "The session name does not exist" +msgstr "Il nome della sessione non esiste" + +msgid "You selected your session name" +msgstr "Hai selezionato il nome della tua sessione" + +msgid "are still associated with this entry" +msgstr "sono ancora associati a questa voce" + +msgid "can't be blank" +msgstr "non può essere vuoto" + +msgid "cannot be the sender" +msgstr "non può essere il mittente" + +msgid "does not match confirmation" +msgstr "non corrisponde alla conferma" + +msgid "has already been taken" +msgstr "è già in uso" + +msgid "has an invalid entry" +msgstr "contiene un elemento non valido" + +msgid "has invalid format" +msgstr "ha un formato non valido" + +msgid "is invalid" +msgstr "non è valido" + +msgid "is not valid" +msgstr "non è valido" + +msgid "is reserved" +msgstr "è riservato" + +msgid "is still associated with this entry" +msgstr "è ancora associato a questa voce" + +msgid "must be accepted" +msgstr "deve essere accettato" + +msgid "must be empty for quick sessions" +msgstr "deve essere vuoto per le sessioni rapide" + +msgid "must be equal to %{number}" +msgstr "deve essere uguale a %{number}" + +msgid "must be greater than %{number}" +msgstr "deve essere maggiore di %{number}" + +msgid "must be greater than or equal to %{number}" +msgstr "deve essere maggiore o uguale a %{number}" + +msgid "must be less than %{number}" +msgstr "deve essere minore di %{number}" + +msgid "must be less than or equal to %{number}" +msgstr "deve essere minore o uguale a %{number}" + +msgid "must contain only alphabetic characters and punctuation" +msgstr "deve contenere solo lettere e punteggiatura" + +msgid "must contain only alphanumeric characters and hyphens" +msgstr "deve contenere solo caratteri alfanumerici e trattini" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "deve contenere %{count} byte" +msgstr[1] "deve contenere %{count} byte" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "deve contenere %{count} carattere" +msgstr[1] "deve contenere %{count} caratteri" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "deve contenere almeno %{count} byte" +msgstr[1] "deve contenere almeno %{count} byte" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "deve contenere almeno %{count} carattere" +msgstr[1] "deve contenere almeno %{count} caratteri" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "deve contenere al massimo %{count} byte" +msgstr[1] "deve contenere al massimo %{count} byte" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "deve contenere al massimo %{count} carattere" +msgstr[1] "deve contenere al massimo %{count} caratteri" + +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "deve avere %{count} elemento" +msgstr[1] "deve avere %{count} elementi" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "deve avere almeno %{count} elemento" +msgstr[1] "deve avere almeno %{count} elementi" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "deve avere al massimo %{count} elemento" +msgstr[1] "deve avere al massimo %{count} elementi" diff --git a/apps/privee_web/priv/gettext/pt-PT/LC_MESSAGES/default.po b/apps/privee_web/priv/gettext/pt-PT/LC_MESSAGES/default.po new file mode 100644 index 00000000..95e67f51 --- /dev/null +++ b/apps/privee_web/priv/gettext/pt-PT/LC_MESSAGES/default.po @@ -0,0 +1,278 @@ +msgid "" +msgstr "" +"Language: pt-PT\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgid "A quick session needs no recovery phrase. You can sign in only once; you will not be able to recover this session later." +msgstr "Uma sessão rápida não precisa de frase de recuperação. Só pode iniciar sessão uma vez; não poderá recuperar esta sessão mais tarde." + +msgid "Accept new security code" +msgstr "Aceitar novo código de segurança" + +msgid "Actions" +msgstr "Ações" + +msgid "Attempting to reconnect" +msgstr "A tentar restabelecer a ligação" + +msgid "Chatting app with focus on privacy" +msgstr "Aplicação de conversas centrada na privacidade" + +msgid "Clear history on this device" +msgstr "Limpar histórico neste dispositivo" + +msgid "Close" +msgstr "Fechar" + +msgid "Compare this number with your contact, in person or over another channel. If it matches, the conversation is end-to-end encrypted." +msgstr "Compare este número com o seu contacto, pessoalmente ou por outro canal. Se corresponder, a conversa está encriptada ponto a ponto." + +msgid "Copy link" +msgstr "Copiar ligação" + +msgid "Copy session name" +msgstr "Copiar nome da sessão" + +msgid "Could not copy to the clipboard." +msgstr "Não foi possível copiar para a área de transferência." + +msgid "Create" +msgstr "Criar" + +msgid "Create a new Privee" +msgstr "Criar um novo Privee" + +msgid "Create a new one" +msgstr "Criar uma nova" + +msgid "Create a new session" +msgstr "Criar uma nova sessão" + +msgid "Create a session quickly without a recovery phrase" +msgstr "Criar rapidamente uma sessão sem frase de recuperação" + +msgid "Create an account" +msgstr "Criar uma conta" + +msgid "Creating account..." +msgstr "A criar a conta..." + +msgid "Creating..." +msgstr "A criar..." + +msgid "Delete all encryption keys and history of this session from this device? You will need to reset encryption to chat again." +msgstr "Eliminar todas as chaves de encriptação e o histórico desta sessão deste dispositivo? Terá de repor a encriptação para voltar a conversar." + +msgid "Delete the local history of this conversation from this device?" +msgstr "Eliminar o histórico local desta conversa deste dispositivo?" + +msgid "Don't have a session?" +msgstr "Não tem uma sessão?" + +msgid "Earlier on this device" +msgstr "Anteriormente neste dispositivo" + +msgid "Encryption could not be initialized. Please reload the page." +msgstr "Não foi possível inicializar a encriptação. Recarregue a página." + +msgid "Encryption for this session was reset on another device, so this device can no longer send or receive messages." +msgstr "A encriptação desta sessão foi reposta noutro dispositivo, pelo que este dispositivo já não pode enviar nem receber mensagens." + +msgid "Error!" +msgstr "Erro!" + +msgid "Exchange a message first to compare safety numbers." +msgstr "Troque primeiro uma mensagem para comparar os números de segurança." + +msgid "Forget this device" +msgstr "Esquecer este dispositivo" + +msgid "Hang in there while we get back on track" +msgstr "Aguarde enquanto repomos o serviço" + +msgid "Information" +msgstr "Informações" + +msgid "Internal Server Error" +msgstr "Erro interno do servidor" + +msgid "Invalid recovery phrase or session name" +msgstr "Frase de recuperação ou nome da sessão inválidos" + +msgid "Keep me logged in" +msgstr "Manter a sessão iniciada" + +msgid "Language" +msgstr "Idioma" + +msgid "Log Out" +msgstr "Terminar sessão" + +msgid "Logged out successfully." +msgstr "Sessão terminada com sucesso." + +msgid "Message" +msgstr "Mensagem" + +msgid "New message received" +msgstr "Nova mensagem recebida" + +msgid "Not Found" +msgstr "Página não encontrada" + +msgid "Oops, something went wrong! Please check the errors below." +msgstr "Ocorreu um problema! Verifique os erros abaixo." + +msgid "Privacy App" +msgstr "Aplicação de privacidade" + +msgid "Privee - Text received" +msgstr "Privee - Mensagem recebida" + +msgid "Privee logo" +msgstr "Logótipo do Privee" + +msgid "Quick session" +msgstr "Sessão rápida" + +msgid "Recovery phrase" +msgstr "Frase de recuperação" + +msgid "Reset encryption identity" +msgstr "Repor identidade de encriptação" + +msgid "Reset the encryption identity of this session? Your contacts will be asked to verify your new safety number." +msgstr "Repor a identidade de encriptação desta sessão? Será pedido aos seus contactos que verifiquem o novo número de segurança." + +msgid "Safety number" +msgstr "Número de segurança" + +msgid "Send" +msgstr "Enviar" + +msgid "Sent from another device." +msgstr "Enviada de outro dispositivo." + +msgid "Session Name" +msgstr "Nome da sessão" + +msgid "Session copied" +msgstr "Sessão copiada" + +msgid "Session created successfully! Your session name has been automatically copied to the clipboard." +msgstr "Sessão criada com sucesso! O nome da sessão foi copiado automaticamente para a área de transferência." + +msgid "Settings" +msgstr "Definições" + +msgid "Sign in" +msgstr "Iniciar sessão" + +msgid "Sign in to existing session" +msgstr "Iniciar sessão numa sessão existente" + +msgid "Signing in..." +msgstr "A iniciar sessão..." + +msgid "Something went wrong!" +msgstr "Ocorreu um problema!" + +msgid "Source code (AGPL-3.0)" +msgstr "Código-fonte (AGPL-3.0)" + +msgid "Specify the session name you would like to chat with." +msgstr "Indique o nome da sessão com que pretende conversar." + +msgid "Start Privee" +msgstr "Iniciar Privee" + +msgid "Success!" +msgstr "Sucesso!" + +msgid "Switch theme" +msgstr "Mudar tema" + +msgid "The application was updated. Please reload the page." +msgstr "A aplicação foi atualizada. Recarregue a página." + +msgid "The message could not be delivered yet. It will be sent automatically." +msgstr "A mensagem ainda não foi entregue. Será enviada automaticamente." + +msgid "The message could not be sent. Please try again." +msgstr "Não foi possível enviar a mensagem. Tente novamente." + +msgid "The session name you'd like to contact." +msgstr "O nome da sessão que pretende contactar." + +msgid "The session you're trying to join doesn't exist." +msgstr "A sessão a que pretende juntar-se não existe." + +msgid "Theme" +msgstr "Tema" + +msgid "This browser does not support notifications." +msgstr "Este navegador não suporta notificações." + +msgid "This browser does not support the features required for end-to-end encryption (Web Locks). Please use an up-to-date browser." +msgstr "Este navegador não suporta as funcionalidades necessárias para a encriptação ponto a ponto (Web Locks). Utilize um navegador atualizado." + +msgid "This device has no encryption keys for this session. Reset the encryption identity to continue; messages sent to your previous device will not be readable here." +msgstr "Este dispositivo não tem chaves de encriptação para esta sessão. Reponha a identidade de encriptação para continuar; as mensagens enviadas para o dispositivo anterior não poderão ser lidas aqui." + +msgid "This language is not supported." +msgstr "Este idioma não é suportado." + +msgid "This message could not be decrypted." +msgstr "Não foi possível desencriptar esta mensagem." + +msgid "To register a session, choose a session name of at least 24 alphanumeric characters separated by hyphens, and a recovery phrase containing only letters, spaces and punctuation." +msgstr "Para registar uma sessão, escolha um nome com pelo menos 24 caracteres alfanuméricos separados por hífen e uma frase de recuperação que contenha apenas letras, espaços e pontuação." + +msgid "Url copied" +msgstr "Ligação copiada" + +msgid "Want to reconnect with an existing session?" +msgstr "Quer voltar a ligar-se a uma sessão existente?" + +msgid "Warning!" +msgstr "Aviso!" + +msgid "We can't find the internet" +msgstr "Sem ligação à Internet" + +msgid "Welcome back!" +msgstr "Bem-vindo de volta!" + +msgid "What's this?" +msgstr "O que é isto?" + +msgid "Write your message here" +msgstr "Escreva aqui a sua mensagem" + +msgid "Write your own session name" +msgstr "Escreva o nome da sua sessão" + +msgid "Write your preferred citation. This will be used to recover the session, so keep it saved." +msgstr "Escreva a sua citação preferida. Será usada para recuperar a sessão, por isso guarde-a." + +msgid "Write your session name." +msgstr "Escreva o nome da sua sessão." + +msgid "You have to select a session to continue" +msgstr "Tem de selecionar uma sessão para continuar" + +msgid "You must have a session to access this page." +msgstr "Tem de ter uma sessão para aceder a esta página." + +msgid "Your citation." +msgstr "A sua citação." + +msgid "Your contact has not set up encryption yet. Try again once they are online." +msgstr "O seu contacto ainda não configurou a encriptação. Tente novamente quando estiver online." + +msgid "Your contact's security code changed. They may have reset their device, or someone may be intercepting the conversation. Verify the safety number with them before continuing." +msgstr "O código de segurança do seu contacto mudou. Pode ter reposto o dispositivo, ou alguém pode estar a intercetar a conversa. Verifique o número de segurança com o contacto antes de continuar." + +msgid "Your language preference could not be saved." +msgstr "Não foi possível guardar a sua preferência de idioma." diff --git a/apps/privee_web/priv/gettext/pt-PT/LC_MESSAGES/errors.po b/apps/privee_web/priv/gettext/pt-PT/LC_MESSAGES/errors.po new file mode 100644 index 00000000..3c6b09bd --- /dev/null +++ b/apps/privee_web/priv/gettext/pt-PT/LC_MESSAGES/errors.po @@ -0,0 +1,116 @@ +msgid "" +msgstr "" +"Language: pt-PT\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgid "The session name does not exist" +msgstr "O nome da sessão não existe" + +msgid "You selected your session name" +msgstr "Selecionou o nome da sua própria sessão" + +msgid "are still associated with this entry" +msgstr "ainda estão associados a esta entrada" + +msgid "can't be blank" +msgstr "não pode estar em branco" + +msgid "cannot be the sender" +msgstr "não pode ser o remetente" + +msgid "does not match confirmation" +msgstr "não corresponde à confirmação" + +msgid "has already been taken" +msgstr "já está em uso" + +msgid "has an invalid entry" +msgstr "contém uma entrada inválida" + +msgid "has invalid format" +msgstr "tem um formato inválido" + +msgid "is invalid" +msgstr "é inválido" + +msgid "is not valid" +msgstr "não é válido" + +msgid "is reserved" +msgstr "está reservado" + +msgid "is still associated with this entry" +msgstr "ainda está associado a esta entrada" + +msgid "must be accepted" +msgstr "tem de ser aceite" + +msgid "must be empty for quick sessions" +msgstr "tem de estar vazio para sessões rápidas" + +msgid "must be equal to %{number}" +msgstr "tem de ser igual a %{number}" + +msgid "must be greater than %{number}" +msgstr "tem de ser superior a %{number}" + +msgid "must be greater than or equal to %{number}" +msgstr "tem de ser superior ou igual a %{number}" + +msgid "must be less than %{number}" +msgstr "tem de ser inferior a %{number}" + +msgid "must be less than or equal to %{number}" +msgstr "tem de ser inferior ou igual a %{number}" + +msgid "must contain only alphabetic characters and punctuation" +msgstr "tem de conter apenas letras e pontuação" + +msgid "must contain only alphanumeric characters and hyphens" +msgstr "tem de conter apenas caracteres alfanuméricos e hífen" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "deve ter %{count} byte" +msgstr[1] "deve ter %{count} bytes" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "deve ter %{count} caráter" +msgstr[1] "deve ter %{count} carateres" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "deve ter pelo menos %{count} byte" +msgstr[1] "deve ter pelo menos %{count} bytes" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "deve ter pelo menos %{count} caráter" +msgstr[1] "deve ter pelo menos %{count} carateres" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "deve ter no máximo %{count} byte" +msgstr[1] "deve ter no máximo %{count} bytes" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "deve ter no máximo %{count} caráter" +msgstr[1] "deve ter no máximo %{count} carateres" + +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "deve ter %{count} elemento" +msgstr[1] "deve ter %{count} elementos" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "deve ter pelo menos %{count} elemento" +msgstr[1] "deve ter pelo menos %{count} elementos" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "deve ter no máximo %{count} elemento" +msgstr[1] "deve ter no máximo %{count} elementos" diff --git a/apps/privee_web/test/privee_web/controllers/session_controller_test.exs b/apps/privee_web/test/privee_web/controllers/session_controller_test.exs index 772c5f66..04419a8f 100644 --- a/apps/privee_web/test/privee_web/controllers/session_controller_test.exs +++ b/apps/privee_web/test/privee_web/controllers/session_controller_test.exs @@ -97,7 +97,7 @@ defmodule PriveeWeb.SessionControllerTest do }) assert Phoenix.Flash.get(conn.assigns.flash, :error) == - "Invalid recovery_phrase or session_name" + "Invalid recovery phrase or session name" assert redirected_to(conn) == ~p"/" end diff --git a/apps/privee_web/test/privee_web/live/chat_live_test.exs b/apps/privee_web/test/privee_web/live/chat_live_test.exs index 331a785f..cbf404e8 100644 --- a/apps/privee_web/test/privee_web/live/chat_live_test.exs +++ b/apps/privee_web/test/privee_web/live/chat_live_test.exs @@ -102,7 +102,7 @@ defmodule PriveeWeb.ChatLiveTest do {:ok, lv, _html} = live(conn, ~p"/chat/#{peer.session_name}") assert has_element?(lv, "#chat-text") refute has_element?(lv, "#chat-text[name]") - refute has_element?(lv, "form[phx-change]") + refute has_element?(lv, "form[phx-change] #chat-text") refute has_element?(lv, "form[phx-submit] #chat-text") end diff --git a/apps/privee_web/test/privee_web/live/session_login_live_test.exs b/apps/privee_web/test/privee_web/live/session_login_live_test.exs index 93d09808..539930f6 100644 --- a/apps/privee_web/test/privee_web/live/session_login_live_test.exs +++ b/apps/privee_web/test/privee_web/live/session_login_live_test.exs @@ -63,7 +63,7 @@ defmodule PriveeWeb.SessionLoginLiveTest do conn = submit_form(form, conn) assert Phoenix.Flash.get(conn.assigns.flash, :error) == - "Invalid recovery_phrase or session_name" + "Invalid recovery phrase or session name" assert redirected_to(conn) == "/" end @@ -99,7 +99,7 @@ defmodule PriveeWeb.SessionLoginLiveTest do conn = submit_form(form, conn) assert Phoenix.Flash.get(conn.assigns.flash, :error) == - "Invalid recovery_phrase or session_name" + "Invalid recovery phrase or session name" assert redirected_to(conn) == "/" end @@ -138,7 +138,7 @@ defmodule PriveeWeb.SessionLoginLiveTest do }) assert Phoenix.Flash.get(conn.assigns.flash, :error) == - "Invalid recovery_phrase or session_name" + "Invalid recovery phrase or session name" assert redirected_to(conn) == "/" end diff --git a/apps/privee_web/test/privee_web/locale_test.exs b/apps/privee_web/test/privee_web/locale_test.exs new file mode 100644 index 00000000..fac20ae6 --- /dev/null +++ b/apps/privee_web/test/privee_web/locale_test.exs @@ -0,0 +1,191 @@ +defmodule PriveeWeb.LocaleTest do + use PriveeWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Privee.SessionsFixtures + + alias PriveeWeb.Locale + + test "negotiates base/regional tags, quality and fallback" do + for {header, expected} <- [ + {"", "en"}, + {"de", "en"}, + {"it-IT", "it"}, + {"PT", "pt-PT"}, + {"pt-BR", "pt-PT"}, + {"es-MX", "es"}, + {"fr-CA", "fr"}, + {"fr;q=0.4, it;q=0.8", "it"}, + {"de;q=1, es;q=0.8", "es"}, + {"it;q=0, fr;q=0.5", "fr"}, + {"fr;q=bad, es;q=0.5", "es"}, + {"fr;q=2", "en"} + ] do + assert Locale.negotiate(header) == expected + end + end + + test "unknown HTML routes use the browser preference before router matching", %{conn: conn} do + conn = + conn |> put_req_cookie("privee_locale", "fr") |> put_req_header("accept", "text/html") + + conn = get(conn, "/unknown-language-test-route") + assert html_response(conn, 404) == "Page introuvable" + end + + test "browser starts English even with an Italian Accept-Language", %{conn: conn} do + conn = conn |> put_req_header("accept-language", "it") |> get("/") + assert conn.assigns.locale == "en" + + assert conn + |> html_response(200) + |> LazyHTML.from_document() + |> LazyHTML.filter("html[lang='en']") + |> Enum.count() == 1 + end + + test "cookie is allowlisted and establishes HTTP and LiveView locale", %{conn: conn} do + for {cookie, expected} <- [{"it", "it"}, {"pt-PT", "pt-PT"}, {"xx", "en"}, {"it-IT", "en"}] do + conn = conn |> recycle() |> put_req_cookie("privee_locale", cookie) |> get("/") + assert conn.assigns.locale == expected + {:ok, view, _} = live(conn) + assert has_element?(view, "#language-settings[data-locale='#{expected}']") + end + end + + test "connected locale overrides the stale signed LiveView session", %{conn: conn} do + conn = conn |> put_req_cookie("privee_locale", "it") |> get("/") + {:ok, view, _} = conn |> put_connect_params(%{"locale" => "fr"}) |> live() + assert has_element?(view, "#language-settings[data-locale='fr']") + assert has_element?(view, "#settings-toggle", "Paramètres") + end + + test "switches all five languages in the same LiveView preserving registration fields", %{ + conn: conn + } do + {:ok, view, _} = live(conn, "/") + name = unique_session_name() + phrase = "a recovery phrase kept exactly as entered" + + view + |> form("#registration_form", session: %{session_name: name, recovery_phrase: phrase}) + |> render_change() + + for {locale, label} <- [ + {"it", "Lingua"}, + {"pt-PT", "Idioma"}, + {"es", "Idioma"}, + {"fr", "Langue"}, + {"en", "Language"} + ] do + view |> form("#language-form", %{locale: locale}) |> render_change() + assert has_element?(view, "label[for='language-select']", label) + assert has_element?(view, "#session_session_name[value='#{name}']") + assert has_element?(view, "#session_recovery_phrase", phrase) + assert_push_event(view, "locale_changed", %{locale: ^locale}) + end + end + + test "rejects invalid selection without changing locale", %{conn: conn} do + {:ok, view, _} = live(conn, "/") + render_hook(view, "set_locale", %{locale: "xx"}) + assert has_element?(view, "#language-settings[data-locale='en']") + assert has_element?(view, "#flash-error", "This language is not supported.") + end + + test "clears old errors but retains drafts and validates in the new language", %{conn: conn} do + {:ok, view, _} = live(conn, "/") + params = %{session_name: "short", recovery_phrase: "short"} + view |> form("#registration_form", session: params) |> render_change() + assert has_element?(view, "#registration_form p", "should be at least 24") + + view |> form("#language-form", %{locale: "it"}) |> render_change() + refute has_element?(view, "#registration_form p", "should be at least 24") + assert has_element?(view, "#session_session_name[value='short']") + view |> form("#registration_form", session: params) |> render_change() + assert has_element?(view, "#registration_form p", "deve contenere almeno 24") + end + + test "preserves login fields and checkbox while updating labels", %{conn: conn} do + {:ok, view, _} = live(conn, "/login") + + view + |> form("#login_form", + session: %{session_name: "draft-name", recovery_phrase: "my citation", remember_me: "true"} + ) + |> render_change() + + view |> form("#language-form", %{locale: "fr"}) |> render_change() + assert has_element?(view, "#session_session_name[value='draft-name']") + assert has_element?(view, "#session_recovery_phrase", "my citation") + assert has_element?(view, "#session_remember_me[checked]") + assert has_element?(view, "#login_form label", "Phrase de récupération") + end + + test "cookie survives logout and authentication session renewal", %{conn: conn} do + session = session_fixture() + conn = conn |> put_req_cookie("privee_locale", "it") |> log_in_session(session) + conn = get(conn, "/privee") + assert conn.assigns.locale == "it" + conn = conn |> recycle() |> delete("/sessions/log_out") + assert conn.assigns.locale == "it" + refute Map.has_key?(conn.resp_cookies, "privee_locale") + conn = conn |> recycle() |> get("/") + assert conn.assigns.locale == "it" + end + + test "API validation translates human errors but not machine codes", %{conn: conn} do + for {locale, text} <- [ + {"en", "can't be blank"}, + {"it-IT", "non può essere vuoto"}, + {"pt", "não pode estar em branco"}, + {"es-MX", "no puede estar en blanco"}, + {"fr-CA", "ne peut pas être vide"}, + {"xx", "can't be blank"} + ] do + conn = + conn + |> recycle() + |> put_req_header("accept-language", locale) + |> post("/api/app/sessions", %{session_name: "short"}) + + assert %{"error" => "invalid", "errors" => errors} = json_response(conn, 422) + assert text in errors["recovery_phrase"] + refute Enum.any?(errors["session_name"], &String.contains?(&1, "%{count}")) + end + + conn = + conn + |> recycle() + |> put_req_header("accept-language", "fr") + |> post("/api/app/sessions/log_in", %{}) + + assert json_response(conn, 401) == %{"error" => "invalid_credentials"} + end + + test "all client keys translate in every non-English language" do + english = PriveeWeb.ClientTexts.translations("en") + + for locale <- ~w(it pt-PT es fr) do + translated = PriveeWeb.ClientTexts.translations(locale) + assert Map.keys(translated) == Map.keys(english) + + for {key, value} <- translated do + assert value != "" + assert value != english[key] or key in [:close, :messageLabel] + end + end + end + + test "error interpolation uses translated singular and plural forms" do + Gettext.with_locale(PriveeWeb.Gettext, "it", fn -> + assert PriveeWeb.CoreComponents.translate_error( + {"should be %{count} character(s)", count: 1} + ) == "deve contenere 1 carattere" + + assert PriveeWeb.CoreComponents.translate_error( + {"should be %{count} character(s)", count: 2} + ) == "deve contenere 2 caratteri" + end) + end +end diff --git a/docs/client-api.md b/docs/client-api.md index 35eba8f1..4404a9b6 100644 --- a/docs/client-api.md +++ b/docs/client-api.md @@ -57,6 +57,21 @@ log in. rate-limited per IP and return `429 {error: "rate_limited"}` when the limit is exceeded. Push notifications carry no message content. +Clients may send `Accept-Language` on every REST request, including registration +and login. Supported languages are `en`, `it`, `pt-PT` (European Portuguese), +`es` and `fr`. Base and regional variants match the supported base language +(for example `pt` and `pt-BR` select the European Portuguese catalog). +Quality weights are respected; absent or unsupported headers default to English. +The browser's language cookie is not consulted for native API requests. + +Only human-readable validation arrays in `errors[field]` are localized, including +interpolated limits. The API version remains **1**; HTTP statuses, envelope/field +names and top-level machine `error` strings (such as `invalid`, +`invalid_credentials` and `rate_limited`) are unchanged. Socket payloads are +unchanged. Clients should translate machine codes locally. Previously received +validation text may be cleared on language changes while retaining entered values; +the next validation request returns errors in the newly selected language. + ## Socket: `/app/socket` A [Phoenix Channels](https://hexdocs.pm/phoenix/channels.html) WebSocket From 2753c6c1e709f0f95a6471e3bcc08c2d92dd3176 Mon Sep 17 00:00:00 2001 From: MaxDac <25101798+MaxDac@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:17:08 +0100 Subject: [PATCH 2/2] fix: preserve plural rules without embedding opaque AST Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6c41f680-5231-4682-a852-fd0830939bfe --- apps/privee_web/lib/privee_web/gettext.ex | 2 +- .../lib/privee_web/gettext_plural.ex | 17 +++++++++++ .../test/privee_web/locale_test.exs | 28 +++++++++++++------ 3 files changed, 37 insertions(+), 10 deletions(-) create mode 100644 apps/privee_web/lib/privee_web/gettext_plural.ex diff --git a/apps/privee_web/lib/privee_web/gettext.ex b/apps/privee_web/lib/privee_web/gettext.ex index b80f700d..66abec25 100644 --- a/apps/privee_web/lib/privee_web/gettext.ex +++ b/apps/privee_web/lib/privee_web/gettext.ex @@ -21,5 +21,5 @@ defmodule PriveeWeb.Gettext do See the [Gettext Docs](https://gettext.hexdocs.pm) for detailed usage. """ - use Gettext.Backend, otp_app: :privee_web + use Gettext.Backend, otp_app: :privee_web, plural_forms: PriveeWeb.GettextPlural end diff --git a/apps/privee_web/lib/privee_web/gettext_plural.ex b/apps/privee_web/lib/privee_web/gettext_plural.ex new file mode 100644 index 00000000..431f273b --- /dev/null +++ b/apps/privee_web/lib/privee_web/gettext_plural.ex @@ -0,0 +1,17 @@ +defmodule PriveeWeb.GettextPlural do + @moduledoc """ + Implements the two-form `n != 1` rule used by all five application catalogs. + + Gettext's built-in English rule is identical to these catalog headers. + Keeping the rule here avoids embedding Expo's opaque plural AST in the + compiled Gettext backend. + """ + + @behaviour Gettext.Plural + + @impl true + def nplurals(_locale), do: 2 + + @impl true + def plural(_locale, count), do: Gettext.Plural.plural("en", count) +end diff --git a/apps/privee_web/test/privee_web/locale_test.exs b/apps/privee_web/test/privee_web/locale_test.exs index fac20ae6..a0cc8a6d 100644 --- a/apps/privee_web/test/privee_web/locale_test.exs +++ b/apps/privee_web/test/privee_web/locale_test.exs @@ -178,14 +178,24 @@ defmodule PriveeWeb.LocaleTest do end test "error interpolation uses translated singular and plural forms" do - Gettext.with_locale(PriveeWeb.Gettext, "it", fn -> - assert PriveeWeb.CoreComponents.translate_error( - {"should be %{count} character(s)", count: 1} - ) == "deve contenere 1 carattere" - - assert PriveeWeb.CoreComponents.translate_error( - {"should be %{count} character(s)", count: 2} - ) == "deve contenere 2 caratteri" - end) + for {locale, singular, plural} <- [ + {"en", "should be 1 character", "should be %{count} characters"}, + {"it", "deve contenere 1 carattere", "deve contenere %{count} caratteri"}, + {"pt-PT", "deve ter 1 caráter", "deve ter %{count} carateres"}, + {"es", "debe tener 1 carácter", "debe tener %{count} caracteres"}, + {"fr", "doit contenir 1 caractère", "doit contenir %{count} caractères"} + ] do + Gettext.with_locale(PriveeWeb.Gettext, locale, fn -> + assert PriveeWeb.CoreComponents.translate_error( + {"should be %{count} character(s)", count: 1} + ) == singular + + for count <- [0, 2, 10] do + assert PriveeWeb.CoreComponents.translate_error( + {"should be %{count} character(s)", count: count} + ) == String.replace(plural, "%{count}", Integer.to_string(count)) + end + end) + end end end