diff --git a/scripts/copy-res.js b/scripts/copy-res.ts
similarity index 68%
rename from scripts/copy-res.js
rename to scripts/copy-res.ts
index 957931f20d..83a0fe693e 100755
--- a/scripts/copy-res.js
+++ b/scripts/copy-res.ts
@@ -1,66 +1,31 @@
#!/usr/bin/env node
-const loaderUtils = require("loader-utils");
-
// copies the resources into the webapp directory.
-//
-// Languages are listed manually, so we can choose when to include a translation in the app
-// (because having a translation with only 3 strings translated is just frustrating)
-// This could readily be automated, but it's nice to explicitly control when new languages are available.
-const INCLUDE_LANGS = [
- "bg",
- "ca",
- "cs",
- "da",
- "de_DE",
- "el",
- "en_EN",
- "en_US",
- "eo",
- "es",
- "et",
- "eu",
- "fi",
- "fr",
- "gl",
- "he",
- "hi",
- "hu",
- "id",
- "is",
- "it",
- "ja",
- "kab",
- "ko",
- "lo",
- "lt",
- "lv",
- "nb_NO",
- "nl",
- "nn",
- "pl",
- "pt",
- "pt_BR",
- "ru",
- "sk",
- "sq",
- "sr",
- "sv",
- "te",
- "th",
- "tr",
- "uk",
- "vi",
- "vls",
- "zh_Hans",
- "zh_Hant",
-];
+import parseArgs from "minimist";
+import * as chokidar from "chokidar";
+import * as fs from "node:fs";
+import _ from "lodash";
+import { Cpx } from "cpx";
+import * as loaderUtils from "loader-utils";
+import { Translations } from "matrix-web-i18n";
+
+const REACT_I18N_BASE_PATH = "node_modules/matrix-react-sdk/src/i18n/strings/";
+const I18N_BASE_PATH = "src/i18n/strings/";
+const INCLUDE_LANGS = [...new Set([...fs.readdirSync(I18N_BASE_PATH), ...fs.readdirSync(REACT_I18N_BASE_PATH)])]
+ .filter((fn) => fn.endsWith(".json"))
+ .map((f) => f.slice(0, -5));
// cpx includes globbed parts of the filename in the destination, but excludes
// common parents. Hence, "res/{a,b}/**": the output will be "dest/a/..." and
// "dest/b/...".
-const COPY_LIST = [
+const COPY_LIST: [
+ sourceGlob: string,
+ outputPath: string,
+ opts?: {
+ directwatch?: 1;
+ },
+][] = [
["res/apple-app-site-association", "webapp"],
["res/manifest.json", "webapp"],
["res/sw.js", "webapp"],
@@ -74,18 +39,12 @@ const COPY_LIST = [
["./config.json", "webapp", { directwatch: 1 }],
["contribute.json", "webapp"],
];
-
-const parseArgs = require("minimist");
-const Cpx = require("cpx");
-const chokidar = require("chokidar");
-const fs = require("fs");
-
const argv = parseArgs(process.argv.slice(2), {});
const watch = argv.w;
const verbose = argv.v;
-function errCheck(err) {
+function errCheck(err?: Error): void {
if (err) {
console.error(err.message);
process.exit(1);
@@ -101,7 +60,7 @@ if (!fs.existsSync("webapp/i18n/")) {
fs.mkdirSync("webapp/i18n/");
}
-function next(i, err) {
+function next(i: number, err?: Error): void {
errCheck(err);
if (i >= COPY_LIST.length) {
@@ -112,13 +71,9 @@ function next(i, err) {
const source = ent[0];
const dest = ent[1];
const opts = ent[2] || {};
- let cpx = undefined;
+ const cpx = new Cpx(source, dest);
- if (!opts.lang) {
- cpx = new Cpx.Cpx(source, dest);
- }
-
- if (verbose && cpx) {
+ if (verbose) {
cpx.on("copy", (event) => {
console.log(`Copied: ${event.srcPath} --> ${event.dstPath}`);
});
@@ -127,7 +82,7 @@ function next(i, err) {
});
}
- const cb = (err) => {
+ const cb = (err?: Error): void => {
next(i + 1, err);
};
@@ -137,7 +92,7 @@ function next(i, err) {
// which in the case of config.json is '.', which inevitably takes
// ages to crawl. So we create our own watcher on the files
// instead.
- const copy = () => {
+ const copy = (): void => {
cpx.copy(errCheck);
};
chokidar.watch(source).on("add", copy).on("change", copy).on("ready", cb).on("error", errCheck);
@@ -151,15 +106,15 @@ function next(i, err) {
}
}
-function genLangFile(lang, dest) {
- const reactSdkFile = "node_modules/matrix-react-sdk/src/i18n/strings/" + lang + ".json";
- const riotWebFile = "src/i18n/strings/" + lang + ".json";
+function genLangFile(lang: string, dest: string): string {
+ const reactSdkFile = REACT_I18N_BASE_PATH + lang + ".json";
+ const riotWebFile = I18N_BASE_PATH + lang + ".json";
- const translations = {};
+ let translations: Translations = {};
[reactSdkFile, riotWebFile].forEach(function (f) {
if (fs.existsSync(f)) {
try {
- Object.assign(translations, JSON.parse(fs.readFileSync(f).toString()));
+ translations = _.merge(translations, JSON.parse(fs.readFileSync(f).toString()));
} catch (e) {
console.error("Failed: " + f, e);
throw e;
@@ -169,7 +124,7 @@ function genLangFile(lang, dest) {
const json = JSON.stringify(translations, null, 4);
const jsonBuffer = Buffer.from(json);
- const digest = loaderUtils.getHashDigest(jsonBuffer, null, null, 7);
+ const digest = loaderUtils.getHashDigest(jsonBuffer, null, "hex", 7);
const filename = `${lang}.${digest}.json`;
fs.writeFileSync(dest + filename, json);
@@ -180,8 +135,8 @@ function genLangFile(lang, dest) {
return filename;
}
-function genLangList(langFileMap) {
- const languages = {};
+function genLangList(langFileMap: Record
): void {
+ const languages: Record = {};
INCLUDE_LANGS.forEach(function (lang) {
const normalizedLanguage = lang.toLowerCase().replace("_", "-");
const languageParts = normalizedLanguage.split("-");
@@ -193,7 +148,7 @@ function genLangList(langFileMap) {
});
fs.writeFile("webapp/i18n/languages.json", JSON.stringify(languages, null, 4), function (err) {
if (err) {
- console.error("Copy Error occured: " + err);
+ console.error("Copy Error occured: " + err.message);
throw new Error("Failed to generate languages.json");
}
});
@@ -207,15 +162,15 @@ function genLangList(langFileMap) {
* regenerate the file, adding its content-hashed filename to langFileMap
* and regenerating languages.json with the new filename
*/
-function watchLanguage(lang, dest, langFileMap) {
- const reactSdkFile = "node_modules/matrix-react-sdk/src/i18n/strings/" + lang + ".json";
- const riotWebFile = "src/i18n/strings/" + lang + ".json";
+function watchLanguage(lang: string, dest: string, langFileMap: Record): void {
+ const reactSdkFile = REACT_I18N_BASE_PATH + lang + ".json";
+ const riotWebFile = I18N_BASE_PATH + lang + ".json";
// XXX: Use a debounce because for some reason if we read the language
// file immediately after the FS event is received, the file contents
// appears empty. Possibly https://github.com/nodejs/node/issues/6112
- let makeLangDebouncer;
- const makeLang = () => {
+ let makeLangDebouncer: ReturnType;
+ const makeLang = (): void => {
if (makeLangDebouncer) {
clearTimeout(makeLangDebouncer);
}
@@ -233,7 +188,7 @@ function watchLanguage(lang, dest, langFileMap) {
// language resources
const I18N_DEST = "webapp/i18n/";
-const I18N_FILENAME_MAP = INCLUDE_LANGS.reduce((m, l) => {
+const I18N_FILENAME_MAP = INCLUDE_LANGS.reduce>((m, l) => {
const filename = genLangFile(l, I18N_DEST);
m[l] = filename;
return m;
@@ -241,7 +196,7 @@ const I18N_FILENAME_MAP = INCLUDE_LANGS.reduce((m, l) => {
genLangList(I18N_FILENAME_MAP);
if (watch) {
- INCLUDE_LANGS.forEach((l) => watchLanguage(l.value, I18N_DEST, I18N_FILENAME_MAP));
+ INCLUDE_LANGS.forEach((l) => watchLanguage(l, I18N_DEST, I18N_FILENAME_MAP));
}
// non-language resources
diff --git a/src/@types/cpx.d.ts b/src/@types/cpx.d.ts
new file mode 100644
index 0000000000..e128517e3d
--- /dev/null
+++ b/src/@types/cpx.d.ts
@@ -0,0 +1,43 @@
+/*
+Copyright 2023 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.
+*/
+
+declare module "cpx" {
+ export class Cpx {
+ public constructor(source: string, outDir: string, options?: object);
+
+ public on(eventName: "copy", fn: (event: { srcPath: string; dstPath: string }) => void): void;
+ public on(eventName: "remove", fn: (event: { path: string }) => void): void;
+ public on(eventName: "watch-ready", fn: () => void): void;
+ public on(eventName: "watch-error", fn: (error: Error) => void): void;
+
+ /**
+ * Copy all files that matches `this.source` pattern to `this.outDir`.
+ *
+ * @param {function} [cb = null] - A callback function.
+ * @returns {void}
+ */
+ public copy(cb: Function | null): void;
+
+ /**
+ * Copy all files that matches `this.source` pattern to `this.outDir`.
+ * And watch changes in `this.base`, and copy only the file every time.
+ *
+ * @returns {void}
+ * @throws {Error} This had been watching already.
+ */
+ public watch(): void;
+ }
+}
diff --git a/src/@types/loader-utils.d.ts b/src/@types/loader-utils.d.ts
new file mode 100644
index 0000000000..c9ceb6574a
--- /dev/null
+++ b/src/@types/loader-utils.d.ts
@@ -0,0 +1,28 @@
+/*
+Copyright 2023 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 * as LoaderUtils from "loader-utils";
+
+declare module "loader-utils" {
+ export function getHashDigest(
+ buffer: Buffer,
+ hashType: null,
+ digestType: LoaderUtils.DigestType,
+ maxLength: number,
+ ): string;
+}
+
+export as namespace Cpx;
diff --git a/src/async-components/structures/CompatibilityView.tsx b/src/async-components/structures/CompatibilityView.tsx
index 4b169f88a0..c6f197e6da 100644
--- a/src/async-components/structures/CompatibilityView.tsx
+++ b/src/async-components/structures/CompatibilityView.tsx
@@ -92,7 +92,7 @@ const CompatibilityView: React.FC = ({ onAccept }) => {
android = [];
}
- let mobileHeader: ReactNode = {_t("Use %(brand)s on mobile", { brand })}
;
+ let mobileHeader: ReactNode = {_t("use_brand_on_mobile", { brand })}
;
if (!android.length && !ios) {
mobileHeader = null;
}
@@ -104,22 +104,17 @@ const CompatibilityView: React.FC = ({ onAccept }) => {
- {_t("Unsupported browser")}
+ {_t("incompatible_browser|title")}