diff --git a/package.json b/package.json
index 520c5f46b..5c966d0ff 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"husky": "^1.3.1",
"jasmine": "^3.5.0",
"tslint": "^5.9.1",
- "typescript": "^3.8.3"
+ "typescript": "^4"
},
"engines": {
"yarn": ">=1.0",
diff --git a/src/server_manager/package.json b/src/server_manager/package.json
index 1d45a7ffb..825e40a29 100644
--- a/src/server_manager/package.json
+++ b/src/server_manager/package.json
@@ -41,7 +41,6 @@
"@sentry/electron": "^1.3.0",
"@webcomponents/webcomponentsjs": "^2.0.0",
"body-parser": "^1.18.3",
- "byte-size": "^6.2.0",
"clipboard-polyfill": "^2.4.6",
"dotenv": "~8.2.0",
"electron-updater": "^4.1.2",
diff --git a/src/server_manager/web_app/app.ts b/src/server_manager/web_app/app.ts
index d0fdd9d32..9f7ca6a57 100644
--- a/src/server_manager/web_app/app.ts
+++ b/src/server_manager/web_app/app.ts
@@ -20,6 +20,7 @@ import * as errors from '../infrastructure/errors';
import {sleep} from '../infrastructure/sleep';
import * as server from '../model/server';
+import {formatBytes} from './data_formatting';
import {TokenManager} from './digitalocean_oauth';
import * as digitalocean_server from './digitalocean_server';
import {parseManualServerConfig} from './management_urls';
@@ -666,7 +667,7 @@ export class App {
view.serverHostname = server.getHostnameForAccessKeys();
view.serverManagementApiUrl = server.getManagementApiUrl();
view.serverPortForNewAccessKeys = server.getPortForNewAccessKeys();
- view.serverCreationDate = localizeDate(server.getCreatedDate(), this.appRoot.language);
+ view.serverCreationDate = server.getCreatedDate();
view.serverVersion = server.getVersion();
view.accessKeyDataLimit = dataLimitToDisplayDataAmount(server.getAccessKeyDataLimit());
view.isAccessKeyDataLimitEnabled = !!view.accessKeyDataLimit;
@@ -686,7 +687,7 @@ export class App {
view.monthlyCost = host.getMonthlyCost().usd;
view.monthlyOutboundTransferBytes =
host.getMonthlyOutboundTransferLimit().terabytes * (10 ** 12);
- view.serverLocation = this.getLocalizedCityName(host.getRegionId());
+ view.serverLocationId = digitalocean_server.GetCityId(host.getRegionId());
} else {
view.isServerManaged = false;
}
@@ -772,7 +773,7 @@ export class App {
for (const accessKeyId in stats.bytesTransferredByUserId) {
totalBytes += stats.bytesTransferredByUserId[accessKeyId];
}
- serverView.setServerTransferredData(totalBytes);
+ serverView.totalInboundBytes = totalBytes;
const accessKeyDataLimit = selectedServer.getAccessKeyDataLimit();
if (accessKeyDataLimit) {
@@ -1087,10 +1088,14 @@ export class App {
});
}
- private setAppLanguage(languageCode: string, languageDir: string) {
- this.appRoot.setLanguage(languageCode, languageDir);
- document.documentElement.setAttribute('dir', languageDir);
- window.localStorage.setItem('overrideLanguage', languageCode);
+ private async setAppLanguage(languageCode: string, languageDir: string) {
+ try {
+ await this.appRoot.setLanguage(languageCode, languageDir);
+ document.documentElement.setAttribute('dir', languageDir);
+ window.localStorage.setItem('overrideLanguage', languageCode);
+ } catch (error) {
+ this.appRoot.showError(this.appRoot.localize('error-unexpected'));
+ }
}
private createLocationModel(cityId: string, regionIds: string[]): Location {
diff --git a/src/server_manager/web_app/data_formatting.spec.ts b/src/server_manager/web_app/data_formatting.spec.ts
new file mode 100644
index 000000000..eb20fd3bf
--- /dev/null
+++ b/src/server_manager/web_app/data_formatting.spec.ts
@@ -0,0 +1,67 @@
+/*
+ Copyright 2020 The Outline Authors
+
+ 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 i18n from './data_formatting';
+
+describe('formatBytesParts', () => {
+ if (process?.versions?.node) {
+ it('doesn\'t run on Node', () => {
+ expect(() => i18n.formatBytesParts(0, 'en')).toThrow();
+ });
+ } else {
+ it('extracts the unit string and value separately', () => {
+ const english = i18n.formatBytesParts(0, 'en');
+ expect(english.unit).toEqual('B');
+ expect(english.value).toEqual('0');
+
+ const korean = i18n.formatBytesParts(2, 'kr');
+ expect(korean.unit).toEqual('B');
+ expect(korean.value).toEqual('2');
+
+ const russian = i18n.formatBytesParts(3000, 'ru');
+ expect(russian.unit).toEqual('кБ');
+ expect(russian.value).toEqual('3');
+
+ const simplifiedChinese = i18n.formatBytesParts(1.5 * 10 ** 9, 'zh-CN');
+ expect(simplifiedChinese.unit).toEqual('吉字节');
+ expect(simplifiedChinese.value).toEqual('1.5');
+
+ const farsi = i18n.formatBytesParts(133.5 * 10 ** 6, 'fa');
+ expect(farsi.unit).toEqual('مگابایت');
+ expect(farsi.value).toEqual('۱۳۳٫۵');
+ });
+ }
+});
+
+describe('formatBytes', () => {
+ if (process?.versions?.node) {
+ it('doesn\'t run on Node', () => {
+ expect(() => i18n.formatBytes(0, 'en')).toThrow();
+ });
+ } else {
+ it('Formats data amounts', () => {
+ expect(i18n.formatBytes(2.1, 'zh-TW')).toEqual('2 byte');
+ expect(i18n.formatBytes(7.8 * 10 ** 3, 'ar')).toEqual('8 كيلوبايت');
+ expect(i18n.formatBytes(1.5 * 10 ** 6, 'tr')).toEqual('1,5 MB');
+ expect(i18n.formatBytes(10 * 10 ** 9, 'jp')).toEqual('10 GB');
+ expect(i18n.formatBytes(2.35 * 10 ** 12, 'pr')).toEqual('2.35 TB');
+ });
+
+ it('Omits trailing zero decimal digits', () => {
+ expect(i18n.formatBytes(10 ** 12, 'en')).toEqual('1 TB');
+ });
+ }
+});
diff --git a/src/server_manager/web_app/data_formatting.ts b/src/server_manager/web_app/data_formatting.ts
new file mode 100644
index 000000000..6d96aa1de
--- /dev/null
+++ b/src/server_manager/web_app/data_formatting.ts
@@ -0,0 +1,107 @@
+/*
+ Copyright 2020 The Outline Authors
+
+ 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 '@formatjs/intl-numberformat/polyfill'
+
+// Utility functions for internationalizing numbers and units
+
+// WARNING! This assumes an ES2020 target as this will always run on browser code in
+// electron's built-in Chromium. This code shouldn't be used by anything running in Node.
+
+const TERABYTE = 10 ** 12;
+const GIGABYTE = 10 ** 9;
+const MEGABYTE = 10 ** 6;
+const KILOBYTE = 10 ** 3;
+
+const inWebApp = typeof window !== 'undefined' && typeof window.document !== 'undefined';
+interface FormatParams {
+ value: number;
+ unit: 'terabyte'|'gigabyte'|'megabyte'|'kilobyte'|'byte';
+ decimalPlaces: number;
+}
+
+function getDataFormattingParams(numBytes: number): FormatParams {
+ if (numBytes >= TERABYTE) {
+ return {value: numBytes / TERABYTE, unit: 'terabyte', decimalPlaces: 2};
+ } else if (numBytes >= GIGABYTE) {
+ return {value: numBytes / GIGABYTE, unit: 'gigabyte', decimalPlaces: 2};
+ } else if (numBytes >= MEGABYTE) {
+ return {value: numBytes / MEGABYTE, unit: 'megabyte', decimalPlaces: 1};
+ } else if (numBytes >= KILOBYTE) {
+ return {value: numBytes / KILOBYTE, unit: 'kilobyte', decimalPlaces: 0};
+ }
+ return {value: numBytes, unit: 'byte', decimalPlaces: 0};
+}
+
+function makeDataAmountFormatter(language: string, params: FormatParams) {
+ // We need to cast through `unknown` since `tsc` mistakenly omits the 'unit' field in
+ // `NumberFormatOptions`.
+ const options = {
+ style: 'unit',
+ unit: params.unit,
+ unitDisplay: 'short',
+ maximumFractionDigits: params.decimalPlaces
+ } as unknown as Intl.NumberFormatOptions;
+ return new Intl.NumberFormat(language, options);
+}
+
+interface DataAmountParts {
+ value: string;
+ unit: string;
+}
+
+/**
+ * Returns a localized amount of bytes as a separate value and unit. This is useful for styling
+ * the unit and the value differently, or if you need them in separate nodes in the layout.
+ *
+ * @param {number} numBytes An amount of data to format.
+ * @param {string} language The ISO language code for the lanugage to translate to, eg 'en'.
+ */
+export function formatBytesParts(numBytes: number, language: string): DataAmountParts {
+ if (!inWebApp) {
+ throw new Error('formatBytesParts only works in web app code. Node usage isn\'t supported.');
+ }
+ const params = getDataFormattingParams(numBytes);
+ const parts = makeDataAmountFormatter(language, params).formatToParts(params.value);
+ // Cast away the type since `tsc` mistakenly omits the possibility for a 'unit' part
+ const isUnit = (part: Intl.NumberFormatPart) => (part as {type: string}).type === 'unit';
+ const unitText = parts.find(isUnit).value;
+ return {
+ value: parts.filter((part: Intl.NumberFormatPart) => !isUnit(part))
+ .map((part: Intl.NumberFormatPart) => part.value)
+ .join('')
+ .trim(),
+ // Special case for "byte", since we'd rather be consistent with "KB", etc. "byte" is
+ // presumably used due to the example in the Unicode standard,
+ // http://unicode.org/reports/tr35/tr35-general.html#Example_Units
+ unit: unitText === 'byte' ? 'B' : unitText
+ };
+}
+
+/**
+ * Returns a string representation of a number of bytes, translated into the given language
+ *
+ * @param {Number} numBytes An amount of data to format.
+ * @param {string} language The ISO language code for the lanugage to translate to, eg 'en'.
+ * @returns {string} The formatted data amount.
+ */
+export function formatBytes(numBytes: number, language: string): string {
+ if (!inWebApp) {
+ throw new Error('formatBytes only works in web app code. Node usage isn\'t supported.');
+ }
+ const params = getDataFormattingParams(numBytes);
+ return makeDataAmountFormatter(language, params).format(params.value);
+}
diff --git a/src/server_manager/web_app/karma.conf.js b/src/server_manager/web_app/karma.conf.js
index bcdf34e3d..37ef9ab6b 100644
--- a/src/server_manager/web_app/karma.conf.js
+++ b/src/server_manager/web_app/karma.conf.js
@@ -19,15 +19,22 @@ const baseConfig = makeConfig({
defaultMode: 'development'
});
+const test_patterns = [
+ '**/*.spec.ts',
+ // We need to test data_formatting in a browser context
+ './data_formatting.spec.ts'
+];
+
+let preprocessors = {};
+for (const pattern of test_patterns) {
+ preprocessors[pattern] = ['webpack'];
+}
+
module.exports = function(config) {
config.set({
frameworks: ['jasmine'],
- files: [
- '**/*.spec.ts',
- ],
- preprocessors: {
- '**/*.spec.ts': ['webpack'],
- },
+ files: test_patterns,
+ preprocessors,
reporters: ['progress'],
colors: true,
logLevel: config.LOG_INFO,
diff --git a/src/server_manager/web_app/main.ts b/src/server_manager/web_app/main.ts
index 0cb2bb536..df46bf09a 100644
--- a/src/server_manager/web_app/main.ts
+++ b/src/server_manager/web_app/main.ts
@@ -115,6 +115,7 @@ document.addEventListener('WebComponentsReady', () => {
// NOTE: this cast is safe and allows us to leverage Polymer typings since we haven't migrated to
// Polymer 3, which adds typescript support.
const appRoot = document.getElementById('appRoot') as unknown as AppRoot;
+ appRoot.language = language.string();
const filteredLanguageDefs = Object.values(SUPPORTED_LANGUAGES);
appRoot.supportedLanguages = sortLanguageDefsByName(filteredLanguageDefs);
diff --git a/src/server_manager/web_app/ui_components/app-root.js b/src/server_manager/web_app/ui_components/app-root.js
index 56d4d0c93..0f65f1e2e 100644
--- a/src/server_manager/web_app/ui_components/app-root.js
+++ b/src/server_manager/web_app/ui_components/app-root.js
@@ -418,7 +418,7 @@ export class AppRoot extends mixinBehaviors
-
+
@@ -555,18 +555,47 @@ export class AppRoot extends mixinBehaviors
}
/**
- * Sets the language and direction for the application
- * @param {string} language
- * @param {string} direction
+ * Loads a new translation file and returns a Promise which resolves when the file is loaded or
+ * rejects when there was an error loading translations.
+ *
+ * @param {string} language The language code to load translations for, eg 'en'
*/
- setLanguage(language, direction) {
+ async loadLanguageResources(language) {
+ const localizeResourcesResponder = new Promise((resolve, reject) => {
+ // loadResources uses events and continuation instead of Promises. In order to make this
+ // function easier to use, we wrap the language-changing logic in event handlers which
+ // resolve or reject the Promise. Note that they need to clean up whichever event handler
+ // didn't fire so we don't leak it, which could cause future language changes to not work
+ // properly by triggering old event listeners.
+ let successHandler, failureHandler;
+ successHandler = () => {
+ this.removeEventListener('app-localize-resources-error', failureHandler);
+ resolve();
+ };
+ failureHandler = (event) => {
+ this.removeEventListener('app-localize-resources-loaded', successHandler);
+ reject(new Error(`Failed to load resources for language ${language}`));
+ };
+ this.addEventListener('app-localize-resources-loaded', successHandler, {once: true});
+ this.addEventListener('app-localize-resources-error', failureHandler, {once: true});
+ });
+
const messagesUrl = `./messages/${language}.json`;
this.loadResources(messagesUrl, language);
+ return localizeResourcesResponder;
+ }
+
+ /**
+ * Sets the language and direction for the application
+ * @param {string} language The ISO language code for the new language, eg 'en'
+ * @param {string} direction The direction of the language, either 'rtl' or 'ltr'
+ */
+ async setLanguage(language, direction) {
+ await this.loadLanguageResources(language);
const alignDir = direction === 'ltr' ? 'left' : 'right';
this.$.appDrawer.align = alignDir;
this.$.sideBar.align = alignDir;
-
this.language = language;
}
diff --git a/src/server_manager/web_app/ui_components/outline-server-settings.js b/src/server_manager/web_app/ui_components/outline-server-settings.js
index a46c87ab7..3dd5ab972 100644
--- a/src/server_manager/web_app/ui_components/outline-server-settings.js
+++ b/src/server_manager/web_app/ui_components/outline-server-settings.js
@@ -26,6 +26,8 @@ import './outline-validated-input.js';
import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {formatBytesParts} from '../data_formatting';
+
Polymer({
_template: html`
@@ -138,7 +140,7 @@ Polymer({
.data-limits-input paper-dropdown-menu {
border: none;
--paper-input-container: {
- width: 64px;
+ width: 72px;
}
}
paper-listbox paper-item {
@@ -175,7 +177,7 @@ Polymer({