Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/privee_web/assets/.prettierignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
vendor/libsignal-wasm/
playwright-report/
test-results/
118 changes: 118 additions & 0 deletions apps/privee_web/assets/e2e/locale.e2e.mjs
Original file line number Diff line number Diff line change
@@ -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()
}
})
6 changes: 5 additions & 1 deletion apps/privee_web/assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
Expand All @@ -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,
})

Expand Down
12 changes: 7 additions & 5 deletions apps/privee_web/assets/js/hooks/chat-hooks.mjs
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -76,16 +77,17 @@ 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")
banner.id = "chat-banner-error"
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)
}
2 changes: 1 addition & 1 deletion apps/privee_web/assets/js/hooks/flash-hooks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
Expand Down
27 changes: 26 additions & 1 deletion apps/privee_web/assets/js/tests/chat.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => `
<div id="chat-banner"></div>
Expand Down Expand Up @@ -58,6 +61,7 @@ const appendEntry = (doc, message) => {
const party = async (server, ownId, peerId, factory = new IDBFactory()) => {
const dom = new JSDOM(`<body>${layout(server.currentEpoch(ownId, peerId))}</body>`)
const doc = dom.window.document
installCatalog(doc)
const client = await SignalClient.open({
ownId,
push: server.connect(ownId, peerId),
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading