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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 0 additions & 1 deletion src/server_manager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 12 additions & 7 deletions src/server_manager/web_app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
67 changes: 67 additions & 0 deletions src/server_manager/web_app/data_formatting.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
}
});
107 changes: 107 additions & 0 deletions src/server_manager/web_app/data_formatting.ts
Original file line number Diff line number Diff line change
@@ -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);
}
19 changes: 13 additions & 6 deletions src/server_manager/web_app/karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/server_manager/web_app/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
41 changes: 35 additions & 6 deletions src/server_manager/web_app/ui_components/app-root.js
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ export class AppRoot extends mixinBehaviors
<outline-region-picker-step id="regionPicker" localize="[[localize]]"></outline-region-picker-step>
<div id="serverView">
<template is="dom-repeat" items="{{serverList}}" as="server">
<outline-server-view id="serverView-{{_base64Encode(server.id)}}" localize="[[localize]]" hidden\$="{{!_isServerSelected(selectedServerId, server)}}"></outline-server-view>
<outline-server-view id="serverView-{{_base64Encode(server.id)}}" language="[[language]]" localize="[[localize]]" hidden\$="{{!_isServerSelected(selectedServerId, server)}}"></outline-server-view>
</template>
</div>
</iron-pages>
Expand Down Expand Up @@ -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;
}

Expand Down
Loading