Skip to content

Commit

Permalink
feat(language-menu): add "Remember language" experiment (#11518)
Browse files Browse the repository at this point in the history
This feature allows users to choose a preferred locale, so that 
they always get redirected if they visit a page in a different 
locale and the page is also available in their preferred locale.

Co-authored-by: Leo McArdle <lmcardle@mozilla.com>
  • Loading branch information
caugner and LeoMcA committed Sep 25, 2024
1 parent e2a4910 commit 3ac917f
Show file tree
Hide file tree
Showing 10 changed files with 246 additions and 42 deletions.
1 change: 1 addition & 0 deletions client/src/telemetry/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,6 @@ export const BASELINE = Object.freeze({

export const CLIENT_SIDE_NAVIGATION = "client_side_nav";
export const LANGUAGE = "language";
export const LANGUAGE_REMEMBER = "language_remember";
export const THEME_SWITCHER = "theme_switcher";
export const SURVEY = "survey";
38 changes: 37 additions & 1 deletion client/src/ui/organisms/article-actions/language-menu/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,44 @@
}

.language-menu {
li {
&:not(:first-child) {
padding-top: 1px;
}

&:not(:last-child) {
padding-bottom: 1px;
}
}

.submenu-item {
padding: 0.5rem;
// Reduce padding compared to other menus.
padding: 0.5rem !important;

&.locale-redirect-setting {
border-bottom: 1px solid var(--border-secondary) !important;
border-radius: 0 !important;
display: block;
font-size: var(--type-tiny-font-size);

&:hover {
background-color: unset;
}

.switch {
display: flex;
}

.glean-thumbs {
font-style: italic;
font-variation-settings: "slnt" -10;
margin-top: 0.5em;

.icon {
margin-right: unset;
}
}
}
}

@media (min-width: $screen-md) {
Expand Down
108 changes: 74 additions & 34 deletions client/src/ui/organisms/article-actions/language-menu/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";

import { useGleanClick } from "../../../../telemetry/glean-context";
Expand All @@ -9,10 +9,18 @@ import { Submenu } from "../../../molecules/submenu";
import "./index.scss";
import { DropdownMenu, DropdownMenuWrapper } from "../../../molecules/dropdown";
import { useLocale } from "../../../../hooks";
import { LANGUAGE } from "../../../../telemetry/constants";
import { LANGUAGE, LANGUAGE_REMEMBER } from "../../../../telemetry/constants";
import {
deleteCookie,
getCookieValue,
setCookieValue,
} from "../../../../utils";
import { GleanThumbs } from "../../../atoms/thumbs";
import { Switch } from "../../../atoms/switch";

// This needs to match what's set in 'libs/constants.js' on the server/builder!
const PREFERRED_LOCALE_COOKIE_NAME = "preferredlocale";
const PREFERRED_LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 * 3; // 3 years.

export function LanguageMenu({
onClose,
Expand All @@ -29,51 +37,44 @@ export function LanguageMenu({
const [isOpen, setIsOpen] = useState<boolean>(false);

const changeLocale: React.MouseEventHandler<HTMLAnchorElement> = (event) => {
const preferredLocale = event.currentTarget.dataset.locale;
const newLocale = event.currentTarget.dataset.locale;
// The default is the current locale itself. If that's what's chosen,
// don't bother redirecting.
if (preferredLocale !== locale) {
let cookieValueBefore = document.cookie
.split("; ")
.find((row) => row.startsWith(`${PREFERRED_LOCALE_COOKIE_NAME}=`));
if (cookieValueBefore && cookieValueBefore.includes("=")) {
cookieValueBefore = cookieValueBefore.split("=")[1];
}
if (newLocale !== locale) {
const oldLocale = getCookieValue(PREFERRED_LOCALE_COOKIE_NAME);

for (const translation of translations) {
if (translation.locale === preferredLocale) {
let cookieValue = `${PREFERRED_LOCALE_COOKIE_NAME}=${
translation.locale
};max-age=${60 * 60 * 24 * 365 * 3};path=/`;
if (
!(
document.location.hostname === "localhost" ||
document.location.hostname === "localhost.org"
)
) {
cookieValue += ";secure";
if (oldLocale === locale) {
for (const translation of translations) {
if (translation.locale === newLocale) {
setCookieValue(PREFERRED_LOCALE_COOKIE_NAME, newLocale, {
maxAge: PREFERRED_LOCALE_COOKIE_MAX_AGE,
});
gleanClick(`${LANGUAGE_REMEMBER}: ${oldLocale} -> ${newLocale}`);
}
document.cookie = cookieValue;
}
}

const oldValue = cookieValueBefore || "none";
gleanClick(`${LANGUAGE}: ${oldValue} -> ${preferredLocale}`);
gleanClick(`${LANGUAGE}: ${locale} -> ${newLocale}`);
}
};

const menuEntry = {
label: "Languages",
id: menuId,
items: translations.map((translation) => ({
component: () => (
<LanguageMenuItem
native={native}
translation={translation}
changeLocale={changeLocale}
/>
),
})),
items: [
{
component: () => <LocaleRedirectSetting />,
},
...translations.map((translation) => ({
component: () => (
<LanguageMenuItem
native={native}
translation={translation}
changeLocale={changeLocale}
/>
),
})),
],
};

return (
Expand Down Expand Up @@ -127,3 +128,42 @@ function LanguageMenuItem({
</a>
);
}

function LocaleRedirectSetting() {
const gleanClick = useGleanClick();
const locale = useLocale();
const [preferredLocale, setPreferredLocale] = useState<string | undefined>();

useEffect(() => {
setPreferredLocale(getCookieValue(PREFERRED_LOCALE_COOKIE_NAME));
}, []);

function toggle(event) {
const oldValue = getCookieValue(PREFERRED_LOCALE_COOKIE_NAME);
const newValue = event.target.checked;
if (newValue) {
setCookieValue(PREFERRED_LOCALE_COOKIE_NAME, locale, {
maxAge: 60 * 60 * 24 * 365 * 3,
});
setPreferredLocale(locale);
gleanClick(`${LANGUAGE_REMEMBER}: ${oldValue} -> ${locale}`);
} else {
deleteCookie(PREFERRED_LOCALE_COOKIE_NAME);
setPreferredLocale(undefined);
gleanClick(`${LANGUAGE_REMEMBER}: ${oldValue} -> 0`);
}
}

return (
<form className="submenu-item locale-redirect-setting">
<Switch
name="locale-redirect"
checked={locale === preferredLocale}
toggle={toggle}
>
Remember language
</Switch>
<GleanThumbs feature="locale-redirect" question="Is this useful?" />
</form>
);
}
38 changes: 38 additions & 0 deletions client/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,41 @@ export function splitQuery(term: string): string[] {
return term.split(/[ ,.]+/);
}
}

export function getCookieValue(name: string) {
let value = document.cookie
.split("; ")
.find((row) => row.startsWith(`${name}=`));

if (value && value.includes("=")) {
value = value.split("=")[1];
}

return value;
}

export function setCookieValue(
name: string,
value: string,
{
expires,
maxAge,
path = "/",
}: { expires?: Date; maxAge?: number; path?: string }
) {
const cookieValue = [
`${name}=${value}`,
expires && `expires=${expires.toUTCString()}`,
maxAge && `max-age=${maxAge}`,
`path=${path}`,
document.location.hostname !== "localhost" && "secure",
]
.filter(Boolean)
.join(";");

document.cookie = cookieValue;
}

export function deleteCookie(name: string) {
setCookieValue(name, "", { expires: new Date(0) });
}
34 changes: 34 additions & 0 deletions cloud-function/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions cloud-function/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@yari-internal/pong": "file:src/internal/pong",
"@yari-internal/slug-utils": "file:src/internal/slug-utils",
"accept-language-parser": "^1.5.0",
"cookie-parser": "^1.4.6",
"dotenv": "^16.0.3",
"express": "^4.19.2",
"http-proxy-middleware": "^3.0.0",
Expand All @@ -44,6 +45,7 @@
"devDependencies": {
"@swc/core": "^1.3.38",
"@types/accept-language-parser": "^1.5.3",
"@types/cookie-parser": "^1.4.7",
"@types/http-proxy": "^1.17.10",
"@types/http-server": "^0.12.1",
"cross-env": "^7.0.3",
Expand Down
4 changes: 4 additions & 0 deletions cloud-function/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import cookieParser from "cookie-parser";
import express, { Request, Response } from "express";
import { Router } from "express";

Expand All @@ -16,6 +17,7 @@ import { redirectMovedPages } from "./middlewares/redirect-moved-pages.js";
import { redirectEnforceTrailingSlash } from "./middlewares/redirect-enforce-trailing-slash.js";
import { redirectFundamental } from "./middlewares/redirect-fundamental.js";
import { redirectLocale } from "./middlewares/redirect-locale.js";
import { redirectPreferredLocale } from "./middlewares/redirect-preferred-locale.js";
import { redirectTrailingSlash } from "./middlewares/redirect-trailing-slash.js";
import { requireOrigin } from "./middlewares/require-origin.js";
import { notFound } from "./middlewares/not-found.js";
Expand All @@ -25,6 +27,7 @@ import { stripForwardedHostHeaders } from "./middlewares/stripForwardedHostHeade
import { proxyPong } from "./handlers/proxy-pong.js";

const router = Router();
router.use(cookieParser());
router.use(stripForwardedHostHeaders);
router.use(redirectLeadingSlash);
// MDN Plus plans.
Expand Down Expand Up @@ -85,6 +88,7 @@ router.get(
requireOrigin(Origin.main),
redirectFundamental,
redirectLocale,
redirectPreferredLocale,
redirectTrailingSlash,
redirectMovedPages,
resolveIndexHTML,
Expand Down
5 changes: 5 additions & 0 deletions cloud-function/src/canonicals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createRequire } from "node:module";

const require = createRequire(import.meta.url);

export const CANONICALS = require("../canonicals.json");
11 changes: 4 additions & 7 deletions cloud-function/src/middlewares/redirect-non-canonicals.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { createRequire } from "node:module";

import { NextFunction, Request, Response } from "express";

import { THIRTY_DAYS } from "../constants.js";
import { normalizePath, redirect } from "../utils.js";
import { CANONICALS } from "../canonicals.js";

const require = createRequire(import.meta.url);
const REDIRECTS = require("../../canonicals.json");
const REDIRECT_SUFFIXES = ["/index.json", "/bcd.json", ""];

export async function redirectNonCanonicals(
Expand All @@ -31,10 +28,10 @@ export async function redirectNonCanonicals(
);
const source = normalizePath(originalSource);
if (
typeof REDIRECTS[source] == "string" &&
REDIRECTS[source] !== originalSource
typeof CANONICALS[source] == "string" &&
CANONICALS[source] !== originalSource
) {
const target = joinPath(REDIRECTS[source], suffix) + parsedUrl.search;
const target = joinPath(CANONICALS[source], suffix) + parsedUrl.search;
if (pathname !== target) {
return redirect(res, target, {
status: 301,
Expand Down
Loading

0 comments on commit 3ac917f

Please sign in to comment.