Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Add support for overriding strings in the app #7886

Merged
merged 8 commits into from
Mar 1, 2022
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: 2 additions & 0 deletions src/SdkConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface ConfigOptions {
// sso_immediate_redirect is deprecated in favour of sso_redirect_options.immediate
sso_immediate_redirect?: boolean;
sso_redirect_options?: ISsoRedirectOptions;

custom_translations_url?: string;
}
/* eslint-enable camelcase*/

Expand Down
92 changes: 88 additions & 4 deletions src/languageHandler.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/*
Copyright 2017 MTRNord and Cooperative EITA
Copyright 2017 Vector Creations Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 - 2022 The Matrix.org Foundation C.I.C.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand All @@ -21,11 +21,13 @@ import request from 'browser-request';
import counterpart from 'counterpart';
import React from 'react';
import { logger } from "matrix-js-sdk/src/logger";
import { Optional } from "matrix-events-sdk";

import SettingsStore from "./settings/SettingsStore";
import PlatformPeg from "./PlatformPeg";
import { SettingLevel } from "./settings/SettingLevel";
import { retry } from "./utils/promise";
import SdkConfig from "./SdkConfig";

// @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config
import webpackLangJsonUrl from "$webapp/i18n/languages.json";
Expand Down Expand Up @@ -394,10 +396,11 @@ export function setLanguage(preferredLangs: string | string[]) {
}

return getLanguageRetry(i18nFolder + availLangs[langToUse].fileName);
}).then((langData) => {
}).then(async (langData) => {
counterpart.registerTranslations(langToUse, langData);
await registerCustomTranslations();
counterpart.setLocale(langToUse);
SettingsStore.setValue("language", null, SettingLevel.DEVICE, langToUse);
await SettingsStore.setValue("language", null, SettingLevel.DEVICE, langToUse);
// Adds a lot of noise to test runs, so disable logging there.
if (process.env.NODE_ENV !== "test") {
logger.log("set language to " + langToUse);
Expand All @@ -407,8 +410,9 @@ export function setLanguage(preferredLangs: string | string[]) {
if (langToUse !== "en") {
return getLanguageRetry(i18nFolder + availLangs['en'].fileName);
}
}).then((langData) => {
}).then(async (langData) => {
if (langData) counterpart.registerTranslations('en', langData);
await registerCustomTranslations();
});
}

Expand Down Expand Up @@ -581,3 +585,83 @@ function getLanguage(langPath: string): Promise<object> {
);
});
}

export interface ICustomTranslations {
// Format is a map of english string to language to override
[str: string]: {
[lang: string]: string;
};
}

let cachedCustomTranslations: Optional<ICustomTranslations> = null;
let cachedCustomTranslationsExpire = 0; // zero to trigger expiration right away

// This awkward class exists so the test runner can get at the function. It is
// not intended for practical or realistic usage.
export class CustomTranslationOptions {
public static lookupFn: (url: string) => ICustomTranslations;
turt2live marked this conversation as resolved.
Show resolved Hide resolved

private constructor() {
// static access for tests only
}
}

/**
* If a custom translations file is configured, it will be parsed and registered.
* If no customization is made, or the file can't be parsed, no action will be
* taken.
*
* This function should be called *after* registering other translations data to
* ensure it overrides strings properly.
*/
export async function registerCustomTranslations() {
const lookupUrl = SdkConfig.get().custom_translations_url;
if (!lookupUrl) return; // easy - nothing to do

try {
let json: ICustomTranslations;
if (Date.now() >= cachedCustomTranslationsExpire) {
json = CustomTranslationOptions.lookupFn
? CustomTranslationOptions.lookupFn(lookupUrl)
: (await (await fetch(lookupUrl)).json() as ICustomTranslations);
cachedCustomTranslations = json;

// Set expiration to the future, but not too far. Just trying to avoid
// repeated, successive, calls to the server rather than anything long-term.
Copy link
Member

Choose a reason for hiding this comment

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

How often would we expect this to be called? It looks like it's just called from setLanguage which will do an http hit to get the file for that langauge anyway?

Copy link
Member Author

Choose a reason for hiding this comment

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

twice on app startup, for reasons I honestly didn't bother investigating.

Copy link
Member

Choose a reason for hiding this comment

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

hmm, that's fun - we should probably check that out at some point & see if the main translations file gets requested twice. Can I press you to make a bug for it? :)

Copy link
Member Author

Choose a reason for hiding this comment

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

cachedCustomTranslationsExpire = Date.now() + (5 * 60 * 1000);
} else {
json = cachedCustomTranslations;
}

// If the (potentially cached) json is invalid, don't use it.
if (!json) return;

// We convert the operator-friendly version into something counterpart can
// consume.
const langs: {
// same structure, just flipped key order
[lang: string]: {
[str: string]: string;
};
} = {};
for (const [str, translations] of Object.entries(json)) {
for (const [lang, newStr] of Object.entries(translations)) {
if (!langs[lang]) langs[lang] = {};
langs[lang][str] = newStr;
}
}

// Finally, tell counterpart about our translations
for (const [lang, translations] of Object.entries(langs)) {
counterpart.registerTranslations(lang, translations);
}
} catch (e) {
// We consume all exceptions because it's considered non-fatal for custom
// translations to break. Most failures will be during initial development
// of the json file and not (hopefully) at runtime.
dbkr marked this conversation as resolved.
Show resolved Hide resolved
logger.warn("Ignoring error while registering custom translations: ", e);

// Like above: trigger a cache of the json to avoid successive calls.
cachedCustomTranslationsExpire = Date.now() + (5 * 60 * 1000);
}
}
70 changes: 70 additions & 0 deletions test/languageHandler-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import SdkConfig from "../src/SdkConfig";
import {
_t,
CustomTranslationOptions,
ICustomTranslations,
registerCustomTranslations,
setLanguage,
} from "../src/languageHandler";

describe('languageHandler', () => {
afterEach(() => {
SdkConfig.unset();
CustomTranslationOptions.lookupFn = undefined;
});

it('should support overriding translations', async () => {
const str = "This is a test string that does not exist in the app.";
const enOverride = "This is the English version of a custom string.";
const deOverride = "This is the German version of a custom string.";
const overrides: ICustomTranslations = {
[str]: {
"en": enOverride,
"de": deOverride,
},
};

const lookupUrl = "/translations.json";
const fn = (url: string): ICustomTranslations => {
expect(url).toEqual(lookupUrl);
return overrides;
};

// First test that overrides aren't being used

await setLanguage("en");
expect(_t(str)).toEqual(str);

await setLanguage("de");
expect(_t(str)).toEqual(str);

// Now test that they *are* being used
SdkConfig.add({
custom_translations_url: lookupUrl,
});
CustomTranslationOptions.lookupFn = fn;
await registerCustomTranslations();

await setLanguage("en");
expect(_t(str)).toEqual(enOverride);

await setLanguage("de");
expect(_t(str)).toEqual(deOverride);
});
});