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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
- **feat(api-keys):** track devices/connections per API key — an in-memory, TTL-evicted device fingerprint tracker (SHA-256 of masked IP + truncated user-agent) wired non-blocking into the chat path and surfaced via `GET /api/keys/[id]/devices` with a dashboard device-count chip. (thanks @mugnimaestra)
- **feat(proxy):** add Webshare proxy pool import and sync — a `WebshareProvider` (`FreeProxyProvider`) that paginates `proxy.webshare.io/api/v2/proxy/list/` gated on `FREE_PROXY_WEBSHARE_API_KEY`, SSRF-guards imported hosts, and tombstones retired proxy IDs via `pruneStaleFreeProxies()`. (thanks @ricatix)
- **feat(dashboard):** suggest HuggingFace Hub media models in the media provider view. (thanks @yicone)
- **feat(i18n):** auto-detect the browser language on first visit. (thanks @ayanmw)

### 🔧 Bug Fixes

Expand Down
3 changes: 2 additions & 1 deletion src/app/(dashboard)/dashboard/HomePageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useRouter } from "next/navigation";
import { Card, CardSkeleton, Button, Modal } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { AI_PROVIDERS, NOAUTH_PROVIDERS, OAUTH_PROVIDERS } from "@/shared/constants/providers";
import { extractApiErrorMessage } from "@/shared/http/apiErrorMessage";
import { useNotificationStore } from "@/store/notificationStore";
import { copyToClipboard } from "@/shared/utils/clipboard";
import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel";
Expand Down Expand Up @@ -685,7 +686,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
if (contentType.includes("application/json")) {
const data = await res.json();
if (!res.ok || !data.success) {
notify.error(data.error || "Failed to start update.");
notify.error(extractApiErrorMessage(data, "Failed to start update."));
setUpdating(false);
setUpdatePhase("idle");
return;
Expand Down
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { normalizeComplianceEventTypes } from "@/i18n/request";
import { getSettings } from "@/lib/db/settings";
import type { Viewport } from "next";
import { PwaRegister } from "@/shared/components/PwaRegister";
import { LocaleAutoDetect } from "@/shared/components/LocaleAutoDetect";

const inter = Inter({
subsets: ["latin"],
Expand Down Expand Up @@ -109,6 +110,7 @@ export default async function RootLayout({ children }) {
</a>
<NextIntlClientProvider locale={locale} messages={messages}>
<PwaRegister />
<LocaleAutoDetect />
<ThemeProvider>{children}</ThemeProvider>
</NextIntlClientProvider>
</body>
Expand Down
52 changes: 52 additions & 0 deletions src/i18n/detectBrowserLocale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Pure browser-language detector used to pick an initial locale on first
* visit, before the user has made an explicit selection (no cookie set).
*
* Matching order:
* 1. Exact match against `navigator.languages` entries (case-insensitive).
* 2. `zh-HK` / `zh-MO` are treated as `zh-TW` (Traditional Chinese) since
* OmniRoute does not ship a dedicated Hong-Kong/Macau locale.
* 3. Language-prefix match — e.g. `en-US` matches a supported `en` locale.
* 4. No match → `null` (caller should keep the existing default).
*
* Kept dependency-free (no DOM/`navigator` access) so it is trivially unit
* testable and reusable from both client components and future server code.
*/
export function detectBrowserLocale(
languages: readonly string[],
locales: readonly string[]
): string | null {
if (!languages || languages.length === 0 || !locales || locales.length === 0) {
return null;
}

const normalizedLocales = locales.map((locale) => locale.toLowerCase());

for (const rawLanguage of languages) {
if (!rawLanguage) continue;
const language = rawLanguage.toLowerCase();

// 1. Exact match.
const exactIndex = normalizedLocales.indexOf(language);
if (exactIndex !== -1) {
return locales[exactIndex];
}

// 2. zh-HK / zh-MO fold to zh-TW when zh-TW is supported.
if (language === "zh-hk" || language === "zh-mo") {
const zhTwIndex = normalizedLocales.indexOf("zh-tw");
if (zhTwIndex !== -1) {
return locales[zhTwIndex];
}
}

// 3. Language-prefix match (e.g. "en-US" -> "en").
const prefix = language.split("-")[0];
const prefixIndex = normalizedLocales.indexOf(prefix);
Comment on lines +44 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match primary tags to regional locales

When a supported locale is regional without a bare-language entry, this lookup fails the new first-visit detection path. The live locale list includes uk-UA and zh-CN but not uk or zh (config/i18n.json), so common browser values like uk or zh-Hans-CN fall through to null instead of selecting the available Ukrainian/Simplified Chinese UI, leaving those users on the default locale despite a supported translation being present. Consider also matching supported locales whose primary subtag equals prefix when no bare locale exists.

Useful? React with 👍 / 👎.

if (prefixIndex !== -1) {
return locales[prefixIndex];
}
}

return null;
}
13 changes: 2 additions & 11 deletions src/shared/components/LanguageSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,10 @@

import { useState, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import { LANGUAGES, LOCALE_COOKIE } from "@/i18n/config";
import { LANGUAGES } from "@/i18n/config";
import type { Locale } from "@/i18n/config";
import { useLocale } from "next-intl";

/** Persist locale preference in cookie + localStorage (outside component scope for ESLint) */
function persistLocale(code: Locale) {
document.cookie = `${LOCALE_COOKIE}=${code};path=/;max-age=${365 * 24 * 60 * 60};samesite=lax`;
try {
localStorage.setItem(LOCALE_COOKIE, code);
} catch {
// Ignore
}
}
import { persistLocale } from "@/shared/lib/persistLocale";

function CountryFlag({ emoji, alt }: { emoji: string; alt: string }) {
const [error, setError] = useState(false);
Expand Down
36 changes: 36 additions & 0 deletions src/shared/components/LocaleAutoDetect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"use client";

import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { LOCALES, LOCALE_COOKIE } from "@/i18n/config";
import type { Locale } from "@/i18n/config";
import { detectBrowserLocale } from "@/i18n/detectBrowserLocale";
import { persistLocale } from "@/shared/lib/persistLocale";

function hasLocaleCookie(): boolean {
return document.cookie
.split(";")
.some((entry) => entry.trim().startsWith(`${LOCALE_COOKIE}=`));
}

/**
* Auto-detects the browser language on first visit (no locale cookie set
* yet) and persists it via the same writer `LanguageSelector` uses for a
* manual selection, then refreshes the router so the server re-renders with
* the detected locale. Mounted once in the root layout; renders nothing.
*/
export function LocaleAutoDetect() {
const router = useRouter();

useEffect(() => {
if (typeof navigator === "undefined" || hasLocaleCookie()) return;

const detected = detectBrowserLocale(navigator.languages ?? [navigator.language], LOCALES);
if (!detected) return;

persistLocale(detected as Locale);
router.refresh();
}, [router]);

return null;
}
19 changes: 19 additions & 0 deletions src/shared/lib/persistLocale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { LOCALE_COOKIE } from "@/i18n/config";
import type { Locale } from "@/i18n/config";

/**
* Persist the locale preference in the cookie `src/i18n/request.ts` reads on
* the server, plus localStorage as a client-side convenience mirror.
*
* Shared by every client-side locale writer (manual selection in
* `LanguageSelector`, first-visit auto-detection in `LocaleAutoDetect`) so
* there is a single source of truth for the cookie name/format.
*/
export function persistLocale(code: Locale): void {
document.cookie = `${LOCALE_COOKIE}=${code};path=/;max-age=${365 * 24 * 60 * 60};samesite=lax`;
try {
localStorage.setItem(LOCALE_COOKIE, code);
} catch {
// Ignore (e.g. storage disabled/full)
}
}
43 changes: 43 additions & 0 deletions tests/unit/i18n-detect-browser-locale.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { detectBrowserLocale } from "../../src/i18n/detectBrowserLocale";

const SUPPORTED_LOCALES = ["en", "pt-BR", "es", "zh-TW", "fr", "de"] as const;

describe("detectBrowserLocale", () => {
it("returns the exact match when a browser language equals a supported locale", () => {
assert.equal(detectBrowserLocale(["pt-BR"], SUPPORTED_LOCALES), "pt-BR");
});

it("folds zh-HK to zh-TW when zh-TW is supported", () => {
assert.equal(detectBrowserLocale(["zh-HK"], SUPPORTED_LOCALES), "zh-TW");
});

it("folds zh-MO to zh-TW when zh-TW is supported", () => {
assert.equal(detectBrowserLocale(["zh-MO"], SUPPORTED_LOCALES), "zh-TW");
});

it("falls back to a language-prefix match when no exact match exists", () => {
assert.equal(detectBrowserLocale(["en-US"], SUPPORTED_LOCALES), "en");
});

it("returns null when nothing matches", () => {
assert.equal(detectBrowserLocale(["ja-JP"], SUPPORTED_LOCALES), null);
});

it("returns null for an empty languages list", () => {
assert.equal(detectBrowserLocale([], SUPPORTED_LOCALES), null);
});

it("returns null for an empty locales list", () => {
assert.equal(detectBrowserLocale(["en-US"], []), null);
});

it("tries each browser language in order until one matches", () => {
assert.equal(detectBrowserLocale(["ja-JP", "fr-CA"], SUPPORTED_LOCALES), "fr");
});

it("is case-insensitive", () => {
assert.equal(detectBrowserLocale(["PT-br"], SUPPORTED_LOCALES), "pt-BR");
});
});
37 changes: 37 additions & 0 deletions tests/unit/ui/home-update-error-render.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { describe, it } from "node:test";
import { fileURLToPath } from "node:url";

const here = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(
resolve(here, "../../../src/app/(dashboard)/dashboard/HomePageClient.tsx"),
"utf8"
);

describe("HomePageClient update error rendering", () => {
it("imports the safe API error extractor", () => {
assert.match(
source,
/import\s*\{\s*extractApiErrorMessage\s*\}\s*from\s*["']@\/shared\/http\/apiErrorMessage["']/,
"HomePageClient must import extractApiErrorMessage to render API errors safely"
);
});

it("funnels the update error body through extractApiErrorMessage", () => {
assert.match(
source,
/notify\.error\(\s*extractApiErrorMessage\(\s*data\s*,/,
"the update-error notify.error call must use extractApiErrorMessage(data, ...)"
);
});

it("does not pass the raw API error object to notify.error", () => {
assert.doesNotMatch(
source,
/notify\.error\(\s*data\.error\b/,
"notify.error(data.error ...) can render an error envelope object as a React child"
);
});
});
Loading