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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ OpenReply is built around Meta's official Instagram private replies. It does not
- Multiple Instagram accounts. Connect several professional accounts under one workspace, each with its own limits.
- Workspaces and roles. Owner, admin, and member roles with invite links, useful if you run this for clients.
- Campaign templates. Start from a preset instead of a blank form.
- English and Traditional Chinese interface, with a saved language preference. See [interface languages](docs/localization.md).
- English, Traditional Chinese and Brazilian Portuguese interface, with a saved language preference. See [interface languages](docs/localization.md).
- Inbox. Read your Instagram DM conversations and reply from the dashboard, inside Meta's 24-hour messaging window. Cached so it loads instantly on repeat visits.
- DM logs. Every send, skip, and failure is logged with a reason.
- Self-comment filtering. Your own comments never trigger a reply, since Meta rejects DMing yourself anyway.
Expand Down
7 changes: 7 additions & 0 deletions __tests__/i18n-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ describe("language preference", () => {
expect(t("Settings")).toBe("設定");
});

it("renders Brazilian Portuguese from the saved cookie", async () => {
store.get.mockReturnValue({ value: "pt-BR" });
const { locale, t } = await getI18n();
expect(locale).toBe("pt-BR");
expect(t("Settings")).toBe("Configurações");
});

it("falls back to English for an invalid cookie", async () => {
store.get.mockReturnValue({ value: "unsupported" });
expect((await getI18n()).locale).toBe("en");
Expand Down
36 changes: 31 additions & 5 deletions __tests__/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { describe, expect, it } from "vitest";
import { createI18n, resolveLocale } from "../lib/i18n";
import ptBR from "../lib/i18n/pt-BR.json";
import zhTW from "../lib/i18n/zh-TW.json";

describe("interface translations", () => {
it("keeps English as the default for absent or unsupported preferences", () => {
for (const value of [undefined, null, "", "fr", "zh-CN", "../zh-TW"]) {
for (const value of [undefined, null, "", "fr", "zh-CN", "../zh-TW", "pt", "pt-PT"]) {
expect(resolveLocale(value)).toBe("en");
}
expect(resolveLocale("zh-TW")).toBe("zh-TW");
expect(resolveLocale("pt-BR")).toBe("pt-BR");
});

it("renders both interface languages from the same keys", () => {
Expand Down Expand Up @@ -49,10 +51,34 @@ describe("interface translations", () => {
it("has complete, plain-text translations with matching interpolation fields", () => {
const placeholders = (text: string) =>
[...text.matchAll(/\{(\w+)\}/g)].map((match) => match[1]).sort();
for (const [source, translation] of Object.entries(zhTW)) {
expect(translation.trim(), source).not.toBe("");
expect(placeholders(translation), source).toEqual(placeholders(source));
expect(source, source).not.toMatch(/&(?:[a-z]+|#\d+);/i);
for (const catalog of [zhTW, ptBR]) {
for (const [source, translation] of Object.entries(catalog)) {
expect(translation.trim(), source).not.toBe("");
expect(placeholders(translation), source).toEqual(placeholders(source));
expect(source, source).not.toMatch(/&(?:[a-z]+|#\d+);/i);
}
}
});

it("keeps the Brazilian Portuguese catalog on exactly the same keys", () => {
expect(Object.keys(ptBR).sort()).toEqual(Object.keys(zhTW).sort());
});

it("renders Brazilian Portuguese with interpolation and display labels", () => {
const { t, label } = createI18n("pt-BR");
expect(t("Campaigns")).toBe("Campanhas");
expect(t("Settings")).toBe("Configurações");
expect(t("{count} connected accounts", { count: 2 })).toBe(
ptBR["{count} connected accounts"].replace("{count}", "2"),
);
expect(t("{count} campaigns", { count: 0 })).toBe(
ptBR["{count} campaigns"].replace("{count}", "0"),
);
expect(["SENT", "OWNER", "active", "CUSTOM_STATUS"].map(label)).toEqual([
ptBR["Sent"],
ptBR["Owner"],
ptBR["Active"],
"CUSTOM_STATUS",
]);
});
});
3 changes: 3 additions & 0 deletions components/language-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ export default function LanguageSwitcher() {
<option value="zh-TW" lang="zh-TW">
繁體中文
</option>
<option value="pt-BR" lang="pt-BR">
Português (Brasil)
</option>
</select>
</label>
{failed && (
Expand Down
10 changes: 6 additions & 4 deletions docs/localization.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Interface languages

OpenReply defaults to English. Choose **English** or **繁體中文** in the dashboard
OpenReply defaults to English. Choose **English**, **繁體中文** or **Português (Brasil)** in the dashboard
sidebar, under **Settings → Interface language**, or on the sign-in screen.
The choice is stored in a browser cookie for one year and applies to the
dashboard, sign-in screens, workspace invitations, and shared campaign reports.
Expand All @@ -16,8 +16,10 @@ interface translation's scope.

## Adding or changing copy

- `lib/i18n/zh-TW.json` maps English source copy to Traditional Chinese. Use
complete sentences with named placeholders when word order can vary.
- `lib/i18n/zh-TW.json` maps English source copy to Traditional Chinese and
`lib/i18n/pt-BR.json` to Brazilian Portuguese. Both catalogs must carry exactly
the same keys; a missing key fails the typecheck. Use complete sentences with
named placeholders when word order can vary.
- Client components use `useI18n()`; server components use `await getI18n()`.
`t("Hello, {name}!", { name })` checks both the message key and required
placeholders at compile time. Never pass user content as a translation key.
Expand Down Expand Up @@ -48,7 +50,7 @@ preference validation, persistence, English fallback, interpolation, and status
labels. For browser verification:

1. Start with no locale cookie: sign-in should display English.
2. Switch to Traditional Chinese, reload, and navigate to another localized
2. Switch to Traditional Chinese (and again to Brazilian Portuguese), reload, and navigate to another localized
route: the selected language, labels, and dates should remain consistent.
3. In the campaign editor, change a message and toggle an option without saving.
Switch languages: both draft values and the selected post should remain.
Expand Down
15 changes: 12 additions & 3 deletions lib/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import ptBRCatalog from "./pt-BR.json";
import zhTW from "./zh-TW.json";

export const LOCALE_COOKIE = "openreply-locale";
export type Locale = "en" | "zh-TW";
export type Locale = "en" | "zh-TW" | "pt-BR";
export type MessageKey = keyof typeof zhTW;

// Every catalog must translate exactly the same English keys. `satisfies` makes
// a missing key a compile error instead of an `undefined` at render time.
const ptBR = ptBRCatalog satisfies Record<MessageKey, string>;
const catalogs: Record<Exclude<Locale, "en">, Record<MessageKey, string>> = {
"zh-TW": zhTW,
"pt-BR": ptBR,
};

export function isLocale(value: unknown): value is Locale {
return value === "en" || value === "zh-TW";
return value === "en" || value === "zh-TW" || value === "pt-BR";
}

export function resolveLocale(value: unknown): Locale {
Expand Down Expand Up @@ -53,7 +62,7 @@ const labels: Record<string, StaticMessageKey> = {

export function createI18n(locale: Locale) {
function t<K extends MessageKey>(key: K, ...args: MessageArgs<K>): string {
const message = locale === "zh-TW" ? zhTW[key] : key;
const message = locale === "en" ? key : catalogs[locale][key];
const values = args[0] as Record<string, string | number> | undefined;
return message.replace(/\{(\w+)\}/g, (placeholder, name: string) =>
values?.[name] === undefined ? placeholder : String(values[name]),
Expand Down
Loading